diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..143f109dd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,195 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - hardening/** + +permissions: + contents: read + +jobs: + versions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python scripts/check-versions.py + + server: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ClipCascade_Server/ClipCascade_Backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + - run: sh ./mvnw test + - run: sh ./mvnw -DskipTests package + - run: docker build -t clipcascade-server:test . + # Pinned action rather than `curl .../main/install.sh | sh`: that piped an + # unpinned script from a moving branch straight into a shell with repo + # checkout on disk. + - name: Generate CycloneDX SBOM (server JAR) + uses: anchore/sbom-action@v0 + with: + path: ClipCascade_Server/ClipCascade_Backend + format: cyclonedx-json + artifact-name: sbom-server.cdx.json + output-file: sbom-server.cdx.json + - name: Upload SBOM + uses: actions/upload-artifact@v4 + with: + name: sbom-server + path: sbom-server.cdx.json + + compose: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ClipCascade_Server/docker-compose + steps: + - uses: actions/checkout@v4 + - run: CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy CC_SERVER_DB_PASSWORD="dummy-file dummy-user" docker compose -f docker-compose.yml config --quiet + - run: CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy CC_SERVER_DB_PASSWORD="dummy-file dummy-user" docker compose -f docker-compose-limitless-data-transfer.yml config --quiet + - run: CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy CC_SERVER_DB_PASSWORD="dummy-file dummy-user" ACTIVEMQ_IMAGE=apache/activemq-classic:6.1.4 ACTIVEMQ_ADMIN_LOGIN=dummy ACTIVEMQ_ADMIN_PASSWORD=dummy docker compose -f docker-compose-stomp-external-broker.yml config --quiet + - run: CLIPCASCADE_IMAGE=clipcascade:test POSTGRES_IMAGE=postgres:16 POSTGRES_PASSWORD=dummy CC_INITIAL_ADMIN_PASSWORD=dummy docker compose -f docker-compose-multi-users.yml config --quiet + # The two files an operator actually follows during migration. Previously + # unvalidated, so a typo in either only surfaced on the live server. + - run: docker compose -f docker-compose.migrate-from-latest.yml --env-file .env.migrate.example config --quiet + - run: CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy CC_SERVER_DB_PASSWORD="dummy-file dummy-user" CC_ALLOWED_ORIGINS=http://100.64.0.1:8080 CC_BIND_ADDRESS=100.64.0.1 docker compose -f docker-compose.yml -f docker-compose.tailscale.yml config --quiet + # Compose merges `ports` by appending, so an overlay that re-declares a + # port leaves the base binding live too. Assert exactly one binding, on + # the address we asked for. + - name: Tailscale overlay must publish only on the Tailscale address + run: | + rendered=$(CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy \ + CC_SERVER_DB_PASSWORD="dummy-file dummy-user" \ + CC_ALLOWED_ORIGINS=http://100.64.0.1:8080 CC_BIND_ADDRESS=100.64.0.1 \ + docker compose -f docker-compose.yml -f docker-compose.tailscale.yml config) + bindings=$(echo "$rendered" | grep -c 'target: 8080') + echo "$rendered" | grep -q 'host_ip: 100.64.0.1' \ + || { echo "expected a bind on 100.64.0.1"; exit 1; } + [ "$bindings" -eq 1 ] \ + || { echo "expected exactly 1 published port, got $bindings"; echo "$rendered"; exit 1; } + # Every shipped topology, not just the two that were checked before β€” + # three siblings kept publishing on 0.0.0.0 while this guard stayed green. + - name: No compose file may publish on every interface + run: | + export CLIPCASCADE_IMAGE=clipcascade:test CC_INITIAL_ADMIN_PASSWORD=dummy \ + CC_SERVER_DB_PASSWORD="dummy-file dummy-user" \ + ACTIVEMQ_IMAGE=apache/activemq-classic:6.1.4 ACTIVEMQ_ADMIN_LOGIN=dummy \ + ACTIVEMQ_ADMIN_PASSWORD=dummy POSTGRES_IMAGE=postgres:16 POSTGRES_PASSWORD=dummy + status=0 + for f in docker-compose.yml docker-compose-multi-users.yml \ + docker-compose-limitless-data-transfer.yml \ + docker-compose-stomp-external-broker.yml; do + rendered=$(docker compose -f "$f" config) || { echo "$f: config failed"; status=1; continue; } + if ! echo "$rendered" | grep -q 'host_ip: 127.0.0.1'; then + echo "$f: does not default to a loopback bind" + echo "$rendered" | grep -A4 'ports:' + status=1 + fi + done + exit $status + + mobile: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ClipCascade_Mobile/src + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ClipCascade_Mobile/src/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm test -- --watchAll=false + - run: npm audit --omit=dev --audit-level=high + + mobile-android: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ClipCascade_Mobile/src/android + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: ClipCascade_Mobile/src/package-lock.json + - name: Install JS deps (autolinking) + working-directory: ClipCascade_Mobile/src + run: npm ci + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + # gradle.properties is gitignored (it holds the upload-key passwords for + # release builds), so a fresh checkout has none and app/build.gradle fails + # at configuration time on `hermesEnabled`. Debug builds need no secrets. + - name: Provide gradle.properties + run: cp gradle.properties.example gradle.properties + - name: Assemble debug APK + run: | + chmod +x ./gradlew + ./gradlew assembleDebug --no-daemon + + desktop: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ClipCascade_Desktop/src + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install --upgrade pip build + - run: python -m pip install keyring pycryptodome + - run: python -m compileall . + - run: python -m unittest discover -s tests -v + - run: python -m build + - name: Dependency audit (pip) + run: | + python -m pip install pip-audit + python -m pip_audit -r requirements_linux.txt || true + + container-scan: + runs-on: ubuntu-latest + needs: server + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + # `needs: server` only orders the jobs; it does not share a workspace, so + # the jar the Dockerfile COPYs has to be built here too. + - name: Build server image + working-directory: ClipCascade_Server/ClipCascade_Backend + run: | + sh ./mvnw -q -B -DskipTests package + docker build -t clipcascade-server:test . + # 0.28.0 does not exist; the tags are v-prefixed. The job failed at + # "Set up job" on every run, so the scan never actually ran. + - name: Trivy vulnerability scan + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: clipcascade-server:test + format: table + exit-code: "0" + severity: CRITICAL,HIGH diff --git a/.gitignore b/.gitignore index d1b66fba2..9eb5cb9ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,14 @@ private/ screenshots/ .DS_Store -.vscode/ \ No newline at end of file +.vscode/ +.cursor/ +.env +*.env +!*.env.example +ClipCascade_Server/ClipCascade_Backend/database/ +ClipCascade_Server/ClipCascade_Backend/logs/ +ClipCascade_Server/docker-compose/cc_users/ +ClipCascade_Server/docker-compose/logs/ +.venv-ci/ +.venv*/ diff --git a/ClipCascade_Desktop/src/cli/tray.py b/ClipCascade_Desktop/src/cli/tray.py index a282cfbe8..4f91b71d4 100644 --- a/ClipCascade_Desktop/src/cli/tray.py +++ b/ClipCascade_Desktop/src/cli/tray.py @@ -337,11 +337,18 @@ def _on_download(self, files): CustomDialog( f"Saving files to: {target_directory}", msg_type="info" ).mainloop() - # Save each file to the chosen directory + # Save each file to the chosen directory (hard byte cap before write) + size_limit = self._download_size_limit() + total_written = 0 for filename, file_obj in files.items(): - file_path = os.path.join(target_directory, filename) - with open(file_path, "wb") as f: - f.write(file_obj.getvalue()) + data = file_obj.getvalue() + total_written += len(data) + if total_written > size_limit: + raise ValueError( + f"Download aborted: total size exceeds limit of {size_limit} bytes" + ) + file_path = self._unique_download_path(target_directory, filename) + self._write_download_file(file_path, data) logging.debug(f"Saved: {file_path}") CustomDialog("Done.", msg_type="success").mainloop() @@ -353,6 +360,33 @@ def _on_download(self, files): msg_type="error", ).mainloop() + def _download_size_limit(self) -> int: + local_limit = self.config.data.get("max_clipboard_size_local_limit_bytes") + server_limit = self.config.data.get("maxsize") + if local_limit is not None and local_limit > 0: + return int(local_limit) + if server_limit is not None and server_limit > 0: + return int(server_limit) + return MAX_SIZE + + @staticmethod + def _unique_download_path(target_directory, filename): + safe_name = os.path.basename(str(filename).replace("\\", "/")) + root, ext = os.path.splitext(safe_name) + candidate = os.path.join(target_directory, safe_name) + counter = 1 + while os.path.exists(candidate): + candidate = os.path.join(target_directory, f"{root}_{counter}{ext}") + counter += 1 + return candidate + + @staticmethod + def _write_download_file(file_path, data): + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + fd = os.open(file_path, flags, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(data) + def _on_logoff(self): try: if self.on_logoff_callback: diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py index 6a83f64b1..0bfae7efb 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py @@ -10,6 +10,8 @@ from core.constants import * from core.config import Config +Image.MAX_IMAGE_PIXELS = 25_000_000 + if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: from cli.tray import TaskbarPanel else: @@ -178,10 +180,14 @@ def base64_to_clipboard(self, base64_string: str, type_: str = "text"): if self.is_clipboard_size_within_limit(txt, type_): self.paste(txt, type_) elif type_ == "image": + if not self.is_inbound_base64_size_within_limit(base64_string): + return img = ClipboardManager.convert_base64_to_image(base64_img=base64_string) if self.is_clipboard_size_within_limit(img, type_): self.paste(img, type_) elif type_ == "files": + if not self.is_inbound_files_size_within_limit(base64_string): + return file_objects = ClipboardManager.convert_base64_to_files( base64_json=base64_string ) @@ -190,6 +196,29 @@ def base64_to_clipboard(self, base64_string: str, type_: str = "text"): except Exception as e: logging.error(f"Failed to convert base64 data to clipboard: {e}") + def _incoming_size_limit(self) -> int: + local_limit = self.config.data.get("max_clipboard_size_local_limit_bytes") + server_limit = self.config.data.get("maxsize") + if local_limit is not None and local_limit > 0: + return int(local_limit) + if server_limit is not None and server_limit > 0: + return int(server_limit) + return MAX_SIZE + + def is_inbound_base64_size_within_limit(self, base64_string: str) -> bool: + return ClipboardManager.calculate_base64_decoded_length(base64_string) <= self._incoming_size_limit() + + def is_inbound_files_size_within_limit(self, base64_json: str) -> bool: + try: + total_size = 0 + for encoded_content in json.loads(base64_json).values(): + total_size += ClipboardManager.calculate_base64_decoded_length(encoded_content) + if total_size > self._incoming_size_limit(): + return False + return True + except Exception: + return False + @staticmethod def execute_command(*args, input_data): """ @@ -357,6 +386,30 @@ def convert_files_to_base64(file_paths: tuple | list) -> str: return json.dumps(base64_encoded_files) + @staticmethod + def calculate_base64_decoded_length(base64_str: str) -> int: + if not isinstance(base64_str, str): + return 0 + padding = len(base64_str) - len(base64_str.rstrip("=")) + return max(0, (len(base64_str) * 3) // 4 - padding) + + @staticmethod + def sanitize_received_filename(file_name: str, used_names: set) -> str: + safe_name = os.path.basename(str(file_name).replace("\\", "/")).strip() + if not safe_name or safe_name in {".", ".."}: + raise ValueError("Invalid received filename") + if any(ord(char) < 32 for char in safe_name): + raise ValueError("Received filename contains control characters") + + root, ext = os.path.splitext(safe_name) + candidate = safe_name + counter = 1 + while candidate in used_names: + candidate = f"{root}_{counter}{ext}" + counter += 1 + used_names.add(candidate) + return candidate + @staticmethod def convert_base64_to_files(base64_json: dict) -> dict: """ @@ -371,9 +424,13 @@ def convert_base64_to_files(base64_json: dict) -> dict: file_objects = {} try: base64_data = json.loads(base64_json) + used_names = set() for file_name, encoded_content in base64_data.items(): + safe_name = ClipboardManager.sanitize_received_filename( + file_name, used_names + ) decoded_content = base64.b64decode(encoded_content) - file_objects[file_name] = io.BytesIO(decoded_content) + file_objects[safe_name] = io.BytesIO(decoded_content) except Exception as e: raise IOError(f"Error processing base64 JSON. {e}") from e diff --git a/ClipCascade_Desktop/src/core/application.py b/ClipCascade_Desktop/src/core/application.py index 0424ca0d6..d451527ab 100644 --- a/ClipCascade_Desktop/src/core/application.py +++ b/ClipCascade_Desktop/src/core/application.py @@ -123,10 +123,23 @@ def create_lock_file(self, path=None): def authenticate_and_connect(self): # Attempt to connect with existing cookie if self.config.data.get("cookie"): - ws_conn_successful, msg = self._get_ws_manager().connect() - if ws_conn_successful: - self._get_ws_manager().is_login_phase = False - return + try: + Config.validate_server_url(self.config.data["server_url"]) + expected_endpoint = ( + WEBSOCKET_ENDPOINT_P2P + if self.config.data["server_mode"] == "P2P" + else WEBSOCKET_ENDPOINT + ) + self.config.data["websocket_url"] = Config.convert_to_websocket_url( + self.config.data["server_url"], expected_endpoint + ) + ws_conn_successful, msg = self._get_ws_manager().connect() + if ws_conn_successful: + self._get_ws_manager().is_login_phase = False + return + except Exception as e: + logging.warning(f"Saved session rejected: {e}") + self.config.data["cookie"] = None # enable login form used_saved_credentials = False @@ -170,6 +183,8 @@ def authenticate_and_connect(self): if self.config.data["server_mode"] == "P2P": self.config.data["stun_url"] = self.request_manager.get_stun_url() self.config.data["maxsize"] = -1 + if self.config.data["max_clipboard_size_local_limit_bytes"] is None: + self.config.data["max_clipboard_size_local_limit_bytes"] = MAX_SIZE self.config.data["websocket_url"] = Config.convert_to_websocket_url( self.config.data["server_url"], WEBSOCKET_ENDPOINT_P2P ) @@ -254,11 +269,8 @@ def logoff_and_exit(self): try: self._get_ws_manager().disconnect() self.request_manager.logout() - self.config.data["hashed_password"] = None - self.config.data["cookie"] = None self.config.data["maxsize"] = None - self.config.data["password"] = "" - self.config.data["csrf_token"] = "" + self.config.clear_secrets() self.config.save() except Exception as e: raise Exception(f"Error during logging off: {e}") diff --git a/ClipCascade_Desktop/src/core/config.py b/ClipCascade_Desktop/src/core/config.py index b91a1e200..345861be8 100644 --- a/ClipCascade_Desktop/src/core/config.py +++ b/ClipCascade_Desktop/src/core/config.py @@ -1,13 +1,20 @@ import base64 +import ipaddress import json +import logging import os import re +from urllib.parse import urlparse from core.constants import * class Config: - def __init__(self, file_name=DATA_FILE_NAME): + SECRET_FIELDS = frozenset({"hashed_password", "cookie", "csrf_token", "password"}) + KEYRING_SERVICE = "ClipCascade" + + def __init__(self, file_name=DATA_FILE_NAME, keyring_backend=None): self.file_name = file_name + self._keyring_backend = keyring_backend self.data = { "cipher_enabled": True, "server_url": "http://localhost:8080", @@ -22,44 +29,90 @@ def __init__(self, file_name=DATA_FILE_NAME): "notification": True, "save_password": False, "password": "", - "max_clipboard_size_local_limit_bytes": None, - "enable_image_sharing": True, - "enable_file_sharing": True, + "max_clipboard_size_local_limit_bytes": MAX_SIZE, + "enable_image_sharing": False, + "enable_file_sharing": False, "default_file_download_location": "", "server_mode": "P2S", "stun_url": "", "ssl_ca_bundle": "", + "device_id": "", + "send_counter": 0, + # Accept pre-3.2.0 (v1 / un-bound) messages. Off by default: v1 has + # no counter, timestamp, or metadata binding, so allowing it lets + # anyone who can inject into the transport strip the envelope and + # replay old clipboard content. Enable only while some device in + # the fleet still runs < 3.2.0. + "allow_legacy_v1": False, } def save(self): """ - Save data to file + Persist non-secret settings to DATA file and secrets to the OS keyring. + + Only secrets that actually reached the keyring are stripped from the + DATA file. Stripping unconditionally made the migration destructive: + on a machine with no keyring backend (a headless Linux box, typically) + the secrets went nowhere and were deleted from disk in the same pass, + silently ending a working login with nothing but a warning in a log. """ try: - temp = self.data.copy() - if self.data.get("cipher_enabled") and self.data.get("hashed_password"): - temp["hashed_password"] = base64.b64encode( - temp["hashed_password"] - ).decode("utf-8") + persisted = self._save_secrets() + temp = { + key: value + for key, value in self.data.items() + if key not in persisted + } with open(self.file_name, "w") as f: json.dump(temp, f, indent=4) + try: + os.chmod(self.file_name, 0o600) + except Exception: + pass except Exception as e: logging.error(f"Failed to save data: {e}") def load(self): """ - Load data from file + Load non-secret settings from DATA file and secrets from the OS keyring. + + Legacy secrets still present in the DATA file are migrated into the keyring + and scrubbed from disk on the next successful save. """ if os.path.isfile(self.file_name): try: with open(self.file_name, "r") as f: file_data = json.load(f) - self.data.update(file_data) - # Decode hashed_password if present - if self.data.get("hashed_password"): - self.data["hashed_password"] = base64.b64decode( - self.data["hashed_password"] + + legacy_secrets = { + key: file_data[key] for key in self.SECRET_FIELDS if key in file_data + } + + for key, value in file_data.items(): + if key not in self.SECRET_FIELDS: + self.data[key] = value + + # Seed memory from legacy on-disk secrets (pre-keyring installs). + if legacy_secrets.get("hashed_password"): + hashed = legacy_secrets["hashed_password"] + if isinstance(hashed, str): + self.data["hashed_password"] = base64.b64decode(hashed) + elif isinstance(hashed, bytes): + self.data["hashed_password"] = hashed + for key in ("cookie", "csrf_token", "password"): + if key in legacy_secrets: + self.data[key] = legacy_secrets[key] + + # Keyring values take precedence over legacy file secrets. + self._load_secrets() + + if legacy_secrets: + # Migrate: write secrets to keyring and rewrite DATA without them. + self.save() + logging.info( + "Migrated desktop secrets from DATA file into OS keyring" ) + return True except Exception as e: logging.error(f"Failed to load data: {e}") @@ -68,19 +121,238 @@ def load(self): ) return False + def clear_secrets(self): + """Clear in-memory secrets and remove them from the OS keyring.""" + self.data["hashed_password"] = None + self.data["cookie"] = None + self.data["csrf_token"] = "" + self.data["password"] = "" + self._delete_all_secrets() + + def _secret_account(self, key: str) -> str: + return f"{os.path.abspath(self.file_name)}:{key}" + + def _keyring(self): + if self._keyring_backend is not None: + return self._keyring_backend + try: + import keyring + + return keyring + except Exception: + return None + + def _is_empty_secret(self, key: str, value) -> bool: + if value is None: + return True + if key == "password" and value == "": + return True + if key == "csrf_token" and value == "": + return True + if key == "cookie" and value in (None, "", {}): + return True + if key == "hashed_password" and value in (None, "", b""): + return True + return False + + def _serialize_secret(self, key: str, value) -> str: + if key == "hashed_password": + if isinstance(value, bytes): + return base64.b64encode(value).decode("utf-8") + if isinstance(value, str): + # Already base64-encoded AES key material. + return value + raise TypeError(f"hashed_password must be bytes or str, got {type(value)}") + return json.dumps(value) + + def _deserialize_secret(self, key: str, value: str): + if key == "hashed_password": + return base64.b64decode(value) + return json.loads(value) + + def _save_secrets(self) -> set: + """ + Write secrets to the OS keyring. + + Returns the keys that are safe to remove from the DATA file: those + genuinely stored in the keyring, plus those that are empty and so have + nothing to lose. A key missing from this set means the secret is NOT in + the keyring, and the caller must leave whatever is on disk alone rather + than deleting the only copy. + """ + keyring = self._keyring() + if keyring is None: + has_secrets = any( + not self._is_empty_secret(key, self.data.get(key)) + for key in self.SECRET_FIELDS + ) + if has_secrets: + logging.error( + "OS keyring unavailable: secrets cannot be stored securely. " + "Install a keyring backend (for example gnome-keyring or " + "kwallet); until then existing secrets are left as they are " + "on disk rather than being discarded." + ) + # Nothing was persisted, so nothing may be stripped. + return set() + return set(self.SECRET_FIELDS) + + persisted = set() + for key in self.SECRET_FIELDS: + value = self.data.get(key) + account = self._secret_account(key) + if self._is_empty_secret(key, value): + self._delete_secret(keyring, account) + persisted.add(key) + continue + try: + keyring.set_password( + self.KEYRING_SERVICE, account, self._serialize_secret(key, value) + ) + persisted.add(key) + except Exception as e: + logging.error( + f"Could not save {key} to the OS keyring: {e}. Leaving the " + f"existing on-disk value in place rather than losing it." + ) + return persisted + + def _load_secrets(self): + keyring = self._keyring() + if keyring is None: + return + + for key in self.SECRET_FIELDS: + account = self._secret_account(key) + try: + value = keyring.get_password(self.KEYRING_SERVICE, account) + except Exception as e: + logging.warning(f"Could not load {key} from OS keyring: {e}") + continue + if value is None: + continue + try: + self.data[key] = self._deserialize_secret(key, value) + except Exception as e: + logging.warning(f"Could not decode {key} from OS keyring: {e}") + + def _delete_secret(self, keyring, account: str): + try: + keyring.delete_password(self.KEYRING_SERVICE, account) + except Exception: + # Entry may not exist; ignore. + pass + + def _delete_all_secrets(self): + keyring = self._keyring() + if keyring is None: + return + for key in self.SECRET_FIELDS: + self._delete_secret(keyring, self._secret_account(key)) + + @staticmethod + def _is_private_or_mesh_host(host: str) -> bool: + """ + True for loopback, RFC1918 LAN, link-local, and Tailscale CGNAT / MagicDNS. + + Tailscale uses 100.64.0.0/10 (CGNAT) and *.ts.net MagicDNS names. + HTTP over those is common for self-host; encryption is at the Tailscale wire. + """ + if not host: + return False + host = host.lower().rstrip(".") + if host == "localhost" or host.endswith(".localhost"): + return True + # Tailscale MagicDNS. Suffix only: "ts.net" itself is a real, + # internet-routable domain, so treating the bare apex as mesh would + # permit cleartext to a public host. Mobile networkPolicy.js has always + # matched only the suffix; this keeps the two in step. + if host.endswith(".ts.net"): + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + # Python lists ::ffff:0:0/96 among the IPv6 private networks, so an + # IPv4-mapped *public* address such as ::ffff:8.8.8.8 reports + # is_private == True. Judge the embedded IPv4 address instead, or + # http://[::ffff:8.8.8.8] would be accepted as a private host. + mapped = getattr(ip, "ipv4_mapped", None) + if mapped is not None: + ip = mapped + if ip.is_loopback or ip.is_link_local or ip.is_private: + return True + # Tailscale CGNAT (also covered by is_private in Python 3 for 100.64/10? + # CPython treats 100.64/10 as is_private=True since 3.8. Still be explicit.) + if isinstance(ip, ipaddress.IPv4Address): + if ip in ipaddress.ip_network("100.64.0.0/10"): + return True + return False + @staticmethod - def convert_to_websocket_url(input_url: str, endpoint: str = None) -> str: + def _allows_insecure_http(input_url: str) -> bool: + parsed = urlparse(input_url) + if parsed.scheme.lower() != "http": + return True + + host = (parsed.hostname or "").lower() + if os.environ.get("CLIPCASCADE_ALLOW_INSECURE_HTTP", "").lower() == "true": + return True + # Explicit opt-in for Tailscale/LAN HTTP (default: allow private/mesh hosts). + allow_private = os.environ.get( + "CLIPCASCADE_ALLOW_PRIVATE_HTTP", "true" + ).lower() + if allow_private in {"0", "false", "no"}: + # Strict mode: only loopback unless ALLOW_INSECURE_HTTP. + try: + ip = ipaddress.ip_address(host) + return ip.is_loopback + except ValueError: + return host == "localhost" + + return Config._is_private_or_mesh_host(host) + + @staticmethod + def validate_server_url(input_url: str): if not input_url or not isinstance(input_url, str): raise ValueError("Invalid URL provided") - # Trim whitespace, remove trailing slashes, and convert to lowercase - input_url = re.sub(r"/+$", "", input_url.strip()).lower() + # Normalise once and reuse. Passing the raw string on to the HTTP check + # would re-parse it differently: urlparse(" http://evil.com") yields an + # empty scheme, so the insecure-HTTP guard would not fire at all. + normalized = re.sub(r"/+$", "", input_url.strip()) + parsed = urlparse(normalized) + if not parsed.hostname: + raise ValueError("Server URL must include a hostname") + if parsed.username or parsed.password: + raise ValueError("Server URL must not include embedded credentials") + if parsed.query or parsed.fragment: + raise ValueError("Server URL must not include query or fragment components") + if parsed.path not in {"", "/"}: + raise ValueError("Server URL must not include a path") + if parsed.scheme.lower() not in {"http", "https"}: + raise ValueError(f"Unsupported protocol in URL: {input_url}") + if parsed.scheme.lower() == "http" and not Config._allows_insecure_http( + normalized + ): + raise ValueError( + "Refusing insecure HTTP for non-private server. " + "Use HTTPS, a Tailscale/LAN address (100.x / *.ts.net / RFC1918), " + "or set CLIPCASCADE_ALLOW_INSECURE_HTTP=true." + ) + + @staticmethod + def convert_to_websocket_url(input_url: str, endpoint: str = None) -> str: + # Trim whitespace and remove trailing slashes + input_url = re.sub(r"/+$", "", input_url.strip()) + Config.validate_server_url(input_url) + parsed = urlparse(input_url) # Determine protocol and convert - if input_url.startswith("https://"): - ws_url = input_url.replace("https://", "wss://", 1) - elif input_url.startswith("http://"): - ws_url = input_url.replace("http://", "ws://", 1) + if parsed.scheme.lower() == "https": + ws_url = parsed._replace(scheme="wss").geturl() + elif parsed.scheme.lower() == "http": + ws_url = parsed._replace(scheme="ws").geturl() else: raise ValueError(f"Unsupported protocol in URL: {input_url}") diff --git a/ClipCascade_Desktop/src/gui/login.py b/ClipCascade_Desktop/src/gui/login.py index 65b031019..c73add12c 100644 --- a/ClipCascade_Desktop/src/gui/login.py +++ b/ClipCascade_Desktop/src/gui/login.py @@ -151,9 +151,12 @@ def __init__( [server_label, self.server_url_entry], "Address of your ClipCascade server.\n\n" "Examples:\n" - "- Local server: http://localhost:8080\n" - "- LAN server: http://192.168.1.50:8080\n" - "- Reverse proxy/domain: https://clipcascade.example.com\n\n" + "- Local: http://localhost:8080\n" + "- LAN: http://192.168.1.50:8080\n" + "- Tailscale IP: http://100.x.y.z:8080\n" + "- Tailscale MagicDNS: http://clipcascade.tail-xxxx.ts.net:8080\n" + "- Public: https://clipcascade.example.com\n\n" + "HTTP is allowed for localhost, LAN, and Tailscale; use HTTPS on the public internet.\n" "Include protocol (http/https). Do not add /login or /clipsocket.", ) diff --git a/ClipCascade_Desktop/src/gui/tray.py b/ClipCascade_Desktop/src/gui/tray.py index d16a25a1f..40552235e 100644 --- a/ClipCascade_Desktop/src/gui/tray.py +++ b/ClipCascade_Desktop/src/gui/tray.py @@ -366,11 +366,18 @@ def _on_download(self, icon, item, files): timeout=5000, ).mainloop() - # Save each file to the chosen directory + # Save each file to the chosen directory (hard byte cap before write) + size_limit = self._download_size_limit() + total_written = 0 for filename, file_obj in files.items(): - file_path = os.path.join(target_directory, filename) - with open(file_path, "wb") as f: - f.write(file_obj.getvalue()) + data = file_obj.getvalue() + total_written += len(data) + if total_written > size_limit: + raise ValueError( + f"Download aborted: total size exceeds limit of {size_limit} bytes" + ) + file_path = self._unique_download_path(target_directory, filename) + self._write_download_file(file_path, data) logging.debug(f"Saved: {file_path}") except Exception as e: @@ -381,6 +388,33 @@ def _on_download(self, icon, item, files): msg_type="error", ).mainloop() + def _download_size_limit(self) -> int: + local_limit = self.config.data.get("max_clipboard_size_local_limit_bytes") + server_limit = self.config.data.get("maxsize") + if local_limit is not None and local_limit > 0: + return int(local_limit) + if server_limit is not None and server_limit > 0: + return int(server_limit) + return MAX_SIZE + + @staticmethod + def _unique_download_path(target_directory, filename): + safe_name = os.path.basename(str(filename).replace("\\", "/")) + root, ext = os.path.splitext(safe_name) + candidate = os.path.join(target_directory, safe_name) + counter = 1 + while os.path.exists(candidate): + candidate = os.path.join(target_directory, f"{root}_{counter}{ext}") + counter += 1 + return candidate + + @staticmethod + def _write_download_file(file_path, data): + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + fd = os.open(file_path, flags, 0o600) + with os.fdopen(fd, "wb") as f: + f.write(data) + def _on_logoff(self, icon, item): try: if self.on_logoff_callback: diff --git a/ClipCascade_Desktop/src/p2p/p2p_manager.py b/ClipCascade_Desktop/src/p2p/p2p_manager.py index da99d71e6..8a0575d4c 100644 --- a/ClipCascade_Desktop/src/p2p/p2p_manager.py +++ b/ClipCascade_Desktop/src/p2p/p2p_manager.py @@ -14,6 +14,21 @@ from utils.notification_manager import NotificationManager from utils.request_manager import RequestManager from utils.ssl_helper import websocket_sslopt_for_config +from utils.protocol_v2 import ( + ReplayCache, + decrypt_legacy_blob, + ensure_device_id, + wrap_outbound, + unwrap_inbound, +) +from utils.stream_transfer import fragment_utf8_string +from utils.device_trust import ( + load_or_create_identity, + sign_signaling, + verify_signaling, + PeerTrustStore, + trust_store_path_for_data_file, +) from core.constants import * from aiortc import ( RTCPeerConnection, @@ -30,11 +45,15 @@ from gui.tray import TaskbarPanel class P2PManager(WSInterface): + MAX_RECEIVING_FRAGMENTS = 4096 + MAX_FRAGMENT_ID_LENGTH = 64 + def __init__(self, config: Config, is_login_phase=True): self.config = config self.clipboard_manager = ClipboardManager(self.config) self.cipher_manager = CipherManager(self.config) self.notification_manager = NotificationManager(self.config) + self.replay_cache = ReplayCache() self.sys_tray: TaskbarPanel = None self.first_conn_lost = True self.is_login_phase = is_login_phase @@ -50,6 +69,15 @@ def __init__(self, config: Config, is_login_phase=True): self.receiving_fragments: dict = {} # Mapping: fragmentid:str -> fragment:list[str] self.sending_fragment_stats: str = None self.receiving_fragment_stats: str = None + self._stream_received_bytes: dict = {} # stream_id -> assembled byte count + + # Device identity + trust (signed signaling) + self._identity = None + self._trust_store = PeerTrustStore( + trust_store_path_for_data_file(self.config.file_name) + ) + self._peer_id_to_device: dict[str, str] = {} + self._pending_public_keys: dict[str, str] = {} # device_id -> public_pem # p2p variables self.my_peer_id: str = None # Own peer id assigned by the server @@ -235,6 +263,96 @@ def manual_reconnect(self): def _on_ws_message(self, ws, message): self.schedule_task(self._on_ws_message_async(message)) + def _ensure_identity(self): + if self._identity is not None: + return self._identity + ensure_device_id(self.config.data) + keyring = self.config._keyring() + account = f"{self.config._secret_account('device_identity')}" + self._identity = load_or_create_identity( + keyring, Config.KEYRING_SERVICE, account + ) + # Align config device_id with signing identity when possible. + if self._identity.device_id: + self.config.data["device_id"] = self._identity.device_id + return self._identity + + def _announce_device(self): + identity = self._ensure_identity() + msg = { + "type": "DEVICE_ANNOUNCE", + "peerId": self.my_peer_id, + "publicKey": identity.public_pem, + "fingerprint": identity.fingerprint, + } + self.ws_send(sign_signaling(identity, msg)) + + def _is_signed_peer_allowed(self, data: dict) -> bool: + """Verify signature and trust for signaling messages from peers.""" + msg_type = data.get("type") + if msg_type in {"ASSIGNED_ID", "PEER_LIST"}: + return True + + device_id = data.get("deviceId") + + if msg_type == "DEVICE_ANNOUNCE": + if not device_id or not data.get("publicKey"): + logging.warning("Rejecting DEVICE_ANNOUNCE without identity") + return False + announced_pem = data["publicKey"] + if self._trust_store.is_trusted(device_id): + stored = self._trust_store.get_public_pem(device_id) + if stored != announced_pem: + logging.warning( + "Rejecting DEVICE_ANNOUNCE key change for trusted device %s", + device_id, + ) + return False + if not verify_signaling(data, stored): + logging.warning("Rejecting DEVICE_ANNOUNCE with bad signature") + return False + else: + if not verify_signaling(data, announced_pem): + logging.warning("Rejecting DEVICE_ANNOUNCE with bad signature") + return False + # TOFU: first valid announce is trusted (same logged-in account room). + self._trust_store.trust(device_id, announced_pem, label="tofu") + logging.info( + "Trusted new P2P device %s (fp=%s)", + device_id, + data.get("fingerprint"), + ) + self._pending_public_keys[device_id] = announced_pem + peer_id = data.get("peerId") or data.get("fromPeerId") + if peer_id: + self._peer_id_to_device[peer_id] = device_id + return True + + # OFFER/ANSWER/ICE/PAIR_*: pin verification to trusted/pending store PEM only. + # Never trust publicKey from the message body (prevents key-injection spoofing). + public_pem = None + if device_id: + public_pem = self._trust_store.get_public_pem(device_id) or self._pending_public_keys.get( + device_id + ) + if not device_id or not public_pem: + logging.warning( + "Rejecting unsigned/untrusted signaling message type=%s", msg_type + ) + return False + if not verify_signaling(data, public_pem): + logging.warning( + "Rejecting signaling with invalid signature type=%s", msg_type + ) + return False + if not self._trust_store.is_trusted(device_id): + logging.warning("Rejecting signaling from untrusted device %s", device_id) + return False + from_peer = data.get("fromPeerId") + if from_peer: + self._peer_id_to_device[from_peer] = device_id + return True + async def _on_ws_message_async(self, message): try: logging.debug("\n<<< " + str(message)) @@ -245,20 +363,48 @@ async def _on_ws_message_async(self, message): if self.my_peer_id is not None and self.my_peer_id != data["peerId"]: await self._cleanup_peer_connections() self.my_peer_id = data["peerId"] + self._announce_device() if self._pending_peer_list is not None: pending = self._pending_peer_list self._pending_peer_list = None await self._handle_peer_list(pending) elif msg_type == "PEER_LIST": await self._handle_peer_list(data["peers"]) - elif msg_type == "OFFER": - await self._handle_offer(data["fromPeerId"], data["offer"]) - - elif msg_type == "ANSWER": - await self._handle_answer(data["fromPeerId"], data["answer"]) - - elif msg_type == "ICE_CANDIDATE": - await self._handle_ice_candidate(data["fromPeerId"], data["candidate"]) + elif msg_type in { + "OFFER", + "ANSWER", + "ICE_CANDIDATE", + "DEVICE_ANNOUNCE", + "PAIR_REQUEST", + "PAIR_ACCEPT", + }: + if not self._is_signed_peer_allowed(data): + return + if msg_type == "DEVICE_ANNOUNCE": + return + if msg_type == "OFFER": + await self._handle_offer(data["fromPeerId"], data["offer"]) + elif msg_type == "ANSWER": + await self._handle_answer(data["fromPeerId"], data["answer"]) + elif msg_type == "ICE_CANDIDATE": + await self._handle_ice_candidate( + data["fromPeerId"], data["candidate"] + ) + elif msg_type == "PAIR_REQUEST": + # Auto-accept pair requests that already verified above. + identity = self._ensure_identity() + accept = { + "type": "PAIR_ACCEPT", + "toPeerId": data.get("fromPeerId"), + "fromPeerId": self.my_peer_id, + "publicKey": identity.public_pem, + } + self.ws_send(sign_signaling(identity, accept)) + elif msg_type == "PAIR_ACCEPT": + device_id = data.get("deviceId") + pem = data.get("publicKey") + if device_id and pem: + self._trust_store.trust(device_id, pem, label="paired") except Exception as e: logging.error(f"Failed to handle websocket message: {e}") @@ -312,6 +458,20 @@ def ws_send(self, data: dict): if self.ws_client is None or not self.is_connected: return + # Sign peer-originated signaling (not server-only types). + if data.get("type") in { + "OFFER", + "ANSWER", + "ICE_CANDIDATE", + "DEVICE_ANNOUNCE", + "PAIR_REQUEST", + "PAIR_ACCEPT", + } and "sig" not in data: + identity = self._ensure_identity() + if data.get("type") == "DEVICE_ANNOUNCE" and "publicKey" not in data: + data["publicKey"] = identity.public_pem + data = sign_signaling(identity, data) + message = json.dumps(data) logging.debug("\n>>> " + message) self.ws_client.send(message) @@ -720,20 +880,34 @@ async def _send(self, payload: str, payload_type: str = "text"): self.reset_sending_fragment_id() self.reset_receiving_fragments() + max_size = self._incoming_size_limit() raw_payload_size_in_bytes = len(payload.encode("utf-8")) - - if self.config.data["cipher_enabled"]: - payload = CipherManager.encode_to_json_string( - **self.cipher_manager.encrypt(payload) + if raw_payload_size_in_bytes > max_size: + logging.warning( + "Outbound payload %s bytes exceeds limit %s", + raw_payload_size_in_bytes, + max_size, ) + return - fragments = P2PManager.fragment_string(payload) + envelope = wrap_outbound( + payload=payload, + payload_type=payload_type, + config_data=self.config.data, + cipher_manager=self.cipher_manager, + cipher_enabled=bool(self.config.data["cipher_enabled"]), + ) + self.config.save() + wire = json.dumps(envelope) + fragments = fragment_utf8_string(wire, FRAGMENT_SIZE) metadata = { "id": str(uuid.uuid4()), + "stream": True, "isFragmented": len(fragments) > 1, "index": 0, "totalFragments": len(fragments), "combinedRawPayloadSizeInBytes": raw_payload_size_in_bytes, + "wireSizeInBytes": len(wire.encode("utf-8")), } self.sending_fragment_id = metadata["id"] @@ -745,7 +919,8 @@ async def _send(self, payload: str, payload_type: str = "text"): { "payload": fragment, "type": payload_type, - "metadata": metadata, + "metadata": dict(metadata), + "v": envelope.get("v", 2), } ) metadata["index"] += 1 @@ -765,9 +940,40 @@ async def _send(self, payload: str, payload_type: str = "text"): except Exception as e: logging.error(f"Failed to send data: {e}") - def reset_receiving_fragments(self): - self.receiving_fragments = {} - self.receiving_fragment_stats = None + def _incoming_size_limit(self) -> int: + local_limit = self.config.data.get("max_clipboard_size_local_limit_bytes") + if local_limit is not None and local_limit > 0: + return int(local_limit) + return MAX_SIZE + + def _is_valid_fragment_metadata(self, metadata: dict, payload: str) -> bool: + if not isinstance(metadata, dict): + return False + fragment_id = metadata.get("id") + is_fragmented = metadata.get("isFragmented") + index = metadata.get("index") + total_fragments = metadata.get("totalFragments") + raw_size = metadata.get("combinedRawPayloadSizeInBytes", 0) + if not isinstance(fragment_id, str) or len(fragment_id) > self.MAX_FRAGMENT_ID_LENGTH: + return False + try: + uuid.UUID(fragment_id) + except ValueError: + return False + if not isinstance(is_fragmented, bool): + return False + if not isinstance(index, int) or not isinstance(total_fragments, int): + return False + if total_fragments < 1 or total_fragments > self.MAX_RECEIVING_FRAGMENTS: + return False + if index < 0 or index >= total_fragments: + return False + if not isinstance(raw_size, int) or raw_size < 0: + return False + local_limit = self.config.data.get("max_clipboard_size_local_limit_bytes") + if local_limit is not None and local_limit > 0 and raw_size > local_limit: + return False + return isinstance(payload, str) and len(payload.encode("utf-8")) <= FRAGMENT_SIZE * 2 def _receive(self, frame: any) -> str: try: @@ -778,20 +984,44 @@ def _receive(self, frame: any) -> str: payload = body["payload"] payload_type = body.get("type", "text") metadata = body.get("metadata") + if metadata is not None and not self._is_valid_fragment_metadata(metadata, payload): + self.reset_receiving_fragments() + logging.warning("Rejected invalid P2P fragment metadata") + return # Check if the payload exceeds the maximum size: first layer protection + limit = self._incoming_size_limit() if ( metadata is not None - and self.config.data["max_clipboard_size_local_limit_bytes"] is not None - and metadata["combinedRawPayloadSizeInBytes"] - > self.config.data["max_clipboard_size_local_limit_bytes"] + and metadata.get("combinedRawPayloadSizeInBytes", 0) > limit ): self.reset_receiving_fragments() logging.debug( - f"Payload size limit exceeded: {metadata['combinedRawPayloadSizeInBytes']} bytes exceeds {self.config.data['max_clipboard_size_local_limit_bytes']} bytes" + f"Payload size limit exceeded: {metadata['combinedRawPayloadSizeInBytes']} bytes exceeds {limit} bytes" ) return + # Track assembled wire size for stream caps + if metadata is not None: + stream_id = metadata.get("id") + chunk_bytes = len(payload.encode("utf-8")) if isinstance(payload, str) else 0 + prev = self._stream_received_bytes.get(stream_id, 0) + new_total = prev + chunk_bytes + # The declared wire size is peer-supplied, so it can only ever + # lower the cap, never raise it. Trusting it outright let a + # sender set the very limit that was meant to bound it. + declared = metadata.get("wireSizeInBytes") + hard_limit = limit * 4 + if isinstance(declared, int) and not isinstance(declared, bool) and declared > 0: + wire_limit = min(declared, hard_limit) + else: + wire_limit = hard_limit + if new_total > max(wire_limit, limit): + self.reset_receiving_fragments() + logging.warning("Stream wire size cap exceeded for %s", stream_id) + return + self._stream_received_bytes[stream_id] = new_total + # Fragmented message handling if metadata is not None and metadata["isFragmented"]: self.receiving_fragment_stats = ( @@ -802,6 +1032,7 @@ def _receive(self, frame: any) -> str: if metadata["index"] == metadata["totalFragments"] - 1: if all(s != "" for s in self.receiving_fragments[metadata["id"]]): payload = "".join(self.receiving_fragments[metadata["id"]]) + self._stream_received_bytes.pop(metadata["id"], None) else: self.reset_receiving_fragments() logging.error( @@ -816,10 +1047,37 @@ def _receive(self, frame: any) -> str: self.receiving_fragments[metadata["id"]][metadata["index"]] = payload return - if self.config.data["cipher_enabled"]: - payload = self.cipher_manager.decrypt( - **CipherManager.decode_from_json_string(payload) - ) + # Reassembled payload is either a v2 envelope JSON or legacy ciphertext/plain. + allow_legacy = bool(self.config.data.get("allow_legacy_v1")) + try: + if isinstance(payload, str) and payload.startswith("{"): + envelope = json.loads(payload) + if isinstance(envelope, dict) and "payload" in envelope: + payload, payload_type = unwrap_inbound( + envelope, + cipher_manager=self.cipher_manager, + cipher_enabled=bool(self.config.data["cipher_enabled"]), + replay_cache=self.replay_cache, + local_device_id=self.config.data.get("device_id") or None, + allow_legacy_v1=allow_legacy, + ) + elif self.config.data["cipher_enabled"]: + # Bare {nonce,ciphertext,tag} from a pre-3.2.0 peer: no + # envelope, so none of the v2 checks above apply. + payload = decrypt_legacy_blob( + payload, + cipher_manager=self.cipher_manager, + allow_legacy_v1=allow_legacy, + ) + elif self.config.data["cipher_enabled"]: + payload = decrypt_legacy_blob( + payload, + cipher_manager=self.cipher_manager, + allow_legacy_v1=allow_legacy, + ) + except ValueError as e: + logging.warning(f"Rejected inbound P2P message: {e}") + return if self.clipboard_manager.has_clipboard_changed(payload): self.reset_receiving_fragments() @@ -833,23 +1091,13 @@ def _receive(self, frame: any) -> str: @staticmethod def fragment_string(s: str, fragment_size: int = FRAGMENT_SIZE) -> list[str]: - """ - Splits a string into a list of fragments, each with a maximum size of `fragment_size` bytes. + """Byte-safe UTF-8 fragmentation (no mid-codepoint corruption).""" + return fragment_utf8_string(s, fragment_size) - Args: - s (str): The string to fragment. - fragment_size (int): The maximum size of each fragment in bytes. - - Returns: - list[str]: A list of string fragments. - """ - # Encode the string to bytes to accurately split by byte size - s_bytes = s.encode("utf-8") - fragments = [ - s_bytes[i : i + fragment_size].decode("utf-8", errors="ignore") - for i in range(0, len(s_bytes), fragment_size) - ] - return fragments + def reset_receiving_fragments(self): + self.receiving_fragments = {} + self.receiving_fragment_stats = None + self._stream_received_bytes = {} @staticmethod def parse_ice_candidate_line(candidate_line: str) -> dict: diff --git a/ClipCascade_Desktop/src/pyproject.toml b/ClipCascade_Desktop/src/pyproject.toml index 28281f93a..7fe79e696 100644 --- a/ClipCascade_Desktop/src/pyproject.toml +++ b/ClipCascade_Desktop/src/pyproject.toml @@ -8,7 +8,7 @@ version = "3.2.0" description = "ClipCascade is a lightweight utility that automatically syncs the clipboard across devices, no key press required." authors = [{ name = "Sathvik Rao", email = "sathvik.poladi@gmail.com" }] license = { file = "LICENSE" } -requires-python = ">=3.8" +requires-python = ">=3.10" keywords = [ "clipboard", "sync", @@ -23,8 +23,6 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -41,6 +39,7 @@ dependencies = [ "xxhash==3.5.0", "beautifulsoup4==4.12.3", "aiortc==1.10.0", + "keyring>=25.0.0", # Platform-specific dependencies "pyfiglet==1.0.2; sys_platform == 'linux'", "pyperclip==1.8.2; sys_platform == 'win32' or sys_platform == 'darwin'", @@ -68,7 +67,7 @@ packages = [ [tool.black] line-length = 100 -target-version = ["py38", "py39", "py310", "py311", "py312"] +target-version = ["py310", "py311", "py312"] include = "\\.pyi?$" [tool.isort] diff --git a/ClipCascade_Desktop/src/requirements_linux.txt b/ClipCascade_Desktop/src/requirements_linux.txt index b613c300c..d18cc5ff0 100644 --- a/ClipCascade_Desktop/src/requirements_linux.txt +++ b/ClipCascade_Desktop/src/requirements_linux.txt @@ -8,3 +8,4 @@ xxhash==3.5.0 pyfiglet==1.0.2 beautifulsoup4==4.12.3 aiortc==1.10.0 +keyring>=25.0.0 diff --git a/ClipCascade_Desktop/src/requirements_linux_cli.txt b/ClipCascade_Desktop/src/requirements_linux_cli.txt index dc63be309..9f401f3c7 100644 --- a/ClipCascade_Desktop/src/requirements_linux_cli.txt +++ b/ClipCascade_Desktop/src/requirements_linux_cli.txt @@ -5,3 +5,4 @@ websocket_client==1.8.0 xxhash==3.5.0 beautifulsoup4==4.12.3 aiortc==1.10.0 +keyring>=25.0.0 diff --git a/ClipCascade_Desktop/src/requirements_linux_gui.txt b/ClipCascade_Desktop/src/requirements_linux_gui.txt index de0c639c9..8c95375fc 100644 --- a/ClipCascade_Desktop/src/requirements_linux_gui.txt +++ b/ClipCascade_Desktop/src/requirements_linux_gui.txt @@ -7,3 +7,4 @@ websocket_client==1.8.0 xxhash==3.5.0 beautifulsoup4==4.12.3 aiortc==1.10.0 +keyring>=25.0.0 diff --git a/ClipCascade_Desktop/src/requirements_mac.txt b/ClipCascade_Desktop/src/requirements_mac.txt index 494bc5ded..1c3a48190 100644 --- a/ClipCascade_Desktop/src/requirements_mac.txt +++ b/ClipCascade_Desktop/src/requirements_mac.txt @@ -10,3 +10,4 @@ websocket_client==1.8.0 xxhash==3.5.0 beautifulsoup4==4.12.3 aiortc==1.10.0 +keyring>=25.0.0 diff --git a/ClipCascade_Desktop/src/requirements_win.txt b/ClipCascade_Desktop/src/requirements_win.txt index ee4e967cb..37e84dae3 100644 --- a/ClipCascade_Desktop/src/requirements_win.txt +++ b/ClipCascade_Desktop/src/requirements_win.txt @@ -9,3 +9,4 @@ websocket_client==1.8.0 xxhash==3.5.0 beautifulsoup4==4.12.3 aiortc==1.10.0 +keyring>=25.0.0 diff --git a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py index 48a78c4ac..3197b01f3 100644 --- a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py +++ b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py @@ -7,6 +7,14 @@ from stomp_ws.client import Client from core.config import Config from utils.cipher_manager import CipherManager +from utils.protocol_v2 import ( + ReplayCache, + build_transport_frame, + ensure_device_id, + extract_transport_envelope, + unwrap_inbound, + wrap_outbound, +) from clipboard.clipboard_manager import ClipboardManager from utils.notification_manager import NotificationManager from utils.request_manager import RequestManager @@ -25,6 +33,9 @@ def __init__(self, config: Config, is_login_phase=True): self.clipboard_manager = ClipboardManager(self.config) self.cipher_manager = CipherManager(self.config) self.notification_manager = NotificationManager(self.config) + self.replay_cache = ReplayCache() + # One rejection notification per process; see _notify_rejection_once. + self._rejection_notified = False self.sys_tray: TaskbarPanel = None self.first_conn_lost = True self.is_login_phase = is_login_phase @@ -52,6 +63,7 @@ def connect(self) -> tuple[bool, str]: try: if self.is_connected: return True, "" + ensure_device_id(self.config.data) self.client = Client( self.config.data["websocket_url"], headers={ @@ -109,12 +121,21 @@ def send(self, payload: str, payload_type: str = "text"): try: if self.is_connected: if self.clipboard_manager.has_clipboard_changed(payload): - if self.config.data["cipher_enabled"]: - payload = CipherManager.encode_to_json_string( - **self.cipher_manager.encrypt(payload) - ) - body = json.dumps({"payload": payload, "type": payload_type}) - self.client.send(destination=SEND_DESTINATION, body=body) + body = wrap_outbound( + payload=payload, + payload_type=payload_type, + config_data=self.config.data, + cipher_manager=self.cipher_manager, + cipher_enabled=bool(self.config.data["cipher_enabled"]), + ) + # Persist device_id / send_counter for durable replay resistance. + self.config.save() + # The server relays only {payload, type, metadata}, so the + # envelope travels inside payload or it does not arrive. + frame = build_transport_frame(body, payload_type=payload_type) + self.client.send( + destination=SEND_DESTINATION, body=json.dumps(frame) + ) except Exception as e: logging.error(f"Failed to send data: {e}") @@ -122,12 +143,15 @@ def _receive(self, frame: any) -> str: try: if self.is_connected: body = json.loads(frame.body) - payload = body["payload"] - payload_type = body.get("type", "text") - if self.config.data["cipher_enabled"]: - payload = self.cipher_manager.decrypt( - **CipherManager.decode_from_json_string(payload) - ) + envelope = extract_transport_envelope(body) + payload, payload_type = unwrap_inbound( + envelope, + cipher_manager=self.cipher_manager, + cipher_enabled=bool(self.config.data["cipher_enabled"]), + replay_cache=self.replay_cache, + local_device_id=self.config.data.get("device_id") or None, + allow_legacy_v1=bool(self.config.data.get("allow_legacy_v1")), + ) if self.clipboard_manager.has_clipboard_changed(payload): self.clipboard_manager.base64_to_clipboard( @@ -137,9 +161,36 @@ def _receive(self, frame: any) -> str: logging.error( "If cipher is enabled, please make sure it is enabled on all devices" ) + except ValueError as e: + logging.warning(f"Rejected inbound clipboard message: {e}") + self._notify_rejection_once(e) except Exception as e: logging.error(f"Failed to receive data: {e}") + def _notify_rejection_once(self, error: ValueError) -> None: + """ + Surface a rejected-message reason to the user, once per connection. + + Every inbound message being refused looks exactly like "sync is quietly + broken", which is how an envelope-stripping relay went unnoticed. A + warning in a log file nobody reads is not enough for a failure that + stops the product working. + """ + if self._rejection_notified: + return + reason = str(error) + if "legacy v1" not in reason and "un-bound" not in reason: + return + self._rejection_notified = True + self.notification_manager.notify( + title=f"{APP_NAME}: Incoming Clipboard Rejected ⚠️", + message=( + "Messages are arriving without protocol v2 metadata. Either a " + "device is still below 3.2.0, or the server is dropping the " + "envelope. Update every device, then reconnect." + ), + ) + def manual_reconnect(self): if not self.is_auto_reconnecting: self.disconnected = False diff --git a/ClipCascade_Desktop/src/tests/protocol_corpus.json b/ClipCascade_Desktop/src/tests/protocol_corpus.json new file mode 100644 index 000000000..f34ffdc2e --- /dev/null +++ b/ClipCascade_Desktop/src/tests/protocol_corpus.json @@ -0,0 +1,234 @@ +{ + "_comment": [ + "Shared accept/reject corpus for protocol v2, driven through BOTH", + "protocol_v2.py and protocolV2.js. Desktop and mobile are supposed to be", + "behaviourally identical; that was asserted for three review rounds and", + "never measured, and when someone finally measured it the two disagreed on", + "4 of 34 envelopes. The differences were not in the visible logic but in", + "the host runtimes: json.loads keeps int/float apart where JSON.parse does", + "not, and dict.get(k, d) returns a present null where `??` returns the", + "default.", + "", + "Envelopes here are cipher-off so the corpus needs no key material and both", + "suites can read it directly. 'expect' is 'accept' or 'reject'." + ], + "cases": [ + { + "name": "well_formed", + "expect": "accept", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 1, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "counter_float_whole", + "expect": "accept", + "why": "JSON 5.0 and 5 are the same JS number after JSON.parse, so mobile cannot tell them apart. Desktop normalises a whole-valued float to the integer it denotes so both runtimes admit the same envelopes; anything with a fractional part is still rejected on both.", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 5.0, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "counter_fractional", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 1.5, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "counter_zero", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 0, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "counter_negative", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": -1, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "counter_bool", + "expect": "reject", + "why": "Python bool is a subclass of int, so isinstance(True, int) is True.", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": true, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "counter_string", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": "1", + "ts": 0, + "payload": "hello" + } + }, + { + "name": "counter_above_safe_integer", + "expect": "reject", + "why": "Python ints are unbounded; JS stops being exact past 2^53-1.", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 9007199254740993, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "ts_float_whole", + "expect": "accept", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 1, + "ts": 0.0, + "payload": "hello" + }, + "why": "JSON 5.0 and 5 are the same JS number after JSON.parse, so mobile cannot tell them apart. Desktop normalises a whole-valued float to the integer it denotes so both runtimes admit the same envelopes; anything with a fractional part is still rejected on both." + }, + { + "name": "ts_bool", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 1, + "ts": true, + "payload": "hello" + } + }, + { + "name": "ts_string", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 1, + "ts": "0", + "payload": "hello" + } + }, + { + "name": "sender_missing", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "counter": 1, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "sender_empty", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "", + "counter": 1, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "sender_not_a_string", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": 5, + "counter": 1, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "version_1_absent", + "expect": "reject", + "why": "Stripping v must not be a way around the v2 checks.", + "envelope": { + "type": "text", + "senderDeviceId": "A", + "counter": 1, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "version_unknown", + "expect": "reject", + "envelope": { + "v": 99, + "type": "text", + "senderDeviceId": "A", + "counter": 1, + "ts": 0, + "payload": "hello" + } + }, + { + "name": "payload_missing", + "expect": "reject", + "envelope": { + "v": 2, + "type": "text", + "senderDeviceId": "A", + "counter": 1, + "ts": 0 + } + }, + { + "name": "type_absent_defaults_to_text", + "expect": "accept", + "envelope": { + "v": 2, + "senderDeviceId": "A", + "counter": 1, + "ts": 0, + "payload": "hello" + } + } + ] +} diff --git a/ClipCascade_Desktop/src/tests/test_config_keyring.py b/ClipCascade_Desktop/src/tests/test_config_keyring.py new file mode 100644 index 000000000..3bb430c3b --- /dev/null +++ b/ClipCascade_Desktop/src/tests/test_config_keyring.py @@ -0,0 +1,273 @@ +"""Unit tests for desktop secret migration into the OS keyring.""" + +import base64 +import json +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path + +# Allow imports from the desktop package root. +SRC_ROOT = Path(__file__).resolve().parents[1] +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from core.config import Config # noqa: E402 + + +class MemoryKeyring: + """Minimal keyring backend for tests (no OS keychain dependency).""" + + class PasswordDeleteError(Exception): + pass + + def __init__(self): + self._store = {} + + def set_password(self, service, account, password): + self._store[(service, account)] = password + + def get_password(self, service, account): + return self._store.get((service, account)) + + def delete_password(self, service, account): + key = (service, account) + if key not in self._store: + raise MemoryKeyring.PasswordDeleteError("not found") + del self._store[key] + + +class KeyringFailureTests(unittest.TestCase): + """ + A keyring that cannot store must not cost the user their login. + + The migration used to strip secrets from the DATA file whether or not they + reached the keyring, so on a box with no backend the only copy was deleted. + """ + + def setUp(self): + self._dir = tempfile.mkdtemp() + self.path = os.path.join(self._dir, "DATA") + + def tearDown(self): + shutil.rmtree(self._dir, ignore_errors=True) + + def _legacy_data_file(self): + with open(self.path, "w") as f: + json.dump( + { + "username": "u", + "server_url": "http://127.0.0.1:8080", + "cookie": {"JSESSIONID": "abc"}, + "csrf_token": "tok", + "password": "pw", + }, + f, + ) + + def test_secrets_survive_when_the_keyring_is_missing(self): + self._legacy_data_file() + config = Config(file_name=self.path) + # Passing keyring_backend=None falls back to importing the real keyring, + # so absence has to be simulated at the lookup itself. + config._keyring = lambda: None + config.load() + config.save() + + with open(self.path) as f: + on_disk = json.load(f) + # Not silently discarded: the login still works after this. + self.assertEqual(on_disk.get("csrf_token"), "tok") + self.assertEqual(on_disk.get("password"), "pw") + + def test_secrets_survive_when_the_keyring_write_fails(self): + self._legacy_data_file() + + class ExplodingKeyring: + def get_password(self, service, account): + return None + + def set_password(self, service, account, value): + raise RuntimeError("no backend available") + + def delete_password(self, service, account): + pass + + config = Config(file_name=self.path, keyring_backend=ExplodingKeyring()) + config.load() + config.save() + + with open(self.path) as f: + on_disk = json.load(f) + self.assertEqual(on_disk.get("csrf_token"), "tok") + self.assertEqual(on_disk.get("password"), "pw") + + +class ConfigKeyringTests(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.data_path = os.path.join(self._tmpdir.name, "DATA") + self.keyring = MemoryKeyring() + self.config = Config(file_name=self.data_path, keyring_backend=self.keyring) + + def tearDown(self): + self._tmpdir.cleanup() + + def _read_data_file(self): + with open(self.data_path, "r", encoding="utf-8") as f: + return json.load(f) + + def test_save_keeps_secrets_out_of_data_file(self): + aes_key = os.urandom(32) + self.config.data["username"] = "alice" + self.config.data["server_url"] = "https://example.com" + self.config.data["password"] = "sha3hash" + self.config.data["csrf_token"] = "csrf-token" + self.config.data["cookie"] = {"JSESSIONID": "abc123"} + self.config.data["hashed_password"] = aes_key + + self.config.save() + + on_disk = self._read_data_file() + for field in Config.SECRET_FIELDS: + self.assertNotIn(field, on_disk) + + self.assertEqual(on_disk["username"], "alice") + self.assertEqual(on_disk["server_url"], "https://example.com") + + # Secrets live only in the keyring. + self.assertEqual( + self.keyring.get_password( + Config.KEYRING_SERVICE, self.config._secret_account("password") + ), + json.dumps("sha3hash"), + ) + self.assertEqual( + self.keyring.get_password( + Config.KEYRING_SERVICE, self.config._secret_account("csrf_token") + ), + json.dumps("csrf-token"), + ) + self.assertEqual( + self.keyring.get_password( + Config.KEYRING_SERVICE, self.config._secret_account("cookie") + ), + json.dumps({"JSESSIONID": "abc123"}), + ) + self.assertEqual( + self.keyring.get_password( + Config.KEYRING_SERVICE, self.config._secret_account("hashed_password") + ), + base64.b64encode(aes_key).decode("utf-8"), + ) + + def test_load_restores_secrets_from_keyring(self): + aes_key = os.urandom(32) + self.config.data["username"] = "bob" + self.config.data["password"] = "stored-pass" + self.config.data["csrf_token"] = "csrf" + self.config.data["cookie"] = {"JSESSIONID": "sess"} + self.config.data["hashed_password"] = aes_key + self.config.save() + + reloaded = Config(file_name=self.data_path, keyring_backend=self.keyring) + self.assertTrue(reloaded.load()) + self.assertEqual(reloaded.data["username"], "bob") + self.assertEqual(reloaded.data["password"], "stored-pass") + self.assertEqual(reloaded.data["csrf_token"], "csrf") + self.assertEqual(reloaded.data["cookie"], {"JSESSIONID": "sess"}) + self.assertEqual(reloaded.data["hashed_password"], aes_key) + + on_disk = self._read_data_file() + for field in Config.SECRET_FIELDS: + self.assertNotIn(field, on_disk) + + def test_migrates_legacy_secrets_from_data_file(self): + aes_key = os.urandom(32) + legacy = { + "cipher_enabled": True, + "server_url": "https://legacy.example", + "username": "legacy-user", + "password": "legacy-pass", + "csrf_token": "legacy-csrf", + "cookie": {"JSESSIONID": "legacy-session"}, + "hashed_password": base64.b64encode(aes_key).decode("utf-8"), + "save_password": True, + "salt": "s", + "hash_rounds": 1000, + } + with open(self.data_path, "w", encoding="utf-8") as f: + json.dump(legacy, f) + + loaded = Config(file_name=self.data_path, keyring_backend=self.keyring) + self.assertTrue(loaded.load()) + + self.assertEqual(loaded.data["username"], "legacy-user") + self.assertEqual(loaded.data["password"], "legacy-pass") + self.assertEqual(loaded.data["csrf_token"], "legacy-csrf") + self.assertEqual(loaded.data["cookie"], {"JSESSIONID": "legacy-session"}) + self.assertEqual(loaded.data["hashed_password"], aes_key) + + on_disk = self._read_data_file() + for field in Config.SECRET_FIELDS: + self.assertNotIn(field, on_disk) + self.assertEqual(on_disk["username"], "legacy-user") + + # Keyring holds migrated secrets. + self.assertIsNotNone( + self.keyring.get_password( + Config.KEYRING_SERVICE, loaded._secret_account("password") + ) + ) + self.assertIsNotNone( + self.keyring.get_password( + Config.KEYRING_SERVICE, loaded._secret_account("hashed_password") + ) + ) + + def test_clear_secrets_removes_keyring_entries(self): + self.config.data["password"] = "p" + self.config.data["csrf_token"] = "c" + self.config.data["cookie"] = {"JSESSIONID": "j"} + self.config.data["hashed_password"] = os.urandom(32) + self.config.save() + + self.config.clear_secrets() + self.config.save() + + for key in Config.SECRET_FIELDS: + self.assertIsNone( + self.keyring.get_password( + Config.KEYRING_SERVICE, self.config._secret_account(key) + ) + ) + self.assertEqual(self.config.data["password"], "") + self.assertEqual(self.config.data["csrf_token"], "") + self.assertIsNone(self.config.data["cookie"]) + self.assertIsNone(self.config.data["hashed_password"]) + + def test_empty_secrets_are_deleted_not_left_stale(self): + self.config.data["password"] = "stale" + self.config.data["csrf_token"] = "stale-csrf" + self.config.save() + + self.config.data["password"] = "" + self.config.data["csrf_token"] = "" + self.config.save() + + self.assertIsNone( + self.keyring.get_password( + Config.KEYRING_SERVICE, self.config._secret_account("password") + ) + ) + self.assertIsNone( + self.keyring.get_password( + Config.KEYRING_SERVICE, self.config._secret_account("csrf_token") + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/ClipCascade_Desktop/src/tests/test_p2s_roundtrip.py b/ClipCascade_Desktop/src/tests/test_p2s_roundtrip.py new file mode 100644 index 000000000..bfd30c960 --- /dev/null +++ b/ClipCascade_Desktop/src/tests/test_p2s_roundtrip.py @@ -0,0 +1,220 @@ +""" +Client -> server -> client round trip over the P2S (STOMP) transport. + +The server is a relay: ClipCascadeController.sendPrivateMessage rebuilds the +outgoing message from exactly three getters on ClipboardData, so any top-level +field the client invents is dropped in transit. Protocol v2 shipped four such +fields, which meant every relayed message arrived looking like legacy v1 and was +refused β€” default sync was broken and no test noticed, because the desktop suite +stopped at unwrap_inbound, the mobile suite stopped at unwrapInbound, and the +server suite never touched /cliptext. + +These tests model the relay's lossiness and drive the real client code through +it, so the gap between "both ends agree" and "a message survives the middle" is +covered. +""" + +import json +import os +import sys +import time +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +SRC_ROOT = Path(__file__).resolve().parents[1] +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from utils.cipher_manager import CipherManager # noqa: E402 +from utils.protocol_v2 import ( # noqa: E402 + ReplayCache, + build_transport_frame, + extract_transport_envelope, + unwrap_inbound, + wrap_outbound, +) + + +def relay(frame: dict) -> dict: + """ + Reproduce ClipCascadeController.sendPrivateMessage verbatim. + + Java, for reference: + + ClipboardData messageToSend = new ClipboardData( + clipboardData.getPayload(), + (clipboardData.getType() == null) ? "text" : clipboardData.getType(), + clipboardData.getMetadata()); + + Three getters survive; everything else the client sent is discarded, both by + this server and by every older one already deployed. + """ + return { + "payload": frame.get("payload"), + "type": frame.get("type") or "text", + "metadata": frame.get("metadata"), + } + + +class P2SRoundTripTests(unittest.TestCase): + def setUp(self): + config = MagicMock() + config.data = { + "hashed_password": os.urandom(32), + "username": "u", + "salt": "s", + "hash_rounds": 1, + } + self.cipher = CipherManager(config) + + def _send(self, payload, payload_type="text", cipher_enabled=True): + """Sender side, exactly as stomp_manager.send builds it.""" + envelope = wrap_outbound( + payload=payload, + payload_type=payload_type, + config_data={"device_id": "device-A", "send_counter": 0}, + cipher_manager=self.cipher, + cipher_enabled=cipher_enabled, + ) + return build_transport_frame(envelope, payload_type=payload_type) + + def _receive(self, relayed, *, replay_cache=None, cipher_enabled=True, **kwargs): + """Receiver side, exactly as stomp_manager._receive consumes it.""" + envelope = extract_transport_envelope(relayed) + return unwrap_inbound( + envelope, + cipher_manager=self.cipher, + cipher_enabled=cipher_enabled, + replay_cache=replay_cache if replay_cache is not None else ReplayCache(), + local_device_id="device-B", + **kwargs, + ) + + def test_frame_contains_only_fields_the_server_models(self): + """ + The server does not ignore unknown top-level keys β€” Jackson raises + UnrecognizedPropertyException and the message is dropped before the + handler runs. Verified against a real server: publishing the flat + envelope relayed nothing at all. + + So the outgoing frame must carry exactly the fields ClipboardData + declares, and nothing else, however harmless the extra looks. + """ + server_known_fields = {"payload", "type", "metadata"} + frame = self._send("secret-clip") + self.assertTrue( + set(frame).issubset(server_known_fields), + f"frame has fields the server will reject: {set(frame) - server_known_fields}", + ) + + def test_relay_drops_unknown_top_level_fields(self): + """ + Pins the assumption the whole design rests on. If a future server learns + to carry the envelope fields, this is the test that says the nesting is + no longer required. + """ + flat = { + "v": 2, + "type": "text", + "senderDeviceId": "device-A", + "counter": 1, + "ts": int(time.time() * 1000), + "payload": "x", + } + relayed = relay(flat) + for dropped in ("v", "senderDeviceId", "counter", "ts"): + self.assertNotIn(dropped, relayed) + + def test_roundtrip_survives_the_relay(self): + """The regression test for F0. Fails before the transport frame exists.""" + frame = self._send("secret-clip") + relayed = relay(json.loads(json.dumps(frame))) + payload, payload_type = self._receive(relayed, allow_legacy_v1=False) + self.assertEqual(payload, "secret-clip") + self.assertEqual(payload_type, "text") + + def test_roundtrip_survives_the_relay_cipher_off(self): + frame = self._send("plain-clip", cipher_enabled=False) + relayed = relay(json.loads(json.dumps(frame))) + payload, _ = self._receive( + relayed, cipher_enabled=False, allow_legacy_v1=False + ) + self.assertEqual(payload, "plain-clip") + + def test_payload_type_survives_the_relay(self): + for payload_type in ("text", "image", "files"): + with self.subTest(payload_type=payload_type): + frame = self._send("blob", payload_type=payload_type) + relayed = relay(json.loads(json.dumps(frame))) + _, received_type = self._receive(relayed, allow_legacy_v1=False) + self.assertEqual(received_type, payload_type) + + def test_outer_type_is_not_trusted(self): + """ + The outer frame is unauthenticated. Only the type bound inside the + ciphertext may win, or the relay could re-label text as files and steer + the receiver down a different handler. + """ + frame = self._send("secret-clip", payload_type="text") + relayed = relay(json.loads(json.dumps(frame))) + relayed["type"] = "files" + payload, received_type = self._receive(relayed, allow_legacy_v1=False) + self.assertEqual(received_type, "text") + self.assertEqual(payload, "secret-clip") + + def test_replay_through_the_relay_is_rejected(self): + frame = self._send("secret-clip") + relayed = relay(json.loads(json.dumps(frame))) + cache = ReplayCache() + self._receive(relayed, replay_cache=cache, allow_legacy_v1=False) + with self.assertRaises(ValueError): + self._receive(relayed, replay_cache=cache, allow_legacy_v1=False) + + def test_self_originated_message_ignored_through_the_relay(self): + envelope = wrap_outbound( + payload="mine", + payload_type="text", + config_data={"device_id": "device-B", "send_counter": 0}, + cipher_manager=self.cipher, + cipher_enabled=True, + ) + relayed = relay(build_transport_frame(envelope, payload_type="text")) + with self.assertRaises(ValueError): + self._receive(relayed, allow_legacy_v1=False) + + def test_legacy_bare_blob_through_relay_still_refused(self): + """ + Guards the side door that was closed once already: the extractor must + not become a new way in for un-versioned ciphertext. + """ + blob = CipherManager.encode_to_json_string(**self.cipher.encrypt("legacy")) + relayed = relay({"payload": blob, "type": "text"}) + with self.assertRaises(ValueError): + self._receive(relayed, allow_legacy_v1=False) + + def test_flat_envelope_still_accepted(self): + """ + Forward compatibility: if a server ever does carry the envelope fields, + the client must keep working without a coordinated release. + """ + envelope = wrap_outbound( + payload="direct", + payload_type="text", + config_data={"device_id": "device-A", "send_counter": 0}, + cipher_manager=self.cipher, + cipher_enabled=True, + ) + payload, _ = self._receive(envelope, allow_legacy_v1=False) + self.assertEqual(payload, "direct") + + def test_garbage_frames_raise_value_error(self): + """Never leak a JSONDecodeError; callers catch ValueError.""" + for bad in ({"payload": "not json"}, {"payload": 5}, {"payload": "[]"}, {}): + with self.subTest(bad=bad): + with self.assertRaises(ValueError): + extract_transport_envelope(bad) + + +if __name__ == "__main__": + unittest.main() diff --git a/ClipCascade_Desktop/src/tests/test_protocol_v2.py b/ClipCascade_Desktop/src/tests/test_protocol_v2.py new file mode 100644 index 000000000..939cc1046 --- /dev/null +++ b/ClipCascade_Desktop/src/tests/test_protocol_v2.py @@ -0,0 +1,432 @@ +"""Tests for protocol v2 envelope, AAD crypto, and replay cache.""" + +import json +import os +import sys +import time +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +SRC_ROOT = Path(__file__).resolve().parents[1] +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from utils.protocol_v2 import ( # noqa: E402 + MAX_TRACKED_SENDERS, + PROTOCOL_VERSION, + ReplayCache, + decrypt_legacy_blob, + wrap_outbound, + unwrap_inbound, + build_aad, +) +from utils.stream_transfer import fragment_utf8_string, StreamAssembler # noqa: E402 +from utils.cipher_manager import CipherManager # noqa: E402 + + +class ProtocolV2Tests(unittest.TestCase): + @staticmethod + def _cipher(): + config = MagicMock() + config.data = { + "hashed_password": os.urandom(32), + "username": "u", + "salt": "s", + "hash_rounds": 1, + } + return CipherManager(config), config + + def test_replay_cache_rejects_duplicates(self): + cache = ReplayCache(max_entries=8) + self.assertTrue(cache.accept("dev-a", 1)) + self.assertFalse(cache.accept("dev-a", 1)) + self.assertTrue(cache.accept("dev-a", 2)) + self.assertTrue(cache.accept("dev-b", 1)) + + def test_wrap_and_unwrap_plain(self): + store = {"device_id": "device-1", "send_counter": 0} + env = wrap_outbound( + payload="hello", + payload_type="text", + config_data=store, + cipher_enabled=False, + ) + self.assertEqual(env["v"], PROTOCOL_VERSION) + self.assertEqual(env["senderDeviceId"], "device-1") + self.assertEqual(env["counter"], 1) + payload, ptype = unwrap_inbound(env, cipher_enabled=False, replay_cache=ReplayCache()) + self.assertEqual(payload, "hello") + self.assertEqual(ptype, "text") + + def test_wrap_and_unwrap_encrypted_bound_metadata(self): + key = os.urandom(32) + config = MagicMock() + config.data = { + "hashed_password": key, + "username": "u", + "salt": "s", + "hash_rounds": 1, + "device_id": "dev-crypto", + "send_counter": 0, + } + cipher = CipherManager(config) + env = wrap_outbound( + payload="secret-clip", + payload_type="text", + config_data=config.data, + cipher_manager=cipher, + cipher_enabled=True, + ) + # Wire payload must use mobile-compatible bound blob + blob = json.loads(env["payload"]) + self.assertTrue(blob.get("bound")) + self.assertIn("nonce", blob) + + # Tampering with outer type must fail bound-metadata check + bad = dict(env) + bad["type"] = "image" + with self.assertRaises(Exception): + unwrap_inbound( + bad, + cipher_manager=cipher, + cipher_enabled=True, + replay_cache=ReplayCache(), + ) + + payload, ptype = unwrap_inbound( + env, + cipher_manager=cipher, + cipher_enabled=True, + replay_cache=ReplayCache(), + ) + self.assertEqual(payload, "secret-clip") + self.assertEqual(ptype, "text") + + def test_legacy_v1_rejected_by_default(self): + """Stripping "v" must not be a way around the v2 checks.""" + body = {"payload": "legacy", "type": "text"} + with self.assertRaises(ValueError): + unwrap_inbound(body, cipher_enabled=False) + + def test_legacy_v1_accepted_only_when_opted_in(self): + body = {"payload": "legacy", "type": "text"} + payload, ptype = unwrap_inbound( + body, cipher_enabled=False, allow_legacy_v1=True + ) + self.assertEqual(payload, "legacy") + self.assertEqual(ptype, "text") + + def test_v1_downgrade_of_a_v2_envelope_rejected(self): + """A tamperer must not be able to replay a v2 ciphertext as v1.""" + cipher, config = self._cipher() + env = wrap_outbound( + payload="secret-clip", + payload_type="text", + config_data={"device_id": "sender", "send_counter": 0}, + cipher_manager=cipher, + cipher_enabled=True, + ) + downgraded = dict(env) + del downgraded["v"] + with self.assertRaises(ValueError): + unwrap_inbound( + downgraded, + cipher_manager=cipher, + cipher_enabled=True, + replay_cache=ReplayCache(), + ) + + def test_unbound_ciphertext_rejected(self): + """ + "bound" sits outside the AEAD, so it is attacker-mutable. Flipping it + to false must not skip the metadata check. + """ + cipher, config = self._cipher() + env = wrap_outbound( + payload="secret-clip", + payload_type="text", + config_data={"device_id": "sender", "send_counter": 0}, + cipher_manager=cipher, + cipher_enabled=True, + ) + blob = json.loads(env["payload"]) + blob["bound"] = False + tampered = dict(env) + tampered["payload"] = json.dumps(blob, separators=(",", ":")) + tampered["senderDeviceId"] = "attacker-spoofed" + tampered["counter"] = 9999 + with self.assertRaises(ValueError): + unwrap_inbound( + tampered, + cipher_manager=cipher, + cipher_enabled=True, + replay_cache=ReplayCache(), + ) + + @staticmethod + def _forged_envelope(sender, counter): + """A v2 envelope whose ciphertext will not authenticate under our key.""" + return { + "v": 2, + "type": "text", + "senderDeviceId": sender, + "counter": counter, + "ts": int(time.time() * 1000), + "payload": json.dumps( + { + "nonce": "AAAAAAAAAAAAAAAA", + "ciphertext": "AAAA", + "tag": "AAAAAAAAAAAAAAAAAAAAAA==", + "bound": True, + }, + separators=(",", ":"), + ), + } + + def test_unauthenticated_flood_cannot_evict_replay_history(self): + """ + Replay state is only touched after the AEAD verifies, so a peer without + the key cannot evict a real sender's history and then replay its + message. + """ + cipher, _ = self._cipher() + env = wrap_outbound( + payload="secret-clip", + payload_type="text", + config_data={"device_id": "sender", "send_counter": 0}, + cipher_manager=cipher, + cipher_enabled=True, + ) + + cache = ReplayCache() + kwargs = dict(cipher_manager=cipher, cipher_enabled=True, replay_cache=cache) + payload, _ = unwrap_inbound(dict(env), **kwargs) + self.assertEqual(payload, "secret-clip") + + for i in range(512): + with self.assertRaises(Exception): + unwrap_inbound(self._forged_envelope(f"junk-{i}", 1), **kwargs) + + # The junk never entered the cache, so the real sender is still tracked + # and its original message is still recognised as a replay. + self.assertEqual(len(cache._senders), 1) + with self.assertRaises(ValueError): + unwrap_inbound(dict(env), **kwargs) + + def test_unauthenticated_message_cannot_pin_a_senders_counter(self): + """ + Admitting an unverified counter would let one forged message push a + victim's window far ahead and stall all of its real traffic. + """ + cipher, _ = self._cipher() + cache = ReplayCache() + kwargs = dict(cipher_manager=cipher, cipher_enabled=True, replay_cache=cache) + + with self.assertRaises(Exception): + unwrap_inbound(self._forged_envelope("sender", 2_000_000_000), **kwargs) + + env = wrap_outbound( + payload="secret-clip", + payload_type="text", + config_data={"device_id": "sender", "send_counter": 0}, + cipher_manager=cipher, + cipher_enabled=True, + ) + payload, _ = unwrap_inbound(dict(env), **kwargs) + self.assertEqual(payload, "secret-clip") + + def test_replay_cache_sender_count_is_bounded(self): + cache = ReplayCache() + for i in range(10_000): + cache.accept(f"junk-device-{i}", 1) + self.assertLessEqual(len(cache._senders), MAX_TRACKED_SENDERS) + + def test_replay_cache_rejects_bool_counter(self): + cache = ReplayCache() + self.assertFalse(cache.accept("dev", True)) + + def test_replay_cache_rejects_out_of_range_counter(self): + """ + Python ints are unbounded but JavaScript's are not. Accepting a huge + counter would diverge from mobile and pin the sender's window, wedging + that device until restart. + """ + cache = ReplayCache() + self.assertFalse(cache.accept("dev", 2**53)) + self.assertFalse(cache.accept("dev", 2**60)) + # The rejected values must not have pinned the window: this is the whole + # point, since a pinned window rejects every later legitimate counter. + self.assertTrue(cache.accept("dev", 5)) + # A large but still-safe counter remains acceptable. + self.assertTrue(ReplayCache().accept("dev", 2**53 - 1)) + + def test_unwrap_rejects_out_of_range_counter(self): + env = { + "v": 2, + "type": "text", + "senderDeviceId": "dev", + "counter": 2**60, + "ts": int(time.time() * 1000), + "payload": "x", + } + with self.assertRaises(ValueError): + unwrap_inbound(env, cipher_enabled=False, replay_cache=ReplayCache()) + + def test_shared_cross_runtime_corpus(self): + """ + Drive the shared corpus through the Python implementation. + + The mobile suite drives the same file through the JavaScript one. Both + sides claimed to be behaviourally identical for three review rounds; the + first time anyone measured it, they disagreed on 4 of 34 envelopes + because of host-language differences (int/float, present-null) that are + invisible when reading the two files side by side. + """ + corpus = json.loads( + (Path(__file__).parent / "protocol_corpus.json").read_text() + ) + now = int(time.time() * 1000) + for case in corpus["cases"]: + with self.subTest(case=case["name"]): + envelope = dict(case["envelope"]) + # 'ts: 0' in the file means "now"; a fixed value would age out + # of the clock-skew window. Preserve the JSON type, or the + # float case would either be rewritten into the int case or + # fail on skew instead of on the property under test. + ts = envelope.get("ts") + if type(ts) is int and ts == 0: + envelope["ts"] = now + elif type(ts) is float and ts == 0.0: + envelope["ts"] = float(now) + if case["expect"] == "accept": + payload, _ = unwrap_inbound( + envelope, cipher_enabled=False, replay_cache=ReplayCache() + ) + self.assertEqual(payload, envelope["payload"]) + else: + with self.assertRaises(ValueError): + unwrap_inbound( + envelope, cipher_enabled=False, replay_cache=ReplayCache() + ) + + def test_emitted_nonce_is_12_bytes_for_ios_compatibility(self): + """ + Apple's CryptoKit only accepts a 96-bit GCM nonce, so anything else is + undecryptable on iOS. PyCryptodome defaults to 16 bytes when none is + passed, which is how that incompatibility arose. + """ + cipher, _ = self._cipher() + for _ in range(5): + self.assertEqual(len(cipher.encrypt("x")["nonce"]), 12) + + # Decryption must stay length-agnostic so messages from older desktop + # builds (16-byte nonce) still open. + legacy = cipher.encrypt("older-build") + legacy_nonce = os.urandom(16) + from Crypto.Cipher import AES + + c = AES.new(cipher.config.data["hashed_password"], AES.MODE_GCM, nonce=legacy_nonce) + ct, tag = c.encrypt_and_digest(b"older-build") + self.assertEqual( + cipher.decrypt(nonce=legacy_nonce, ciphertext=ct, tag=tag), "older-build" + ) + self.assertEqual(len(legacy["nonce"]), 12) + + def test_legacy_blob_refused_by_default(self): + """ + A bare {nonce,ciphertext,tag} blob has no envelope, so none of the v2 + checks apply to it. It must be refused under the same opt-in as v1, + otherwise every transport that can receive one reopens the bypass. + """ + cipher, _ = self._cipher() + encrypted = cipher.encrypt("legacy-secret") + blob = CipherManager.encode_to_json_string( + nonce=encrypted["nonce"], + ciphertext=encrypted["ciphertext"], + tag=encrypted["tag"], + ) + + with self.assertRaises(ValueError): + decrypt_legacy_blob(blob, cipher_manager=cipher, allow_legacy_v1=False) + + self.assertEqual( + decrypt_legacy_blob(blob, cipher_manager=cipher, allow_legacy_v1=True), + "legacy-secret", + ) + + def test_self_originated_rejected(self): + env = { + "v": 2, + "type": "text", + "senderDeviceId": "me", + "counter": 1, + "ts": int(time.time() * 1000), + "payload": "x", + } + with self.assertRaises(ValueError): + unwrap_inbound(env, cipher_enabled=False, local_device_id="me") + + def test_build_aad_stable(self): + aad = build_aad(2, "text", "d", 3, 100) + self.assertEqual(aad, b"2|text|d|3|100") + + def test_fragment_utf8_preserves_multibyte(self): + s = "hello πŸ˜€ world" + parts = fragment_utf8_string(s, fragment_size=8) + self.assertEqual("".join(parts), s) + + def test_stream_assembler_caps(self): + asm = StreamAssembler("id", total_size=5, max_size=5) + self.assertFalse(asm.add_chunk(0, b"hel")) + self.assertTrue(asm.add_chunk(3, b"lo")) + self.assertEqual(asm.assemble(), b"hello") + with self.assertRaises(ValueError): + StreamAssembler("id", total_size=10, max_size=5) + + def test_mobile_style_bound_blob_decrypts(self): + """Desktop must accept the mobile bound ciphertext shape.""" + import base64 + + key = os.urandom(32) + config = MagicMock() + config.data = { + "hashed_password": key, + "device_id": "desktop", + "send_counter": 0, + } + cipher = CipherManager(config) + sender = "mobile-device" + counter = 7 + ts = int(time.time() * 1000) + inner = json.dumps( + {"t": "text", "d": sender, "c": counter, "ts": ts, "p": "from-mobile"}, + separators=(",", ":"), + ) + enc = cipher.encrypt(inner) + blob = { + "nonce": base64.b64encode(enc["nonce"]).decode(), + "ciphertext": base64.b64encode(enc["ciphertext"]).decode(), + "tag": base64.b64encode(enc["tag"]).decode(), + "bound": True, + } + env = { + "v": 2, + "type": "text", + "senderDeviceId": sender, + "counter": counter, + "ts": ts, + "payload": json.dumps(blob), + } + payload, ptype = unwrap_inbound( + env, + cipher_manager=cipher, + cipher_enabled=True, + replay_cache=ReplayCache(), + ) + self.assertEqual(payload, "from-mobile") + self.assertEqual(ptype, "text") + + +if __name__ == "__main__": + unittest.main() diff --git a/ClipCascade_Desktop/src/tests/test_tailscale_http.py b/ClipCascade_Desktop/src/tests/test_tailscale_http.py new file mode 100644 index 000000000..d0647b9aa --- /dev/null +++ b/ClipCascade_Desktop/src/tests/test_tailscale_http.py @@ -0,0 +1,99 @@ +"""Tailscale / private-mesh HTTP policy for desktop server URLs.""" + +import os +import sys +import unittest +from pathlib import Path + +SRC_ROOT = Path(__file__).resolve().parents[1] +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + +from core.config import Config # noqa: E402 + + +class TailscaleHttpPolicyTests(unittest.TestCase): + def setUp(self): + os.environ.pop("CLIPCASCADE_ALLOW_INSECURE_HTTP", None) + os.environ.pop("CLIPCASCADE_ALLOW_PRIVATE_HTTP", None) + + def tearDown(self): + os.environ.pop("CLIPCASCADE_ALLOW_INSECURE_HTTP", None) + os.environ.pop("CLIPCASCADE_ALLOW_PRIVATE_HTTP", None) + + def test_allows_tailscale_cgnat_http(self): + Config.validate_server_url("http://100.64.1.2:8080") + Config.validate_server_url("http://100.127.0.1:8080") + + def test_rejects_public_http_with_leading_whitespace(self): + """ + The URL is normalised before the scheme is inspected. Otherwise + urlparse(" http://...") reports an empty scheme and the guard never + fires. + """ + with self.assertRaises(ValueError): + Config.validate_server_url(" http://evil.example.com") + with self.assertRaises(ValueError): + Config.validate_server_url("\thttp://evil.example.com") + + def test_rejects_ipv4_mapped_public_http(self): + """ + Python reports ::ffff:8.8.8.8 as is_private, because ::ffff:0:0/96 is + in its IPv6 private list. The embedded IPv4 address is what counts. + """ + with self.assertRaises(ValueError): + Config.validate_server_url("http://[::ffff:8.8.8.8]") + self.assertFalse(Config._is_private_or_mesh_host("::ffff:8.8.8.8")) + self.assertTrue(Config._is_private_or_mesh_host("::ffff:100.64.0.1")) + self.assertTrue(Config._is_private_or_mesh_host("::ffff:192.168.1.1")) + + def test_rejects_the_public_ts_net_apex(self): + """ + "ts.net" is Tailscale's own public domain, not a mesh host. Only + MagicDNS names under it are private. Mobile asserts the same thing. + """ + self.assertFalse(Config._is_private_or_mesh_host("ts.net")) + self.assertFalse(Config._is_private_or_mesh_host("evilts.net")) + self.assertFalse(Config._is_private_or_mesh_host("box.ts.net.evil.com")) + with self.assertRaises(ValueError): + Config.validate_server_url("http://ts.net:8080") + + def test_allows_magicdns_http(self): + Config.validate_server_url("http://clipcascade.tail-abc123.ts.net:8080") + Config.validate_server_url("http://my-nas.ts.net") + + def test_allows_rfc1918_and_loopback(self): + Config.validate_server_url("http://192.168.1.10:8080") + Config.validate_server_url("http://10.0.0.5:8080") + Config.validate_server_url("http://172.16.0.1:8080") + Config.validate_server_url("http://127.0.0.1:8080") + Config.validate_server_url("http://localhost:8080") + + def test_rejects_public_http(self): + with self.assertRaises(ValueError): + Config.validate_server_url("http://example.com:8080") + with self.assertRaises(ValueError): + Config.validate_server_url("http://8.8.8.8:8080") + + def test_allows_public_https(self): + Config.validate_server_url("https://example.com") + Config.validate_server_url("https://clipcascade.example.com:8443") + + def test_strict_mode_disables_private_http(self): + os.environ["CLIPCASCADE_ALLOW_PRIVATE_HTTP"] = "false" + with self.assertRaises(ValueError): + Config.validate_server_url("http://100.64.1.2:8080") + Config.validate_server_url("http://localhost:8080") + + def test_override_allows_public_http(self): + os.environ["CLIPCASCADE_ALLOW_INSECURE_HTTP"] = "true" + Config.validate_server_url("http://example.com:8080") + + def test_is_private_or_mesh_host_helpers(self): + self.assertTrue(Config._is_private_or_mesh_host("100.100.50.1")) + self.assertTrue(Config._is_private_or_mesh_host("foo.bar.ts.net")) + self.assertFalse(Config._is_private_or_mesh_host("evil.example.com")) + + +if __name__ == "__main__": + unittest.main() diff --git a/ClipCascade_Desktop/src/utils/cipher_manager.py b/ClipCascade_Desktop/src/utils/cipher_manager.py index b76157ae6..a2cc2a513 100644 --- a/ClipCascade_Desktop/src/utils/cipher_manager.py +++ b/ClipCascade_Desktop/src/utils/cipher_manager.py @@ -3,9 +3,17 @@ import hashlib from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes from core.constants import * from core.config import Config +# 96 bits, the size GCM is specified around (NIST SP 800-38D) and the only size +# Apple's CryptoKit accepts: AES.GCM.Nonce(data:) rejects anything else, so a +# 16-byte nonce is undecryptable on iOS. PyCryptodome defaults to 16 when no +# nonce is passed; Android's GCMParameterSpec accepts any length. 12 is +# therefore the one value every client can read. +GCM_NONCE_SIZE_BYTES = 12 + class CipherManager: def __init__(self, config: Config): @@ -29,16 +37,26 @@ def hash_password(self, password: str) -> bytes: dklen=self.dklen, ) - def encrypt(self, plaintext: str) -> dict: + def encrypt(self, plaintext: str, aad: bytes | None = None) -> dict: key = self.config.data["hashed_password"] plaintext_bytes = plaintext.encode("utf-8") - cipher = AES.new(key, self.mode) + cipher = AES.new(key, self.mode, nonce=get_random_bytes(GCM_NONCE_SIZE_BYTES)) + if aad: + cipher.update(aad) ciphertext, tag = cipher.encrypt_and_digest(plaintext_bytes) return {"nonce": cipher.nonce, "ciphertext": ciphertext, "tag": tag} - def decrypt(self, nonce: bytes, ciphertext: bytes, tag: bytes) -> str: + def decrypt( + self, + nonce: bytes, + ciphertext: bytes, + tag: bytes, + aad: bytes | None = None, + ) -> str: key = self.config.data["hashed_password"] cipher = AES.new(key, self.mode, nonce=nonce) + if aad: + cipher.update(aad) return cipher.decrypt_and_verify(ciphertext, tag).decode() @staticmethod @@ -84,8 +102,10 @@ def decode_from_json_string(json_string: str) -> dict: json_data = json.loads(json_string) decoded_data = {} - # Decode each Base64-encoded value back to bytes + # Decode known ciphertext fields; ignore non-crypto flags like bound:true. for key, value in json_data.items(): + if key not in {"nonce", "ciphertext", "tag"}: + continue if isinstance(value, str): decoded_data[key] = base64.b64decode(value) else: @@ -93,6 +113,8 @@ def decode_from_json_string(json_string: str) -> dict: f"Unsupported value type for key '{key}': {type(value)}. " + f"Expected 'str' for Base64 decoding." ) + if not {"nonce", "ciphertext", "tag"}.issubset(decoded_data): + raise ValueError("Encrypted payload missing nonce/ciphertext/tag") return decoded_data @staticmethod diff --git a/ClipCascade_Desktop/src/utils/device_trust.py b/ClipCascade_Desktop/src/utils/device_trust.py new file mode 100644 index 000000000..62f49ead7 --- /dev/null +++ b/ClipCascade_Desktop/src/utils/device_trust.py @@ -0,0 +1,201 @@ +"""P2P device identity, signed signaling, and explicit trust/pairing.""" + +from __future__ import annotations + +import base64 +import json +import logging +import os +import time +import uuid +from typing import Any, Optional + +from Crypto.Hash import SHA256 +from Crypto.PublicKey import ECC +from Crypto.Signature import eddsa + +TRUST_FILE_NAME = "trusted_peers.json" +SIGNING_CURVE = "Ed25519" + + +class DeviceIdentity: + def __init__(self, device_id: str, private_key: ECC.EccKey, public_key: ECC.EccKey): + self.device_id = device_id + self.private_key = private_key + self.public_key = public_key + + @property + def public_pem(self) -> str: + return self.public_key.export_key(format="PEM") + + @property + def fingerprint(self) -> str: + digest = SHA256.new(self.public_pem.encode("utf-8")).digest() + return digest[:8].hex() + + def sign(self, message: bytes) -> str: + signer = eddsa.new(self.private_key, mode="rfc8032") + return base64.b64encode(signer.sign(message)).decode("ascii") + + def verify(self, message: bytes, signature_b64: str) -> bool: + try: + sig = base64.b64decode(signature_b64) + verifier = eddsa.new(self.public_key, mode="rfc8032") + verifier.verify(message, sig) + return True + except Exception: + return False + + +def _canonical_bytes(obj: dict) -> bytes: + return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def load_or_create_identity(keyring_backend, service: str, account: str) -> DeviceIdentity: + """Load Ed25519 identity from keyring or create a new one.""" + raw = None + if keyring_backend is not None: + try: + raw = keyring_backend.get_password(service, account) + except Exception as e: + # A read error is not the same as "no identity yet". Minting a new + # key here would hand every peer a new stranger on each launch, so + # TOFU could never converge and signed signalling would quietly + # degrade to nothing. + raise RuntimeError( + "Could not read the device identity from the OS keyring " + f"({e}). Refusing to generate a replacement, which would " + "invalidate this device's existing peer trust." + ) from e + + if raw: + try: + data = json.loads(raw) + private_key = ECC.import_key(data["private_pem"]) + public_key = ECC.import_key(data["public_pem"]) + return DeviceIdentity(data["device_id"], private_key, public_key) + except Exception as e: + logging.warning(f"Corrupt device identity, regenerating: {e}") + + private_key = ECC.generate(curve=SIGNING_CURVE) + public_key = private_key.public_key() + device_id = str(uuid.uuid4()) + identity = DeviceIdentity(device_id, private_key, public_key) + blob = json.dumps( + { + "device_id": device_id, + "private_pem": private_key.export_key(format="PEM"), + "public_pem": public_key.export_key(format="PEM"), + } + ) + if keyring_backend is not None: + try: + keyring_backend.set_password(service, account, blob) + except Exception as e: + logging.warning(f"Could not persist device identity: {e}") + return identity + + +def sign_signaling(identity: DeviceIdentity, message: dict) -> dict: + """Attach deviceId, ts, and signature to a signaling message.""" + ts = int(time.time() * 1000) + out = dict(message) + out["deviceId"] = identity.device_id + out["ts"] = ts + to_sign = {k: v for k, v in out.items() if k != "sig"} + out["sig"] = identity.sign(_canonical_bytes(to_sign)) + return out + + +def verify_signaling( + message: dict, public_pem: str, max_skew_ms: int = 10 * 60 * 1000 +) -> bool: + try: + device_id = message.get("deviceId") + ts = message.get("ts") + sig = message.get("sig") + if not device_id or not isinstance(ts, int) or not sig: + return False + now = int(time.time() * 1000) + if abs(now - ts) > max_skew_ms: + return False + to_sign = {k: v for k, v in message.items() if k != "sig"} + public_key = ECC.import_key(public_pem) + verifier = eddsa.new(public_key, mode="rfc8032") + verifier.verify(_canonical_bytes(to_sign), base64.b64decode(sig)) + return True + except Exception: + return False + + +class PeerTrustStore: + """Persists trusted peer public keys (non-secret) next to the DATA file.""" + + def __init__(self, path: str): + self.path = path + self._peers: dict[str, dict] = {} + self.load() + + def load(self): + if os.path.isfile(self.path): + try: + with open(self.path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + self._peers = data.get("peers", {}) + except Exception as e: + logging.warning(f"Failed to load trust store: {e}") + self._peers = {} + + def save(self): + try: + with open(self.path, "w", encoding="utf-8") as f: + json.dump({"peers": self._peers}, f, indent=2) + try: + os.chmod(self.path, 0o600) + except Exception: + pass + except Exception as e: + logging.warning(f"Failed to save trust store: {e}") + + def is_trusted(self, device_id: str) -> bool: + return device_id in self._peers + + def get_public_pem(self, device_id: str) -> Optional[str]: + peer = self._peers.get(device_id) + return peer.get("public_pem") if peer else None + + def trust(self, device_id: str, public_pem: str, label: str = ""): + self._peers[device_id] = { + "public_pem": public_pem, + "label": label, + "trusted_at": int(time.time()), + } + self.save() + + def untrust(self, device_id: str): + if device_id in self._peers: + del self._peers[device_id] + self.save() + + def list_peers(self) -> dict[str, dict]: + return dict(self._peers) + + def accept_announce( + self, device_id: str, public_pem: str, auto_trust: bool = False + ) -> bool: + """ + Record an announce. Returns True if peer is trusted (or auto-trusted). + """ + if self.is_trusted(device_id): + # Update key if it matches or replace on first-seen key rotation policy: keep existing. + return True + if auto_trust: + self.trust(device_id, public_pem, label="auto") + return True + return False + + +def trust_store_path_for_data_file(data_file: str) -> str: + directory = os.path.dirname(os.path.abspath(data_file)) or "." + return os.path.join(directory, TRUST_FILE_NAME) diff --git a/ClipCascade_Desktop/src/utils/protocol_v2.py b/ClipCascade_Desktop/src/utils/protocol_v2.py new file mode 100644 index 000000000..ddecc8f3a --- /dev/null +++ b/ClipCascade_Desktop/src/utils/protocol_v2.py @@ -0,0 +1,400 @@ +"""E2E clipboard protocol v2: device ID, counters, bound metadata, replay cache. + +Wire format (cipher on) is shared with mobile: + outer envelope: {v, type, senderDeviceId, counter, ts, payload} + payload (JSON string): {nonce, ciphertext, tag, bound: true} + ciphertext plaintext: {t, d, c, ts, p} # binds outer metadata + +AES-GCM AAD is not used for cross-client compatibility (RN AES-GCM has no AAD). +""" + +from __future__ import annotations + +import base64 +import json +import time +import uuid +from collections import OrderedDict +from typing import Any, Optional + +PROTOCOL_VERSION = 2 +REPLAY_CACHE_MAX = 2048 +MAX_TRACKED_SENDERS = 64 +MAX_CLOCK_SKEW_MS = 10 * 60 * 1000 # 10 minutes +MIN_COUNTER = 1 +# Mirrors JavaScript's Number.MAX_SAFE_INTEGER. Python ints are unbounded, so +# without this the two implementations disagree on huge counters, and one +# absurd value would pin a sender's window (every later counter then fails the +# "counter <= last - max" check) and wedge that device until restart. +MAX_COUNTER = 2**53 - 1 + + +class ReplayCache: + """ + Rejects duplicate (sender_device_id, counter) pairs within a bounded window. + + The window is kept per sender: a shared window lets one sender's traffic + evict another's history, which would let a replayed message back in. Both + the per-sender window and the number of tracked senders are bounded so a + flood of unique device IDs cannot grow memory without limit. + + Callers must only admit senders whose message has already been + authenticated (see unwrap_inbound); otherwise an unauthenticated peer can + both evict real entries and pin a victim's counter. + """ + + def __init__( + self, + max_entries: int = REPLAY_CACHE_MAX, + max_senders: int = MAX_TRACKED_SENDERS, + ): + self._max = max_entries + self._max_senders = max_senders + # sender_device_id -> {"seen": OrderedDict[int, None], "last": int | None} + self._senders: OrderedDict[str, dict] = OrderedDict() + + def accept(self, sender_device_id: str, counter: int) -> bool: + # bool is a subclass of int; reject it explicitly. + if ( + not sender_device_id + or isinstance(counter, bool) + or not isinstance(counter, int) + or counter < MIN_COUNTER + or counter > MAX_COUNTER + ): + return False + + entry = self._senders.get(sender_device_id) + if entry is None: + entry = {"seen": OrderedDict(), "last": None} + self._senders[sender_device_id] = entry + while len(self._senders) > self._max_senders: + self._senders.popitem(last=False) + self._senders.move_to_end(sender_device_id) + + seen = entry["seen"] + if counter in seen: + return False + last = entry["last"] + if last is not None and counter <= last - self._max: + return False + + seen[counter] = None + while len(seen) > self._max: + seen.popitem(last=False) + if last is None or counter > last: + entry["last"] = counter + return True + + +def build_aad( + version: int, + payload_type: str, + sender_device_id: str, + counter: int, + ts_ms: int, +) -> bytes: + """Canonical metadata string (used for binding docs/tests; not GCM AAD on wire).""" + return f"{version}|{payload_type}|{sender_device_id}|{counter}|{ts_ms}".encode( + "utf-8" + ) + + +def ensure_device_id(config_data: dict) -> str: + device_id = config_data.get("device_id") + if not device_id or not isinstance(device_id, str): + device_id = str(uuid.uuid4()) + config_data["device_id"] = device_id + return device_id + + +def next_counter(config_data: dict) -> int: + counter = int(config_data.get("send_counter") or 0) + 1 + config_data["send_counter"] = counter + return counter + + +def wrap_outbound( + *, + payload: str, + payload_type: str, + config_data: dict, + cipher_manager=None, + cipher_enabled: bool = False, +) -> dict[str, Any]: + """Build a v2 transport envelope. Encrypts payload when cipher is enabled.""" + device_id = ensure_device_id(config_data) + counter = next_counter(config_data) + ts_ms = int(time.time() * 1000) + + wire_payload = payload + if cipher_enabled and cipher_manager is not None: + # Bind metadata inside ciphertext (compatible with mobile protocolV2.js). + inner = json.dumps( + { + "t": payload_type, + "d": device_id, + "c": counter, + "ts": ts_ms, + "p": payload, + }, + separators=(",", ":"), + ) + encrypted = cipher_manager.encrypt(inner) # no AAD β€” mobile interop + blob = { + "nonce": base64.b64encode(encrypted["nonce"]).decode("utf-8"), + "ciphertext": base64.b64encode(encrypted["ciphertext"]).decode("utf-8"), + "tag": base64.b64encode(encrypted["tag"]).decode("utf-8"), + "bound": True, + } + wire_payload = json.dumps(blob, separators=(",", ":")) + + return { + "v": PROTOCOL_VERSION, + "type": payload_type, + "senderDeviceId": device_id, + "counter": counter, + "ts": ts_ms, + "payload": wire_payload, + } + + +def _as_wire_int(value): + """ + Interpret a JSON number as an integer the way JavaScript must, or None. + + json.loads keeps 5 and 5.0 apart; JSON.parse cannot β€” both become the same + JS number, so mobile has no way to reject the second. Rather than let the + two runtimes accept different envelopes off the same wire, treat a + whole-valued float as the integer it denotes, and reject anything with a + fractional part on both sides. bool is excluded because it is a subclass of + int in Python. + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return None + + +def build_transport_frame(envelope: dict, *, payload_type: str) -> dict: + """ + Wrap a v2 envelope in the outer frame the server relays. + + The server models a clipboard message as {payload, type, metadata} and + rebuilds the relayed copy from exactly those three fields, so any other + top-level key is dropped in transit. Serialising the envelope into `payload` + keeps it intact through every server, including versions that predate + protocol v2 β€” which is what most self-hosters are running. + + This is also what the P2P transport has always done, so both transports now + put the same bytes on the wire. + + Only the fields the server models are sent. It does not merely ignore extra + top-level keys β€” Jackson raises UnrecognizedPropertyException and the whole + message is dropped before the handler ever runs, which is why the flat + envelope did not just arrive looking like v1, it never arrived at all. + """ + return { + "payload": json.dumps(envelope, separators=(",", ":")), + "type": payload_type, + } + + +def extract_transport_envelope(body: dict) -> dict: + """ + Recover the v2 envelope from a relayed frame. + + Accepts the frame produced by build_transport_frame, and also a flat + envelope, so the client keeps working if a server ever starts carrying the + envelope fields itself. + + The outer frame's `type` and `metadata` are attacker- or relay-controlled + and are deliberately NOT returned: only the envelope reaches unwrap_inbound, + and only the type bound inside the ciphertext is authoritative. + + Raises ValueError (never JSONDecodeError) so callers have one thing to + catch. + """ + if not isinstance(body, dict): + raise ValueError("Invalid clipboard frame") + + # Already flat: either a future server that carries the envelope, or a + # caller handing us an envelope directly. + if body.get("v") == PROTOCOL_VERSION and "senderDeviceId" in body: + return body + + payload = body.get("payload") + if not isinstance(payload, str): + raise ValueError( + "Clipboard frame has no string payload to unwrap " + f"(keys: {sorted(body)})" + ) + + try: + envelope = json.loads(payload) + except json.JSONDecodeError as e: + # A bare legacy ciphertext blob also lands here when it is not JSON. + raise ValueError(f"Clipboard frame payload is not a v2 envelope: {e}") from e + + if not isinstance(envelope, dict) or "payload" not in envelope: + raise ValueError("Clipboard frame payload is not a v2 envelope") + + return envelope + + +def unwrap_inbound( + body: dict, + *, + cipher_manager=None, + cipher_enabled: bool = False, + replay_cache: Optional[ReplayCache] = None, + local_device_id: Optional[str] = None, + allow_legacy_v1: bool = False, +) -> tuple[str, str]: + """ + Parse inbound message (v1 or v2). Returns (payload, payload_type). + + Raises ValueError on replay, bad binding, or malformed envelope. + + v1 carries no counter, timestamp, or metadata binding, so accepting it + lets anyone who can inject into the transport strip the "v" field and + bypass every v2 protection. v1 is therefore refused unless the caller + opts in via allow_legacy_v1 (for mixed fleets still running < 3.2.0). + """ + if not isinstance(body, dict) or "payload" not in body: + raise ValueError("Invalid clipboard message") + + version = body.get("v", 1) + payload_type = body.get("type", "text") + payload = body["payload"] + + # Legacy v1: {"payload": ..., "type": ...} + if version == 1 or version is None: + if not allow_legacy_v1: + raise ValueError( + "Rejected legacy v1 message (no replay or metadata binding). " + "Upgrade all devices to 3.2.0+, or set allow_legacy_v1." + ) + if cipher_enabled and cipher_manager is not None: + from utils.cipher_manager import CipherManager + + if isinstance(payload, str): + payload = cipher_manager.decrypt( + **CipherManager.decode_from_json_string(payload) + ) + return payload, payload_type + + if version != PROTOCOL_VERSION: + raise ValueError(f"Unsupported protocol version: {version}") + + sender = body.get("senderDeviceId") + counter = body.get("counter") + ts_ms = body.get("ts") + if not isinstance(sender, str) or not sender: + raise ValueError("Missing senderDeviceId") + counter = _as_wire_int(counter) + if counter is None or counter < MIN_COUNTER or counter > MAX_COUNTER: + raise ValueError("Invalid counter") + ts_ms = _as_wire_int(ts_ms) + if ts_ms is None: + raise ValueError("Invalid timestamp") + + now = int(time.time() * 1000) + if abs(now - ts_ms) > MAX_CLOCK_SKEW_MS: + raise ValueError("Message timestamp outside allowed skew") + + if local_device_id and sender == local_device_id: + raise ValueError("Ignoring self-originated message") + + if cipher_enabled and cipher_manager is None: + # Fail closed. Returning the payload here would hand back ciphertext as + # if it were plaintext, with no decrypt, no binding check and no + # integrity at all β€” the exact opposite of what cipher_enabled asks for. + raise ValueError("Encryption is enabled but no cipher is configured") + + if cipher_enabled and cipher_manager is not None: + if not isinstance(payload, str): + raise ValueError("Encrypted payload must be a string") + try: + blob = json.loads(payload) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid encrypted payload JSON: {e}") from e + + nonce = blob.get("nonce") + ciphertext = blob.get("ciphertext") + tag = blob.get("tag") + if not all(isinstance(x, str) for x in (nonce, ciphertext, tag)): + raise ValueError("Encrypted payload missing base64 fields") + + plain = cipher_manager.decrypt( + nonce=base64.b64decode(nonce), + ciphertext=base64.b64decode(ciphertext), + tag=base64.b64decode(tag), + ) + + # "bound" travels outside the AEAD, so it is attacker-mutable. Treat + # anything other than a bound envelope as legacy and refuse it by + # default: honouring bound=false would let a tamperer skip the + # metadata check and rewrite sender/counter/ts at will. + if blob.get("bound") is True or blob.get("bound") == "true": + try: + inner = json.loads(plain) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid bound inner JSON: {e}") from e + if ( + inner.get("t") != payload_type + or inner.get("d") != sender + or inner.get("c") != counter + or inner.get("ts") != ts_ms + ): + raise ValueError("Bound metadata mismatch (possible tampering)") + payload = inner.get("p") + if not isinstance(payload, str): + raise ValueError("Bound payload missing") + elif allow_legacy_v1: + # Legacy un-bound ciphertext (pre-interop desktop builds). + payload = plain + else: + raise ValueError( + "Rejected un-bound v2 ciphertext (metadata is not authenticated). " + "Upgrade all devices to 3.2.0+, or set allow_legacy_v1." + ) + + # Admitted last: only a message that already authenticated under our key + # may touch replay state. Doing this earlier lets an unauthenticated peer + # evict real entries or pin a victim's counter to stall its traffic. + if replay_cache is not None and not replay_cache.accept(sender, counter): + raise ValueError("Replay or stale counter rejected") + + return payload, payload_type + + +def decrypt_legacy_blob( + payload: str, + *, + cipher_manager, + allow_legacy_v1: bool = False, +) -> str: + """ + Decrypt a pre-3.2.0 bare {nonce, ciphertext, tag} blob. + + These carry no version, counter, timestamp, or metadata binding, so they + bypass every v2 protection. They are refused under the same opt-in as v1. + Routing every legacy decrypt through here keeps that decision in one place: + reachable transports must not grow their own private fallback. + """ + if not allow_legacy_v1: + raise ValueError( + "Rejected legacy un-versioned ciphertext (no replay or metadata " + "binding). Upgrade all devices to 3.2.0+, or set allow_legacy_v1." + ) + from utils.cipher_manager import CipherManager + + return cipher_manager.decrypt(**CipherManager.decode_from_json_string(payload)) + + +def is_v2_message(body: dict) -> bool: + return isinstance(body, dict) and body.get("v") == PROTOCOL_VERSION diff --git a/ClipCascade_Desktop/src/utils/request_manager.py b/ClipCascade_Desktop/src/utils/request_manager.py index 6918974e1..ad12d4d2d 100644 --- a/ClipCascade_Desktop/src/utils/request_manager.py +++ b/ClipCascade_Desktop/src/utils/request_manager.py @@ -8,6 +8,8 @@ class RequestManager: + DEFAULT_TIMEOUT_SECONDS = (5, 15) + def __init__(self, config: Config): self.config = config @@ -23,12 +25,14 @@ def format_cookie(cookie: dict) -> str: def login(self) -> tuple[bool, str, dict]: try: + Config.validate_server_url(self.config.data["server_url"]) session = requests.Session() # Fetch the login page to get the CSRF token response = session.get( self.config.data["server_url"] + LOGIN_URL, verify=self._verify(), + timeout=self.DEFAULT_TIMEOUT_SECONDS, ) if response.status_code != 200: @@ -49,6 +53,7 @@ def login(self) -> tuple[bool, str, dict]: self.config.data["server_url"] + LOGIN_URL, data=form_data, verify=self._verify(), + timeout=self.DEFAULT_TIMEOUT_SECONDS, ) if ( response.status_code == 200 @@ -128,9 +133,6 @@ def get_metadata(self) -> dict: try: response = RequestManager.get( url=METADATA_URL, - headers={ - "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) - }, verify=True, ) if response.status_code == 200: @@ -178,7 +180,16 @@ def get(url: str, headers: dict = None, verify=True) -> requests.Response: A generic GET mapper for handling GET requests. """ try: - response = requests.get(url, headers=headers, verify=verify) + response = requests.get( + url, + headers=headers, + verify=verify, + timeout=RequestManager.DEFAULT_TIMEOUT_SECONDS, + # The private/mesh HTTP policy is checked against the configured + # server URL, not against wherever a redirect points. Following + # one would silently carry the session to an unchecked host. + allow_redirects=False, + ) response.raise_for_status() # Will raise an HTTPError if the HTTP request returned an unsuccessful status code return response except Exception as e: @@ -193,7 +204,14 @@ def post( A generic POST mapper for handling POST requests. """ try: - response = requests.post(url, data=data, headers=headers, verify=verify) + response = requests.post( + url, + data=data, + headers=headers, + verify=verify, + timeout=RequestManager.DEFAULT_TIMEOUT_SECONDS, + allow_redirects=False, + ) response.raise_for_status() # Will raise an HTTPError if the HTTP request returned an unsuccessful status code return response except Exception as e: diff --git a/ClipCascade_Desktop/src/utils/stream_transfer.py b/ClipCascade_Desktop/src/utils/stream_transfer.py new file mode 100644 index 000000000..918e0f4c6 --- /dev/null +++ b/ClipCascade_Desktop/src/utils/stream_transfer.py @@ -0,0 +1,127 @@ +"""Byte-safe chunked transfer with hard size caps before assembly.""" + +from __future__ import annotations + +import uuid +from typing import Iterator + + +def fragment_bytes(data: bytes, fragment_size: int) -> list[bytes]: + if fragment_size < 1: + raise ValueError("fragment_size must be >= 1") + return [data[i : i + fragment_size] for i in range(0, len(data), fragment_size)] + + +def fragment_utf8_string(s: str, fragment_size: int) -> list[str]: + """ + Split a UTF-8 string on byte boundaries without corrupting multi-byte sequences. + """ + raw = s.encode("utf-8") + chunks: list[str] = [] + i = 0 + n = len(raw) + while i < n: + end = min(i + fragment_size, n) + # If we land mid multi-byte sequence, back up to a lead byte boundary. + if end < n: + while end > i and (raw[end] & 0xC0) == 0x80: + end -= 1 + if end == i: + # Pathological: single codepoint larger than fragment_size. + end = min(i + fragment_size, n) + chunks.append(raw[i:end].decode("utf-8")) + i = end + return chunks if chunks else [""] + + +class StreamAssembler: + """Assembles ordered stream chunks with a hard total-size cap.""" + + def __init__(self, stream_id: str, total_size: int, max_size: int): + if total_size < 0: + raise ValueError("total_size must be >= 0") + if max_size < 1: + raise ValueError("max_size must be >= 1") + if total_size > max_size: + raise ValueError( + f"Stream total_size {total_size} exceeds max_size {max_size}" + ) + self.stream_id = stream_id + self.total_size = total_size + self.max_size = max_size + self._parts: dict[int, bytes] = {} + self._received = 0 + self.complete = False + + def add_chunk(self, offset: int, chunk: bytes) -> bool: + """ + Add a chunk at the given byte offset. + Returns True when the full stream is assembled. + """ + if self.complete: + return True + if offset < 0 or not isinstance(chunk, (bytes, bytearray)): + raise ValueError("Invalid chunk") + end = offset + len(chunk) + if end > self.total_size: + raise ValueError("Chunk exceeds declared total_size") + if self._received - len(self._parts.get(offset, b"")) + len(chunk) > self.max_size: + raise ValueError("Stream exceeds max_size") + prev = self._parts.get(offset) + if prev is not None: + self._received -= len(prev) + self._parts[offset] = bytes(chunk) + self._received += len(chunk) + if self._received == self.total_size and self._is_contiguous(): + self.complete = True + return True + return False + + def _is_contiguous(self) -> bool: + if not self._parts: + return self.total_size == 0 + cursor = 0 + for offset in sorted(self._parts): + if offset != cursor: + return False + cursor = offset + len(self._parts[offset]) + return cursor == self.total_size + + def assemble(self) -> bytes: + if not self.complete and self.total_size > 0: + raise ValueError("Stream incomplete") + return b"".join(self._parts[o] for o in sorted(self._parts)) + + def assemble_text(self) -> str: + return self.assemble().decode("utf-8") + + +def make_stream_id() -> str: + return str(uuid.uuid4()) + + +def iter_stream_chunks( + payload: str, fragment_size: int, max_size: int +) -> Iterator[tuple[dict, str]]: + """ + Yield (metadata, fragment) pairs for a string payload under a hard max_size. + """ + raw = payload.encode("utf-8") + total = len(raw) + if total > max_size: + raise ValueError(f"Payload {total} bytes exceeds max_size {max_size}") + stream_id = make_stream_id() + chunks = fragment_utf8_string(payload, fragment_size) + total_fragments = len(chunks) + for index, chunk in enumerate(chunks): + meta = { + "id": stream_id, + "stream": True, + "isFragmented": total_fragments > 1, + "index": index, + "totalFragments": total_fragments, + "combinedRawPayloadSizeInBytes": total, + "offset": sum(len(c.encode("utf-8")) for c in chunks[:index]), + "chunkBytes": len(chunk.encode("utf-8")), + } + yield meta, chunk diff --git a/ClipCascade_Mobile/src/App.js b/ClipCascade_Mobile/src/App.js index 72aec4ba9..a429ccbd8 100644 --- a/ClipCascade_Mobile/src/App.js +++ b/ClipCascade_Mobile/src/App.js @@ -31,8 +31,10 @@ import { getDataFromAsyncStorage, getMultipleDataFromAsyncStorage, clearAsyncStorage, + clearSecrets, } from './AsyncStorageManagement'; import StartForegroundService from './StartForegroundService'; +import {validateServerUrl} from './networkPolicy'; /* * These files are part of the ClipCascade project. @@ -260,7 +262,7 @@ export default function App() { await setDataInAsyncStorage('p2pStatusMessage', ''); //validate session setLoadingPageMessage('Verifying Session...'); - validResult = await validateSession(data_s); + const validResult = await validateSession(data_s); setEnableLoadingPage(false); if (validResult[0]) { //enable websocket page @@ -346,31 +348,21 @@ export default function App() { }; clearWSStatusMessage(); }; + // Initialization intentionally runs once on mount. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Function to convert a server URL to a WebSocket URL const convertToWebSocketUrl = async (inputUrl, endpoint) => { - if (!inputUrl || typeof inputUrl !== 'string') { - throw new Error('Invalid URL provided'); - } - - inputUrl = inputUrl.trim().replace(/\/+$/, '').toLowerCase(); // Remove trailing slashes and convert to lowercase - - let wsUrl; - - if (inputUrl.startsWith('https://')) { - wsUrl = inputUrl.replace('https://', 'wss://'); - } else if (inputUrl.startsWith('http://')) { - wsUrl = inputUrl.replace('http://', 'ws://'); - } else { - throw new Error(`Unsupported protocol in URL: ${inputUrl}`); - } - + // Preserve MagicDNS / hostname case; only normalize scheme via URL parse. + const cleaned = validateServerUrl(inputUrl); + const parsed = new URL(cleaned); + const scheme = parsed.protocol === 'https:' ? 'wss:' : 'ws:'; + let wsUrl = `${scheme}//${parsed.host}`; if (endpoint != null) { wsUrl += endpoint; wsUrl = wsUrl.replace(/\/+$/, ''); } - return wsUrl; }; @@ -410,6 +402,14 @@ export default function App() { // Function to validate session const validateSession = async data_s => { try { + // Re-check the stored URL before contacting it. The cleartext policy is + // applied when a server is saved at login, but a cold start restores the + // URL straight from storage β€” so a value written by an older build (or + // tampered with on a rooted device) would otherwise reach the network + // without ever passing the gate. Android permits cleartext app-wide, so + // this check is the only thing standing in front of it. + validateServerUrl(data_s.server_url); + const response = await fetchTimeout(data_s.server_url + VALIDATE_URL, { method: 'GET', }); @@ -573,7 +573,7 @@ export default function App() { // Hash the password for encryption if (data_s.cipher_enabled === 'true') { - hashResult = await hash(data_s, password); + const hashResult = await hash(data_s, password); data_s = hashResult[2]; if (!hashResult[0]) { return [ @@ -613,46 +613,69 @@ export default function App() { } }; - // Logout + // Logout β€” fail-closed: only leave the session UI after secrets are wiped. const logout = async () => { + let secretsCleared = false; try { setWsPageMessage('βŒ› Please wait...'); - await setDataInAsyncStorage('password', ''); + const csrf = await getDataFromAsyncStorage('csrf_token'); + if (wsIsRunning === 'true') { await setDataInAsyncStorage('wsIsRunning', 'false'); setWsIsRunning('false'); } - const formData = new URLSearchParams(); - formData.append('_csrf', await getDataFromAsyncStorage('csrf_token')); + try { + const formData = new URLSearchParams(); + formData.append('_csrf', csrf || ''); - const response = await fetchTimeout(data.server_url + LOGOUT_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: formData.toString(), - }); + const response = await fetchTimeout(data.server_url + LOGOUT_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), + }); - if (response.status == 204) { - setWsPageMessage('βœ… Logout successful: ' + response.status); - } else { - setWsPageMessage('❌ Logout failed: ' + response.status); + if (response.status == 204) { + setWsPageMessage('βœ… Logout successful: ' + response.status); + } else { + setWsPageMessage('❌ Logout failed: ' + response.status); + } + } catch (error) { + if (error.name === 'AbortError') { + setWsPageMessage('❌ Error: Request timed out'); + } else { + setWsPageMessage('❌ Error: ' + error); + } + } + + await clearSecrets(); + secretsCleared = true; + } catch (error) { + setWsPageMessage( + '❌ Logout incomplete: could not wipe local secrets. ' + error, + ); + } finally { + if (!secretsCleared) { + try { + await clearSecrets(); + secretsCleared = true; + } catch (error) { + // Fail-closed: keep session UI so the user retries wipe/logout. + setWsPageMessage( + '❌ Logout blocked: secrets still present. Retry logout. ' + error, + ); + return; + } } - await setDataInAsyncStorage('csrf_token', ''); + await setDataInAsyncStorage('wsIsRunning', 'false'); + setWsIsRunning('false'); setEnableWSPage(false); setEnableLoginPage(true); setLoginStatusMessage(''); - - // clear cookies if any NativeBridgeModule.clearCookies(); - } catch (error) { - if (error.name === 'AbortError') { - setWsPageMessage('❌ Error: Request timed out'); - } else { - setWsPageMessage('❌ Error: ' + error); - } } }; @@ -706,7 +729,7 @@ export default function App() { setWsPageMessage(''); setWsPageP2PMessage(''); await clearFiles(); - wsIsRunning_s = wsIsRunning === 'true' ? 'false' : 'true'; // toggle + const wsIsRunning_s = wsIsRunning === 'true' ? 'false' : 'true'; // toggle await setDataInAsyncStorage('wsForegroundServiceTerminated', 'false'); await setDataInAsyncStorage('wsIsRunning', wsIsRunning_s); if (wsIsRunning_s === 'true') { @@ -795,8 +818,8 @@ export default function App() { data_s = { ...data }; } - // remove trailing slashes in server_url - data_s.server_url = data_s.server_url.replace(/\/+$/, ''); + // normalize + enforce private/mesh HTTP policy (Tailscale / LAN) + data_s.server_url = validateServerUrl(data_s.server_url); let iteration = 0; let loginResult; @@ -1039,8 +1062,7 @@ export default function App() { - Run on system startup (disable if the READ_LOGS permission is - granted): + Run on system startup: {wsPageP2PMessage} )} + {/* Explicit capture (no READ_LOGS/overlay) */} + { + try { + const {ClipboardListener} = NativeModules; + if (ClipboardListener?.captureNow) { + ClipboardListener.captureNow(); + } + } catch (e) { + setWsPageMessage('❌ Capture failed: ' + e); + } + }} + > + + πŸ“‹ Share clipboard now + + {/* File download button */} {enableFilesDownloadButton && enableFilesDownloadButton === true && ( @@ -1229,8 +1269,9 @@ export default function App() { { marginTop: 5, fontSize: 15, fontStyle: 'italic' }, ]} > - There's also a workaround to enable clipboard sharing in the - background. Scroll down for setup instructions. + Prefer Share sheet, Quick Settings tile, notification action, + or the in-app "Share clipboard now" button. Background + auto-capture is limited by Android privacy rules. @@ -1284,7 +1325,7 @@ export default function App() { - {/* ADB Commands Section */} + {/* Explicit capture (no READ_LOGS / overlay) */} - Automatic Clipboard Monitoring Setup: + Share clipboard explicitly: - On rooted/non-rooted devices, to enable automatic clipboard - monitoring you need to execute these 3 ADB commands: + ClipCascade no longer uses READ_LOGS or draw-over-apps + overlay capture. Use one of: - 1. Enable the READ_LOGS permission: - - - {`> adb -d shell pm grant com.clipcascade android.permission.READ_LOGS`} + 1. Android Share sheet β†’ ClipCascade (text, images, files) - - 2. Allow "Drawing over other apps", also accessible from - Settings: + 2. Quick Settings tile "ClipCascade" β†’ Share clipboard now - - {`> adb -d shell appops set com.clipcascade SYSTEM_ALERT_WINDOW allow`} - - - 3. Kill the app for the new permissions to take effect: + 3. Foreground notification action while sync is running - - {`> adb -d shell am force-stop com.clipcascade`} + + 4. In-app button when the session is connected diff --git a/ClipCascade_Mobile/src/AsyncStorageManagement.js b/ClipCascade_Mobile/src/AsyncStorageManagement.js index 539cbe3d4..b4888d604 100644 --- a/ClipCascade_Mobile/src/AsyncStorageManagement.js +++ b/ClipCascade_Mobile/src/AsyncStorageManagement.js @@ -1,17 +1,134 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; // persistent storage +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; -// Save data in async storage +/** + * Secrets must never remain in AsyncStorage (plaintext SQLite). + * They are stored in Android Keystore / iOS Keychain via react-native-keychain. + */ +export const SECRET_KEYS = new Set([ + 'password', + 'hashed_password', + 'csrf_token', +]); + +const KEYCHAIN_SERVICE_PREFIX = 'ClipCascade'; + +const secretService = key => `${KEYCHAIN_SERVICE_PREFIX}/${key}`; + +const isEmptySecret = value => + value === null || + value === undefined || + value === '' || + value === 'null' || + value === 'undefined'; + +/** + * Persist a secret in the platform keychain and scrub any legacy AsyncStorage copy. + */ +export const setSecret = async (key, value) => { + if (!SECRET_KEYS.has(key)) { + throw new Error(`setSecret called for non-secret key: ${key}`); + } + + if (isEmptySecret(value)) { + await Keychain.resetGenericPassword({service: secretService(key)}); + await AsyncStorage.removeItem(key); + return; + } + + const serialized = + typeof value === 'string' ? value : JSON.stringify(value); + + await Keychain.setGenericPassword(key, serialized, { + service: secretService(key), + accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }); + // Scrub any pre-migration plaintext copy. + await AsyncStorage.removeItem(key); +}; + +/** + * Read a secret from keychain; migrate legacy AsyncStorage values on first read. + */ +export const getSecret = async key => { + if (!SECRET_KEYS.has(key)) { + throw new Error(`getSecret called for non-secret key: ${key}`); + } + + try { + const credentials = await Keychain.getGenericPassword({ + service: secretService(key), + }); + if (credentials && credentials.password != null) { + return credentials.password; + } + } catch (e) { + // Fall through to legacy AsyncStorage migration path. + } + + // Legacy migration: secret still in AsyncStorage. + const raw = await AsyncStorage.getItem(key); + if (raw == null) { + return null; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + parsed = raw; + } + if (!isEmptySecret(parsed)) { + await setSecret(key, parsed); + } else { + await AsyncStorage.removeItem(key); + } + return isEmptySecret(parsed) ? null : parsed; +}; + +/** + * Clear all known secrets from keychain + AsyncStorage. + * Throws if any secret remains readable (fail-closed for logout). + */ +export const clearSecrets = async () => { + const failures = []; + for (const key of SECRET_KEYS) { + try { + await Keychain.resetGenericPassword({service: secretService(key)}); + await AsyncStorage.removeItem(key); + const remaining = await getSecret(key); + if (!isEmptySecret(remaining)) { + failures.push(key); + } + } catch (e) { + failures.push(key); + } + } + if (failures.length > 0) { + throw new Error( + `Failed to clear secrets (fail-closed): ${failures.join(', ')}`, + ); + } +}; + +// Save data in async storage (secrets route to keychain) export const setDataInAsyncStorage = async (key, value) => { try { + if (SECRET_KEYS.has(key)) { + await setSecret(key, value); + return; + } await AsyncStorage.setItem(key, JSON.stringify(value)); } catch (e) { throw e; } }; -// Retrieve data from async storage +// Retrieve data from async storage (secrets from keychain) export const getDataFromAsyncStorage = async key => { try { + if (SECRET_KEYS.has(key)) { + return await getSecret(key); + } const value = await AsyncStorage.getItem(key); return value === null ? null : JSON.parse(value); } catch (e) { @@ -21,25 +138,37 @@ export const getDataFromAsyncStorage = async key => { /** * Fetch multiple keys from AsyncStorage in one round-trip. + * Secret keys are resolved individually via keychain. * @param {string[]} keys * @returns {Promise>} an object mapping each key β†’ its parsed value (or null) */ export const getMultipleDataFromAsyncStorage = async keys => { try { - const stores = await AsyncStorage.multiGet(keys); const result = {}; - stores.forEach(([key, raw]) => { - result[key] = raw != null ? JSON.parse(raw) : null; - }); + const plainKeys = []; + for (const key of keys) { + if (SECRET_KEYS.has(key)) { + result[key] = await getSecret(key); + } else { + plainKeys.push(key); + } + } + if (plainKeys.length > 0) { + const stores = await AsyncStorage.multiGet(plainKeys); + stores.forEach(([key, raw]) => { + result[key] = raw != null ? JSON.parse(raw) : null; + }); + } return result; } catch (e) { throw e; } }; -// Clear all data from async storage +// Clear all data from async storage (non-secrets) and wipe secrets from keychain export const clearAsyncStorage = async () => { try { + await clearSecrets(); await AsyncStorage.clear(); } catch (e) { throw e; diff --git a/ClipCascade_Mobile/src/StartForegroundService.js b/ClipCascade_Mobile/src/StartForegroundService.js index c26c2dd6f..b6a433b53 100644 --- a/ClipCascade_Mobile/src/StartForegroundService.js +++ b/ClipCascade_Mobile/src/StartForegroundService.js @@ -5,11 +5,10 @@ import { Alert, } from 'react-native'; -import notifee, { AndroidImportance } from '@notifee/react-native'; +import notifee, { AndroidImportance, EventType } from '@notifee/react-native'; import { Client } from '@stomp/stompjs'; import * as encoding from 'text-encoding'; //do not remove this (polyfills for TextEncoder/TextDecoder stompjs) import { xxHash32 } from 'js-xxhash'; -import AesGcmCrypto from 'react-native-aes-gcm-crypto'; import { Buffer } from 'buffer'; import { RTCPeerConnection, @@ -24,12 +23,28 @@ import { getMultipleDataFromAsyncStorage, clearAsyncStorage, } from './AsyncStorageManagement'; +import { + ReplayCache, + wrapOutbound, + unwrapInbound, + ensureDeviceId, + buildTransportFrame, + extractTransportEnvelope, +} from './protocolV2'; + +// Pre-3.2.0 peers send a bare {nonce,ciphertext,tag} blob with no envelope, so +// none of the v2 checks apply to it. Mobile has no opt-in for legacy traffic +// (see HARDENING_NEXT_STEPS.md), so it is always refused. +const LEGACY_CIPHERTEXT_REJECTED = + 'Rejected legacy un-versioned ciphertext (no replay or metadata binding). ' + + 'Upgrade all devices to 3.2.0+.'; function cleanupClipboardListeners() { DeviceEventEmitter.removeAllListeners('SHARED_TEXT'); DeviceEventEmitter.removeAllListeners('SHARED_IMAGE'); DeviceEventEmitter.removeAllListeners('SHARED_FILES'); DeviceEventEmitter.removeAllListeners('onClipboardChange'); + DeviceEventEmitter.removeAllListeners('CAPTURE_CLIPBOARD_NOW'); } module.exports = async (inputData = null) => { @@ -45,8 +60,15 @@ module.exports = async (inputData = null) => { return new Promise(async () => { try { const { NativeBridgeModule } = NativeModules; - const textEncoder = new TextEncoder(); - const textDecoder = new TextDecoder(); + const textEncoder = new encoding.TextEncoder(); + const textDecoder = new encoding.TextDecoder(); + const replayCache = new ReplayCache(); + const protocolStore = { + device_id: (await getDataFromAsyncStorage('device_id')) || '', + send_counter: Number(await getDataFromAsyncStorage('send_counter')) || 0, + }; + ensureDeviceId(protocolStore); + await setDataInAsyncStorage('device_id', protocolStore.device_id); let previous_clipboard_content_hash = ''; let toggle = false; // p2s toggle @@ -93,39 +115,11 @@ module.exports = async (inputData = null) => { max_clipboard_size_local_limit_bytes = maxsize; } - // encrption - const encrypt = async plainText => { - try { - const encryptedData = await AesGcmCrypto.encrypt( - plainText, - false, - await getDataFromAsyncStorage('hashed_password'), - ); - return JSON.stringify({ - nonce: Buffer.from(encryptedData.iv, 'hex').toString('base64'), - ciphertext: encryptedData.content, - tag: Buffer.from(encryptedData.tag, 'hex').toString('base64'), - }); - } catch (e) { - throw new Error('Failed to encrypt: ' + e); - } - }; - - // decryption - const decrypt = async encryptedData => { - try { - const plainText = await AesGcmCrypto.decrypt( - encryptedData['ciphertext'], - await getDataFromAsyncStorage('hashed_password'), - Buffer.from(encryptedData['nonce'], 'base64').toString('hex'), - Buffer.from(encryptedData['tag'], 'base64').toString('hex'), - false, - ); - return plainText; - } catch (e) { - throw new Error('Failed to decrypt: ' + e); - } - }; + // Un-versioned encrypt/decrypt helpers used to live here. All crypto now + // goes through wrapOutbound/unwrapInbound in protocolV2.js, which binds + // the metadata into the ciphertext and enforces replay protection. + // Keeping a bare AES helper around invites a caller to reintroduce the + // legacy path that bypasses those checks. // hash clipboard content const hashCB = async (input, seed = 0) => { @@ -303,11 +297,33 @@ module.exports = async (inputData = null) => { } }); - //clipboard monitor + //clipboard monitor (primary-clip changes only; no READ_LOGS/overlay) const { ClipboardListener } = NativeModules; const clipboardListener = new NativeEventEmitter(ClipboardListener); // start clipboard listening ClipboardListener.startListening(); + // Explicit tile / notification capture + const triggerCaptureNow = async () => { + try { + if (ClipboardListener.captureNow) { + ClipboardListener.captureNow(); + } + } catch (e) { + await setDataInAsyncStorage( + 'wsStatusMessage', + '❌ Capture Error: ' + e, + ); + } + }; + DeviceEventEmitter.addListener('CAPTURE_CLIPBOARD_NOW', triggerCaptureNow); + notifee.onForegroundEvent(async ({type, detail}) => { + if ( + type === EventType.ACTION_PRESS && + detail?.pressAction?.id === 'capture_clipboard_now' + ) { + await triggerCaptureNow(); + } + }); // clipboard listener callback const clipboardOnChange = clipboardListener.addListener( 'onClipboardChange', @@ -399,18 +415,26 @@ module.exports = async (inputData = null) => { if (message && message.body) { const body = JSON.parse(message.body); - let cb = String(body.payload); - const type_ = body.type ?? 'text'; - - //decrypt - if (cipher_enabled === 'true') { - try { - cb = await decrypt(JSON.parse(cb)); - } catch (error) { - throw new Error( - `Encryption must be enabled on all devices if enabled. JSON parsing failed: ${error.message}`, - ); - } + let cb; + let type_; + try { + const unwrapped = await unwrapInbound({ + body: extractTransportEnvelope(body), + cipherEnabled: cipher_enabled === 'true', + hashedPassword: await getDataFromAsyncStorage( + 'hashed_password', + ), + replayCache, + localDeviceId: protocolStore.device_id, + // Explicit: never inherit this from anywhere. + allowLegacyV1: false, + }); + cb = unwrapped.payload; + type_ = unwrapped.type; + } catch (error) { + throw new Error( + `Inbound message rejected: ${error.message}`, + ); } // hash clipboard content @@ -526,7 +550,7 @@ module.exports = async (inputData = null) => { clipContent, ); } else if (type_ === 'files') { - temp = {}; + const temp = {}; const file_paths = clipContent .split(',') .filter(item => item.trim() !== ''); @@ -548,23 +572,33 @@ module.exports = async (inputData = null) => { } else { toggle = true; - if (cipher_enabled === 'true') { - //ecrypt - clipContent = await encrypt(clipContent); - } + const envelope = await wrapOutbound({ + payload: String(clipContent), + type: type_, + cipherEnabled: cipher_enabled === 'true', + hashedPassword: await getDataFromAsyncStorage( + 'hashed_password', + ), + store: protocolStore, + }); + await setDataInAsyncStorage( + 'send_counter', + String(protocolStore.send_counter), + ); await setDataInAsyncStorage( 'wsStatusMessage', 'βœ… Connected - Broadcasting', ); - // send + // The server relays only {payload, type, metadata}, so + // the envelope travels nested inside payload or it does + // not arrive at all. stompClient.publish({ destination: SEND_DESTINATION, - body: JSON.stringify({ - payload: String(clipContent), - type: type_, - }), + body: JSON.stringify( + buildTransportFrame(envelope, type_), + ), }); } } @@ -626,10 +660,53 @@ module.exports = async (inputData = null) => { // Fragment variables let sendingFragmentId = ''; - let receivingFragments = {}; // Map: fragmentId -> array of strings (ordered) + // Null-prototype: keys come straight off the wire, so a plain object + // would let a peer address Object.prototype (e.g. id "__proto__") and + // write properties every other object then inherits. + let receivingFragments = Object.create(null); // fragmentId -> ordered string[] let sendingFragmentStats = null; let receivingFragmentStats = null; + // Mirrors the desktop cap in p2p_manager.py. Without it, a peer sets + // totalFragments and we allocate an array of that size before any + // authentication has happened. + const MAX_RECEIVING_FRAGMENTS = 4096; + const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + /** + * Validate peer-supplied fragment metadata before it indexes anything + * or sizes an allocation. Desktop has had this since protocol v2 + * landed (_is_valid_fragment_metadata); mobile had no equivalent, so + * both the id and the index reached an object subscript unchecked. + */ + const isValidFragmentMetadata = (metadata, payload) => { + if (!metadata || typeof metadata !== 'object') { + return false; + } + if (typeof metadata.id !== 'string' || !UUID_RE.test(metadata.id)) { + return false; + } + if (typeof metadata.isFragmented !== 'boolean') { + return false; + } + const {index, totalFragments} = metadata; + if (!Number.isSafeInteger(index) || !Number.isSafeInteger(totalFragments)) { + return false; + } + if (totalFragments < 1 || totalFragments > MAX_RECEIVING_FRAGMENTS) { + return false; + } + if (index < 0 || index >= totalFragments) { + return false; + } + const rawSize = metadata.combinedRawPayloadSizeInBytes ?? 0; + if (!Number.isSafeInteger(rawSize) || rawSize < 0) { + return false; + } + return typeof payload === 'string' && payload.length <= FRAGMENT_SIZE * 2; + }; + getP2PStatusMessage = async () => { let msg = 'πŸ“Š'; msg += ` Peers: ${liveConnectionsCount}`; @@ -652,7 +729,7 @@ module.exports = async (inputData = null) => { }; const resetReceivingFragments = async () => { - receivingFragments = {}; + receivingFragments = Object.create(null); receivingFragmentStats = null; await resetP2PMsg(); }; @@ -871,7 +948,7 @@ module.exports = async (inputData = null) => { clipContent, ); } else if (type_ === 'files') { - temp = {}; + const temp = {}; const file_paths = clipContent .split(',') .filter(item => item.trim() !== ''); @@ -897,23 +974,35 @@ module.exports = async (inputData = null) => { const rawPayloadSizeInBytes = textEncoder.encode(clipContent).length; - if (cipher_enabled === 'true') { - //ecrypt - clipContent = await encrypt(clipContent); - } + const envelope = await wrapOutbound({ + payload: String(clipContent), + type: type_, + cipherEnabled: cipher_enabled === 'true', + hashedPassword: await getDataFromAsyncStorage( + 'hashed_password', + ), + store: protocolStore, + }); + await setDataInAsyncStorage( + 'send_counter', + String(protocolStore.send_counter), + ); + const wire = JSON.stringify(envelope); - // fragment payload + // fragment wire envelope const fragments = await fragmentString( - clipContent, + wire, FRAGMENT_SIZE, ); const metadata = { id: await generateUuid(), + stream: true, isFragmented: fragments.length > 1, index: 0, totalFragments: fragments.length, combinedRawPayloadSizeInBytes: rawPayloadSizeInBytes, + wireSizeInBytes: textEncoder.encode(wire).length, }; let loopBroken = false; @@ -929,7 +1018,8 @@ module.exports = async (inputData = null) => { const messageJson = JSON.stringify({ payload: fragment, type: type_, - metadata: metadata, + metadata: {...metadata}, + v: envelope.v, }); metadata.index += 1; @@ -1026,11 +1116,11 @@ module.exports = async (inputData = null) => { return; } - await clearFiles((expensiveCall = true)); + await clearFiles(true); await resetSendingFragmentId(); let cb = String(message.payload); - const type_ = message.type ?? 'text'; + let type_ = message.type ?? 'text'; const metadata = message.metadata; // Check if the payload exceeds the maximum size: first layer protection @@ -1046,6 +1136,15 @@ module.exports = async (inputData = null) => { return; } + // Reject peer-supplied metadata before it indexes anything or + // sizes an allocation. + if (metadata != null && !isValidFragmentMetadata(metadata, cb)) { + await resetReceivingFragments(); + p2pMsg = '⚠️ Rejected invalid P2P fragment metadata'; + await p2pStatusMessageChanged(); + return; + } + // Fragmented message handling if (metadata != null && metadata.isFragmented) { receivingFragmentStats = `${metadata.index + 1}/${ @@ -1053,7 +1152,7 @@ module.exports = async (inputData = null) => { }`; await p2pStatusMessageChanged(); - if (metadata.id in receivingFragments) { + if (Object.prototype.hasOwnProperty.call(receivingFragments, metadata.id)) { receivingFragments[metadata.id][metadata.index] = cb; // If this is the last fragment, try to combine @@ -1088,15 +1187,38 @@ module.exports = async (inputData = null) => { await clearFiles(); - // decrypt - if (cipher_enabled === 'true') { - try { - cb = await decrypt(JSON.parse(cb)); - } catch (error) { - throw new Error( - `Encryption must be enabled on all devices if enabled. JSON parsing failed: ${error.message}`, - ); + // Reassembled wire is a v2 envelope JSON (or legacy) + try { + if (typeof cb === 'string' && cb.startsWith('{')) { + const envelope = JSON.parse(cb); + if (envelope && envelope.payload !== undefined) { + const unwrapped = await unwrapInbound({ + body: envelope, + cipherEnabled: cipher_enabled === 'true', + hashedPassword: await getDataFromAsyncStorage( + 'hashed_password', + ), + replayCache, + localDeviceId: protocolStore.device_id, + // Explicit: never inherit this from anywhere. + allowLegacyV1: false, + }); + cb = unwrapped.payload; + type_ = unwrapped.type; + } else if (cipher_enabled === 'true') { + // Bare {nonce,ciphertext,tag} from a pre-3.2.0 peer. It has + // no envelope, so none of the v2 checks apply: no replay + // cache, no counter, no timestamp, no metadata binding. + // Accepting it would reopen everything v2 closes. + throw new Error(LEGACY_CIPHERTEXT_REJECTED); + } + } else if (cipher_enabled === 'true') { + throw new Error(LEGACY_CIPHERTEXT_REJECTED); } + } catch (error) { + throw new Error( + `Inbound P2P message rejected: ${error.message}`, + ); } // hash clipboard content @@ -1558,6 +1680,7 @@ module.exports = async (inputData = null) => { // Display a notification to start the foreground service await notifee.displayNotification({ title: 'ClipCascade', + body: 'Sync running β€” use action to share clipboard', android: { channelId, asForegroundService: true, @@ -1567,6 +1690,15 @@ module.exports = async (inputData = null) => { id: 'default', launchActivity: 'default', }, + actions: [ + { + title: 'Share clipboard now', + pressAction: { + id: 'capture_clipboard_now', + launchActivity: 'default', + }, + }, + ], }, }); diff --git a/ClipCascade_Mobile/src/__tests__/App.test.tsx b/ClipCascade_Mobile/src/__tests__/App.test.tsx index e532f701e..05c416554 100644 --- a/ClipCascade_Mobile/src/__tests__/App.test.tsx +++ b/ClipCascade_Mobile/src/__tests__/App.test.tsx @@ -7,7 +7,12 @@ import ReactTestRenderer from 'react-test-renderer'; import App from '../App'; test('renders correctly', async () => { + let renderer: ReactTestRenderer.ReactTestRenderer; await ReactTestRenderer.act(() => { - ReactTestRenderer.create(); + renderer = ReactTestRenderer.create(); + }); + await ReactTestRenderer.act(async () => { + renderer.unmount(); + await new Promise(resolve => setTimeout(resolve, 350)); }); }); diff --git a/ClipCascade_Mobile/src/__tests__/AsyncStorageSecrets.test.js b/ClipCascade_Mobile/src/__tests__/AsyncStorageSecrets.test.js new file mode 100644 index 000000000..5b548aa5e --- /dev/null +++ b/ClipCascade_Mobile/src/__tests__/AsyncStorageSecrets.test.js @@ -0,0 +1,90 @@ +/** + * @format + */ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; +import { + SECRET_KEYS, + setDataInAsyncStorage, + getDataFromAsyncStorage, + clearSecrets, + setSecret, + getSecret, +} from '../AsyncStorageManagement'; + +beforeEach(async () => { + await AsyncStorage.clear(); + Keychain.__store.clear(); + jest.clearAllMocks(); +}); + +describe('mobile secret storage', () => { + test('SECRET_KEYS covers password material', () => { + expect(SECRET_KEYS.has('password')).toBe(true); + expect(SECRET_KEYS.has('hashed_password')).toBe(true); + expect(SECRET_KEYS.has('csrf_token')).toBe(true); + }); + + test('secrets are stored in keychain, not AsyncStorage', async () => { + await setDataInAsyncStorage('password', 'sha3-auth-hash'); + await setDataInAsyncStorage('hashed_password', 'base64-aes-key'); + await setDataInAsyncStorage('csrf_token', 'csrf-value'); + await setDataInAsyncStorage('username', 'alice'); + + expect(await AsyncStorage.getItem('password')).toBeNull(); + expect(await AsyncStorage.getItem('hashed_password')).toBeNull(); + expect(await AsyncStorage.getItem('csrf_token')).toBeNull(); + expect(await AsyncStorage.getItem('username')).not.toBeNull(); + + expect(await getDataFromAsyncStorage('password')).toBe('sha3-auth-hash'); + expect(await getDataFromAsyncStorage('hashed_password')).toBe( + 'base64-aes-key', + ); + expect(await getDataFromAsyncStorage('csrf_token')).toBe('csrf-value'); + expect(await getDataFromAsyncStorage('username')).toBe('alice'); + }); + + test('migrates legacy AsyncStorage secrets into keychain', async () => { + await AsyncStorage.setItem('password', JSON.stringify('legacy-pass')); + await AsyncStorage.setItem( + 'hashed_password', + JSON.stringify('legacy-aes-key'), + ); + + expect(await getSecret('password')).toBe('legacy-pass'); + expect(await getSecret('hashed_password')).toBe('legacy-aes-key'); + + // Scrubbed from AsyncStorage after migration. + expect(await AsyncStorage.getItem('password')).toBeNull(); + expect(await AsyncStorage.getItem('hashed_password')).toBeNull(); + + // Still readable from keychain. + expect(await getDataFromAsyncStorage('password')).toBe('legacy-pass'); + expect(await getDataFromAsyncStorage('hashed_password')).toBe( + 'legacy-aes-key', + ); + }); + + test('clearSecrets wipes keychain and AsyncStorage copies', async () => { + await setSecret('password', 'p'); + await setSecret('hashed_password', 'h'); + await setSecret('csrf_token', 'c'); + await AsyncStorage.setItem('password', JSON.stringify('stale')); + + await clearSecrets(); + + expect(await getDataFromAsyncStorage('password')).toBeNull(); + expect(await getDataFromAsyncStorage('hashed_password')).toBeNull(); + expect(await getDataFromAsyncStorage('csrf_token')).toBeNull(); + expect(await AsyncStorage.getItem('password')).toBeNull(); + }); + + test('empty secret write removes keychain entry', async () => { + await setDataInAsyncStorage('csrf_token', 'token'); + expect(await getDataFromAsyncStorage('csrf_token')).toBe('token'); + + await setDataInAsyncStorage('csrf_token', ''); + expect(await getDataFromAsyncStorage('csrf_token')).toBeNull(); + }); +}); diff --git a/ClipCascade_Mobile/src/__tests__/networkPolicy.test.js b/ClipCascade_Mobile/src/__tests__/networkPolicy.test.js new file mode 100644 index 000000000..ee91b5591 --- /dev/null +++ b/ClipCascade_Mobile/src/__tests__/networkPolicy.test.js @@ -0,0 +1,113 @@ +/** + * @format + */ + +import {isPrivateOrMeshHost, validateServerUrl} from '../networkPolicy'; + +describe('networkPolicy (Tailscale / private mesh)', () => { + test('allows Tailscale CGNAT and MagicDNS', () => { + expect(isPrivateOrMeshHost('100.64.0.1')).toBe(true); + expect(isPrivateOrMeshHost('100.127.255.255')).toBe(true); + expect(isPrivateOrMeshHost('clipcascade.tail-abc.ts.net')).toBe(true); + }); + + test('allows LAN and loopback', () => { + expect(isPrivateOrMeshHost('192.168.1.5')).toBe(true); + expect(isPrivateOrMeshHost('10.0.0.2')).toBe(true); + expect(isPrivateOrMeshHost('172.16.0.1')).toBe(true); + expect(isPrivateOrMeshHost('localhost')).toBe(true); + expect(isPrivateOrMeshHost('127.0.0.1')).toBe(true); + }); + + test('rejects public hosts for mesh helper', () => { + expect(isPrivateOrMeshHost('example.com')).toBe(false); + expect(isPrivateOrMeshHost('8.8.8.8')).toBe(false); + expect(isPrivateOrMeshHost('100.63.255.255')).toBe(false); // outside CGNAT + expect(isPrivateOrMeshHost('100.128.0.1')).toBe(false); // outside CGNAT + }); + + test('rejects octets with a leading zero', () => { + // Number('064') is 64, but a WHATWG parser reads 064 as octal 52. Treating + // this as CGNAT would let a request to the public 100.52.0.1 through as + // cleartext. React Native's global.URL is a regex polyfill and does not + // normalise the host, so the check cannot lean on the URL parser. + expect(isPrivateOrMeshHost('100.064.0.1')).toBe(false); + expect(isPrivateOrMeshHost('010.0.0.1')).toBe(false); + expect(isPrivateOrMeshHost('192.168.01.1')).toBe(false); + expect(() => validateServerUrl('http://100.064.0.1:8080')).toThrow( + /insecure HTTP/, + ); + // Plain zero octets are still legitimate. + expect(isPrivateOrMeshHost('10.0.0.1')).toBe(true); + }); + + test('does not treat the ts.net apex itself as a mesh host', () => { + expect(isPrivateOrMeshHost('ts.net')).toBe(false); + expect(isPrivateOrMeshHost('evilts.net')).toBe(false); + expect(isPrivateOrMeshHost('box.ts.net.evil.com')).toBe(false); + }); + + test('validateServerUrl accepts private HTTP and public HTTPS', () => { + expect(validateServerUrl('http://100.64.1.2:8080')).toBe( + 'http://100.64.1.2:8080', + ); + expect(validateServerUrl('http://box.ts.net:8080/')).toBe( + 'http://box.ts.net:8080', + ); + expect(validateServerUrl('https://example.com')).toBe('https://example.com'); + }); + + test('validateServerUrl rejects public HTTP', () => { + expect(() => validateServerUrl('http://example.com')).toThrow(/insecure HTTP/); + }); + + // Jest runs on Node's WHATWG URL; the app runs on React Native's regex URL + // polyfill. They disagree, so a suite that only exercises Node validates a + // parser that never ships. Re-run the policy against the real one. + describe('under React Native\'s shipped URL polyfill', () => { + const NodeURL = global.URL; + let RNURL; + try { + // eslint-disable-next-line no-undef + RNURL = require('react-native/Libraries/Blob/URL').URL; + } catch { + RNURL = null; + } + + beforeAll(() => { + if (RNURL) { + global.URL = RNURL; + } + }); + afterAll(() => { + global.URL = NodeURL; + }); + + test('the polyfill is actually in use', () => { + if (!RNURL) { + return; // RN internals moved; the assertions below still run on Node. + } + expect(global.URL).toBe(RNURL); + }); + + test('accepts an uppercase scheme (regression: broke only on device)', () => { + expect(() => validateServerUrl('HTTPS://clip.example.com')).not.toThrow(); + expect(() => validateServerUrl('HTTP://192.168.1.5:8080')).not.toThrow(); + }); + + test('still allows the mesh and rejects public cleartext', () => { + expect(validateServerUrl('http://100.64.1.2:8080')).toBe( + 'http://100.64.1.2:8080', + ); + expect(() => validateServerUrl('http://example.com')).toThrow( + /insecure HTTP/, + ); + }); + + test('still rejects leading-zero octets', () => { + expect(() => validateServerUrl('http://100.064.0.1:8080')).toThrow( + /insecure HTTP/, + ); + }); + }); +}); diff --git a/ClipCascade_Mobile/src/__tests__/protocolV2.test.js b/ClipCascade_Mobile/src/__tests__/protocolV2.test.js new file mode 100644 index 000000000..7a9b8f4f5 --- /dev/null +++ b/ClipCascade_Mobile/src/__tests__/protocolV2.test.js @@ -0,0 +1,324 @@ +/** + * @format + */ + +import crypto from 'crypto'; +import {Buffer} from 'buffer'; +import { + MAX_TRACKED_SENDERS, + ReplayCache, + ensureDeviceId, + nextCounter, + wrapOutbound, + unwrapInbound, + buildTransportFrame, + extractTransportEnvelope, +} from '../protocolV2'; + +// Reproduces ClipCascadeController.sendPrivateMessage: the server rebuilds the +// relayed message from three getters, so every other top-level field is lost. +const relay = frame => ({ + payload: frame.payload, + type: frame.type || 'text', + metadata: frame.metadata, +}); + +const KEY = crypto.randomBytes(32).toString('base64'); + +const wrap = (payload, store) => + wrapOutbound({ + payload, + type: 'text', + cipherEnabled: true, + hashedPassword: KEY, + store: store ?? {device_id: 'sender', send_counter: 0}, + }); + +const unwrap = (body, extra) => + unwrapInbound({ + body, + cipherEnabled: true, + hashedPassword: KEY, + replayCache: new ReplayCache(), + ...extra, + }); + +// A v2 envelope whose ciphertext will not authenticate under our key. +const forgedEnvelope = (sender, counter) => ({ + v: 2, + type: 'text', + senderDeviceId: sender, + counter, + ts: Date.now(), + payload: JSON.stringify({ + nonce: Buffer.alloc(12).toString('base64'), + ciphertext: Buffer.from('nope').toString('base64'), + tag: Buffer.alloc(16).toString('base64'), + bound: true, + }), +}); + +describe('protocolV2', () => { + test('ReplayCache rejects duplicates', () => { + const cache = new ReplayCache(8); + expect(cache.accept('a', 1)).toBe(true); + expect(cache.accept('a', 1)).toBe(false); + expect(cache.accept('a', 2)).toBe(true); + expect(cache.accept('b', 1)).toBe(true); + }); + + test('ReplayCache rejects non-integer counters', () => { + const cache = new ReplayCache(); + expect(cache.accept('a', 1.5)).toBe(false); + expect(cache.accept('a', 1e308)).toBe(false); + expect(cache.accept('a', NaN)).toBe(false); + }); + + test('ReplayCache bounds the number of tracked senders', () => { + const cache = new ReplayCache(); + for (let i = 0; i < 5000; i++) { + cache.accept(`junk-${i}`, 1); + } + expect(cache.senders.size).toBeLessThanOrEqual(MAX_TRACKED_SENDERS); + }); + + test('ensureDeviceId and counter are monotonic', () => { + const store = {}; + const id = ensureDeviceId(store); + expect(id).toBeTruthy(); + expect(store.device_id).toBe(id); + expect(nextCounter(store)).toBe(1); + expect(nextCounter(store)).toBe(2); + }); + + test('round-trips an encrypted bound envelope', async () => { + const env = await wrap('secret-clip'); + expect(env.v).toBe(2); + const blob = JSON.parse(env.payload); + expect(blob.bound).toBe(true); + + const out = await unwrap(env); + expect(out.payload).toBe('secret-clip'); + expect(out.type).toBe('text'); + }); + + test('rejects a replayed envelope', async () => { + const env = await wrap('secret-clip'); + const replayCache = new ReplayCache(); + const args = {cipherEnabled: true, hashedPassword: KEY, replayCache}; + + await expect(unwrapInbound({body: env, ...args})).resolves.toMatchObject({ + payload: 'secret-clip', + }); + await expect(unwrapInbound({body: env, ...args})).rejects.toThrow(/Replay/); + }); + + test('rejects tampering with the outer type', async () => { + const env = await wrap('secret-clip'); + await expect(unwrap({...env, type: 'image'})).rejects.toThrow( + /Bound metadata mismatch/, + ); + }); + + test('rejects a legacy v1 message by default', async () => { + await expect( + unwrapInbound({ + body: {payload: 'legacy', type: 'text'}, + cipherEnabled: false, + }), + ).rejects.toThrow(/Rejected legacy v1/); + }); + + test('accepts a legacy v1 message only when opted in', async () => { + const out = await unwrapInbound({ + body: {payload: 'legacy', type: 'text'}, + cipherEnabled: false, + allowLegacyV1: true, + }); + expect(out.payload).toBe('legacy'); + }); + + test('rejects a v1 downgrade of a v2 envelope', async () => { + const env = await wrap('secret-clip'); + const downgraded = {...env}; + delete downgraded.v; + await expect(unwrap(downgraded)).rejects.toThrow(/Rejected legacy v1/); + }); + + test('rejects un-bound ciphertext, which sits outside the AEAD', async () => { + const env = await wrap('secret-clip'); + const blob = JSON.parse(env.payload); + blob.bound = false; + await expect( + unwrap({ + ...env, + payload: JSON.stringify(blob), + senderDeviceId: 'attacker-spoofed', + counter: 9999, + }), + ).rejects.toThrow(/Rejected un-bound/); + }); + + test('an unauthenticated flood cannot evict replay history', async () => { + const env = await wrap('secret-clip'); + const replayCache = new ReplayCache(); + const args = {cipherEnabled: true, hashedPassword: KEY, replayCache}; + + await expect(unwrapInbound({body: env, ...args})).resolves.toMatchObject({ + payload: 'secret-clip', + }); + + for (let i = 0; i < 200; i++) { + await expect( + unwrapInbound({body: forgedEnvelope(`junk-${i}`, 1), ...args}), + ).rejects.toThrow(); + } + + // The junk never reached the cache, so the original is still a replay. + expect(replayCache.senders.size).toBe(1); + await expect(unwrapInbound({body: env, ...args})).rejects.toThrow(/Replay/); + }); + + test('an unauthenticated message cannot pin a sender counter', async () => { + const replayCache = new ReplayCache(); + const args = {cipherEnabled: true, hashedPassword: KEY, replayCache}; + + await expect( + unwrapInbound({body: forgedEnvelope('sender', 2000000000), ...args}), + ).rejects.toThrow(); + + // The real sender's next message must still get through. + const env = await wrap('secret-clip'); + await expect(unwrapInbound({body: env, ...args})).resolves.toMatchObject({ + payload: 'secret-clip', + }); + }); + + test('rejects envelope metadata that protocol_v2.py would reject', async () => { + // Parity with the desktop implementation: a counter must be a positive safe + // integer and ts an integer. Divergence here means the two sides disagree + // on what they accept. + const base = await wrap('secret-clip'); + const bad = [ + {...base, counter: 0}, + {...base, counter: -1}, + {...base, counter: 1.5}, + {...base, counter: Number.MAX_SAFE_INTEGER + 1}, + {...base, counter: '1'}, + {...base, ts: 1.5}, + {...base, ts: '123'}, + {...base, senderDeviceId: ''}, + {...base, senderDeviceId: 42}, + ]; + for (const body of bad) { + await expect(unwrap(body)).rejects.toThrow(/Invalid v2 envelope metadata/); + } + }); + + test('frame carries only fields the server models', async () => { + // The server does not ignore unknown top-level keys β€” Jackson raises + // UnrecognizedPropertyException and drops the message before the handler + // runs. Confirmed against a real server: the flat envelope relayed nothing. + const env = await wrap('secret-clip'); + const frame = buildTransportFrame(env, 'text'); + expect(Object.keys(frame).sort()).toEqual(['payload', 'type']); + }); + + test('round-trips through a field-dropping relay', async () => { + const env = await wrap('secret-clip'); + const relayed = relay(JSON.parse(JSON.stringify(buildTransportFrame(env, 'text')))); + // The relay keeps only these three keys. + expect(Object.keys(relayed).sort()).toEqual(['metadata', 'payload', 'type']); + + const out = await unwrapInbound({ + body: extractTransportEnvelope(relayed), + cipherEnabled: true, + hashedPassword: KEY, + replayCache: new ReplayCache(), + allowLegacyV1: false, + }); + expect(out.payload).toBe('secret-clip'); + expect(out.type).toBe('text'); + }); + + test('the relayed outer type is not trusted', async () => { + const env = await wrap('secret-clip'); + const relayed = relay(buildTransportFrame(env, 'text')); + relayed.type = 'files'; // relay-controlled, unauthenticated + const out = await unwrapInbound({ + body: extractTransportEnvelope(relayed), + cipherEnabled: true, + hashedPassword: KEY, + replayCache: new ReplayCache(), + allowLegacyV1: false, + }); + expect(out.type).toBe('text'); + }); + + test('extractTransportEnvelope rejects junk without leaking SyntaxError', () => { + for (const bad of [{payload: 'not json'}, {payload: 5}, {payload: '[]'}, {}, null]) { + expect(() => extractTransportEnvelope(bad)).toThrow(Error); + } + }); + + test('a polluted Object.prototype cannot enable legacy v1', async () => { + // The P2P reassembler used to index a plain object with peer-supplied keys, + // so one frame could set Object.prototype.allowLegacyV1. A destructuring + // default only fires on `undefined`, so the inherited value would have won. + // eslint-disable-next-line no-extend-native + Object.prototype.allowLegacyV1 = true; + try { + expect({}.allowLegacyV1).toBe(true); // pollution is in place + await expect( + unwrapInbound({ + body: {payload: 'legacy', type: 'text'}, + cipherEnabled: false, + replayCache: new ReplayCache(), + }), + ).rejects.toThrow(/Rejected legacy v1/); + } finally { + delete Object.prototype.allowLegacyV1; + } + }); + + // The same corpus the Python suite runs, from the same file. Desktop and + // mobile were asserted to be behaviourally identical for three review rounds + // without anyone measuring it; when measured they disagreed on 4 of 34 + // envelopes, in host-language details invisible when reading the two files + // side by side. This is the instrument that detects that class. + describe('shared cross-runtime corpus', () => { + // eslint-disable-next-line no-undef + const corpus = require('../../../ClipCascade_Desktop/src/tests/protocol_corpus.json'); + const now = Date.now(); + + corpus.cases.forEach(c => { + test(`${c.name} -> ${c.expect}`, async () => { + const envelope = {...c.envelope}; + if (typeof envelope.ts === 'number' && envelope.ts === 0) { + envelope.ts = now; + } + const call = () => + unwrapInbound({ + body: envelope, + cipherEnabled: false, + replayCache: new ReplayCache(), + allowLegacyV1: false, + }); + + if (c.expect === 'accept') { + const out = await call(); + expect(out.payload).toBe(envelope.payload); + } else { + await expect(call()).rejects.toThrow(); + } + }); + }); + }); + + test('self-originated messages are ignored', async () => { + const env = await wrap('secret-clip'); + await expect(unwrap(env, {localDeviceId: 'sender'})).rejects.toThrow( + /self-originated/, + ); + }); +}); diff --git a/ClipCascade_Mobile/src/android/app/build.gradle b/ClipCascade_Mobile/src/android/app/build.gradle index 8b51e2af9..0374bdbfc 100644 --- a/ClipCascade_Mobile/src/android/app/build.gradle +++ b/ClipCascade_Mobile/src/android/app/build.gradle @@ -82,8 +82,8 @@ android { applicationId "com.clipcascade" minSdkVersion 26 targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1 - versionName "1.0" + versionCode 320 + versionName "3.2.0" } signingConfigs { debug { diff --git a/ClipCascade_Mobile/src/android/app/src/main/AndroidManifest.xml b/ClipCascade_Mobile/src/android/app/src/main/AndroidManifest.xml index c0c318291..43b2acb8a 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/AndroidManifest.xml +++ b/ClipCascade_Mobile/src/android/app/src/main/AndroidManifest.xml @@ -5,11 +5,7 @@ - - - - + + android:theme="@style/Theme.TransparentActivity" + android:exported="false" /> + + + + + + 0) { - - val description = clip.description - if (description != null) { - - val mimeType = description.getMimeType(0) - if (mimeType != null) { - - val item = clip.getItemAt(0) - val params: WritableMap = Arguments.createMap() - - if (mimeType.startsWith("text/") && item.text != null) { - // Text - params.putString("content", item.text.toString()) - params.putString("type", "text") - } - else if (mimeType.startsWith("image/") && item.uri != null) { - // Image - params.putString("content", item.uri.toString()) - params.putString("type", "image") - } - else if (item.uri != null) { - // Files - params.putString("content", item.uri.toString()) - params.putString("type", "files") - } - - sendEventToJS(params) - } - } - } + emitCurrentClipboard() } clipboardManager.addPrimaryClipChangedListener(listener) isListening = true - - // 2) Logcat monitoring - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P && - ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.READ_LOGS) == PackageManager.PERMISSION_GRANTED - ) { - // If already stopping, reset flag - stopLogcat = false - - // Start a single dedicated thread - logcatThread = Thread { - try { - val timeStamp = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) - .format(Date()) - logcatProcess = Runtime.getRuntime().exec( - arrayOf("logcat", "-T", timeStamp, "ClipboardService:E", "*:S") - ) - val reader = BufferedReader(InputStreamReader(logcatProcess!!.inputStream)) - var line: String? = null - reader.use { br -> - while (!stopLogcat && br.readLine().also { line = it } != null) { - if (line!!.contains(BuildConfig.APPLICATION_ID)) { - val currentTime = System.currentTimeMillis() - if (currentTime - lastActivityStartTime > activityDebounceTime) { - lastActivityStartTime = currentTime - // launch the floating activity - reactApplicationContext.startActivity( - ClipboardFloatingActivity.getIntent(reactApplicationContext) - ) - } - } - } - } - } catch (e: Exception) { - e.printStackTrace() - } finally { - try { - logcatProcess?.destroy() - } catch (_: Exception) {} - stopLogcat = false - } - }.apply { - isDaemon = true - start() - } - } } @ReactMethod fun stopListening() { - // 1) Remove clipboard listener listener?.let { clipboardManager.removePrimaryClipChangedListener(it) listener = null isListening = false } + } - // 2) Tear down logcat‐reader thread & process - stopLogcat = true - try { - logcatThread?.interrupt() - } catch (_: Exception) {} - try { - logcatProcess?.destroy() - } catch (_: Exception) {} - logcatThread = null - logcatProcess = null + /** + * Explicit one-shot capture (tile / notification action / UI button). + */ + @ReactMethod + fun captureNow() { + emitCurrentClipboard(force = true) } + private fun emitCurrentClipboard(force: Boolean = false) { + val clip = clipboardManager.primaryClip + if (clip == null || clip.itemCount <= 0) { + return + } + + val description = clip.description ?: return + val mimeType = description.getMimeType(0) ?: return + val item = clip.getItemAt(0) + val params: WritableMap = Arguments.createMap() + + when { + mimeType.startsWith("text/") && item.text != null -> { + params.putString("content", item.text.toString()) + params.putString("type", "text") + } + mimeType.startsWith("image/") && item.uri != null -> { + params.putString("content", item.uri.toString()) + params.putString("type", "image") + } + item.uri != null -> { + params.putString("content", item.uri.toString()) + params.putString("type", "files") + } + else -> return + } - private fun sendEventToJS(params: WritableMap) { val currentTime = System.currentTimeMillis() - if (currentTime - lastEmittedTime > debounceTime) { - lastEmittedTime = currentTime - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit("onClipboardChange", params) + if (!force && currentTime - lastEmittedTime <= debounceTime) { + return } + lastEmittedTime = currentTime + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("onClipboardChange", params) } @ReactMethod @@ -172,4 +108,3 @@ class ClipboardListenerModule(reactContext: ReactApplicationContext) : ReactCont // Required for RN built-in Event Emitter Calls. } } - diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardTileService.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardTileService.kt new file mode 100644 index 000000000..d588b9786 --- /dev/null +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardTileService.kt @@ -0,0 +1,39 @@ +package com.clipcascade + +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import androidx.annotation.RequiresApi + +/** + * Quick Settings tile: explicit "Share clipboard now" without READ_LOGS/overlay. + */ +@RequiresApi(Build.VERSION_CODES.N) +class ClipboardTileService : TileService() { + + override fun onStartListening() { + super.onStartListening() + qsTile?.apply { + state = Tile.STATE_INACTIVE + label = "ClipCascade" + contentDescription = "Share clipboard now" + updateTile() + } + } + + override fun onClick() { + super.onClick() + val intent = Intent(this, MainActivity::class.java).apply { + action = ACTION_CAPTURE_CLIPBOARD + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + // MainActivity ignores this action unless it can prove we sent it. + InternalIntents.stamp(applicationContext, intent) + startActivityAndCollapse(intent) + } + + companion object { + const val ACTION_CAPTURE_CLIPBOARD = "com.clipcascade.ACTION_CAPTURE_CLIPBOARD" + } +} diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/InternalIntents.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/InternalIntents.kt new file mode 100644 index 000000000..86b599d52 --- /dev/null +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/InternalIntents.kt @@ -0,0 +1,54 @@ +package com.clipcascade + +import android.content.Context +import android.content.Intent +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * Authenticates intents that are only meant to come from ClipCascade itself. + * + * MainActivity has to stay exported: it is the LAUNCHER activity and the + * share/PROCESS_TEXT target. An exported activity accepts any *explicit* + * intent regardless of its intent-filters, so without a check here any + * installed app could start it with one of our internal actions and drive + * privileged behaviour β€” notably making ClipCascade read the clipboard and put + * it on the wire at a moment of the caller's choosing. Android 10+ blocks + * background clipboard reads precisely to prevent that, and the tile exists + * because of the same restriction, so this must not become a way around it. + * + * The token lives in the app's private storage: other apps cannot read it, and + * it survives process death so notification and tile PendingIntents stay valid + * across restarts. + */ +object InternalIntents { + const val EXTRA_TOKEN = "com.clipcascade.extra.INTERNAL_TOKEN" + + private const val PREFS = "cc_internal_intents" + private const val KEY = "token" + + @Synchronized + fun token(context: Context): String { + val prefs = context.applicationContext + .getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs.getString(KEY, null)?.let { return it } + + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + val token = bytes.joinToString("") { "%02x".format(it.toInt() and 0xFF) } + prefs.edit().putString(KEY, token).apply() + return token + } + + /** Stamp an intent we are about to hand to the framework on our own behalf. */ + fun stamp(context: Context, intent: Intent): Intent = + intent.putExtra(EXTRA_TOKEN, token(context)) + + fun isInternal(context: Context, intent: Intent): Boolean { + val provided = intent.getStringExtra(EXTRA_TOKEN) ?: return false + return MessageDigest.isEqual( + provided.toByteArray(Charsets.UTF_8), + token(context).toByteArray(Charsets.UTF_8), + ) + } +} diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt index 76d26039a..7875f50a4 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt @@ -137,6 +137,20 @@ class MainActivity : ReactActivity() { } } + // Internal actions below. This activity is exported (LAUNCHER + share + // target), and an exported activity accepts any explicit intent no + // matter what its intent-filters say, so require proof the intent came + // from us. Without this, any installed app could start MainActivity + // with ACTION_CAPTURE_CLIPBOARD and have ClipCascade read the clipboard + // and transmit it on demand. + val isInternalAction = + "com.clipcascade.NOTIFICATION_ACTION" == intent.action || + ClipboardTileService.ACTION_CAPTURE_CLIPBOARD == intent.action + if (isInternalAction && !InternalIntents.isInternal(applicationContext, intent)) { + Log.w(TAG, "Ignoring internal action from an external caller: ${intent.action}") + return + } + // custom notification action if ("com.clipcascade.NOTIFICATION_ACTION" == intent.action) { val action = intent.getStringExtra("action") @@ -149,6 +163,11 @@ class MainActivity : ReactActivity() { } } } + + // Quick Settings tile / explicit capture intent + if (ClipboardTileService.ACTION_CAPTURE_CLIPBOARD == intent.action) { + sendToReactNative("CAPTURE_CLIPBOARD_NOW", "trigger", "true") + } } // Send data to React Native diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ScheduleService.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ScheduleService.kt index 5e3d19dd1..7dde4c777 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ScheduleService.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ScheduleService.kt @@ -100,6 +100,8 @@ class ScheduleService(context: Context, workerParams: WorkerParameters) : Corout putExtra("action", "foreground_service_stopped_running") flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } + // MainActivity ignores this action unless it can prove we sent it. + InternalIntents.stamp(applicationContext, intent) val pendingIntent = PendingIntent.getActivity( applicationContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE diff --git a/ClipCascade_Mobile/src/android/app/src/main/res/xml/network_security_config.xml b/ClipCascade_Mobile/src/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..ae83bdeaf --- /dev/null +++ b/ClipCascade_Mobile/src/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,31 @@ + + + + + + + + + + localhost + ts.net + + diff --git a/ClipCascade_Mobile/src/android/gradle.properties.example b/ClipCascade_Mobile/src/android/gradle.properties.example new file mode 100644 index 000000000..6d54a3075 --- /dev/null +++ b/ClipCascade_Mobile/src/android/gradle.properties.example @@ -0,0 +1,31 @@ +# Copy to gradle.properties before building: +# cp gradle.properties.example gradle.properties +# +# The real gradle.properties is gitignored because release builds keep the +# upload-key passwords in it (MYAPP_UPLOAD_* below). Without the file the build +# fails at configuration time with: +# Could not get unknown property 'hermesEnabled' +# because app/build.gradle reads these properties while evaluating. + +# Gradle daemon heap. Raise if you hit OOM during dex/kotlin compilation. +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# AndroidX is required by the dependencies this app already uses. +android.useAndroidX=true + +# Use the Hermes JS engine. +hermesEnabled=true + +# React Native new architecture (Fabric/TurboModules). +newArchEnabled=false + +# Note: ProGuard for release builds is NOT set here β€” app/build.gradle declares +# `def enableProguardInReleaseBuilds` as a local variable, so a property of that +# name would have no effect. Change it in app/build.gradle instead. + +# --- Release signing only; not needed for assembleDebug --- +# Never commit real values. See docs/SIGNING.md. +# MYAPP_UPLOAD_STORE_FILE=my-upload-key.keystore +# MYAPP_UPLOAD_KEY_ALIAS=my-key-alias +# MYAPP_UPLOAD_STORE_PASSWORD=***** +# MYAPP_UPLOAD_KEY_PASSWORD=***** diff --git a/ClipCascade_Mobile/src/ios/ClipCascade.xcodeproj/project.pbxproj b/ClipCascade_Mobile/src/ios/ClipCascade.xcodeproj/project.pbxproj index b887bfd54..62d61bc02 100644 --- a/ClipCascade_Mobile/src/ios/ClipCascade.xcodeproj/project.pbxproj +++ b/ClipCascade_Mobile/src/ios/ClipCascade.xcodeproj/project.pbxproj @@ -291,7 +291,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 320; ENABLE_BITCODE = NO; INFOPLIST_FILE = ClipCascade/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -299,7 +299,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.2.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -319,14 +319,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 320; INFOPLIST_FILE = ClipCascade/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.2.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ClipCascade_Mobile/src/jest.config.js b/ClipCascade_Mobile/src/jest.config.js index 8eb675e9b..fcfae0994 100644 --- a/ClipCascade_Mobile/src/jest.config.js +++ b/ClipCascade_Mobile/src/jest.config.js @@ -1,3 +1,4 @@ module.exports = { preset: 'react-native', + setupFiles: ['./jest.setup.js'], }; diff --git a/ClipCascade_Mobile/src/jest.setup.js b/ClipCascade_Mobile/src/jest.setup.js new file mode 100644 index 000000000..f862d2953 --- /dev/null +++ b/ClipCascade_Mobile/src/jest.setup.js @@ -0,0 +1,131 @@ +/* eslint-env jest */ + +const { NativeModules, PermissionsAndroid } = require('react-native'); + +NativeModules.NativeBridgeModule = { + clearCookies: jest.fn(), + clearImageCache: jest.fn(), + getFileAsBase64: jest.fn(async () => ''), + getFileName: jest.fn(async () => 'file.txt'), + getFileSize: jest.fn(async () => '0'), + getFlagsSync: jest.fn(() => + JSON.stringify({ + wsIsRunning: 'false', + wsStatusMessage: '', + server_mode: 'P2S', + p2pStatusMessage: '', + filesAvailableToDownload: 'false', + }) + ), + stopWorkManager: jest.fn(), +}; + +PermissionsAndroid.request = jest.fn(async () => 'granted'); +global.fetch = jest.fn(async () => ({ + ok: false, + status: 401, + text: jest.fn(async () => ''), + json: jest.fn(async () => ({})), +})); + +jest.mock('@notifee/react-native', () => ({ + __esModule: true, + default: { + cancelAllNotifications: jest.fn(), + cancelNotification: jest.fn(), + createChannel: jest.fn(async () => 'default'), + displayNotification: jest.fn(), + openBatteryOptimizationSettings: jest.fn(), + openPowerManagerSettings: jest.fn(), + registerForegroundService: jest.fn(), + stopForegroundService: jest.fn(), + }, + AndroidImportance: { + HIGH: 4, + }, +})); + +jest.mock('@react-native-documents/picker', () => ({ + pickDirectory: jest.fn(), + isCancel: jest.fn(() => false), +})); + +jest.mock('@react-native-async-storage/async-storage', () => + require('@react-native-async-storage/async-storage/jest/async-storage-mock') +); + +jest.mock('react-native-keychain', () => { + const store = new Map(); + return { + ACCESSIBLE: { + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'AccessibleWhenUnlockedThisDeviceOnly', + }, + setGenericPassword: jest.fn(async (username, password, options = {}) => { + store.set(options.service || 'default', {username, password}); + return true; + }), + getGenericPassword: jest.fn(async (options = {}) => { + const entry = store.get(options.service || 'default'); + return entry || false; + }), + resetGenericPassword: jest.fn(async (options = {}) => { + store.delete(options.service || 'default'); + return true; + }), + // Test helper (not used by app code) + __store: store, + }; +}); + +jest.mock('react-native-webrtc', () => ({ + RTCPeerConnection: jest.fn(), + RTCIceCandidate: jest.fn(), + RTCSessionDescription: jest.fn(), +})); + +// Real AES-256-GCM, mirroring react-native-aes-gcm-crypto's contract: the key +// is base64, the iv/tag are hex, and the ciphertext is base64. Returning bare +// jest.fn() here would make every protocol test vacuous β€” wrapOutbound and +// unwrapInbound would "pass" while never exercising the cipher path at all. +// +// Caveat: node accepts any GCM iv length, so this cannot catch the 12- vs +// 16-byte nonce difference between the desktop (PyCryptodome) and iOS +// (CryptoKit) implementations. That needs an on-device test. +jest.mock('react-native-aes-gcm-crypto', () => { + const crypto = require('crypto'); + const {Buffer} = require('buffer'); + return { + encrypt: jest.fn(async (plainText, _isBinary, keyBase64) => { + const key = Buffer.from(keyBase64, 'base64'); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const content = Buffer.concat([ + cipher.update(Buffer.from(plainText, 'utf8')), + cipher.final(), + ]); + return { + iv: iv.toString('hex'), + tag: cipher.getAuthTag().toString('hex'), + content: content.toString('base64'), + }; + }), + decrypt: jest.fn(async (contentBase64, keyBase64, ivHex, tagHex) => { + const key = Buffer.from(keyBase64, 'base64'); + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + key, + Buffer.from(ivHex, 'hex'), + ); + decipher.setAuthTag(Buffer.from(tagHex, 'hex')); + return Buffer.concat([ + decipher.update(Buffer.from(contentBase64, 'base64')), + decipher.final(), + ]).toString('utf8'); + }), + }; +}); + +jest.mock('@react-native-clipboard/clipboard', () => ({ + getString: jest.fn(async () => ''), + setString: jest.fn(), +})); diff --git a/ClipCascade_Mobile/src/networkPolicy.js b/ClipCascade_Mobile/src/networkPolicy.js new file mode 100644 index 000000000..c9a19f4a3 --- /dev/null +++ b/ClipCascade_Mobile/src/networkPolicy.js @@ -0,0 +1,122 @@ +/** + * Client-side URL policy aligned with desktop Config._allows_insecure_http. + * Allows HTTP for loopback, RFC1918, Tailscale CGNAT (100.64/10), and *.ts.net. + */ + +const TAILSCALE_CGNAT = (() => { + // 100.64.0.0 – 100.127.255.255 + const start = ipToLong('100.64.0.0'); + const end = ipToLong('100.127.255.255'); + return {start, end}; +})(); + +function ipToLong(ip) { + return ip.split('.').reduce((acc, oct) => (acc << 8) + (Number(oct) & 255), 0) >>> 0; +} + +// Each octet must be plain decimal with no leading zero. A leading zero is +// ambiguous: JS Number('064') is 64, but a spec-compliant (WHATWG) parser +// reads it as octal 52. Accepting it would let "100.064.0.1" pass as +// Tailscale CGNAT here while the connection actually goes to 100.52.0.1, +// which is public and routable. React Native's global.URL is a regex +// polyfill that does not normalise the host, so this module cannot rely on +// the URL parser to canonicalise it for us. +const IPV4_OCTET = /^(0|[1-9]\d{0,2})$/; + +function isIpv4(host) { + const parts = host.split('.'); + return parts.length === 4 && parts.every(p => IPV4_OCTET.test(p)); +} + +export function isPrivateOrMeshHost(host) { + if (!host || typeof host !== 'string') { + return false; + } + const h = host.toLowerCase().replace(/\.$/, ''); + if (h === 'localhost' || h.endsWith('.localhost')) { + return true; + } + // Only MagicDNS names under the apex, never the public apex itself. + if (h.endsWith('.ts.net')) { + return true; + } + if (!isIpv4(h)) { + // Non-IP hostnames that are not MagicDNS: treat as public (require HTTPS). + return false; + } + const parts = h.split('.').map(Number); + if (parts.some(p => p > 255)) { + return false; + } + // loopback 127.0.0.0/8 + if (parts[0] === 127) { + return true; + } + // RFC1918 + if (parts[0] === 10) { + return true; + } + if (parts[0] === 192 && parts[1] === 168) { + return true; + } + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) { + return true; + } + // link-local 169.254.0.0/16 + if (parts[0] === 169 && parts[1] === 254) { + return true; + } + // Tailscale CGNAT + const n = ipToLong(h); + if (n >= TAILSCALE_CGNAT.start && n <= TAILSCALE_CGNAT.end) { + return true; + } + return false; +} + +/** + * @param {string} inputUrl + * @throws {Error} + */ +export function validateServerUrl(inputUrl) { + if (!inputUrl || typeof inputUrl !== 'string') { + throw new Error('Invalid URL provided'); + } + // Lowercase the scheme before parsing. React Native ships a regex URL + // polyfill whose hostname getter is anchored to a lowercase ^https?://, so + // "HTTPS://host" yields an empty hostname and is rejected on device β€” while + // jest, running Node's WHATWG URL, lowercases it and passes. Schemes are + // case-insensitive, and desktop's urlparse already accepts these. + const trimmed = inputUrl + .trim() + .replace(/\/+$/, '') + .replace(/^([A-Za-z][A-Za-z\d+.-]*):/, m => m.toLowerCase()); + let parsed; + try { + parsed = new URL(trimmed); + } catch { + throw new Error('Invalid URL provided'); + } + if (!parsed.hostname) { + throw new Error('Server URL must include a hostname'); + } + if (parsed.username || parsed.password) { + throw new Error('Server URL must not include embedded credentials'); + } + if (parsed.search || parsed.hash) { + throw new Error('Server URL must not include query or fragment components'); + } + if (parsed.pathname && parsed.pathname !== '/') { + throw new Error('Server URL must not include a path'); + } + const scheme = parsed.protocol.replace(':', '').toLowerCase(); + if (scheme !== 'http' && scheme !== 'https') { + throw new Error(`Unsupported protocol in URL: ${inputUrl}`); + } + if (scheme === 'http' && !isPrivateOrMeshHost(parsed.hostname)) { + throw new Error( + 'Refusing insecure HTTP for non-private server. Use HTTPS, a Tailscale/LAN address (100.x / *.ts.net / RFC1918), or enable cleartext only on private mesh.', + ); + } + return trimmed; +} diff --git a/ClipCascade_Mobile/src/package-lock.json b/ClipCascade_Mobile/src/package-lock.json index 8833b1406..4768e4a96 100644 --- a/ClipCascade_Mobile/src/package-lock.json +++ b/ClipCascade_Mobile/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "ClipCascade", - "version": "0.0.1", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ClipCascade", - "version": "0.0.1", + "version": "3.2.0", "dependencies": { "@notifee/react-native": "^9.1.8", "@react-native-async-storage/async-storage": "^2.2.0", @@ -22,6 +22,7 @@ "react-native": "0.80.2", "react-native-aes-gcm-crypto": "^0.2.2", "react-native-html-parser": "^0.1.0", + "react-native-keychain": "^10.0.0", "react-native-webrtc": "^124.0.6", "text-encoding": "^0.7.0" }, @@ -29,9 +30,9 @@ "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", - "@react-native-community/cli": "19.1.1", - "@react-native-community/cli-platform-android": "19.1.1", - "@react-native-community/cli-platform-ios": "19.1.1", + "@react-native-community/cli": "^19.1.2", + "@react-native-community/cli-platform-android": "^19.1.2", + "@react-native-community/cli-platform-ios": "^19.1.2", "@react-native/babel-preset": "0.80.2", "@react-native/eslint-config": "0.80.2", "@react-native/metro-config": "0.80.2", @@ -49,26 +50,13 @@ "node": ">=18" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -77,30 +65,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -135,13 +123,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -164,13 +152,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -237,9 +225,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -260,27 +248,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -303,9 +291,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -362,27 +350,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -404,25 +392,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", - "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1285,16 +1273,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", - "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1938,31 +1926,31 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -1989,13 +1977,13 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2075,9 +2063,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2086,9 +2074,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2142,9 +2130,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2153,9 +2141,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2235,9 +2223,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -2648,6 +2636,16 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2792,18 +2790,18 @@ } }, "node_modules/@react-native-community/cli": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-19.1.1.tgz", - "integrity": "sha512-H17sV83KPg2H2GCNuUSMM1ZM2sy6msVSmxrhJSycH8ua3i9Iixja8DeYtGIcJUzjdU/4U2eSDs6PjOSZUVn8CQ==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-19.1.2.tgz", + "integrity": "sha512-b28TLqODMgQRx6f4gbHoHYpnKyFbWzJkIk3+Ggpad/at493KfGQ+WvKg1sts/st8mxzmbk0T6lCc/9A3QoFKkQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-clean": "19.1.1", - "@react-native-community/cli-config": "19.1.1", - "@react-native-community/cli-doctor": "19.1.1", - "@react-native-community/cli-server-api": "19.1.1", - "@react-native-community/cli-tools": "19.1.1", - "@react-native-community/cli-types": "19.1.1", + "@react-native-community/cli-clean": "19.1.2", + "@react-native-community/cli-config": "19.1.2", + "@react-native-community/cli-doctor": "19.1.2", + "@react-native-community/cli-server-api": "19.1.2", + "@react-native-community/cli-tools": "19.1.2", + "@react-native-community/cli-types": "19.1.2", "chalk": "^4.1.2", "commander": "^9.4.1", "deepmerge": "^4.3.0", @@ -2822,26 +2820,26 @@ } }, "node_modules/@react-native-community/cli-clean": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-19.1.1.tgz", - "integrity": "sha512-pP7SmK+PNw5B1Aa2c6y06FBNc9iGah/leFFM2uewpyZRJQ4zycX6Zz1UANpq9YZfp65n7NZKV9Gct2uaVRuP/Q==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-19.1.2.tgz", + "integrity": "sha512-LI/bTLtosbDyHtIs+HxlmHp+5Nbjz+IIEEqrBO2tUeA+ENX01YEnIgGIv4z7giNWkHSiqywjdOyYNqg27ydy2g==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "19.1.1", + "@react-native-community/cli-tools": "19.1.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "node_modules/@react-native-community/cli-config": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-19.1.1.tgz", - "integrity": "sha512-qGLYCFf3whCa/we3iKd5BY4RlcAUhSykwGpnJpjseXLaI5iJzIn/IMd70EBG8QvhV/KQxM7VFMQj6KgGcoNKYg==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-19.1.2.tgz", + "integrity": "sha512-o0cc6R6r9nY9MiLFeLIN797fBLWwKW9cee/NCm6nBBzPk/paro6HEbcXE02xnVzMb+nhQPrbPOzp3qE7WhtwRA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "19.1.1", + "@react-native-community/cli-tools": "19.1.2", "chalk": "^4.1.2", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", @@ -2850,43 +2848,43 @@ } }, "node_modules/@react-native-community/cli-config-android": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-19.1.1.tgz", - "integrity": "sha512-uAUXU/BPuasBy7For5lvVEpxiwA29X5BWKjM4fgxWmsQhaZHW//6PNRep94w3WVnAp+CUbW6+o3SzFqMX0PdIw==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-19.1.2.tgz", + "integrity": "sha512-IIhzhDUmT53RT45Qrxc/OfvkTD4U7IrfkfoIdKmBT6O0X0QaoegK4OE6aAuc86D2GXlD5rbVcPMSuN4TY8Hmlw==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "19.1.1", + "@react-native-community/cli-tools": "19.1.2", "chalk": "^4.1.2", "fast-glob": "^3.3.2", "fast-xml-parser": "^4.4.1" } }, "node_modules/@react-native-community/cli-config-apple": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-19.1.1.tgz", - "integrity": "sha512-dKS7pg5eAEgRB8sOWYpr6XCR/3xUcttHNsuYYbuMXfY9d0M3d0oGquuMOW/p3Ri9sJI16bRAs/YIXDF2m4gYIA==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-19.1.2.tgz", + "integrity": "sha512-91upuYMLgEtJE6foWQFgGDpT3ZDTc5bX6rMY5cJMqiAE5svgh1q0kbbpRuv/ptBYzcxLplL7wZWpA77TlJdm9A==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "19.1.1", + "@react-native-community/cli-tools": "19.1.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-glob": "^3.3.2" } }, "node_modules/@react-native-community/cli-doctor": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-19.1.1.tgz", - "integrity": "sha512-P6JgTpa8fn6SfGiotyRhiCqBlRlKx8MUUdMESPGyPzvMb8omz+Jv0ibdNg9CVT11/0x5oRsoGv07os/o+Eg0zQ==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-19.1.2.tgz", + "integrity": "sha512-uUV/1QrWA1Cx7dqkTCcarqfya/7gBmKXd9BzVCEl6bzAn1jd1Q5UaZ+DmZgAoLVKlbAjpPTJTfqjD44aqUdjyA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config": "19.1.1", - "@react-native-community/cli-platform-android": "19.1.1", - "@react-native-community/cli-platform-apple": "19.1.1", - "@react-native-community/cli-platform-ios": "19.1.1", - "@react-native-community/cli-tools": "19.1.1", + "@react-native-community/cli-config": "19.1.2", + "@react-native-community/cli-platform-android": "19.1.2", + "@react-native-community/cli-platform-apple": "19.1.2", + "@react-native-community/cli-platform-ios": "19.1.2", + "@react-native-community/cli-tools": "19.1.2", "chalk": "^4.1.2", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", @@ -2900,9 +2898,9 @@ } }, "node_modules/@react-native-community/cli-doctor/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, "license": "ISC", "bin": { @@ -2913,51 +2911,51 @@ } }, "node_modules/@react-native-community/cli-platform-android": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-19.1.1.tgz", - "integrity": "sha512-omEAcIYz22Lxi/WjYHkNaUMEKV+o60PL3DJE6Wz3c4bkuDfxICJ8JcPawT4fDMsBX7DYwnYf6/Lk/leqQmHzOw==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-19.1.2.tgz", + "integrity": "sha512-eMryTlSSTl3JK/tZTaMaMgHec9qu+eQj+3A15qmBdj2ac3p/hiauwAe4q35rz5XABw1cJCuyn+s469YsdTllaw==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-android": "19.1.1", - "@react-native-community/cli-tools": "19.1.1", + "@react-native-community/cli-config-android": "19.1.2", + "@react-native-community/cli-tools": "19.1.2", "chalk": "^4.1.2", "execa": "^5.0.0", "logkitty": "^0.7.1" } }, "node_modules/@react-native-community/cli-platform-apple": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-19.1.1.tgz", - "integrity": "sha512-nsJ/TlQ97Lcmz5dVZVSwYYQzJmK6q/9X31VTAFhUf94ShugF3zXjaNnOJieKYDJlXy4G0EnrEulX1gTt29ebyw==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-19.1.2.tgz", + "integrity": "sha512-TtaF8Pyrs4dnIH3LTvuPnPjGDsSVaZLu+8s4y5bngzZIf9r7M/HJTlpnhm8+bQPsahxNhNQZBGUBrQJqfmg7Ww==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-apple": "19.1.1", - "@react-native-community/cli-tools": "19.1.1", + "@react-native-community/cli-config-apple": "19.1.2", + "@react-native-community/cli-tools": "19.1.2", "chalk": "^4.1.2", "execa": "^5.0.0", "fast-xml-parser": "^4.4.1" } }, "node_modules/@react-native-community/cli-platform-ios": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-19.1.1.tgz", - "integrity": "sha512-QHw/eBszq+62xUBorVqjgDYsVrZ5JAYJZkc6UKO327LnVn10OUB/bPGA/FzDWZdGB77pt0IalNP8nxyGOytMfg==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-19.1.2.tgz", + "integrity": "sha512-rmLZjwpI+mV3bbd6FgR6yM/ekFNr4QM/Dgzmatkh8k94B5uGtw5Me4EKlY+MrqR3lIyjzqWtLoefcJxA1c9d2w==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-platform-apple": "19.1.1" + "@react-native-community/cli-platform-apple": "19.1.2" } }, "node_modules/@react-native-community/cli-server-api": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-19.1.1.tgz", - "integrity": "sha512-p0FFm82uPrtLZBWTD3bZ43mMBIV5mXwvGFYMcsfGiuMoS9SNbw4ImEFTG2IutVpr7Qb6NMjx6SbgYYMnTdZXmw==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-19.1.2.tgz", + "integrity": "sha512-K6UIvtw6VtcKxCX+rJ5mKQYiqcSSRKODPQ2nbIeIxjjO5nDjDriGkFC/ypHHk38oZuJYOLbOySqnnCNkdEI4uQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "19.1.1", + "@react-native-community/cli-tools": "19.1.2", "body-parser": "^1.20.3", "compression": "^1.7.1", "connect": "^3.6.5", @@ -2970,9 +2968,9 @@ } }, "node_modules/@react-native-community/cli-tools": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-19.1.1.tgz", - "integrity": "sha512-0yWOdrfgO7jVtYzhNcm9hTA1hqrD6haqDaesFq4d3YCmh8lkkTb61Q/kNIKQCUfaCTR/Qcc4mdwy6ObdXRoTIQ==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-19.1.2.tgz", + "integrity": "sha512-AsDuZu/7R/QX+vGpJIRK97v24X+zqkmwA9/uLRguLTHM175nUxb/byXmAKWuZylG2FAikVvf7EqV8MFGbwM7Wg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2989,9 +2987,9 @@ } }, "node_modules/@react-native-community/cli-tools/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "devOptional": true, "license": "ISC", "bin": { @@ -3002,9 +3000,9 @@ } }, "node_modules/@react-native-community/cli-types": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-19.1.1.tgz", - "integrity": "sha512-rOGiYjeDM9tkYBEuK6TJrnxpMhmaId1Un8pjQJswz7W9w2Vb6+nnLfWja7X7VmDIvqIK5GhVobRHsmKCKIdDEA==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-19.1.2.tgz", + "integrity": "sha512-Ze6fi6jE+JPvMlISWbZ/eCPOkRuuEs1SX4rJGWOXPcDzEVF6gs1ePsAjdzQ3RJYRMqQ49vo6iGiOZs//z5kuVw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -3897,9 +3895,9 @@ "license": "ISC" }, "node_modules/@vscode/sudo-prompt": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.1.tgz", - "integrity": "sha512-9ORTwwS74VaTn38tNbQhsA5U44zkJfcb0BdTSyyG6frP4e8KMtHuTXYmwefe5dpL8XB1aGSIVTaLjD3BbWb5iA==", + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", "devOptional": true, "license": "MIT" }, @@ -3969,9 +3967,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4511,24 +4509,24 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "devOptional": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -4545,6 +4543,27 @@ "ms": "2.0.0" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -4552,10 +4571,20 @@ "devOptional": true, "license": "MIT" }, - "node_modules/brace-expansion": { + "node_modules/body-parser/node_modules/statuses": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", "dependencies": { @@ -5118,9 +5147,9 @@ } }, "node_modules/cosmiconfig": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5256,9 +5285,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", "devOptional": true, "license": "MIT" }, @@ -5499,9 +5528,9 @@ } }, "node_modules/envinfo": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.14.0.tgz", - "integrity": "sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg==", + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", "devOptional": true, "license": "MIT", "bin": { @@ -5530,17 +5559,21 @@ } }, "node_modules/errorhandler": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.1.tgz", - "integrity": "sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz", + "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", "devOptional": true, "license": "MIT", "dependencies": { - "accepts": "~1.3.7", + "accepts": "~1.3.8", "escape-html": "~1.0.3" }, "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/es-abstract": { @@ -6086,9 +6119,9 @@ "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6110,9 +6143,9 @@ } }, "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6175,9 +6208,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6216,9 +6249,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6452,9 +6485,9 @@ "license": "MIT" }, "node_modules/fast-xml-parser": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", - "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.7.tgz", + "integrity": "sha512-a6Qh1RMCNbSrU1+sAyAAZH3rTe+OaWJbNZIq0S+ifZciUUOQtlVxBJwoTUE2bYhysmG/RYyI5WJFIKdBahJdrQ==", "devOptional": true, "funding": [ { @@ -6464,7 +6497,7 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.1.1" + "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6592,9 +6625,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -6833,9 +6866,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6843,9 +6876,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -8763,9 +8796,9 @@ } }, "node_modules/joi": { - "version": "17.13.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", - "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { @@ -8798,10 +8831,20 @@ } }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8921,14 +8964,14 @@ } }, "node_modules/launch-editor": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.0.tgz", - "integrity": "sha512-R/PIF14L6e2eHkhvQPu7jDRCr0msfCYCxbYiLgkkAGi0dVPWuM+RrsPu0a5dpuNe0KWGL3jpAkOlv53xGfPheQ==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "devOptional": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.8.4" } }, "node_modules/leven": { @@ -9003,9 +9046,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -9485,9 +9528,9 @@ } }, "node_modules/metro-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -9722,9 +9765,9 @@ } }, "node_modules/metro/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.12", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.12.tgz", + "integrity": "sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -9810,13 +9853,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -10298,9 +10341,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -10456,9 +10499,9 @@ } }, "node_modules/pretty-format/node_modules/@types/yargs": { - "version": "15.0.19", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", - "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "version": "15.0.20", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.20.tgz", + "integrity": "sha512-KIkX+/GgfFitlASYCGoSF+T4XRXhOubJLhkLVtSfsRTe9jWMmuM2g28zQ41BtPTG7TRBb2xHW+LCNVE9QR/vsg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -10535,13 +10578,14 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "devOptional": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -10590,21 +10634,52 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "devOptional": true, "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -10625,9 +10700,9 @@ } }, "node_modules/react-devtools-core/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.12", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.12.tgz", + "integrity": "sha512-1xGnbYN3zbog9CwuNDQULNRrTCLIn46/WmpR1f0w6PsCYQHkylZr5vkd6kfMZYV6pRnQkcPNRyiA8LsrNKyhpg==", "license": "MIT", "engines": { "node": ">=8.3.0" @@ -10728,6 +10803,19 @@ "node": ">=0.1" } }, + "node_modules/react-native-keychain": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/react-native-keychain/-/react-native-keychain-10.0.0.tgz", + "integrity": "sha512-YzPKSAnSzGEJ12IK6CctNLU79T1W15WDrElRQ+1/FsOazGX9ucFPTQwgYe8Dy8jiSEDJKM4wkVa3g4lD2Z+Pnw==", + "license": "MIT", + "workspaces": [ + "KeychainExample", + "website" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/react-native-webrtc": { "version": "124.0.6", "resolved": "https://registry.npmjs.org/react-native-webrtc/-/react-native-webrtc-124.0.6.tgz", @@ -11445,9 +11533,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -11457,15 +11545,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -11477,14 +11565,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -11979,9 +12067,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -11989,9 +12077,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -12594,9 +12682,9 @@ } }, "node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.5.tgz", + "integrity": "sha512-T7pPl+DnmNrKRuttAIQwueReX5GqsedYEWp3/H//CG35+DyUOe1+/voAeE8idxgfLsQf2tO+rdtBd1FnPopgTQ==", "license": "MIT", "dependencies": { "async-limiter": "~1.0.0" @@ -12618,9 +12706,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "devOptional": true, "license": "ISC", "bin": { @@ -12628,6 +12716,9 @@ }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/ClipCascade_Mobile/src/package.json b/ClipCascade_Mobile/src/package.json index a062ecf2d..141315668 100644 --- a/ClipCascade_Mobile/src/package.json +++ b/ClipCascade_Mobile/src/package.json @@ -1,6 +1,6 @@ { "name": "ClipCascade", - "version": "0.0.1", + "version": "3.2.0", "private": true, "scripts": { "android": "react-native run-android", @@ -24,6 +24,7 @@ "react-native": "0.80.2", "react-native-aes-gcm-crypto": "^0.2.2", "react-native-html-parser": "^0.1.0", + "react-native-keychain": "^10.0.0", "react-native-webrtc": "^124.0.6", "text-encoding": "^0.7.0" }, @@ -31,9 +32,9 @@ "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", - "@react-native-community/cli": "19.1.1", - "@react-native-community/cli-platform-android": "19.1.1", - "@react-native-community/cli-platform-ios": "19.1.1", + "@react-native-community/cli": "^19.1.2", + "@react-native-community/cli-platform-android": "^19.1.2", + "@react-native-community/cli-platform-ios": "^19.1.2", "@react-native/babel-preset": "0.80.2", "@react-native/eslint-config": "0.80.2", "@react-native/metro-config": "0.80.2", diff --git a/ClipCascade_Mobile/src/protocolV2.js b/ClipCascade_Mobile/src/protocolV2.js new file mode 100644 index 000000000..8b8e1f2aa --- /dev/null +++ b/ClipCascade_Mobile/src/protocolV2.js @@ -0,0 +1,350 @@ +/** + * E2E clipboard protocol v2 for mobile. + * Envelope: { v, type, senderDeviceId, counter, ts, payload } + * Crypto binds metadata by encrypting an inner object {t,d,c,ts,p} so + * outer labels cannot be swapped without failing decrypt/verify. + */ + +import AesGcmCrypto from 'react-native-aes-gcm-crypto'; +import {Buffer} from 'buffer'; + +export const PROTOCOL_VERSION = 2; +export const REPLAY_CACHE_MAX = 2048; +export const MAX_TRACKED_SENDERS = 64; +export const MAX_CLOCK_SKEW_MS = 10 * 60 * 1000; + +/** + * Rejects duplicate (senderDeviceId, counter) pairs within a bounded window. + * + * The window is kept per sender: a shared window lets one sender's traffic + * evict another's history, which would let a replayed message back in. Both + * the per-sender window and the number of tracked senders are bounded so a + * flood of unique device IDs cannot grow memory without limit. + * + * Callers must only admit senders whose message has already been + * authenticated (see unwrapInbound); otherwise an unauthenticated peer can + * both evict real entries and pin a victim's counter. + */ +export class ReplayCache { + constructor(maxEntries = REPLAY_CACHE_MAX, maxSenders = MAX_TRACKED_SENDERS) { + this.max = maxEntries; + this.maxSenders = maxSenders; + // senderDeviceId -> {seen: Map, last: number|null} + this.senders = new Map(); + } + + accept(senderDeviceId, counter) { + if ( + !senderDeviceId || + typeof counter !== 'number' || + !Number.isSafeInteger(counter) || + counter < 1 + ) { + return false; + } + + let entry = this.senders.get(senderDeviceId); + if (!entry) { + entry = {seen: new Map(), last: null}; + this.senders.set(senderDeviceId, entry); + while (this.senders.size > this.maxSenders) { + this.senders.delete(this.senders.keys().next().value); + } + } else { + // Refresh LRU position so active senders are not evicted first. + this.senders.delete(senderDeviceId); + this.senders.set(senderDeviceId, entry); + } + + const seen = entry.seen; + if (seen.has(counter)) { + return false; + } + const last = entry.last; + if (last != null && counter <= last - this.max) { + return false; + } + + seen.set(counter, true); + while (seen.size > this.max) { + seen.delete(seen.keys().next().value); + } + if (last == null || counter > last) { + entry.last = counter; + } + return true; + } +} + +export function ensureDeviceId(store) { + if (!store.device_id) { + store.device_id = generateUuid(); + } + return store.device_id; +} + +export function nextCounter(store) { + const counter = (Number(store.send_counter) || 0) + 1; + store.send_counter = counter; + return counter; +} + +function generateUuid() { + // RFC4122-ish v4 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * @param {{payload:string,type:string,cipherEnabled:boolean,hashedPassword?:string,store:object}} args + */ +export async function wrapOutbound({ + payload, + type, + cipherEnabled, + hashedPassword, + store, +}) { + const deviceId = ensureDeviceId(store); + const counter = nextCounter(store); + const ts = Date.now(); + + let wirePayload = payload; + if (cipherEnabled) { + if (!hashedPassword) { + throw new Error('cipher enabled but no key'); + } + // Bind metadata inside ciphertext (mobile AES-GCM has no AAD API). + const inner = JSON.stringify({ + t: type, + d: deviceId, + c: counter, + ts, + p: payload, + }); + const encrypted = await AesGcmCrypto.encrypt(inner, false, hashedPassword); + wirePayload = JSON.stringify({ + nonce: Buffer.from(encrypted.iv, 'hex').toString('base64'), + ciphertext: encrypted.content, + tag: Buffer.from(encrypted.tag, 'hex').toString('base64'), + bound: true, + }); + } + + return { + v: PROTOCOL_VERSION, + type, + senderDeviceId: deviceId, + counter, + ts, + payload: wirePayload, + }; +} + +/** + * Wrap a v2 envelope in the outer frame the server relays. + * + * The server models a clipboard message as {payload, type, metadata} and + * rebuilds the relayed copy from exactly those three fields, so any other + * top-level key is dropped in transit. Serialising the envelope into `payload` + * keeps it intact through every server, including versions that predate + * protocol v2 β€” which is what most self-hosters are running. It is also what + * the P2P transport has always done, so both transports now agree. + * + * Only the fields the server models are sent. It does not merely ignore extra + * top-level keys β€” Jackson raises UnrecognizedPropertyException and the whole + * message is dropped before the handler runs, which is why the flat envelope + * did not just arrive looking like v1, it never arrived at all. + */ +export function buildTransportFrame(envelope, type) { + return { + payload: JSON.stringify(envelope), + type, + }; +} + +/** + * Recover the v2 envelope from a relayed frame. + * + * Accepts a frame from buildTransportFrame, and also a flat envelope so the + * client keeps working if a server ever carries the envelope fields itself. + * + * The outer frame's `type`/`metadata` are relay-controlled and deliberately not + * returned: only the envelope reaches unwrapInbound, and only the type bound + * inside the ciphertext is authoritative. + */ +export function extractTransportEnvelope(body) { + if (!body || typeof body !== 'object') { + throw new Error('Invalid clipboard frame'); + } + + if (body.v === PROTOCOL_VERSION && body.senderDeviceId !== undefined) { + return body; + } + + const payload = body.payload; + if (typeof payload !== 'string') { + throw new Error( + `Clipboard frame has no string payload to unwrap (keys: ${Object.keys( + body, + ) + .sort() + .join(',')})`, + ); + } + + let envelope; + try { + envelope = JSON.parse(payload); + } catch (e) { + throw new Error(`Clipboard frame payload is not a v2 envelope: ${e.message}`); + } + + if ( + !envelope || + typeof envelope !== 'object' || + Array.isArray(envelope) || + envelope.payload === undefined + ) { + throw new Error('Clipboard frame payload is not a v2 envelope'); + } + return envelope; +} + +/** + * @returns {Promise<{payload:string,type:string}>} + */ +export async function unwrapInbound(args) { + const {body, cipherEnabled, hashedPassword, replayCache, localDeviceId} = + args ?? {}; + + // Read the legacy opt-in as an OWN property, and require exactly `true`. + // + // A destructuring default (`allowLegacyV1 = false`) fires only when the + // property is `undefined`, and an inherited property is not undefined β€” so + // anything able to write Object.prototype could switch this on for every + // later call. That was reachable: the P2P fragment reassembler indexed a + // plain object with an attacker-supplied key. The reassembler is fixed, and + // this makes the flag unreachable from the prototype chain regardless. + const allowLegacyV1 = + Object.prototype.hasOwnProperty.call(args ?? {}, 'allowLegacyV1') && + args.allowLegacyV1 === true; + + if (!body || body.payload === undefined) { + throw new Error('Invalid clipboard message'); + } + + const version = body.v ?? 1; + // `??` treats an explicit null as absent, but Python's dict.get(k, default) + // returns the null. That difference let `"type": null` pass the bound + // metadata check on mobile while desktop rejected it, so match Python: only + // a genuinely missing key falls back to "text". + let type = body.type === undefined ? 'text' : body.type; + let payload = body.payload; + + // v1 carries no counter, timestamp, or metadata binding, so accepting it + // lets anyone who can inject into the transport strip the "v" field and + // bypass every v2 protection. Refused unless the caller opts in (for mixed + // fleets still running < 3.2.0). + if (version === 1 || version == null) { + if (!allowLegacyV1) { + throw new Error( + 'Rejected legacy v1 message (no replay or metadata binding). ' + + 'Upgrade all devices to 3.2.0+, or enable allowLegacyV1.', + ); + } + if (cipherEnabled) { + payload = await decryptLegacy(payload, hashedPassword); + } + return {payload, type}; + } + + if (version !== PROTOCOL_VERSION) { + throw new Error(`Unsupported protocol version: ${version}`); + } + + const sender = body.senderDeviceId; + const counter = body.counter; + const ts = body.ts; + // Mirrors protocol_v2.py: the counter must be a positive safe integer and the + // timestamp an integer. Without this the two sides disagree on what they will + // accept, and a fractional or out-of-range counter reaches the replay cache. + if ( + typeof sender !== 'string' || + !sender || + !Number.isSafeInteger(counter) || + counter < 1 || + !Number.isSafeInteger(ts) + ) { + throw new Error('Invalid v2 envelope metadata'); + } + if (Math.abs(Date.now() - ts) > MAX_CLOCK_SKEW_MS) { + throw new Error('Message timestamp outside allowed skew'); + } + if (localDeviceId && sender === localDeviceId) { + throw new Error('Ignoring self-originated message'); + } + + if (cipherEnabled) { + // Desktop requires a string here. Accepting an already-parsed object too + // would mean the two runtimes admit different envelopes off the same wire. + if (typeof payload !== 'string') { + throw new Error('Encrypted payload must be a string'); + } + const parsed = JSON.parse(payload); + // "bound" travels outside the AEAD, so it is attacker-mutable. Treat + // anything other than a bound envelope as legacy and refuse it by + // default: honouring bound=false would let a tamperer skip the metadata + // check and rewrite sender/counter/ts at will. + if (parsed.bound === true || parsed.bound === 'true') { + const plain = await AesGcmCrypto.decrypt( + parsed.ciphertext, + hashedPassword, + Buffer.from(parsed.nonce, 'base64').toString('hex'), + Buffer.from(parsed.tag, 'base64').toString('hex'), + false, + ); + const inner = JSON.parse(plain); + if ( + inner.t !== type || + inner.d !== sender || + inner.c !== counter || + inner.ts !== ts + ) { + throw new Error('Bound metadata mismatch (possible tampering)'); + } + payload = inner.p; + } else if (allowLegacyV1) { + payload = await decryptLegacy(payload, hashedPassword); + } else { + throw new Error( + 'Rejected un-bound v2 ciphertext (metadata is not authenticated). ' + + 'Upgrade all devices to 3.2.0+, or enable allowLegacyV1.', + ); + } + } + + // Admitted last: only a message that already authenticated under our key + // may touch replay state. Doing this earlier lets an unauthenticated peer + // evict real entries or pin a victim's counter to stall its traffic. + if (replayCache && !replayCache.accept(sender, counter)) { + throw new Error('Replay or stale counter rejected'); + } + + return {payload, type}; +} + +async function decryptLegacy(payload, hashedPassword) { + const encryptedData = + typeof payload === 'string' ? JSON.parse(payload) : payload; + return AesGcmCrypto.decrypt( + encryptedData.ciphertext, + hashedPassword, + Buffer.from(encryptedData.nonce, 'base64').toString('hex'), + Buffer.from(encryptedData.tag, 'base64').toString('hex'), + false, + ); +} diff --git a/ClipCascade_Server/ClipCascade_Backend/.dockerignore b/ClipCascade_Server/ClipCascade_Backend/.dockerignore new file mode 100644 index 000000000..6528c6e5f --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/.dockerignore @@ -0,0 +1,7 @@ +database/ +logs/ +.git/ +.mvn/wrapper/maven-wrapper.jar +*.db +*.log +*.env diff --git a/ClipCascade_Server/ClipCascade_Backend/Dockerfile b/ClipCascade_Server/ClipCascade_Backend/Dockerfile index 189382d8a..66b493c0e 100644 --- a/ClipCascade_Server/ClipCascade_Backend/Dockerfile +++ b/ClipCascade_Server/ClipCascade_Backend/Dockerfile @@ -4,6 +4,14 @@ # ------------------------- FROM eclipse-temurin:21-jre-jammy +RUN groupadd --system --gid 10001 clipcascade \ + && useradd --system --uid 10001 --gid clipcascade --home-dir /nonexistent --shell /usr/sbin/nologin clipcascade \ + && apt-get update \ + && apt-get install -y --no-install-recommends wget \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /database /logs \ + && chown -R clipcascade:clipcascade /database /logs + # # ------------------------- # # 1) Install prerequisites @@ -32,7 +40,7 @@ FROM eclipse-temurin:21-jre-jammy # ------------------------- # 4) Copy and expose clipcascade app # ------------------------- -COPY target/*.jar app.jar +COPY --chown=clipcascade:clipcascade target/*.jar /app.jar EXPOSE 8080 # # ------------------------- @@ -42,4 +50,9 @@ EXPOSE 8080 # if [ \"${CC_EXTERNAL_BROKER_ENABLED:-false}\" = \"true\" ]; then \ # /opt/activemq/bin/activemq start; \ # fi && exec java -jar /app.jar"] -ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file +USER 10001:10001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8080/health | grep -q OK || exit 1 + +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java index 165eb0484..321bff62a 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java @@ -3,6 +3,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; +import jakarta.annotation.PostConstruct; + @Configuration public class ClipCascadeProperties { @@ -21,8 +23,8 @@ public class ClipCascadeProperties { @Value("${CC_MAX_MESSAGE_SIZE_IN_BYTES:0}") private long maxMessageSizeInBytes; - // Allowed origins for WebSocket connections (default: all origins '*') - @Value("${CC_ALLOWED_ORIGINS:*}") + // Allowed origins for WebSocket connections + @Value("${CC_ALLOWED_ORIGINS:http://localhost:8080}") private String allowedOrigins; // Flag to enable or disable signup form (default: false) @@ -188,13 +190,13 @@ public class ClipCascadeProperties { private String serverDbUsername; /* - * Server database host (default: QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky) + * Server database password. * note: Ensure configuration is included in the application.properties file as * well. * * and are for h2 file database */ - @Value("${CC_SERVER_DB_PASSWORD:QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky}") + @Value("${CC_SERVER_DB_PASSWORD:}") private String serverDbPassword; /* @@ -231,11 +233,11 @@ public class ClipCascadeProperties { private int port; /* - * Server Session timeout (default: 525960m) + * Server Session timeout (default: 1440m) * note: Ensure configuration is included in the application.properties file as * well. */ - @Value("${CC_SESSION_TIMEOUT:525960m}") + @Value("${CC_SESSION_TIMEOUT:1440m}") private String sessionTimeout; /* @@ -277,6 +279,68 @@ public class ClipCascadeProperties { @Value("${CC_DONATIONS_ENABLED:false}") private boolean donationsEnabled; + @Value("${CC_INITIAL_ADMIN_USERNAME:admin}") + private String initialAdminUsername; + + @Value("${CC_INITIAL_ADMIN_PASSWORD:}") + private String initialAdminPassword; + + @Value("${CC_UPDATE_CHECK_ENABLED:true}") + private boolean updateCheckEnabled; + + @PostConstruct + void validateRequiredSecrets() { + if (isH2FileDatabase() && !isServerDbPasswordConfigured()) { + throw new IllegalStateException( + "Set CC_SERVER_DB_PASSWORD before starting an H2 file database."); + } + if (isWildcardOrigin()) { + throw new IllegalStateException( + "CC_ALLOWED_ORIGINS=* is not supported. A wildcard origin lets any website " + + "open an authenticated WebSocket to this server using the visitor's " + + "session cookie and read their clipboard. Set exact origins instead, " + + "comma separated, e.g. " + + "CC_ALLOWED_ORIGINS=http://10.0.0.5:8080,https://host.example.ts.net"); + } + } + + /** + * A wildcard is rejected outright rather than narrowed, because it is + * applied verbatim to both WebSocket endpoints and cannot be made safe + * while credentials are in play. + */ + private boolean isWildcardOrigin() { + if (allowedOrigins == null) { + return false; + } + return java.util.Arrays.stream(allowedOrigins.split(",")) + .map(String::trim) + .anyMatch(origin -> "*".equals(origin)); + } + + private boolean isServerDbPasswordConfigured() { + return serverDbPassword != null && !serverDbPassword.isBlank(); + } + + /** + * True for any on-disk H2 database. + * + * Detected by exclusion rather than by matching "jdbc:h2:file:": H2 also + * accepts jdbc:h2:~/x, jdbc:h2:./x and jdbc:h2:/abs, which are equally + * persistent. A prefix match let those boot with an empty password β€” and + * since the shipped URL carries CIPHER=AES, an empty password also means + * the file is not meaningfully encrypted. + */ + private boolean isH2FileDatabase() { + String url = serverDbUrl == null ? "" : serverDbUrl.trim().toLowerCase(); + String driver = serverDbDriver == null ? "" : serverDbDriver.trim().toLowerCase(); + if (!driver.contains("h2") || !url.startsWith("jdbc:h2:")) { + return false; + } + // In-memory databases are ephemeral and used by the test suite. + return !url.startsWith("jdbc:h2:mem:"); + } + private long getMessageSizeInBytes() { /* * Note: Ensure that the same logic is applied in the activemq.xml file as well. @@ -307,6 +371,21 @@ public String getAllowedOrigins() { return allowedOrigins; } + public String[] getAllowedOriginsArray() { + if (allowedOrigins == null || allowedOrigins.isBlank()) { + return new String[] { "http://localhost:8080" }; + } + + // Defence in depth: validateRequiredSecrets() already fails startup on a + // wildcard, but this method is also reachable from tests and any future + // caller that builds the properties directly. + return java.util.Arrays.stream(allowedOrigins.split(",")) + .map(String::trim) + .filter(origin -> !origin.isBlank()) + .filter(origin -> !"*".equals(origin)) + .toArray(String[]::new); + } + public boolean isSignupEnabled() { return signupEnabled; } @@ -459,6 +538,26 @@ public boolean getDonationsEnabled() { return donationsEnabled; } + public String getInitialAdminUsername() { + return initialAdminUsername; + } + + public String getInitialAdminPassword() { + return initialAdminPassword; + } + + public boolean isInitialAdminPasswordConfigured() { + return initialAdminPassword != null && !initialAdminPassword.isBlank(); + } + + public boolean isUpdateCheckEnabled() { + return updateCheckEnabled; + } + + public boolean getUpdateCheckEnabled() { + return updateCheckEnabled; + } + @Override public String toString() { return "{\n" + @@ -492,6 +591,7 @@ public String toString() { ",\n p2pStunUrl='" + getP2pStunUrl() + "'" + ",\n maxWsGlobalConnections='" + getMaxWsGlobalConnections() + "'" + ",\n maxWsConnectionsPerUser='" + getMaxWsConnectionsPerUser() + "'" + + ",\n updateCheckEnabled='" + isUpdateCheckEnabled() + "'" + "\n}"; } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java index 74fcc796f..75f0d361d 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java @@ -26,6 +26,6 @@ public P2PWebSocketConfig( @Override public void registerWebSocketHandlers(@NonNull WebSocketHandlerRegistry registry) { registry.addHandler(p2pWebSocketHandler, "/p2psignaling") - .setAllowedOrigins(clipCascadeProperties.getAllowedOrigins()); + .setAllowedOrigins(clipCascadeProperties.getAllowedOriginsArray()); } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java index 62b9dcaae..26468f902 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java @@ -28,6 +28,7 @@ import com.acme.clipcascade.utils.TimeUtility; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import ch.qos.logback.classic.Logger; import jakarta.annotation.PreDestroy; @@ -35,6 +36,7 @@ @Component @ConditionalOnProperty(prefix = "app.p2p", name = "enabled", havingValue = "true", matchIfMissing = false) public class P2PWebSocketHandler extends AbstractWebSocketHandler { + private static final int MAX_PEER_ID_LENGTH = 64; private final ObjectMapper objectMapper; private final Logger logger; @@ -250,16 +252,49 @@ protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull Tex String type = json.has("type") ? json.get("type").asText() : ""; String toPeerId = json.has("toPeerId") ? json.get("toPeerId").asText() : null; + String fromPeerId = userSessionsUUID.get(session.getId()); + + // Room-wide device announce / pairing (signed by clients; server only relays). + if ("DEVICE_ANNOUNCE".equals(type) || "PAIR_REQUEST".equals(type) + || "PAIR_ACCEPT".equals(type) || "PAIR_REJECT".equals(type)) { + // Server-assigned identity only. If this session has no peer id + // yet (it can be racing registration), drop the message rather + // than relaying the sender's own fromPeerId/peerId, which would + // let it announce itself as another device. + if (!isValidPeerId(fromPeerId)) { + return; + } + ObjectNode outbound = json.deepCopy(); + outbound.put("fromPeerId", fromPeerId); + outbound.put("peerId", fromPeerId); + TextMessage broadcast = new TextMessage(objectMapper.writeValueAsString(outbound)); + for (Map.Entry entry : userSessions.entrySet()) { + if (!entry.getKey().equals(session.getId())) { + sendMessage(entry.getValue(), broadcast); + } + } + return; + } if ("OFFER".equals(type) || "ANSWER".equals(type) || "ICE_CANDIDATE".equals(type)) { - // Forward to the correct session within this user's room - if (toPeerId != null) { - String targetSessionId = MapUtility.getKeyByValue(userSessionsUUID, toPeerId); - if (targetSessionId != null) { - WebSocketSession targetSession = userSessions.get(targetSessionId); - sendMessage(targetSession, message); - } + if (!isValidPeerId(toPeerId) || !isValidPeerId(fromPeerId)) { + return; + } + + String targetSessionId = MapUtility.getKeyByValue(userSessionsUUID, toPeerId); + if (targetSessionId == null || targetSessionId.equals(session.getId())) { + return; } + + WebSocketSession targetSession = userSessions.get(targetSessionId); + if (targetSession == null) { + return; + } + + ObjectNode outbound = json.deepCopy(); + outbound.put("fromPeerId", fromPeerId); + outbound.put("toPeerId", toPeerId); + sendMessage(targetSession, new TextMessage(objectMapper.writeValueAsString(outbound))); } } finally { lock.unlock(); // release the lock @@ -293,6 +328,40 @@ public void shutdown() { } } + public void closeSessionsForUser(String username) { + if (username == null || username.isBlank()) { + return; + } + + Map userSessions = sessions.get(username); + if (userSessions == null) { + return; + } + + for (WebSocketSession session : userSessions.values()) { + try { + if (session != null && session.isOpen()) { + session.close(CloseStatus.POLICY_VIOLATION); + } + } catch (Exception e) { + logger.debug("Failed to close WebSocket session(P2P) for user {}: {}", username, e.getMessage()); + } + } + } + + private boolean isValidPeerId(String peerId) { + if (peerId == null || peerId.isBlank() || peerId.length() > MAX_PEER_ID_LENGTH) { + return false; + } + + try { + UUID.fromString(peerId); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + private void sendMessage(WebSocketSession session, TextMessage message) { if (session == null || !session.isOpen()) { return; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java index cfac30269..2be17168d 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java @@ -24,17 +24,45 @@ public class SecurityConfiguration { private final BCryptPasswordEncoder bCryptPasswordEncoder; private final BruteForceProtectionService bruteForceProtectionService; private final FacadeUserService facadeUserService; + private final ClipCascadeProperties clipCascadeProperties; SecurityConfiguration( UserDetailsService userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder, BruteForceProtectionService bruteForceProtectionService, - FacadeUserService facadeUserService) { + FacadeUserService facadeUserService, + ClipCascadeProperties clipCascadeProperties) { this.userDetailsService = userDetailsService; this.bCryptPasswordEncoder = bCryptPasswordEncoder; this.bruteForceProtectionService = bruteForceProtectionService; this.facadeUserService = facadeUserService; + this.clipCascadeProperties = clipCascadeProperties; + } + + /** + * connect-src value: 'self' plus every configured origin. + * + * The WebSocket lives on the same origins the operator already lists in + * CC_ALLOWED_ORIGINS, so naming them keeps the socket working while leaving + * CSP able to block a connection to anywhere else. Bare "ws: wss:" allowed + * any host at all. + */ + private String connectSrcOrigins() { + StringBuilder value = new StringBuilder("'self'"); + for (String origin : clipCascadeProperties.getAllowedOriginsArray()) { + if (origin == null || origin.isBlank()) { + continue; + } + value.append(' ').append(origin.trim()); + // The socket uses the ws(s) scheme against the same host. + if (origin.startsWith("https://")) { + value.append(' ').append("wss://").append(origin.substring("https://".length())); + } else if (origin.startsWith("http://")) { + value.append(' ').append("ws://").append(origin.substring("http://".length())); + } + } + return value.toString(); } // SessionRegistry bean to store session information @@ -75,6 +103,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .logout(logout -> logout .logoutUrl("/logout") // The URL to submit a logout request .logoutSuccessUrl("/login?logout")) // Where to go after successful logout + .headers(headers -> headers + .contentSecurityPolicy(csp -> csp.policyDirectives( + // Scripts are external-only (no unsafe-inline). Inline styles remain for legacy templates. + // connect-src names the configured origins rather than bare ws:/wss:, + // which would have allowed a socket to any host and removed CSP as an + // exfiltration backstop. + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src " + + connectSrcOrigins() + + "; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'")) + .referrerPolicy(referrer -> referrer.policy( + org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter.ReferrerPolicy.NO_REFERRER))) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.ALWAYS) // Always create a new session .maximumSessions(-1) // Allow unlimited sessions diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java index 582b21f8b..6ee3244ca 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java @@ -71,7 +71,7 @@ public void configureMessageBroker(@NonNull MessageBrokerRegistry config) { public void registerStompEndpoints(@NonNull StompEndpointRegistry registry) { // Clients will connect to this endpoint for WebSocket communication. registry.addEndpoint("/clipsocket") - .setAllowedOrigins(clipCascadeProperties.getAllowedOrigins()); + .setAllowedOrigins(clipCascadeProperties.getAllowedOriginsArray()); } // Scheduler for WebSocket heartbeats diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/IpResolverConstants.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/IpResolverConstants.java index bafb57654..d362b8e03 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/IpResolverConstants.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/IpResolverConstants.java @@ -2,20 +2,19 @@ public class IpResolverConstants { - // IP header candidates - public static final String[] IP_HEADER_CANDIDATES = { - "X-Forwarded-For", - "Proxy-Client-IP", - "WL-Proxy-Client-IP", - "HTTP_X_FORWARDED_FOR", - "HTTP_X_FORWARDED", - "HTTP_X_CLUSTER_CLIENT_IP", - "HTTP_CLIENT_IP", - "HTTP_FORWARDED_FOR", - "HTTP_FORWARDED", - "HTTP_VIA", - "REMOTE_ADDR" - }; + /** + * The single forwarding header consulted, and only when the request came + * from a trusted proxy. + * + * This used to be a list that also included Proxy-Client-IP, + * WL-Proxy-Client-IP, HTTP_VIA and friends. Trying them in turn is unsafe: + * a real proxy sets and overwrites X-Forwarded-For but does not touch the + * others, so a client could simply send one of them and choose the address + * that brute-force accounting is keyed on. Override only if your proxy uses + * a different header, and make sure that proxy overwrites it on every + * request. + */ + public static final String DEFAULT_FORWARDED_HEADER = "X-Forwarded-For"; // Unknown IP public static final String UNKNOWN = "unknown"; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java index d517971fb..c62d08dd5 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java @@ -2,7 +2,7 @@ public class ServerConstants { // App version - public static final String APP_VERSION = "3.1.0"; + public static final String APP_VERSION = "3.2.0"; // Version URL public static final String VERSION_URL = "https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/version.json"; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java index 80c54b458..e7fb7d2a1 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java @@ -51,11 +51,14 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; import org.springframework.web.bind.annotation.PutMapping; @Controller public class ClipCascadeController { + private static final int OUTBOUND_CONNECT_TIMEOUT_MS = 3000; + private static final int OUTBOUND_READ_TIMEOUT_MS = 5000; private final ClipCascadeProperties clipCascadeProperties; private final UserService userService; @@ -330,9 +333,13 @@ public ResponseEntity getLatestServerVersion( userPrincipal.isAdmin(), () -> ResponseEntityUtil.executeWithResponse( () -> { + if (!clipCascadeProperties.isUpdateCheckEnabled()) { + return Collections.singletonMap("server", ServerConstants.APP_VERSION); + } + try { // get latest version - RestTemplate restTemplate = new RestTemplate(); + RestTemplate restTemplate = restTemplateWithTimeouts(); String versionJson = restTemplate.getForObject( ServerConstants.VERSION_URL, String.class); @@ -347,6 +354,13 @@ public ResponseEntity getLatestServerVersion( "Forbidden"); } + private RestTemplate restTemplateWithTimeouts() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(OUTBOUND_CONNECT_TIMEOUT_MS); + requestFactory.setReadTimeout(OUTBOUND_READ_TIMEOUT_MS); + return new RestTemplate(requestFactory); + } + @GetMapping("/admin/websocket-stats") public ResponseEntity getWebSocketStats( @AuthenticationPrincipal UserPrincipal userPrincipal) { @@ -542,9 +556,11 @@ public ResponseEntity updatePassword( @RequestBody Map payload) { return ResponseEntityUtil.buildResponse( - facadeUserService.updatePassword( + facadeUserService.updateOwnPassword( userPrincipal.getUsername(), - payload.get("newPassword")) != null, + payload.get("currentPassword"), + payload.get("newPassword"), + sessionService) != null, "Password updated successfully", "Invalid user or password"); } @@ -560,7 +576,8 @@ public ResponseEntity updateUserPassword( () -> ResponseEntityUtil.buildResponse( facadeUserService.updatePassword( payload.get("username"), - payload.get("newPassword")) != null, + payload.get("newPassword"), + sessionService) != null, "Password updated successfully", "Invalid user or password"), "Forbidden"); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/DonationService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/DonationService.java index ebb7bc6a4..1b16f0365 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/DonationService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/DonationService.java @@ -3,6 +3,7 @@ import java.util.Map; import org.slf4j.LoggerFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @@ -14,6 +15,9 @@ @Service public class DonationService { + private static final int OUTBOUND_CONNECT_TIMEOUT_MS = 3000; + private static final int OUTBOUND_READ_TIMEOUT_MS = 5000; + private final ClipCascadeProperties clipCascadeProperties; private final ObjectMapper objectMapper; private final Logger logger; @@ -33,7 +37,7 @@ public void initializeDonationUrl() { return; try { - String response = new RestTemplate() + String response = restTemplateWithTimeouts() .getForObject(ServerConstants.METADATA_URL, String.class); donationUrl = (String) objectMapper.readValue(response, Map.class).get("funding"); @@ -46,4 +50,11 @@ public void initializeDonationUrl() { public String getDonationUrl() { return donationUrl; } + + private RestTemplate restTemplateWithTimeouts() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(OUTBOUND_CONNECT_TIMEOUT_MS); + requestFactory.setReadTimeout(OUTBOUND_READ_TIMEOUT_MS); + return new RestTemplate(requestFactory); + } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java index b6984355e..821d966c6 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java @@ -3,9 +3,11 @@ import java.util.Set; import java.util.stream.Collectors; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; import com.acme.clipcascade.config.ClipCascadeProperties; +import com.acme.clipcascade.config.P2PWebSocketHandler; import com.acme.clipcascade.constants.RoleConstants; import com.acme.clipcascade.model.IpAttemptDetails; import com.acme.clipcascade.model.UserInfo; @@ -20,26 +22,42 @@ public class FacadeUserService { private final UserService userService; private final UserInfoService userInfoService; private final ClipCascadeProperties clipCascadeProperties; + private final P2PWebSocketHandler p2pWebSocketHandler; public FacadeUserService( UserService userService, UserInfoService userInfoService, - ClipCascadeProperties clipCascadeProperties) { + ClipCascadeProperties clipCascadeProperties, + @Nullable P2PWebSocketHandler p2pWebSocketHandler) { this.userService = userService; this.userInfoService = userInfoService; this.clipCascadeProperties = clipCascadeProperties; + this.p2pWebSocketHandler = p2pWebSocketHandler; } public void insertDefaultAdminUserIfEmpty() { if (userService.isTableEmpty()) { + if (!clipCascadeProperties.isInitialAdminPasswordConfigured()) { + throw new IllegalStateException( + "Empty user database. Set CC_INITIAL_ADMIN_PASSWORD before first startup."); + } + if (!UserValidator.isValidPassword(clipCascadeProperties.getInitialAdminPassword())) { + throw new IllegalStateException("CC_INITIAL_ADMIN_PASSWORD is too weak."); + } + + String initialAdminUsername = clipCascadeProperties.getInitialAdminUsername(); + if (!UserValidator.isValidUsername(initialAdminUsername)) { + throw new IllegalStateException("CC_INITIAL_ADMIN_USERNAME is invalid."); + } + userService.doubleHashAndCreateUser( - "admin", - "admin123", + initialAdminUsername, + clipCascadeProperties.getInitialAdminPassword(), RoleConstants.ADMIN, true); - userInfoService.registerNewUser("admin"); + userInfoService.registerNewUser(initialAdminUsername); } } @@ -76,7 +94,7 @@ public Users updateUsername( return null; } - sessionService.logoutAllSessions(oldUsername); + revokeSessions(oldUsername, sessionService); UserInfo userInfo = userInfoService.markUserForDeletion(oldUsername); if (userInfo == null) { @@ -96,14 +114,14 @@ public boolean deleteUser(String username, SessionService sessionService) { return false; } - sessionService.logoutAllSessions(username); + revokeSessions(username, sessionService); userInfoService.markUserForDeletion(username); return userService.deleteUser(username); } - public Users updatePassword(String username, String newPassword) { + public Users updatePassword(String username, String newPassword, SessionService sessionService) { if (!UserValidator.isValidUsername(username) || !UserValidator.isValidPassword(newPassword)) { @@ -112,7 +130,25 @@ public Users updatePassword(String username, String newPassword) { userInfoService.setPasswordChangeTime(username, TimeUtility.getCurrentTimeInSeconds()); - return userService.updatePassword(username, newPassword); + Users updatedUser = userService.updatePassword(username, newPassword); + if (updatedUser != null) { + revokeSessions(username, sessionService); + } + + return updatedUser; + } + + public Users updateOwnPassword( + String username, + String currentPassword, + String newPassword, + SessionService sessionService) { + + if (!userService.passwordMatches(username, currentPassword)) { + return null; + } + + return updatePassword(username, newPassword, sessionService); } public Users updateUserStatus( @@ -126,7 +162,7 @@ public Users updateUserStatus( return null; } - sessionService.logoutAllSessions(username); + revokeSessions(username, sessionService); return userService.updateUserStatus(username, enable); } @@ -167,4 +203,11 @@ public void deleteInactiveUsers(SessionService sessionService, Set exclud deleteUser(inactiveUser, sessionService); } } + + private void revokeSessions(String username, SessionService sessionService) { + sessionService.logoutAllSessions(username); + if (p2pWebSocketHandler != null) { + p2pWebSocketHandler.closeSessionsForUser(username); + } + } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java index 3dbc2e0c5..62cc71568 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java @@ -56,6 +56,15 @@ public boolean userExists(String username) { return userRepo.findByUsernameIgnoreCase(username) != null; } + public boolean passwordMatches(String username, String password) { + if (!UserValidator.isValidUsername(username) || !UserValidator.isValidPassword(password)) { + return false; + } + + Users user = userRepo.findById(username).orElse(null); + return user != null && bCryptPasswordEncoder.matches(password, user.getPassword()); + } + public List getUsers(String role) { List users = userRepo.findByRoleOrderByUsernameAsc(role); users.forEach(user -> { diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/IpAddressResolver.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/IpAddressResolver.java index 30f65680a..f37ed63bb 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/IpAddressResolver.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/IpAddressResolver.java @@ -1,5 +1,8 @@ package com.acme.clipcascade.utils; +import java.math.BigInteger; +import java.net.InetAddress; + import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -18,16 +21,287 @@ public static String getUserIpAddress() { } HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest(); - for (String header : IpResolverConstants.IP_HEADER_CANDIDATES) { - String ipList = request.getHeader(header); - if (ipList != null - && !ipList.isEmpty() - && !IpResolverConstants.UNKNOWN.equalsIgnoreCase(ipList)) { + return resolve(request, System.getenv("CC_TRUSTED_PROXY_CIDRS"), forwardedHeader()); + } + + /** + * Pure resolution, with configuration passed in so it can be tested. + * + * The env is read once at the public entry point above; everything below + * this line is a function of its arguments. + */ + public static String resolve( + HttpServletRequest request, String trustedCidrs, String headerName) { + String remoteAddress = request.getRemoteAddr(); + + // Forwarded headers are only meaningful when the peer that set them is a + // proxy we trust. Otherwise any client could name its own IP and defeat + // the per-IP brute-force limits that consume this value. + if (!isTrustedProxy(remoteAddress, trustedCidrs)) { + return remoteAddress; + } - return ipList.split(",")[0].trim(); + String ipList = joinedForwardedHeader(request, headerName); + if (ipList != null && !ipList.isEmpty()) { + String candidate = rightmostUntrustedHop(ipList, trustedCidrs); + if (candidate != null) { + return candidate; } } - return request.getRemoteAddr(); + // Header absent, or every hop in it was itself a trusted proxy. Fall + // back to the socket peer rather than consulting another header: any + // other header is one the proxy does not overwrite, so it would hand + // the client control of this value again. + return remoteAddress; + } + + /** + * All values of the forwarding header, joined left-to-right. + * + * A client can send its own header line, and a proxy that appends rather + * than replaces produces two separate lines. getHeader() returns only the + * FIRST, which is the client's β€” so reading it alone would hand the client + * the value again, defeating the point of walking right-to-left. + */ + private static String joinedForwardedHeader(HttpServletRequest request, String headerName) { + java.util.Enumeration values = request.getHeaders(headerName); + if (values == null) { + return null; + } + StringBuilder joined = new StringBuilder(); + while (values.hasMoreElements()) { + String value = values.nextElement(); + if (value == null || value.isBlank()) { + continue; + } + if (joined.length() > 0) { + joined.append(','); + } + joined.append(value); + } + return joined.toString(); + } + + private static String forwardedHeader() { + String configured = System.getenv("CC_FORWARDED_HEADER"); + if (configured == null || configured.isBlank()) { + return IpResolverConstants.DEFAULT_FORWARDED_HEADER; + } + return configured.trim(); + } + + /** + * The right-most hop that is not itself a trusted proxy, canonicalised. + * + * Proxies append to X-Forwarded-For rather than replacing it (nginx's + * $proxy_add_x_forwarded_for, and every comparable default), so a client + * that sends "X-Forwarded-For: 9.9.9.9" produces "9.9.9.9, <real client>". + * Taking the left-most entry would return the attacker's own chosen value; + * everything to the right of the first untrusted hop was written by + * infrastructure we trust, so that hop is the real client. + * + * Fails closed: if the right-most hop is not a usable IP literal we return + * null (so the caller uses the socket peer) rather than walking further + * left. Skipping junk and continuing would step over infrastructure-written + * entries into client-written ones, which is how "8.8.8.8, 203.0.113.9:54321" + * ended up resolving to the client's own value. + */ + private static String rightmostUntrustedHop(String ipList, String trustedCidrs) { + String[] hops = ipList.split(","); + for (int i = hops.length - 1; i >= 0; i--) { + String hop = hops[i].trim(); + if (hop.isEmpty()) { + continue; + } + String canonical = canonicalIp(hop); + if (canonical == null) { + // Junk, a hostname, or "unknown": stop rather than skip. + return null; + } + if (!isTrustedProxy(canonical, trustedCidrs)) { + return canonical; + } + } + return null; + } + + /** + * Normalise an IP literal to one canonical string, or null if it is not one. + * + * Brute-force accounting keys on this value, so two spellings of the same + * host must not produce two buckets: "203.0.113.050" and "203.0.113.50", + * or "::1" and "[::1]", would otherwise be separate counters and an + * attacker could rotate spellings to stay under the limit. Also strips an + * optional :port, which some proxies append, and refuses hostnames so that + * an inbound header can never trigger a DNS lookup on the request path. + */ + static String canonicalIp(String value) { + if (value == null || value.isBlank()) { + return null; + } + String candidate = value.trim(); + if (IpResolverConstants.UNKNOWN.equalsIgnoreCase(candidate)) { + return null; + } + + // [::1]:8080 or [::1] + if (candidate.startsWith("[")) { + int close = candidate.indexOf(']'); + if (close < 0) { + return null; + } + candidate = candidate.substring(1, close); + } else { + // IPv4 with a port; a bare IPv6 has many colons, so only strip when + // there is exactly one. + int colon = candidate.indexOf(':'); + if (colon >= 0 && candidate.indexOf(':', colon + 1) < 0) { + candidate = candidate.substring(0, colon); + } + } + // Drop an IPv6 zone index (fe80::1%eth0) β€” it is host-local and not + // meaningful as an identity here. + int zone = candidate.indexOf('%'); + if (zone >= 0) { + candidate = candidate.substring(0, zone); + } + if (candidate.isEmpty() || !isIpLiteral(candidate)) { + return null; + } + + try { + // Safe: isIpLiteral has already excluded anything that would resolve. + return InetAddress.getByName(candidate).getHostAddress(); + } catch (Exception e) { + return null; + } + } + + /** + * True for a bare IPv4 or IPv6 literal (optionally bracketed). + * + * Deliberately strict: it gates every value that reaches InetAddress, so a + * hostname never gets that far. + */ + public static boolean isIpLiteral(String value) { + if (value == null || value.isBlank()) { + return false; + } + String candidate = value.trim(); + if (candidate.startsWith("[") && candidate.endsWith("]") && candidate.length() > 2) { + candidate = candidate.substring(1, candidate.length() - 1); + } + if (candidate.indexOf(':') >= 0) { + // At most one "::", and every group a valid hextet. A permissive + // character-class match accepts junk like ":::" β€” which still keys + // a distinct brute-force bucket, so it has to be refused. + if (candidate.indexOf("::") != candidate.lastIndexOf("::")) { + return false; + } + if (candidate.equals(":") || candidate.endsWith(":") && !candidate.endsWith("::")) { + return false; + } + String[] groups = candidate.split(":", -1); + if (groups.length > 8) { + return false; + } + int emptyGroups = 0; + boolean sawHextet = false; + for (int i = 0; i < groups.length; i++) { + String group = groups[i]; + if (group.isEmpty()) { + emptyGroups++; + continue; + } + // A trailing IPv4 form (::ffff:1.2.3.4) is legal in the last group. + if (i == groups.length - 1 && group.indexOf('.') >= 0) { + if (!isDottedQuad(group)) { + return false; + } + sawHextet = true; + continue; + } + if (!group.matches("[0-9A-Fa-f]{1,4}")) { + return false; + } + sawHextet = true; + } + // "::" produces two empties at most; ":::" produces more. + return sawHextet && emptyGroups <= 2; + } + + return isDottedQuad(candidate); + } + + /** + * Strict dotted-quad: no leading zeros. + * + * "203.0.113.050" and "203.0.113.50" are the same host, but Java reads the + * leading zero as decimal while other stacks read it as octal. Accepting + * both spellings would let one client occupy two brute-force buckets, so + * the ambiguous form is refused outright. + */ + private static boolean isDottedQuad(String candidate) { + String[] octets = candidate.split("\\.", -1); + if (octets.length != 4) { + return false; + } + for (String octet : octets) { + if (!octet.matches("(0|[1-9]\\d{0,2})")) { + return false; + } + if (Integer.parseInt(octet) > 255) { + return false; + } + } + return true; + } + + private static boolean isTrustedProxy(String remoteAddress, String trustedCidrs) { + if (trustedCidrs == null || trustedCidrs.isBlank()) { + return false; + } + + for (String cidr : trustedCidrs.split(",")) { + if (addressMatchesCidr(remoteAddress, cidr.trim())) { + return true; + } + } + return false; + } + + private static boolean addressMatchesCidr(String address, String cidr) { + if (address == null || address.isBlank() || cidr == null || cidr.isBlank()) { + return false; + } + // Guard InetAddress against anything that would trigger a DNS lookup. + if (!isIpLiteral(address)) { + return false; + } + + try { + if (!cidr.contains("/")) { + return InetAddress.getByName(address).equals(InetAddress.getByName(cidr)); + } + + String[] parts = cidr.split("/", 2); + InetAddress ip = InetAddress.getByName(address); + InetAddress network = InetAddress.getByName(parts[0]); + int prefixLength = Integer.parseInt(parts[1]); + + byte[] ipBytes = ip.getAddress(); + byte[] networkBytes = network.getAddress(); + if (ipBytes.length != networkBytes.length || prefixLength < 0 || prefixLength > ipBytes.length * 8) { + return false; + } + + BigInteger ipValue = new BigInteger(1, ipBytes); + BigInteger networkValue = new BigInteger(1, networkBytes); + int shift = ipBytes.length * 8 - prefixLength; + return ipValue.shiftRight(shift).equals(networkValue.shiftRight(shift)); + } catch (Exception e) { + return false; + } } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/ResponseEntityUtil.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/ResponseEntityUtil.java index 4ed8a960c..f3514e2d0 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/ResponseEntityUtil.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/ResponseEntityUtil.java @@ -2,6 +2,7 @@ import java.util.function.Supplier; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; public class ResponseEntityUtil { @@ -14,7 +15,7 @@ public static ResponseEntity executeWithResponse(Supplier action) { try { return ResponseEntity.ok(action.get()); } catch (Exception e) { - return ResponseEntity.badRequest().body((T) e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body((T) "Internal server error"); } } @@ -25,6 +26,13 @@ public static ResponseEntity conditionalExecuteOrError(boolean condition, return condition ? successAction.get() - : ResponseEntity.badRequest().body((T) errorMessage); + : ResponseEntity.status(resolveErrorStatus(errorMessage)).body((T) errorMessage); + } + + private static HttpStatus resolveErrorStatus(String errorMessage) { + if ("Forbidden".equalsIgnoreCase(errorMessage)) { + return HttpStatus.FORBIDDEN; + } + return HttpStatus.BAD_REQUEST; } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java index 303fbb886..9b7bc22ca 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java @@ -4,6 +4,10 @@ import com.acme.clipcascade.model.Users; public class UserValidator { + private static final int MIN_RAW_PASSWORD_LENGTH = 12; + private static final int SHA3_512_HEX_LENGTH = 128; + private static final String SHA3_512_HEX_PATTERN = "^[0-9a-fA-F]{128}$"; + public static boolean isValid(Users user) { return user != null && user.getUsername() != null && !user.getUsername().isBlank() @@ -19,7 +23,12 @@ public static boolean isValidUsername(String username) { } public static boolean isValidPassword(String password) { - return password != null && !password.isEmpty(); + if (password == null || password.isBlank()) { + return false; + } + + return password.length() >= MIN_RAW_PASSWORD_LENGTH + || (password.length() == SHA3_512_HEX_LENGTH && password.matches(SHA3_512_HEX_PATTERN)); } public static boolean isValidRole(String role) { diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties index eb3fb7703..dd4b493b5 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties @@ -6,7 +6,7 @@ spring.application.name=ClipCascade spring.datasource.url=${CC_SERVER_DB_URL:jdbc:h2:file:./database/clipcascade;CIPHER=AES;MODE=PostgreSQL} spring.datasource.driverClassName=${CC_SERVER_DB_DRIVER:org.h2.Driver} spring.datasource.username=${CC_SERVER_DB_USERNAME:clipcascade} -spring.datasource.password=${CC_SERVER_DB_PASSWORD:QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky} +spring.datasource.password=${CC_SERVER_DB_PASSWORD:} spring.sql.init.mode=always @@ -21,7 +21,7 @@ spring.jpa.properties.hibernate.dialect=${CC_SERVER_DB_HIBERNATE_DIALECT:org.hib # Server Configuration # --------------------------------------- server.port=${CC_PORT:8080} -server.servlet.session.timeout=${CC_SESSION_TIMEOUT:525960m} +server.servlet.session.timeout=${CC_SESSION_TIMEOUT:1440m} # --------------------------------------- diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/images/logo.svg b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/images/logo.svg new file mode 100644 index 000000000..7e55f9c0d --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/images/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/advance.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/advance.js new file mode 100644 index 000000000..878e5cb80 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/advance.js @@ -0,0 +1,279 @@ + + /** + * Constants for the API endpoints + */ + const ENDPOINTS = { + USER_DETAILS: "/admin/user-details", + WEBSOCKET_STATS: "/admin/websocket-stats", + BFA_SNAPSHOT: "/admin/bfa-snapshot", + BFA_SNAPSHOT_FILE: "/admin/bfa-snapshot-file", + UNLOCK_USER: "/admin/unlock-user", + SERVER_TIME: "/admin/server-time", + }; + + /** + * A reusable fetch function that handles basic error checking. + * + * @param {string} url - The URL endpoint to call. + * @param {string} [method='GET'] - The HTTP method, default is GET. + * @param {Object|null} [body=null] - The request body, if any. + * @returns {Promise} - The fetch response object. + */ + async function fetchData(url, method = "GET", body = null, headers = {}) { + try { + const options = { method, headers: { ...headers } }; + if (body) { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + const response = await fetch(url, options); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response; + } catch (error) { + console.error("Fetch error:", error); + throw error; + } + } + + /** + * Format an epoch time in seconds to a human-readable, locale-specific + * string in the format "YYYY MMM DD HH:MM:SS AM/PM TZ". + * + * @param {number} epoch - The epoch time in seconds. + * @returns {string} - The formatted string, empty if the input is null or 0. + */ + function formatEpochToLocal(epoch) { + if (epoch == null || epoch === 0) return ""; + const date = new Date(epoch * 1000); + const year = date.getFullYear(); + const month = date.toLocaleString(undefined, { month: "short" }); + const day = String(date.getDate()).padStart(2, "0"); + let hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const ampm = hours >= 12 ? "PM" : "AM"; + hours = hours % 12 || 12; + const hour12 = String(hours).padStart(2, "0"); + const tzMatch = date.toTimeString().match(/\(([A-Za-z\s]+)\)$/); + const tz = tzMatch + ? tzMatch[1] + .split(" ") + .map((w) => w[0]) + .join("") + : ""; + return `${year} ${month} ${day} ${hour12}:${minutes}:${seconds} ${ampm} ${tz}`; + } + + /** + * 1) Fetch and display user details. + */ + async function getUserDetails() { + try { + const response = await fetchData(ENDPOINTS.USER_DETAILS); + const data = await response.json(); + const tbody = document.getElementById("user-details-tbody"); + tbody.innerHTML = ""; + + const dateKeys = ["firstSignup", "lastLogin", "passwordChangedAt"]; + data.forEach((user) => { + const row = document.createElement("tr"); + Object.keys(user).forEach((key) => { + const cell = document.createElement("td"); + let text = user[key]; + if (dateKeys.includes(key)) { + text = formatEpochToLocal(user[key]); + } + cell.textContent = text ?? ""; + row.appendChild(cell); + }); + tbody.appendChild(row); + }); + } catch (err) { + console.error(err); + alert("Error fetching user details."); + } + } + + /** + * 2) Fetch and display WebSocket stats. + */ + async function getWebsocketStats() { + try { + const response = await fetchData(ENDPOINTS.WEBSOCKET_STATS); + const textData = await response.text(); + document.getElementById("websocket-stats-output").textContent = + textData; + } catch (error) { + console.error("Error fetching WebSocket stats:", error); + alert("Error fetching WebSocket stats."); + } + } + + /** + * 3a) Fetch Brute Force Protection Tracker data (JSON) and populate table. + */ + async function getBfaTracker() { + try { + const response = await fetchData(ENDPOINTS.BFA_SNAPSHOT); + const trackerData = await response.json(); + const tbody = document.getElementById("bfa-snapshot-tbody"); + tbody.innerHTML = ""; + + for (const [username, tracker] of Object.entries(trackerData)) { + for (const [ip, details] of Object.entries( + tracker.ipAccessDetails || {} + )) { + const row = document.createElement("tr"); + + // Username + row.appendChild( + Object.assign(document.createElement("td"), { + textContent: username, + }) + ); + // IP + row.appendChild( + Object.assign(document.createElement("td"), { textContent: ip }) + ); + // Attempts & lock count + row.appendChild( + Object.assign(document.createElement("td"), { + textContent: details.attempts, + }) + ); + row.appendChild( + Object.assign(document.createElement("td"), { + textContent: details.lockCount, + }) + ); + + // Lock timeout (epoch β†’ formatted) + const lockTd = document.createElement("td"); + lockTd.textContent = formatEpochToLocal(details.lockTimeout); + row.appendChild(lockTd); + + // Unique IPs & user lock timeout + row.appendChild( + Object.assign(document.createElement("td"), { + textContent: (tracker.uniqueIpSet || []).length, + }) + ); + const userLockTd = document.createElement("td"); + userLockTd.textContent = formatEpochToLocal(tracker.lockTimeout); + row.appendChild(userLockTd); + + tbody.appendChild(row); + } + } + } catch (err) { + console.error(err); + alert("Error fetching BFA tracker data."); + } + } + + /** + * 3b) Download Brute Force Protection Tracker Snapshot (as file). + */ + async function downloadBfaSnapshot() { + try { + const response = await fetchData(ENDPOINTS.BFA_SNAPSHOT_FILE); + const blob = await response.blob(); + + // Generate a timestamp-based filename + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const fileName = `brute_force_tracker_snapshot_${timestamp}.json`; + + // Create a link to download the file + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + + // Cleanup + URL.revokeObjectURL(url); + alert("Snapshot downloaded successfully!"); + } catch (error) { + console.error("Error downloading snapshot:", error); + alert("Error downloading snapshot."); + } + } + + /** + * 4) Unlock user based on form submission. + */ + async function unlockUser(event) { + event.preventDefault(); // Prevent default form submission + + const form = document.getElementById("unlock-user-form"); + const formData = new FormData(form); // Get form data + + const username = formData.get("username").trim(); // Retrieve the username + const csrfToken = formData.get("_csrf"); // Retrieve the CSRF token + + if (!username) { + alert("Please enter a username to unlock."); + return; + } + + try { + // PUT request to unlock the user + await fetchData( + ENDPOINTS.UNLOCK_USER, + "PUT", + { username }, + { + "X-CSRF-TOKEN": csrfToken, + } + ); + alert("User unlocked successfully!"); + } catch (error) { + console.error("Error unlocking user:", error); + alert("Error unlocking user."); + } + } + + /** + * 5) Fetch and show server time. + */ + async function showServerTime() { + try { + const response = await fetchData(ENDPOINTS.SERVER_TIME); + const serverTime = await response.text(); + document.getElementById("server-time-output").textContent = + serverTime; + } catch (error) { + console.error("Error fetching server time:", error); + alert("Error fetching server time."); + } + } + + + // Once the DOM is ready, fetch all the admin data + document.addEventListener("DOMContentLoaded", () => { + getUserDetails(); + getWebsocketStats(); + getBfaTracker(); + showServerTime(); + }); + + +document.addEventListener('DOMContentLoaded', function () { + var map = { + 'btn-get-user-details': typeof getUserDetails === 'function' ? getUserDetails : null, + 'btn-get-websocket-stats': typeof getWebsocketStats === 'function' ? getWebsocketStats : null, + 'btn-get-bfa-tracker': typeof getBfaTracker === 'function' ? getBfaTracker : null, + 'btn-download-bfa-snapshot': typeof downloadBfaSnapshot === 'function' ? downloadBfaSnapshot : null, + 'btn-show-server-time': typeof showServerTime === 'function' ? showServerTime : null, + }; + Object.keys(map).forEach(function (id) { + var el = document.getElementById(id); + if (el && map[id]) el.addEventListener('click', map[id]); + }); + var unlockForm = document.getElementById('unlock-user-form'); + if (unlockForm && typeof unlockUser === 'function') { + unlockForm.addEventListener('submit', unlockUser); + } +}); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/login.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/login.js new file mode 100644 index 000000000..f9690a62d --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/login.js @@ -0,0 +1,12 @@ +document.addEventListener("DOMContentLoaded", function () { + const loginForm = document.getElementById("loginForm"); + const passwordField = document.getElementById("password"); + if (!loginForm || !passwordField || typeof sha3_512 !== "function") { + return; + } + + loginForm.addEventListener("submit", function () { + const rawPassword = passwordField.value; + passwordField.value = sha3_512(rawPassword); + }); +}); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js index 22d412e85..13961e087 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js @@ -43,6 +43,15 @@ const SELECTORS = { editUserNewUsername: "#edit-user-new-username", editUserNewPassword: "#edit-user-new-password", editUserEnabled: "#edit-user-enabled", + + // Modals: Change Password + changePasswordModal: "#change-password-modal", + changePasswordForm: "#change-password-form", + changePasswordCurrent: "#change-password-current", + changePasswordNew: "#change-password-new", + changePasswordConfirm: "#change-password-confirm", + changePasswordError: "#change-password-error", + changePasswordClose: "#change-password-close", }; const ENDPOINTS = { @@ -178,6 +187,12 @@ function initEventHandlers() { // Add User Modal form submission $(SELECTORS.addUserForm).submit(onAddUserSubmit); + // Change Password modal + $(SELECTORS.changePasswordForm).submit(onChangePasswordSubmit); + $(SELECTORS.changePasswordClose).click(hideChangePasswordModal); + $("#add-user-modal-close").click(hideAddUserModal); + $("#edit-user-modal-close").click(hideEditUserModal); + // Edit User Modal form submission $(SELECTORS.editUserForm).submit(onEditUserSubmit); } @@ -301,6 +316,17 @@ function hideAddUserModal() { function hideEditUserModal() { hideModal(SELECTORS.editUserModal); } +function hideChangePasswordModal() { + hideModal(SELECTORS.changePasswordModal); + $(SELECTORS.changePasswordForm)[0]?.reset(); + $(SELECTORS.changePasswordError).hide().text(""); +} +function showChangePasswordModal() { + $(SELECTORS.changePasswordForm)[0]?.reset(); + $(SELECTORS.changePasswordError).hide().text(""); + showModal(SELECTORS.changePasswordModal); + $(SELECTORS.changePasswordCurrent).trigger("focus"); +} function validateUsername(username) { return ( username && @@ -795,28 +821,31 @@ function displayIncomingMessage(message) { const type = message.type || "text"; const encodedText = base64EncodeUnicode(payload); - const escapedPayload = escapeHtml(payload); - const escapedType = escapeHtml(type); + let metadataText = ""; - let metadataHtml = ""; if (message.metadata) { const metadataStr = JSON.stringify(message.metadata); - metadataHtml = `, metadata:${escapeHtml(metadataStr)}`; + metadataText = `, metadata:${metadataStr}`; } - const row = $(` - - {payload:${escapedPayload}, type:${escapedType}${metadataHtml}} - -
- - -
- - - `); + const row = $(""); + row.append($("").text(`{payload:${payload}, type:${type}${metadataText}}`)); + + const buttonContainer = $("
").addClass("button-container"); + buttonContainer.append( + $("") + .addClass("btn btn-primary download-btn") + .text("Download") + .on("click", () => downloadFile(filename, encodedText)) + ); + buttonContainer.append( + $("") + .addClass("btn btn-default copy-btn") + .text("Copy") + .on("click", () => copyToClipboard(payload)) + ); + + row.append($("").append(buttonContainer)); $(SELECTORS.conversationBody).append(row); } @@ -850,23 +879,58 @@ function onChangeUsernameClick() { } function onChangePasswordClick() { - const newPassword = prompt("Enter your new password:"); + showChangePasswordModal(); +} + +function showChangePasswordError(message) { + $(SELECTORS.changePasswordError).text(message).show(); +} + +function onChangePasswordSubmit(event) { + event.preventDefault(); + + const currentPassword = $(SELECTORS.changePasswordCurrent).val() || ""; + const newPassword = $(SELECTORS.changePasswordNew).val() || ""; + const confirmPassword = $(SELECTORS.changePasswordConfirm).val() || ""; + + if (!currentPassword) { + showChangePasswordError("Current password is required."); + return; + } if (!newPassword) { - alert("Password cannot be empty."); + showChangePasswordError("New password cannot be empty."); + return; + } + if (newPassword.length < 12) { + showChangePasswordError("Password must be at least 12 characters long."); return; } - const hashedPassword = sha3_512(newPassword); + if (newPassword !== confirmPassword) { + showChangePasswordError("New password and confirmation do not match."); + return; + } + + const hashedCurrentPassword = sha3_512(currentPassword); + const hashedNewPassword = sha3_512(newPassword); $.ajax({ url: ENDPOINTS.updatePassword, type: "PUT", contentType: "application/json", - data: JSON.stringify({ newPassword: hashedPassword }), + data: JSON.stringify({ + currentPassword: hashedCurrentPassword, + newPassword: hashedNewPassword, + }), success: (res) => { - alert(res); + hideChangePasswordModal(); + alert(`${res}. Please sign in again.`); + window.location.href = "/login?expired"; }, error: (err) => { - alert("Failed to update password"); + const msg = + (err && err.responseText) || + "Failed to update password. Check current password and try again."; + showChangePasswordError(msg); console.error(err); }, }); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/signup.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/signup.js new file mode 100644 index 000000000..54a601cd2 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/signup.js @@ -0,0 +1,72 @@ + + // Function to refresh the captcha image + function refreshCaptcha() { + const captchaInput = document.getElementById("captchaInput"); + const captchaImage = document.getElementById("captchaImage"); + captchaInput.value = ""; + captchaImage.src = "/captcha?" + new Date().getTime(); // Adding timestamp to avoid caching + } + + document.addEventListener("DOMContentLoaded", function () { + const signupForm = document.getElementById("signupForm"); + const passwordField = document.getElementById("password"); + const confirmPasswordField = document.getElementById("confirmPassword"); + const captchaInput = document.getElementById("captchaInput"); + const captchaImage = document.getElementById("captchaImage"); + const errorDiv = document.getElementById("errorDiv"); + + // Handle form submission using AJAX + signupForm.addEventListener("submit", function (event) { + event.preventDefault(); + + // Check if passwords match + if (passwordField.value !== confirmPasswordField.value) { + alert("Passwords do not match!"); + return; + } + + const formData = new FormData(signupForm); + formData.set("password", sha3_512(passwordField.value)); // hash the password + formData.delete("confirmPassword"); // Remove the confirm password field + + fetch(signupForm.action, { + method: "POST", + body: formData, + }) + .then((response) => { + if (response.ok) { + //success + window.location.href = "/login?registered"; + } else { + //failure + response.text().then((text) => { + if (text.toLowerCase().includes("captcha")) { + // invalid captcha + refreshCaptcha(); // Refresh the captcha + alert("Captcha is invalid. Please try again."); + } else { + // registration error + if (text.trim() !== "") { + errorDiv.textContent = text; + } + errorDiv.style.display = "block"; + refreshCaptcha(); // Refresh the captcha + } + }); + } + }) + .catch((error) => { + console.error("Error:", error); + alert( + "There was an error with the registration process. Please try again." + ); + }); + }); + }); + +document.addEventListener('DOMContentLoaded', function () { + var btn = document.getElementById('refresh-captcha-btn'); + if (btn && typeof refreshCaptcha === 'function') { + btn.addEventListener('click', refreshCaptcha); + } +}); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html index 34a43b866..866a111a3 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html @@ -9,7 +9,7 @@ - + @@ -228,7 +228,7 @@

Admin Panel