From 6917b5989e584feeed10d466fe6d31401bf89597 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sun, 30 Aug 2026 00:46:18 +0300 Subject: [PATCH 01/32] add Reticulum RPC and realtime networking --- docs/qapp-reticulum.md | 113 +++++ electron/resources/presence_bridge.py | 473 ++++++++++++++++++++ electron/resources/presence_bridge_test.py | 143 ++++++ electron/src/preload.ts | 16 + electron/src/qapp-reticulum-framing.test.ts | 123 +++++ electron/src/qapp-reticulum-framing.ts | 135 ++++++ electron/src/qapp-reticulum-manager.test.ts | 223 +++++++++ electron/src/qapp-reticulum-manager.ts | 309 +++++++++++++ electron/src/reticulum-bridge.ts | 60 ++- electron/src/setup.ts | 87 ++++ examples/qapp-reticulum/backend.py | 167 +++++++ examples/qapp-reticulum/client.js | 37 ++ protocol-v1-vectors.json | 47 ++ src/hooks/useQortalMessageListener.tsx | 67 ++- src/i18n/locales/ar/question.json | 1 + src/i18n/locales/de/question.json | 1 + src/i18n/locales/en/question.json | 1 + src/i18n/locales/es/question.json | 1 + src/i18n/locales/et/question.json | 1 + src/i18n/locales/fi/question.json | 1 + src/i18n/locales/fr/question.json | 1 + src/i18n/locales/it/question.json | 1 + src/i18n/locales/ja/question.json | 1 + src/i18n/locales/pt/question.json | 1 + src/i18n/locales/ru/question.json | 1 + src/i18n/locales/zh/question.json | 1 + src/qortal/get.ts | 43 ++ src/qortal/qortal-requests.ts | 68 +++ src/types/window.d.ts | 13 + 29 files changed, 2134 insertions(+), 2 deletions(-) create mode 100644 docs/qapp-reticulum.md create mode 100644 electron/src/qapp-reticulum-framing.test.ts create mode 100644 electron/src/qapp-reticulum-framing.ts create mode 100644 electron/src/qapp-reticulum-manager.test.ts create mode 100644 electron/src/qapp-reticulum-manager.ts create mode 100644 examples/qapp-reticulum/backend.py create mode 100644 examples/qapp-reticulum/client.js create mode 100644 protocol-v1-vectors.json diff --git a/docs/qapp-reticulum.md b/docs/qapp-reticulum.md new file mode 100644 index 00000000..bc885253 --- /dev/null +++ b/docs/qapp-reticulum.md @@ -0,0 +1,113 @@ +# Reticulum networking for Q-Apps + +This desktop-only API gives a Q-App fetch-like RPC path and a persistent, +message-oriented Reticulum path. Q-Apps never receive an `RNS.Link`, Channel, +Buffer, path-discovery state, or stream chunks. + +Backends use the dedicated Reticulum destination aspect +`qortal-hub-v3.qapp-backend.v1`; Qortal presence destinations are not reused. + +## API + +- `RNS_REQUEST`: `destination`, `path`, `payload`, optional `timeoutMs`, + `maxResponseBytes`, and application `requestId`. +- `RNS_CONNECT`: `destination`; returns `{ connectionId, state }`. +- `RNS_SEND`: `connectionId`, `payload`; resolves once the complete logical + message is queued and written. +- `RNS_CLOSE`: `connectionId`. + +The host posts `RNS_MESSAGE` and `RNS_CONNECTION_STATE` events to the owning +Q-App iframe. States are `CONNECTING`, `CONNECTED`, `RECONNECTING`, +`DISCONNECTED`, `CLOSING`, `CLOSED`, and `ERROR`. + +The first operation for a destination in a tab displays a permission prompt. +Permission and connections are isolated by trusted tab, service, application +name, and destination. Closing or reloading the tab removes its logical +connections. This initial version does not consume a destination declared in +QDN metadata because no owner-signed metadata field exists yet. + +## Pooling and reconnects + +RPC and realtime operations for one isolation key share one physical Link. +Concurrent establishment joins one pending Link operation. Logical connection +IDs remain stable when that Link is replaced. Reconnect uses exponential +backoff with jitter. Incomplete stream frames are discarded with the old +Buffer; complete unacknowledged messages are resent from their beginning. + +RPC calls are not replayed automatically after submission. Backends should use +the supplied application `requestId` to deduplicate non-idempotent operations. + +## Wire protocol version 1 + +Version 1 is frozen. Incompatible changes require a new destination aspect such +as `qortal-hub-v3.qapp-backend.v2`. The canonical machine-readable vectors are +in [`protocol-v1-vectors.json`](../protocol-v1-vectors.json), with an identical +copy in the Python backend. + +Every Buffer frame uses network byte order: + +| Field | Size | +| ------------------------------------- | --------------: | +| version | 1 byte | +| type (`DATA=1`, `ACK=2`, `CONTROL=3`) | 1 byte | +| transport message ID | 8 bytes | +| payload length | 4 bytes | +| payload | declared length | + +The parser supports partial and coalesced reads. Frames are limited to 256 KiB, +the receive accumulator to twice that, the unacknowledged queue to 64 messages, +and queued bytes to 2 MiB. Received IDs are retained in a bounded 512-entry +deduplication cache. An ACK is returned even for a duplicate DATA frame. + +Realtime uses bidirectional RNS Buffer stream ID `7`. Writers loop over partial +writes and flush only after the complete frame is accepted. Each sender starts +at a random nonzero 63-bit message ID, increments modulo `2^64`, and retains +complete pending frames. ACK carries one DATA ID in the header and has an empty +payload. ACK timeout is 120 seconds; there is no timer-driven same-Link retry. + +DATA payload is compact UTF-8 JSON: + +```json +{ "connectionId": "rns-", "payloadBase64": "...", "encoding": "json" } +``` + +`encoding` is `json` for a Base64-wrapped UTF-8 JSON value and `base64` for +arbitrary bytes. Standard padded Base64 is used. JSON key order and whitespace +are not normative. Multiple stable logical `connectionId` values can share the +Link. The complete envelope, not only the decoded Q-App body, must fit 256 KiB. + +CONTROL `3` supports exactly `{"type":"PING"}` and `{"type":"PONG"}`. +PING receives PONG with the same header message ID. CONTROL is not ACKed. Other +control shapes are protocol errors. Application authentication/session resume +is above transport; v1 has no WELCOME or RESUME transport control. + +RPC `link.request()` data is a native dictionary with `version: 1`, application +`requestId`, `encoding`, and `payloadBase64`. The backend returns a native JSON +value or bytes directly. Requests are limited to 256 KiB decoded; responses are +limited to the requested bound, at most 1 MiB. Submitted RPCs are never replayed +automatically after ambiguous failure. + +Reconnect begins at 0.5 seconds, doubles with 0.8–1.2 jitter, and caps at 30 +seconds. Partial Buffer bytes are discarded. Complete unacknowledged DATA frames +are resent in message-ID order from byte zero. Closing the last logical +connection prevents a pending reconnect timer from reopening the Link. + +Transport IDs prevent duplicate delivery across reconnects. They do not prevent +duplicate business operations: Q-Apps must include their own operation IDs for +payments, game actions, or any other non-idempotent operation. + +## Backend example and limits + +[`examples/qapp-reticulum/backend.py`](../examples/qapp-reticulum/backend.py) +creates a persistent identity, announces the backend destination, serves +`/hello`, and echoes realtime frames. The accompanying `client.js` shows all +four Q-App actions and both event types. + +Current limitations: + +- Electron only; browser and mobile builds return a native-transport error. +- Backend identity authorization is explicit user approval, not QDN metadata. +- `RNS.Resource` upload/download actions are intentionally deferred. Realtime + messages and RPC bodies are bounded and are not suitable for large files. +- Request cancellation and a bulk-priority scheduler are not exposed yet. +- Application session/subscription resumption remains the Q-App/backend's job. diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index e93d647c..bbac9bd7 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -15,6 +15,7 @@ import shutil import socket import statistics +import struct import sys import threading import time @@ -26743,6 +26744,470 @@ def handle_send_group_audio_link_heartbeat(req_id: str, payload: Dict[str, Any]) ) +_QAPP_RNS_VERSION = 1 +_QAPP_RNS_DATA = 1 +_QAPP_RNS_ACK = 2 +_QAPP_RNS_CONTROL = 3 +_QAPP_RNS_HEADER = struct.Struct(">BBQI") +_QAPP_RNS_STREAM_ID = 7 +_QAPP_RNS_MAX_FRAME = int(os.environ.get("QORTAL_QAPP_RNS_MAX_FRAME_BYTES", 256 * 1024)) +_QAPP_RNS_MAX_QUEUE = int(os.environ.get("QORTAL_QAPP_RNS_MAX_UNACKED", 64)) +_QAPP_RNS_MAX_QUEUE_BYTES = int(os.environ.get("QORTAL_QAPP_RNS_MAX_QUEUE_BYTES", 2 * 1024 * 1024)) +_QAPP_RNS_IDLE_SECONDS = float(os.environ.get("QORTAL_QAPP_RNS_IDLE_SECONDS", 300)) +_QAPP_RNS_ACK_TIMEOUT_SECONDS = float(os.environ.get("QORTAL_QAPP_RNS_ACK_TIMEOUT_SECONDS", 120)) +_qapp_rns_entries: Dict[str, Dict[str, Any]] = {} + + +def _qapp_rns_error(req_id: str, code: str) -> None: + emit_resp(req_id, False, payload={"code": code}, error=code) + + +def _qapp_rns_frame(frame_type: int, message_id: int, payload: bytes = b"") -> bytes: + if len(payload) > _QAPP_RNS_MAX_FRAME: + raise ValueError("RNS_MESSAGE_TOO_LARGE") + return _QAPP_RNS_HEADER.pack(_QAPP_RNS_VERSION, frame_type, message_id, len(payload)) + payload + + +def _qapp_rns_write(entry: Dict[str, Any], frame: bytes) -> bool: + writer = entry.get("writer") + if writer is None or entry.get("established") is not True: + return False + with entry["write_lock"]: + offset = 0 + deadline = time.monotonic() + 10.0 + while offset < len(frame): + if entry.get("writer") is not writer or time.monotonic() >= deadline: + return False + written = writer.write(frame[offset:]) + written = int(written or 0) + if written <= 0: + time.sleep(0.01) + continue + offset += written + writer.flush() + entry["last_used"] = time.time() + return True + + +def _qapp_rns_emit_state(entry: Dict[str, Any], state: str, reason: str = "") -> None: + for connection_id in list(entry.get("connections") or []): + emit_event("qapp_rns", { + "managerKey": entry["managerKey"], + "connectionId": connection_id, + "kind": "state", + "state": state, + **({"reason": reason} if reason else {}), + }) + + +def _qapp_rns_handle_frame(entry: Dict[str, Any], frame_type: int, message_id: int, payload: bytes) -> None: + if frame_type == _QAPP_RNS_ACK: + if payload: + raise ValueError("ACK payload must be empty") + with _state_lock: + removed = entry["unacked"].pop(message_id, None) + if removed is not None: + timer = removed.get("timer") + if timer is not None: + timer.cancel() + entry["queued_bytes"] = max( + 0, int(entry.get("queued_bytes") or 0) - len(removed["frame"]) + ) + _qapp_rns_schedule_idle(entry) + return + if frame_type == _QAPP_RNS_CONTROL: + control = json.loads(payload.decode("utf-8")) + if not isinstance(control, dict) or set(control) != {"type"}: + raise ValueError("invalid control frame") + control_type = control.get("type") + if control_type == "PING": + response = json.dumps({"type": "PONG"}, separators=(",", ":")).encode("utf-8") + _qapp_rns_write(entry, _qapp_rns_frame(_QAPP_RNS_CONTROL, message_id, response)) + return + if control_type == "PONG": + return + raise ValueError("unsupported control frame") + if frame_type != _QAPP_RNS_DATA: + raise ValueError("unsupported frame type") + duplicate = False + with _state_lock: + duplicate = message_id in entry["received_set"] + if not duplicate: + if len(entry["received_ids"]) >= 512: + expired = entry["received_ids"].popleft() + entry["received_set"].discard(expired) + entry["received_ids"].append(message_id) + entry["received_set"].add(message_id) + _qapp_rns_write(entry, _qapp_rns_frame(_QAPP_RNS_ACK, message_id)) + if duplicate: + return + envelope = json.loads(payload.decode("utf-8")) + connection_id = str(envelope.get("connectionId") or "") + if connection_id not in entry.get("connections", set()): + return + emit_event("qapp_rns", { + "managerKey": entry["managerKey"], + "connectionId": connection_id, + "kind": "message", + "payloadBase64": str(envelope.get("payloadBase64") or ""), + "encoding": "base64" if envelope.get("encoding") == "base64" else "json", + }) + + +def _qapp_rns_reader(entry: Dict[str, Any], generation: int) -> None: + reader = entry.get("reader") + buffered = bytearray() + try: + while entry.get("generation") == generation and entry.get("established") is True: + chunk = reader.read(64 * 1024) + if chunk is None: + time.sleep(0.01) + continue + if chunk == b"": + return + buffered.extend(chunk) + if len(buffered) > _QAPP_RNS_MAX_FRAME * 2: + raise ValueError("receive buffer exceeded") + while len(buffered) >= _QAPP_RNS_HEADER.size: + version, frame_type, message_id, payload_len = _QAPP_RNS_HEADER.unpack_from(buffered) + if ( + version != _QAPP_RNS_VERSION + or frame_type not in (_QAPP_RNS_DATA, _QAPP_RNS_ACK, _QAPP_RNS_CONTROL) + or payload_len > _QAPP_RNS_MAX_FRAME + ): + raise ValueError("invalid frame header") + total = _QAPP_RNS_HEADER.size + payload_len + if len(buffered) < total: + break + payload = bytes(buffered[_QAPP_RNS_HEADER.size:total]) + del buffered[:total] + _qapp_rns_handle_frame(entry, frame_type, message_id, payload) + except Exception as exc: + log(f"[presence_bridge] target=qapp-rns protocol_error destination={entry.get('destination')} error={type(exc).__name__}") + link = entry.get("link") + if link is not None: + try: + link.teardown() + except Exception: + pass + + +def _qapp_rns_established(manager_key: str, generation: int, link) -> None: + with _state_lock: + entry = _qapp_rns_entries.get(manager_key) + if entry is None or entry.get("generation") != generation or entry.get("link") is not link: + try: + link.teardown() + except Exception: + pass + return + entry["established"] = True + entry["establishing"] = False + entry["reconnect_attempt"] = 0 + channel = link.get_channel() + entry["writer"] = RNS.Buffer.create_writer(_QAPP_RNS_STREAM_ID, channel) + entry["reader"] = RNS.Buffer.create_reader(_QAPP_RNS_STREAM_ID, channel) + entry["established_event"].set() + unacked = [value["frame"] for _, value in sorted(entry["unacked"].items())] + threading.Thread(target=_qapp_rns_reader, args=(entry, generation), daemon=True, name="qapp-rns-reader").start() + for frame in unacked: + if not _qapp_rns_write(entry, frame): + break + _qapp_rns_emit_state(entry, "CONNECTED") + + +def _qapp_rns_schedule_reconnect(entry: Dict[str, Any]) -> None: + if not entry.get("connections"): + return + attempt = int(entry.get("reconnect_attempt") or 0) + 1 + entry["reconnect_attempt"] = attempt + delay = min(30.0, 0.5 * (2 ** min(attempt - 1, 6))) * (0.8 + secrets.randbelow(41) / 100.0) + + def reconnect() -> None: + with _state_lock: + current = _qapp_rns_entries.get(entry["managerKey"]) + if current is not entry or not entry.get("connections") or entry.get("established") is True: + return + _qapp_rns_open_link(entry, wait=False) + + timer = threading.Timer(delay, reconnect) + timer.daemon = True + entry["reconnect_timer"] = timer + timer.start() + + +def _qapp_rns_schedule_idle(entry: Dict[str, Any]) -> None: + generation = int(entry.get("idle_generation") or 0) + 1 + entry["idle_generation"] = generation + + def cleanup() -> None: + link = None + with _state_lock: + current = _qapp_rns_entries.get(entry["managerKey"]) + if ( + current is not entry + or entry.get("idle_generation") != generation + or entry.get("connections") + or int(entry.get("active_requests") or 0) > 0 + or entry.get("unacked") + or time.time() - float(entry.get("last_used") or 0) < _QAPP_RNS_IDLE_SECONDS + ): + return + _qapp_rns_entries.pop(entry["managerKey"], None) + link = entry.get("link") + entry["generation"] = int(entry.get("generation") or 0) + 1 + if link is not None: + try: + link.teardown() + except Exception: + pass + log(f"[presence_bridge] target=qapp-rns idle_teardown destination={entry.get('destination')}") + + timer = threading.Timer(_QAPP_RNS_IDLE_SECONDS, cleanup) + timer.daemon = True + timer.start() + + +def _qapp_rns_closed(manager_key: str, generation: int, link) -> None: + with _state_lock: + entry = _qapp_rns_entries.get(manager_key) + if entry is None or entry.get("generation") != generation or entry.get("link") is not link: + return + entry["established"] = False + entry["establishing"] = False + entry["link"] = None + entry["writer"] = None + entry["reader"] = None + has_connections = bool(entry.get("connections")) + _qapp_rns_emit_state(entry, "RECONNECTING" if has_connections else "DISCONNECTED") + if has_connections: + _qapp_rns_schedule_reconnect(entry) + + +def _qapp_rns_open_link(entry: Dict[str, Any], wait: bool = True) -> bool: + with _state_lock: + if entry.get("established") is True: + return True + pending = entry.get("establishing") is True or entry.get("link") is not None + established_event = entry["established_event"] + if not pending: + entry["establishing"] = True + established_event.clear() + entry["generation"] = int(entry.get("generation") or 0) + 1 + generation = entry["generation"] + if pending: + return established_event.wait(20.0) if wait else False + destination = entry["destination"] + try: + destination_hash = bytes.fromhex(destination) + if not _reticulum_local_has_path(destination_hash): + _request_and_await_destination_path(destination_hash, 12.0, log_context=f"qapp-rns destination={destination}") + identity = RNS.Identity.recall(destination_hash) + if identity is None: + raise RuntimeError("identity unavailable") + outbound = RNS.Destination( + identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + APP_NAMESPACE, + "qapp-backend", + "v1", + ) + if destination_hash_hex(outbound.hash) != destination: + raise RuntimeError("destination identity mismatch") + link = RNS.Link( + outbound, + established_callback=lambda value: _qapp_rns_established(entry["managerKey"], generation, value), + closed_callback=lambda value: _qapp_rns_closed(entry["managerKey"], generation, value), + ) + with _state_lock: + if entry.get("generation") != generation: + link.teardown() + return False + entry["link"] = link + except Exception: + with _state_lock: + entry["link"] = None + entry["establishing"] = False + if entry.get("connections"): + _qapp_rns_schedule_reconnect(entry) + return False + return established_event.wait(20.0) if wait else False + + +def _qapp_rns_entry(manager_key: str, destination: str) -> Dict[str, Any]: + with _state_lock: + entry = _qapp_rns_entries.get(manager_key) + if entry is not None: + if entry.get("destination") != destination: + raise ValueError("RNS_PERMISSION_DENIED") + return entry + entry = { + "managerKey": manager_key, + "destination": destination, + "link": None, + "writer": None, + "reader": None, + "established": False, + "establishing": False, + "established_event": threading.Event(), + "generation": 0, + "connections": set(), + "active_requests": 0, + "unacked": {}, + "queued_bytes": 0, + "next_message_id": secrets.randbits(63) or 1, + "received_ids": deque(), + "received_set": set(), + "write_lock": threading.Lock(), + "last_used": time.time(), + "reconnect_attempt": 0, + "idle_generation": 0, + } + _qapp_rns_entries[manager_key] = entry + return entry + + +def handle_qapp_rns_connect(req_id: str, payload: Dict[str, Any]) -> None: + try: + manager_key = str(payload.get("managerKey") or "") + destination = str(payload.get("destination") or "").lower() + connection_id = str(payload.get("connectionId") or "") + if not manager_key or not connection_id or len(destination) != 32: + return _qapp_rns_error(req_id, "RNS_DESTINATION_UNREACHABLE") + entry = _qapp_rns_entry(manager_key, destination) + with _state_lock: + entry["connections"].add(connection_id) + if not _qapp_rns_open_link(entry): + with _state_lock: + entry["connections"].discard(connection_id) + _qapp_rns_schedule_idle(entry) + return _qapp_rns_error(req_id, "RNS_LINK_TIMEOUT") + emit_resp(req_id, True, payload={"state": "CONNECTED"}) + except Exception: + _qapp_rns_error(req_id, "RNS_DESTINATION_UNREACHABLE") + + +def handle_qapp_rns_send(req_id: str, payload: Dict[str, Any]) -> None: + manager_key = str(payload.get("managerKey") or "") + connection_id = str(payload.get("connectionId") or "") + with _state_lock: + entry = _qapp_rns_entries.get(manager_key) + if entry is None or connection_id not in entry.get("connections", set()): + return _qapp_rns_error(req_id, "RNS_INVALID_CONNECTION") + raw = str(payload.get("payloadBase64") or "") + envelope = json.dumps({ + "connectionId": connection_id, + "payloadBase64": raw, + "encoding": "base64" if payload.get("encoding") == "base64" else "json", + }, separators=(",", ":")).encode("utf-8") + if len(envelope) > _QAPP_RNS_MAX_FRAME: + return _qapp_rns_error(req_id, "RNS_MESSAGE_TOO_LARGE") + message_id = int(entry["next_message_id"]) + entry["next_message_id"] = (message_id + 1) & 0xffffffffffffffff + frame = _qapp_rns_frame(_QAPP_RNS_DATA, message_id, envelope) + if len(entry["unacked"]) >= _QAPP_RNS_MAX_QUEUE or entry["queued_bytes"] + len(frame) > _QAPP_RNS_MAX_QUEUE_BYTES: + return _qapp_rns_error(req_id, "RNS_SEND_QUEUE_FULL") + def expire_unacked() -> None: + expired = None + with _state_lock: + expired = entry["unacked"].pop(message_id, None) + if expired is not None: + entry["queued_bytes"] = max(0, int(entry.get("queued_bytes") or 0) - len(frame)) + if expired is not None: + emit_event("qapp_rns", { + "managerKey": manager_key, + "connectionId": connection_id, + "kind": "state", + "state": "ERROR", + "reason": "RNS_REQUEST_TIMEOUT", + }) + + timer = threading.Timer(_QAPP_RNS_ACK_TIMEOUT_SECONDS, expire_unacked) + timer.daemon = True + entry["unacked"][message_id] = {"frame": frame, "created": time.time(), "timer": timer} + entry["queued_bytes"] += len(frame) + timer.start() + _qapp_rns_write(entry, frame) + emit_resp(req_id, True, payload={"messageId": str(message_id)}) + + +def handle_qapp_rns_close(req_id: str, payload: Dict[str, Any]) -> None: + manager_key = str(payload.get("managerKey") or "") + connection_id = str(payload.get("connectionId") or "") + with _state_lock: + entry = _qapp_rns_entries.get(manager_key) + if entry is not None: + entry["connections"].discard(connection_id) + entry["last_used"] = time.time() + _qapp_rns_schedule_idle(entry) + emit_resp(req_id, True, payload={"state": "CLOSED"}) + + +def handle_qapp_rns_request(req_id: str, payload: Dict[str, Any]) -> None: + try: + manager_key = str(payload.get("managerKey") or "") + destination = str(payload.get("destination") or "").lower() + entry = _qapp_rns_entry(manager_key, destination) + if not _qapp_rns_open_link(entry): + return _qapp_rns_error(req_id, "RNS_LINK_TIMEOUT") + timeout = min(120.0, max(1.0, float(payload.get("timeoutMs") or 30000) / 1000.0)) + completed = threading.Event() + result: Dict[str, Any] = {} + max_response = min(1024 * 1024, max(1, int(payload.get("maxResponseBytes") or 1024 * 1024))) + + def response_received(receipt) -> None: + try: + value = receipt.get_response() + if isinstance(value, bytes): + response_bytes = value + encoding = "base64" + else: + response_bytes = json.dumps(value, separators=(",", ":")).encode("utf-8") + encoding = "json" + if len(response_bytes) > max_response: + result["code"] = "RNS_RESPONSE_TOO_LARGE" + else: + result["payload"] = {"payloadBase64": base64.b64encode(response_bytes).decode("ascii"), "encoding": encoding} + except Exception: + result["code"] = "RNS_PROTOCOL_ERROR" + completed.set() + + def request_failed(_value=None) -> None: + result["code"] = "RNS_REQUEST_TIMEOUT" + completed.set() + + request_data = { + "version": 1, + "requestId": str(payload.get("requestId") or ""), + "encoding": "base64" if payload.get("encoding") == "base64" else "json", + "payloadBase64": str(payload.get("payloadBase64") or ""), + } + with _state_lock: + if int(entry.get("active_requests") or 0) >= 16: + return _qapp_rns_error(req_id, "RNS_SEND_QUEUE_FULL") + entry["active_requests"] += 1 + receipt = entry["link"].request( + str(payload.get("path") or "/"), + data=request_data, + response_callback=response_received, + failed_callback=request_failed, + timeout=timeout, + ) + if receipt is False: + request_failed() + completed.wait(timeout + 1.0) + with _state_lock: + entry["active_requests"] = max(0, int(entry["active_requests"]) - 1) + entry["last_used"] = time.time() + _qapp_rns_schedule_idle(entry) + if "payload" not in result: + return _qapp_rns_error(req_id, str(result.get("code") or "RNS_REQUEST_TIMEOUT")) + emit_resp(req_id, True, payload=result["payload"]) + except Exception: + _qapp_rns_error(req_id, "RNS_REQUEST_TIMEOUT") + + def handle_command(message: Dict[str, Any]) -> None: req_id = str(message.get("id") or "") action = message.get("action") @@ -26882,6 +27347,14 @@ def handle_command(message: Dict[str, Any]) -> None: handle_ensure_peer_identity(req_id, payload) elif action == "register_peer_identity": handle_register_peer_identity(req_id, payload) + elif action == "qapp_rns_request": + handle_qapp_rns_request(req_id, payload) + elif action == "qapp_rns_connect": + handle_qapp_rns_connect(req_id, payload) + elif action == "qapp_rns_send": + handle_qapp_rns_send(req_id, payload) + elif action == "qapp_rns_close": + handle_qapp_rns_close(req_id, payload) else: emit_resp(req_id, False, error=f"Unknown action: {action}") diff --git a/electron/resources/presence_bridge_test.py b/electron/resources/presence_bridge_test.py index 7308cb09..8df56528 100644 --- a/electron/resources/presence_bridge_test.py +++ b/electron/resources/presence_bridge_test.py @@ -38,6 +38,149 @@ def load_bridge(): return module +class QAppReticulumV1CompatibilityTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.bridge = load_bridge() + vector_path = Path(__file__).parents[2] / "protocol-v1-vectors.json" + cls.vectors = json.loads(vector_path.read_text(encoding="utf-8")) + + def test_frozen_frame_vectors(self): + for key in ("data_frame", "ack_frame", "control_frame"): + vector = self.vectors[key] + encoded = self.bridge._qapp_rns_frame( + int(vector["type"]), + int(vector["message_id_hex"], 16), + bytes.fromhex(vector["payload_hex"]), + ) + self.assertEqual(encoded.hex(), vector["frame_hex"]) + + def test_ping_control_returns_pong_with_same_message_id(self): + writes = [] + + class Writer: + def write(self, data): + writes.append(bytes(data)) + return len(data) + + def flush(self): + return None + + entry = { + "writer": Writer(), + "established": True, + "write_lock": threading.Lock(), + "last_used": 0, + } + vector = self.vectors["control_frame"] + self.bridge._qapp_rns_handle_frame( + entry, + self.bridge._QAPP_RNS_CONTROL, + int(vector["message_id_hex"], 16), + bytes.fromhex(vector["payload_hex"]), + ) + _version, frame_type, message_id, length = self.bridge._QAPP_RNS_HEADER.unpack_from(writes[0]) + self.assertEqual(frame_type, self.bridge._QAPP_RNS_CONTROL) + self.assertEqual(message_id, int(vector["message_id_hex"], 16)) + self.assertEqual(writes[0][self.bridge._QAPP_RNS_HEADER.size:], b'{"type":"PONG"}') + self.assertEqual(length, len(b'{"type":"PONG"}')) + + def test_buffer_write_retries_partial_writes_and_flushes(self): + output = bytearray() + + class PartialWriter: + flushed = False + + def write(self, data): + amount = min(2, len(data)) + output.extend(data[:amount]) + return amount + + def flush(self): + self.flushed = True + + writer = PartialWriter() + entry = { + "writer": writer, + "established": True, + "write_lock": threading.Lock(), + "last_used": 0, + } + self.assertTrue(self.bridge._qapp_rns_write(entry, b"0123456789")) + self.assertEqual(bytes(output), b"0123456789") + self.assertTrue(writer.flushed) + self.assertEqual(self.bridge._QAPP_RNS_STREAM_ID, 7) + + def test_rpc_response_callback_extracts_request_receipt_response(self): + emitted = [] + + class Receipt: + def get_response(self): + return { + "protocol": "qortal-example", + "version": 1, + "items": [], + } + + class Link: + def request(self, _path, **options): + options["response_callback"](Receipt()) + return object() + + entry = {"link": Link(), "active_requests": 0, "last_used": 0} + payload = { + "managerKey": "rpc-receipt-test", + "destination": "00" * 16, + "path": "/example/status", + "requestId": "status-test", + "encoding": "json", + "payloadBase64": base64.b64encode(b"{}").decode("ascii"), + } + + with mock.patch.object(self.bridge, "_qapp_rns_entry", return_value=entry), mock.patch.object( + self.bridge, "_qapp_rns_open_link", return_value=True + ), mock.patch.object(self.bridge, "_qapp_rns_schedule_idle"), mock.patch.object( + self.bridge, + "emit_resp", + side_effect=lambda request_id, ok, payload=None: emitted.append((request_id, ok, payload)), + ): + self.bridge.handle_qapp_rns_request("bridge-request", payload) + + self.assertEqual(len(emitted), 1) + request_id, ok, response = emitted[0] + self.assertEqual(request_id, "bridge-request") + self.assertTrue(ok) + self.assertEqual(response["encoding"], "json") + decoded = json.loads(base64.b64decode(response["payloadBase64"])) + self.assertEqual( + decoded, + {"protocol": "qortal-example", "version": 1, "items": []}, + ) + + def test_close_while_reconnecting_does_not_resurrect_link(self): + entry = { + "managerKey": "close-race-test", + "connections": {"rns-test"}, + "established": False, + "reconnect_attempt": 0, + } + self.bridge._qapp_rns_entries[entry["managerKey"]] = entry + opened = mock.Mock() + try: + with mock.patch.object(self.bridge, "_qapp_rns_open_link", opened), mock.patch.object( + self.bridge.secrets, "randbelow", return_value=20 + ): + self.bridge._qapp_rns_schedule_reconnect(entry) + entry["connections"].clear() + time.sleep(0.65) + opened.assert_not_called() + finally: + timer = entry.get("reconnect_timer") + if timer is not None: + timer.cancel() + self.bridge._qapp_rns_entries.pop(entry["managerKey"], None) + + class PresenceBridgePacketSendDispatcherTest(unittest.TestCase): def setUp(self): self.bridge = load_bridge() diff --git a/electron/src/preload.ts b/electron/src/preload.ts index fef0fde1..de1a410d 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -668,6 +668,22 @@ try { ) as Promise<{ publicKeyBase64: string | null; }>, + qappReticulumRequest: (owner, options) => + ipcRenderer.invoke('qappReticulum:request', owner, options), + qappReticulumConnect: (owner, destination: string) => + ipcRenderer.invoke('qappReticulum:connect', owner, destination), + qappReticulumSend: (owner, connectionId: string, payload) => + ipcRenderer.invoke('qappReticulum:send', owner, connectionId, payload), + qappReticulumClose: (owner, connectionId: string) => + ipcRenderer.invoke('qappReticulum:close', owner, connectionId), + qappReticulumCleanupOwner: (owner) => + ipcRenderer.invoke('qappReticulum:cleanupOwner', owner), + onQAppReticulumEvent: (callback: (payload: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => + callback(payload); + ipcRenderer.on('qappReticulum:event', listener); + return () => ipcRenderer.removeListener('qappReticulum:event', listener); + }, qchatFileSelect: () => ipcRenderer.invoke('reticulum:qchatFileSelect') as Promise<{ ok: boolean; diff --git a/electron/src/qapp-reticulum-framing.test.ts b/electron/src/qapp-reticulum-framing.test.ts new file mode 100644 index 00000000..a59b88ab --- /dev/null +++ b/electron/src/qapp-reticulum-framing.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + decodeQAppRnsControl, + encodeQAppRnsControl, + encodeQAppRnsFrame, + QAppRnsFrameParser, + QAppRnsFrameType, + QAppRnsProtocolError, +} from './qapp-reticulum-framing'; +import vectors from '../../protocol-v1-vectors.json'; + +describe('Q-App Reticulum framing', () => { + it('matches the frozen DATA, ACK, and CONTROL golden vectors', () => { + const cases = [ + [vectors.data_frame, QAppRnsFrameType.Data], + [vectors.ack_frame, QAppRnsFrameType.Ack], + [vectors.control_frame, QAppRnsFrameType.Control], + ] as const; + for (const [vector, type] of cases) { + const encoded = encodeQAppRnsFrame( + type, + BigInt(`0x${vector.message_id_hex}`), + Buffer.from(vector.payload_hex, 'hex') + ); + expect(encoded.toString('hex')).toBe(vector.frame_hex); + const [decoded] = new QAppRnsFrameParser().push(encoded); + expect(decoded.type).toBe(type); + expect(decoded.messageId).toBe(BigInt(`0x${vector.message_id_hex}`)); + expect(decoded.payload.toString('hex')).toBe(vector.payload_hex); + } + expect(encodeQAppRnsControl('PING').toString('hex')).toBe( + vectors.control_frame.payload_hex + ); + expect(decodeQAppRnsControl(encodeQAppRnsControl('PING'))).toEqual({ + type: 'PING', + }); + }); + + it('round-trips the maximum unsigned message ID', () => { + const frame = encodeQAppRnsFrame( + QAppRnsFrameType.Data, + 0xffffffffffffffffn, + Buffer.alloc(0) + ); + expect(new QAppRnsFrameParser().push(frame)[0].messageId).toBe( + 0xffffffffffffffffn + ); + }); + + it('parses complete, binary, and zero-length frames', () => { + const parser = new QAppRnsFrameParser(); + const binary = Buffer.from([0, 255, 7]); + const frames = parser.push( + Buffer.concat([ + encodeQAppRnsFrame(QAppRnsFrameType.Data, 1n, binary), + encodeQAppRnsFrame(QAppRnsFrameType.Ack, 1n, Buffer.alloc(0)), + ]) + ); + expect(frames.map((frame) => frame.type)).toEqual([1, 2]); + expect(frames[0].payload).toEqual(binary); + expect(frames[1].payload).toHaveLength(0); + }); + + it('handles every possible split point', () => { + const encoded = encodeQAppRnsFrame( + QAppRnsFrameType.Data, + 42n, + Buffer.from('partial payload') + ); + for (let split = 1; split < encoded.length; split += 1) { + const parser = new QAppRnsFrameParser(); + expect(parser.push(encoded.subarray(0, split))).toEqual([]); + const frames = parser.push(encoded.subarray(split)); + expect(frames).toHaveLength(1); + expect(frames[0].messageId).toBe(42n); + expect(frames[0].payload.toString()).toBe('partial payload'); + } + }); + + it('parses several frames from one read', () => { + const parser = new QAppRnsFrameParser(); + const bytes = Buffer.concat( + [1n, 2n, 3n].map((id) => + encodeQAppRnsFrame(QAppRnsFrameType.Data, id, Buffer.from(`${id}`)) + ) + ); + expect(parser.push(bytes).map((frame) => frame.messageId)).toEqual([ + 1n, + 2n, + 3n, + ]); + }); + + it('rejects malformed and oversized lengths without retaining input', () => { + const malformed = Buffer.alloc(14); + malformed[0] = 1; + malformed[1] = 1; + malformed.writeUInt32BE(65, 10); + const parser = new QAppRnsFrameParser(64, 128); + expect(() => parser.push(malformed)).toThrow(QAppRnsProtocolError); + expect(parser.bufferedBytes).toBe(0); + }); + + it('rejects unsupported protocol versions and frame types', () => { + for (const offset of [0, 1]) { + const malformed = encodeQAppRnsFrame( + QAppRnsFrameType.Data, + 1n, + Buffer.alloc(0) + ); + malformed[offset] = 99; + expect(() => new QAppRnsFrameParser().push(malformed)).toThrow( + QAppRnsProtocolError + ); + } + }); + + it('rejects unsupported CONTROL payloads', () => { + expect(() => + decodeQAppRnsControl(Buffer.from('{"type":"WELCOME"}')) + ).toThrow(QAppRnsProtocolError); + }); +}); diff --git a/electron/src/qapp-reticulum-framing.ts b/electron/src/qapp-reticulum-framing.ts new file mode 100644 index 00000000..c9649d70 --- /dev/null +++ b/electron/src/qapp-reticulum-framing.ts @@ -0,0 +1,135 @@ +export const QAPP_RNS_PROTOCOL_VERSION = 1; +export const QAPP_RNS_HEADER_BYTES = 14; +export const QAPP_RNS_DEFAULT_MAX_FRAME_BYTES = 256 * 1024; + +export enum QAppRnsFrameType { + Data = 1, + Ack = 2, + Control = 3, +} + +export type QAppRnsControlType = 'PING' | 'PONG'; + +export type QAppRnsControl = { + type: QAppRnsControlType; +}; + +export type QAppRnsFrame = { + version: number; + type: QAppRnsFrameType; + messageId: bigint; + payload: Buffer; +}; + +export class QAppRnsProtocolError extends Error {} + +export function encodeQAppRnsFrame( + type: QAppRnsFrameType, + messageId: bigint, + payload: Uint8Array, + maxFrameBytes = QAPP_RNS_DEFAULT_MAX_FRAME_BYTES +): Buffer { + if (!Object.values(QAppRnsFrameType).includes(type)) { + throw new QAppRnsProtocolError('RNS_PROTOCOL_ERROR'); + } + if (payload.byteLength > maxFrameBytes) { + throw new QAppRnsProtocolError('RNS_MESSAGE_TOO_LARGE'); + } + if (messageId < 0n || messageId > 0xffffffffffffffffn) { + throw new QAppRnsProtocolError('RNS_PROTOCOL_ERROR'); + } + const frame = Buffer.allocUnsafe(QAPP_RNS_HEADER_BYTES + payload.byteLength); + frame.writeUInt8(QAPP_RNS_PROTOCOL_VERSION, 0); + frame.writeUInt8(type, 1); + frame.writeBigUInt64BE(messageId, 2); + frame.writeUInt32BE(payload.byteLength, 10); + Buffer.from(payload).copy(frame, QAPP_RNS_HEADER_BYTES); + return frame; +} + +export function encodeQAppRnsControl(type: QAppRnsControlType): Buffer { + return Buffer.from(JSON.stringify({ type }), 'utf8'); +} + +export function decodeQAppRnsControl(payload: Uint8Array): QAppRnsControl { + try { + const value = JSON.parse(Buffer.from(payload).toString('utf8')); + if ( + !value || + typeof value !== 'object' || + Object.keys(value).length !== 1 || + (value.type !== 'PING' && value.type !== 'PONG') + ) { + throw new Error('unsupported control'); + } + return { type: value.type }; + } catch { + throw new QAppRnsProtocolError('RNS_PROTOCOL_ERROR'); + } +} + +export class QAppRnsFrameParser { + private buffered = Buffer.alloc(0); + + constructor( + private readonly maxFrameBytes = QAPP_RNS_DEFAULT_MAX_FRAME_BYTES, + private readonly maxReceiveBufferBytes = maxFrameBytes * 2 + ) {} + + push(chunk: Uint8Array): QAppRnsFrame[] { + if (chunk.byteLength === 0) return []; + if ( + this.buffered.byteLength + chunk.byteLength > + this.maxReceiveBufferBytes + ) { + this.reset(); + throw new QAppRnsProtocolError('RNS_PROTOCOL_ERROR'); + } + this.buffered = Buffer.concat([this.buffered, Buffer.from(chunk)]); + const frames: QAppRnsFrame[] = []; + while (this.buffered.byteLength >= QAPP_RNS_HEADER_BYTES) { + const version = this.buffered.readUInt8(0); + const type = this.buffered.readUInt8(1); + const messageId = this.buffered.readBigUInt64BE(2); + const payloadLength = this.buffered.readUInt32BE(10); + if (version !== QAPP_RNS_PROTOCOL_VERSION) { + this.reset(); + throw new QAppRnsProtocolError('RNS_PROTOCOL_ERROR'); + } + if ( + ![ + QAppRnsFrameType.Data, + QAppRnsFrameType.Ack, + QAppRnsFrameType.Control, + ].includes(type) + ) { + this.reset(); + throw new QAppRnsProtocolError('RNS_PROTOCOL_ERROR'); + } + if (payloadLength > this.maxFrameBytes) { + this.reset(); + throw new QAppRnsProtocolError('RNS_MESSAGE_TOO_LARGE'); + } + const frameLength = QAPP_RNS_HEADER_BYTES + payloadLength; + if (this.buffered.byteLength < frameLength) break; + frames.push({ + version, + type, + messageId, + payload: Buffer.from( + this.buffered.subarray(QAPP_RNS_HEADER_BYTES, frameLength) + ), + }); + this.buffered = this.buffered.subarray(frameLength); + } + return frames; + } + + reset(): void { + this.buffered = Buffer.alloc(0); + } + + get bufferedBytes(): number { + return this.buffered.byteLength; + } +} diff --git a/electron/src/qapp-reticulum-manager.test.ts b/electron/src/qapp-reticulum-manager.test.ts new file mode 100644 index 00000000..3053636d --- /dev/null +++ b/electron/src/qapp-reticulum-manager.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + QAppReticulumError, + QAppReticulumManager, + type QAppReticulumNativeEvent, + type QAppReticulumOwner, + type QAppReticulumTransport, +} from './qapp-reticulum-manager'; +import vectors from '../../protocol-v1-vectors.json'; + +class FakeTransport implements QAppReticulumTransport { + listeners = new Set<(event: QAppReticulumNativeEvent) => void>(); + invoke = vi.fn(async (action: string, payload: Record) => ({ + ok: true, + payload: + action === 'qapp_rns_request' + ? { + payloadBase64: Buffer.from(JSON.stringify({ ok: true })).toString( + 'base64' + ), + encoding: 'json', + } + : { messageId: '1', ...payload }, + })); + onEvent(listener: (event: QAppReticulumNativeEvent) => void) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} + +const destination = '0123456789abcdef0123456789abcdef'; +const alice: QAppReticulumOwner = { + tabId: '1', + name: 'Alice App', + service: 'APP', +}; +const bob: QAppReticulumOwner = { + tabId: '2', + name: 'Bob App', + service: 'APP', +}; + +describe('QAppReticulumManager', () => { + it('uses the same isolated manager key for RPC and realtime', async () => { + const transport = new FakeTransport(); + const manager = new QAppReticulumManager(transport); + await manager.connect(alice, destination); + await manager.request(alice, { destination, path: '/hello', payload: {} }); + const connectPayload = transport.invoke.mock.calls[0][1]; + const requestPayload = transport.invoke.mock.calls[1][1]; + expect(connectPayload.managerKey).toBe(requestPayload.managerKey); + }); + + it('encodes the frozen RPC request payload and decodes its JSON response', async () => { + const transport = new FakeTransport(); + const manager = new QAppReticulumManager(transport); + const response = await manager.request(alice, { + destination, + path: '/hello', + payload: vectors.rpc_request.application_json, + requestId: vectors.rpc_request.request_id, + }); + const request = transport.invoke.mock.calls[0][1]; + expect(request).toMatchObject({ + path: '/hello', + requestId: vectors.rpc_request.request_id, + encoding: 'json', + payloadBase64: vectors.rpc_request.payload_base64, + }); + expect(response).toEqual({ ok: true }); + }); + + it('encodes RNS_SEND JSON and decodes RNS_MESSAGE JSON symmetrically', async () => { + const transport = new FakeTransport(); + const manager = new QAppReticulumManager(transport); + const listener = vi.fn(); + manager.on('event', listener); + const connection = await manager.connect(alice, destination); + await manager.send( + alice, + connection.connectionId, + vectors.data_envelope.application_json + ); + const sendPayload = transport.invoke.mock.calls[1][1]; + expect(sendPayload).toMatchObject({ + encoding: 'json', + payloadBase64: vectors.data_envelope.payload_base64, + }); + const managerKey = transport.invoke.mock.calls[0][1].managerKey as string; + for (const callback of transport.listeners) { + callback({ + managerKey, + connectionId: connection.connectionId, + kind: 'message', + encoding: 'json', + payloadBase64: vectors.data_envelope.payload_base64, + }); + } + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'RNS_MESSAGE', + payload: vectors.data_envelope.application_json, + }) + ); + }); + + it('delivers a zero-length binary RNS_MESSAGE', async () => { + const transport = new FakeTransport(); + const manager = new QAppReticulumManager(transport); + const listener = vi.fn(); + manager.on('event', listener); + const connection = await manager.connect(alice, destination); + const managerKey = transport.invoke.mock.calls[0][1].managerKey as string; + for (const callback of transport.listeners) { + callback({ + managerKey, + connectionId: connection.connectionId, + kind: 'message', + encoding: 'base64', + payloadBase64: '', + }); + } + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'RNS_MESSAGE', + payload: new Uint8Array(0), + }) + ); + }); + + it('rejects cross-Q-App send and close attempts', async () => { + const manager = new QAppReticulumManager(new FakeTransport()); + const { connectionId } = await manager.connect(alice, destination); + await expect(manager.send(bob, connectionId, {})).rejects.toMatchObject({ + code: 'RNS_INVALID_CONNECTION', + }); + await expect(manager.close(bob, connectionId)).rejects.toBeInstanceOf( + QAppReticulumError + ); + }); + + it('cleans every logical connection owned by a destroyed tab', async () => { + const transport = new FakeTransport(); + const manager = new QAppReticulumManager(transport); + await manager.connect(alice, destination); + await manager.connect(alice, destination); + await manager.cleanupOwner(alice); + expect( + transport.invoke.mock.calls.filter( + ([action]) => action === 'qapp_rns_close' + ) + ).toHaveLength(2); + }); + + it('drops native events whose manager ownership does not match', async () => { + const transport = new FakeTransport(); + const manager = new QAppReticulumManager(transport); + const listener = vi.fn(); + manager.on('event', listener); + const connection = await manager.connect(alice, destination); + for (const callback of transport.listeners) { + callback({ + managerKey: 'attacker', + connectionId: connection.connectionId, + kind: 'message', + payloadBase64: Buffer.from('{}').toString('base64'), + encoding: 'json', + }); + } + expect(listener).not.toHaveBeenCalled(); + }); + + it('does not resurrect a connection closed while native events are pending', async () => { + const transport = new FakeTransport(); + const manager = new QAppReticulumManager(transport); + const listener = vi.fn(); + manager.on('event', listener); + const connection = await manager.connect(alice, destination); + const managerKey = transport.invoke.mock.calls[0][1].managerKey as string; + await manager.close(alice, connection.connectionId); + for (const callback of transport.listeners) { + callback({ + managerKey, + connectionId: connection.connectionId, + kind: 'state', + state: 'CONNECTED', + }); + } + expect(listener.mock.calls.map(([event]) => event.state)).toEqual([ + 'CLOSING', + 'CLOSED', + ]); + await expect( + manager.send(alice, connection.connectionId, {}) + ).rejects.toMatchObject({ code: 'RNS_INVALID_CONNECTION' }); + }); + + it('rejects a raw payload whose Base64 DATA envelope exceeds the frame limit', async () => { + const manager = new QAppReticulumManager(new FakeTransport()); + const connection = await manager.connect(alice, destination); + await expect( + manager.send(alice, connection.connectionId, new Uint8Array(256 * 1024)) + ).rejects.toMatchObject({ code: 'RNS_MESSAGE_TOO_LARGE' }); + }); + + it('does not automatically replay an ambiguously failed RPC', async () => { + const transport = new FakeTransport(); + transport.invoke = vi.fn(async () => ({ + ok: false, + code: 'RNS_REQUEST_TIMEOUT', + })); + const manager = new QAppReticulumManager(transport); + await expect( + manager.request(alice, { + destination, + path: '/create-item', + payload: { value: 1 }, + requestId: 'operation-1', + }) + ).rejects.toMatchObject({ code: 'RNS_REQUEST_TIMEOUT' }); + expect(transport.invoke).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/src/qapp-reticulum-manager.ts b/electron/src/qapp-reticulum-manager.ts new file mode 100644 index 00000000..906aa2ed --- /dev/null +++ b/electron/src/qapp-reticulum-manager.ts @@ -0,0 +1,309 @@ +import { EventEmitter } from 'events'; +import { randomUUID } from 'crypto'; + +export type QAppReticulumState = + | 'CONNECTING' + | 'CONNECTED' + | 'RECONNECTING' + | 'DISCONNECTED' + | 'CLOSING' + | 'CLOSED' + | 'ERROR'; + +export type QAppReticulumOwner = { + tabId: string; + name: string; + service: string; +}; + +export type QAppReticulumNativeEvent = { + managerKey: string; + connectionId?: string; + kind: 'message' | 'state'; + state?: QAppReticulumState; + payloadBase64?: string; + encoding?: 'json' | 'base64'; + reason?: string; +}; + +export interface QAppReticulumTransport { + invoke( + action: string, + payload: Record + ): Promise<{ + ok: boolean; + payload?: Record; + code?: string; + }>; + onEvent(listener: (event: QAppReticulumNativeEvent) => void): () => void; +} + +export class QAppReticulumError extends Error { + constructor( + public readonly code: string, + message = code + ) { + super(message); + } +} + +type LogicalConnection = { + connectionId: string; + ownerKey: string; + managerKey: string; + destination: string; + state: QAppReticulumState; +}; + +const DESTINATION_PATTERN = /^[0-9a-f]{32}$/; +const MAX_RPC_REQUEST_BYTES = 256 * 1024; +const MAX_RPC_RESPONSE_BYTES = 1024 * 1024; +const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024; + +function ownerKey(owner: QAppReticulumOwner): string { + return `${owner.tabId}\u0000${owner.service}\u0000${owner.name}`; +} + +function encodePayload( + payload: unknown, + limit: number +): { + payloadBase64: string; + encoding: 'json' | 'base64'; +} { + const bytes = + payload instanceof Uint8Array + ? Buffer.from(payload) + : Buffer.from(JSON.stringify(payload ?? null), 'utf8'); + if (bytes.byteLength > limit) { + throw new QAppReticulumError('RNS_MESSAGE_TOO_LARGE'); + } + return { + payloadBase64: bytes.toString('base64'), + encoding: payload instanceof Uint8Array ? 'base64' : 'json', + }; +} + +function decodePayload(payload: Record): unknown { + const encoded = String(payload.payloadBase64 ?? ''); + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.byteLength > MAX_RPC_RESPONSE_BYTES) { + throw new QAppReticulumError('RNS_RESPONSE_TOO_LARGE'); + } + return payload.encoding === 'base64' + ? new Uint8Array(bytes) + : JSON.parse(bytes.toString('utf8')); +} + +export class QAppReticulumManager extends EventEmitter { + private readonly connections = new Map(); + private readonly connectionsByOwner = new Map>(); + private readonly detachTransport: () => void; + + constructor(private readonly transport: QAppReticulumTransport) { + super(); + this.detachTransport = transport.onEvent((event) => + this.handleTransportEvent(event) + ); + } + + async request( + owner: QAppReticulumOwner, + options: { + destination: string; + path: string; + payload?: unknown; + timeoutMs?: number; + maxResponseBytes?: number; + requestId?: string; + } + ): Promise { + const destination = this.validateDestination(options.destination); + const path = String(options.path ?? '').trim(); + if (!path.startsWith('/') || path.length > 512) { + throw new QAppReticulumError( + 'RNS_PROTOCOL_ERROR', + 'Invalid request path' + ); + } + const encoded = encodePayload(options.payload, MAX_RPC_REQUEST_BYTES); + const result = await this.transport.invoke('qapp_rns_request', { + managerKey: this.managerKey(owner, destination), + destination, + path, + ...encoded, + timeoutMs: Math.min( + Math.max(options.timeoutMs ?? 30_000, 1_000), + 120_000 + ), + maxResponseBytes: Math.min( + Math.max(options.maxResponseBytes ?? MAX_RPC_RESPONSE_BYTES, 1), + MAX_RPC_RESPONSE_BYTES + ), + requestId: String(options.requestId ?? randomUUID()), + }); + if (!result.ok || !result.payload) { + throw new QAppReticulumError(result.code ?? 'RNS_REQUEST_TIMEOUT'); + } + return decodePayload(result.payload); + } + + async connect( + owner: QAppReticulumOwner, + destinationValue: string + ): Promise<{ connectionId: string; state: QAppReticulumState }> { + const destination = this.validateDestination(destinationValue); + const connectionId = `rns-${randomUUID()}`; + const key = ownerKey(owner); + const connection: LogicalConnection = { + connectionId, + ownerKey: key, + managerKey: this.managerKey(owner, destination), + destination, + state: 'CONNECTING', + }; + this.connections.set(connectionId, connection); + const owned = this.connectionsByOwner.get(key) ?? new Set(); + owned.add(connectionId); + this.connectionsByOwner.set(key, owned); + const result = await this.transport.invoke('qapp_rns_connect', { + managerKey: connection.managerKey, + connectionId, + destination, + }); + if (!result.ok) { + this.removeConnection(connection); + throw new QAppReticulumError( + result.code ?? 'RNS_DESTINATION_UNREACHABLE' + ); + } + connection.state = 'CONNECTED'; + return { connectionId, state: 'CONNECTED' }; + } + + async send( + owner: QAppReticulumOwner, + connectionId: string, + payload: unknown + ): Promise<{ messageId: string }> { + const connection = this.requireOwned(owner, connectionId); + if ( + connection.state !== 'CONNECTED' && + connection.state !== 'RECONNECTING' + ) { + throw new QAppReticulumError('RNS_CONNECTION_CLOSED'); + } + const encoded = encodePayload(payload, MAX_REALTIME_MESSAGE_BYTES); + const envelopeBytes = Buffer.byteLength( + JSON.stringify({ + connectionId, + payloadBase64: encoded.payloadBase64, + encoding: encoded.encoding, + }), + 'utf8' + ); + if (envelopeBytes > MAX_REALTIME_MESSAGE_BYTES) { + throw new QAppReticulumError('RNS_MESSAGE_TOO_LARGE'); + } + const result = await this.transport.invoke('qapp_rns_send', { + managerKey: connection.managerKey, + connectionId, + ...encoded, + }); + if (!result.ok) { + throw new QAppReticulumError(result.code ?? 'RNS_SEND_QUEUE_FULL'); + } + return { messageId: String(result.payload?.messageId ?? '') }; + } + + async close(owner: QAppReticulumOwner, connectionId: string): Promise { + const connection = this.requireOwned(owner, connectionId); + connection.state = 'CLOSING'; + this.emit('event', { + ownerKey: connection.ownerKey, + connectionId, + action: 'RNS_CONNECTION_STATE', + state: 'CLOSING', + }); + this.removeConnection(connection); + await this.transport.invoke('qapp_rns_close', { + managerKey: connection.managerKey, + connectionId, + }); + this.emit('event', { + ownerKey: connection.ownerKey, + connectionId, + action: 'RNS_CONNECTION_STATE', + state: 'CLOSED', + }); + } + + async cleanupOwner(owner: QAppReticulumOwner): Promise { + const key = ownerKey(owner); + const ids = [...(this.connectionsByOwner.get(key) ?? [])]; + await Promise.allSettled(ids.map((id) => this.close(owner, id))); + } + + destroy(): void { + this.detachTransport(); + this.connections.clear(); + this.connectionsByOwner.clear(); + } + + private handleTransportEvent(event: QAppReticulumNativeEvent): void { + const connectionId = String(event.connectionId ?? ''); + const connection = this.connections.get(connectionId); + if (!connection || connection.managerKey !== event.managerKey) return; + if (event.kind === 'state' && event.state) { + connection.state = event.state; + this.emit('event', { + ownerKey: connection.ownerKey, + connectionId, + action: 'RNS_CONNECTION_STATE', + state: event.state, + reason: event.reason, + }); + return; + } + if (event.kind === 'message' && typeof event.payloadBase64 === 'string') { + this.emit('event', { + ownerKey: connection.ownerKey, + connectionId, + action: 'RNS_MESSAGE', + payload: decodePayload(event as unknown as Record), + }); + } + } + + private requireOwned( + owner: QAppReticulumOwner, + connectionId: string + ): LogicalConnection { + const connection = this.connections.get(connectionId); + if (!connection || connection.ownerKey !== ownerKey(owner)) { + throw new QAppReticulumError('RNS_INVALID_CONNECTION'); + } + return connection; + } + + private removeConnection(connection: LogicalConnection): void { + this.connections.delete(connection.connectionId); + const owned = this.connectionsByOwner.get(connection.ownerKey); + owned?.delete(connection.connectionId); + if (owned?.size === 0) this.connectionsByOwner.delete(connection.ownerKey); + } + + private managerKey(owner: QAppReticulumOwner, destination: string): string { + return `${ownerKey(owner)}\u0000${destination}`; + } + + private validateDestination(destinationValue: string): string { + const destination = String(destinationValue ?? '') + .trim() + .toLowerCase(); + if (!DESTINATION_PATTERN.test(destination)) { + throw new QAppReticulumError('RNS_DESTINATION_UNREACHABLE'); + } + return destination; + } +} diff --git a/electron/src/reticulum-bridge.ts b/electron/src/reticulum-bridge.ts index 911baaf3..e8b4f758 100644 --- a/electron/src/reticulum-bridge.ts +++ b/electron/src/reticulum-bridge.ts @@ -235,6 +235,10 @@ type BridgeCmdFrame = { | 'configure_group_audio_data_plane_routes' | 'configure_group_audio_forwarding' | 'configure_land_state_forwarding' + | 'qapp_rns_request' + | 'qapp_rns_connect' + | 'qapp_rns_send' + | 'qapp_rns_close' | 'get_local_identity_public_key' | 'ensure_peer_identity' | 'register_peer_identity'; @@ -823,6 +827,11 @@ type BridgeEventFrame = lastActivityAgeMs?: number | null; }; } + | { + type: 'event'; + event: 'qapp_rns'; + payload?: Record; + } | { type: 'event'; event: 'qchat_file_transfer'; @@ -3655,6 +3664,39 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { } } + async invokeQAppReticulum( + action: + | 'qapp_rns_request' + | 'qapp_rns_connect' + | 'qapp_rns_send' + | 'qapp_rns_close', + payload: Record + ): Promise<{ + ok: boolean; + payload?: Record; + code?: string; + }> { + try { + await this.start(); + if (this.state !== 'ready') { + return { ok: false, code: 'RNS_DESTINATION_UNREACHABLE' }; + } + const response = await this.sendCommand(action, payload); + return { + ok: response.ok, + payload: response.payload, + code: + typeof response.payload?.code === 'string' + ? response.payload.code + : response.ok + ? undefined + : 'RNS_DESTINATION_UNREACHABLE', + }; + } catch { + return { ok: false, code: 'RNS_LINK_TIMEOUT' }; + } + } + async ensurePeerIdentityKnown(peerPresenceHash: string): Promise { const peer = peerPresenceHash.trim().toLowerCase(); if (!peer) return false; @@ -3963,12 +4005,24 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; const frame: BridgeCmdFrame = { type: 'cmd', action, id, payload }; const wire = JSON.stringify(frame) + '\n'; + const timeoutMs = + action === 'qapp_rns_connect' + ? 45_000 + : action === 'qapp_rns_request' + ? Math.min( + 125_000, + Math.max( + REQUEST_TIMEOUT_MS, + Number(payload?.timeoutMs ?? 30_000) + 5_000 + ) + ) + : REQUEST_TIMEOUT_MS; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Reticulum bridge request timed out: ${action}`)); - }, REQUEST_TIMEOUT_MS); + }, timeoutMs); this.pending.set(id, { action, priority, resolve, reject, timer }); this.enqueueCommand({ id, wire, priority }); this.flushWriteQueue(); @@ -5328,6 +5382,10 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { this.emitBridgeFrameEvent('qchat-file-transfer', frame.payload ?? {}); return; } + case 'qapp_rns': { + this.emitBridgeFrameEvent('qapp-rns', frame.payload ?? {}); + return; + } case 'reticulum_chat_resource': { this.emitBridgeFrameEvent( 'reticulum-chat-resource', diff --git a/electron/src/setup.ts b/electron/src/setup.ts index 6adba669..68467d3b 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -132,6 +132,11 @@ import { startReticulumBridge, type ReticulumOverlayVerifiedPeer, } from './reticulum-bridge'; +import { + QAppReticulumManager, + type QAppReticulumNativeEvent, + type QAppReticulumOwner, +} from './qapp-reticulum-manager'; import { attachReticulumStatusBridgeEvents } from './reticulum-daemon'; import { startReticulumMeshCoordinator, @@ -2823,11 +2828,93 @@ export function stopReticulumManagers(): void { stopReticulumChatManager(); reticulumChatListenersAttached = false; stopPresenceManager(); + qAppReticulumManager?.destroy(); + qAppReticulumManager = null; shutdownReticulumResourceStore(); attachReticulumStatusBridgeEvents(null); reticulumChatReadiness.reset(); } +let qAppReticulumManager: QAppReticulumManager | null = null; +let qAppReticulumBridge: ReturnType = null; +const qAppReticulumNativeListeners = new Set< + (event: QAppReticulumNativeEvent) => void +>(); + +function getQAppReticulumManager(): QAppReticulumManager { + if (qAppReticulumManager) return qAppReticulumManager; + const transport = { + invoke: async (action: string, payload: Record) => { + await ensureReticulumManagersStarted(); + const bridge = getReticulumBridge() ?? (await startReticulumBridge()); + if (qAppReticulumBridge !== bridge) { + qAppReticulumBridge = bridge; + bridge.on('qapp-rns', (event: QAppReticulumNativeEvent) => { + for (const listener of qAppReticulumNativeListeners) listener(event); + }); + } + return bridge.invokeQAppReticulum(action as any, payload); + }, + onEvent: (listener: (event: QAppReticulumNativeEvent) => void) => { + qAppReticulumNativeListeners.add(listener); + return () => qAppReticulumNativeListeners.delete(listener); + }, + }; + qAppReticulumManager = new QAppReticulumManager(transport); + qAppReticulumManager.on('event', (event) => { + const win = myCapacitorApp.getMainWindow(); + if (!win.isDestroyed()) win.webContents.send('qappReticulum:event', event); + }); + return qAppReticulumManager; +} + +function validateQAppReticulumIpcSender( + event: Electron.IpcMainInvokeEvent +): void { + const win = myCapacitorApp.getMainWindow(); + if (win.isDestroyed() || event.sender.id !== win.webContents.id) { + throw new Error('RNS_PERMISSION_DENIED'); + } +} + +ipcMain.handle( + 'qappReticulum:request', + async (event, owner: QAppReticulumOwner, options) => { + validateQAppReticulumIpcSender(event); + return getQAppReticulumManager().request(owner, options); + } +); +ipcMain.handle( + 'qappReticulum:connect', + async (event, owner: QAppReticulumOwner, destination: string) => { + validateQAppReticulumIpcSender(event); + return getQAppReticulumManager().connect(owner, destination); + } +); +ipcMain.handle( + 'qappReticulum:send', + async (event, owner: QAppReticulumOwner, connectionId: string, payload) => { + validateQAppReticulumIpcSender(event); + return getQAppReticulumManager().send(owner, connectionId, payload); + } +); +ipcMain.handle( + 'qappReticulum:close', + async (event, owner: QAppReticulumOwner, connectionId: string) => { + validateQAppReticulumIpcSender(event); + await getQAppReticulumManager().close(owner, connectionId); + return true; + } +); +ipcMain.handle( + 'qappReticulum:cleanupOwner', + async (event, owner: QAppReticulumOwner) => { + validateQAppReticulumIpcSender(event); + await getQAppReticulumManager().cleanupOwner(owner); + return true; + } +); + async function getReadyReticulumChatManager(): Promise< ReturnType > { diff --git a/examples/qapp-reticulum/backend.py b/examples/qapp-reticulum/backend.py new file mode 100644 index 00000000..05137872 --- /dev/null +++ b/examples/qapp-reticulum/backend.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Minimal Q-App Reticulum backend for protocol version 1.""" + +import argparse +import base64 +from collections import deque +import json +import os +import secrets +import struct +import threading +import time + +import RNS + +HEADER = struct.Struct(">BBQI") +VERSION, DATA, ACK, STREAM_ID = 1, 1, 2, 7 +MAX_FRAME = 256 * 1024 +STATE_LOCK = threading.Lock() +SEEN_ORDER, SEEN = deque(), set() +PENDING = {} +NEXT_MESSAGE_ID = secrets.randbits(63) or 1 + + +def frame(kind, message_id, payload=b""): + if len(payload) > MAX_FRAME: + raise ValueError("frame too large") + return HEADER.pack(VERSION, kind, message_id, len(payload)) + payload + + +def write_all(writer, data): + offset = 0 + while offset < len(data): + written = int(writer.write(data[offset:]) or 0) + if written <= 0: + time.sleep(0.01) + continue + offset += written + writer.flush() + + +def serve_link(link): + global NEXT_MESSAGE_ID + channel = link.get_channel() + reader = RNS.Buffer.create_reader(STREAM_ID, channel) + writer = RNS.Buffer.create_writer(STREAM_ID, channel) + buffered = bytearray() + while True: + chunk = reader.read(64 * 1024) + if chunk is None: + time.sleep(0.01) + continue + if chunk == b"": + return + buffered.extend(chunk) + if len(buffered) > MAX_FRAME * 2: + link.teardown() + return + while len(buffered) >= HEADER.size: + version, kind, message_id, length = HEADER.unpack_from(buffered) + if version != VERSION or length > MAX_FRAME: + link.teardown() + return + total = HEADER.size + length + if len(buffered) < total: + break + payload = bytes(buffered[HEADER.size:total]) + del buffered[:total] + if kind == ACK: + with STATE_LOCK: + PENDING.pop(message_id, None) + continue + if kind != DATA: + link.teardown() + return + write_all(writer, frame(ACK, message_id)) + envelope = json.loads(payload.decode("utf-8")) + connection_id = envelope["connectionId"] + with STATE_LOCK: + pending_for_connection = [ + item["frame"] + for _, item in sorted(PENDING.items()) + if item["connectionId"] == connection_id + ] + for pending_frame in pending_for_connection: + write_all(writer, pending_frame) + with STATE_LOCK: + duplicate = message_id in SEEN + if not duplicate: + if len(SEEN_ORDER) >= 512: + SEEN.discard(SEEN_ORDER.popleft()) + SEEN_ORDER.append(message_id) + SEEN.add(message_id) + if duplicate: + continue + decoded = base64.b64decode(envelope["payloadBase64"]) + response = { + "type": "echo", + "received": ( + json.loads(decoded.decode("utf-8")) + if envelope.get("encoding") == "json" + else envelope["payloadBase64"] + ), + } + response_envelope = { + "connectionId": connection_id, + "encoding": "json", + "payloadBase64": base64.b64encode( + json.dumps(response, separators=(",", ":")).encode("utf-8") + ).decode("ascii"), + } + body = json.dumps(response_envelope, separators=(",", ":")).encode("utf-8") + with STATE_LOCK: + outgoing_id = NEXT_MESSAGE_ID + NEXT_MESSAGE_ID = (NEXT_MESSAGE_ID + 1) & 0xFFFFFFFFFFFFFFFF + outgoing_frame = frame(DATA, outgoing_id, body) + PENDING[outgoing_id] = { + "connectionId": connection_id, + "frame": outgoing_frame, + } + write_all(writer, outgoing_frame) + + +def hello(_path, data, _request_id, _link_id, _remote_identity, _requested_at): + payload = base64.b64decode(str(data.get("payloadBase64") or "")) + name = "world" + if data.get("encoding") == "json": + name = str(json.loads(payload.decode("utf-8")).get("name") or name) + return {"message": f"Hello, {name}", "requestId": data.get("requestId")} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--config", default=None) + parser.add_argument("--identity", default="qapp-backend.identity") + args = parser.parse_args() + RNS.Reticulum(configdir=args.config) + identity = ( + RNS.Identity.from_file(args.identity) + if os.path.exists(args.identity) + else RNS.Identity() + ) + if not os.path.exists(args.identity): + identity.to_file(args.identity) + destination = RNS.Destination( + identity, + RNS.Destination.IN, + RNS.Destination.SINGLE, + "qortal-hub-v3", + "qapp-backend", + "v1", + ) + destination.register_request_handler( + "/hello", response_generator=hello, allow=RNS.Destination.ALLOW_ALL + ) + destination.set_link_established_callback( + lambda link: threading.Thread(target=serve_link, args=(link,), daemon=True).start() + ) + destination.announce() + print(f"Destination: {RNS.prettyhexrep(destination.hash)}") + while True: + time.sleep(60) + destination.announce() + + +if __name__ == "__main__": + main() diff --git a/examples/qapp-reticulum/client.js b/examples/qapp-reticulum/client.js new file mode 100644 index 00000000..1164eddf --- /dev/null +++ b/examples/qapp-reticulum/client.js @@ -0,0 +1,37 @@ +/* global qortalRequest */ +const destination = 'REPLACE_WITH_32_CHARACTER_DESTINATION_HASH'; + +window.addEventListener('message', (event) => { + if (event.data?.action === 'RNS_MESSAGE') { + console.log( + 'Reticulum message', + event.data.connectionId, + event.data.payload + ); + } + if (event.data?.action === 'RNS_CONNECTION_STATE') { + console.log('Reticulum state', event.data.connectionId, event.data.state); + } +}); + +const profile = await qortalRequest({ + action: 'RNS_REQUEST', + destination, + path: '/hello', + payload: { name: 'Alice' }, + requestId: crypto.randomUUID(), +}); + +const connection = await qortalRequest({ + action: 'RNS_CONNECT', + destination, +}); + +await qortalRequest({ + action: 'RNS_SEND', + connectionId: connection.connectionId, + payload: { type: 'hello', text: profile.message }, +}); + +// Later: +// await qortalRequest({ action: 'RNS_CLOSE', connectionId: connection.connectionId }); diff --git a/protocol-v1-vectors.json b/protocol-v1-vectors.json new file mode 100644 index 00000000..c7edfc45 --- /dev/null +++ b/protocol-v1-vectors.json @@ -0,0 +1,47 @@ +{ + "protocol": "qortal-hub-v3.qapp-backend.v1", + "header_bytes": 14, + "data_frame": { + "version": 1, + "type": 1, + "message_id_hex": "0102030405060708", + "payload_hex": "01020304", + "frame_hex": "010101020304050607080000000401020304" + }, + "ack_frame": { + "version": 1, + "type": 2, + "message_id_hex": "1112131415161718", + "payload_hex": "", + "frame_hex": "0102111213141516171800000000" + }, + "control_frame": { + "version": 1, + "type": 3, + "message_id_hex": "2122232425262728", + "control": { "type": "PING" }, + "payload_hex": "7b2274797065223a2250494e47227d", + "frame_hex": "010321222324252627280000000f7b2274797065223a2250494e47227d" + }, + "data_envelope": { + "connection_id": "rns-00000000-0000-0000-0000-000000000000", + "encoding": "json", + "application_json": { "type": "echo", "value": 42, "text": "hello" }, + "application_utf8_hex": "7b2274797065223a226563686f222c2276616c7565223a34322c2274657874223a2268656c6c6f227d", + "payload_base64": "eyJ0eXBlIjoiZWNobyIsInZhbHVlIjo0MiwidGV4dCI6ImhlbGxvIn0=", + "envelope_utf8": "{\"connectionId\":\"rns-00000000-0000-0000-0000-000000000000\",\"payloadBase64\":\"eyJ0eXBlIjoiZWNobyIsInZhbHVlIjo0MiwidGV4dCI6ImhlbGxvIn0=\",\"encoding\":\"json\"}" + }, + "rpc_request": { + "version": 1, + "request_id": "request-0001", + "encoding": "json", + "application_json": { "name": "Alice" }, + "payload_base64": "eyJuYW1lIjoiQWxpY2UifQ==" + }, + "rpc_response": { + "application_json": { + "message": "Hello, Alice", + "requestId": "request-0001" + } + } +} diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index f4b8e73a..f80e9a58 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { executeEvent } from '../utils/events'; import { infoSnackGlobalAtom, @@ -178,6 +178,10 @@ export function openIndexedDB() { } export const listOfAllQortalRequests = [ + 'RNS_CLOSE', + 'RNS_CONNECT', + 'RNS_REQUEST', + 'RNS_SEND', 'ADD_FOREIGN_SERVER', 'ADD_GROUP_ADMIN', 'ADD_LIST_ITEMS', @@ -285,6 +289,10 @@ export const listOfAllQortalRequests = [ ]; export const UIQortalRequests = [ + 'RNS_CLOSE', + 'RNS_CONNECT', + 'RNS_REQUEST', + 'RNS_SEND', 'ADD_FOREIGN_SERVER', 'ADD_GROUP_ADMIN', 'ADD_LIST_ITEMS', @@ -555,6 +563,7 @@ export const useQortalMessageListener = ( appService, appIdentifier ) => { + const hasLoadedFrameRef = useRef(false); const [path, setPath] = useState(''); const [history, setHistory] = useState({ customQDNHistoryPaths: [], @@ -701,6 +710,16 @@ export const useQortalMessageListener = ( TIME_MINUTES_30_IN_MILLISECONDS; } else if (message?.action === 'PUBLISH_QDN_RESOURCE') { timeout = TIME_HOURS_1_IN_MILLISECONDS; + } else if (message?.action === 'RNS_CONNECT') { + timeout = 120_000; + } else if (message?.action === 'RNS_REQUEST') { + timeout = Math.min( + 195_000, + Math.max( + 120_000, + Number(message?.payload?.timeoutMs ?? 30_000) + 75_000 + ) + ); } // Build deduplication key for read-only actions @@ -947,5 +966,51 @@ export const useQortalMessageListener = ( }; }, [isDevMode, appName, appService, tabId]); // Empty dependency array to run once when the component mounts + useEffect(() => { + const api = window.electronAPI; + const iframe = iframeRef.current; + if (!api || !iframe || tabId == null || !appName) return; + const owner = { + tabId: String(tabId), + name: String(appName), + service: String(appService ?? ''), + }; + const expectedOwnerKey = `${owner.tabId}\u0000${owner.service}\u0000${owner.name}`; + const unsubscribe = api.onQAppReticulumEvent?.((payload) => { + if (payload?.ownerKey !== expectedOwnerKey || !iframe.contentWindow) + return; + let targetOrigin: string; + try { + targetOrigin = new URL(iframe.src).origin; + } catch { + return; + } + iframe.contentWindow.postMessage( + { + action: payload.action, + connectionId: payload.connectionId, + ...(payload.action === 'RNS_MESSAGE' + ? { payload: payload.payload } + : { state: payload.state, reason: payload.reason }), + requestedHandler: 'UI', + }, + targetOrigin + ); + }); + const handleLoad = () => { + if (!hasLoadedFrameRef.current) { + hasLoadedFrameRef.current = true; + return; + } + void api.qappReticulumCleanupOwner?.(owner); + }; + iframe.addEventListener('load', handleLoad); + return () => { + unsubscribe?.(); + iframe.removeEventListener('load', handleLoad); + void api.qappReticulumCleanupOwner?.(owner); + }; + }, [appName, appService, iframeRef, tabId]); + return { path, history, resetHistory, changeCurrentIndex }; }; diff --git a/src/i18n/locales/ar/question.json b/src/i18n/locales/ar/question.json index 907bebc1..7c7734e1 100644 --- a/src/i18n/locales/ar/question.json +++ b/src/i18n/locales/ar/question.json @@ -170,6 +170,7 @@ "register_name": "هل تسمح للتطبيق بتسجيل هذا الاسم؟", "remove_admin": "هل تسمح للتطبيق بإزالة {{ partecipant }} من الأدمنية؟", "remove_from_list": "هل تسمح للتطبيق بإزالة الآتي من القائمة {{ name }}:", + "reticulum_destination": "يريد {{appName}} الاتصال بخادم Reticulum الخلفي {{destination}}. هل تسمح بهذا الاتصال لعلامة التبويب هذه؟", "sell_name_cancel": "هل تسمح للتطبيق بإلغاء بيع الاسم؟", "sell_name_transaction": "هل تسمح للتطبيق بعمل صفقة بيع اسم؟", "sell_name_transaction_detail": "بيع {{ name }} بسعر {{ price }} QORT", diff --git a/src/i18n/locales/de/question.json b/src/i18n/locales/de/question.json index 1531fbfb..05c73643 100644 --- a/src/i18n/locales/de/question.json +++ b/src/i18n/locales/de/question.json @@ -170,6 +170,7 @@ "register_name": "Möchten Sie dieser Anwendung die Berechtigung geben, diesen Namen zu registrieren?", "remove_admin": "Möchten Sie dieser Anwendung die Berechtigung geben, den Benutzer {{ partecipant }} als Administrator zu entfernen?", "remove_from_list": "Möchten Sie dieser Anwendung die Berechtigung geben, Folgendes aus der Liste {{ name }} zu entfernen:", + "reticulum_destination": "{{appName}} möchte eine Verbindung zum Reticulum-Backend {{destination}} herstellen. Diese Verbindung für diesen Tab zulassen?", "sell_name_cancel": "Möchten Sie dieser Anwendung die Berechtigung geben, den Verkauf eines Namens abzubrechen?", "sell_name_transaction": "Möchten Sie dieser Anwendung die Berechtigung geben, eine Verkaufs-Transaktion für einen Namen zu erstellen?", "sell_name_transaction_detail": "{{ name }} für {{ price }} QORT verkaufen", diff --git a/src/i18n/locales/en/question.json b/src/i18n/locales/en/question.json index 418e6700..a7747781 100644 --- a/src/i18n/locales/en/question.json +++ b/src/i18n/locales/en/question.json @@ -170,6 +170,7 @@ "register_name": "do you give this application permission to register this name?", "remove_admin": "do you give this application permission to remove user {{ partecipant }} as an admin?", "remove_from_list": "do you give this application permission to remove the following from the list {{ name }}:", + "reticulum_destination": "{{appName}} wants to connect to the Reticulum backend {{destination}}. Allow this connection for this tab?", "sell_name_cancel": "do you give this application permission to cancel the selling of a name?", "sell_name_transaction": "do you give this application permission to create a sell name transaction?", "sell_name_transaction_detail": "sell {{ name }} for {{ price }} QORT", diff --git a/src/i18n/locales/es/question.json b/src/i18n/locales/es/question.json index 1b73f8d4..22e824e6 100644 --- a/src/i18n/locales/es/question.json +++ b/src/i18n/locales/es/question.json @@ -170,6 +170,7 @@ "register_name": "¿Deseas permitir que esta aplicación registre este nombre?", "remove_admin": "¿Deseas permitir que esta aplicación elimine al usuario {{ partecipant }} como administrador?", "remove_from_list": "¿Deseas permitir que esta aplicación elimine lo siguiente de la lista {{ name }}?", + "reticulum_destination": "{{appName}} quiere conectarse al backend de Reticulum {{destination}}. ¿Permitir esta conexión para esta pestaña?", "sell_name_cancel": "¿Deseas permitir que esta aplicación cancele la venta de un nombre?", "sell_name_transaction": "¿Deseas permitir que esta aplicación cree una transacción para vender un nombre?", "sell_name_transaction_detail": "vender {{ name }} por {{ price }} QORT", diff --git a/src/i18n/locales/et/question.json b/src/i18n/locales/et/question.json index 4cad1f6f..a9314a28 100644 --- a/src/i18n/locales/et/question.json +++ b/src/i18n/locales/et/question.json @@ -170,6 +170,7 @@ "register_name": "kas sa annad sellele rakendusele loa registreerida see nimi?", "remove_admin": "kas sa annad sellele rakendusele loa eemaldada kasutaja {{ partecipant }} administraatori staatusest?", "remove_from_list": "kas sa annad sellele rakendusele loa eemaldada järgmised asjad nimekirjast {{ name }}:", + "reticulum_destination": "{{appName}} soovib luua ühenduse Reticulumi taustserveriga {{destination}}. Kas lubada see ühendus sellel vahekaardil?", "sell_name_cancel": "kas sa annad sellele rakendusele loa tühistada nime müümise?", "sell_name_transaction": "kas sa annad sellele rakendusele loa luua nime müümise tehing?", "sell_name_transaction_detail": "müü {{ name }} hinnaga {{ price }} QORT", diff --git a/src/i18n/locales/fi/question.json b/src/i18n/locales/fi/question.json index 76994626..a2177a30 100644 --- a/src/i18n/locales/fi/question.json +++ b/src/i18n/locales/fi/question.json @@ -170,6 +170,7 @@ "register_name": "annatko tälle sovellukselle luvan rekisteröidä tämän nimen?", "remove_admin": "annatko tälle sovellukselle luvan poistaa käyttäjä {{ partecipant }} järjestelmänvalvojana?", "remove_from_list": "annatko tälle sovellukselle luvan poistaa seuraavat kohteet luettelosta {{ name }}:", + "reticulum_destination": "{{appName}} haluaa muodostaa yhteyden Reticulum-taustapalvelimeen {{destination}}. Sallitaanko yhteys tässä välilehdessä?", "sell_name_cancel": "annatko tälle sovellukselle luvan peruuttaa nimen myynnin?", "sell_name_transaction": "annatko tälle sovellukselle luvan luoda nimenmyyntitransaktion?", "sell_name_transaction_detail": "myy {{ name }} hintaan {{ price }} QORT", diff --git a/src/i18n/locales/fr/question.json b/src/i18n/locales/fr/question.json index e9afed2a..28334866 100644 --- a/src/i18n/locales/fr/question.json +++ b/src/i18n/locales/fr/question.json @@ -170,6 +170,7 @@ "register_name": "Souhaitez-vous autoriser cette application à enregistrer ce nom ?", "remove_admin": "Souhaitez-vous autoriser cette application à retirer {{ partecipant }} en tant qu'administrateur ?", "remove_from_list": "Souhaitez-vous autoriser cette application à supprimer les éléments suivants de la liste {{ name }} :", + "reticulum_destination": "{{appName}} souhaite se connecter au serveur Reticulum {{destination}}. Autoriser cette connexion pour cet onglet ?", "sell_name_cancel": "Souhaitez-vous autoriser cette application à annuler la vente d'un nom ?", "sell_name_transaction": "Souhaitez-vous autoriser cette application à créer une transaction de vente de nom ?", "sell_name_transaction_detail": "vente de {{ name }} pour {{ price }} QORT", diff --git a/src/i18n/locales/it/question.json b/src/i18n/locales/it/question.json index bdd99026..8b92416b 100644 --- a/src/i18n/locales/it/question.json +++ b/src/i18n/locales/it/question.json @@ -170,6 +170,7 @@ "register_name": "consenti a questa applicazione di registrare questo nome?", "remove_admin": "consenti a questa applicazione di rimuovere l'utente {{ partecipant }} come amministratore?", "remove_from_list": "consenti a questa applicazione di rimuovere i seguenti elementi dalla lista {{ name }}?", + "reticulum_destination": "{{appName}} vuole connettersi al backend Reticulum {{destination}}. Consentire questa connessione per questa scheda?", "sell_name_cancel": "consenti a questa applicazione di annullare la vendita di un nome?", "sell_name_transaction": "consenti a questa applicazione di creare una transazione di vendita nome?", "sell_name_transaction_detail": "vendi {{ name }} per {{ price }} QORT", diff --git a/src/i18n/locales/ja/question.json b/src/i18n/locales/ja/question.json index 490e636c..0b7b8db3 100644 --- a/src/i18n/locales/ja/question.json +++ b/src/i18n/locales/ja/question.json @@ -170,6 +170,7 @@ "register_name": "このアプリにこの名前を登録することを許可しますか?", "remove_admin": "このアプリに {{ partecipant }} を管理者から削除することを許可しますか?", "remove_from_list": "このアプリにリスト {{ name }} から以下を削除することを許可しますか?", + "reticulum_destination": "{{appName}} が Reticulum バックエンド {{destination}} への接続を要求しています。このタブで接続を許可しますか?", "sell_name_cancel": "このアプリに名前の販売をキャンセルすることを許可しますか?", "sell_name_transaction": "このアプリに名前の販売取引を作成することを許可しますか?", "sell_name_transaction_detail": "{{ price }} QORTで{{ name }}を販売", diff --git a/src/i18n/locales/pt/question.json b/src/i18n/locales/pt/question.json index c5a64dbe..e2a1e24d 100644 --- a/src/i18n/locales/pt/question.json +++ b/src/i18n/locales/pt/question.json @@ -170,6 +170,7 @@ "register_name": "você dá permissão para que este aplicativo registre este nome?", "remove_admin": "você dá permissão para que este aplicativo remova o usuário {{ partecipant }} como administrador?", "remove_from_list": "você dá permissão para que este aplicativo remova o seguinte da lista {{ name }}:", + "reticulum_destination": "{{appName}} quer se conectar ao backend Reticulum {{destination}}. Permitir esta conexão para esta aba?", "sell_name_cancel": "você dá permissão para que este aplicativo cancele a venda de um nome?", "sell_name_transaction": "você dá permissão para que este aplicativo crie uma transação de venda de nome?", "sell_name_transaction_detail": "vender {{ name }} por {{ price }} QORTs", diff --git a/src/i18n/locales/ru/question.json b/src/i18n/locales/ru/question.json index 3c5b79c2..6b8aa1fc 100644 --- a/src/i18n/locales/ru/question.json +++ b/src/i18n/locales/ru/question.json @@ -170,6 +170,7 @@ "register_name": "Разрешить этому приложению зарегистрировать это имя?", "remove_admin": "Разрешить этому приложению удалить пользователя {{ partecipant }} из администраторов?", "remove_from_list": "Разрешить этому приложению удалить следующее из списка {{ name }}:", + "reticulum_destination": "{{appName}} запрашивает подключение к серверу Reticulum {{destination}}. Разрешить это подключение для данной вкладки?", "sell_name_cancel": "Разрешить этому приложению отменить продажу имени?", "sell_name_transaction": "Разрешить этому приложению создать транзакцию продажи имени?", "sell_name_transaction_detail": "продажа {{ name }} за {{ price }} QORT", diff --git a/src/i18n/locales/zh/question.json b/src/i18n/locales/zh/question.json index 1a48325e..8f69eef3 100644 --- a/src/i18n/locales/zh/question.json +++ b/src/i18n/locales/zh/question.json @@ -170,6 +170,7 @@ "register_name": "是否允许该应用注册该名称?", "remove_admin": "是否允许该应用将用户 {{ partecipant }} 移除管理员权限?", "remove_from_list": "是否允许该应用从列表 {{ name }} 中移除以下内容?", + "reticulum_destination": "{{appName}} 想要连接到 Reticulum 后端 {{destination}}。是否允许此标签页建立连接?", "sell_name_cancel": "是否允许该应用取消名称的出售?", "sell_name_transaction": "是否允许该应用创建出售名称的交易?", "sell_name_transaction_detail": "以 {{ price }} QORT 出售 {{ name }}", diff --git a/src/qortal/get.ts b/src/qortal/get.ts index 8c016dcb..7677de11 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -452,6 +452,7 @@ function getFileFromContentScript(fileId) { const responseResolvers = new Map(); const notificationPermissionResolvers = new Map(); +const rnsDestinationPermissions = new Set(); function generatePermissionRequestId(): string { if (globalThis.crypto?.randomUUID) { @@ -515,6 +516,48 @@ async function getUserPermission(payload, isFromExtension) { }); } +export const authorizeRnsDestination = async ( + destination: string, + isFromExtension: boolean, + appInfo: { tabId?: string | number; name?: string } +) => { + const normalized = String(destination ?? '') + .trim() + .toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(normalized)) { + throw new Error('RNS_DESTINATION_UNREACHABLE'); + } + if (!window.electronAPI?.qappReticulumConnect) { + throw new Error('RNS_NATIVE_TRANSPORT_UNAVAILABLE'); + } + if (appInfo?.tabId == null || !appInfo?.name) { + throw new Error('RNS_PERMISSION_DENIED'); + } + const key = `${appInfo.tabId}\u0000${appInfo.name}\u0000${normalized}`; + if (rnsDestinationPermissions.has(key)) return normalized; + const response = await getUserPermission( + { + text1: i18n.t('question:permission.reticulum_destination', { + appName: appInfo.name, + destination: normalized, + }), + }, + isFromExtension + ); + if (response?.accepted !== true) throw new Error('RNS_PERMISSION_DENIED'); + rnsDestinationPermissions.add(key); + return normalized; +}; + +export const clearRnsDestinationPermissionsByTabId = ( + tabId: string | number +) => { + const prefix = `${tabId}\u0000`; + for (const key of rnsDestinationPermissions) { + if (key.startsWith(prefix)) rnsDestinationPermissions.delete(key); + } +}; + export const getWhichUI = async () => { try { // Check if we're running in Electron diff --git a/src/qortal/qortal-requests.ts b/src/qortal/qortal-requests.ts index 2ca26a3d..74546f29 100644 --- a/src/qortal/qortal-requests.ts +++ b/src/qortal/qortal-requests.ts @@ -83,6 +83,8 @@ import { markNotificationSeenInApp, notificationHasPermission, removeNotificationSubscriptions, + authorizeRnsDestination, + clearRnsDestinationPermissionsByTabId, } from './get.ts'; import { triggerMemberGroupsFetch } from '../subscriptions/useInitializeMySubscriptions.ts'; import { getData, storeData } from '../utils/chromeStorage.ts'; @@ -535,6 +537,7 @@ export function clearSessionPermissionsByTabId(tabId) { } } keysToDelete.forEach((key) => sessionPermissionsStore.delete(key)); + clearRnsDestinationPermissionsByTabId(tabId); // Also cleanup any encrypted media associated with this tab cleanupEncryptedMediaByTabId(tabId).catch((error) => { @@ -568,6 +571,71 @@ function setupMessageListenerQortalRequest() { // Handle actions based on the `request.action` value switch (request.action) { + case 'RNS_REQUEST': + case 'RNS_CONNECT': + case 'RNS_SEND': + case 'RNS_CLOSE': { + try { + const owner = { + tabId: String(appInfo?.tabId ?? ''), + name: String(appInfo?.name ?? ''), + service: String(appInfo?.service ?? ''), + }; + let res; + if (request.action === 'RNS_REQUEST') { + const destination = await authorizeRnsDestination( + request.payload?.destination, + isFromExtension, + appInfo + ); + res = await window.electronAPI.qappReticulumRequest(owner, { + ...request.payload, + destination, + }); + } else if (request.action === 'RNS_CONNECT') { + const destination = await authorizeRnsDestination( + request.payload?.destination, + isFromExtension, + appInfo + ); + res = await window.electronAPI.qappReticulumConnect( + owner, + destination + ); + } else if (request.action === 'RNS_SEND') { + res = await window.electronAPI.qappReticulumSend( + owner, + request.payload?.connectionId, + request.payload?.payload + ); + } else { + res = await window.electronAPI.qappReticulumClose( + owner, + request.payload?.connectionId + ); + } + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error?.code || error?.message || 'RNS_PROTOCOL_ERROR', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } case 'GET_USER_ACCOUNT': { try { const res = await getUserAccount({ diff --git a/src/types/window.d.ts b/src/types/window.d.ts index d50ed257..8381df43 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -348,6 +348,19 @@ declare global { reticulumGetLocalIdentityPublicKeyBase64?: () => Promise<{ publicKeyBase64: string | null; }>; + qappReticulumRequest?: (owner: any, options: any) => Promise; + qappReticulumConnect?: (owner: any, destination: string) => Promise; + qappReticulumSend?: ( + owner: any, + connectionId: string, + payload: unknown + ) => Promise; + qappReticulumClose?: ( + owner: any, + connectionId: string + ) => Promise; + qappReticulumCleanupOwner?: (owner: any) => Promise; + onQAppReticulumEvent?: (callback: (payload: any) => void) => () => void; /** Hidden audio-surface: proxy signing to the main shell (wallet key in-memory). */ gcallProxySignPresenceMessage?: ( payload: Record From 789d5a92cd4b8dc64f1f6d534fdf10a2394344d1 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sun, 30 Aug 2026 01:50:19 +0300 Subject: [PATCH 02/32] fixes --- src/hooks/useQortalMessageListener.tsx | 2 + src/qortal/get.ts | 74 ++++++++++++++++++++++++++ src/qortal/qortal-requests.ts | 30 +++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index f80e9a58..fb3a309d 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -276,6 +276,7 @@ export const listOfAllQortalRequests = [ 'SHOW_ACTIONS', 'SHOW_PDF_READER', 'SIGN_FOREIGN_FEES', + 'SIGN_QAPP_IDENTITY', 'SIGN_TRANSACTION', 'START_CROSSCHAIN_SERVER', 'TRANSFER_ASSET', @@ -359,6 +360,7 @@ export const UIQortalRequests = [ 'SHOW_ACTIONS', 'SHOW_PDF_READER', 'SIGN_FOREIGN_FEES', + 'SIGN_QAPP_IDENTITY', 'SIGN_TRANSACTION', 'START_CROSSCHAIN_SERVER', 'TRANSFER_ASSET', diff --git a/src/qortal/get.ts b/src/qortal/get.ts index 7677de11..594eb19b 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -643,6 +643,80 @@ export const getUserAccount = async ({ isFromExtension, appInfo }) => { } }; +export const signQappIdentityProof = async ( + payload, + _isFromExtension, + appInfo +) => { + const protocol = payload?.protocol; + const challengeId = payload?.challengeId; + const nonce = payload?.nonce; + const backendDestination = payload?.backendDestination; + const expiresAt = payload?.expiresAt; + if ( + protocol !== 'qortal-qapp-auth-v1' || + typeof challengeId !== 'string' || + !/^[A-Za-z0-9_-]{12,128}$/.test(challengeId) || + typeof nonce !== 'string' || + !/^[A-Za-z0-9_-]{32,128}$/.test(nonce) || + typeof backendDestination !== 'string' || + !/^[0-9a-f]{32}$/.test(backendDestination) || + !Number.isSafeInteger(expiresAt) || + expiresAt < Date.now() - 5_000 || + expiresAt > Date.now() + 180_000 + ) { + throw new Error('Invalid Q-App identity challenge'); + } + const hasPersistentAccountPermission = appInfo?.name + ? Boolean(await getPermission(`qAPPAutoAuth-${appInfo.name}`)) + : false; + const hasAccountSessionPermission = Boolean( + appInfo?.tabId && + appInfo?.name && + hasSessionPermission(appInfo.tabId, appInfo.name, 'GET_USER_ACCOUNT') + ); + if (!hasPersistentAccountPermission && !hasAccountSessionPermission) { + throw new Error( + 'Q-App must authenticate with GET_USER_ACCOUNT before requesting an identity proof' + ); + } + const wallet = await getSaveWallet(); + const account = { + address: wallet?.address0, + publicKey: wallet?.publicKey, + }; + const keyPair = await getKeyPair(); + if ( + !account?.address || + !account?.publicKey || + keyPair?.publicKey !== account.publicKey + ) { + throw new Error('Authenticated wallet does not match Qortal account'); + } + const fields = { + address: account.address, + backendDestination, + challengeId, + expiresAt, + nonce, + protocol, + publicKey: account.publicKey, + }; + const sorted = {}; + for (const key of Object.keys(fields).sort()) sorted[key] = fields[key]; + const message = new TextEncoder().encode(JSON.stringify(sorted)); + const privateKey = Base58.decode(keyPair.privateKey); + try { + return { + ...fields, + signature: Base58.encode(nacl.sign.detached(message, privateKey)), + }; + } finally { + privateKey.fill(0); + message.fill(0); + } +}; + export const getNotificationPermission = async ({ appInfo }) => { try { const stored = diff --git a/src/qortal/qortal-requests.ts b/src/qortal/qortal-requests.ts index 74546f29..e443a345 100644 --- a/src/qortal/qortal-requests.ts +++ b/src/qortal/qortal-requests.ts @@ -71,6 +71,7 @@ import { sellNameRequest, cancelSellNameRequest, signForeignFees, + signQappIdentityProof, multiPaymentWithPrivateData, transferAssetRequest, reEncryptQortalKeys, @@ -664,6 +665,35 @@ function setupMessageListenerQortalRequest() { } break; } + case 'SIGN_QAPP_IDENTITY': { + try { + const res = await signQappIdentityProof( + request.payload, + isFromExtension, + appInfo + ); + event.source!.postMessage( + { + requestId: request.requestId, + action: request.action, + payload: res, + type: 'backgroundMessageResponse', + }, + event.origin + ); + } catch (error) { + event.source!.postMessage( + { + requestId: request.requestId, + action: request.action, + error: error?.message || 'Unable to sign Q-App identity proof', + type: 'backgroundMessageResponse', + }, + event.origin + ); + } + break; + } case 'NOTIFICATION_PERMISSION': { try { From 92065476f07eafa155a920a77fa9c90937f9b0b3 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sun, 30 Aug 2026 21:32:41 +0300 Subject: [PATCH 03/32] fixes --- .../useSubscriptionsFromManagedGroups.ts | 284 ++++++++++-------- 1 file changed, 164 insertions(+), 120 deletions(-) diff --git a/src/subscriptions/useSubscriptionsFromManagedGroups.ts b/src/subscriptions/useSubscriptionsFromManagedGroups.ts index 072e9cd9..643db1b8 100644 --- a/src/subscriptions/useSubscriptionsFromManagedGroups.ts +++ b/src/subscriptions/useSubscriptionsFromManagedGroups.ts @@ -21,6 +21,16 @@ export type ManagedSubscriptionEntry = { url: string; }; +const MANAGED_SUBSCRIPTIONS_CACHE_MS = 5 * 60 * 1000; +const managedSubscriptionsCache = new Map< + string, + { expiresAt: number; value: ManagedSubscriptionEntry[] } +>(); +const managedSubscriptionsInFlight = new Map< + string, + Promise +>(); + export async function getGroupMembers(groupNumber: number) { const response = await fetch( `${getBaseApiReact()}/groups/members/${groupNumber}?limit=0` @@ -69,149 +79,183 @@ export function useManagedSubscriptionsFromGroups( if (!hasLoadedOnce.current) setLoading(true); setError(null); - try { - const results = await Promise.all( - groups - .filter((group) => group.owner === address) - .map(async (group): Promise => { - try { - const subscriptionId = getSubscriptionIdForGroup(group.groupId); - const { detailsIdentifier, indexIdentifier } = - await buildSubscriptionIdentifiers(subscriptionId); - const baseIdentifierPrefix = indexIdentifier.replace( - /-v\d+$/, - '' - ); - - const matchesResponse = await fetch( - `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${baseIdentifierPrefix}&exactmatchnames=true&limit=1&prefix=true&reverse=true&name=${name}` - ); - if (!matchesResponse.ok) return null; - const matches = await matchesResponse.json(); - const latestIdentifier = matches?.[0]?.identifier; - if (!latestIdentifier || !/-v\d+$/.test(latestIdentifier)) { - return null; - } + const cacheKey = JSON.stringify({ + address, + name, + groups: groups + .filter((group) => group.owner === address) + .map((group) => group.groupId) + .sort((left, right) => left - right), + }); + const cached = managedSubscriptionsCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + setManagedSubscriptions(cached.value); + hasLoadedOnce.current = true; + setLoading(false); + return; + } - let validJoinRequestCount = 0; - const joinResponse = await fetch( - `${getBaseApiReact()}/groups/joinrequests/${group.groupId}` - ); - if (joinResponse.ok) { - const joinRequests = await joinResponse.json(); - const validations = await Promise.all( - (Array.isArray(joinRequests) ? joinRequests : []).map( - async (request) => { - try { - const nameResponse = await fetch( - `${getBaseApiReact()}/names/primary/${request.joiner}` - ); - if (!nameResponse.ok) return false; - const primaryName = (await nameResponse.json())?.name; - if (!primaryName) return false; + try { + let request = managedSubscriptionsInFlight.get(cacheKey); + if (!request) { + request = Promise.all( + groups + .filter((group) => group.owner === address) + .map(async (group): Promise => { + try { + const subscriptionId = getSubscriptionIdForGroup( + group.groupId + ); + const { detailsIdentifier, indexIdentifier } = + await buildSubscriptionIdentifiers(subscriptionId); + const baseIdentifierPrefix = indexIdentifier.replace( + /-v\d+$/, + '' + ); - const resourceResponse = await fetch( - `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=PRODUCT&identifier=${detailsIdentifier}&exactmatchnames=true&limit=1&prefix=true&reverse=true&name=${primaryName}` - ); - const resources = await resourceResponse.json(); - return resourceResponse.ok && resources.length > 0; - } catch { - return false; - } - } - ) + const matchesResponse = await fetch( + `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=DOCUMENT&identifier=${baseIdentifierPrefix}&exactmatchnames=true&limit=1&prefix=true&reverse=true&name=${name}` ); - validJoinRequestCount = validations.filter(Boolean).length; - } + if (!matchesResponse.ok) return null; + const matches = await matchesResponse.json(); + const latestIdentifier = matches?.[0]?.identifier; + if (!latestIdentifier || !/-v\d+$/.test(latestIdentifier)) { + return null; + } - let needsReEncryption = false; - try { - const memberData = await getGroupMembers(group.groupId); - const { names: adminNames } = await getGroupAdmins( - group.groupId + let validJoinRequestCount = 0; + const joinResponse = await fetch( + `${getBaseApiReact()}/groups/joinrequests/${group.groupId}` ); + if (joinResponse.ok) { + const joinRequests = await joinResponse.json(); + const validations = await Promise.all( + (Array.isArray(joinRequests) ? joinRequests : []).map( + async (request) => { + try { + const nameResponse = await fetch( + `${getBaseApiReact()}/names/primary/${request.joiner}` + ); + if (!nameResponse.ok) return false; + const primaryName = (await nameResponse.json()) + ?.name; + if (!primaryName) return false; - if (adminNames.length > 0) { - const queryString = adminNames - .map( - (adminName) => `name=${encodeURIComponent(adminName)}` + const resourceResponse = await fetch( + `${getBaseApiReact()}/arbitrary/resources/search?mode=ALL&service=PRODUCT&identifier=${detailsIdentifier}&exactmatchnames=true&limit=1&prefix=true&reverse=true&name=${primaryName}` + ); + const resources = await resourceResponse.json(); + return resourceResponse.ok && resources.length > 0; + } catch { + return false; + } + } ) - .join('&'); - const resourcesResponse = await fetch( - `${getBaseApiReact()}/arbitrary/resources/searchsimple?mode=ALL&service=DOCUMENT_PRIVATE&identifier=symmetric-qchat-group-${group.groupId}&exactmatchnames=true&limit=0&reverse=true&${queryString}&prefix=true` + ); + validJoinRequestCount = validations.filter(Boolean).length; + } + + let needsReEncryption = false; + try { + const memberData = await getGroupMembers(group.groupId); + const { names: adminNames } = await getGroupAdmins( + group.groupId ); - if (!resourcesResponse.ok) { - needsReEncryption = true; - } else { - const resources = await resourcesResponse.json(); - const matchingResources = resources.filter( - (resource: any) => - resource.identifier === - `symmetric-qchat-group-${group.groupId}` + if (adminNames.length > 0) { + const queryString = adminNames + .map( + (adminName) => `name=${encodeURIComponent(adminName)}` + ) + .join('&'); + const resourcesResponse = await fetch( + `${getBaseApiReact()}/arbitrary/resources/searchsimple?mode=ALL&service=DOCUMENT_PRIVATE&identifier=symmetric-qchat-group-${group.groupId}&exactmatchnames=true&limit=0&reverse=true&${queryString}&prefix=true` ); - if (!matchingResources.length) { + if (!resourcesResponse.ok) { needsReEncryption = true; } else { - const latestResource = matchingResources.sort( - (left: any, right: any) => { - const leftDate = left.updated - ? new Date(left.updated) - : new Date(left.created); - const rightDate = right.updated - ? new Date(right.updated) - : new Date(right.created); - return rightDate.getTime() - leftDate.getTime(); - } - )[0]; - - const encryptedResponse = await fetch( - `${getBaseApiReact()}/arbitrary/DOCUMENT_PRIVATE/${latestResource.name}/${latestResource.identifier}?encoding=base64` + const resources = await resourcesResponse.json(); + const matchingResources = resources.filter( + (resource: any) => + resource.identifier === + `symmetric-qchat-group-${group.groupId}` ); - const encryptedData = await encryptedResponse.text(); - const combined = base64ToUint8Array(encryptedData); - const countStart = combined.length - 4; - const countArray = combined.slice( - countStart, - countStart + 4 - ); - const count = new Uint32Array(countArray.buffer)[0]; - if (count !== memberData?.memberCount) { + if (!matchingResources.length) { needsReEncryption = true; + } else { + const latestResource = matchingResources.sort( + (left: any, right: any) => { + const leftDate = left.updated + ? new Date(left.updated) + : new Date(left.created); + const rightDate = right.updated + ? new Date(right.updated) + : new Date(right.created); + return rightDate.getTime() - leftDate.getTime(); + } + )[0]; + + const encryptedResponse = await fetch( + `${getBaseApiReact()}/arbitrary/DOCUMENT_PRIVATE/${latestResource.name}/${latestResource.identifier}?encoding=base64` + ); + const encryptedData = await encryptedResponse.text(); + const combined = base64ToUint8Array(encryptedData); + const countStart = combined.length - 4; + const countArray = combined.slice( + countStart, + countStart + 4 + ); + const count = new Uint32Array(countArray.buffer)[0]; + + if (count !== memberData?.memberCount) { + needsReEncryption = true; + } } } } + } catch { + // Network failures should not create false positive actions. } - } catch { - // Network failures should not create false positive actions. - } - const actions = { - groupId: group.groupId, - pendingJoinRequests: validJoinRequestCount, - needsReEncryption, - totalActions: - validJoinRequestCount + (needsReEncryption ? 1 : 0), - }; - - return { - group, - groupId: group.groupId, - actions, - url: `manage/${group.groupId}`, - }; - } catch { - return null; - } - }) - ); + const actions = { + groupId: group.groupId, + pendingJoinRequests: validJoinRequestCount, + needsReEncryption, + totalActions: + validJoinRequestCount + (needsReEncryption ? 1 : 0), + }; - if (!cancelled) { - setManagedSubscriptions( - results.filter(Boolean) as ManagedSubscriptionEntry[] + return { + group, + groupId: group.groupId, + actions, + url: `manage/${group.groupId}`, + }; + } catch { + return null; + } + }) + ).then( + (results) => results.filter(Boolean) as ManagedSubscriptionEntry[] ); + managedSubscriptionsInFlight.set(cacheKey, request); + } + let results: ManagedSubscriptionEntry[]; + try { + results = await request; + } finally { + if (managedSubscriptionsInFlight.get(cacheKey) === request) { + managedSubscriptionsInFlight.delete(cacheKey); + } + } + managedSubscriptionsCache.set(cacheKey, { + expiresAt: Date.now() + MANAGED_SUBSCRIPTIONS_CACHE_MS, + value: results, + }); + if (!cancelled) { + setManagedSubscriptions(results); hasLoadedOnce.current = true; } } catch (event: any) { From 3da21f3e22b3bdbadd3e86cef81e325bbca87050 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sun, 30 Aug 2026 22:53:55 +0300 Subject: [PATCH 04/32] fixes --- docs/reticulum.md | 10 + electron/resources/presence_bridge.py | 142 ++++++++--- electron/resources/presence_bridge_test.py | 267 ++++++++++++++++++++ electron/src/qapp-reticulum-manager.test.ts | 126 +++++++++ electron/src/qapp-reticulum-manager.ts | 91 +++++-- 5 files changed, 577 insertions(+), 59 deletions(-) diff --git a/docs/reticulum.md b/docs/reticulum.md index a0b50698..18f2e6fc 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -94,6 +94,16 @@ A Python subprocess (`presence_bridge.py`) launched after the daemon is ready. | `send_qchat_file_resource` | Send a file offer to a peer | | `authorize_qchat_file_resource` | Authorise file delivery | +### Q-App Backend Scheduling + +Q-App backend traffic is isolated from Hub control traffic and from its own realtime data path: + +- `qapp-rpc-*` handles bounded request/response work. +- `qapp-realtime-*` handles gameplay and other realtime sends. +- `qapp-lifecycle-*` handles connection setup and teardown. + +Each class is deterministically sharded by Q-App owner and backend destination. Once an RPC link is established, waiting for its response is callback-driven; it does not occupy a scheduler worker. The Electron manager additionally caps pending RPCs and logical connections both per Q-App owner and globally so one Q-App—or many malfunctioning owners—cannot exhaust shared bridge capacity. + ### Mesh Coordinator — `reticulum-mesh.ts` Handles hub-to-hub mesh networking, separate from the TLS P2P layer (`p2p-network.ts`). diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index bbac9bd7..3228bcff 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -873,6 +873,9 @@ def _qchat_file_should_bridge_chunk_resource(resource_type: str, stream_mode: bo _SCHEDULER_LAND_STATE_SHARDS = 4 _SCHEDULER_RESOURCE_OPEN_SHARDS = 4 _SCHEDULER_OVERLAY_MIGRATION_SHARDS = 2 +_SCHEDULER_QAPP_RPC_SHARDS = 4 +_SCHEDULER_QAPP_REALTIME_SHARDS = 4 +_SCHEDULER_QAPP_LIFECYCLE_SHARDS = 2 _SCHEDULER_RNS_OVERLAY_CALLBACK_SHARDS = 4 _SCHEDULER_RNS_REALTIME_CALLBACK_SHARDS = 4 _SCHEDULER_RNS_RESOURCE_CALLBACK_SHARDS = 4 @@ -904,6 +907,12 @@ def _qchat_file_should_bridge_chunk_resource(resource_type: str, stream_mode: bo _SCHEDULER_QUEUE_MAX_BY_LANE[f"resource-open-{_resource_open_shard}"] = 32 for _overlay_migration_shard in range(_SCHEDULER_OVERLAY_MIGRATION_SHARDS): _SCHEDULER_QUEUE_MAX_BY_LANE[f"overlay-migration-{_overlay_migration_shard}"] = 2 +for _qapp_rpc_shard in range(_SCHEDULER_QAPP_RPC_SHARDS): + _SCHEDULER_QUEUE_MAX_BY_LANE[f"qapp-rpc-{_qapp_rpc_shard}"] = 32 +for _qapp_realtime_shard in range(_SCHEDULER_QAPP_REALTIME_SHARDS): + _SCHEDULER_QUEUE_MAX_BY_LANE[f"qapp-realtime-{_qapp_realtime_shard}"] = 64 +for _qapp_lifecycle_shard in range(_SCHEDULER_QAPP_LIFECYCLE_SHARDS): + _SCHEDULER_QUEUE_MAX_BY_LANE[f"qapp-lifecycle-{_qapp_lifecycle_shard}"] = 16 for _callback_shard in range(_SCHEDULER_RNS_OVERLAY_CALLBACK_SHARDS): _SCHEDULER_QUEUE_MAX_BY_LANE[f"rns-overlay-callback-{_callback_shard}"] = 1024 for _callback_shard in range(_SCHEDULER_RNS_REALTIME_CALLBACK_SHARDS): @@ -5507,6 +5516,20 @@ def _resource_open_scheduler_lane(peer_hash: str) -> str: return f"resource-open-{shard}" +def _qapp_scheduler_lane(prefix: str, message: Optional[Dict[str, Any]], shards: int) -> str: + payload = message.get("payload") if isinstance(message, dict) else None + route_key = "unknown" + if isinstance(payload, dict): + route_key = str( + payload.get("managerKey") + or payload.get("destination") + or payload.get("connectionId") + or "unknown" + ) + digest = hashlib.blake2s(route_key.encode("utf-8"), digest_size=2).digest() + return f"{prefix}-{int.from_bytes(digest, 'big') % max(1, shards)}" + + def _rns_callback_lane(kind: str, link_id: str) -> str: normalized_kind = str(kind or "overlay").strip().lower() route_key = str(link_id or "unknown") @@ -5737,6 +5760,16 @@ def _scheduler_lane_for_command( message: Optional[Dict[str, Any]] = None, ) -> str: action_name = str(action or "") + if action_name == "qapp_rns_request": + return _qapp_scheduler_lane("qapp-rpc", message, _SCHEDULER_QAPP_RPC_SHARDS) + if action_name == "qapp_rns_send": + return _qapp_scheduler_lane( + "qapp-realtime", message, _SCHEDULER_QAPP_REALTIME_SHARDS + ) + if action_name in {"qapp_rns_connect", "qapp_rns_close"}: + return _qapp_scheduler_lane( + "qapp-lifecycle", message, _SCHEDULER_QAPP_LIFECYCLE_SHARDS + ) if action_name in {"clear_group_audio_diagnostics"}: return "control-send" if action_name == "send_group_audio_link_control": @@ -26937,9 +26970,6 @@ def reconnect() -> None: def _qapp_rns_schedule_idle(entry: Dict[str, Any]) -> None: - generation = int(entry.get("idle_generation") or 0) + 1 - entry["idle_generation"] = generation - def cleanup() -> None: link = None with _state_lock: @@ -26953,6 +26983,7 @@ def cleanup() -> None: or time.time() - float(entry.get("last_used") or 0) < _QAPP_RNS_IDLE_SECONDS ): return + entry["idle_timer"] = None _qapp_rns_entries.pop(entry["managerKey"], None) link = entry.get("link") entry["generation"] = int(entry.get("generation") or 0) + 1 @@ -26963,9 +26994,23 @@ def cleanup() -> None: pass log(f"[presence_bridge] target=qapp-rns idle_teardown destination={entry.get('destination')}") - timer = threading.Timer(_QAPP_RNS_IDLE_SECONDS, cleanup) - timer.daemon = True - timer.start() + with _state_lock: + previous_timer = entry.get("idle_timer") + if previous_timer is not None: + previous_timer.cancel() + generation = int(entry.get("idle_generation") or 0) + 1 + entry["idle_generation"] = generation + timer = threading.Timer(_QAPP_RNS_IDLE_SECONDS, cleanup) + timer.daemon = True + entry["idle_timer"] = timer + try: + timer.start() + except Exception as exc: + entry["idle_timer"] = None + log( + "[presence_bridge] target=qapp-rns idle_timer_start_failed " + f"destination={entry.get('destination')} error={type(exc).__name__}" + ) def _qapp_rns_closed(manager_key: str, generation: int, link) -> None: @@ -27063,6 +27108,7 @@ def _qapp_rns_entry(manager_key: str, destination: str) -> Dict[str, Any]: "last_used": time.time(), "reconnect_attempt": 0, "idle_generation": 0, + "idle_timer": None, } _qapp_rns_entries[manager_key] = entry return entry @@ -27152,10 +27198,39 @@ def handle_qapp_rns_request(req_id: str, payload: Dict[str, Any]) -> None: if not _qapp_rns_open_link(entry): return _qapp_rns_error(req_id, "RNS_LINK_TIMEOUT") timeout = min(120.0, max(1.0, float(payload.get("timeoutMs") or 30000) / 1000.0)) - completed = threading.Event() - result: Dict[str, Any] = {} max_response = min(1024 * 1024, max(1, int(payload.get("maxResponseBytes") or 1024 * 1024))) + with _state_lock: + if int(entry.get("active_requests") or 0) >= 16: + return _qapp_rns_error(req_id, "RNS_SEND_QUEUE_FULL") + entry["active_requests"] += 1 + + completion_lock = threading.Lock() + completed = False + timeout_timer: Optional[threading.Timer] = None + + def finish( + response_payload: Optional[Dict[str, Any]] = None, + code: str = "", + ) -> None: + nonlocal completed + with completion_lock: + if completed: + return + completed = True + if timeout_timer is not None: + timeout_timer.cancel() + with _state_lock: + entry["active_requests"] = max( + 0, int(entry.get("active_requests") or 0) - 1 + ) + entry["last_used"] = time.time() + _qapp_rns_schedule_idle(entry) + if response_payload is not None: + emit_resp(req_id, True, payload=response_payload) + else: + _qapp_rns_error(req_id, code or "RNS_REQUEST_TIMEOUT") + def response_received(receipt) -> None: try: value = receipt.get_response() @@ -27166,16 +27241,19 @@ def response_received(receipt) -> None: response_bytes = json.dumps(value, separators=(",", ":")).encode("utf-8") encoding = "json" if len(response_bytes) > max_response: - result["code"] = "RNS_RESPONSE_TOO_LARGE" + finish(code="RNS_RESPONSE_TOO_LARGE") else: - result["payload"] = {"payloadBase64": base64.b64encode(response_bytes).decode("ascii"), "encoding": encoding} + finish( + { + "payloadBase64": base64.b64encode(response_bytes).decode("ascii"), + "encoding": encoding, + } + ) except Exception: - result["code"] = "RNS_PROTOCOL_ERROR" - completed.set() + finish(code="RNS_PROTOCOL_ERROR") def request_failed(_value=None) -> None: - result["code"] = "RNS_REQUEST_TIMEOUT" - completed.set() + finish(code="RNS_REQUEST_TIMEOUT") request_data = { "version": 1, @@ -27183,27 +27261,21 @@ def request_failed(_value=None) -> None: "encoding": "base64" if payload.get("encoding") == "base64" else "json", "payloadBase64": str(payload.get("payloadBase64") or ""), } - with _state_lock: - if int(entry.get("active_requests") or 0) >= 16: - return _qapp_rns_error(req_id, "RNS_SEND_QUEUE_FULL") - entry["active_requests"] += 1 - receipt = entry["link"].request( - str(payload.get("path") or "/"), - data=request_data, - response_callback=response_received, - failed_callback=request_failed, - timeout=timeout, - ) - if receipt is False: - request_failed() - completed.wait(timeout + 1.0) - with _state_lock: - entry["active_requests"] = max(0, int(entry["active_requests"]) - 1) - entry["last_used"] = time.time() - _qapp_rns_schedule_idle(entry) - if "payload" not in result: - return _qapp_rns_error(req_id, str(result.get("code") or "RNS_REQUEST_TIMEOUT")) - emit_resp(req_id, True, payload=result["payload"]) + try: + timeout_timer = threading.Timer(timeout + 1.0, request_failed) + timeout_timer.daemon = True + timeout_timer.start() + receipt = entry["link"].request( + str(payload.get("path") or "/"), + data=request_data, + response_callback=response_received, + failed_callback=request_failed, + timeout=timeout, + ) + if receipt is False: + request_failed() + except Exception: + finish(code="RNS_REQUEST_TIMEOUT") except Exception: _qapp_rns_error(req_id, "RNS_REQUEST_TIMEOUT") diff --git a/electron/resources/presence_bridge_test.py b/electron/resources/presence_bridge_test.py index 8df56528..cf05f2d2 100644 --- a/electron/resources/presence_bridge_test.py +++ b/electron/resources/presence_bridge_test.py @@ -157,6 +157,217 @@ def request(self, _path, **options): {"protocol": "qortal-example", "version": 1, "items": []}, ) + def test_rpc_response_wait_does_not_block_scheduler_worker(self): + emitted = [] + callbacks = {} + + class Receipt: + def get_response(self): + return {"ok": True} + + class Link: + def request(self, _path, **options): + callbacks.update(options) + return object() + + entry = {"link": Link(), "active_requests": 0, "last_used": 0} + payload = { + "managerKey": "rpc-async-test", + "destination": "00" * 16, + "path": "/example/status", + "requestId": "status-test", + "encoding": "json", + "payloadBase64": base64.b64encode(b"{}").decode("ascii"), + } + + with mock.patch.object( + self.bridge, "_qapp_rns_entry", return_value=entry + ), mock.patch.object( + self.bridge, "_qapp_rns_open_link", return_value=True + ), mock.patch.object( + self.bridge, "_qapp_rns_schedule_idle" + ), mock.patch.object( + self.bridge, + "emit_resp", + side_effect=lambda request_id, ok, payload=None: emitted.append( + (request_id, ok, payload) + ), + ): + started = time.monotonic() + self.bridge.handle_qapp_rns_request("bridge-request", payload) + elapsed = time.monotonic() - started + + self.assertLess(elapsed, 0.1) + self.assertEqual(emitted, []) + self.assertEqual(entry["active_requests"], 1) + + callbacks["response_callback"](Receipt()) + self.assertEqual(len(emitted), 1) + self.assertEqual(entry["active_requests"], 0) + + def test_rpc_timeout_and_late_callback_complete_only_once(self): + emitted = [] + callbacks = {} + timers = [] + + class Timer: + def __init__(self, _delay, callback): + self.callback = callback + self.cancelled = False + self.daemon = False + timers.append(self) + + def start(self): + return None + + def cancel(self): + self.cancelled = True + + class Receipt: + def get_response(self): + return {"late": True} + + class Link: + def request(self, _path, **options): + callbacks.update(options) + return object() + + entry = {"link": Link(), "active_requests": 0, "last_used": 0} + payload = { + "managerKey": "rpc-timeout-test", + "destination": "00" * 16, + "path": "/example/status", + "requestId": "timeout-test", + } + + with mock.patch.object( + self.bridge, "_qapp_rns_entry", return_value=entry + ), mock.patch.object( + self.bridge, "_qapp_rns_open_link", return_value=True + ), mock.patch.object( + self.bridge, "_qapp_rns_schedule_idle" + ), mock.patch.object( + self.bridge.threading, "Timer", Timer + ), mock.patch.object( + self.bridge, + "emit_resp", + side_effect=lambda request_id, ok, payload=None, error=None: emitted.append( + (request_id, ok, payload, error) + ), + ): + self.bridge.handle_qapp_rns_request("bridge-request", payload) + timers[0].callback() + callbacks["response_callback"](Receipt()) + + self.assertEqual(len(emitted), 1) + self.assertFalse(emitted[0][1]) + self.assertEqual(entry["active_requests"], 0) + + def test_rpc_timer_start_failure_releases_active_request(self): + emitted = [] + + class Timer: + daemon = False + + def __init__(self, _delay, _callback): + pass + + def start(self): + raise RuntimeError("thread unavailable") + + def cancel(self): + pass + + entry = {"link": object(), "active_requests": 0, "last_used": 0} + payload = { + "managerKey": "rpc-timer-failure-test", + "destination": "00" * 16, + "path": "/example/status", + "requestId": "timer-failure-test", + } + + with mock.patch.object( + self.bridge, "_qapp_rns_entry", return_value=entry + ), mock.patch.object( + self.bridge, "_qapp_rns_open_link", return_value=True + ), mock.patch.object( + self.bridge, "_qapp_rns_schedule_idle" + ), mock.patch.object( + self.bridge.threading, "Timer", Timer + ), mock.patch.object( + self.bridge, + "emit_resp", + side_effect=lambda request_id, ok, payload=None, error=None: emitted.append( + (request_id, ok, payload, error) + ), + ): + self.bridge.handle_qapp_rns_request("bridge-request", payload) + + self.assertEqual(len(emitted), 1) + self.assertFalse(emitted[0][1]) + self.assertEqual(entry["active_requests"], 0) + + def test_idle_reschedule_replaces_previous_timer(self): + timers = [] + + class Timer: + def __init__(self, _delay, callback): + self.callback = callback + self.cancelled = False + self.daemon = False + timers.append(self) + + def start(self): + return None + + def cancel(self): + self.cancelled = True + + entry = { + "managerKey": "idle-timer-test", + "destination": "00" * 16, + "idle_generation": 0, + "idle_timer": None, + } + + with mock.patch.object(self.bridge.threading, "Timer", Timer): + self.bridge._qapp_rns_schedule_idle(entry) + self.bridge._qapp_rns_schedule_idle(entry) + + self.assertEqual(len(timers), 2) + self.assertTrue(timers[0].cancelled) + self.assertFalse(timers[1].cancelled) + self.assertIs(entry["idle_timer"], timers[1]) + self.assertEqual(entry["idle_generation"], 2) + + def test_idle_timer_start_failure_is_best_effort(self): + class Timer: + daemon = False + + def __init__(self, _delay, _callback): + pass + + def start(self): + raise RuntimeError("thread unavailable") + + def cancel(self): + pass + + entry = { + "managerKey": "idle-timer-failure-test", + "destination": "00" * 16, + "idle_generation": 0, + "idle_timer": None, + } + + with mock.patch.object(self.bridge.threading, "Timer", Timer), mock.patch.object( + self.bridge, "log" + ) as log: + self.bridge._qapp_rns_schedule_idle(entry) + + self.assertIsNone(entry["idle_timer"]) + log.assert_called_once() + def test_close_while_reconnecting_does_not_resurrect_link(self): entry = { "managerKey": "close-race-test", @@ -4458,6 +4669,62 @@ def test_resource_commands_use_fast_control_lane(self): "resource-control", ) + def test_qapp_commands_are_isolated_by_work_class_and_owner(self): + first = {"payload": {"managerKey": "owner-a"}} + same_owner = {"payload": {"managerKey": "owner-a"}} + second = {"payload": {"managerKey": "owner-b"}} + + rpc_lane = self.bridge._scheduler_lane_for_command( + "qapp_rns_request", first + ) + self.assertEqual( + rpc_lane, + self.bridge._scheduler_lane_for_command( + "qapp_rns_request", same_owner + ), + ) + self.assertTrue(rpc_lane.startswith("qapp-rpc-")) + self.assertTrue( + self.bridge._scheduler_lane_for_command( + "qapp_rns_send", first + ).startswith("qapp-realtime-") + ) + self.assertTrue( + self.bridge._scheduler_lane_for_command( + "qapp_rns_connect", second + ).startswith("qapp-lifecycle-") + ) + self.assertEqual( + self.bridge._scheduler_lane_for_command("publish_presence"), + "control-send", + ) + self.assertIn(rpc_lane, self.bridge._SCHEDULER_QUEUE_MAX_BY_LANE) + + def test_full_qapp_rpc_lane_does_not_consume_control_capacity(self): + message = {"payload": {"managerKey": "owner-a"}} + rpc_lane = self.bridge._scheduler_lane_for_command( + "qapp_rns_request", message + ) + previous = dict(self.bridge._scheduler_queues) + try: + self.bridge._scheduler_queues[rpc_lane] = queue.Queue(maxsize=1) + self.bridge._scheduler_queues["control-send"] = queue.Queue(maxsize=1) + self.bridge._scheduler_queues[rpc_lane].put_nowait(object()) + + self.assertFalse( + self.bridge._enqueue_scheduler_task( + rpc_lane, "blocked-rpc", lambda: None + ) + ) + self.assertTrue( + self.bridge._enqueue_scheduler_task( + "control-send", "healthy-control", lambda: None + ) + ) + finally: + self.bridge._scheduler_queues.clear() + self.bridge._scheduler_queues.update(previous) + def test_resource_open_shard_is_stable_for_a_peer(self): lane = self.bridge._resource_open_scheduler_lane(self.peer_hash) self.assertEqual( diff --git a/electron/src/qapp-reticulum-manager.test.ts b/electron/src/qapp-reticulum-manager.test.ts index 3053636d..b8b71602 100644 --- a/electron/src/qapp-reticulum-manager.test.ts +++ b/electron/src/qapp-reticulum-manager.test.ts @@ -220,4 +220,130 @@ describe('QAppReticulumManager', () => { ).rejects.toMatchObject({ code: 'RNS_REQUEST_TIMEOUT' }); expect(transport.invoke).toHaveBeenCalledTimes(1); }); + + it('limits pending RPCs independently for each Q-App owner', async () => { + const transport = new FakeTransport(); + const releases: Array<() => void> = []; + transport.invoke = vi.fn( + () => + new Promise((resolve) => { + releases.push(() => + resolve({ + ok: true, + payload: { + payloadBase64: Buffer.from('{}').toString('base64'), + encoding: 'json', + }, + }) + ); + }) + ); + const manager = new QAppReticulumManager(transport); + const pending = Array.from({ length: 8 }, (_, index) => + manager.request(alice, { + destination, + path: '/status', + requestId: `pending-${index}`, + }) + ); + + await expect( + manager.request(alice, { destination, path: '/status' }) + ).rejects.toMatchObject({ code: 'RNS_SEND_QUEUE_FULL' }); + const bobRequest = manager.request(bob, { + destination, + path: '/status', + }); + + for (const release of releases) release(); + await expect(Promise.all([...pending, bobRequest])).resolves.toHaveLength( + 9 + ); + }); + + it('bounds pending RPCs across all Q-App owners', async () => { + const transport = new FakeTransport(); + const releases: Array<() => void> = []; + transport.invoke = vi.fn( + () => + new Promise((resolve) => { + releases.push(() => + resolve({ + ok: true, + payload: { + payloadBase64: Buffer.from('{}').toString('base64'), + encoding: 'json', + }, + }) + ); + }) + ); + const manager = new QAppReticulumManager(transport); + const pending = Array.from({ length: 64 }, (_, index) => + manager.request( + { ...alice, tabId: `global-rpc-${index}` }, + { destination, path: '/status' } + ) + ); + + await expect( + manager.request( + { ...alice, tabId: 'global-rpc-overflow' }, + { destination, path: '/status' } + ) + ).rejects.toMatchObject({ code: 'RNS_SEND_QUEUE_FULL' }); + + for (const release of releases) release(); + await expect(Promise.all(pending)).resolves.toHaveLength(64); + }); + + it('releases a logical connection slot when native connect rejects', async () => { + const transport = new FakeTransport(); + transport.invoke = vi + .fn() + .mockRejectedValueOnce(new Error('transport unavailable')) + .mockResolvedValue({ ok: true, payload: {} }); + const manager = new QAppReticulumManager(transport); + + await expect(manager.connect(alice, destination)).rejects.toThrow( + 'transport unavailable' + ); + await expect( + Promise.all( + Array.from({ length: 8 }, () => manager.connect(alice, destination)) + ) + ).resolves.toHaveLength(8); + }); + + it('limits logical realtime connections per Q-App owner', async () => { + const manager = new QAppReticulumManager(new FakeTransport()); + await Promise.all( + Array.from({ length: 8 }, () => manager.connect(alice, destination)) + ); + await expect(manager.connect(alice, destination)).rejects.toMatchObject({ + code: 'RNS_SEND_QUEUE_FULL', + }); + await expect(manager.connect(bob, destination)).resolves.toMatchObject({ + state: 'CONNECTED', + }); + }); + + it('bounds logical realtime connections across all Q-App owners', async () => { + const manager = new QAppReticulumManager(new FakeTransport()); + await Promise.all( + Array.from({ length: 64 }, (_, index) => + manager.connect( + { ...alice, tabId: `global-connection-${index}` }, + destination + ) + ) + ); + + await expect( + manager.connect( + { ...alice, tabId: 'global-connection-overflow' }, + destination + ) + ).rejects.toMatchObject({ code: 'RNS_SEND_QUEUE_FULL' }); + }); }); diff --git a/electron/src/qapp-reticulum-manager.ts b/electron/src/qapp-reticulum-manager.ts index 906aa2ed..bc64f594 100644 --- a/electron/src/qapp-reticulum-manager.ts +++ b/electron/src/qapp-reticulum-manager.ts @@ -59,6 +59,10 @@ const DESTINATION_PATTERN = /^[0-9a-f]{32}$/; const MAX_RPC_REQUEST_BYTES = 256 * 1024; const MAX_RPC_RESPONSE_BYTES = 1024 * 1024; const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024; +const MAX_PENDING_RPC_REQUESTS_PER_OWNER = 8; +const MAX_PENDING_RPC_REQUESTS_GLOBAL = 64; +const MAX_CONNECTIONS_PER_OWNER = 8; +const MAX_CONNECTIONS_GLOBAL = 64; function ownerKey(owner: QAppReticulumOwner): string { return `${owner.tabId}\u0000${owner.service}\u0000${owner.name}`; @@ -98,6 +102,8 @@ function decodePayload(payload: Record): unknown { export class QAppReticulumManager extends EventEmitter { private readonly connections = new Map(); private readonly connectionsByOwner = new Map>(); + private readonly pendingRequestsByOwner = new Map(); + private pendingRequestCount = 0; private readonly detachTransport: () => void; constructor(private readonly transport: QAppReticulumTransport) { @@ -126,26 +132,40 @@ export class QAppReticulumManager extends EventEmitter { 'Invalid request path' ); } - const encoded = encodePayload(options.payload, MAX_RPC_REQUEST_BYTES); - const result = await this.transport.invoke('qapp_rns_request', { - managerKey: this.managerKey(owner, destination), - destination, - path, - ...encoded, - timeoutMs: Math.min( - Math.max(options.timeoutMs ?? 30_000, 1_000), - 120_000 - ), - maxResponseBytes: Math.min( - Math.max(options.maxResponseBytes ?? MAX_RPC_RESPONSE_BYTES, 1), - MAX_RPC_RESPONSE_BYTES - ), - requestId: String(options.requestId ?? randomUUID()), - }); - if (!result.ok || !result.payload) { - throw new QAppReticulumError(result.code ?? 'RNS_REQUEST_TIMEOUT'); + const key = ownerKey(owner); + const pending = this.pendingRequestsByOwner.get(key) ?? 0; + if ( + pending >= MAX_PENDING_RPC_REQUESTS_PER_OWNER || + this.pendingRequestCount >= MAX_PENDING_RPC_REQUESTS_GLOBAL + ) { + throw new QAppReticulumError('RNS_SEND_QUEUE_FULL'); + } + this.pendingRequestsByOwner.set(key, pending + 1); + this.pendingRequestCount += 1; + try { + const encoded = encodePayload(options.payload, MAX_RPC_REQUEST_BYTES); + const result = await this.transport.invoke('qapp_rns_request', { + managerKey: this.managerKey(owner, destination), + destination, + path, + ...encoded, + timeoutMs: Math.min( + Math.max(options.timeoutMs ?? 30_000, 1_000), + 120_000 + ), + maxResponseBytes: Math.min( + Math.max(options.maxResponseBytes ?? MAX_RPC_RESPONSE_BYTES, 1), + MAX_RPC_RESPONSE_BYTES + ), + requestId: String(options.requestId ?? randomUUID()), + }); + if (!result.ok || !result.payload) { + throw new QAppReticulumError(result.code ?? 'RNS_REQUEST_TIMEOUT'); + } + return decodePayload(result.payload); + } finally { + this.releasePendingRequest(key); } - return decodePayload(result.payload); } async connect( @@ -155,6 +175,13 @@ export class QAppReticulumManager extends EventEmitter { const destination = this.validateDestination(destinationValue); const connectionId = `rns-${randomUUID()}`; const key = ownerKey(owner); + if ( + (this.connectionsByOwner.get(key)?.size ?? 0) >= + MAX_CONNECTIONS_PER_OWNER || + this.connections.size >= MAX_CONNECTIONS_GLOBAL + ) { + throw new QAppReticulumError('RNS_SEND_QUEUE_FULL'); + } const connection: LogicalConnection = { connectionId, ownerKey: key, @@ -166,11 +193,17 @@ export class QAppReticulumManager extends EventEmitter { const owned = this.connectionsByOwner.get(key) ?? new Set(); owned.add(connectionId); this.connectionsByOwner.set(key, owned); - const result = await this.transport.invoke('qapp_rns_connect', { - managerKey: connection.managerKey, - connectionId, - destination, - }); + let result: Awaited>; + try { + result = await this.transport.invoke('qapp_rns_connect', { + managerKey: connection.managerKey, + connectionId, + destination, + }); + } catch (error) { + this.removeConnection(connection); + throw error; + } if (!result.ok) { this.removeConnection(connection); throw new QAppReticulumError( @@ -248,6 +281,8 @@ export class QAppReticulumManager extends EventEmitter { this.detachTransport(); this.connections.clear(); this.connectionsByOwner.clear(); + this.pendingRequestsByOwner.clear(); + this.pendingRequestCount = 0; } private handleTransportEvent(event: QAppReticulumNativeEvent): void { @@ -293,6 +328,14 @@ export class QAppReticulumManager extends EventEmitter { if (owned?.size === 0) this.connectionsByOwner.delete(connection.ownerKey); } + private releasePendingRequest(key: string): void { + const pending = this.pendingRequestsByOwner.get(key) ?? 0; + if (pending <= 0) return; + if (pending === 1) this.pendingRequestsByOwner.delete(key); + else this.pendingRequestsByOwner.set(key, pending - 1); + this.pendingRequestCount = Math.max(0, this.pendingRequestCount - 1); + } + private managerKey(owner: QAppReticulumOwner, destination: string): string { return `${ownerKey(owner)}\u0000${destination}`; } From e5dcdf9bc8d235d5943629abdfa1c48323ad9baf Mon Sep 17 00:00:00 2001 From: PhilReact Date: Mon, 31 Aug 2026 00:12:51 +0300 Subject: [PATCH 05/32] fixes --- electron/src/single-flight-readiness.test.ts | 17 +++ electron/src/single-flight-readiness.ts | 8 ++ src/hooks/useQortalMessageListener.tsx | 49 +++++---- src/qortal/qapp-reticulum-request.test.ts | 106 +++++++++++++++++++ src/qortal/qapp-reticulum-request.ts | 95 +++++++++++++++++ src/qortal/qortal-requests.ts | 66 ------------ 6 files changed, 252 insertions(+), 89 deletions(-) create mode 100644 src/qortal/qapp-reticulum-request.test.ts create mode 100644 src/qortal/qapp-reticulum-request.ts diff --git a/electron/src/single-flight-readiness.test.ts b/electron/src/single-flight-readiness.test.ts index 5f82e6d2..e85af7ee 100644 --- a/electron/src/single-flight-readiness.test.ts +++ b/electron/src/single-flight-readiness.test.ts @@ -19,6 +19,23 @@ function deferred(): { } describe('SingleFlightReadiness', () => { + it('is a no-op when the target is already ready', async () => { + const start = vi.fn(async () => undefined); + const onStatusChange = vi.fn(); + const readiness = new SingleFlightReadiness({ + isReady: () => true, + onStatusChange, + start, + }); + + await expect(readiness.ensureReady()).resolves.toBeUndefined(); + await expect(readiness.ensureReady()).resolves.toBeUndefined(); + + expect(start).not.toHaveBeenCalled(); + expect(onStatusChange).not.toHaveBeenCalled(); + expect(readiness.getStatus()).toEqual({ state: 'ready', revision: 0 }); + }); + it('shares one startup attempt between concurrent callers', async () => { const startup = deferred(); let ready = false; diff --git a/electron/src/single-flight-readiness.ts b/electron/src/single-flight-readiness.ts index 5ea34680..933e3ab9 100644 --- a/electron/src/single-flight-readiness.ts +++ b/electron/src/single-flight-readiness.ts @@ -31,6 +31,14 @@ export class SingleFlightReadiness { return this.activeStart; } + // Callers use ensureReady() at request boundaries. Once the target exists, + // another call is not a lifecycle transition and must not publish a false + // `starting -> ready` cycle. Consumers treat that cycle as a reconnect and + // may perform expensive recovery work. + if (this.options.isReady()) { + return Promise.resolve(); + } + this.setStatus({ state: 'starting' }); const generation = this.generation; const start = Promise.resolve() diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index fb3a309d..af0e9610 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -17,6 +17,10 @@ import { TIME_SECONDS_30_IN_MILLISECONDS, } from '../constants/constants'; import { buildQortalResourceLink } from '../utils/qortalLink'; +import { + dispatchQAppReticulumRequest, + isQAppReticulumAction, +} from '../qortal/qapp-reticulum-request'; export const saveFileInChunks = async ( blob: Blob, @@ -712,16 +716,6 @@ export const useQortalMessageListener = ( TIME_MINUTES_30_IN_MILLISECONDS; } else if (message?.action === 'PUBLISH_QDN_RESOURCE') { timeout = TIME_HOURS_1_IN_MILLISECONDS; - } else if (message?.action === 'RNS_CONNECT') { - timeout = 120_000; - } else if (message?.action === 'RNS_REQUEST') { - timeout = Math.min( - 195_000, - Math.max( - 120_000, - Number(message?.payload?.timeoutMs ?? 30_000) + 75_000 - ) - ); } // Build deduplication key for read-only actions @@ -768,17 +762,24 @@ export const useQortalMessageListener = ( return; } - const requestPromise = window.sendMessage( - message.action, - message.payload, - timeout, - message.isExtension, - { - name: appName, - service: appService, - tabId, - } - ); + const requestPromise = isQAppReticulumAction(message?.action) + ? dispatchQAppReticulumRequest(message.payload, { + appName, + appService, + isFromExtension: message.isExtension, + tabId, + }) + : window.sendMessage( + message.action, + message.payload, + timeout, + message.isExtension, + { + name: appName, + service: appService, + tabId, + } + ); // Store the promise for deduplication if (isDeduplicable) { @@ -812,11 +813,13 @@ export const useQortalMessageListener = ( }) .catch((error) => { console.error('Failed qortalRequest', error); + const requestError = + error?.code || error?.message || 'Request failed'; eventPort.postMessage({ result: null, error: { - error: error?.message || 'Request failed', - message: error?.message || 'An error has occurred', + error: requestError, + message: requestError, }, }); }); diff --git a/src/qortal/qapp-reticulum-request.test.ts b/src/qortal/qapp-reticulum-request.test.ts new file mode 100644 index 00000000..6fab4e6a --- /dev/null +++ b/src/qortal/qapp-reticulum-request.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { authorizeRnsDestination } = vi.hoisted(() => ({ + authorizeRnsDestination: vi.fn(), +})); + +vi.mock('./get.ts', () => ({ authorizeRnsDestination })); + +import { dispatchQAppReticulumRequest } from './qapp-reticulum-request'; + +const destination = 'a'.repeat(32); +const context = { + appName: 'ExampleApp', + appService: 'APP', + isFromExtension: true, + tabId: 42, +}; +const owner = { name: 'ExampleApp', service: 'APP', tabId: '42' }; + +describe('dedicated Q-App Reticulum request dispatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + authorizeRnsDestination.mockResolvedValue(destination); + Object.assign(window, { + electronAPI: { + qappReticulumClose: vi.fn().mockResolvedValue(true), + qappReticulumConnect: vi + .fn() + .mockResolvedValue({ connectionId: 'connection', state: 'OPEN' }), + qappReticulumRequest: vi.fn().mockResolvedValue({ ok: true }), + qappReticulumSend: vi.fn().mockResolvedValue(true), + }, + sendMessage: vi.fn(), + }); + }); + + it('sends RPC directly to Electron without using the global message bus', async () => { + const message = { + action: 'RNS_REQUEST', + destination, + path: '/example/status', + payload: { requestId: 'request-1' }, + }; + + await expect( + dispatchQAppReticulumRequest(message, context) + ).resolves.toEqual({ ok: true }); + + expect(authorizeRnsDestination).toHaveBeenCalledWith( + destination, + true, + { name: 'ExampleApp', tabId: 42 } + ); + expect(window.electronAPI.qappReticulumRequest).toHaveBeenCalledWith( + owner, + message + ); + expect(window.sendMessage).not.toHaveBeenCalled(); + }); + + it('keeps realtime connections scoped to their Q-App owner', async () => { + await dispatchQAppReticulumRequest( + { action: 'RNS_CONNECT', destination }, + context + ); + await dispatchQAppReticulumRequest( + { + action: 'RNS_SEND', + connectionId: 'connection', + payload: { type: 'ping' }, + }, + context + ); + await dispatchQAppReticulumRequest( + { action: 'RNS_CLOSE', connectionId: 'connection' }, + context + ); + + expect(window.electronAPI.qappReticulumConnect).toHaveBeenCalledWith( + owner, + destination + ); + expect(window.electronAPI.qappReticulumSend).toHaveBeenCalledWith( + owner, + 'connection', + { type: 'ping' } + ); + expect(window.electronAPI.qappReticulumClose).toHaveBeenCalledWith( + owner, + 'connection' + ); + expect(window.sendMessage).not.toHaveBeenCalled(); + }); + + it('rejects malformed connection operations before native dispatch', async () => { + await expect( + dispatchQAppReticulumRequest( + { action: 'RNS_SEND', payload: 'message' }, + context + ) + ).rejects.toThrow('RNS_UNKNOWN_CONNECTION'); + + expect(window.electronAPI.qappReticulumSend).not.toHaveBeenCalled(); + expect(window.sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/qortal/qapp-reticulum-request.ts b/src/qortal/qapp-reticulum-request.ts new file mode 100644 index 00000000..bb0dbfe6 --- /dev/null +++ b/src/qortal/qapp-reticulum-request.ts @@ -0,0 +1,95 @@ +import { authorizeRnsDestination } from './get.ts'; + +const QAPP_RETICULUM_ACTIONS = new Set([ + 'RNS_CLOSE', + 'RNS_CONNECT', + 'RNS_REQUEST', + 'RNS_SEND', +]); + +type QAppReticulumContext = { + appName: string; + appService?: string; + isFromExtension: boolean; + tabId: string | number; +}; + +type QAppReticulumMessage = { + action?: string; + connectionId?: string; + destination?: string; + payload?: unknown; + [key: string]: unknown; +}; + +export function isQAppReticulumAction(action: unknown): action is string { + return typeof action === 'string' && QAPP_RETICULUM_ACTIONS.has(action); +} + +/** + * Dispatches native Q-App Reticulum operations without putting them on the + * Hub-wide backgroundMessage bus. That bus is shared by unrelated Hub + * features and is the wrong isolation boundary for a per-Q-App transport. + */ +export async function dispatchQAppReticulumRequest( + message: QAppReticulumMessage, + context: QAppReticulumContext +) { + if (!isQAppReticulumAction(message.action)) { + throw new Error('RNS_PROTOCOL_ERROR'); + } + + const api = window.electronAPI; + if (!api) throw new Error('RNS_NATIVE_TRANSPORT_UNAVAILABLE'); + + const owner = { + tabId: String(context.tabId), + name: String(context.appName), + service: String(context.appService ?? ''), + }; + const appInfo = { tabId: context.tabId, name: context.appName }; + + if (message.action === 'RNS_REQUEST') { + if (!api.qappReticulumRequest) + throw new Error('RNS_NATIVE_TRANSPORT_UNAVAILABLE'); + if (typeof message.destination !== 'string') + throw new Error('RNS_DESTINATION_UNREACHABLE'); + const destination = await authorizeRnsDestination( + message.destination, + context.isFromExtension, + appInfo + ); + return api.qappReticulumRequest(owner, { ...message, destination }); + } + + if (message.action === 'RNS_CONNECT') { + if (!api.qappReticulumConnect) + throw new Error('RNS_NATIVE_TRANSPORT_UNAVAILABLE'); + if (typeof message.destination !== 'string') + throw new Error('RNS_DESTINATION_UNREACHABLE'); + const destination = await authorizeRnsDestination( + message.destination, + context.isFromExtension, + appInfo + ); + return api.qappReticulumConnect(owner, destination); + } + + if (message.action === 'RNS_SEND') { + if (!api.qappReticulumSend) + throw new Error('RNS_NATIVE_TRANSPORT_UNAVAILABLE'); + if (typeof message.connectionId !== 'string' || !message.connectionId) + throw new Error('RNS_UNKNOWN_CONNECTION'); + return api.qappReticulumSend( + owner, + message.connectionId, + message.payload + ); + } + + if (!api.qappReticulumClose) + throw new Error('RNS_NATIVE_TRANSPORT_UNAVAILABLE'); + if (typeof message.connectionId !== 'string' || !message.connectionId) + throw new Error('RNS_UNKNOWN_CONNECTION'); + return api.qappReticulumClose(owner, message.connectionId); +} diff --git a/src/qortal/qortal-requests.ts b/src/qortal/qortal-requests.ts index e443a345..c0fadcd2 100644 --- a/src/qortal/qortal-requests.ts +++ b/src/qortal/qortal-requests.ts @@ -84,7 +84,6 @@ import { markNotificationSeenInApp, notificationHasPermission, removeNotificationSubscriptions, - authorizeRnsDestination, clearRnsDestinationPermissionsByTabId, } from './get.ts'; import { triggerMemberGroupsFetch } from '../subscriptions/useInitializeMySubscriptions.ts'; @@ -572,71 +571,6 @@ function setupMessageListenerQortalRequest() { // Handle actions based on the `request.action` value switch (request.action) { - case 'RNS_REQUEST': - case 'RNS_CONNECT': - case 'RNS_SEND': - case 'RNS_CLOSE': { - try { - const owner = { - tabId: String(appInfo?.tabId ?? ''), - name: String(appInfo?.name ?? ''), - service: String(appInfo?.service ?? ''), - }; - let res; - if (request.action === 'RNS_REQUEST') { - const destination = await authorizeRnsDestination( - request.payload?.destination, - isFromExtension, - appInfo - ); - res = await window.electronAPI.qappReticulumRequest(owner, { - ...request.payload, - destination, - }); - } else if (request.action === 'RNS_CONNECT') { - const destination = await authorizeRnsDestination( - request.payload?.destination, - isFromExtension, - appInfo - ); - res = await window.electronAPI.qappReticulumConnect( - owner, - destination - ); - } else if (request.action === 'RNS_SEND') { - res = await window.electronAPI.qappReticulumSend( - owner, - request.payload?.connectionId, - request.payload?.payload - ); - } else { - res = await window.electronAPI.qappReticulumClose( - owner, - request.payload?.connectionId - ); - } - event.source.postMessage( - { - requestId: request.requestId, - action: request.action, - payload: res, - type: 'backgroundMessageResponse', - }, - event.origin - ); - } catch (error) { - event.source.postMessage( - { - requestId: request.requestId, - action: request.action, - error: error?.code || error?.message || 'RNS_PROTOCOL_ERROR', - type: 'backgroundMessageResponse', - }, - event.origin - ); - } - break; - } case 'GET_USER_ACCOUNT': { try { const res = await getUserAccount({ From fa8b6357439c0605f29d80cfa77c4d2b26e16b32 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Mon, 31 Aug 2026 22:48:17 +0300 Subject: [PATCH 06/32] fixes --- electron/resources/presence_bridge.py | 74 ++++++++++++++++++++++ electron/resources/presence_bridge_test.py | 66 +++++++++++++++++++ src/components/Apps/AppViewer.tsx | 2 +- src/i18n/locales/ar/auth.json | 6 +- src/i18n/locales/de/auth.json | 6 +- src/i18n/locales/en/auth.json | 6 +- src/i18n/locales/es/auth.json | 6 +- src/i18n/locales/et/auth.json | 6 +- src/i18n/locales/fi/auth.json | 6 +- src/i18n/locales/fr/auth.json | 6 +- src/i18n/locales/it/auth.json | 6 +- src/i18n/locales/ja/auth.json | 6 +- src/i18n/locales/pt/auth.json | 6 +- src/i18n/locales/ru/auth.json | 6 +- src/i18n/locales/zh/auth.json | 6 +- src/qortal/get.ts | 31 +++++++-- src/qortal/qapp-identity.test.ts | 32 ++++++++++ src/qortal/qapp-identity.ts | 34 ++++++++++ 18 files changed, 294 insertions(+), 17 deletions(-) create mode 100644 src/qortal/qapp-identity.test.ts create mode 100644 src/qortal/qapp-identity.ts diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index 3228bcff..b7ca7c2a 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -26788,6 +26788,10 @@ def handle_send_group_audio_link_heartbeat(req_id: str, payload: Dict[str, Any]) _QAPP_RNS_MAX_QUEUE_BYTES = int(os.environ.get("QORTAL_QAPP_RNS_MAX_QUEUE_BYTES", 2 * 1024 * 1024)) _QAPP_RNS_IDLE_SECONDS = float(os.environ.get("QORTAL_QAPP_RNS_IDLE_SECONDS", 300)) _QAPP_RNS_ACK_TIMEOUT_SECONDS = float(os.environ.get("QORTAL_QAPP_RNS_ACK_TIMEOUT_SECONDS", 120)) +_QAPP_RNS_KEEPALIVE_SECONDS = max( + 15.0, + float(os.environ.get("QORTAL_QAPP_RNS_KEEPALIVE_SECONDS", 60)), +) _qapp_rns_entries: Dict[str, Dict[str, Any]] = {} @@ -26833,6 +26837,69 @@ def _qapp_rns_emit_state(entry: Dict[str, Any], state: str, reason: str = "") -> }) +def _qapp_rns_cancel_keepalive(entry: Dict[str, Any]) -> None: + with _state_lock: + entry["keepalive_generation"] = int(entry.get("keepalive_generation") or 0) + 1 + timer = entry.get("keepalive_timer") + entry["keepalive_timer"] = None + if timer is not None: + timer.cancel() + + +def _qapp_rns_schedule_keepalive(entry: Dict[str, Any]) -> None: + """Keep an actively-owned realtime Link alive without involving the Q-App.""" + with _state_lock: + previous_timer = entry.get("keepalive_timer") + if previous_timer is not None: + previous_timer.cancel() + generation = int(entry.get("keepalive_generation") or 0) + 1 + entry["keepalive_generation"] = generation + entry["keepalive_timer"] = None + if entry.get("established") is not True or not entry.get("connections"): + return + + def keepalive() -> None: + with _state_lock: + current = _qapp_rns_entries.get(entry["managerKey"]) + if ( + current is not entry + or entry.get("keepalive_generation") != generation + or entry.get("established") is not True + or not entry.get("connections") + ): + return + entry["keepalive_timer"] = None + message_id = int(entry["next_message_id"]) + entry["next_message_id"] = (message_id + 1) & 0xffffffffffffffff + payload = json.dumps({"type": "PING"}, separators=(",", ":")).encode("utf-8") + if not _qapp_rns_write( + entry, + _qapp_rns_frame(_QAPP_RNS_CONTROL, message_id, payload), + ): + link = entry.get("link") + if link is not None: + try: + link.teardown() + except Exception: + pass + return + _qapp_rns_schedule_keepalive(entry) + + timer = threading.Timer(_QAPP_RNS_KEEPALIVE_SECONDS, keepalive) + timer.daemon = True + entry["keepalive_timer"] = timer + try: + timer.start() + except Exception as exc: + with _state_lock: + if entry.get("keepalive_timer") is timer: + entry["keepalive_timer"] = None + log( + "[presence_bridge] target=qapp-rns keepalive_timer_start_failed " + f"destination={entry.get('destination')} error={type(exc).__name__}" + ) + + def _qapp_rns_handle_frame(entry: Dict[str, Any], frame_type: int, message_id: int, payload: bytes) -> None: if frame_type == _QAPP_RNS_ACK: if payload: @@ -26943,6 +27010,7 @@ def _qapp_rns_established(manager_key: str, generation: int, link) -> None: entry["established_event"].set() unacked = [value["frame"] for _, value in sorted(entry["unacked"].items())] threading.Thread(target=_qapp_rns_reader, args=(entry, generation), daemon=True, name="qapp-rns-reader").start() + _qapp_rns_schedule_keepalive(entry) for frame in unacked: if not _qapp_rns_write(entry, frame): break @@ -27024,6 +27092,7 @@ def _qapp_rns_closed(manager_key: str, generation: int, link) -> None: entry["writer"] = None entry["reader"] = None has_connections = bool(entry.get("connections")) + _qapp_rns_cancel_keepalive(entry) _qapp_rns_emit_state(entry, "RECONNECTING" if has_connections else "DISCONNECTED") if has_connections: _qapp_rns_schedule_reconnect(entry) @@ -27109,6 +27178,8 @@ def _qapp_rns_entry(manager_key: str, destination: str) -> Dict[str, Any]: "reconnect_attempt": 0, "idle_generation": 0, "idle_timer": None, + "keepalive_generation": 0, + "keepalive_timer": None, } _qapp_rns_entries[manager_key] = entry return entry @@ -27129,6 +27200,7 @@ def handle_qapp_rns_connect(req_id: str, payload: Dict[str, Any]) -> None: entry["connections"].discard(connection_id) _qapp_rns_schedule_idle(entry) return _qapp_rns_error(req_id, "RNS_LINK_TIMEOUT") + _qapp_rns_schedule_keepalive(entry) emit_resp(req_id, True, payload={"state": "CONNECTED"}) except Exception: _qapp_rns_error(req_id, "RNS_DESTINATION_UNREACHABLE") @@ -27186,6 +27258,8 @@ def handle_qapp_rns_close(req_id: str, payload: Dict[str, Any]) -> None: if entry is not None: entry["connections"].discard(connection_id) entry["last_used"] = time.time() + if not entry["connections"]: + _qapp_rns_cancel_keepalive(entry) _qapp_rns_schedule_idle(entry) emit_resp(req_id, True, payload={"state": "CLOSED"}) diff --git a/electron/resources/presence_bridge_test.py b/electron/resources/presence_bridge_test.py index cf05f2d2..26397e5b 100644 --- a/electron/resources/presence_bridge_test.py +++ b/electron/resources/presence_bridge_test.py @@ -111,6 +111,72 @@ def flush(self): self.assertTrue(writer.flushed) self.assertEqual(self.bridge._QAPP_RNS_STREAM_ID, 7) + def test_active_realtime_connection_sends_internal_keepalive(self): + timers = [] + writes = [] + + class Timer: + def __init__(self, delay, callback): + self.delay = delay + self.callback = callback + self.cancelled = False + self.daemon = False + timers.append(self) + + def start(self): + return None + + def cancel(self): + self.cancelled = True + + entry = { + "managerKey": "keepalive-test", + "destination": "00" * 16, + "connections": {"rns-test"}, + "established": True, + "generation": 1, + "next_message_id": 7, + "keepalive_generation": 0, + "keepalive_timer": None, + } + self.bridge._qapp_rns_entries[entry["managerKey"]] = entry + try: + with mock.patch.object(self.bridge.threading, "Timer", Timer), mock.patch.object( + self.bridge, + "_qapp_rns_write", + side_effect=lambda _entry, frame: writes.append(frame) or True, + ): + self.bridge._qapp_rns_schedule_keepalive(entry) + self.assertEqual(timers[0].delay, self.bridge._QAPP_RNS_KEEPALIVE_SECONDS) + timers[0].callback() + + self.assertEqual(len(writes), 1) + _version, frame_type, message_id, length = self.bridge._QAPP_RNS_HEADER.unpack_from(writes[0]) + self.assertEqual(frame_type, self.bridge._QAPP_RNS_CONTROL) + self.assertEqual(message_id, 7) + self.assertEqual(writes[0][self.bridge._QAPP_RNS_HEADER.size:], b'{"type":"PING"}') + self.assertEqual(length, len(b'{"type":"PING"}')) + self.assertEqual(entry["next_message_id"], 8) + self.assertEqual(len(timers), 2) + finally: + timer = entry.get("keepalive_timer") + if timer is not None: + timer.cancel() + self.bridge._qapp_rns_entries.pop(entry["managerKey"], None) + + def test_keepalive_stops_without_logical_connections(self): + timer = mock.Mock() + entry = { + "keepalive_generation": 2, + "keepalive_timer": timer, + } + + self.bridge._qapp_rns_cancel_keepalive(entry) + + timer.cancel.assert_called_once_with() + self.assertIsNone(entry["keepalive_timer"]) + self.assertEqual(entry["keepalive_generation"], 3) + def test_rpc_response_callback_extracts_request_receipt_response(self): emitted = [] diff --git a/src/components/Apps/AppViewer.tsx b/src/components/Apps/AppViewer.tsx index d6920f1c..a531ecdd 100644 --- a/src/components/Apps/AppViewer.tsx +++ b/src/components/Apps/AppViewer.tsx @@ -25,7 +25,7 @@ export const AppViewer = forwardRef( app?.tabId, isDevMode, isDevMode ? 'devapp' : app?.name, - app?.service, + isDevMode ? 'APP' : app?.service, app?.identifier ); diff --git a/src/i18n/locales/ar/auth.json b/src/i18n/locales/ar/auth.json index b7d0e4b5..12881129 100644 --- a/src/i18n/locales/ar/auth.json +++ b/src/i18n/locales/ar/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} غير موجود في JSON", "find_secret_key": "تعذر العثور على المفتاح السري الصحيح", "incorrect_password": "كلمة مرور غير صحيحة", + "invalid_qapp_identity_challenge": "تحدي هوية Q-App غير صالح", + "invalid_qapp_identity_context": "سياق هوية Q-App غير صالح", "invalid_qortal_link": "رابط كورتال غير صالح", "invalid_secret_key": "المفتاح السري غير صالح", "invalid_uint8": "بيانات Uint8Array المقدمة غير صالحة", "name_not_existing": "الاسم غير موجود", "name_not_registered": "الاسم غير مسجل", + "qapp_account_authentication_required": "يجب على Q-App مصادقة حساب Qortal الخاص بك قبل طلب إثبات الهوية", "read_blob_base64": "فشل قراءة Blob كسلسلة مشفرة base64", "reencrypt_secret_key": "تعذر إعادة تشفير المفتاح السري", - "set_apikey": "فشل تعيين مفتاح API:" + "set_apikey": "فشل تعيين مفتاح API:", + "wallet_account_mismatch": "المحفظة المصادَق عليها لا تطابق حساب Qortal النشط" }, "generic": { "block_list_intro": "أضف اسمًا أو عنوان Qortal للحظر. العناوين تؤثر على المعاملات؛ حظر الاسم يخفي بياناته في كل الـ Hub.", diff --git a/src/i18n/locales/de/auth.json b/src/i18n/locales/de/auth.json index 41271c23..f2ba27e3 100644 --- a/src/i18n/locales/de/auth.json +++ b/src/i18n/locales/de/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} nicht im JSON gefunden", "find_secret_key": "Korrekter SecretKey konnte nicht gefunden werden", "incorrect_password": "falsches Passwort", + "invalid_qapp_identity_challenge": "ungültige Q-App-Identitätsanforderung", + "invalid_qapp_identity_context": "ungültiger Q-App-Identitätskontext", "invalid_qortal_link": "ungültiger Qortal-Link", "invalid_secret_key": "SecretKey ist ungültig", "invalid_uint8": "Das eingereichte Uint8ArrayData ist ungültig", "name_not_existing": "Name existiert nicht", "name_not_registered": "Name nicht registriert", + "qapp_account_authentication_required": "die Q-App muss dein Qortal-Konto authentifizieren, bevor sie einen Identitätsnachweis anfordert", "read_blob_base64": "Blob konnte nicht als base64-codierter String gelesen werden", "reencrypt_secret_key": "Secret Key konnte nicht neu verschlüsselt werden", - "set_apikey": "API-Schlüssel konnte nicht gesetzt werden:" + "set_apikey": "API-Schlüssel konnte nicht gesetzt werden:", + "wallet_account_mismatch": "die authentifizierte Wallet stimmt nicht mit dem aktiven Qortal-Konto überein" }, "generic": { "block_list_intro": "Füge einen Qortal-Namen oder eine Adresse hinzu, um zu blockieren. Adressen wirken auf Transaktionen; das Blockieren eines Namens blendet Daten im gesamten Hub aus.", diff --git a/src/i18n/locales/en/auth.json b/src/i18n/locales/en/auth.json index 4fadeb20..c7a92328 100644 --- a/src/i18n/locales/en/auth.json +++ b/src/i18n/locales/en/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} not found in JSON", "find_secret_key": "cannot find correct secretKey", "incorrect_password": "incorrect password", + "invalid_qapp_identity_challenge": "invalid Q-App identity challenge", + "invalid_qapp_identity_context": "invalid Q-App identity context", "invalid_qortal_link": "invalid qortal link", "invalid_secret_key": "secretKey is not valid", "invalid_uint8": "the Uint8ArrayData you've submitted is invalid", "name_not_existing": "name does not exist", "name_not_registered": "name not registered", + "qapp_account_authentication_required": "the Q-App must authenticate your Qortal account before requesting an identity proof", "read_blob_base64": "failed to read the Blob as a base64-encoded string", "reencrypt_secret_key": "unable to re-encrypt secret key", - "set_apikey": "failed to set API key:" + "set_apikey": "failed to set API key:", + "wallet_account_mismatch": "the authenticated wallet does not match the active Qortal account" }, "generic": { "block_list_intro": "Add a Qortal name or address to block them. Addresses affect transaction handling; blocking a name hides their data across the Hub.", diff --git a/src/i18n/locales/es/auth.json b/src/i18n/locales/es/auth.json index bf1b3779..c60c36bb 100644 --- a/src/i18n/locales/es/auth.json +++ b/src/i18n/locales/es/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} no encontrado en el JSON", "find_secret_key": "no se puede encontrar la clave secreta correcta", "incorrect_password": "contraseña incorrecta", + "invalid_qapp_identity_challenge": "desafío de identidad de Q-App no válido", + "invalid_qapp_identity_context": "contexto de identidad de Q-App no válido", "invalid_qortal_link": "enlace Qortal inválido", "invalid_secret_key": "clave secreta no válida", "invalid_uint8": "el Uint8ArrayData enviado no es válido", "name_not_existing": "el nombre no existe", "name_not_registered": "nombre no registrado", + "qapp_account_authentication_required": "la Q-App debe autenticar tu cuenta de Qortal antes de solicitar una prueba de identidad", "read_blob_base64": "fallo al leer el Blob como cadena codificada en base64", "reencrypt_secret_key": "no se pudo reencriptar la clave secreta", - "set_apikey": "fallo al establecer la clave API:" + "set_apikey": "fallo al establecer la clave API:", + "wallet_account_mismatch": "la cartera autenticada no coincide con la cuenta de Qortal activa" }, "generic": { "block_list_intro": "Añade un nombre Qortal o una dirección para bloquearlos. Las direcciones afectan las transacciones; bloquear un nombre oculta sus datos en todo el Hub.", diff --git a/src/i18n/locales/et/auth.json b/src/i18n/locales/et/auth.json index 243f09c9..906f56d1 100644 --- a/src/i18n/locales/et/auth.json +++ b/src/i18n/locales/et/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} ei leitud JSON-ist", "find_secret_key": "õiget salavõtit ei leitud", "incorrect_password": "vale parool", + "invalid_qapp_identity_challenge": "vigane Q-Appi identiteediväljakutse", + "invalid_qapp_identity_context": "vigane Q-Appi identiteedikontekst", "invalid_qortal_link": "vigane Qortal link", "invalid_secret_key": "salavõti pole kehtiv", "invalid_uint8": "esitatud Uint8ArrayData pole kehtiv", "name_not_existing": "nimi ei eksisteeri", "name_not_registered": "nimi pole registreeritud", + "qapp_account_authentication_required": "Q-App peab enne identiteeditõendi taotlemist sinu Qortali konto autentima", "read_blob_base64": "Blob'i ei õnnestunud lugeda base64-stringina", "reencrypt_secret_key": "salavõtit ei saa uuesti krüpteerida", - "set_apikey": "API võtme seadmine ebaõnnestus:" + "set_apikey": "API võtme seadmine ebaõnnestus:", + "wallet_account_mismatch": "autenditud rahakott ei vasta aktiivsele Qortali kontole" }, "generic": { "block_list_intro": "Lisa Qortali nimi või aadress blokeerimiseks. Aadressid mõjutavad tehinguid; nime blokeerimine peidab selle andmed kogu Hubis.", diff --git a/src/i18n/locales/fi/auth.json b/src/i18n/locales/fi/auth.json index 24d97a67..b861d857 100644 --- a/src/i18n/locales/fi/auth.json +++ b/src/i18n/locales/fi/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} ei löytynyt JSONista", "find_secret_key": "oikeaa salaista avainta ei löytynyt", "incorrect_password": "väärä salasana", + "invalid_qapp_identity_challenge": "virheellinen Q-App-identiteettihaaste", + "invalid_qapp_identity_context": "virheellinen Q-App-identiteettikonteksti", "invalid_qortal_link": "virheellinen Qortal-linkki", "invalid_secret_key": "salainen avain ei ole kelvollinen", "invalid_uint8": "antamasi Uint8ArrayData on virheellinen", "name_not_existing": "nimeä ei ole olemassa", "name_not_registered": "nimeä ei ole rekisteröity", + "qapp_account_authentication_required": "Q-Appin on tunnistettava Qortal-tilisi ennen identiteettitodisteen pyytämistä", "read_blob_base64": "Blobin lukeminen base64-merkkijonona epäonnistui", "reencrypt_secret_key": "salaisen avaimen uudelleensalaus epäonnistui", - "set_apikey": "API-avaimen asettaminen epäonnistui:" + "set_apikey": "API-avaimen asettaminen epäonnistui:", + "wallet_account_mismatch": "tunnistettu lompakko ei vastaa aktiivista Qortal-tiliä" }, "generic": { "block_list_intro": "Lisää Qortal-nimi tai -osoite estääksesi. Osoitteet vaikuttavat tapahtumiin; nimen esto piilottaa tiedot koko Hubissa.", diff --git a/src/i18n/locales/fr/auth.json b/src/i18n/locales/fr/auth.json index c3958306..fa59fec3 100644 --- a/src/i18n/locales/fr/auth.json +++ b/src/i18n/locales/fr/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} introuvable dans le JSON", "find_secret_key": "clé secrète correcte introuvable", "incorrect_password": "mot de passe incorrect", + "invalid_qapp_identity_challenge": "défi d’identité Q-App non valide", + "invalid_qapp_identity_context": "contexte d’identité Q-App non valide", "invalid_qortal_link": "lien Qortal invalide", "invalid_secret_key": "clé secrète invalide", "invalid_uint8": "les données Uint8Array soumises sont invalides", "name_not_existing": "le nom n'existe pas", "name_not_registered": "nom non enregistré", + "qapp_account_authentication_required": "la Q-App doit authentifier votre compte Qortal avant de demander une preuve d’identité", "read_blob_base64": "échec de la lecture du Blob en base64", "reencrypt_secret_key": "impossible de re-chiffrer la clé secrète", - "set_apikey": "échec de la configuration de la clé API :" + "set_apikey": "échec de la configuration de la clé API :", + "wallet_account_mismatch": "le portefeuille authentifié ne correspond pas au compte Qortal actif" }, "generic": { "block_list_intro": "Ajoutez un nom Qortal ou une adresse pour les bloquer. Les adresses concernent les transactions ; bloquer un nom masque ses données dans tout le Hub.", diff --git a/src/i18n/locales/it/auth.json b/src/i18n/locales/it/auth.json index c43e164b..8105e81a 100644 --- a/src/i18n/locales/it/auth.json +++ b/src/i18n/locales/it/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} non trovato nel JSON", "find_secret_key": "impossibile trovare secretkey corretta", "incorrect_password": "password errata", + "invalid_qapp_identity_challenge": "richiesta di identità Q-App non valida", + "invalid_qapp_identity_context": "contesto di identità Q-App non valido", "invalid_qortal_link": "collegamento Qortal non valido", "invalid_secret_key": "la chiave segreta non è valida", "invalid_uint8": "l'uint8arraydata inviato non è valido", "name_not_existing": "il nome non esiste", "name_not_registered": "nome non registrato", + "qapp_account_authentication_required": "la Q-App deve autenticare il tuo account Qortal prima di richiedere una prova d’identità", "read_blob_base64": "impossibile leggere il BLOB come stringa codificata da base64", "reencrypt_secret_key": "impossibile recriptare la chiave segreta", - "set_apikey": "impossibile impostare la chiave API:" + "set_apikey": "impossibile impostare la chiave API:", + "wallet_account_mismatch": "il portafoglio autenticato non corrisponde all’account Qortal attivo" }, "generic": { "block_list_intro": "Aggiungi un nome Qortal o un indirizzo per bloccarli. Gli indirizzi influenzano le transazioni; bloccare un nome nasconde i suoi dati in tutto il Hub.", diff --git a/src/i18n/locales/ja/auth.json b/src/i18n/locales/ja/auth.json index 58fadcec..2f3bf19c 100644 --- a/src/i18n/locales/ja/auth.json +++ b/src/i18n/locales/ja/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "JSONに{{ field }}が見つかりません", "find_secret_key": "正しい秘密鍵が見つかりません", "incorrect_password": "パスワードが間違っています", + "invalid_qapp_identity_challenge": "Q-Appの本人確認チャレンジが無効です", + "invalid_qapp_identity_context": "Q-Appの本人確認コンテキストが無効です", "invalid_qortal_link": "無効なQortalリンク", "invalid_secret_key": "無効な秘密鍵です", "invalid_uint8": "送信されたUint8ArrayDataは無効です", "name_not_existing": "名前が存在しません", "name_not_registered": "名前が登録されていません", + "qapp_account_authentication_required": "本人確認証明を要求する前に、Q-AppでQortalアカウントを認証する必要があります", "read_blob_base64": "Blobをbase64文字列として読み取れませんでした", "reencrypt_secret_key": "秘密鍵を再暗号化できませんでした", - "set_apikey": "APIキーの設定に失敗しました:" + "set_apikey": "APIキーの設定に失敗しました:", + "wallet_account_mismatch": "認証されたウォレットが現在のQortalアカウントと一致しません" }, "generic": { "block_list_intro": "Qortal の名前またはアドレスを追加してブロックします。アドレスは取引処理に影響し、名前をブロックすると Hub 全体でそのデータが表示されません。", diff --git a/src/i18n/locales/pt/auth.json b/src/i18n/locales/pt/auth.json index d724b0f5..88d3fbba 100644 --- a/src/i18n/locales/pt/auth.json +++ b/src/i18n/locales/pt/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} não encontrado no JSON", "find_secret_key": "não é possível encontrar a chave secreta correta", "incorrect_password": "senha incorreta", + "invalid_qapp_identity_challenge": "desafio de identidade da Q-App inválido", + "invalid_qapp_identity_context": "contexto de identidade da Q-App inválido", "invalid_qortal_link": "link do qortal inválido", "invalid_secret_key": "chave secreta inválida", "invalid_uint8": "o Uint8ArrayData enviado é inválido", "name_not_existing": "nome não existe", "name_not_registered": "nome não registrado", + "qapp_account_authentication_required": "a Q-App deve autenticar a sua conta Qortal antes de solicitar uma prova de identidade", "read_blob_base64": "falha ao ler o Blob como string codificada em base64", "reencrypt_secret_key": "não foi possível recriptografar a chave secreta", - "set_apikey": "falha ao definir a APIkey:" + "set_apikey": "falha ao definir a APIkey:", + "wallet_account_mismatch": "a carteira autenticada não corresponde à conta Qortal ativa" }, "generic": { "block_list_intro": "Adicione um nome Qortal ou um endereço para bloqueá-los. Os endereços afetam transações; bloquear um nome oculta seus dados em todo o Hub.", diff --git a/src/i18n/locales/ru/auth.json b/src/i18n/locales/ru/auth.json index 7fa68363..33dae5b7 100644 --- a/src/i18n/locales/ru/auth.json +++ b/src/i18n/locales/ru/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "{{ field }} не найден в JSON", "find_secret_key": "не удалось найти правильный секретный ключ", "incorrect_password": "неверный пароль", + "invalid_qapp_identity_challenge": "недопустимый запрос подтверждения личности Q-App", + "invalid_qapp_identity_context": "недопустимый контекст идентификации Q-App", "invalid_qortal_link": "недействительная ссылка Qortal", "invalid_secret_key": "недопустимый секретный ключ", "invalid_uint8": "предоставленные данные Uint8Array недействительны", "name_not_existing": "имя не существует", "name_not_registered": "имя не зарегистрировано", + "qapp_account_authentication_required": "Q-App должно аутентифицировать вашу учётную запись Qortal перед запросом подтверждения личности", "read_blob_base64": "не удалось прочитать Blob как строку base64", "reencrypt_secret_key": "не удалось повторно зашифровать секретный ключ", - "set_apikey": "не удалось установить API-ключ:" + "set_apikey": "не удалось установить API-ключ:", + "wallet_account_mismatch": "аутентифицированный кошелёк не соответствует активной учётной записи Qortal" }, "generic": { "block_list_intro": "Добавьте имя Qortal или адрес для блокировки. Адреса влияют на транзакции; блокировка имени скрывает данные в Hub.", diff --git a/src/i18n/locales/zh/auth.json b/src/i18n/locales/zh/auth.json index b9e1a419..edc17c3f 100644 --- a/src/i18n/locales/zh/auth.json +++ b/src/i18n/locales/zh/auth.json @@ -199,14 +199,18 @@ "field_not_found_json": "在 JSON 中未找到 {{ field }}", "find_secret_key": "找不到正确的密钥", "incorrect_password": "密码错误", + "invalid_qapp_identity_challenge": "无效的Q-App身份验证质询", + "invalid_qapp_identity_context": "无效的Q-App身份上下文", "invalid_qortal_link": "无效的 Qortal 链接", "invalid_secret_key": "密钥无效", "invalid_uint8": "提供的 Uint8ArrayData 无效", "name_not_existing": "名称不存在", "name_not_registered": "名称未注册", + "qapp_account_authentication_required": "Q-App必须先验证您的Qortal账户,才能请求身份证明", "read_blob_base64": "无法将 Blob 读取为 base64 编码字符串", "reencrypt_secret_key": "无法重新加密密钥", - "set_apikey": "设置 API 密钥失败:" + "set_apikey": "设置 API 密钥失败:", + "wallet_account_mismatch": "已验证的钱包与当前Qortal账户不匹配" }, "generic": { "block_list_intro": "添加 Qortal 名称或地址以屏蔽。地址会影响交易处理;屏蔽名称会在整个 Hub 中隐藏其数据。", diff --git a/src/qortal/get.ts b/src/qortal/get.ts index 594eb19b..fd2a22be 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -112,6 +112,10 @@ import { getElectronPersistentStorage } from '../utils/electronPersistentStorage import { fileToBase64 } from '../utils/fileReading/index.ts'; import { mimeToExtensionMap } from '../utils/memeTypes.ts'; import { RequestQueueWithPromise } from '../utils/queue/queue.ts'; +import { + normalizeQappIdentityContext, + qappReticulumSessionPermissionKey, +} from './qapp-identity.ts'; import utils from '../utils/utils.ts'; import ShortUniqueId from 'short-unique-id'; import { @@ -533,7 +537,11 @@ export const authorizeRnsDestination = async ( if (appInfo?.tabId == null || !appInfo?.name) { throw new Error('RNS_PERMISSION_DENIED'); } - const key = `${appInfo.tabId}\u0000${appInfo.name}\u0000${normalized}`; + const key = qappReticulumSessionPermissionKey( + appInfo.tabId, + appInfo.name, + normalized + ); if (rnsDestinationPermissions.has(key)) return normalized; const response = await getUserPermission( { @@ -665,7 +673,13 @@ export const signQappIdentityProof = async ( expiresAt < Date.now() - 5_000 || expiresAt > Date.now() + 180_000 ) { - throw new Error('Invalid Q-App identity challenge'); + throw new Error( + i18n.t('auth:message.error.invalid_qapp_identity_challenge') + ); + } + const qapp = normalizeQappIdentityContext(appInfo); + if (appInfo?.tabId == null) { + throw new Error(i18n.t('auth:message.error.invalid_qapp_identity_context')); } const hasPersistentAccountPermission = appInfo?.name ? Boolean(await getPermission(`qAPPAutoAuth-${appInfo.name}`)) @@ -677,9 +691,16 @@ export const signQappIdentityProof = async ( ); if (!hasPersistentAccountPermission && !hasAccountSessionPermission) { throw new Error( - 'Q-App must authenticate with GET_USER_ACCOUNT before requesting an identity proof' + i18n.t('auth:message.error.qapp_account_authentication_required') ); } + const reticulumPermissionKey = qappReticulumSessionPermissionKey( + appInfo.tabId, + appInfo.name, + backendDestination + ); + if (!rnsDestinationPermissions.has(reticulumPermissionKey)) + throw new Error('RNS_PERMISSION_DENIED'); const wallet = await getSaveWallet(); const account = { address: wallet?.address0, @@ -691,7 +712,7 @@ export const signQappIdentityProof = async ( !account?.publicKey || keyPair?.publicKey !== account.publicKey ) { - throw new Error('Authenticated wallet does not match Qortal account'); + throw new Error(i18n.t('auth:message.error.wallet_account_mismatch')); } const fields = { address: account.address, @@ -701,6 +722,8 @@ export const signQappIdentityProof = async ( nonce, protocol, publicKey: account.publicKey, + qappName: qapp.name, + qappService: qapp.service, }; const sorted = {}; for (const key of Object.keys(fields).sort()) sorted[key] = fields[key]; diff --git a/src/qortal/qapp-identity.test.ts b/src/qortal/qapp-identity.test.ts new file mode 100644 index 00000000..231a7620 --- /dev/null +++ b/src/qortal/qapp-identity.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeQappIdentityContext, + qappReticulumSessionPermissionKey, +} from './qapp-identity'; + +describe('Q-App identity context', () => { + it('canonicalizes only host-provided QDN identity fields', () => { + expect( + normalizeQappIdentityContext({ + name: ' Official-App ', + service: 'app', + }) + ).toEqual({ + name: 'official-app', + service: 'APP', + }); + }); + + it('rejects missing or malformed identity fields', () => { + expect(() => normalizeQappIdentityContext({ name: 'example' })).toThrow(); + expect(() => + normalizeQappIdentityContext({ name: 'example', service: 'APP/' }) + ).toThrow(); + }); + + it('scopes Reticulum permission to the tab, app, and backend', () => { + expect(qappReticulumSessionPermissionKey(7, 'Example', 'ab'.repeat(16))).toBe( + `7\u0000Example\u0000${'ab'.repeat(16)}` + ); + }); +}); diff --git a/src/qortal/qapp-identity.ts b/src/qortal/qapp-identity.ts new file mode 100644 index 00000000..94eeabe6 --- /dev/null +++ b/src/qortal/qapp-identity.ts @@ -0,0 +1,34 @@ +import i18n from 'i18next'; + +export type QAppIdentityContext = { + name: string; + service: string; +}; + +export const normalizeQappIdentityContext = ( + appInfo: unknown +): QAppIdentityContext => { + const candidate = appInfo as Record | null | undefined; + const name = + typeof candidate?.name === 'string' + ? candidate.name.trim().toLowerCase() + : ''; + const service = + typeof candidate?.service === 'string' + ? candidate.service.trim().toUpperCase() + : ''; + if ( + !name || + name.length > 128 || + !/^[A-Z0-9_]{1,32}$/.test(service) + ) { + throw new Error(i18n.t('auth:message.error.invalid_qapp_identity_context')); + } + return { name, service }; +}; + +export const qappReticulumSessionPermissionKey = ( + tabId: string | number, + appName: string, + destination: string +) => `${tabId}\u0000${appName}\u0000${destination}`; From acac0beaac00063b1d826a8673eafab747a715c0 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Tue, 1 Sep 2026 20:38:22 +0300 Subject: [PATCH 07/32] fixes --- docs/qapp-reticulum.md | 14 ++- electron/resources/presence_bridge.py | 27 +++++- electron/resources/presence_bridge_test.py | 96 +++++++++++++++++++ electron/src/qapp-reticulum-framing.test.ts | 7 ++ electron/src/qapp-reticulum-framing.ts | 38 ++++++-- src/components/Apps/AppViewer.tsx | 34 +++++-- src/components/Apps/AppsDesktop.tsx | 20 ++++ src/components/Apps/AppsPrivate.tsx | 42 ++++---- .../Apps/__tests__/appPreviewUrl.test.ts | 43 +++++++++ .../Apps/__tests__/privateGroups.test.ts | 38 ++++++++ src/components/Apps/appPreviewUrl.ts | 24 +++++ src/components/Apps/privateGroups.ts | 27 ++++++ src/hooks/useHandlePrivateApps.tsx | 85 ++++++++-------- src/qortal/qortal-requests.ts | 6 +- 14 files changed, 413 insertions(+), 88 deletions(-) create mode 100644 src/components/Apps/__tests__/appPreviewUrl.test.ts create mode 100644 src/components/Apps/__tests__/privateGroups.test.ts create mode 100644 src/components/Apps/appPreviewUrl.ts create mode 100644 src/components/Apps/privateGroups.ts diff --git a/docs/qapp-reticulum.md b/docs/qapp-reticulum.md index bc885253..566115bd 100644 --- a/docs/qapp-reticulum.md +++ b/docs/qapp-reticulum.md @@ -76,10 +76,13 @@ arbitrary bytes. Standard padded Base64 is used. JSON key order and whitespace are not normative. Multiple stable logical `connectionId` values can share the Link. The complete envelope, not only the decoded Q-App body, must fit 256 KiB. -CONTROL `3` supports exactly `{"type":"PING"}` and `{"type":"PONG"}`. -PING receives PONG with the same header message ID. CONTROL is not ACKed. Other -control shapes are protocol errors. Application authentication/session resume -is above transport; v1 has no WELCOME or RESUME transport control. +CONTROL `3` supports `{"type":"PING"}`, `{"type":"PONG"}`, and +`{"type":"CLOSE","connectionId":"rns-"}`. PING receives PONG with the +same header message ID. CLOSE removes only the named logical connection on the +receiving physical Link and is idempotent; a delayed DATA frame for that ID is +acknowledged but not delivered. CONTROL is not ACKed. Other control shapes are +protocol errors. Application authentication/session resume is above transport; +v1 has no WELCOME or RESUME transport control. RPC `link.request()` data is a native dictionary with `version: 1`, application `requestId`, `encoding`, and `payloadBase64`. The backend returns a native JSON @@ -90,7 +93,8 @@ automatically after ambiguous failure. Reconnect begins at 0.5 seconds, doubles with 0.8–1.2 jitter, and caps at 30 seconds. Partial Buffer bytes are discarded. Complete unacknowledged DATA frames are resent in message-ID order from byte zero. Closing the last logical -connection prevents a pending reconnect timer from reopening the Link. +connection sends CLOSE before removing local ownership and prevents a pending +reconnect timer from reopening the Link. Transport IDs prevent duplicate delivery across reconnects. They do not prevent duplicate business operations: Q-Apps must include their own operation IDs for diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index b7ca7c2a..f43abcdb 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -27253,14 +27253,39 @@ def expire_unacked() -> None: def handle_qapp_rns_close(req_id: str, payload: Dict[str, Any]) -> None: manager_key = str(payload.get("managerKey") or "") connection_id = str(payload.get("connectionId") or "") + entry = None + close_frame = None with _state_lock: entry = _qapp_rns_entries.get(manager_key) - if entry is not None: + if entry is not None and connection_id in entry.get("connections", set()): + message_id = int(entry["next_message_id"]) + entry["next_message_id"] = (message_id + 1) & 0xffffffffffffffff + control = json.dumps({ + "type": "CLOSE", + "connectionId": connection_id, + }, separators=(",", ":")).encode("utf-8") + close_frame = _qapp_rns_frame(_QAPP_RNS_CONTROL, message_id, control) + try: + close_written = close_frame is None or _qapp_rns_write(entry, close_frame) + except Exception: + close_written = False + with _state_lock: + current = _qapp_rns_entries.get(manager_key) + if current is entry and entry is not None: entry["connections"].discard(connection_id) entry["last_used"] = time.time() if not entry["connections"]: _qapp_rns_cancel_keepalive(entry) _qapp_rns_schedule_idle(entry) + if not close_written and entry is not None: + # If the stream cannot carry the logical CLOSE, closing the failed + # physical Link is the only way to guarantee backend cleanup. + link = entry.get("link") + if link is not None: + try: + link.teardown() + except Exception: + pass emit_resp(req_id, True, payload={"state": "CLOSED"}) diff --git a/electron/resources/presence_bridge_test.py b/electron/resources/presence_bridge_test.py index 26397e5b..76e51522 100644 --- a/electron/resources/presence_bridge_test.py +++ b/electron/resources/presence_bridge_test.py @@ -177,6 +177,102 @@ def test_keepalive_stops_without_logical_connections(self): self.assertIsNone(entry["keepalive_timer"]) self.assertEqual(entry["keepalive_generation"], 3) + def test_close_sends_logical_control_without_closing_other_connections(self): + writes = [] + + class Writer: + def write(self, data): + writes.append(bytes(data)) + return len(data) + + def flush(self): + return None + + entry = { + "managerKey": "logical-close-test", + "destination": "00" * 16, + "connections": {"rns-one", "rns-two"}, + "established": True, + "writer": Writer(), + "write_lock": threading.Lock(), + "last_used": 0, + "next_message_id": 41, + "keepalive_generation": 0, + "keepalive_timer": None, + "idle_generation": 0, + "idle_timer": None, + "link": mock.Mock(), + } + self.bridge._qapp_rns_entries[entry["managerKey"]] = entry + try: + with mock.patch.object(self.bridge, "emit_resp") as emit_resp, mock.patch.object( + self.bridge, "_qapp_rns_schedule_idle" + ): + self.bridge.handle_qapp_rns_close("request-1", { + "managerKey": entry["managerKey"], + "connectionId": "rns-one", + }) + + self.assertEqual(entry["connections"], {"rns-two"}) + self.assertEqual(entry["next_message_id"], 42) + self.assertEqual(len(writes), 1) + _version, frame_type, message_id, _length = ( + self.bridge._QAPP_RNS_HEADER.unpack_from(writes[0]) + ) + self.assertEqual(frame_type, self.bridge._QAPP_RNS_CONTROL) + self.assertEqual(message_id, 41) + self.assertEqual( + json.loads(writes[0][self.bridge._QAPP_RNS_HEADER.size:]), + {"type": "CLOSE", "connectionId": "rns-one"}, + ) + entry["link"].teardown.assert_not_called() + emit_resp.assert_called_once_with( + "request-1", True, payload={"state": "CLOSED"} + ) + finally: + timer = entry.get("idle_timer") + if timer is not None: + timer.cancel() + self.bridge._qapp_rns_entries.pop(entry["managerKey"], None) + + def test_close_tears_down_failed_link_when_control_cannot_be_written(self): + class FailingWriter: + def write(self, _data): + raise RuntimeError("link failed") + + def flush(self): + return None + + entry = { + "managerKey": "logical-close-failure-test", + "destination": "00" * 16, + "connections": {"rns-one"}, + "established": True, + "writer": FailingWriter(), + "write_lock": threading.Lock(), + "last_used": 0, + "next_message_id": 51, + "keepalive_generation": 0, + "keepalive_timer": None, + "idle_generation": 0, + "idle_timer": None, + "link": mock.Mock(), + } + self.bridge._qapp_rns_entries[entry["managerKey"]] = entry + try: + with mock.patch.object(self.bridge, "emit_resp"), mock.patch.object( + self.bridge, "_qapp_rns_schedule_idle" + ): + self.bridge.handle_qapp_rns_close("request-2", { + "managerKey": entry["managerKey"], + "connectionId": "rns-one", + }) + + self.assertEqual(entry["connections"], set()) + entry["link"].teardown.assert_called_once_with() + finally: + self.bridge._qapp_rns_entries.pop(entry["managerKey"], None) + def test_rpc_response_callback_extracts_request_receipt_response(self): emitted = [] diff --git a/electron/src/qapp-reticulum-framing.test.ts b/electron/src/qapp-reticulum-framing.test.ts index a59b88ab..86157197 100644 --- a/electron/src/qapp-reticulum-framing.test.ts +++ b/electron/src/qapp-reticulum-framing.test.ts @@ -34,6 +34,9 @@ describe('Q-App Reticulum framing', () => { expect(decodeQAppRnsControl(encodeQAppRnsControl('PING'))).toEqual({ type: 'PING', }); + expect( + decodeQAppRnsControl(encodeQAppRnsControl('CLOSE', 'rns-one')) + ).toEqual({ type: 'CLOSE', connectionId: 'rns-one' }); }); it('round-trips the maximum unsigned message ID', () => { @@ -119,5 +122,9 @@ describe('Q-App Reticulum framing', () => { expect(() => decodeQAppRnsControl(Buffer.from('{"type":"WELCOME"}')) ).toThrow(QAppRnsProtocolError); + expect(() => encodeQAppRnsControl('CLOSE')).toThrow(QAppRnsProtocolError); + expect(() => decodeQAppRnsControl(Buffer.from('{"type":"CLOSE"}'))).toThrow( + QAppRnsProtocolError + ); }); }); diff --git a/electron/src/qapp-reticulum-framing.ts b/electron/src/qapp-reticulum-framing.ts index c9649d70..5560c86a 100644 --- a/electron/src/qapp-reticulum-framing.ts +++ b/electron/src/qapp-reticulum-framing.ts @@ -8,11 +8,11 @@ export enum QAppRnsFrameType { Control = 3, } -export type QAppRnsControlType = 'PING' | 'PONG'; +export type QAppRnsControlType = 'PING' | 'PONG' | 'CLOSE'; -export type QAppRnsControl = { - type: QAppRnsControlType; -}; +export type QAppRnsControl = + | { type: 'PING' | 'PONG' } + | { type: 'CLOSE'; connectionId: string }; export type QAppRnsFrame = { version: number; @@ -47,16 +47,40 @@ export function encodeQAppRnsFrame( return frame; } -export function encodeQAppRnsControl(type: QAppRnsControlType): Buffer { +export function encodeQAppRnsControl( + type: QAppRnsControlType, + connectionId?: string +): Buffer { + if (type === 'CLOSE') { + if (!connectionId || connectionId.length > 128) { + throw new QAppRnsProtocolError('RNS_PROTOCOL_ERROR'); + } + return Buffer.from(JSON.stringify({ type, connectionId }), 'utf8'); + } + if (connectionId !== undefined) { + throw new QAppRnsProtocolError('RNS_PROTOCOL_ERROR'); + } return Buffer.from(JSON.stringify({ type }), 'utf8'); } export function decodeQAppRnsControl(payload: Uint8Array): QAppRnsControl { try { const value = JSON.parse(Buffer.from(payload).toString('utf8')); + if (!value || typeof value !== 'object') { + throw new Error('unsupported control'); + } + if (value.type === 'CLOSE') { + if ( + Object.keys(value).length !== 2 || + typeof value.connectionId !== 'string' || + value.connectionId.length < 1 || + value.connectionId.length > 128 + ) { + throw new Error('unsupported control'); + } + return { type: 'CLOSE', connectionId: value.connectionId }; + } if ( - !value || - typeof value !== 'object' || Object.keys(value).length !== 1 || (value.type !== 'PING' && value.type !== 'PONG') ) { diff --git a/src/components/Apps/AppViewer.tsx b/src/components/Apps/AppViewer.tsx index a531ecdd..34257196 100644 --- a/src/components/Apps/AppViewer.tsx +++ b/src/components/Apps/AppViewer.tsx @@ -8,6 +8,7 @@ import { useThemeContext } from '../Theme/ThemeContext'; import { useTranslation } from 'react-i18next'; import { QORTAL_PROTOCOL } from '../../constants/constants'; import { appHeighOffsetPx } from '../Desktop/CustomTitleBar'; +import { buildPreviewUrl } from './appPreviewUrl'; type AppViewerProps = { app: any; @@ -53,9 +54,14 @@ export const AppViewer = forwardRef( useEffect(() => { if (app?.isPreview && app?.url) { resetHistory(); - setUrl(app.url + `&theme=${themeMode}&lang=${currentLang}`); + setUrl( + buildPreviewUrl(app.url, { + language: currentLang, + theme: themeMode, + }) + ); } - }, [app?.url, app?.isPreview]); + }, [app?.url, app?.isPreview, currentLang, resetHistory, themeMode]); const defaultUrl = useMemo(() => { return url; @@ -64,14 +70,26 @@ export const AppViewer = forwardRef( const refreshAppFunc = (e) => { const { tabId } = e.detail; if (tabId === app?.tabId) { + if (app?.isPreview && app?.url) { + resetHistory(); + setUrl( + buildPreviewUrl(app.url, { + cacheBuster: Date.now(), + language: currentLang, + theme: themeMode, + }) + ); + return; + } if (isDevMode) { resetHistory(); - if (!app?.isPreview || app?.isPrivate) { - setUrl( - app?.url + - `?time=${Date.now()}&theme=${themeMode}&lang=${currentLang}` - ); - } + setUrl( + buildPreviewUrl(app?.url, { + cacheBuster: Date.now(), + language: currentLang, + theme: themeMode, + }) + ); return; } const constructUrl = `${getBaseApiReact()}/render/${app?.service}/${app?.name}${path != null ? path : ''}?theme=${themeMode}&lang=${currentLang}&identifier=${app?.identifier != null ? app?.identifier : ''}&time=${new Date().getMilliseconds()}`; diff --git a/src/components/Apps/AppsDesktop.tsx b/src/components/Apps/AppsDesktop.tsx index 98cc915d..4ececde7 100644 --- a/src/components/Apps/AppsDesktop.tsx +++ b/src/components/Apps/AppsDesktop.tsx @@ -925,6 +925,26 @@ export const AppsDesktop = ({ }; }, [tabs]); + const updateAppUrlFunc = useCallback((e) => { + const { tabId, url } = e.detail ?? {}; + if (!tabId || !url) return; + + setTabs((currentTabs) => + currentTabs.map((tab) => (tab?.tabId === tabId ? { ...tab, url } : tab)) + ); + setSelectedTab((currentTab) => + currentTab?.tabId === tabId ? { ...currentTab, url } : currentTab + ); + }, []); + + useEffect(() => { + subscribeToEvent('updateAppUrl', updateAppUrlFunc); + + return () => { + unsubscribeFromEvent('updateAppUrl', updateAppUrlFunc); + }; + }, [updateAppUrlFunc]); + const setSelectedTabFunc = (e) => { const data = e.detail?.data; const isDev = e.detail?.isDevMode; diff --git a/src/components/Apps/AppsPrivate.tsx b/src/components/Apps/AppsPrivate.tsx index e7401026..4630d8c6 100644 --- a/src/components/Apps/AppsPrivate.tsx +++ b/src/components/Apps/AppsPrivate.tsx @@ -47,6 +47,7 @@ import { import { useTranslation } from 'react-i18next'; import { useSortedMyNames } from '../../hooks/useSortedMyNames'; import { Label } from '../../styles/App-styles.ts'; +import { filterPrivateGroups } from './privateGroups'; const maxFileSize = 50 * 1024 * 1024; // 50MB @@ -65,8 +66,9 @@ export const AppsPrivate = ({ myName, myAddress }) => { const [myGroupsWhereIAmAdminFromGlobal] = useAtom(myGroupsWhereIAmAdminAtom); const myGroupsWhereIAmAdmin = useMemo(() => { - return myGroupsWhereIAmAdminFromGlobal?.filter( - (group) => groupsProperties[group?.groupId]?.isOpen === false + return filterPrivateGroups( + myGroupsWhereIAmAdminFromGlobal, + groupsProperties ); }, [myGroupsWhereIAmAdminFromGlobal, groupsProperties]); @@ -80,9 +82,7 @@ export const AppsPrivate = ({ myName, myAddress }) => { const { t } = useTranslation(['auth', 'core', 'group', 'question']); const myGroupsPrivate = useMemo(() => { - return memberGroups?.filter( - (group) => groupsProperties[group?.groupId]?.isOpen === false - ); + return filterPrivateGroups(memberGroups, groupsProperties); }, [memberGroups, groupsProperties]); const [privateAppValues, setPrivateAppValues] = useState({ @@ -463,15 +463,13 @@ export const AppsPrivate = ({ myName, myAddress }) => { })} - {myGroupsPrivate - ?.filter((item) => !item?.isOpen) - .map((group) => { - return ( - - {group?.groupName} - - ); - })} + {myGroupsPrivate?.map((group) => { + return ( + + {group?.groupName} + + ); + })} @@ -680,15 +678,13 @@ export const AppsPrivate = ({ myName, myAddress }) => { })} - {myGroupsWhereIAmAdmin - ?.filter((item) => !item?.isOpen) - .map((group) => { - return ( - - {group?.groupName} - - ); - })} + {myGroupsWhereIAmAdmin?.map((group) => { + return ( + + {group?.groupName} + + ); + })} diff --git a/src/components/Apps/__tests__/appPreviewUrl.test.ts b/src/components/Apps/__tests__/appPreviewUrl.test.ts new file mode 100644 index 00000000..4ce3f0fa --- /dev/null +++ b/src/components/Apps/__tests__/appPreviewUrl.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { buildPreviewUrl } from '../appPreviewUrl'; + +describe('buildPreviewUrl', () => { + it('preserves a private preview secret when adding viewer settings', () => { + const result = buildPreviewUrl( + 'http://localhost:12391/render/hash/example?secret=private-token', + { + language: 'en', + theme: 'dark', + } + ); + + expect(result).toBe( + 'http://localhost:12391/render/hash/example?secret=private-token&theme=dark&lang=en' + ); + }); + + it('adds a cache buster without replacing the preview route', () => { + const result = buildPreviewUrl( + 'http://localhost:12391/render/hash/example?secret=private-token', + { + cacheBuster: 123456, + language: 'de', + theme: 'light', + } + ); + + expect(result).toBe( + 'http://localhost:12391/render/hash/example?secret=private-token&theme=light&lang=de&time=123456' + ); + expect(result).not.toContain('/render/DOCUMENT/'); + }); + + it('starts a query string when the preview URL has no parameters', () => { + expect( + buildPreviewUrl('http://localhost:12391/render/hash/example', { + language: 'en', + theme: 'dark', + }) + ).toBe('http://localhost:12391/render/hash/example?theme=dark&lang=en'); + }); +}); diff --git a/src/components/Apps/__tests__/privateGroups.test.ts b/src/components/Apps/__tests__/privateGroups.test.ts new file mode 100644 index 00000000..5fa97434 --- /dev/null +++ b/src/components/Apps/__tests__/privateGroups.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { filterPrivateGroups } from '../privateGroups'; + +describe('filterPrivateGroups', () => { + it('keeps private admin groups that are absent from the member-group cache', () => { + const groups = [ + { groupId: 1, groupName: 'Cached private', isOpen: false }, + { groupId: 2, groupName: 'Admin-only private', isOpen: false }, + ]; + const groupsProperties = { + 1: { isOpen: false }, + }; + + expect(filterPrivateGroups(groups, groupsProperties)).toEqual(groups); + }); + + it('uses cached privacy when the group does not include it', () => { + const privateGroup = { groupId: '3', groupName: 'Private' }; + const publicGroup = { groupId: 4, groupName: 'Public' }; + + expect( + filterPrivateGroups([privateGroup, publicGroup], { + 3: { isOpen: false }, + 4: { isOpen: true }, + }) + ).toEqual([privateGroup]); + }); + + it('does not let stale cached metadata override group data', () => { + const publicGroup = { groupId: 5, groupName: 'Public', isOpen: true }; + + expect( + filterPrivateGroups([publicGroup], { + 5: { isOpen: false }, + }) + ).toEqual([]); + }); +}); diff --git a/src/components/Apps/appPreviewUrl.ts b/src/components/Apps/appPreviewUrl.ts new file mode 100644 index 00000000..0665caca --- /dev/null +++ b/src/components/Apps/appPreviewUrl.ts @@ -0,0 +1,24 @@ +type PreviewUrlOptions = { + cacheBuster?: number; + language: string; + theme: string; +}; + +export const buildPreviewUrl = ( + url: string, + { cacheBuster, language, theme }: PreviewUrlOptions +): string => { + const query = new URLSearchParams({ + theme, + lang: language, + }); + + if (cacheBuster != null) { + query.set('time', String(cacheBuster)); + } + + const separator = + url.endsWith('?') || url.endsWith('&') ? '' : url.includes('?') ? '&' : '?'; + + return `${url}${separator}${query.toString()}`; +}; diff --git a/src/components/Apps/privateGroups.ts b/src/components/Apps/privateGroups.ts new file mode 100644 index 00000000..d9fc60f7 --- /dev/null +++ b/src/components/Apps/privateGroups.ts @@ -0,0 +1,27 @@ +type GroupWithPrivacy = { + groupId?: number | string; + isOpen?: boolean; +}; + +type GroupProperties = Record< + string, + { + isOpen?: boolean; + } +>; + +export const filterPrivateGroups = ( + groups: T[] | null | undefined, + groupsProperties: GroupProperties | null | undefined +): T[] => { + return (groups ?? []).filter((group) => { + const cachedIsOpen = + group.groupId == null + ? undefined + : groupsProperties?.[String(group.groupId)]?.isOpen; + const isOpen = + typeof group.isOpen === 'boolean' ? group.isOpen : cachedIsOpen; + + return isOpen === false; + }); +}; diff --git a/src/hooks/useHandlePrivateApps.tsx b/src/hooks/useHandlePrivateApps.tsx index 27d80322..13cc9679 100644 --- a/src/hooks/useHandlePrivateApps.tsx +++ b/src/hooks/useHandlePrivateApps.tsx @@ -139,53 +139,54 @@ export const useHandlePrivateApps = () => { }), }); - const endpoint = await createEndpoint( - `/arbitrary/APP/${privateAppProperties?.name}/zip?preview=true` - ); + const buildPrivatePreview = async () => { + const endpoint = await createEndpoint( + `/arbitrary/APP/${privateAppProperties?.name}/zip?preview=true` + ); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + body: UintToObject?.app, + }); - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - }, - body: UintToObject?.app, - }); + if (!response.ok) { + throw new Error( + t('core:message.error.build_app', { + postProcess: 'capitalizeFirstChar', + }) + ); + } - const previewPath = await response.text(); + return createEndpoint(await response.text()); + }; - const refreshfunc = async (tabId, privateAppProperties) => { - const checkIfPreviewLinkStillWorksUrl = await createEndpoint( - `/render/hash/HmtnZpcRPwisMfprUXuBp27N2xtv5cDiQjqGZo8tbZS?secret=E39WTiG4qBq3MFcMPeRZabtQuzyfHg9ZuR5SgY7nW1YH` - ); - const res = await fetch(checkIfPreviewLinkStillWorksUrl); - if (res.ok) { - executeEvent('refreshApp', { - tabId: tabId, - }); - } else { - const endpoint = await createEndpoint( - `/arbitrary/APP/${privateAppProperties?.name}/zip?preview=true` - ); + const previewUrl = await buildPrivatePreview(); - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - }, - body: UintToObject?.app, + const refreshFunc = async (tabId) => { + try { + setOpenSnackGlobal(true); + setInfoSnackCustom({ + type: 'info', + message: t('core:message.generic.building_app', { + postProcess: 'capitalizeFirstChar', + }), }); - - const previewPath = await response.text(); executeEvent('updateAppUrl', { - tabId: tabId, - url: await createEndpoint(previewPath), + tabId, + url: await buildPrivatePreview(), + }); + } catch (error) { + setOpenSnackGlobal(true); + setInfoSnackCustom({ + type: 'error', + message: + error?.message || + t('core:message.error.build_app', { + postProcess: 'capitalizeFirstChar', + }), }); - - setTimeout(() => { - executeEvent('refreshApp', { - tabId: tabId, - }); - }, 300); } }; @@ -198,13 +199,13 @@ export const useHandlePrivateApps = () => { identifier: privateAppProperties?.identifier, name: privateAppProperties?.name, service: privateAppProperties?.service, - url: await createEndpoint(previewPath), + url: previewUrl, isPreview: true, isPrivate: true, privateAppProperties: { ...privateAppProperties, logo, appName }, filePath: '', refreshFunc: (tabId) => { - refreshfunc(tabId, privateAppProperties); + void refreshFunc(tabId); }, }; executeEvent('addTab', { diff --git a/src/qortal/qortal-requests.ts b/src/qortal/qortal-requests.ts index c0fadcd2..64ffbb66 100644 --- a/src/qortal/qortal-requests.ts +++ b/src/qortal/qortal-requests.ts @@ -415,10 +415,12 @@ export const VALID_SESSION_PERMISSIONS = [ 'NOTIFICATION_PERMISSION', ]; -// Permissions automatically granted for the session when GET_USER_ACCOUNT is accepted -// These are read-only, low-risk permissions +// Permissions automatically granted for the same Q-App tab session when +// GET_USER_ACCOUNT is accepted. LOCK_TAB is a reversible, tab-scoped UI +// safeguard and therefore does not need a second prompt after authentication. export const AUTO_GRANTED_PERMISSIONS_ON_AUTH = [ 'GET_USER_ACCOUNT', + 'LOCK_TAB', 'GET_USER_WALLET', 'GET_WALLET_BALANCE', 'GET_USER_WALLET_INFO', From b5fbb856947fff807b65211ef96745381a3bb734 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Thu, 3 Sep 2026 13:08:04 +0300 Subject: [PATCH 08/32] fixes --- .../App/QortalRequestExtensionDialog.test.tsx | 53 +++++++++++++++++++ .../App/QortalRequestExtensionDialog.tsx | 7 +-- src/i18n/locales/ar/question.json | 2 + src/i18n/locales/de/question.json | 2 + src/i18n/locales/en/question.json | 2 + src/i18n/locales/es/question.json | 2 + src/i18n/locales/et/question.json | 2 + src/i18n/locales/fi/question.json | 2 + src/i18n/locales/fr/question.json | 2 + src/i18n/locales/it/question.json | 2 + src/i18n/locales/ja/question.json | 2 + src/i18n/locales/pt/question.json | 2 + src/i18n/locales/ru/question.json | 2 + src/i18n/locales/zh/question.json | 2 + src/qortal/get.ts | 4 ++ 15 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 src/components/App/QortalRequestExtensionDialog.test.tsx diff --git a/src/components/App/QortalRequestExtensionDialog.test.tsx b/src/components/App/QortalRequestExtensionDialog.test.tsx new file mode 100644 index 00000000..bd111a08 --- /dev/null +++ b/src/components/App/QortalRequestExtensionDialog.test.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { ThemeProvider } from '@mui/material/styles'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { QortalRequestExtensionDialog } from './QortalRequestExtensionDialog'; +import '../../i18n/i18n'; +import { darkTheme } from '../../styles/theme-dark'; + +describe('QortalRequestExtensionDialog confirmation', () => { + it('requires the acknowledgement before accepting', async () => { + const onAccept = vi.fn(); + const user = userEvent.setup(); + + function Harness() { + const [confirmed, setConfirmed] = useState(false); + return ( + + ); + } + + render( + + + + ); + await user.click(screen.getByText('Accept')); + expect(onAccept).not.toHaveBeenCalled(); + + await user.click( + screen.getByText( + 'I understand this Q-App will exchange data with a remote backend outside Qortal Hub.' + ) + ); + await user.click(screen.getByText('Accept')); + expect(onAccept).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/App/QortalRequestExtensionDialog.tsx b/src/components/App/QortalRequestExtensionDialog.tsx index b585ac16..748d3bd3 100644 --- a/src/components/App/QortalRequestExtensionDialog.tsx +++ b/src/components/App/QortalRequestExtensionDialog.tsx @@ -64,7 +64,7 @@ export function QortalRequestExtensionDialog({ onCountdownComplete, }: QortalRequestExtensionDialogProps) { const theme = useTheme(); - const { t } = useTranslation(['core']); + const { t } = useTranslation(['core', 'question']); const requestMessage = (message || {}) as MessageQortalRequestExtension & { appName?: string; sourceKind?: string; @@ -81,7 +81,7 @@ export function QortalRequestExtensionDialog({ const summaryText = requestMessage.highlightedText || (requestMessage.text1?.toLowerCase().includes('authenticate') - ? 'Grant permissions for this session only' + ? t('question:permission.session_only_summary') : requestMessage.text1); const wrappingTextSx = { overflowWrap: 'anywhere', @@ -635,7 +635,8 @@ export function QortalRequestExtensionDialog({ transform: canAccept ? 'translateY(-1px)' : 'none', }, }} - onClick={onAccept} + aria-disabled={!canAccept} + onClick={canAccept ? onAccept : undefined} > {t('core:action.accept', { postProcess: 'capitalizeFirstChar' })} diff --git a/src/i18n/locales/ar/question.json b/src/i18n/locales/ar/question.json index 7c7734e1..a86826d1 100644 --- a/src/i18n/locales/ar/question.json +++ b/src/i18n/locales/ar/question.json @@ -171,6 +171,7 @@ "remove_admin": "هل تسمح للتطبيق بإزالة {{ partecipant }} من الأدمنية؟", "remove_from_list": "هل تسمح للتطبيق بإزالة الآتي من القائمة {{ name }}:", "reticulum_destination": "يريد {{appName}} الاتصال بخادم Reticulum الخلفي {{destination}}. هل تسمح بهذا الاتصال لعلامة التبويب هذه؟", + "reticulum_remote_backend_understand": "أفهم أن تطبيق Q-App هذا سيتبادل البيانات مع واجهة خلفية بعيدة خارج Qortal Hub.", "sell_name_cancel": "هل تسمح للتطبيق بإلغاء بيع الاسم؟", "sell_name_transaction": "هل تسمح للتطبيق بعمل صفقة بيع اسم؟", "sell_name_transaction_detail": "بيع {{ name }} بسعر {{ price }} QORT", @@ -179,6 +180,7 @@ "send_coins": "هل تسمح للتطبيق بإرسال عملات؟", "server_add": "هل تسمح للتطبيق بإضافة سيرفر؟", "server_remove": "هل تسمح للتطبيق بإزالة سيرفر؟", + "session_only_summary": "منح الأذونات لهذه الجلسة فقط", "session_permissions": "{{appName}} يطلب أذونات الجلسة", "session_permissions_description": "سيتم منح الأذونات التالية تلقائيًا لهذه الجلسة:", "session_understand": "أثق بهذا التطبيق وأفهم أن هذه الأذونات ستُنفذ تلقائيًا", diff --git a/src/i18n/locales/de/question.json b/src/i18n/locales/de/question.json index 05c73643..8f3a9f79 100644 --- a/src/i18n/locales/de/question.json +++ b/src/i18n/locales/de/question.json @@ -171,6 +171,7 @@ "remove_admin": "Möchten Sie dieser Anwendung die Berechtigung geben, den Benutzer {{ partecipant }} als Administrator zu entfernen?", "remove_from_list": "Möchten Sie dieser Anwendung die Berechtigung geben, Folgendes aus der Liste {{ name }} zu entfernen:", "reticulum_destination": "{{appName}} möchte eine Verbindung zum Reticulum-Backend {{destination}} herstellen. Diese Verbindung für diesen Tab zulassen?", + "reticulum_remote_backend_understand": "Ich verstehe, dass diese Q-App Daten mit einem entfernten Backend außerhalb von Qortal Hub austauscht.", "sell_name_cancel": "Möchten Sie dieser Anwendung die Berechtigung geben, den Verkauf eines Namens abzubrechen?", "sell_name_transaction": "Möchten Sie dieser Anwendung die Berechtigung geben, eine Verkaufs-Transaktion für einen Namen zu erstellen?", "sell_name_transaction_detail": "{{ name }} für {{ price }} QORT verkaufen", @@ -179,6 +180,7 @@ "send_coins": "Möchten Sie dieser Anwendung die Berechtigung geben, Coins zu senden?", "server_add": "Möchten Sie dieser Anwendung die Berechtigung geben, einen Server hinzuzufügen?", "server_remove": "Möchten Sie dieser Anwendung die Berechtigung geben, einen Server zu entfernen?", + "session_only_summary": "Berechtigungen nur für diese Sitzung erteilen", "session_permissions": "{{appName}} fordert Sitzungsberechtigungen an", "session_permissions_description": "Die folgenden Berechtigungen werden für diese Sitzung automatisch erteilt:", "session_understand": "Ich vertraue dieser App und verstehe, dass diese Berechtigungen automatisch ausgeführt werden", diff --git a/src/i18n/locales/en/question.json b/src/i18n/locales/en/question.json index a7747781..6e2150c3 100644 --- a/src/i18n/locales/en/question.json +++ b/src/i18n/locales/en/question.json @@ -171,6 +171,7 @@ "remove_admin": "do you give this application permission to remove user {{ partecipant }} as an admin?", "remove_from_list": "do you give this application permission to remove the following from the list {{ name }}:", "reticulum_destination": "{{appName}} wants to connect to the Reticulum backend {{destination}}. Allow this connection for this tab?", + "reticulum_remote_backend_understand": "I understand this Q-App will exchange data with a remote backend outside Qortal Hub.", "sell_name_cancel": "do you give this application permission to cancel the selling of a name?", "sell_name_transaction": "do you give this application permission to create a sell name transaction?", "sell_name_transaction_detail": "sell {{ name }} for {{ price }} QORT", @@ -179,6 +180,7 @@ "send_coins": "do you give this application permission to send coins?", "server_add": "do you give this application permission to add a server?", "server_remove": "do you give this application permission to remove a server?", + "session_only_summary": "Grant permissions for this session only", "session_permissions": "{{appName}} is requesting session permissions", "session_permissions_description": "The following permissions will be automatically granted for this session:", "session_understand": "I trust this app and understand these permissions will auto-execute", diff --git a/src/i18n/locales/es/question.json b/src/i18n/locales/es/question.json index 22e824e6..c62e8c26 100644 --- a/src/i18n/locales/es/question.json +++ b/src/i18n/locales/es/question.json @@ -171,6 +171,7 @@ "remove_admin": "¿Deseas permitir que esta aplicación elimine al usuario {{ partecipant }} como administrador?", "remove_from_list": "¿Deseas permitir que esta aplicación elimine lo siguiente de la lista {{ name }}?", "reticulum_destination": "{{appName}} quiere conectarse al backend de Reticulum {{destination}}. ¿Permitir esta conexión para esta pestaña?", + "reticulum_remote_backend_understand": "Entiendo que esta Q-App intercambiará datos con un backend remoto externo a Qortal Hub.", "sell_name_cancel": "¿Deseas permitir que esta aplicación cancele la venta de un nombre?", "sell_name_transaction": "¿Deseas permitir que esta aplicación cree una transacción para vender un nombre?", "sell_name_transaction_detail": "vender {{ name }} por {{ price }} QORT", @@ -179,6 +180,7 @@ "send_coins": "¿Deseas permitir que esta aplicación envíe monedas?", "server_add": "¿Deseas permitir que esta aplicación agregue un servidor?", "server_remove": "¿Deseas permitir que esta aplicación elimine un servidor?", + "session_only_summary": "Conceder permisos solo para esta sesión", "session_permissions": "{{appName}} solicita permisos de sesión", "session_permissions_description": "Los siguientes permisos se concederán automáticamente para esta sesión:", "session_understand": "Confío en esta aplicación y entiendo que estos permisos se ejecutarán automáticamente", diff --git a/src/i18n/locales/et/question.json b/src/i18n/locales/et/question.json index a9314a28..ac01acbf 100644 --- a/src/i18n/locales/et/question.json +++ b/src/i18n/locales/et/question.json @@ -171,6 +171,7 @@ "remove_admin": "kas sa annad sellele rakendusele loa eemaldada kasutaja {{ partecipant }} administraatori staatusest?", "remove_from_list": "kas sa annad sellele rakendusele loa eemaldada järgmised asjad nimekirjast {{ name }}:", "reticulum_destination": "{{appName}} soovib luua ühenduse Reticulumi taustserveriga {{destination}}. Kas lubada see ühendus sellel vahekaardil?", + "reticulum_remote_backend_understand": "Mõistan, et see Q-App vahetab andmeid Qortal Hubist väljaspool asuva kaugtaustateenusega.", "sell_name_cancel": "kas sa annad sellele rakendusele loa tühistada nime müümise?", "sell_name_transaction": "kas sa annad sellele rakendusele loa luua nime müümise tehing?", "sell_name_transaction_detail": "müü {{ name }} hinnaga {{ price }} QORT", @@ -179,6 +180,7 @@ "send_coins": "kas sa annad sellele rakendusele loa saata münte?", "server_add": "kas sa annad sellele rakendusele loa serverit lisada?", "server_remove": "kas sa annad sellele rakendusele loa serverit eemaldada?", + "session_only_summary": "Anna õigused ainult selleks seansiks", "session_permissions": "{{appName}} taotleb seansi õigusi", "session_permissions_description": "Järgmised õigused antakse selle seansi jaoks automaatselt:", "session_understand": "Ma usaldan seda rakendust ja mõistan, et need õigused käivitatakse automaatselt", diff --git a/src/i18n/locales/fi/question.json b/src/i18n/locales/fi/question.json index a2177a30..4dbf00da 100644 --- a/src/i18n/locales/fi/question.json +++ b/src/i18n/locales/fi/question.json @@ -171,6 +171,7 @@ "remove_admin": "annatko tälle sovellukselle luvan poistaa käyttäjä {{ partecipant }} järjestelmänvalvojana?", "remove_from_list": "annatko tälle sovellukselle luvan poistaa seuraavat kohteet luettelosta {{ name }}:", "reticulum_destination": "{{appName}} haluaa muodostaa yhteyden Reticulum-taustapalvelimeen {{destination}}. Sallitaanko yhteys tässä välilehdessä?", + "reticulum_remote_backend_understand": "Ymmärrän, että tämä Q-App vaihtaa tietoja Qortal Hubin ulkopuolisen etätaustapalvelun kanssa.", "sell_name_cancel": "annatko tälle sovellukselle luvan peruuttaa nimen myynnin?", "sell_name_transaction": "annatko tälle sovellukselle luvan luoda nimenmyyntitransaktion?", "sell_name_transaction_detail": "myy {{ name }} hintaan {{ price }} QORT", @@ -179,6 +180,7 @@ "send_coins": "annatko tälle sovellukselle luvan lähettää kolikoita?", "server_add": "annatko tälle sovellukselle luvan lisätä palvelimen?", "server_remove": "annatko tälle sovellukselle luvan poistaa palvelimen?", + "session_only_summary": "Myönnä oikeudet vain tälle istunnolle", "session_permissions": "{{appName}} pyytää istuntokohtaisia oikeuksia", "session_permissions_description": "Seuraavat oikeudet myönnetään automaattisesti tälle istunnolle:", "session_understand": "Luotan tähän sovellukseen ja ymmärrän, että nämä oikeudet suoritetaan automaattisesti", diff --git a/src/i18n/locales/fr/question.json b/src/i18n/locales/fr/question.json index 28334866..1aa12e4e 100644 --- a/src/i18n/locales/fr/question.json +++ b/src/i18n/locales/fr/question.json @@ -171,6 +171,7 @@ "remove_admin": "Souhaitez-vous autoriser cette application à retirer {{ partecipant }} en tant qu'administrateur ?", "remove_from_list": "Souhaitez-vous autoriser cette application à supprimer les éléments suivants de la liste {{ name }} :", "reticulum_destination": "{{appName}} souhaite se connecter au serveur Reticulum {{destination}}. Autoriser cette connexion pour cet onglet ?", + "reticulum_remote_backend_understand": "Je comprends que cette Q-App échangera des données avec un serveur distant externe à Qortal Hub.", "sell_name_cancel": "Souhaitez-vous autoriser cette application à annuler la vente d'un nom ?", "sell_name_transaction": "Souhaitez-vous autoriser cette application à créer une transaction de vente de nom ?", "sell_name_transaction_detail": "vente de {{ name }} pour {{ price }} QORT", @@ -179,6 +180,7 @@ "send_coins": "Souhaitez-vous autoriser cette application à envoyer des pièces ?", "server_add": "Souhaitez-vous autoriser cette application à ajouter un serveur ?", "server_remove": "Souhaitez-vous autoriser cette application à supprimer un serveur ?", + "session_only_summary": "Accorder les autorisations uniquement pour cette session", "session_permissions": "{{appName}} demande des autorisations de session", "session_permissions_description": "Les autorisations suivantes seront automatiquement accordées pour cette session :", "session_understand": "Je fais confiance à cette application et comprends que ces autorisations seront exécutées automatiquement", diff --git a/src/i18n/locales/it/question.json b/src/i18n/locales/it/question.json index 8b92416b..39314929 100644 --- a/src/i18n/locales/it/question.json +++ b/src/i18n/locales/it/question.json @@ -171,6 +171,7 @@ "remove_admin": "consenti a questa applicazione di rimuovere l'utente {{ partecipant }} come amministratore?", "remove_from_list": "consenti a questa applicazione di rimuovere i seguenti elementi dalla lista {{ name }}?", "reticulum_destination": "{{appName}} vuole connettersi al backend Reticulum {{destination}}. Consentire questa connessione per questa scheda?", + "reticulum_remote_backend_understand": "Comprendo che questa Q-App scambierà dati con un backend remoto esterno a Qortal Hub.", "sell_name_cancel": "consenti a questa applicazione di annullare la vendita di un nome?", "sell_name_transaction": "consenti a questa applicazione di creare una transazione di vendita nome?", "sell_name_transaction_detail": "vendi {{ name }} per {{ price }} QORT", @@ -179,6 +180,7 @@ "send_coins": "consenti a questa applicazione di inviare monete?", "server_add": "consenti a questa applicazione di aggiungere un server?", "server_remove": "consenti a questa applicazione di rimuovere un server?", + "session_only_summary": "Concedi le autorizzazioni solo per questa sessione", "session_permissions": "{{appName}} richiede autorizzazioni di sessione", "session_permissions_description": "Le seguenti autorizzazioni verranno concesse automaticamente per questa sessione:", "session_understand": "Mi fido di questa applicazione e comprendo che queste autorizzazioni verranno eseguite automaticamente", diff --git a/src/i18n/locales/ja/question.json b/src/i18n/locales/ja/question.json index 0b7b8db3..141af33b 100644 --- a/src/i18n/locales/ja/question.json +++ b/src/i18n/locales/ja/question.json @@ -171,6 +171,7 @@ "remove_admin": "このアプリに {{ partecipant }} を管理者から削除することを許可しますか?", "remove_from_list": "このアプリにリスト {{ name }} から以下を削除することを許可しますか?", "reticulum_destination": "{{appName}} が Reticulum バックエンド {{destination}} への接続を要求しています。このタブで接続を許可しますか?", + "reticulum_remote_backend_understand": "このQ-AppがQortal Hub外部のリモートバックエンドとデータを交換することを理解しています。", "sell_name_cancel": "このアプリに名前の販売をキャンセルすることを許可しますか?", "sell_name_transaction": "このアプリに名前の販売取引を作成することを許可しますか?", "sell_name_transaction_detail": "{{ price }} QORTで{{ name }}を販売", @@ -179,6 +180,7 @@ "send_coins": "このアプリにコインを送信することを許可しますか?", "server_add": "このアプリにサーバーを追加することを許可しますか?", "server_remove": "このアプリにサーバーを削除することを許可しますか?", + "session_only_summary": "このセッションにのみ権限を付与", "session_permissions": "{{appName}} がセッション権限を要求しています", "session_permissions_description": "以下の権限はこのセッション中に自動的に付与されます:", "session_understand": "このアプリを信頼しており、これらの権限が自動的に実行されることを理解しています", diff --git a/src/i18n/locales/pt/question.json b/src/i18n/locales/pt/question.json index e2a1e24d..2cb06fca 100644 --- a/src/i18n/locales/pt/question.json +++ b/src/i18n/locales/pt/question.json @@ -171,6 +171,7 @@ "remove_admin": "você dá permissão para que este aplicativo remova o usuário {{ partecipant }} como administrador?", "remove_from_list": "você dá permissão para que este aplicativo remova o seguinte da lista {{ name }}:", "reticulum_destination": "{{appName}} quer se conectar ao backend Reticulum {{destination}}. Permitir esta conexão para esta aba?", + "reticulum_remote_backend_understand": "Entendo que este Q-App trocará dados com um backend remoto externo ao Qortal Hub.", "sell_name_cancel": "você dá permissão para que este aplicativo cancele a venda de um nome?", "sell_name_transaction": "você dá permissão para que este aplicativo crie uma transação de venda de nome?", "sell_name_transaction_detail": "vender {{ name }} por {{ price }} QORTs", @@ -179,6 +180,7 @@ "send_coins": "você dá permissão para que este aplicativo envie moedas?", "server_add": "você dá permissão para que este aplicativo adicione um servidor?", "server_remove": "você dá permissão para que este aplicativo remova um servidor?", + "session_only_summary": "Conceder permissões apenas para esta sessão", "session_permissions": "{{appName}} está solicitando permissões de sessão", "session_permissions_description": "As seguintes permissões serão concedidas automaticamente para esta sessão:", "session_understand": "Confio neste aplicativo e entendo que essas permissões serão executadas automaticamente", diff --git a/src/i18n/locales/ru/question.json b/src/i18n/locales/ru/question.json index 6b8aa1fc..508f87a7 100644 --- a/src/i18n/locales/ru/question.json +++ b/src/i18n/locales/ru/question.json @@ -171,6 +171,7 @@ "remove_admin": "Разрешить этому приложению удалить пользователя {{ partecipant }} из администраторов?", "remove_from_list": "Разрешить этому приложению удалить следующее из списка {{ name }}:", "reticulum_destination": "{{appName}} запрашивает подключение к серверу Reticulum {{destination}}. Разрешить это подключение для данной вкладки?", + "reticulum_remote_backend_understand": "Я понимаю, что это Q-App будет обмениваться данными с удалённым сервером за пределами Qortal Hub.", "sell_name_cancel": "Разрешить этому приложению отменить продажу имени?", "sell_name_transaction": "Разрешить этому приложению создать транзакцию продажи имени?", "sell_name_transaction_detail": "продажа {{ name }} за {{ price }} QORT", @@ -179,6 +180,7 @@ "send_coins": "Разрешить этому приложению отправить монеты?", "server_add": "Разрешить этому приложению добавить сервер?", "server_remove": "Разрешить этому приложению удалить сервер?", + "session_only_summary": "Предоставить разрешения только для этого сеанса", "session_permissions": "{{appName}} запрашивает разрешения сеанса", "session_permissions_description": "Следующие разрешения будут автоматически предоставлены для этого сеанса:", "session_understand": "Я доверяю этому приложению и понимаю, что эти разрешения будут выполняться автоматически", diff --git a/src/i18n/locales/zh/question.json b/src/i18n/locales/zh/question.json index 8f69eef3..6464b5ad 100644 --- a/src/i18n/locales/zh/question.json +++ b/src/i18n/locales/zh/question.json @@ -171,6 +171,7 @@ "remove_admin": "是否允许该应用将用户 {{ partecipant }} 移除管理员权限?", "remove_from_list": "是否允许该应用从列表 {{ name }} 中移除以下内容?", "reticulum_destination": "{{appName}} 想要连接到 Reticulum 后端 {{destination}}。是否允许此标签页建立连接?", + "reticulum_remote_backend_understand": "我了解此 Q-App 将与 Qortal Hub 外部的远程后端交换数据。", "sell_name_cancel": "是否允许该应用取消名称的出售?", "sell_name_transaction": "是否允许该应用创建出售名称的交易?", "sell_name_transaction_detail": "以 {{ price }} QORT 出售 {{ name }}", @@ -179,6 +180,7 @@ "send_coins": "是否允许该应用发送代币?", "server_add": "是否允许该应用添加服务器?", "server_remove": "是否允许该应用移除服务器?", + "session_only_summary": "仅为此会话授予权限", "session_permissions": "{{appName}} 正在请求会话权限", "session_permissions_description": "以下权限将自动授予本次会话:", "session_understand": "我信任此应用,并理解这些权限将自动执行", diff --git a/src/qortal/get.ts b/src/qortal/get.ts index fd2a22be..5242d967 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -549,6 +549,10 @@ export const authorizeRnsDestination = async ( appName: appInfo.name, destination: normalized, }), + confirmCheckbox: true, + confirmCheckboxLabel: i18n.t( + 'question:permission.reticulum_remote_backend_understand' + ), }, isFromExtension ); From b1884fcf5642a3ffbd59784c60fbbd67b6e3ad66 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Fri, 4 Sep 2026 19:07:34 +0300 Subject: [PATCH 09/32] block webrtc --- docs/development.md | 2 +- docs/qapp-reticulum.md | 8 +- electron/electron-builder.config.arm.json | 6 +- electron/electron-builder.config.json | 6 +- electron/electron-builder.config.lin.json | 6 +- electron/package-lock.json | 3419 +++++------------ electron/package.json | 15 +- electron/resources/presence_bridge.py | 108 +- electron/resources/presence_bridge_test.py | 94 +- .../src/embedded-frame-network-policy.test.ts | 39 + electron/src/embedded-frame-network-policy.ts | 28 + electron/src/reticulum-chat-db.ts | 2 +- electron/src/setup.ts | 17 +- package.json | 2 +- 14 files changed, 1357 insertions(+), 2395 deletions(-) create mode 100644 electron/src/embedded-frame-network-policy.test.ts create mode 100644 electron/src/embedded-frame-network-policy.ts diff --git a/docs/development.md b/docs/development.md index c8f14df6..caff76da 100644 --- a/docs/development.md +++ b/docs/development.md @@ -2,7 +2,7 @@ Requirements for the development of Qortal-Hub from sources: -- installation of nodejs from the [official site](https://nodejs.org/en/download) +- installation of Node.js 22.12 or newer from the [official site](https://nodejs.org/en/download) - Python 3.9+ and Git on PATH for Reticulum networking during Electron development - an IDE like vscode or Intellij, or similar tools - some knowledge of React and its ecosystem diff --git a/docs/qapp-reticulum.md b/docs/qapp-reticulum.md index 566115bd..9b10890c 100644 --- a/docs/qapp-reticulum.md +++ b/docs/qapp-reticulum.md @@ -92,9 +92,11 @@ automatically after ambiguous failure. Reconnect begins at 0.5 seconds, doubles with 0.8–1.2 jitter, and caps at 30 seconds. Partial Buffer bytes are discarded. Complete unacknowledged DATA frames -are resent in message-ID order from byte zero. Closing the last logical -connection sends CLOSE before removing local ownership and prevents a pending -reconnect timer from reopening the Link. +are resent in message-ID order from byte zero. Closing removes local ownership +immediately and queues the remote CLOSE on a bounded, per-backend worker. A +failed or stalled CLOSE tears down that same physical Link, while a replacement +Link created in the meantime is left untouched. Removing the last logical +connection prevents a pending reconnect timer from reopening the Link. Transport IDs prevent duplicate delivery across reconnects. They do not prevent duplicate business operations: Q-Apps must include their own operation IDs for diff --git a/electron/electron-builder.config.arm.json b/electron/electron-builder.config.arm.json index d166cdbf..05e72ff4 100644 --- a/electron/electron-builder.config.arm.json +++ b/electron/electron-builder.config.arm.json @@ -75,8 +75,10 @@ "category": "Network", "packageCategory": "Network", "desktop": { - "Name": "Qortal Hub", - "StartupWMClass": "qortal-hub" + "entry": { + "Name": "Qortal Hub", + "StartupWMClass": "qortal-hub" + } }, "executableName": "qortal-hub", "icon": "assets/png" diff --git a/electron/electron-builder.config.json b/electron/electron-builder.config.json index 2d2af757..4b7ac72a 100644 --- a/electron/electron-builder.config.json +++ b/electron/electron-builder.config.json @@ -88,8 +88,10 @@ "category": "Network", "packageCategory": "Network", "desktop": { - "StartupWMClass": "qortal-hub", - "Name": "Qortal Hub" + "entry": { + "StartupWMClass": "qortal-hub", + "Name": "Qortal Hub" + } }, "executableName": "qortal-hub", "icon": "assets/png", diff --git a/electron/electron-builder.config.lin.json b/electron/electron-builder.config.lin.json index 3d82ac6d..6381c66f 100644 --- a/electron/electron-builder.config.lin.json +++ b/electron/electron-builder.config.lin.json @@ -75,8 +75,10 @@ "category": "Network", "packageCategory": "Network", "desktop": { - "Name": "Qortal Hub", - "StartupWMClass": "qortal-hub" + "entry": { + "Name": "Qortal Hub", + "StartupWMClass": "qortal-hub" + } }, "executableName": "qortal-hub", "icon": "assets/png" diff --git a/electron/package-lock.json b/electron/package-lock.json index b1b21293..cfbb801f 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -11,13 +11,13 @@ "dependencies": { "@capacitor-community/electron": "^5.0.0", "@silentbot1/nat-api": "^0.4.9", - "adm-zip": "^0.5.16", - "better-sqlite3": "^12.8.0", + "adm-zip": "^0.6.0", + "better-sqlite3": "^13.0.3", "chokidar": "^4.0.3", "electron-is-dev": "~2.0.0", "electron-serve": "~1.1.0", "electron-unhandled": "~4.0.1", - "electron-updater": "^6.6.2", + "electron-updater": "^6.8.9", "electron-window-state": "^5.0.3", "extract-zip": "^2.0.1", "moment-timezone": "^0.5.48", @@ -28,13 +28,16 @@ }, "devDependencies": { "@electron/notarize": "^2.5.0", - "@electron/rebuild": "^3.7.2", + "@electron/rebuild": "^4.2.0", "@types/better-sqlite3": "^7.6.13", "@types/write-file-atomic": "^4.0.3", - "electron": "^32.3.1", - "electron-builder": "^25.1.8", + "electron": "^44.1.1", + "electron-builder": "^26.15.3", "shelljs": "^0.8.5", "typescript": "^5.0.4" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@capacitor-community/electron": { @@ -96,28 +99,20 @@ "tslib": "^2.1.0" } }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=22.12.0" } }, "node_modules/@electron/asar": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.3.1.tgz", - "integrity": "sha512-WtpC/+34p0skWZiarRjLAyqaAX78DofhDxnREy/V5XHfu1XEXbFCSSMcDQ6hNCPJFaPy8/NnUgYuf9uiCkvKPg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", "dev": true, "license": "MIT", "dependencies": { @@ -142,108 +137,82 @@ "node": ">= 6" } }, - "node_modules/@electron/asar/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "bin": { + "electron-fuses": "dist/bin.js" } }, - "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" + "node": ">=10" } }, - "node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "node_modules/@electron/fuses/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "universalify": "^2.0.0" }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/@electron/get/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, "engines": { - "node": ">= 4.0.0" + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" } }, - "node_modules/@electron/node-gyp": { - "version": "10.2.0-electron.1", - "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", - "integrity": "sha512-CrYo6TntjpoMO1SHjl5Pa/JoUsECNqNdB7Kx49WLQpWzPw53eEITJ2Hs9fh/ryUYDn4pxZz11StaBYBrLFJdqg==", + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^8.1.0", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.2.1", - "nopt": "^6.0.0", - "proc-log": "^2.0.1", - "semver": "^7.3.5", - "tar": "^6.2.1", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, "engines": { - "node": ">=12.13.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@electron/notarize": { @@ -291,9 +260,9 @@ } }, "node_modules/@electron/osx-sign": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", - "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -341,9 +310,9 @@ } }, "node_modules/@electron/osx-sign/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -354,70 +323,47 @@ } }, "node_modules/@electron/rebuild": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.2.tgz", - "integrity": "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", "dev": true, "license": "MIT", "dependencies": { - "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", - "got": "^11.7.0", - "node-abi": "^3.45.0", - "node-api-version": "^0.2.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" }, "engines": { - "node": ">=12.13.0" + "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/@electron/rebuild/node_modules/node-abi": { + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", + "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "semver": "^7.6.3" }, "engines": { - "node": ">=12" - } - }, - "node_modules/@electron/rebuild/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "node": ">=22.12.0" } }, "node_modules/@electron/universal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", - "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", "dev": true, "license": "MIT", "dependencies": { - "@electron/asar": "^3.2.7", + "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", @@ -430,9 +376,9 @@ } }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -440,13 +386,13 @@ } }, "node_modules/@electron/universal/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" @@ -455,6 +401,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.2", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", @@ -465,13 +433,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1170,107 +1131,27 @@ "node": ">=16.0.0" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" + "minipass": "^7.0.4" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18.0.0" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=16 || 14 >=14.17" } }, "node_modules/@malept/cross-spawn-promise": { @@ -1329,9 +1210,9 @@ } }, "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1353,117 +1234,36 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@npmcli/fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", - "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", - "dev": true, - "license": "ISC", + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", + "license": "MIT", "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@npmcli/move-file": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", - "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "dev": true, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", "license": "MIT", "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" } }, - "node_modules/@npmcli/move-file/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/move-file/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/move-file/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", - "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "@peculiar/asn1-x509-attr": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-csr": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", - "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.1", - "asn1js": "^3.0.6", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-ecc": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", - "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", - "license": "MIT", + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", @@ -1526,14 +1326,17 @@ } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", - "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.4.tgz", + "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", "license": "MIT", "dependencies": { - "asn1js": "^3.0.6", - "pvtsutils": "^1.3.6", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-x509": { @@ -1560,6 +1363,45 @@ "tslib": "^2.8.1" } }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/@peculiar/x509": { "version": "1.14.3", "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", @@ -1582,17 +1424,6 @@ "node": ">=20.0.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@silentbot1/nat-api": { "version": "0.4.9", "resolved": "https://registry.npmjs.org/@silentbot1/nat-api/-/nat-api-0.4.9.tgz", @@ -1658,16 +1489,6 @@ "node": ">=10" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -1692,9 +1513,9 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dev": true, "license": "MIT", "dependencies": { @@ -1711,9 +1532,9 @@ } }, "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", "dev": true, "license": "MIT" }, @@ -1735,24 +1556,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", - "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" - } - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" + "undici-types": "~7.18.0" } }, "node_modules/@types/responselike": { @@ -1771,14 +1580,6 @@ "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", "license": "MIT" }, - "node_modules/@types/verror": { - "version": "1.10.10", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.10.tgz", - "integrity": "sha512-l4MM0Jppn18hb9xmM6wwD1uTdShpf9Pn80aXTStnK1C94gtPvJcV2FrDmbOQUAQfJ1cKZHktkQUDwEqaAKXMMg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/@types/write-file-atomic": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/write-file-atomic/-/write-file-atomic-4.0.3.tgz", @@ -1808,19 +1609,15 @@ "node": ">=10.0.0" } }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, "node_modules/abort-controller": { "version": "3.0.0", @@ -1835,81 +1632,41 @@ } }, "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", + "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "license": "MIT", "engines": { - "node": ">=12.0" + "node": ">=14.0" } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", - "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", - "dev": true, - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, "engines": { - "node": ">=8" + "node": ">= 14" } }, "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": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1934,88 +1691,131 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.10", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", - "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", - "dev": true, - "license": "MIT" - }, "node_modules/app-builder-lib": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", - "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", "dev": true, "license": "MIT", "dependencies": { - "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.1", - "@electron/rebuild": "3.6.1", - "@electron/universal": "2.0.1", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "bluebird-lst": "^1.0.9", - "builder-util": "25.1.7", - "builder-util-runtime": "9.2.10", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", - "config-file-ts": "0.2.8-rc1", + "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "25.1.7", - "form-data": "^4.0.0", + "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", - "is-ci": "^3.0.0", "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", - "minimatch": "^10.0.0", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", - "sanitize-filename": "^1.6.3", - "semver": "^7.3.8", - "tar": "^6.1.12", - "temp-file": "^3.4.0" + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "25.1.8", - "electron-builder-squirrel-windows": "25.1.8" + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" } }, - "node_modules/app-builder-lib/node_modules/@electron/rebuild": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", - "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", "dev": true, "license": "MIT", "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", - "got": "^11.7.0", - "node-abi": "^3.45.0", - "node-api-version": "^0.2.0", - "node-gyp": "^9.0.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" }, - "bin": { - "electron-rebuild": "lib/cli.js" + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" }, "engines": { - "node": ">=12.13.0" + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/app-builder-lib/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/app-builder-lib/node_modules/@types/fs-extra": { @@ -2028,28 +1828,53 @@ "@types/node": "*" } }, - "node_modules/app-builder-lib/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "node_modules/app-builder-lib/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": "18 || 20 || >=22" } }, - "node_modules/app-builder-lib/node_modules/builder-util-runtime": { - "version": "9.2.10", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", - "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "node_modules/app-builder-lib/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=12.0.0" + "node": "20 || >=22" + } + }, + "node_modules/app-builder-lib/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/app-builder-lib/node_modules/fs-extra": { @@ -2067,10 +1892,10 @@ "node": ">=12" } }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2080,144 +1905,109 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "node_modules/app-builder-lib/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 10" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "node_modules/app-builder-lib/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "glob": "^7.1.4", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 6" + "node": ">= 18" } }, - "node_modules/archiver-utils/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=10" } }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/app-builder-lib/node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, - "license": "MIT", - "peer": true, + "license": "BlueOak-1.0.0", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" } }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, - "license": "MIT", - "peer": true, + "license": "ISC", "dependencies": { - "safe-buffer": "~5.1.0" + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", + "node_modules/app-builder-lib/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=18" } }, "node_modules/argparse": { @@ -2227,30 +2017,19 @@ "license": "Python-2.0" }, "node_modules/asn1js": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", - "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", - "pvutils": "^1.1.3", + "pvutils": "^1.1.5", "tslib": "^2.8.1" }, "engines": { "node": ">=12.0.0" } }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8" - } - }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2293,6 +2072,13 @@ "node": ">= 4.0.0" } }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2320,17 +2106,15 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", - "hasInstallScript": true, + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", "license": "MIT", "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" + "node-addon-api": "^8.0.0" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": ">=22" } }, "node_modules/big-integer": { @@ -2342,15 +2126,6 @@ "node": ">=0.6" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -2369,16 +2144,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bluebird-lst": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", - "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bluebird": "^3.5.5" - } - }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -2452,114 +2217,63 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "25.1.7", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", - "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", "dev": true, "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.10", - "bluebird-lst": "^1.0.9", - "builder-util-runtime": "9.2.10", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", - "is-ci": "^3.0.0", "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", - "temp-file": "^3.4.0" - } - }, - "node_modules/builder-util-runtime": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", - "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/builder-util/node_modules/agent-base": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", - "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" + "node": ">=14.0.0" } }, - "node_modules/builder-util/node_modules/builder-util-runtime": { - "version": "9.2.10", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", - "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", - "dev": true, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "license": "MIT", "dependencies": { "debug": "^4.3.4", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/builder-util/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/builder-util/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "sax": "^1.2.4" }, "engines": { - "node": ">= 14" + "node": ">=12.0.0" } }, - "node_modules/builder-util/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">= 14" + "node": ">=12" } }, "node_modules/builder-util/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2578,98 +2292,6 @@ "node": ">=6.0.0" } }, - "node_modules/cacache": { - "version": "16.1.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", - "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^2.1.0", - "@npmcli/move-file": "^2.0.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "glob": "^8.0.1", - "infer-owner": "^1.0.4", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^9.0.0", - "tar": "^6.1.11", - "unique-filename": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/cacache/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/cacheable-lookup": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", @@ -2785,9 +2407,9 @@ "license": "MIT" }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -2833,40 +2455,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2922,16 +2510,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2964,23 +2542,6 @@ "node": ">=0.10.0" } }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2988,127 +2549,21 @@ "dev": true, "license": "MIT" }, - "node_modules/config-file-ts": { - "version": "0.2.8-rc1", - "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", - "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.3.12", - "typescript": "^5.4.3" - } - }, - "node_modules/config-file-ts/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/config-file-ts/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/config-file-ts/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/config-file-ts/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, - "license": "ISC" - }, "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, "license": "MIT" }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" - } + "peer": true }, "node_modules/cross-fetch-ponyfill": { "version": "1.0.3", @@ -3164,6 +2619,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -3179,6 +2635,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -3187,15 +2644,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/default-gateway": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-7.2.2.tgz", @@ -3287,13 +2735,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3323,35 +2764,16 @@ } }, "node_modules/dmg-builder": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", - "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "25.1.8", - "builder-util": "25.1.7", - "builder-util-runtime": "9.2.10", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-builder/node_modules/builder-util-runtime": { - "version": "9.2.10", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", - "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" } }, "node_modules/dmg-builder/node_modules/fs-extra": { @@ -3370,9 +2792,9 @@ } }, "node_modules/dmg-builder/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3382,37 +2804,10 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3445,21 +2840,57 @@ "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "license": "MIT" }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -3477,37 +2908,38 @@ } }, "node_modules/electron": { - "version": "32.3.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-32.3.1.tgz", - "integrity": "sha512-gjHN9NagNajvllKrTZVwGuCqX1hPF7OI6hkCDFRCbqT5Zr05d17qjDhjyTNBmTca2DpvZIYQMRbqieNCcglVYQ==", + "version": "44.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.1.1.tgz", + "integrity": "sha512-N2WCq2sbOkqQgvXJYx2lS6UiO8bF+Yr67trDnS6JKa2WxTCRQsAjGl57SUtdW9h6r5PlduBFjIhxhgd3dzv1hg==", "dev": true, - "hasInstallScript": true, + "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^20.9.0", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", - "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "25.1.8", - "builder-util": "25.1.7", - "builder-util-runtime": "9.2.10", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", - "dmg-builder": "25.1.8", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", - "is-ci": "^3.0.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" @@ -3521,61 +2953,16 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", - "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "25.1.8", - "archiver": "^5.3.1", - "builder-util": "25.1.7", - "fs-extra": "^10.1.0" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder/node_modules/builder-util-runtime": { - "version": "9.2.10", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", - "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" } }, "node_modules/electron-builder/node_modules/fs-extra": { @@ -3616,16 +3003,18 @@ } }, "node_modules/electron-publish": { - "version": "25.1.7", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", - "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "25.1.7", - "builder-util-runtime": "9.2.10", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", + "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" @@ -3641,20 +3030,6 @@ "@types/node": "*" } }, - "node_modules/electron-publish/node_modules/builder-util-runtime": { - "version": "9.2.10", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", - "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/electron-publish/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -3671,9 +3046,9 @@ } }, "node_modules/electron-publish/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3706,18 +3081,18 @@ } }, "node_modules/electron-updater": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.6.2.tgz", - "integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==", + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.3.1", + "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", - "semver": "^7.6.3", + "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, @@ -3747,6 +3122,18 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-window-state": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/electron-window-state/-/electron-window-state-5.0.3.tgz", @@ -3760,20 +3147,54 @@ "node": ">=8.0.0" } }, - "node_modules/electron/node_modules/@types/node": { - "version": "20.17.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.19.tgz", - "integrity": "sha512-LEwC7o1ifqg/6r2gn9Dns0f1rhK+fPFDoMiceTJ6kWmVk6bgXBI/9IOWfVan4WiAavK9pIVWdX0/e3J+eEUh5A==", + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "undici-types": "~6.19.2" + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" } }, - "node_modules/electron/node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } }, "node_modules/elementtree": { "version": "0.1.7", @@ -3799,17 +3220,6 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -3865,9 +3275,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -4005,19 +3415,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/exponential-backoff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", - "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "dev": true, "license": "Apache-2.0" }, @@ -4041,17 +3442,6 @@ "@types/yauzl": "^2.9.1" } }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "optional": true - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4059,12 +3449,22 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fd-slicer": { "version": "1.1.0", @@ -4075,6 +3475,24 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -4098,16 +3516,10 @@ "node": "^12.20 || >= 14.13" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4115,9 +3527,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -4125,9 +3537,9 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -4137,48 +3549,18 @@ "node": ">=10" } }, - "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4196,12 +3578,6 @@ "node": ">=12.20.0" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs-extra": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", @@ -4256,27 +3632,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4341,56 +3696,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=12" + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -4556,17 +3883,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, - "license": "ISC" - }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -4590,25 +3910,24 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "dev": true, "license": "BSD-2-Clause" }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/http2-wrapper": { @@ -4626,17 +3945,17 @@ } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/human-signals": { @@ -4648,47 +3967,6 @@ "node": ">=14.18.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4709,33 +3987,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "license": "ISC" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4773,33 +4024,6 @@ "node": ">= 0.10" } }, - "node_modules/ip-address": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", - "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "jsbn": "1.1.0", - "sprintf-js": "^1.1.3" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4849,13 +4073,6 @@ "node": ">=8" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -4897,13 +4114,12 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.4.tgz", - "integrity": "sha512-YKBKVkKhty7s8rxddb40oOkuP0NbaeXrQvLin6QMHL7Ypiy2RW9LwOVrVgZRyOrhQlayMd9t+D8yDy8MKFTSDQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", "dev": true, "license": "MIT", "engines": { @@ -4919,33 +4135,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", + "async": "^3.2.6", "filelist": "^1.0.4", - "minimatch": "^3.1.2" + "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" @@ -4954,6 +4153,16 @@ "node": ">=10" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -4966,13 +4175,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "dev": true, - "license": "MIT" - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4980,9 +4182,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -5040,60 +4242,10 @@ "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "license": "MIT" }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "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" }, @@ -5103,58 +4255,18 @@ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.difference": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", "license": "MIT" }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "license": "MIT" }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -5194,44 +4306,6 @@ "node": ">=10" } }, - "node_modules/make-fetch-happen": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", - "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", - "dev": true, - "license": "ISC", - "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^16.1.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.3", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^9.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5349,76 +4423,6 @@ "node": ">=8" } }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", - "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/minizlib": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", @@ -5444,12 +4448,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -5477,12 +4475,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/native-run": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.1.tgz", @@ -5508,40 +4500,19 @@ "node": ">=16.0.0" } }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.71.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.71.0.tgz", - "integrity": "sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==", + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, "engines": { - "node": ">=10" + "node": "^18 || ^20 || >= 21" } }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/node-api-version": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.0.tgz", - "integrity": "sha512-fthTTsi8CxaBXMaBAD7ST2uylwvsnYxh2PfaScwpMhos6KlSFajXQPcM4ogNE1q2s3Lbz9GCGqeIHC+C6OZnKg==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5587,95 +4558,147 @@ } }, "node_modules/node-gyp": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", - "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", - "glob": "^7.1.4", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^6.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^12.13 || ^14.13 || >=16" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/node-gyp/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/node-gyp/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minipass": "^7.1.2" }, "engines": { - "node": "*" + "node": ">= 18" + } + }, + "node_modules/node-gyp/node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=18" } }, - "node_modules/node-gyp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", "dev": true, "license": "ISC", "dependencies": { - "glob": "^7.1.3" + "isexe": "^4.0.0" }, "bin": { - "rimraf": "bin.js" + "node-which": "bin/which.js" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/nopt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", - "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "abbrev": "^1.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-url": { @@ -5718,23 +4741,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -5836,29 +4842,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5937,6 +4920,26 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", @@ -5968,41 +4971,32 @@ "node": ">=10.4.0" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" }, "bin": { - "prebuild-install": "bin.js" + "postject": "dist/cli.js" }, "engines": { - "node": ">=10" + "node": ">=14.0.0" } }, "node_modules/proc-log": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-2.0.1.tgz", - "integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "dev": true, "license": "ISC", "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/process-nextick-args": { @@ -6010,8 +5004,7 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -6023,13 +5016,6 @@ "node": ">=0.4.0" } }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, - "license": "ISC" - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -6066,6 +5052,18 @@ "node": ">=6" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -6076,16 +5074,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -6117,27 +5105,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -6165,42 +5132,6 @@ "node": ">= 6" } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -6242,6 +5173,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -6452,17 +5393,10 @@ ], "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/sanitize-filename": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz", - "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", "dev": true, "license": "WTFPL OR ISC", "dependencies": { @@ -6523,13 +5457,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" - }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -6613,78 +5540,11 @@ "node": ">=4" } }, - "node_modules/shelljs/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" }, "node_modules/simple-update-notifier": { "version": "2.0.0", @@ -6722,47 +5582,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^9.0.5", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", - "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6798,20 +5617,8 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/ssri": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", - "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } + "license": "BSD-3-Clause", + "optional": true }, "node_modules/stat-mode": { "version": "1.0.0", @@ -6846,22 +5653,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6874,20 +5665,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -6900,15 +5677,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -6964,40 +5732,6 @@ "node": ">=10" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar/node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -7019,6 +5753,21 @@ "node": ">=10" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", @@ -7046,9 +5795,9 @@ } }, "node_modules/temp-file/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7058,6 +5807,21 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -7067,16 +5831,53 @@ "readable-stream": "3" } }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/tiny-typed-emitter": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -7136,18 +5937,6 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -7180,37 +5969,22 @@ "node": ">=14.17" } }, - "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "license": "MIT" - }, - "node_modules/unique-filename": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", - "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^3.0.0" - }, + "license": "MIT", + "optional": true, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=20.18.1" } }, - "node_modules/unique-slug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", - "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", @@ -7236,14 +6010,46 @@ "node": ">=8" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/unzipper/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", "dependencies": { - "punycode": "^2.1.0" + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, "node_modules/utf8-byte-length": { @@ -7259,22 +6065,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -7293,6 +6083,20 @@ "node": ">= 8" } }, + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7308,16 +6112,6 @@ "node": ">= 8" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -7335,25 +6129,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -7432,9 +6207,9 @@ "license": "ISC" }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { @@ -7482,68 +6257,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } } } } diff --git a/electron/package.json b/electron/package.json index 6c0e17bf..16480b90 100644 --- a/electron/package.json +++ b/electron/package.json @@ -22,6 +22,9 @@ ] }, "license": "GPL-3.0", + "engines": { + "node": ">=22.12.0" + }, "main": "build/src/index.js", "scripts": { "build": "tsc && electron-rebuild", @@ -49,13 +52,13 @@ "dependencies": { "@capacitor-community/electron": "^5.0.0", "@silentbot1/nat-api": "^0.4.9", - "adm-zip": "^0.5.16", - "better-sqlite3": "^12.8.0", + "adm-zip": "^0.6.0", + "better-sqlite3": "^13.0.3", "chokidar": "^4.0.3", "electron-is-dev": "~2.0.0", "electron-serve": "~1.1.0", "electron-unhandled": "~4.0.1", - "electron-updater": "^6.6.2", + "electron-updater": "^6.8.9", "electron-window-state": "^5.0.3", "extract-zip": "^2.0.1", "moment-timezone": "^0.5.48", @@ -66,11 +69,11 @@ }, "devDependencies": { "@electron/notarize": "^2.5.0", - "@electron/rebuild": "^3.7.2", + "@electron/rebuild": "^4.2.0", "@types/better-sqlite3": "^7.6.13", "@types/write-file-atomic": "^4.0.3", - "electron": "^32.3.1", - "electron-builder": "^25.1.8", + "electron": "^44.1.1", + "electron-builder": "^26.15.3", "shelljs": "^0.8.5", "typescript": "^5.0.4" }, diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index f43abcdb..1bc9c41e 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -1137,6 +1137,9 @@ def stop(self, timeout_seconds: float = 5.0) -> None: _REALTIME_PACKET_SEND_WORKERS = 4 _REALTIME_PACKET_SEND_MAX_PENDING = 128 _REALTIME_PACKET_SEND_MAX_QUEUE_WAIT_SECONDS = 0.05 +_QAPP_CLOSE_WRITE_WORKERS = 4 +_QAPP_CLOSE_WRITE_MAX_PENDING = 64 +_QAPP_CLOSE_WRITE_MAX_QUEUE_WAIT_SECONDS = 0.25 _control_packet_send_dispatcher = _BoundedKeyedSendDispatcher( "control-packet", _CONTROL_PACKET_SEND_WORKERS, @@ -1149,6 +1152,15 @@ def stop(self, timeout_seconds: float = 5.0) -> None: _REALTIME_PACKET_SEND_MAX_PENDING, _REALTIME_PACKET_SEND_MAX_QUEUE_WAIT_SECONDS, ) +_qapp_close_write_dispatcher = _BoundedKeyedSendDispatcher( + "qapp-close", + _QAPP_CLOSE_WRITE_WORKERS, + _QAPP_CLOSE_WRITE_MAX_PENDING, + _QAPP_CLOSE_WRITE_MAX_QUEUE_WAIT_SECONDS, +) +_qapp_close_coordinator_slots = threading.BoundedSemaphore( + _QAPP_CLOSE_WRITE_MAX_PENDING +) _rns_overlay_callback_pending_keys: set[str] = set() _rns_packet_dispatcher_missing_logged = False _latest_land_state_lock = threading.Lock() @@ -2219,16 +2231,19 @@ def _start_scheduler_workers() -> None: def _start_packet_send_dispatchers() -> None: _control_packet_send_dispatcher.start() _realtime_packet_send_dispatcher.start() + _qapp_close_write_dispatcher.start() log( "[presence_bridge] target=reticulum-packet-send-dispatcher started " f"control_workers={_CONTROL_PACKET_SEND_WORKERS} " - f"realtime_workers={_REALTIME_PACKET_SEND_WORKERS}" + f"realtime_workers={_REALTIME_PACKET_SEND_WORKERS} " + f"qapp_close_workers={_QAPP_CLOSE_WRITE_WORKERS}" ) def _stop_packet_send_dispatchers() -> None: _control_packet_send_dispatcher.stop() _realtime_packet_send_dispatcher.stop() + _qapp_close_write_dispatcher.stop() def _stop_scheduler_workers() -> None: @@ -26788,6 +26803,10 @@ def handle_send_group_audio_link_heartbeat(req_id: str, payload: Dict[str, Any]) _QAPP_RNS_MAX_QUEUE_BYTES = int(os.environ.get("QORTAL_QAPP_RNS_MAX_QUEUE_BYTES", 2 * 1024 * 1024)) _QAPP_RNS_IDLE_SECONDS = float(os.environ.get("QORTAL_QAPP_RNS_IDLE_SECONDS", 300)) _QAPP_RNS_ACK_TIMEOUT_SECONDS = float(os.environ.get("QORTAL_QAPP_RNS_ACK_TIMEOUT_SECONDS", 120)) +_QAPP_RNS_CLOSE_WRITE_TIMEOUT_SECONDS = max( + 0.1, + float(os.environ.get("QORTAL_QAPP_RNS_CLOSE_WRITE_TIMEOUT_SECONDS", 10.0)), +) _QAPP_RNS_KEEPALIVE_SECONDS = max( 15.0, float(os.environ.get("QORTAL_QAPP_RNS_KEEPALIVE_SECONDS", 60)), @@ -26826,6 +26845,70 @@ def _qapp_rns_write(entry: Dict[str, Any], frame: bytes) -> bool: return True +def _qapp_rns_teardown_failed_close( + entry: Dict[str, Any], + link: Any, + reason: str, +) -> None: + if link is None: + return + with _state_lock: + if entry.get("link") is not link: + return + _teardown_reticulum_link_bounded( + link, + f"target=qapp-rns close_write_failed reason={reason}", + ) + + +def _qapp_rns_schedule_close_write(entry: Dict[str, Any], frame: bytes) -> None: + manager_key = str(entry.get("managerKey") or "unknown") + link = entry.get("link") + if link is None: + return + if not _qapp_close_coordinator_slots.acquire(blocking=False): + log( + "[presence_bridge] target=qapp-rns close_write_rejected " + f"manager={manager_key} reason=coordinator_full" + ) + _qapp_rns_teardown_failed_close(entry, link, "coordinator_full") + return + + def coordinate() -> None: + try: + status, result, error = _qapp_close_write_dispatcher.submit_and_wait( + manager_key, + _QAPP_RNS_CLOSE_WRITE_TIMEOUT_SECONDS, + lambda: _qapp_rns_write(entry, frame), + ) + if status == "completed" and result is True and not error: + return + log( + "[presence_bridge] target=qapp-rns close_write_failed " + f"manager={manager_key} reason={status} " + f"timeout_ms={int(_QAPP_RNS_CLOSE_WRITE_TIMEOUT_SECONDS * 1000)}" + f"{f' err={error}' if error else ''}" + ) + _qapp_rns_teardown_failed_close(entry, link, status) + finally: + _qapp_close_coordinator_slots.release() + + thread = threading.Thread( + target=coordinate, + name=f"qapp-rns-close-{manager_key[-12:]}", + daemon=True, + ) + try: + thread.start() + except Exception: + _qapp_close_coordinator_slots.release() + _qapp_rns_teardown_failed_close( + entry, + link, + "coordinator_start_failed", + ) + + def _qapp_rns_emit_state(entry: Dict[str, Any], state: str, reason: str = "") -> None: for connection_id in list(entry.get("connections") or []): emit_event("qapp_rns", { @@ -27265,10 +27348,6 @@ def handle_qapp_rns_close(req_id: str, payload: Dict[str, Any]) -> None: "connectionId": connection_id, }, separators=(",", ":")).encode("utf-8") close_frame = _qapp_rns_frame(_QAPP_RNS_CONTROL, message_id, control) - try: - close_written = close_frame is None or _qapp_rns_write(entry, close_frame) - except Exception: - close_written = False with _state_lock: current = _qapp_rns_entries.get(manager_key) if current is entry and entry is not None: @@ -27277,16 +27356,15 @@ def handle_qapp_rns_close(req_id: str, payload: Dict[str, Any]) -> None: if not entry["connections"]: _qapp_rns_cancel_keepalive(entry) _qapp_rns_schedule_idle(entry) - if not close_written and entry is not None: - # If the stream cannot carry the logical CLOSE, closing the failed - # physical Link is the only way to guarantee backend cleanup. - link = entry.get("link") - if link is not None: - try: - link.teardown() - except Exception: - pass - emit_resp(req_id, True, payload={"state": "CLOSED"}) + try: + emit_resp(req_id, True, payload={"state": "CLOSED"}) + finally: + if close_frame is not None and entry is not None: + # Local ownership is already closed. Deliver the remote CLOSE in a + # bounded worker so a wedged Reticulum stream can never hold the + # lifecycle scheduler. Failure tears down only the same physical + # Link; a replacement Link created meanwhile is left untouched. + _qapp_rns_schedule_close_write(entry, close_frame) def handle_qapp_rns_request(req_id: str, payload: Dict[str, Any]) -> None: diff --git a/electron/resources/presence_bridge_test.py b/electron/resources/presence_bridge_test.py index 76e51522..a675903f 100644 --- a/electron/resources/presence_bridge_test.py +++ b/electron/resources/presence_bridge_test.py @@ -179,6 +179,7 @@ def test_keepalive_stops_without_logical_connections(self): def test_close_sends_logical_control_without_closing_other_connections(self): writes = [] + flushed = threading.Event() class Writer: def write(self, data): @@ -186,6 +187,7 @@ def write(self, data): return len(data) def flush(self): + flushed.set() return None entry = { @@ -213,6 +215,7 @@ def flush(self): "connectionId": "rns-one", }) + self.assertTrue(flushed.wait(0.5)) self.assertEqual(entry["connections"], {"rns-two"}) self.assertEqual(entry["next_message_id"], 42) self.assertEqual(len(writes), 1) @@ -260,19 +263,104 @@ def flush(self): } self.bridge._qapp_rns_entries[entry["managerKey"]] = entry try: + teardown_called = threading.Event() with mock.patch.object(self.bridge, "emit_resp"), mock.patch.object( self.bridge, "_qapp_rns_schedule_idle" - ): + ), mock.patch.object( + self.bridge, + "_qapp_rns_teardown_failed_close", + side_effect=lambda *_args: teardown_called.set(), + ) as teardown: self.bridge.handle_qapp_rns_close("request-2", { "managerKey": entry["managerKey"], "connectionId": "rns-one", }) - self.assertEqual(entry["connections"], set()) - entry["link"].teardown.assert_called_once_with() + self.assertEqual(entry["connections"], set()) + self.assertTrue(teardown_called.wait(0.5)) + teardown.assert_called_once_with(entry, entry["link"], "exception") + finally: + self.bridge._qapp_rns_entries.pop(entry["managerKey"], None) + + def test_close_releases_lifecycle_lane_when_control_write_stalls(self): + write_started = threading.Event() + release_write = threading.Event() + + def stalled_write(_entry, _frame): + write_started.set() + release_write.wait(1.0) + return True + + entry = { + "managerKey": "logical-close-stalled-test", + "destination": "00" * 16, + "connections": {"rns-one"}, + "established": True, + "writer": mock.Mock(), + "write_lock": threading.Lock(), + "last_used": 0, + "next_message_id": 61, + "keepalive_generation": 0, + "keepalive_timer": None, + "idle_generation": 0, + "idle_timer": None, + "link": mock.Mock(), + } + self.bridge._qapp_rns_entries[entry["managerKey"]] = entry + try: + teardown_called = threading.Event() + started_at = time.monotonic() + with mock.patch.object( + self.bridge, "_qapp_rns_write", side_effect=stalled_write + ), mock.patch.object( + self.bridge, "_QAPP_RNS_CLOSE_WRITE_TIMEOUT_SECONDS", 0.05 + ), mock.patch.object( + self.bridge, "emit_resp" + ) as emit_resp, mock.patch.object( + self.bridge, "_qapp_rns_schedule_idle" + ), mock.patch.object( + self.bridge, + "_qapp_rns_teardown_failed_close", + side_effect=lambda *_args: teardown_called.set(), + ) as teardown: + self.bridge.handle_qapp_rns_close("request-stalled", { + "managerKey": entry["managerKey"], + "connectionId": "rns-one", + }) + elapsed = time.monotonic() - started_at + + self.assertLess(elapsed, 0.1) + self.assertEqual(entry["connections"], set()) + emit_resp.assert_called_once_with( + "request-stalled", True, payload={"state": "CLOSED"} + ) + self.assertTrue(write_started.wait(0.5)) + self.assertTrue(teardown_called.wait(0.5)) + teardown.assert_called_once_with( + entry, + entry["link"], + "send_timeout", + ) finally: + release_write.set() self.bridge._qapp_rns_entries.pop(entry["managerKey"], None) + def test_failed_close_does_not_teardown_a_replacement_link(self): + stale_link = mock.Mock() + current_link = mock.Mock() + entry = {"link": current_link} + + with mock.patch.object( + self.bridge, "_teardown_reticulum_link_bounded" + ) as teardown: + self.bridge._qapp_rns_teardown_failed_close( + entry, + stale_link, + "send_timeout", + ) + + teardown.assert_not_called() + def test_rpc_response_callback_extracts_request_receipt_response(self): emitted = [] diff --git a/electron/src/embedded-frame-network-policy.test.ts b/electron/src/embedded-frame-network-policy.test.ts new file mode 100644 index 00000000..f7cffb6a --- /dev/null +++ b/electron/src/embedded-frame-network-policy.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { + EMBEDDED_FRAME_CONNECTION_ALLOWLIST, + withEmbeddedFrameWebRtcBlocked, +} from './embedded-frame-network-policy'; + +describe('embedded frame network policy', () => { + it('blocks WebRTC only on subframe document responses', () => { + expect( + withEmbeddedFrameWebRtcBlocked( + { Existing: ['value'] }, + { resourceType: 'subFrame' } + ) + ).toEqual({ + Existing: ['value'], + 'Connection-Allowlist': [EMBEDDED_FRAME_CONNECTION_ALLOWLIST], + }); + }); + + it('does not restrict the Hub main frame', () => { + expect( + withEmbeddedFrameWebRtcBlocked( + { Existing: ['value'] }, + { resourceType: 'mainFrame' } + ) + ).toEqual({ Existing: ['value'] }); + }); + + it('replaces a frame-provided allowlist case-insensitively', () => { + expect( + withEmbeddedFrameWebRtcBlocked( + { 'connection-allowlist': ['("https://example.com");webrtc=allow'] }, + { resourceType: 'subFrame' } + ) + ).toEqual({ + 'Connection-Allowlist': [EMBEDDED_FRAME_CONNECTION_ALLOWLIST], + }); + }); +}); diff --git a/electron/src/embedded-frame-network-policy.ts b/electron/src/embedded-frame-network-policy.ts new file mode 100644 index 00000000..4e4da347 --- /dev/null +++ b/electron/src/embedded-frame-network-policy.ts @@ -0,0 +1,28 @@ +type HeaderRequestDetails = { + resourceType?: string; +}; + +/** + * Keep ordinary frame networking available while preventing embedded content + * from establishing WebRTC peer connections or DataChannels. The Hub document + * is a mainFrame and therefore does not receive this policy. + */ +export const EMBEDDED_FRAME_CONNECTION_ALLOWLIST = + '("*://*:*/*");redirects=allow;webrtc=block'; + +export function withEmbeddedFrameWebRtcBlocked( + responseHeaders: Record, + details?: HeaderRequestDetails +): Record { + if (details?.resourceType !== 'subFrame') { + return { ...responseHeaders }; + } + + const next = Object.fromEntries( + Object.entries(responseHeaders).filter( + ([name]) => name.toLowerCase() !== 'connection-allowlist' + ) + ); + next['Connection-Allowlist'] = [EMBEDDED_FRAME_CONNECTION_ALLOWLIST]; + return next; +} diff --git a/electron/src/reticulum-chat-db.ts b/electron/src/reticulum-chat-db.ts index 3c31a20d..457b8662 100644 --- a/electron/src/reticulum-chat-db.ts +++ b/electron/src/reticulum-chat-db.ts @@ -13627,7 +13627,7 @@ export class ReticulumChatDatabase { now + 1, 1_000 ); - occurrence = recent.at(-1) ?? null; + occurrence = recent[recent.length - 1] ?? null; } if (!occurrence) return null; const creation = this.db diff --git a/electron/src/setup.ts b/electron/src/setup.ts index 68467d3b..54e616e6 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -167,6 +167,7 @@ import { buildAudioSurfaceUrl, withAudioSurfaceIsolationHeaders, } from './audio-window-policy'; +import { withEmbeddedFrameWebRtcBlocked } from './embedded-frame-network-policy'; import { ensureAudioSurfaceHttpsServer } from './audio-surface-https'; import { buildDefaultAudioSurfaceBridgeStateLike, @@ -1329,10 +1330,11 @@ export function setupContentSecurityPolicy(customScheme: string): void { // Determine if the request is cross-origin const isCrossOrigin = requestOrigin !== requestUrlOrigin; + const originalResponseHeaders = details.responseHeaders ?? {}; // Check if the response already includes Access-Control-Allow-Origin const hasAccessControlAllowOrigin = Object.keys( - details.responseHeaders + originalResponseHeaders ).some( (header) => header.toLowerCase() === 'access-control-allow-origin' ); @@ -1341,14 +1343,17 @@ export function setupContentSecurityPolicy(customScheme: string): void { // so only our permissive CSP is applied and qapps (e.g. extract7z) can use eval. const cspHeaderLower = 'content-security-policy'; const filtered = Object.fromEntries( - Object.entries(details.responseHeaders).filter( + Object.entries(originalResponseHeaders).filter( ([key]) => key.toLowerCase() !== cspHeaderLower ) ); - const responseHeaders: Record = { - ...filtered, - 'Content-Security-Policy': [csp], - }; + const responseHeaders = withEmbeddedFrameWebRtcBlocked( + { + ...filtered, + 'Content-Security-Policy': [csp], + }, + { resourceType: details.resourceType } + ); Object.assign( responseHeaders, diff --git a/package.json b/package.json index e4e5ad37..4915a43a 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "build": "vite build", + "build": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build", "coverage": "vitest run --coverage", "dev": "vite", "format": "prettier --write .", From 89f357373c1cf084567e089b1173ed97387cc75f Mon Sep 17 00:00:00 2001 From: PhilReact Date: Fri, 4 Sep 2026 23:25:30 +0300 Subject: [PATCH 10/32] fixes --- .../src/reticulum-resource.worker.test.ts | 16 ++++++++++++++ electron/src/reticulum-resource.worker.ts | 22 ++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/electron/src/reticulum-resource.worker.test.ts b/electron/src/reticulum-resource.worker.test.ts index 52784db7..4fd25328 100644 --- a/electron/src/reticulum-resource.worker.test.ts +++ b/electron/src/reticulum-resource.worker.test.ts @@ -7,6 +7,7 @@ import { deleteReticulumResourcePaths, finalizeReticulumResource, hashReticulumResourceFile, + readAndHashReticulumResourceFile, writeReticulumResourceRange, } from './reticulum-resource.worker'; @@ -43,6 +44,21 @@ describe('reticulum resource worker operations', () => { expect(fs.readFileSync(sourcePath)).toEqual(contents); }); + it('copies file bytes into a transferable array buffer', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reticulum-resource-worker-test-')); + directories.push(dir); + const sourcePath = path.join(dir, 'download.partial'); + const contents = Buffer.from('transferable worker resource'); + const expectedHash = nodeCrypto.createHash('sha256').update(contents).digest('hex'); + fs.writeFileSync(sourcePath, contents); + + const result = readAndHashReticulumResourceFile(sourcePath); + + expect(Buffer.isBuffer(result.bytes)).toBe(false); + expect(Buffer.from(result.bytes)).toEqual(contents); + expect(result.hash).toBe(expectedHash); + }); + it('moves a completed download into place without a second full-size copy', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reticulum-resource-worker-test-')); directories.push(dir); diff --git a/electron/src/reticulum-resource.worker.ts b/electron/src/reticulum-resource.worker.ts index 87a9f00f..70cbc998 100644 --- a/electron/src/reticulum-resource.worker.ts +++ b/electron/src/reticulum-resource.worker.ts @@ -101,6 +101,22 @@ export function hashReticulumResourceFile(filePath: string): string { return hash.digest('hex'); } +export function readAndHashReticulumResourceFile(filePath: string): { + bytes: Uint8Array; + hash: string; +} { + const fileBytes = fs.readFileSync(filePath); + // Node 24 marks buffers returned by fs.readFileSync() as non-transferable. + // Copy into a plain ArrayBuffer-backed view before using a worker transfer + // list so Electron 44 can hand the bytes back to the main process. + const bytes = new Uint8Array(fileBytes.length); + bytes.set(fileBytes); + return { + bytes, + hash: nodeCrypto.createHash('sha256').update(fileBytes).digest('hex'), + }; +} + export function finalizeReticulumResource( task: Extract ): string { @@ -201,16 +217,16 @@ parentPort?.on('message', (task: ReticulumResourceWorkerTask) => { return; } if (task.kind === 'read_and_hash_file') { - const bytes = fs.readFileSync(task.path); + const { bytes, hash } = readAndHashReticulumResourceFile(task.path); const result = { id: task.id, kind: task.kind, ok: true, bytes, - hash: nodeCrypto.createHash('sha256').update(bytes).digest('hex'), + hash, durationMs: Date.now() - startedAt, } satisfies ReticulumResourceWorkerResult; - parentPort?.postMessage(result, [bytes.buffer as ArrayBuffer]); + parentPort?.postMessage(result, [bytes.buffer]); return; } if (task.kind === 'write_range_file') { From b046fb550bbc5620b93ed5ddafb9c13dda378e11 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sat, 5 Sep 2026 17:57:50 +0300 Subject: [PATCH 11/32] fixes to linux build --- electron/scripts/linux-docker-entry.sh | 78 ++++++++++++++++++++++++ src/hooks/useQortalMessageListener.tsx | 40 +++++++----- src/messaging/MessagesToBackground.tsx | 5 +- src/qortal/get.ts | 7 ++- src/qortal/qortal-request-errors.test.ts | 30 +++++++++ src/qortal/qortal-request-errors.ts | 34 +++++++++++ src/qortal/qortal-requests.ts | 4 +- 7 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 src/qortal/qortal-request-errors.test.ts create mode 100644 src/qortal/qortal-request-errors.ts diff --git a/electron/scripts/linux-docker-entry.sh b/electron/scripts/linux-docker-entry.sh index 8bc8b200..e7f2089b 100755 --- a/electron/scripts/linux-docker-entry.sh +++ b/electron/scripts/linux-docker-entry.sh @@ -40,6 +40,68 @@ cd electron npm ci npm run build + +# better-sqlite3 13 ships platform prebuilds and loads them before its normal +# build/Release output. Those upstream Linux binaries may be linked against a +# newer glibc than this Debian 11 compatibility container. Force a real source +# build for Electron, then replace the preferred platform prebuild with it. +echo "Rebuilding better-sqlite3 from source for Linux ${BUILD_ARCH}…" +npm_config_force_build=1 npx electron-rebuild \ + --force \ + --build-from-source \ + --which-module better-sqlite3 \ + --arch "${BUILD_ARCH}" + +BETTER_SQLITE3_BUILD="node_modules/better-sqlite3/build/Release/better_sqlite3.node" +BETTER_SQLITE3_PREBUILD="node_modules/better-sqlite3/prebuilds/linux-${BUILD_ARCH}.node" + +if [[ ! -f "${BETTER_SQLITE3_BUILD}" ]]; then + echo "Forced better-sqlite3 build did not produce ${BETTER_SQLITE3_BUILD}" >&2 + exit 1 +fi + +install -m 0644 "${BETTER_SQLITE3_BUILD}" "${BETTER_SQLITE3_PREBUILD}" + +verify_glibc_baseline() { + local binary_path="$1" + local artifact_label="$2" + + python3 - "${binary_path}" "${artifact_label}" <<'PY' +import re +import subprocess +import sys + +binary_path, artifact_label = sys.argv[1:] +output = subprocess.check_output( + ["readelf", "--version-info", binary_path], + text=True, + stderr=subprocess.STDOUT, +) +versions = sorted( + {(int(major), int(minor)) for major, minor in re.findall(r"GLIBC_(\d+)\.(\d+)", output)} +) +newer = [version for version in versions if version > (2, 31)] +display = ", ".join(f"GLIBC_{major}.{minor}" for major, minor in versions) or "none" +print(f"{artifact_label} glibc symbols: {display}") +if newer: + required = ", ".join(f"GLIBC_{major}.{minor}" for major, minor in newer) + raise SystemExit( + f"{artifact_label} exceeds the Debian 11 glibc 2.31 baseline: {required}" + ) +PY +} + +verify_glibc_baseline "${BETTER_SQLITE3_PREBUILD}" "Rebuilt better-sqlite3" + +# Exercise the same prebuild path that better-sqlite3 selects at runtime. +node - <<'NODE' +const Database = require('better-sqlite3'); +const database = new Database(':memory:'); +const result = database.prepare('SELECT 1 AS ok').get(); +database.close(); +if (result?.ok !== 1) throw new Error('better-sqlite3 smoke test failed'); +NODE + npm run bundle:reticulum # Snap/rpm are painful inside Docker; AppImage + deb cover most sharing. @@ -52,4 +114,20 @@ npx electron-builder build \ --publish=never \ "${BUILDER_TARGETS[@]}" +case "${BUILD_ARCH}" in + x64) + UNPACKED_DIR="dist/linux-unpacked" + ;; + arm64) + UNPACKED_DIR="dist/linux-arm64-unpacked" + ;; +esac + +PACKAGED_BETTER_SQLITE3="${UNPACKED_DIR}/resources/app.asar.unpacked/${BETTER_SQLITE3_PREBUILD}" +if [[ ! -f "${PACKAGED_BETTER_SQLITE3}" ]]; then + echo "Packaged better-sqlite3 binary was not found at ${PACKAGED_BETTER_SQLITE3}" >&2 + exit 1 +fi +verify_glibc_baseline "${PACKAGED_BETTER_SQLITE3}" "Packaged better-sqlite3" + echo "Artifacts: electron/dist/ (installers + latest-linux.yml + *.blockmap for auto-update)" diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index af0e9610..68736dc1 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -21,6 +21,7 @@ import { dispatchQAppReticulumRequest, isQAppReticulumAction, } from '../qortal/qapp-reticulum-request'; +import { serializeQortalRequestError } from '../qortal/qortal-request-errors'; export const saveFileInChunks = async ( blob: Blob, @@ -735,12 +736,18 @@ export const useQortalMessageListener = ( result: null, error: { error: response?.error, + ...(typeof response?.code === 'string' + ? { code: response.code } + : {}), message: - typeof response?.error === 'string' - ? response?.error - : typeof response?.message === 'string' - ? response?.message - : 'An error has occurred', + typeof response?.code === 'string' && + typeof response?.message === 'string' + ? response?.message + : typeof response?.error === 'string' + ? response?.error + : typeof response?.message === 'string' + ? response?.message + : 'An error has occurred', }, }); } else { @@ -796,12 +803,18 @@ export const useQortalMessageListener = ( result: null, error: { error: response?.error, + ...(typeof response?.code === 'string' + ? { code: response.code } + : {}), message: - typeof response?.error === 'string' - ? response?.error - : typeof response?.message === 'string' - ? response?.message - : 'An error has occurred', + typeof response?.code === 'string' && + typeof response?.message === 'string' + ? response?.message + : typeof response?.error === 'string' + ? response?.error + : typeof response?.message === 'string' + ? response?.message + : 'An error has occurred', }, }); } else { @@ -813,14 +826,9 @@ export const useQortalMessageListener = ( }) .catch((error) => { console.error('Failed qortalRequest', error); - const requestError = - error?.code || error?.message || 'Request failed'; eventPort.postMessage({ result: null, - error: { - error: requestError, - message: requestError, - }, + error: serializeQortalRequestError(error), }); }); }; diff --git a/src/messaging/MessagesToBackground.tsx b/src/messaging/MessagesToBackground.tsx index 6780027a..adb112b0 100644 --- a/src/messaging/MessagesToBackground.tsx +++ b/src/messaging/MessagesToBackground.tsx @@ -18,7 +18,7 @@ window.addEventListener('message', (event) => { return; } - const { type, requestId, payload, error, message } = event.data; + const { type, requestId } = event.data; // Only process messages of type `backgroundMessageResponse` if (type !== 'backgroundMessageResponse') return; @@ -84,6 +84,9 @@ export const sendMessageBackground = async ( } else if (response_2?.error || response_2?.message) { return { error: response_2.error, + ...(typeof response_2?.code === 'string' + ? { code: response_2.code } + : {}), message: response_2?.message || response_2?.error || 'An error occurred', }; } diff --git a/src/qortal/get.ts b/src/qortal/get.ts index 5242d967..2c7d6cb9 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -47,6 +47,10 @@ import { uint8ArrayToObject, } from '../encryption/encryption.ts'; import { showSaveFilePicker } from '../hooks/useQortalMessageListener.tsx'; +import { + codedQortalRequestError, + USER_DECLINED_REQUEST_CODE, +} from './qortal-request-errors.ts'; import { getPublishesFromAdminsAdminSpace } from '../components/Chat/AdminSpaceInner.tsx'; import { extractComponents } from '../components/Chat/MessageDisplay.tsx'; import { @@ -5749,7 +5753,8 @@ export const sendCoin = async (data, isFromExtension) => { ); return makePayment.res?.data; } else { - throw new Error( + throw codedQortalRequestError( + USER_DECLINED_REQUEST_CODE, i18n.t('question:message.generic.user_declined_request', { postProcess: 'capitalizeFirstChar', }) diff --git a/src/qortal/qortal-request-errors.test.ts b/src/qortal/qortal-request-errors.test.ts new file mode 100644 index 00000000..7662ba61 --- /dev/null +++ b/src/qortal/qortal-request-errors.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { + codedQortalRequestError, + serializeQortalRequestError, + USER_DECLINED_REQUEST_CODE, +} from './qortal-request-errors'; + +describe('Q-App request errors', () => { + it('preserves a stable code separately from its localized message', () => { + const error = codedQortalRequestError( + USER_DECLINED_REQUEST_CODE, + 'Localized decline message' + ); + + expect(serializeQortalRequestError(error)).toEqual({ + error: USER_DECLINED_REQUEST_CODE, + code: USER_DECLINED_REQUEST_CODE, + message: 'Localized decline message', + }); + }); + + it('keeps uncoded failures backward compatible', () => { + expect( + serializeQortalRequestError(new Error('Network unavailable')) + ).toEqual({ + error: 'Network unavailable', + message: 'Network unavailable', + }); + }); +}); diff --git a/src/qortal/qortal-request-errors.ts b/src/qortal/qortal-request-errors.ts new file mode 100644 index 00000000..16842a6a --- /dev/null +++ b/src/qortal/qortal-request-errors.ts @@ -0,0 +1,34 @@ +export const USER_DECLINED_REQUEST_CODE = 'USER_DECLINED'; + +type CodedError = Error & { code?: string }; + +export function codedQortalRequestError( + code: string, + message: string +): CodedError { + const error = new Error(message) as CodedError; + error.code = code; + return error; +} + +export function serializeQortalRequestError(error: unknown): { + error: string; + code?: string; + message: string; +} { + const candidate = + typeof error === 'object' && error !== null + ? (error as { code?: unknown; message?: unknown }) + : undefined; + const code = + typeof candidate?.code === 'string' && candidate.code + ? candidate.code + : undefined; + const message = + typeof candidate?.message === 'string' && candidate.message + ? candidate.message + : typeof error === 'string' && error + ? error + : 'Request failed'; + return { error: code ?? message, ...(code ? { code } : {}), message }; +} diff --git a/src/qortal/qortal-requests.ts b/src/qortal/qortal-requests.ts index 64ffbb66..29cdc98d 100644 --- a/src/qortal/qortal-requests.ts +++ b/src/qortal/qortal-requests.ts @@ -86,6 +86,7 @@ import { removeNotificationSubscriptions, clearRnsDestinationPermissionsByTabId, } from './get.ts'; +import { serializeQortalRequestError } from './qortal-request-errors.ts'; import { triggerMemberGroupsFetch } from '../subscriptions/useInitializeMySubscriptions.ts'; import { getData, storeData } from '../utils/chromeStorage.ts'; import { executeEvent } from '../utils/events.ts'; @@ -1572,11 +1573,12 @@ function setupMessageListenerQortalRequest() { event.origin ); } catch (error) { + const serializedError = serializeQortalRequestError(error); event.source.postMessage( { requestId: request.requestId, action: request.action, - error: error.message, + ...serializedError, type: 'backgroundMessageResponse', }, event.origin From 28750e6b6eec8f1038f4ed79579115d2cccfef11 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sun, 6 Sep 2026 18:42:32 +0300 Subject: [PATCH 12/32] fixes to blocklist --- .../Chat/ChatGroup.blockIsolation.test.ts | 158 ++++++++++++++++++ src/components/Chat/ChatGroup.tsx | 31 +--- 2 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 src/components/Chat/ChatGroup.blockIsolation.test.ts diff --git a/src/components/Chat/ChatGroup.blockIsolation.test.ts b/src/components/Chat/ChatGroup.blockIsolation.test.ts new file mode 100644 index 00000000..65f804f1 --- /dev/null +++ b/src/components/Chat/ChatGroup.blockIsolation.test.ts @@ -0,0 +1,158 @@ +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; +import componentSource from './ChatGroup.tsx?raw'; +import { buildReticulumInitialHistoryState } from './reticulumInitialHistory'; +import { projectReticulumReactionReferences } from '../../utils/reticulumReactionProjection'; + +// Exercise the component's actual callback wiring without mounting its native +// transports, editors, and providers. Testing only the history reducer would +// miss this regression: it was the caller supplying the wrong exclusion policy. +const source = ts.createSourceFile( + 'ChatGroup.tsx', + componentSource, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX +); + +const findNodes = (predicate: (node: ts.Node) => boolean) => { + const found: ts.Node[] = []; + const visit = (node: ts.Node) => { + if (predicate(node)) found.push(node); + ts.forEachChild(node, visit); + }; + visit(source); + return found; +}; + +const evaluate = (node: ts.Node, scope: Record) => + runInNewContext( + ts.transpileModule(`(${node.getText(source)})`, { + compilerOptions: { target: ts.ScriptTarget.ES2020 }, + }).outputText, + scope + ); + +const callback = (name: string, scope: Record) => { + const declarations = findNodes( + (node) => + ts.isVariableDeclaration(node) && node.name.getText(source) === name + ) as ts.VariableDeclaration[]; + expect(declarations).toHaveLength(1); + const initializer = declarations[0].initializer as ts.CallExpression; + return evaluate(initializer.arguments[0], scope); +}; + +const parent = { + signature: 'parent', + eventType: 'message', + sender: 'PolarBear', + message: 'Original message', +}; +const reply = { + signature: 'reply', + eventType: 'message', + sender: 'Other', + message: 'Reply', + repliedTo: 'parent', +}; + +const visibility = (hidden = false) => ({ + // A node-wide legacy block must not even be consulted by Reticulum. + isChatSenderBlocked: vi.fn((item) => item?.sender === parent.sender), + isReticulumHiddenAuthor: (address: unknown) => + hidden && address === parent.sender, +}); + +describe('ChatGroup transport block isolation', () => { + it.each([false, true])( + 'uses only Reticulum hiding in initial and incremental history (hidden=%s)', + (hidden) => { + const predicates = findNodes( + (node) => + ts.isPropertyAssignment(node) && + node.name.getText(source) === 'shouldExclude' + ) as ts.PropertyAssignment[]; + expect(predicates).toHaveLength(2); + for (const predicate of predicates) { + const scope = visibility(hidden); + const shouldExclude = evaluate(predicate.initializer, scope); + const result = buildReticulumInitialHistoryState([parent, reply], { + shouldExclude, + }); + expect(result.messages.map((item) => item.signature)).toEqual( + hidden ? ['reply'] : ['parent', 'reply'] + ); + expect(scope.isChatSenderBlocked).not.toHaveBeenCalled(); + } + } + ); + + it.each([false, true])( + 'uses only Reticulum hiding for live messages (hidden=%s)', + (hidden) => { + const scope = visibility(hidden); + let messages = []; + const apply = callback('applyReticulumChatItem', { + ...scope, + setMessages: (update) => { + messages = update(messages); + }, + }); + apply(parent); + apply(reply); + expect(messages.map((item) => item.signature)).toEqual( + hidden ? ['reply'] : ['parent', 'reply'] + ); + expect(scope.isChatSenderBlocked).not.toHaveBeenCalled(); + } + ); + + it.each([false, true])( + 'uses only Reticulum hiding for reactions (hidden=%s)', + (hidden) => { + const scope = visibility(hidden); + const project = callback('reticulumReactionReferences', { + ...scope, + reticulumChatEnabled: true, + projectReticulumReactionReferences, + reticulumChatEvents: [ + { + eventId: 'reaction', + eventType: 'reaction_add', + authorAddress: parent.sender, + targetEventId: parent.signature, + timestamp: 1, + encryptedPayload: JSON.stringify({ content: '👍' }), + }, + ], + }); + const references = project(); + expect(Object.keys(references)).toEqual(hidden ? [] : ['parent']); + expect(scope.isChatSenderBlocked).not.toHaveBeenCalled(); + } + ); + + it.each([false, true])( + 'limits Core block-update events to legacy chat (Reticulum=%s)', + (reticulumChatEnabled) => { + const scope = visibility(); + let messages = [parent, reply]; + const onBlockUpdate = callback('updateChatMessagesWithBlocksFunc', { + ...scope, + reticulumChatEnabled, + setMessages: (update) => { + messages = update(messages); + }, + }); + onBlockUpdate({ detail: true }); + expect(messages.map((item) => item.signature)).toEqual( + reticulumChatEnabled ? ['parent', 'reply'] : ['reply'] + ); + expect(scope.isChatSenderBlocked).toHaveBeenCalledTimes( + reticulumChatEnabled ? 0 : 2 + ); + } + ); +}); diff --git a/src/components/Chat/ChatGroup.tsx b/src/components/Chat/ChatGroup.tsx index c0df9702..a011951b 100644 --- a/src/components/Chat/ChatGroup.tsx +++ b/src/components/Chat/ChatGroup.tsx @@ -2779,12 +2779,10 @@ export const ChatGroup = ({ return projectReticulumReactionReferences( reticulumChatEvents.filter( (event: any) => - !isChatSenderBlocked({ sender: event?.authorAddress }) && !isReticulumHiddenAuthor(event?.authorAddress) ) ); }, [ - isChatSenderBlocked, isReticulumHiddenAuthor, reticulumChatEnabled, reticulumChatEvents, @@ -3742,13 +3740,14 @@ export const ChatGroup = ({ const updateChatMessagesWithBlocksFunc = useCallback( (e) => { - if (e.detail) { + // Core block lists apply only to legacy Q-Chat. Reticulum uses silences. + if (e.detail && !reticulumChatEnabled) { setMessages((prev) => prev?.filter((item) => !isChatSenderBlocked(item)) ); } }, - [isChatSenderBlocked] + [isChatSenderBlocked, reticulumChatEnabled] ); useEffect(() => { @@ -4627,12 +4626,7 @@ export const ChatGroup = ({ const applyReticulumChatItem = useCallback( (item) => { - if ( - !item || - isChatSenderBlocked(item) || - isReticulumHiddenAuthor(item?.sender) - ) - return; + if (!item || isReticulumHiddenAuthor(item?.sender)) return; const targetReference = item.chatReference; const itemType = item?.eventType || item?.decryptedData?.type || item?.type; @@ -4687,11 +4681,7 @@ export const ChatGroup = ({ ? processWithNewMessages([item], reticulumChatQueueId) : [item]; const nextItem = processed?.[0] || item; - if ( - isChatSenderBlocked(nextItem) || - isReticulumHiddenAuthor(nextItem?.sender) - ) - return; + if (isReticulumHiddenAuthor(nextItem?.sender)) return; if ( targetReference && @@ -4779,12 +4769,7 @@ export const ChatGroup = ({ return [...prev, nextItem]; }); }, - [ - isChatSenderBlocked, - isReticulumHiddenAuthor, - processWithNewMessages, - reticulumChatQueueId, - ] + [isReticulumHiddenAuthor, processWithNewMessages, reticulumChatQueueId] ); const convertReticulumEventToChatItem = useCallback( @@ -5659,7 +5644,6 @@ export const ChatGroup = ({ conversionResults.map((result) => result.item), { shouldExclude: (item) => - isChatSenderBlocked(item) || isReticulumHiddenAuthor(item?.sender), reconcileItem: (item) => { const specialId = reticulumHistoryItemSpecialId(item); @@ -5763,7 +5747,7 @@ export const ChatGroup = ({ initialMessages: messages, initialChatReferences: chatReferences, shouldExclude: (item) => - isChatSenderBlocked(item) || isReticulumHiddenAuthor(item?.sender), + isReticulumHiddenAuthor(item?.sender), reconcileItem: (item) => { const specialId = reticulumHistoryItemSpecialId(item); const hasPendingOptimisticMatch = Boolean( @@ -5817,7 +5801,6 @@ export const ChatGroup = ({ }, [ chatReferences, convertReticulumEventToChatItem, - isChatSenderBlocked, isReticulumHiddenAuthor, messages, processWithNewMessages, From 511376c6a32d552b872656e34a2592ebf719a0b9 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Mon, 7 Sep 2026 21:32:03 +0300 Subject: [PATCH 13/32] test --- .gitignore | 3 + electron/electron-builder.config.arm.json | 5 + electron/electron-builder.config.json | 5 + electron/electron-builder.config.lin.json | 5 + electron/electron-builder.config.mac.json | 5 + electron/electron-builder.config.win.json | 5 + electron/native/private-transport/README.md | 82 +++ .../main.go | 289 +++++++++ .../main.go | 132 ++++ .../main.go | 184 ++++++ .../cmd/qortal-private-transport/main.go | 15 + electron/native/private-transport/go.mod | 18 + electron/native/private-transport/go.sum | 32 + .../internal/innerquic/framing.go | 121 ++++ .../internal/innerquic/framing_test.go | 35 ++ .../internal/innerquic/session.go | 271 ++++++++ .../internal/masque/client.go | 156 +++++ .../internal/protocol/server.go | 460 ++++++++++++++ .../internal/protocol/server_test.go | 84 +++ electron/package.json | 1 + electron/resources/presence_bridge.py | 187 ++++++ .../resources/private-transport/README.md | 19 + electron/scripts/afterPack.js | 21 + electron/scripts/build-private-transport.mjs | 57 ++ electron/src/index.ts | 6 + electron/src/masque-relay-discovery.test.ts | 76 +++ electron/src/masque-relay-discovery.ts | 133 ++++ electron/src/preload.ts | 48 ++ electron/src/private-channel-bootstrap.ts | 182 ++++++ electron/src/private-channel-manager.test.ts | 291 +++++++++ electron/src/private-channel-manager.ts | 538 ++++++++++++++++ electron/src/private-channel-step3.test.ts | 583 ++++++++++++++++++ electron/src/private-channel-step4.test.ts | 371 +++++++++++ .../src/private-transport-runtime.test.ts | 37 ++ electron/src/private-transport-runtime.ts | 134 ++++ .../src/private-transport-sidecar.test.ts | 235 +++++++ electron/src/private-transport-sidecar.ts | 483 +++++++++++++++ electron/src/qapp-reticulum-manager.test.ts | 30 + electron/src/qapp-reticulum-manager.ts | 37 ++ electron/src/quic-masque-transport.ts | 190 ++++++ electron/src/reticulum-bridge.ts | 51 ++ electron/src/setup.ts | 116 ++++ package.json | 1 + src/hooks/useQortalMessageListener.tsx | 95 ++- .../qapp-private-channel-request.test.ts | 108 ++++ src/qortal/qapp-private-channel-request.ts | 117 ++++ src/qortal/qapp-reticulum-request.test.ts | 11 +- src/qortal/qapp-reticulum-request.ts | 15 +- src/qortal/qortal-requests.ts | 1 + src/types/window.d.ts | 16 + 50 files changed, 6065 insertions(+), 32 deletions(-) create mode 100644 electron/native/private-transport/README.md create mode 100644 electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go create mode 100644 electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go create mode 100644 electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go create mode 100644 electron/native/private-transport/cmd/qortal-private-transport/main.go create mode 100644 electron/native/private-transport/go.mod create mode 100644 electron/native/private-transport/go.sum create mode 100644 electron/native/private-transport/internal/innerquic/framing.go create mode 100644 electron/native/private-transport/internal/innerquic/framing_test.go create mode 100644 electron/native/private-transport/internal/innerquic/session.go create mode 100644 electron/native/private-transport/internal/masque/client.go create mode 100644 electron/native/private-transport/internal/protocol/server.go create mode 100644 electron/native/private-transport/internal/protocol/server_test.go create mode 100644 electron/resources/private-transport/README.md create mode 100644 electron/scripts/build-private-transport.mjs create mode 100644 electron/src/masque-relay-discovery.test.ts create mode 100644 electron/src/masque-relay-discovery.ts create mode 100644 electron/src/private-channel-bootstrap.ts create mode 100644 electron/src/private-channel-manager.test.ts create mode 100644 electron/src/private-channel-manager.ts create mode 100644 electron/src/private-channel-step3.test.ts create mode 100644 electron/src/private-channel-step4.test.ts create mode 100644 electron/src/private-transport-runtime.test.ts create mode 100644 electron/src/private-transport-runtime.ts create mode 100644 electron/src/private-transport-sidecar.test.ts create mode 100644 electron/src/private-transport-sidecar.ts create mode 100644 electron/src/quic-masque-transport.ts create mode 100644 src/qortal/qapp-private-channel-request.test.ts create mode 100644 src/qortal/qapp-private-channel-request.ts diff --git a/.gitignore b/.gitignore index 8e41ebb3..cb08c34d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ lerna-debug.log* node_modules .emsdk native/libopus-fec-wasm/build +electron/resources/private-transport/*/qortal-private-transport +electron/resources/private-transport/*/qortal-private-transport.exe +electron/tools/masque-relay/ dist dist.zip dist-ssr diff --git a/electron/electron-builder.config.arm.json b/electron/electron-builder.config.arm.json index 05e72ff4..1e7a24db 100644 --- a/electron/electron-builder.config.arm.json +++ b/electron/electron-builder.config.arm.json @@ -6,6 +6,11 @@ "asar": true, "afterPack": "scripts/afterPack.js", "extraResources": [ + { + "from": "resources/private-transport", + "to": "private-transport", + "filter": ["**/qortal-private-transport"] + }, { "from": "resources/presence_bridge.py", "to": "reticulum/presence_bridge.py" diff --git a/electron/electron-builder.config.json b/electron/electron-builder.config.json index 4b7ac72a..9bcb25ba 100644 --- a/electron/electron-builder.config.json +++ b/electron/electron-builder.config.json @@ -6,6 +6,11 @@ "afterPack": "scripts/afterPack.js", "extraResources": [ + { + "from": "resources/private-transport", + "to": "private-transport", + "filter": ["**/qortal-private-transport", "**/qortal-private-transport.exe"] + }, { "from": "resources/presence_bridge.py", "to": "reticulum/presence_bridge.py" diff --git a/electron/electron-builder.config.lin.json b/electron/electron-builder.config.lin.json index 6381c66f..53919d5a 100644 --- a/electron/electron-builder.config.lin.json +++ b/electron/electron-builder.config.lin.json @@ -6,6 +6,11 @@ "asar": true, "afterPack": "scripts/afterPack.js", "extraResources": [ + { + "from": "resources/private-transport", + "to": "private-transport", + "filter": ["**/qortal-private-transport"] + }, { "from": "resources/presence_bridge.py", "to": "reticulum/presence_bridge.py" diff --git a/electron/electron-builder.config.mac.json b/electron/electron-builder.config.mac.json index 996eed23..af2eea7c 100644 --- a/electron/electron-builder.config.mac.json +++ b/electron/electron-builder.config.mac.json @@ -11,6 +11,11 @@ "afterPack": "scripts/afterPack.js", "extraResources": [ + { + "from": "resources/private-transport", + "to": "private-transport", + "filter": ["**/qortal-private-transport"] + }, { "from": "resources/presence_bridge.py", "to": "reticulum/presence_bridge.py" diff --git a/electron/electron-builder.config.win.json b/electron/electron-builder.config.win.json index 170ce0a8..f12aecac 100644 --- a/electron/electron-builder.config.win.json +++ b/electron/electron-builder.config.win.json @@ -5,6 +5,11 @@ "compression": "normal", "asar": true, "extraResources": [ + { + "from": "resources/private-transport", + "to": "private-transport", + "filter": ["**/qortal-private-transport.exe"] + }, { "from": "resources/presence_bridge.py", "to": "reticulum/presence_bridge.py" diff --git a/electron/native/private-transport/README.md b/electron/native/private-transport/README.md new file mode 100644 index 00000000..5787268f --- /dev/null +++ b/electron/native/private-transport/README.md @@ -0,0 +1,82 @@ +# Qortal private transport sidecar + +The sidecar carries authenticated inner QUIC through RFC 9298 CONNECT-UDP: + +``` +Electron -> sidecar -> outer QUIC/HTTP3 -> MASQUE -> inner QUIC -> backend +``` + +It opens no listening socket. `masque-go v0.4.0` supplies the CONNECT-UDP +`net.PacketConn`; `quic-go v0.60.0` dials inner QUIC directly over it. There is +no direct backend dial or fallback. + +## Authentication and bootstrap + +Electron obtains bootstrap version 1 through the authenticated reserved RNS +request `/qortal/private-transport/bootstrap/v1`. Electron binds that request +to the already-owned logical RNS connection in the internal request envelope. +Its descriptor contains the +logical session, authenticated RNS destination, literal backend endpoint, +backend certificate SHA-256 pin, short-lived single-use attach token, nonce, +owner binding hash, expiry, transport identifier, and supported features. The +backend derives the owner binding from its signed Q-App/application session; +the descriptor is never exposed to the Q-App. + +Both relay and backend TLS use pinned leaf-certificate DER hashes, logical +hostname verification, and validity-period checks. Public Web PKI is not +required. Inner QUIC uses ALPN `qortal-private/1`. + +After TLS, the client sends ATTACH with protocol version, logical session ID, +attach token, nonce, channel purpose, and owner binding. The backend must return +ATTACHED for the same logical session and transport generation 1. + +## Framing and IPC + +The reliable lane uses one persistent bidirectional QUIC stream: + +``` +magic[4] | version[1] | type[1] | metadataLength[2] | +payloadLength[4] | bounded JSON metadata | raw payload +``` + +Metadata is limited to 4 KiB and raw reliable payloads to 64 KiB. QUIC +DATAGRAMs use a distinct `QP3D` binary frame with protocol version, bounded +message ID, and at most 1024 application bytes. They are not emulated with +streams. + +Sidecar IPC version 2 is hybrid: NDJSON controls retain request IDs and coded +errors; a declared `binaryLength` is followed immediately by raw bytes. +Unsolicited reliable/datagram/error events use the same bounded sideband. This +removes base64 expansion from the Step 3 application data path. + +Inner QUIC uses a conservative 1200-byte initial packet size and disables PMTU +probing because the generic MASQUE PacketConn cannot expose UDP DF/OOB support. +CONNECT-UDP permits 1500-byte UDP payloads. Larger application datagrams fail +locally rather than being fragmented or converted to streams. + +Two congestion controllers are active: inner QUIC to the backend and outer +QUIC to the relay. No nested-congestion tuning is attempted. Metrics include +inner smoothed RTT, application bytes, datagram drops, stream errors, and +connection errors; endpoints are excluded. + +## Build and scope + +The module requires Go 1.25+ and was tested with Go 1.26.5. + +``` +QORTAL_GO_BINARY=/path/to/go npm run build:private-transport --prefix electron +QORTAL_GO_BINARY=/path/to/go npm run test:private-transport +``` + +The normal development MASQUE path uses the real RNS provider and the companion +`qapp-backend` implementation. Isolated Step 3 tests retain provider injection; +the Step 4 integration test invokes the real backend bootstrap/token service and +Python QUIC listener. Large-file framing and resumability remain out of scope. + +## Standalone community MASQUE relay + +The operator-run relay is maintained as the separate `qortal-masque-relay` +project. Hub contains only the client, Reticulum discovery, and private-channel +coordination code. `QORTAL_PRIVATE_TRANSPORT=masque` selects discovered relays; +`QORTAL_PRIVATE_TRANSPORT=masque-test` retains the explicit development +override. diff --git a/electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go b/electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go new file mode 100644 index 00000000..1a54b0c9 --- /dev/null +++ b/electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go @@ -0,0 +1,289 @@ +package main + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "encoding/pem" + "math/big" + "net" + "net/http" + "os" + "sync" + "time" + + masque "github.com/quic-go/masque-go" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" + "qortal.org/qortal-hub/private-transport/internal/innerquic" +) + +const ( + logicalSessionID = "logical-test-session" + attachToken = "single-use-test-attach-token-00000001" + nonce = "test-bootstrap-nonce-0000000000000001" + purpose = "realtime" + ownerBindingHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +type fixture struct { + mu sync.Mutex + backendUDP *net.UDPConn + backendListener *quic.Listener + relayUDP *net.UDPConn + relayServer *http3.Server + proxy masque.Proxy + backendAddress string + backendSource string + relayEgress string + reliableCount int + datagramCount int + tokenUsed bool + dropNextDatagram bool + connections map[*quic.Conn]struct{} +} + +func main() { + f, startup, err := startFixture() + if err != nil { + os.Exit(1) + } + defer f.close() + enc := json.NewEncoder(os.Stdout) + _ = enc.Encode(startup) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + var cmd struct { + Operation string `json:"operation"` + } + if json.Unmarshal(scanner.Bytes(), &cmd) != nil { + continue + } + switch cmd.Operation { + case "stats": + f.mu.Lock() + v := map[string]interface{}{"backendSource": f.backendSource, "relayEgress": f.relayEgress, "reliableCount": f.reliableCount, "datagramCount": f.datagramCount, "tokenUsed": f.tokenUsed} + f.mu.Unlock() + _ = enc.Encode(v) + case "stopRelay": + f.stopRelay() + _ = enc.Encode(map[string]bool{"relayStopped": true}) + case "stopBackend": + f.stopBackend() + _ = enc.Encode(map[string]bool{"backendStopped": true}) + case "dropNextDatagram": + f.mu.Lock() + f.dropNextDatagram = true + f.mu.Unlock() + _ = enc.Encode(map[string]bool{"willDrop": true}) + case "shutdown": + _ = enc.Encode(map[string]bool{"shuttingDown": true}) + return + } + } +} + +func startFixture() (*fixture, map[string]interface{}, error) { + backendCert, backendDER, err := certificate("qortal-test-backend") + if err != nil { + return nil, nil, err + } + backendUDP, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + return nil, nil, err + } + backendALPN := innerquic.ALPN + if os.Getenv("QORTAL_STEP3_TEST_WRONG_ALPN") == "1" { + backendALPN = "qortal-private/unsupported" + } + listener, err := quic.Listen(backendUDP, &tls.Config{Certificates: []tls.Certificate{backendCert}, NextProtos: []string{backendALPN}}, &quic.Config{EnableDatagrams: true, InitialPacketSize: innerquic.InnerPacketSize, DisablePathMTUDiscovery: true}) + if err != nil { + return nil, nil, err + } + f := &fixture{backendUDP: backendUDP, backendListener: listener, backendAddress: backendUDP.LocalAddr().String(), connections: make(map[*quic.Conn]struct{})} + go f.acceptBackend() + relayCert, relayDER, err := certificate("qortal-test-relay") + if err != nil { + f.close() + return nil, nil, err + } + relayUDP, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + f.close() + return nil, nil, err + } + f.relayUDP = relayUDP + relayAddress := relayUDP.LocalAddr().String() + template := uritemplate.MustNew("https://" + relayAddress + "/.well-known/masque/udp/{target_host}/{target_port}/") + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, r *http.Request) { + req, e := masque.ParseProxyRequest(r, template) + if e != nil || req.Target != f.backendAddress { + w.WriteHeader(http.StatusBadRequest) + return + } + target, e := net.ResolveUDPAddr("udp", f.backendAddress) + if e != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + egress, e := net.DialUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}, target) + if e != nil { + w.WriteHeader(http.StatusBadGateway) + return + } + f.mu.Lock() + f.relayEgress = egress.LocalAddr().String() + f.mu.Unlock() + _ = f.proxy.ProxyConnectedSocket(w, req, egress) + }) + f.relayServer = &http3.Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{relayCert}, NextProtos: []string{http3.NextProtoH3}}, QUICConfig: &quic.Config{EnableDatagrams: true}, EnableDatagrams: true, Handler: mux} + go func() { _ = f.relayServer.Serve(relayUDP) }() + relayPin := sha256.Sum256(relayDER) + backendPin := sha256.Sum256(backendDER) + return f, map[string]interface{}{"relayAddress": relayAddress, "relayServerName": "qortal-test-relay", "relayCertSha256": hex.EncodeToString(relayPin[:]), "backendAddress": f.backendAddress, "backendServerName": "qortal-test-backend", "backendCertSha256": hex.EncodeToString(backendPin[:]), "logicalSessionId": logicalSessionID, "attachToken": attachToken, "nonce": nonce, "purpose": purpose, "ownerBindingHash": ownerBindingHash, "expiresAt": time.Now().Add(time.Minute).UnixMilli(), "backendRnsDestination": "0123456789abcdef0123456789abcdef", "transport": "quic-masque-inner-v1"}, nil +} + +func (f *fixture) acceptBackend() { + for { + conn, err := f.backendListener.Accept(context.Background()) + if err != nil { + return + } + f.mu.Lock() + f.backendSource = conn.RemoteAddr().String() + f.connections[conn] = struct{}{} + f.mu.Unlock() + go f.handleBackend(conn) + } +} +func (f *fixture) handleBackend(conn *quic.Conn) { + defer func() { f.mu.Lock(); delete(f.connections, conn); f.mu.Unlock(); _ = conn.CloseWithError(0, "done") }() + stream, err := conn.AcceptStream(context.Background()) + if err != nil { + return + } + frame, err := innerquic.ReadFrame(stream) + if err != nil || frame.Type != innerquic.FrameAttach { + return + } + var attach struct { + ProtocolVersion int `json:"protocolVersion"` + LogicalSessionID string `json:"logicalSessionId"` + AttachToken string `json:"attachToken"` + Nonce string `json:"nonce"` + Purpose string `json:"purpose"` + OwnerBindingHash string `json:"ownerBindingHash"` + } + _ = json.Unmarshal(frame.Metadata, &attach) + f.mu.Lock() + valid := attach.ProtocolVersion == innerquic.ProtocolVersion && attach.LogicalSessionID == logicalSessionID && attach.AttachToken == attachToken && attach.Nonce == nonce && attach.Purpose == purpose && attach.OwnerBindingHash == ownerBindingHash && !f.tokenUsed + if valid { + f.tokenUsed = true + } + f.mu.Unlock() + response := map[string]interface{}{"ok": valid, "logicalSessionId": logicalSessionID, "transportGeneration": 1, "reliable": true, "datagrams": true} + if !valid { + response["code"] = "ATTACH_TOKEN_REJECTED" + } + metadata, _ := innerquic.Metadata(response) + _ = innerquic.WriteFrame(stream, innerquic.Frame{Type: innerquic.FrameAttached, Metadata: metadata}) + if !valid { + _ = stream.Close() + time.Sleep(20 * time.Millisecond) + return + } + go f.echoDatagrams(conn) + for { + message, err := innerquic.ReadFrame(stream) + if err != nil { + return + } + if message.Type != innerquic.FrameReliable { + return + } + f.mu.Lock() + f.reliableCount++ + f.mu.Unlock() + if innerquic.WriteFrame(stream, message) != nil { + return + } + } +} +func (f *fixture) echoDatagrams(conn *quic.Conn) { + for { + data, err := conn.ReceiveDatagram(context.Background()) + if err != nil { + return + } + if _, _, err := innerquic.DecodeDatagram(data); err != nil { + continue + } + f.mu.Lock() + f.datagramCount++ + drop := f.dropNextDatagram + f.dropNextDatagram = false + f.mu.Unlock() + if drop { + continue + } + _ = conn.SendDatagram(data) + } +} +func (f *fixture) stopRelay() { + if f.relayServer != nil { + _ = f.relayServer.Close() + f.relayServer = nil + } + if f.relayUDP != nil { + _ = f.relayUDP.Close() + f.relayUDP = nil + } +} +func (f *fixture) stopBackend() { + f.mu.Lock() + connections := make([]*quic.Conn, 0, len(f.connections)) + for conn := range f.connections { + connections = append(connections, conn) + } + f.mu.Unlock() + for _, conn := range connections { + _ = conn.CloseWithError(2, "backend stopped") + } + if f.backendListener != nil { + _ = f.backendListener.Close() + f.backendListener = nil + } + if f.backendUDP != nil { + _ = f.backendUDP.Close() + f.backendUDP = nil + } +} +func (f *fixture) close() { f.stopRelay(); _ = f.proxy.Close(); f.stopBackend() } +func certificate(name string) (tls.Certificate, []byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, nil, err + } + now := time.Now() + template := &x509.Certificate{SerialNumber: big.NewInt(now.UnixNano()), Subject: pkix.Name{CommonName: name}, DNSNames: []string{name}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, nil, err + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return tls.Certificate{}, nil, err + } + cert, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})) + return cert, der, err +} diff --git a/electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go b/electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go new file mode 100644 index 00000000..9171f1dc --- /dev/null +++ b/electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "math/big" + "net" + "net/http" + "os" + "sync" + "time" + + masque "github.com/quic-go/masque-go" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" +) + +type relay struct { + mu sync.Mutex + server *http3.Server + proxy masque.Proxy + udp *net.UDPConn + target string + lastEgress string +} + +func main() { + target := os.Getenv("QORTAL_STEP4_BACKEND_ADDRESS") + parsedTarget, err := net.ResolveUDPAddr("udp", target) + if err != nil || parsedTarget.Port == 0 || parsedTarget.IP == nil { + panic("QORTAL_STEP4_BACKEND_ADDRESS must be a literal UDP endpoint") + } + certificate, der, err := testCertificate("qortal-step4-relay") + if err != nil { + panic(err) + } + udp, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + panic(err) + } + r := &relay{udp: udp, target: parsedTarget.String()} + template := uritemplate.MustNew("https://" + udp.LocalAddr().String() + "/.well-known/masque/udp/{target_host}/{target_port}/") + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, request *http.Request) { + proxyRequest, parseErr := masque.ParseProxyRequest(request, template) + if parseErr != nil || proxyRequest.Target != r.target { + w.WriteHeader(http.StatusBadRequest) + return + } + egress, dialErr := net.DialUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}, parsedTarget) + if dialErr != nil { + w.WriteHeader(http.StatusBadGateway) + return + } + r.mu.Lock() + r.lastEgress = egress.LocalAddr().String() + r.mu.Unlock() + _ = r.proxy.ProxyConnectedSocket(w, proxyRequest, egress) + }) + r.server = &http3.Server{ + TLSConfig: &tls.Config{Certificates: []tls.Certificate{certificate}, NextProtos: []string{http3.NextProtoH3}}, + QUICConfig: &quic.Config{EnableDatagrams: true}, + EnableDatagrams: true, + Handler: mux, + } + go func() { _ = r.server.Serve(udp) }() + pin := sha256.Sum256(der) + write(map[string]interface{}{ + "relayAddress": udp.LocalAddr().String(), + "relayServerName": "qortal-step4-relay", + "relayCertSha256": hex.EncodeToString(pin[:]), + }) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + var command struct { + Operation string `json:"operation"` + } + _ = json.Unmarshal(scanner.Bytes(), &command) + switch command.Operation { + case "stats": + r.mu.Lock() + write(map[string]string{"relayEgress": r.lastEgress}) + r.mu.Unlock() + case "shutdown": + r.close() + write(map[string]bool{"closed": true}) + return + default: + write(map[string]string{"error": "unknown operation"}) + } + } + r.close() +} + +func (r *relay) close() { + _ = r.server.Close() + _ = r.proxy.Close() + _ = r.udp.Close() +} + +func write(value interface{}) { _ = json.NewEncoder(os.Stdout).Encode(value) } + +func testCertificate(name string) (tls.Certificate, []byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, nil, err + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(now.UnixNano()), + Subject: pkix.Name{CommonName: name}, + DNSNames: []string{name}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, nil, err + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, der, nil +} diff --git a/electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go b/electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go new file mode 100644 index 00000000..e5548b93 --- /dev/null +++ b/electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go @@ -0,0 +1,184 @@ +package main + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "encoding/pem" + "math/big" + "net" + "net/http" + "os" + "sync" + "time" + + masque "github.com/quic-go/masque-go" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" +) + +type fixture struct { + mu sync.Mutex + echoConn *net.UDPConn + relayConn *net.UDPConn + server *http3.Server + proxy masque.Proxy + targetAddress string + relayEgress string + echoSource string + echoCount int +} + +func main() { + f, startup, err := startFixture() + if err != nil { + os.Exit(1) + } + defer f.close() + encoder := json.NewEncoder(os.Stdout) + _ = encoder.Encode(startup) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + var command struct { + Operation string `json:"operation"` + } + if json.Unmarshal(scanner.Bytes(), &command) != nil { + continue + } + switch command.Operation { + case "stats": + f.mu.Lock() + response := map[string]interface{}{"echoCount": f.echoCount, "echoSource": f.echoSource, "relayEgress": f.relayEgress} + f.mu.Unlock() + _ = encoder.Encode(response) + case "stopRelay": + f.stopRelay() + _ = encoder.Encode(map[string]bool{"relayStopped": true}) + case "shutdown": + _ = encoder.Encode(map[string]bool{"shuttingDown": true}) + return + } + } +} + +func startFixture() (*fixture, map[string]string, error) { + echoConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + return nil, nil, err + } + f := &fixture{echoConn: echoConn, targetAddress: echoConn.LocalAddr().String()} + go f.echoLoop() + + cert, der, err := testCertificate() + if err != nil { + f.close() + return nil, nil, err + } + relayConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + f.close() + return nil, nil, err + } + f.relayConn = relayConn + relayAddress := relayConn.LocalAddr().String() + template := uritemplate.MustNew("https://" + relayAddress + "/.well-known/masque/udp/{target_host}/{target_port}/") + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, r *http.Request) { + req, parseErr := masque.ParseProxyRequest(r, template) + if parseErr != nil || req.Target != f.targetAddress { + w.WriteHeader(http.StatusBadRequest) + return + } + target, resolveErr := net.ResolveUDPAddr("udp", f.targetAddress) + if resolveErr != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + egress, dialErr := net.DialUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}, target) + if dialErr != nil { + w.WriteHeader(http.StatusBadGateway) + return + } + f.mu.Lock() + f.relayEgress = egress.LocalAddr().String() + f.mu.Unlock() + _ = f.proxy.ProxyConnectedSocket(w, req, egress) + }) + f.server = &http3.Server{ + TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{http3.NextProtoH3}}, + QUICConfig: &quic.Config{EnableDatagrams: true}, EnableDatagrams: true, Handler: mux, + } + go func() { _ = f.server.Serve(relayConn) }() + pin := sha256.Sum256(der) + return f, map[string]string{ + "relayAddress": relayAddress, "relayServerName": "qortal-test-relay", + "relayCertSha256": hex.EncodeToString(pin[:]), "targetAddress": f.targetAddress, + }, nil +} + +func (f *fixture) echoLoop() { + buf := make([]byte, 1500) + for { + n, addr, err := f.echoConn.ReadFromUDP(buf) + if err != nil { + return + } + f.mu.Lock() + f.echoCount++ + f.echoSource = addr.String() + f.mu.Unlock() + _, _ = f.echoConn.WriteToUDP(buf[:n], addr) + } +} + +func (f *fixture) stopRelay() { + if f.server != nil { + _ = f.server.Close() + f.server = nil + } + if f.relayConn != nil { + _ = f.relayConn.Close() + f.relayConn = nil + } +} +func (f *fixture) close() { + f.stopRelay() + _ = f.proxy.Close() + if f.echoConn != nil { + _ = f.echoConn.Close() + } +} + +func testCertificate() (tls.Certificate, []byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, nil, err + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "qortal-test-relay"}, + DNSNames: []string{"qortal-test-relay"}, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, nil, err + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return tls.Certificate{}, nil, err + } + cert, err := tls.X509KeyPair( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), + ) + return cert, der, err +} diff --git a/electron/native/private-transport/cmd/qortal-private-transport/main.go b/electron/native/private-transport/cmd/qortal-private-transport/main.go new file mode 100644 index 00000000..1cfdafdf --- /dev/null +++ b/electron/native/private-transport/cmd/qortal-private-transport/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "context" + "os" + + "qortal.org/qortal-hub/private-transport/internal/protocol" +) + +func main() { + server := protocol.NewServer() + if err := server.Serve(context.Background(), os.Stdin, os.Stdout); err != nil { + os.Exit(1) + } +} diff --git a/electron/native/private-transport/go.mod b/electron/native/private-transport/go.mod new file mode 100644 index 00000000..88fdbeab --- /dev/null +++ b/electron/native/private-transport/go.mod @@ -0,0 +1,18 @@ +module qortal.org/qortal-hub/private-transport + +go 1.25.0 + +require ( + github.com/quic-go/masque-go v0.4.0 + github.com/quic-go/quic-go v0.60.0 + github.com/yosida95/uritemplate/v3 v3.0.2 +) + +require ( + github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/electron/native/private-transport/go.sum b/electron/native/private-transport/go.sum new file mode 100644 index 00000000..900bff1d --- /dev/null +++ b/electron/native/private-transport/go.sum @@ -0,0 +1,32 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/masque-go v0.4.0 h1:7elD7yLRK2+84IUWtrfxqzJVu3o+6wNQzpOpBjlCGcU= +github.com/quic-go/masque-go v0.4.0/go.mod h1:4B6icFXeaiql1M0dFUDjs7mxHuFYt79L43cxq5PEd5M= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/electron/native/private-transport/internal/innerquic/framing.go b/electron/native/private-transport/internal/innerquic/framing.go new file mode 100644 index 00000000..1f0437a3 --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/framing.go @@ -0,0 +1,121 @@ +package innerquic + +import ( + "encoding/binary" + "encoding/json" + "errors" + "io" +) + +const ( + ALPN = "qortal-private/1" + ProtocolVersion = 1 + MaxReliablePayloadBytes = 64 * 1024 + MaxMetadataBytes = 4 * 1024 + MaxDatagramPayloadBytes = 1024 + InnerPacketSize = 1200 + + FrameAttach byte = 1 + FrameAttached byte = 2 + FrameReliable byte = 3 +) + +var streamMagic = [4]byte{'Q', 'P', '3', 'F'} +var datagramMagic = [4]byte{'Q', 'P', '3', 'D'} + +type Frame struct { + Type byte + Metadata []byte + Payload []byte +} + +func WriteFrame(w io.Writer, frame Frame) error { + if len(frame.Metadata) > MaxMetadataBytes || len(frame.Payload) > MaxReliablePayloadBytes { + return errors.New("inner frame exceeds limit") + } + header := make([]byte, 12) + copy(header[:4], streamMagic[:]) + header[4] = ProtocolVersion + header[5] = frame.Type + binary.BigEndian.PutUint16(header[6:8], uint16(len(frame.Metadata))) + binary.BigEndian.PutUint32(header[8:12], uint32(len(frame.Payload))) + if err := writeFull(w, header); err != nil { + return err + } + if err := writeFull(w, frame.Metadata); err != nil { + return err + } + return writeFull(w, frame.Payload) +} + +func writeFull(w io.Writer, data []byte) error { + for len(data) > 0 { + n, err := w.Write(data) + if err != nil { + return err + } + if n <= 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} + +func ReadFrame(r io.Reader) (Frame, error) { + header := make([]byte, 12) + if _, err := io.ReadFull(r, header); err != nil { + return Frame{}, err + } + if string(header[:4]) != string(streamMagic[:]) { + return Frame{}, errors.New("invalid inner frame magic") + } + if header[4] != ProtocolVersion { + return Frame{}, errors.New("unsupported inner protocol version") + } + metadataLen := int(binary.BigEndian.Uint16(header[6:8])) + payloadLen := int(binary.BigEndian.Uint32(header[8:12])) + if metadataLen > MaxMetadataBytes || payloadLen > MaxReliablePayloadBytes { + return Frame{}, errors.New("inner frame exceeds limit") + } + frame := Frame{Type: header[5], Metadata: make([]byte, metadataLen), Payload: make([]byte, payloadLen)} + if _, err := io.ReadFull(r, frame.Metadata); err != nil { + return Frame{}, err + } + if _, err := io.ReadFull(r, frame.Payload); err != nil { + return Frame{}, err + } + return frame, nil +} + +func Metadata(value interface{}) ([]byte, error) { + data, err := json.Marshal(value) + if err != nil || len(data) > MaxMetadataBytes { + return nil, errors.New("invalid frame metadata") + } + return data, nil +} + +func EncodeDatagram(messageID string, payload []byte) ([]byte, error) { + if len(messageID) == 0 || len(messageID) > 128 || len(payload) > MaxDatagramPayloadBytes { + return nil, errors.New("invalid inner datagram") + } + result := make([]byte, 7+len(messageID)+len(payload)) + copy(result[:4], datagramMagic[:]) + result[4] = ProtocolVersion + binary.BigEndian.PutUint16(result[5:7], uint16(len(messageID))) + copy(result[7:], messageID) + copy(result[7+len(messageID):], payload) + return result, nil +} + +func DecodeDatagram(data []byte) (string, []byte, error) { + if len(data) < 7 || string(data[:4]) != string(datagramMagic[:]) || data[4] != ProtocolVersion { + return "", nil, errors.New("invalid inner datagram") + } + idLen := int(binary.BigEndian.Uint16(data[5:7])) + if idLen < 1 || idLen > 128 || 7+idLen > len(data) || len(data)-(7+idLen) > MaxDatagramPayloadBytes { + return "", nil, errors.New("invalid inner datagram lengths") + } + return string(data[7 : 7+idLen]), append([]byte(nil), data[7+idLen:]...), nil +} diff --git a/electron/native/private-transport/internal/innerquic/framing_test.go b/electron/native/private-transport/internal/innerquic/framing_test.go new file mode 100644 index 00000000..a41bd8bd --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/framing_test.go @@ -0,0 +1,35 @@ +package innerquic + +import ( + "bytes" + "testing" +) + +func TestReliableFramePreservesMetadataAndBinaryPayload(t *testing.T) { + var wire bytes.Buffer + want := Frame{Type: FrameReliable, Metadata: []byte(`{"messageId":"m1"}`), Payload: []byte{0, 1, 2, 255}} + if err := WriteFrame(&wire, want); err != nil { + t.Fatal(err) + } + got, err := ReadFrame(&wire) + if err != nil { + t.Fatal(err) + } + if got.Type != want.Type || !bytes.Equal(got.Metadata, want.Metadata) || !bytes.Equal(got.Payload, want.Payload) { + t.Fatalf("frame changed: %#v", got) + } +} + +func TestFramesRejectOversizeAndProtocolMismatch(t *testing.T) { + if err := WriteFrame(&bytes.Buffer{}, Frame{Payload: make([]byte, MaxReliablePayloadBytes+1)}); err == nil { + t.Fatal("oversized frame accepted") + } + wire, err := EncodeDatagram("m", []byte("ok")) + if err != nil { + t.Fatal(err) + } + wire[4]++ + if _, _, err := DecodeDatagram(wire); err == nil { + t.Fatal("unsupported datagram version accepted") + } +} diff --git a/electron/native/private-transport/internal/innerquic/session.go b/electron/native/private-transport/internal/innerquic/session.go new file mode 100644 index 00000000..3280a1dc --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/session.go @@ -0,0 +1,271 @@ +package innerquic + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/quic-go/quic-go" + masqueclient "qortal.org/qortal-hub/private-transport/internal/masque" +) + +type Config struct { + Relay masqueclient.Config + BackendServerName string + BackendCertSHA256 string + LogicalSessionID string + AttachToken string + Nonce string + Purpose string + OwnerBindingHash string + Timeout time.Duration +} + +type Event struct { + Kind, MessageID, Code string + Data []byte +} +type Metrics struct { + InnerRTTMillis int64 `json:"innerRttMillis"` + BytesSent uint64 `json:"bytesSent"` + BytesReceived uint64 `json:"bytesReceived"` + DatagramsDropped uint64 `json:"datagramsDropped"` + StreamErrors uint64 `json:"streamErrors"` + ConnectionErrors uint64 `json:"connectionErrors"` +} + +type Session struct { + tunnel *masqueclient.Tunnel + conn *quic.Conn + stream *quic.Stream + onEvent func(Event) + writeMu sync.Mutex + closed atomic.Bool + appSent atomic.Uint64 + appReceived atomic.Uint64 + datagramsDropped atomic.Uint64 + streamErrors atomic.Uint64 + connectionErrors atomic.Uint64 +} + +type attachMetadata struct { + ProtocolVersion int `json:"protocolVersion"` + LogicalSessionID string `json:"logicalSessionId"` + AttachToken string `json:"attachToken"` + Nonce string `json:"nonce"` + Purpose string `json:"purpose"` + OwnerBindingHash string `json:"ownerBindingHash"` +} +type attachedMetadata struct { + OK bool `json:"ok"` + LogicalSessionID string `json:"logicalSessionId"` + TransportGeneration int `json:"transportGeneration"` + Reliable bool `json:"reliable"` + Datagrams bool `json:"datagrams"` + Code string `json:"code,omitempty"` +} +type messageMetadata struct { + MessageID string `json:"messageId"` +} + +func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error) { + pin, err := hex.DecodeString(cfg.BackendCertSHA256) + if err != nil || len(pin) != sha256.Size { + return nil, errors.New("invalid backend certificate pin") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 8 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + tunnel, err := masqueclient.Open(ctx, cfg.Relay) + if err != nil { + return nil, fmt.Errorf("MASQUE_TUNNEL_FAILED: %w", err) + } + fail := func(err error) (*Session, error) { _ = tunnel.Close(); return nil, err } + tlsConf := &tls.Config{ + MinVersion: tls.VersionTLS13, NextProtos: []string{ALPN}, ServerName: cfg.BackendServerName, + InsecureSkipVerify: true, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return errors.New("backend certificate missing") + } + leaf := state.PeerCertificates[0] + if now := time.Now(); now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) { + return errors.New("backend certificate expired") + } + if err := leaf.VerifyHostname(cfg.BackendServerName); err != nil { + return errors.New("backend certificate name mismatch") + } + observed := sha256.Sum256(leaf.Raw) + if subtle.ConstantTimeCompare(observed[:], pin) != 1 { + return errors.New("backend certificate pin mismatch") + } + return nil + }, + } + if tunnel.RemoteAddr() == nil { + return fail(errors.New("INNER_QUIC_FAILED: missing proxied backend address")) + } + conn, err := quic.Dial(ctx, tunnel.PacketConn(), tunnel.RemoteAddr(), tlsConf, &quic.Config{ + EnableDatagrams: true, InitialPacketSize: InnerPacketSize, DisablePathMTUDiscovery: true, + }) + if err != nil { + return fail(fmt.Errorf("INNER_QUIC_FAILED: %w", err)) + } + stream, err := conn.OpenStreamSync(ctx) + if err != nil { + _ = conn.CloseWithError(1, "attach failed") + _ = tunnel.Close() + return nil, fmt.Errorf("SESSION_ATTACH_FAILED: %w", err) + } + metadata, err := Metadata(attachMetadata{ProtocolVersion: ProtocolVersion, LogicalSessionID: cfg.LogicalSessionID, AttachToken: cfg.AttachToken, Nonce: cfg.Nonce, Purpose: cfg.Purpose, OwnerBindingHash: cfg.OwnerBindingHash}) + if err != nil { + _ = conn.CloseWithError(1, "attach failed") + _ = tunnel.Close() + return nil, fmt.Errorf("SESSION_ATTACH_FAILED: %w", err) + } + if err := WriteFrame(stream, Frame{Type: FrameAttach, Metadata: metadata}); err != nil { + _ = conn.CloseWithError(1, "attach failed") + _ = tunnel.Close() + return nil, fmt.Errorf("SESSION_ATTACH_FAILED: %w", err) + } + response, err := ReadFrame(stream) + if err != nil || response.Type != FrameAttached { + _ = conn.CloseWithError(1, "attach failed") + _ = tunnel.Close() + return nil, errors.New("SESSION_ATTACH_FAILED") + } + var attached attachedMetadata + if json.Unmarshal(response.Metadata, &attached) != nil || !attached.OK || attached.LogicalSessionID != cfg.LogicalSessionID || attached.TransportGeneration != 1 { + _ = conn.CloseWithError(1, "attach rejected") + _ = tunnel.Close() + if attached.Code == "ATTACH_TOKEN_REJECTED" { + return nil, errors.New("ATTACH_TOKEN_REJECTED") + } + return nil, errors.New("SESSION_ATTACH_FAILED") + } + if !conn.ConnectionState().SupportsDatagrams.Remote { + _ = conn.CloseWithError(1, "datagrams required") + _ = tunnel.Close() + return nil, errors.New("DATAGRAM_UNSUPPORTED") + } + s := &Session{tunnel: tunnel, conn: conn, stream: stream, onEvent: onEvent} + go s.readReliable() + go s.readDatagrams() + go s.watchConnection() + return s, nil +} + +func (s *Session) SendReliable(messageID string, data []byte) error { + if s.closed.Load() { + return errors.New("TRANSPORT_CLOSED") + } + if len(messageID) == 0 || len(messageID) > 128 || len(data) > MaxReliablePayloadBytes { + return errors.New("FRAME_TOO_LARGE") + } + metadata, _ := Metadata(messageMetadata{MessageID: messageID}) + s.writeMu.Lock() + err := WriteFrame(s.stream, Frame{Type: FrameReliable, Metadata: metadata, Payload: data}) + s.writeMu.Unlock() + if err == nil { + s.appSent.Add(uint64(len(data))) + } else { + s.streamErrors.Add(1) + } + return err +} + +func (s *Session) SendDatagram(messageID string, data []byte) error { + if s.closed.Load() { + return errors.New("TRANSPORT_CLOSED") + } + encoded, err := EncodeDatagram(messageID, data) + if err != nil { + return err + } + if err = s.conn.SendDatagram(encoded); err != nil { + s.datagramsDropped.Add(1) + return err + } + s.appSent.Add(uint64(len(data))) + return nil +} + +func (s *Session) Metrics() Metrics { + stats := s.conn.ConnectionStats() + return Metrics{InnerRTTMillis: stats.SmoothedRTT.Milliseconds(), BytesSent: s.appSent.Load(), BytesReceived: s.appReceived.Load(), DatagramsDropped: s.datagramsDropped.Load(), StreamErrors: s.streamErrors.Load(), ConnectionErrors: s.connectionErrors.Load()} +} + +func (s *Session) Close() error { + if !s.closed.CompareAndSwap(false, true) { + return nil + } + connErr := s.conn.CloseWithError(0, "closed") + // The inner CONNECTION_CLOSE must traverse CONNECT-UDP before its packet + // transport is torn down. A short grace avoids racing the outer tunnel close. + time.Sleep(50 * time.Millisecond) + return errors.Join(connErr, s.tunnel.Close()) +} + +func (s *Session) readReliable() { + for { + frame, err := ReadFrame(s.stream) + if err != nil { + if !s.closed.Load() { + s.streamErrors.Add(1) + s.emit(Event{Kind: "error", Code: "STREAM_ERROR"}) + } + return + } + if frame.Type != FrameReliable { + s.streamErrors.Add(1) + s.emit(Event{Kind: "error", Code: "PROTOCOL_MISMATCH"}) + return + } + var metadata messageMetadata + if json.Unmarshal(frame.Metadata, &metadata) != nil || metadata.MessageID == "" { + s.streamErrors.Add(1) + s.emit(Event{Kind: "error", Code: "PROTOCOL_MISMATCH"}) + return + } + s.appReceived.Add(uint64(len(frame.Payload))) + s.emit(Event{Kind: "reliableMessage", MessageID: metadata.MessageID, Data: frame.Payload}) + } +} +func (s *Session) readDatagrams() { + for { + data, err := s.conn.ReceiveDatagram(context.Background()) + if err != nil { + return + } + id, payload, err := DecodeDatagram(data) + if err != nil { + s.datagramsDropped.Add(1) + continue + } + s.appReceived.Add(uint64(len(payload))) + s.emit(Event{Kind: "datagram", MessageID: id, Data: payload}) + } +} +func (s *Session) watchConnection() { + <-s.conn.Context().Done() + if !s.closed.Load() { + s.connectionErrors.Add(1) + s.emit(Event{Kind: "error", Code: "INNER_QUIC_FAILED"}) + } +} +func (s *Session) emit(event Event) { + if s.onEvent != nil { + s.onEvent(event) + } +} diff --git a/electron/native/private-transport/internal/masque/client.go b/electron/native/private-transport/internal/masque/client.go new file mode 100644 index 00000000..b7c8017c --- /dev/null +++ b/electron/native/private-transport/internal/masque/client.go @@ -0,0 +1,156 @@ +package masqueclient + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "encoding/hex" + "errors" + "fmt" + "net" + "net/netip" + "time" + + masque "github.com/quic-go/masque-go" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" +) + +const MaxDatagramBytes = 1200 + +type Config struct { + RelayAddress string + RelayServerName string + RelayCertSHA256 string + TargetAddress string + Timeout time.Duration +} + +type Tunnel struct { + conn net.PacketConn +} + +// PacketConn is the only packet path exposed to the inner QUIC transport. +func (t *Tunnel) PacketConn() net.PacketConn { return t.conn } + +// RemoteAddr is the logical backend endpoint represented by CONNECT-UDP. +func (t *Tunnel) RemoteAddr() net.Addr { + type remoteAddr interface{ RemoteAddr() net.Addr } + if conn, ok := t.conn.(remoteAddr); ok { + return conn.RemoteAddr() + } + return nil +} + +func Open(ctx context.Context, cfg Config) (*Tunnel, error) { + relay, err := parseLiteralAddrPort("relayAddress", cfg.RelayAddress) + if err != nil { + return nil, err + } + target, err := parseLiteralAddrPort("targetAddress", cfg.TargetAddress) + if err != nil { + return nil, err + } + if cfg.RelayServerName == "" { + return nil, errors.New("relayServerName is required") + } + pin, err := hex.DecodeString(cfg.RelayCertSHA256) + if err != nil || len(pin) != sha256.Size { + return nil, errors.New("relayCertSha256 must be a 64-character hexadecimal SHA-256 hash") + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 5 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + relayAddress := relay.String() + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + NextProtos: []string{http3.NextProtoH3}, + ServerName: cfg.RelayServerName, + InsecureSkipVerify: true, // Standard verification is replaced by the mandatory leaf pin below. + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return errors.New("relay did not present a certificate") + } + leaf := state.PeerCertificates[0] + if now := time.Now(); now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) { + return errors.New("relay certificate is outside its validity period") + } + if err := leaf.VerifyHostname(cfg.RelayServerName); err != nil { + return errors.New("relay certificate identity mismatch") + } + observed := sha256.Sum256(leaf.Raw) + if subtle.ConstantTimeCompare(observed[:], pin) != 1 { + return errors.New("relay certificate pin mismatch") + } + return nil + }, + } + + proxyTemplate := uritemplate.MustNew(fmt.Sprintf( + "https://%s/.well-known/masque/udp/{target_host}/{target_port}/", + relayAddress, + )) + req, err := masque.NewRequest(ctx, proxyTemplate, target.String()) + if err != nil { + return nil, fmt.Errorf("create CONNECT-UDP request: %w", err) + } + transport := masque.Transport{ + TLSClientConfig: tlsConfig, + QUICConfig: &quic.Config{ + EnableDatagrams: true, + }, + // Pin the network destination as well as its certificate identity. This + // prevents a library change from resolving or substituting a hostname. + DialAddr: func(dialCtx context.Context, addr string, tlsConf *tls.Config, quicConf *quic.Config) (*quic.Conn, error) { + if addr != relayAddress { + return nil, fmt.Errorf("unexpected relay address %q", addr) + } + return quic.DialAddr(dialCtx, relayAddress, tlsConf, quicConf) + }, + } + conn, _, err := transport.Dial(req) + if err != nil { + return nil, fmt.Errorf("open CONNECT-UDP tunnel: %w", err) + } + return &Tunnel{conn: conn}, nil +} + +func parseLiteralAddrPort(name, value string) (netip.AddrPort, error) { + addr, err := netip.ParseAddrPort(value) + if err != nil || !addr.Addr().IsValid() || addr.Port() == 0 { + return netip.AddrPort{}, fmt.Errorf("%s must be a literal IP address and non-zero port", name) + } + return addr, nil +} + +func (t *Tunnel) Send(data []byte) error { + if len(data) == 0 || len(data) > MaxDatagramBytes { + return fmt.Errorf("datagram must contain 1..%d bytes", MaxDatagramBytes) + } + _, err := t.conn.WriteTo(data, nil) + return err +} + +func (t *Tunnel) Receive(timeout time.Duration) ([]byte, error) { + if timeout <= 0 { + timeout = 5 * time.Second + } + if err := t.conn.SetReadDeadline(time.Now().Add(timeout)); err != nil { + return nil, err + } + buf := make([]byte, 1500) + n, _, err := t.conn.ReadFrom(buf) + if err != nil { + return nil, err + } + return append([]byte(nil), buf[:n]...), nil +} + +func (t *Tunnel) Close() error { return t.conn.Close() } diff --git a/electron/native/private-transport/internal/protocol/server.go b/electron/native/private-transport/internal/protocol/server.go new file mode 100644 index 00000000..b04a7703 --- /dev/null +++ b/electron/native/private-transport/internal/protocol/server.go @@ -0,0 +1,460 @@ +package protocol + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "strings" + "sync" + "time" + + "qortal.org/qortal-hub/private-transport/internal/innerquic" + masqueclient "qortal.org/qortal-hub/private-transport/internal/masque" +) + +const ( + Version = 2 + SidecarVersion = "0.2.0" + MaxControlMessageBytes = 64 * 1024 + MaxBinaryMessageBytes = innerquic.MaxReliablePayloadBytes + maxRememberedRequestIDs = 4096 +) + +type Request struct { + Version int `json:"version"` + RequestID string `json:"requestId"` + Operation string `json:"operation"` + Params json.RawMessage `json:"params,omitempty"` + BinaryLength int `json:"binaryLength,omitempty"` +} +type Error struct { + Code string `json:"code"` + Message string `json:"message"` +} +type Response struct { + Version int `json:"version"` + Type string `json:"type"` + RequestID string `json:"requestId"` + OK bool `json:"ok"` + Result interface{} `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} +type Event struct { + Version int `json:"version"` + Type string `json:"type"` + Event string `json:"event"` + SessionID string `json:"sessionId"` + MessageID string `json:"messageId,omitempty"` + Code string `json:"code,omitempty"` + BinaryLength int `json:"binaryLength,omitempty"` +} + +type Server struct { + mu sync.Mutex + tunnels map[string]*masqueclient.Tunnel + sessions map[string]*innerquic.Session + seen map[string]struct{} + seenIDs []string + writeMu sync.Mutex + emit func(Event, []byte) +} + +func NewServer() *Server { + return &Server{tunnels: map[string]*masqueclient.Tunnel{}, sessions: map[string]*innerquic.Session{}, seen: map[string]struct{}{}} +} + +func (s *Server) Serve(ctx context.Context, input io.Reader, output io.Writer) error { + reader := bufio.NewReaderSize(input, 4096) + s.emit = func(event Event, binary []byte) { s.write(output, event, binary) } + defer s.Close() + for { + line, err := readControlLine(reader) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + s.write(output, failure("", "CONTROL_MESSAGE_TOO_LARGE", "control message exceeds limit"), nil) + return nil + } + if len(bytes.TrimSpace(line)) == 0 { + continue + } + var req Request + if json.Unmarshal(line, &req) != nil { + s.write(output, failure("", "MALFORMED_JSON", "invalid JSON request"), nil) + continue + } + if req.BinaryLength < 0 || req.BinaryLength > MaxBinaryMessageBytes { + s.write(output, failure(req.RequestID, "BINARY_FRAME_TOO_LARGE", "binary frame exceeds limit"), nil) + return nil + } + binary := make([]byte, req.BinaryLength) + if _, err := io.ReadFull(reader, binary); err != nil { + s.write(output, failure(req.RequestID, "MALFORMED_BINARY_FRAME", "truncated binary frame"), nil) + return nil + } + response, shutdown := s.handleRequest(ctx, req, binary) + s.write(output, response, nil) + if shutdown { + return nil + } + } +} + +func readControlLine(reader *bufio.Reader) ([]byte, error) { + var line []byte + for { + part, prefix, err := reader.ReadLine() + if err != nil { + return nil, err + } + if len(line)+len(part) > MaxControlMessageBytes { + return nil, errors.New("control too large") + } + line = append(line, part...) + if !prefix { + return line, nil + } + } +} +func (s *Server) write(output io.Writer, value interface{}, binary []byte) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + encoded, err := json.Marshal(value) + if err != nil { + return + } + if writeAll(output, append(encoded, '\n')) != nil { + return + } + if len(binary) > 0 { + _ = writeAll(output, binary) + } +} + +func writeAll(output io.Writer, data []byte) error { + for len(data) > 0 { + n, err := output.Write(data) + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + data = data[n:] + } + return nil +} + +func (s *Server) Handle(ctx context.Context, line []byte) (Response, bool) { + var req Request + if json.Unmarshal(line, &req) != nil { + return failure("", "MALFORMED_JSON", "invalid JSON request"), false + } + return s.handleRequest(ctx, req, nil) +} + +func (s *Server) handleRequest(ctx context.Context, req Request, binary []byte) (Response, bool) { + if req.RequestID == "" { + return failure("", "MISSING_REQUEST_ID", "requestId is required"), false + } + if req.Version != Version { + return failure(req.RequestID, "UNSUPPORTED_VERSION", "unsupported protocol version"), false + } + if s.rememberRequestID(req.RequestID) { + return failure(req.RequestID, "DUPLICATE_REQUEST_ID", "requestId was already used"), false + } + switch req.Operation { + case "health": + return success(req.RequestID, map[string]interface{}{"service": "qortal-private-transport", "sidecarVersion": SidecarVersion, "protocolVersion": Version, "innerAlpn": innerquic.ALPN}), false + case "openMasqueTunnel": + return s.openTunnel(ctx, req), false + case "sendDatagram": + return s.sendTunnel(req), false + case "receiveDatagram": + return s.receiveTunnel(req), false + case "closeTunnel": + return s.closeTunnel(req), false + case "openPrivateSession": + return s.openPrivateSession(ctx, req), false + case "sendPrivateReliable": + return s.sendPrivate(req, binary, true), false + case "sendPrivateDatagram": + return s.sendPrivate(req, binary, false), false + case "sessionMetrics": + return s.sessionMetrics(req), false + case "closePrivateSession": + return s.closePrivateSession(req), false + case "shutdown": + return success(req.RequestID, map[string]bool{"shuttingDown": true}), true + default: + return failure(req.RequestID, "UNKNOWN_OPERATION", "unsupported operation"), false + } +} + +type privateOpenParams struct { + RelayAddress string `json:"relayAddress"` + RelayServerName string `json:"relayServerName"` + RelayCertSHA256 string `json:"relayCertSha256"` + BackendAddress string `json:"backendAddress"` + BackendServerName string `json:"backendServerName"` + BackendCertSHA256 string `json:"backendCertSha256"` + LogicalSessionID string `json:"logicalSessionId"` + AttachToken string `json:"attachToken"` + Nonce string `json:"nonce"` + Purpose string `json:"purpose"` + OwnerBindingHash string `json:"ownerBindingHash"` + TimeoutMS int `json:"timeoutMs"` +} + +func (s *Server) openPrivateSession(ctx context.Context, req Request) Response { + var p privateOpenParams + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + sessionID, err := randomID("session-") + if err != nil { + return failure(req.RequestID, "INTERNAL_ERROR", "failed to allocate session ID") + } + session, err := innerquic.Open(ctx, innerquic.Config{Relay: masqueclient.Config{RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, RelayCertSHA256: p.RelayCertSHA256, TargetAddress: p.BackendAddress, Timeout: duration(p.TimeoutMS)}, BackendServerName: p.BackendServerName, BackendCertSHA256: p.BackendCertSHA256, LogicalSessionID: p.LogicalSessionID, AttachToken: p.AttachToken, Nonce: p.Nonce, Purpose: p.Purpose, OwnerBindingHash: p.OwnerBindingHash, Timeout: duration(p.TimeoutMS)}, func(e innerquic.Event) { s.emitInner(sessionID, e) }) + if err != nil { + return failure(req.RequestID, innerErrorCode(err), "private session establishment failed") + } + s.mu.Lock() + s.sessions[sessionID] = session + s.mu.Unlock() + innerID, _ := randomID("inner-") + return success(req.RequestID, map[string]interface{}{"sessionId": sessionID, "innerQuicConnectionId": innerID, "logicalSessionId": p.LogicalSessionID, "transportGeneration": 1}) +} +func innerErrorCode(err error) string { + text := err.Error() + for _, code := range []string{"ATTACH_TOKEN_REJECTED", "DATAGRAM_UNSUPPORTED", "SESSION_ATTACH_FAILED", "MASQUE_TUNNEL_FAILED"} { + if strings.Contains(text, code) { + return code + } + } + if strings.Contains(text, "certificate") { + return "BACKEND_IDENTITY_MISMATCH" + } + if strings.Contains(text, "INNER_QUIC_FAILED") { + return "INNER_QUIC_FAILED" + } + return "INNER_QUIC_FAILED" +} +func (s *Server) emitInner(sessionID string, e innerquic.Event) { + if s.emit == nil { + return + } + s.emit(Event{Version: Version, Type: "event", Event: e.Kind, SessionID: sessionID, MessageID: e.MessageID, Code: e.Code, BinaryLength: len(e.Data)}, e.Data) +} +func (s *Server) sendPrivate(req Request, binary []byte, reliable bool) Response { + var p struct { + SessionID string `json:"sessionId"` + MessageID string `json:"messageId"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + if len(binary) == 0 { + return failure(req.RequestID, "INVALID_BINARY_FRAME", "binary payload required") + } + session := s.getSession(p.SessionID) + if session == nil { + return failure(req.RequestID, "TRANSPORT_CLOSED", "session does not exist") + } + var err error + if reliable { + err = session.SendReliable(p.MessageID, binary) + } else { + err = session.SendDatagram(p.MessageID, binary) + } + if err != nil { + code := "TRANSPORT_SEND_FAILED" + if strings.Contains(err.Error(), "large") || strings.Contains(err.Error(), "invalid") { + code = "FRAME_TOO_LARGE" + } + return failure(req.RequestID, code, "transport send failed") + } + return success(req.RequestID, map[string]interface{}{"accepted": true, "bytesSent": len(binary)}) +} +func (s *Server) sessionMetrics(req Request) Response { + var p struct { + SessionID string `json:"sessionId"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + session := s.getSession(p.SessionID) + if session == nil { + return failure(req.RequestID, "TRANSPORT_CLOSED", "session does not exist") + } + return success(req.RequestID, session.Metrics()) +} +func (s *Server) closePrivateSession(req Request) Response { + var p struct { + SessionID string `json:"sessionId"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + s.mu.Lock() + session := s.sessions[p.SessionID] + delete(s.sessions, p.SessionID) + s.mu.Unlock() + if session == nil { + return failure(req.RequestID, "TRANSPORT_CLOSED", "session does not exist") + } + _ = session.Close() + return success(req.RequestID, map[string]bool{"closed": true}) +} +func (s *Server) getSession(id string) *innerquic.Session { + s.mu.Lock() + defer s.mu.Unlock() + return s.sessions[id] +} + +// Step 2 tunnel operations remain for regression tests. +func (s *Server) openTunnel(ctx context.Context, req Request) Response { + var p struct { + RelayAddress string `json:"relayAddress"` + RelayServerName string `json:"relayServerName"` + RelayCertSHA256 string `json:"relayCertSha256"` + TargetAddress string `json:"targetAddress"` + TimeoutMS int `json:"timeoutMs"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + t, err := masqueclient.Open(ctx, masqueclient.Config{RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, RelayCertSHA256: p.RelayCertSHA256, TargetAddress: p.TargetAddress, Timeout: duration(p.TimeoutMS)}) + if err != nil { + return failure(req.RequestID, "MASQUE_OPEN_FAILED", "failed to open authenticated MASQUE tunnel") + } + id, _ := randomID("tunnel-") + s.mu.Lock() + s.tunnels[id] = t + s.mu.Unlock() + return success(req.RequestID, map[string]string{"tunnelId": id}) +} +func (s *Server) sendTunnel(req Request) Response { + var p struct { + TunnelID string `json:"tunnelId"` + DataBase64 string `json:"dataBase64"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + data, err := base64.StdEncoding.DecodeString(p.DataBase64) + if err != nil || len(data) == 0 || len(data) > masqueclient.MaxDatagramBytes { + return failure(req.RequestID, "INVALID_DATAGRAM", "invalid or oversized datagram") + } + t := s.getTunnel(p.TunnelID) + if t == nil { + return failure(req.RequestID, "UNKNOWN_TUNNEL", "tunnel does not exist") + } + if t.Send(data) != nil { + return failure(req.RequestID, "DATAGRAM_SEND_FAILED", "failed to send datagram") + } + return success(req.RequestID, map[string]int{"bytesSent": len(data)}) +} +func (s *Server) receiveTunnel(req Request) Response { + var p struct { + TunnelID string `json:"tunnelId"` + TimeoutMS int `json:"timeoutMs"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + t := s.getTunnel(p.TunnelID) + if t == nil { + return failure(req.RequestID, "UNKNOWN_TUNNEL", "tunnel does not exist") + } + data, err := t.Receive(duration(p.TimeoutMS)) + if err != nil { + return failure(req.RequestID, "DATAGRAM_RECEIVE_FAILED", "failed to receive datagram") + } + return success(req.RequestID, map[string]string{"dataBase64": base64.StdEncoding.EncodeToString(data)}) +} +func (s *Server) closeTunnel(req Request) Response { + var p struct { + TunnelID string `json:"tunnelId"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + s.mu.Lock() + t := s.tunnels[p.TunnelID] + delete(s.tunnels, p.TunnelID) + s.mu.Unlock() + if t == nil { + return failure(req.RequestID, "UNKNOWN_TUNNEL", "tunnel does not exist") + } + _ = t.Close() + return success(req.RequestID, map[string]bool{"closed": true}) +} +func (s *Server) getTunnel(id string) *masqueclient.Tunnel { + s.mu.Lock() + defer s.mu.Unlock() + return s.tunnels[id] +} +func (s *Server) Close() { + s.mu.Lock() + ts := s.tunnels + ss := s.sessions + s.tunnels = map[string]*masqueclient.Tunnel{} + s.sessions = map[string]*innerquic.Session{} + s.mu.Unlock() + for _, x := range ss { + _ = x.Close() + } + for _, x := range ts { + _ = x.Close() + } +} +func (s *Server) rememberRequestID(id string) bool { + if _, ok := s.seen[id]; ok { + return true + } + if len(s.seenIDs) == maxRememberedRequestIDs { + delete(s.seen, s.seenIDs[0]) + s.seenIDs = s.seenIDs[1:] + } + s.seen[id] = struct{}{} + s.seenIDs = append(s.seenIDs, id) + return false +} +func decodeParams(raw json.RawMessage, target interface{}) error { + if len(raw) == 0 { + return errors.New("params required") + } + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if d.Decode(target) != nil { + return errors.New("invalid params") + } + return nil +} +func duration(ms int) time.Duration { + if ms < 1 || ms > 30_000 { + return 8 * time.Second + } + return time.Duration(ms) * time.Millisecond +} +func randomID(prefix string) (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return prefix + hex.EncodeToString(b), nil +} +func success(id string, result interface{}) Response { + return Response{Version: Version, Type: "response", RequestID: id, OK: true, Result: result} +} +func failure(id, code, message string) Response { + return Response{Version: Version, Type: "response", RequestID: id, OK: false, Error: &Error{Code: code, Message: message}} +} diff --git a/electron/native/private-transport/internal/protocol/server_test.go b/electron/native/private-transport/internal/protocol/server_test.go new file mode 100644 index 00000000..de4c9ed5 --- /dev/null +++ b/electron/native/private-transport/internal/protocol/server_test.go @@ -0,0 +1,84 @@ +package protocol + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestMalformedAndUnsupportedRequestsFailSafely(t *testing.T) { + tests := []struct { + name string + line string + code string + }{ + {"malformed JSON", "{", "MALFORMED_JSON"}, + {"missing request ID", `{"version":2,"operation":"health"}`, "MISSING_REQUEST_ID"}, + {"unsupported version", `{"version":1,"requestId":"v1","operation":"health"}`, "UNSUPPORTED_VERSION"}, + {"unknown operation", `{"version":2,"requestId":"unknown","operation":"exec"}`, "UNKNOWN_OPERATION"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + response, shutdown := NewServer().Handle(context.Background(), []byte(tt.line)) + if shutdown || response.OK || response.Error == nil || response.Error.Code != tt.code { + t.Fatalf("unexpected response: %#v", response) + } + }) + } +} + +func TestInnerErrorCodeDistinguishesRelayAndBackendCertificates(t *testing.T) { + if got := innerErrorCode(errors.New("MASQUE_TUNNEL_FAILED: relay certificate pin mismatch")); got != "MASQUE_TUNNEL_FAILED" { + t.Fatalf("relay error mapped to %q", got) + } + if got := innerErrorCode(errors.New("INNER_QUIC_FAILED: backend certificate pin mismatch")); got != "BACKEND_IDENTITY_MISMATCH" { + t.Fatalf("backend error mapped to %q", got) + } +} + +func TestDuplicateRequestIDFails(t *testing.T) { + server := NewServer() + request := []byte(`{"version":2,"requestId":"same","operation":"health"}`) + first, _ := server.Handle(context.Background(), request) + second, _ := server.Handle(context.Background(), request) + if !first.OK || second.OK || second.Error == nil || second.Error.Code != "DUPLICATE_REQUEST_ID" { + t.Fatalf("unexpected responses: first=%#v second=%#v", first, second) + } +} + +func TestOversizedControlMessageFailsWithoutPanic(t *testing.T) { + input := strings.NewReader(strings.Repeat("x", MaxControlMessageBytes+1) + "\n") + var output bytes.Buffer + if err := NewServer().Serve(context.Background(), input, &output); err != nil { + t.Fatal(err) + } + var response Response + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &response); err != nil { + t.Fatal(err) + } + if response.OK || response.Error == nil || response.Error.Code != "CONTROL_MESSAGE_TOO_LARGE" { + t.Fatalf("unexpected response: %#v", response) + } +} + +func TestMalformedAndOversizedBinaryFramesFailClosed(t *testing.T) { + for _, input := range []string{ + `{"version":2,"requestId":"truncated","operation":"sendPrivateReliable","params":{},"binaryLength":5}` + "\nxx", + `{"version":2,"requestId":"oversized","operation":"sendPrivateReliable","params":{},"binaryLength":65537}` + "\n", + } { + var output bytes.Buffer + if err := NewServer().Serve(context.Background(), strings.NewReader(input), &output); err != nil { + t.Fatal(err) + } + var response Response + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &response); err != nil { + t.Fatal(err) + } + if response.OK || response.Error == nil { + t.Fatalf("unexpected response: %#v", response) + } + } +} diff --git a/electron/package.json b/electron/package.json index 16480b90..3628d437 100644 --- a/electron/package.json +++ b/electron/package.json @@ -47,6 +47,7 @@ "electron:make-all": "node ./scripts/run-electron-package.mjs all", "bundle:reticulum": "node ./scripts/run-build-rnsd-frozen.mjs", "bundle:reticulum-venv": "node ./scripts/bundle-reticulum-runtime.mjs", + "build:private-transport": "node ./scripts/build-private-transport.mjs", "generate:mesh-network-identity": "python3 -c \"import RNS; RNS.Identity().to_file('resources/mesh-network.identity')\"" }, "dependencies": { diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index 1bc9c41e..18547cc0 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -59,6 +59,14 @@ ) COMMUNITY_STUN_IDENTITY_MAX_AGE_SECONDS = 24 * 60 * 60 COMMUNITY_STUN_PORT = 47321 +COMMUNITY_MASQUE_ASPECT = "community-masque-relay" +COMMUNITY_MASQUE_VERSION = "v1" +COMMUNITY_MASQUE_IDENTITY_FILENAME = ( + "community-masque-relay." + + hashlib.sha256(APP_NAMESPACE.encode("utf-8")).hexdigest()[:12] + + ".identity" +) +COMMUNITY_MASQUE_IDENTITY_MAX_AGE_SECONDS = 24 * 60 * 60 disable_bootstrap = False _developer_logs_filtered = ( str(os.environ.get("QORTAL_FILTER_DEVELOPER_LOGS", "1")).strip().lower() @@ -101,6 +109,13 @@ def _parse_test_blocked_overlay_peers(raw_value: str) -> Set[str]: _community_stun_local_hashes: "deque[bytes]" = deque(maxlen=8) _community_stun_local_endpoint: Optional[Dict[str, Any]] = None _community_stun_last_query_response_at = 0.0 +_community_masque_identity = None +_community_masque_destination = None +_community_masque_announce_handler = None +_community_masque_seen_endpoints: Dict[str, float] = {} +_community_masque_recent_endpoints: Dict[str, Dict[str, Any]] = {} +_community_masque_event_times: "deque[float]" = deque() +_community_masque_local_hashes: "deque[bytes]" = deque(maxlen=8) _reticulum_config_dir = "" # A shared-instance client does not own the authoritative Transport.path_table. # Keep a very short cache for the uncommon local-miss/RPC-fallback path so @@ -9329,6 +9344,168 @@ def handle_get_community_stun_endpoints(req_id: str) -> None: emit_resp(req_id, True, payload={"endpoints": endpoints}) +def _community_masque_host_allowed(host: str) -> bool: + try: + address = ipaddress.ip_address(host) + except ValueError: + return False + if not isinstance(address, (ipaddress.IPv4Address, ipaddress.IPv6Address)): + return False + if address.is_global: + return True + return ( + str(os.environ.get("QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY", "")) + .strip() + .lower() + in {"1", "true", "yes", "on"} + and address.is_loopback + ) + + +class CommunityMasqueRelayAnnounceHandler: + """Receive anonymous, short-lived MASQUE relay advertisements. + + The Reticulum announce authenticates the advertised metadata as one + internally consistent lease. The relay's advertised TLS leaf pin then + authenticates the QUIC endpoint without HTTPS, DNS, or public Web PKI. + """ + + def __init__(self): + self.aspect_filter = ( + f"{APP_NAMESPACE}.{COMMUNITY_MASQUE_ASPECT}.{COMMUNITY_MASQUE_VERSION}" + ) + + def received_announce(self, destination_hash, announced_identity, app_data): + if destination_hash in _community_masque_local_hashes: + return + try: + raw = bytes(app_data or b"") + if not raw or len(raw) > 512: + return + value = json.loads(raw.decode("utf-8")) + if not isinstance(value, dict) or int(value.get("v") or 0) != 1: + return + if value.get("q") is True: + return + host = str(value.get("h") or "").strip() + port = int(value.get("p") or 0) + server_name = str(value.get("s") or "").strip() + cert_sha256 = str(value.get("c") or "").strip().lower() + expires_at = int(value.get("x") or 0) + now = int(time.time()) + if ( + not _community_masque_host_allowed(host) + or port < 1 + or port > 65535 + or not server_name + or len(server_name) > 128 + or any(ch not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-" for ch in server_name) + or len(cert_sha256) != 64 + or any(ch not in "0123456789abcdef" for ch in cert_sha256) + or expires_at <= now + or expires_at > now + 60 * 60 + ): + return + endpoint_key = f"{host}:{port}:{cert_sha256}" + received_at = time.time() + with _state_lock: + while ( + _community_masque_event_times + and received_at - _community_masque_event_times[0] >= 60.0 + ): + _community_masque_event_times.popleft() + if len(_community_masque_event_times) >= 60: + return + seen_at = _community_masque_seen_endpoints.get(endpoint_key, 0.0) + if received_at - seen_at < 30.0: + return + endpoint = { + "host": host, + "port": port, + "serverName": server_name, + "certSha256": cert_sha256, + "expiresAt": expires_at * 1000, + } + _community_masque_seen_endpoints[endpoint_key] = received_at + _community_masque_recent_endpoints[endpoint_key] = endpoint + _community_masque_event_times.append(received_at) + if len(_community_masque_seen_endpoints) > 256: + oldest = sorted( + _community_masque_seen_endpoints.items(), + key=lambda item: item[1], + )[:64] + for key, _ in oldest: + _community_masque_seen_endpoints.pop(key, None) + _community_masque_recent_endpoints.pop(key, None) + emit_event("community_masque_relay", endpoint) + except Exception: + return + + +def _ensure_community_masque_discovery(config_dir: str) -> None: + global _community_masque_identity, _community_masque_destination + global _community_masque_announce_handler + if _community_masque_announce_handler is None: + _community_masque_announce_handler = CommunityMasqueRelayAnnounceHandler() + RNS.Transport.register_announce_handler(_community_masque_announce_handler) + identity_path = os.path.join(config_dir, COMMUNITY_MASQUE_IDENTITY_FILENAME) + if _community_masque_destination is not None: + try: + age = time.time() - os.path.getmtime(identity_path) + if 0 <= age < COMMUNITY_MASQUE_IDENTITY_MAX_AGE_SECONDS: + return + except Exception: + pass + try: + age = time.time() - os.path.getmtime(identity_path) + if 0 <= age < COMMUNITY_MASQUE_IDENTITY_MAX_AGE_SECONDS: + identity = RNS.Identity.from_file(identity_path) + else: + identity = None + except Exception: + identity = None + if identity is None: + identity = RNS.Identity() + identity.to_file(identity_path) + try: + os.chmod(identity_path, 0o600) + except Exception: + pass + _community_masque_identity = identity + _community_masque_destination = RNS.Destination( + identity, + RNS.Destination.IN, + RNS.Destination.SINGLE, + APP_NAMESPACE, + COMMUNITY_MASQUE_ASPECT, + COMMUNITY_MASQUE_VERSION, + ) + _community_masque_local_hashes.append(_community_masque_destination.hash) + + +def handle_get_community_masque_relays(req_id: str) -> None: + now_ms = int(time.time() * 1000) + with _state_lock: + expired = [ + key + for key, value in _community_masque_recent_endpoints.items() + if int(value.get("expiresAt") or 0) <= now_ms + ] + for key in expired: + _community_masque_recent_endpoints.pop(key, None) + _community_masque_seen_endpoints.pop(key, None) + endpoints = list(_community_masque_recent_endpoints.values())[:128] + try: + _ensure_community_masque_discovery(_reticulum_config_dir) + query_data = json.dumps( + {"v": 1, "q": True}, separators=(",", ":") + ).encode("utf-8") + _community_masque_destination.announce(app_data=query_data) + except Exception: + pass + emit_resp(req_id, True, payload={"relays": endpoints}) + + def handle_configure_developer_log_filter( req_id: str, payload: Dict[str, Any], @@ -21552,6 +21729,7 @@ def ensure_started(config_dir: str): _announce_handler = PresenceAnnounceHandler(_destination.hash) RNS.Transport.register_announce_handler(_announce_handler) _ensure_community_stun_discovery(config_dir) + _ensure_community_masque_discovery(config_dir) ensure_transport_monitor_started() ensure_rns_callback_scheduler_monitor_started() ensure_audio_rtt_monitor_started() @@ -27372,6 +27550,11 @@ def handle_qapp_rns_request(req_id: str, payload: Dict[str, Any]) -> None: manager_key = str(payload.get("managerKey") or "") destination = str(payload.get("destination") or "").lower() entry = _qapp_rns_entry(manager_key, destination) + logical_connection_id = str(payload.get("logicalConnectionId") or "") + if logical_connection_id: + with _state_lock: + if logical_connection_id not in entry.get("connections", set()): + return _qapp_rns_error(req_id, "RNS_INVALID_CONNECTION") if not _qapp_rns_open_link(entry): return _qapp_rns_error(req_id, "RNS_LINK_TIMEOUT") timeout = min(120.0, max(1.0, float(payload.get("timeoutMs") or 30000) / 1000.0)) @@ -27438,6 +27621,8 @@ def request_failed(_value=None) -> None: "encoding": "base64" if payload.get("encoding") == "base64" else "json", "payloadBase64": str(payload.get("payloadBase64") or ""), } + if logical_connection_id: + request_data["logicalConnectionId"] = logical_connection_id try: timeout_timer = threading.Timer(timeout + 1.0, request_failed) timeout_timer.daemon = True @@ -27490,6 +27675,8 @@ def handle_command(message: Dict[str, Any]) -> None: handle_configure_community_stun(req_id, payload) elif action == "get_community_stun_endpoints": handle_get_community_stun_endpoints(req_id) + elif action == "get_community_masque_relays": + handle_get_community_masque_relays(req_id) elif action == "configure_developer_log_filter": handle_configure_developer_log_filter(req_id, payload) elif action == "stop": diff --git a/electron/resources/private-transport/README.md b/electron/resources/private-transport/README.md new file mode 100644 index 00000000..471da9d6 --- /dev/null +++ b/electron/resources/private-transport/README.md @@ -0,0 +1,19 @@ +# Private transport sidecar binaries + +`npm run build:private-transport --prefix electron` writes the current-platform +binary here using this layout: + +``` +private-transport/ + linux-x64/qortal-private-transport + linux-arm64/qortal-private-transport + darwin-x64/qortal-private-transport + darwin-arm64/qortal-private-transport + windows-x64/qortal-private-transport.exe + windows-arm64/qortal-private-transport.exe +``` + +The binaries are generated artifacts and are not committed. Release jobs must +build the target artifact before invoking electron-builder. The Go program is +CGO-free, but signing/notarization of packaged macOS and Windows binaries remains +a release-pipeline responsibility. diff --git a/electron/scripts/afterPack.js b/electron/scripts/afterPack.js index c95e84d4..ecb1f026 100644 --- a/electron/scripts/afterPack.js +++ b/electron/scripts/afterPack.js @@ -32,6 +32,27 @@ module.exports = async function afterPack(context) { warn(`⚠️ chmod reticulum/${name} failed:`, e.message); } } + const privateTransportDir = path.join( + appOutDir, + 'resources', + 'private-transport' + ); + if (fs.existsSync(privateTransportDir)) { + for (const archDir of fs.readdirSync(privateTransportDir)) { + const sidecarPath = path.join( + privateTransportDir, + archDir, + 'qortal-private-transport' + ); + if (!fs.existsSync(sidecarPath)) continue; + try { + fs.chmodSync(sidecarPath, 0o755); + log(`✅ private-transport/${archDir} permissions set.`); + } catch (e) { + warn(`⚠️ chmod private-transport/${archDir} failed:`, e.message); + } + } + } } if (!plat.includes('linux')) return; diff --git a/electron/scripts/build-private-transport.mjs b/electron/scripts/build-private-transport.mjs new file mode 100644 index 00000000..d562f3a5 --- /dev/null +++ b/electron/scripts/build-private-transport.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const electronRoot = path.resolve(scriptDir, '..'); +const moduleRoot = path.join(electronRoot, 'native', 'private-transport'); +const go = process.env.QORTAL_GO_BINARY || 'go'; +const goos = + process.env.QORTAL_PRIVATE_TRANSPORT_GOOS || + (process.platform === 'win32' + ? 'windows' + : process.platform === 'darwin' + ? 'darwin' + : 'linux'); +const goarch = + process.env.QORTAL_PRIVATE_TRANSPORT_GOARCH || + (process.arch === 'arm64' ? 'arm64' : 'amd64'); +const packageArch = goarch === 'amd64' ? 'x64' : goarch; +const outputDir = path.join( + electronRoot, + 'resources', + 'private-transport', + `${goos}-${packageArch}` +); +const output = path.join( + outputDir, + `qortal-private-transport${goos === 'windows' ? '.exe' : ''}` +); + +fs.mkdirSync(outputDir, { recursive: true }); +const result = spawnSync( + go, + [ + 'build', + '-trimpath', + '-ldflags=-s -w', + '-o', + output, + './cmd/qortal-private-transport', + ], + { + cwd: moduleRoot, + stdio: 'inherit', + windowsHide: true, + env: { ...process.env, GOOS: goos, GOARCH: goarch, CGO_ENABLED: '0' }, + } +); +if (result.error) { + console.error(`Unable to run Go compiler (${go}):`, result.error.message); + process.exit(1); +} +if (result.status !== 0) process.exit(result.status ?? 1); +if (goos !== 'windows') fs.chmodSync(output, 0o755); +console.log(`Built ${output}`); diff --git a/electron/src/index.ts b/electron/src/index.ts index 941a8c1c..67637c08 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -47,6 +47,7 @@ import { setLastP2POptions, startDecentralizedStunAfterP2P, } from './setup'; +import { shutdownPrivateTransportSidecar } from './private-transport-runtime'; import { startP2PNetwork, DEFAULT_P2P_PORT, @@ -290,6 +291,11 @@ function performAppShutdown(reason: string): Promise { } catch (error) { loggerError('[Reticulum] Manager shutdown failed:', error); } + try { + await shutdownPrivateTransportSidecar(); + } catch (error) { + loggerError('[PrivateTransport] Sidecar shutdown failed:', error); + } try { // Do not let Electron disappear after merely sending SIGTERM. Waiting // here gives the bridge a bounded graceful exit and stopAndWait applies diff --git a/electron/src/masque-relay-discovery.test.ts b/electron/src/masque-relay-discovery.test.ts new file mode 100644 index 00000000..b677d3f6 --- /dev/null +++ b/electron/src/masque-relay-discovery.test.ts @@ -0,0 +1,76 @@ +import { EventEmitter } from 'events'; +import { describe, expect, it, vi } from 'vitest'; +import { + discoverCommunityMasqueRelay, + validateCommunityMasqueRelay, +} from './masque-relay-discovery'; + +class BridgeStub extends EventEmitter { + getCommunityMasqueRelays = vi.fn(async () => []); +} + +const relay = { + host: '8.8.8.8', + port: 47322, + serverName: 'relay.example', + certSha256: 'ab'.repeat(32), + expiresAt: 1_500_000, +}; + +describe('community MASQUE relay discovery', () => { + it('validates a pinned literal endpoint with a bounded lease', () => { + expect( + validateCommunityMasqueRelay(relay, { now: () => 1_000_000 }) + ).toEqual(relay); + }); + + it('rejects DNS endpoints, bad pins, and loopback by default', () => { + const options = { now: () => 1_000_000 }; + expect( + validateCommunityMasqueRelay({ ...relay, host: 'relay.example' }, options) + ).toBeNull(); + expect( + validateCommunityMasqueRelay({ ...relay, certSha256: 'bad' }, options) + ).toBeNull(); + expect( + validateCommunityMasqueRelay({ ...relay, host: '127.0.0.1' }, options) + ).toBeNull(); + }); + + it('uses a cached relay without waiting', async () => { + const bridge = new BridgeStub(); + bridge.getCommunityMasqueRelays.mockResolvedValueOnce([relay]); + await expect( + discoverCommunityMasqueRelay(bridge as never, { + now: () => 1_000_000, + random: () => 0, + }) + ).resolves.toEqual({ + relayAddress: '8.8.8.8:47322', + relayServerName: 'relay.example', + relayCertSha256: 'ab'.repeat(32), + }); + }); + + it('accepts a query response event', async () => { + const bridge = new BridgeStub(); + const pending = discoverCommunityMasqueRelay(bridge as never, { + now: () => 1_000_000, + timeoutMs: 500, + }); + await new Promise((resolve) => setImmediate(resolve)); + bridge.emit('community-masque-relay', relay); + await expect(pending).resolves.toMatchObject({ + relayAddress: '8.8.8.8:47322', + }); + }); + + it('allows loopback only for an explicit development override', () => { + expect( + validateCommunityMasqueRelay( + { ...relay, host: '127.0.0.1' }, + { now: () => 1_000_000, allowLoopback: true } + ) + ).not.toBeNull(); + }); +}); diff --git a/electron/src/masque-relay-discovery.ts b/electron/src/masque-relay-discovery.ts new file mode 100644 index 00000000..18882410 --- /dev/null +++ b/electron/src/masque-relay-discovery.ts @@ -0,0 +1,133 @@ +import net from 'net'; +import type { ReticulumBridge } from './reticulum-bridge'; +import type { TrustedRelayConfig } from './quic-masque-transport'; + +export const MASQUE_RELAY_DISCOVERY_TIMEOUT_MS = 2_500; +const MAX_LEASE_AHEAD_MS = 60 * 60_000; +const MIN_REMAINING_LEASE_MS = 5_000; + +export type CommunityMasqueRelay = { + host: string; + port: number; + serverName: string; + certSha256: string; + expiresAt: number; +}; + +export type MasqueRelayDiscoveryOptions = { + timeoutMs?: number; + allowLoopback?: boolean; + now?: () => number; + random?: () => number; +}; + +export function validateCommunityMasqueRelay( + value: unknown, + options: MasqueRelayDiscoveryOptions = {} +): CommunityMasqueRelay | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as Partial; + const host = String(candidate.host ?? '').trim(); + const port = Number(candidate.port); + const serverName = String(candidate.serverName ?? '').trim(); + const certSha256 = String(candidate.certSha256 ?? '') + .trim() + .toLowerCase(); + const expiresAt = Number(candidate.expiresAt); + const now = (options.now ?? Date.now)(); + const ipFamily = net.isIP(host); + const loopback = host === '127.0.0.1' || host === '::1'; + if ( + ipFamily === 0 || + (!isPublicRelayHost(host) && + !(loopback && options.allowLoopback === true)) || + !Number.isInteger(port) || + port < 1 || + port > 65535 || + !/^[a-zA-Z0-9.-]{1,128}$/.test(serverName) || + !/^[a-f0-9]{64}$/.test(certSha256) || + !Number.isFinite(expiresAt) || + expiresAt < now + MIN_REMAINING_LEASE_MS || + expiresAt > now + MAX_LEASE_AHEAD_MS + ) { + return null; + } + return { host, port, serverName, certSha256, expiresAt }; +} + +function isPublicRelayHost(host: string): boolean { + if (net.isIP(host) === 4) { + const [a, b, c] = host.split('.').map(Number); + if (a === 0 || a === 10 || a === 127 || a >= 224) return false; + if (a === 100 && b >= 64 && b <= 127) return false; + if (a === 169 && b === 254) return false; + if (a === 172 && b >= 16 && b <= 31) return false; + if (a === 192 && b === 168) return false; + if (a === 192 && b === 0 && (c === 0 || c === 2)) return false; + if (a === 198 && (b === 18 || b === 19)) return false; + if (a === 198 && b === 51 && c === 100) return false; + if (a === 203 && b === 0 && c === 113) return false; + return true; + } + if (net.isIP(host) !== 6) return false; + const normalized = host.toLowerCase(); + return !( + normalized === '::' || + normalized === '::1' || + normalized.startsWith('fc') || + normalized.startsWith('fd') || + /^fe[89ab]/.test(normalized) || + normalized.startsWith('ff') || + normalized === '2001:db8::' || + normalized.startsWith('2001:db8:') || + normalized.startsWith('::ffff:') + ); +} + +export async function discoverCommunityMasqueRelay( + bridge: ReticulumBridge, + options: MasqueRelayDiscoveryOptions = {} +): Promise { + const candidates = new Map(); + let wake: (() => void) | null = null; + const accept = (value: unknown) => { + const relay = validateCommunityMasqueRelay(value, options); + if (!relay) return; + candidates.set(`${relay.host}:${relay.port}:${relay.certSha256}`, relay); + wake?.(); + }; + const onRelay = (value: unknown) => accept(value); + bridge.on('community-masque-relay', onRelay); + try { + const cached = await bridge.getCommunityMasqueRelays(); + for (const relay of cached) accept(relay); + if (candidates.size === 0) { + await new Promise((resolve) => { + const timer = setTimeout( + resolve, + Math.max(100, options.timeoutMs ?? MASQUE_RELAY_DISCOVERY_TIMEOUT_MS) + ); + timer.unref?.(); + wake = () => { + clearTimeout(timer); + wake = null; + resolve(); + }; + }); + } + } finally { + bridge.off('community-masque-relay', onRelay); + } + const relays = [...candidates.values()]; + if (relays.length === 0) throw new Error('MASQUE_RELAY_UNAVAILABLE'); + const random = options.random ?? Math.random; + const selected = + relays[Math.min(relays.length - 1, Math.floor(random() * relays.length))]; + const host = + net.isIP(selected.host) === 6 ? `[${selected.host}]` : selected.host; + return { + relayAddress: `${host}:${selected.port}`, + relayServerName: selected.serverName, + relayCertSha256: selected.certSha256, + }; +} diff --git a/electron/src/preload.ts b/electron/src/preload.ts index de1a410d..b8c011c9 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -69,6 +69,26 @@ function parseWindowRoleFromArgv(): string { return 'main-shell'; } +async function invokePrivateChannel( + ipcChannel: string, + ...args: unknown[] +): Promise { + const result = (await ipcRenderer.invoke(ipcChannel, ...args)) as + | { ok: true; value: unknown } + | { ok: false; error: { code?: string; message?: string } }; + if (result.ok === true) return result.value; + const failure = result as { + ok: false; + error: { code?: string; message?: string }; + }; + const code = failure.error?.code || 'PRIVATE_CHANNEL_ERROR'; + const error = new Error(failure.error?.message || code) as Error & { + code?: string; + }; + error.code = code; + throw error; +} + const hubP2pBootstrapIceServers = isDisabledLegacy ? [] : buildBootstrapIceServers(parseHubBootstrapSeedsFromArgv()); @@ -684,6 +704,34 @@ try { ipcRenderer.on('qappReticulum:event', listener); return () => ipcRenderer.removeListener('qappReticulum:event', listener); }, + privateChannelOpen: (owner, rnsConnectionId, purpose) => + invokePrivateChannel( + 'privateChannel:open', + owner, + rnsConnectionId, + purpose + ), + privateChannelSend: (owner, channelId, lane, messageId, data) => + invokePrivateChannel( + 'privateChannel:send', + owner, + channelId, + lane, + messageId, + data + ), + privateChannelStatus: (owner, channelId) => + invokePrivateChannel('privateChannel:status', owner, channelId), + privateChannelClose: (owner, channelId) => + invokePrivateChannel('privateChannel:close', owner, channelId), + privateChannelCleanupOwner: (owner) => + invokePrivateChannel('privateChannel:cleanupOwner', owner), + onPrivateChannelEvent: (callback: (payload: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => + callback(payload); + ipcRenderer.on('privateChannel:event', listener); + return () => ipcRenderer.removeListener('privateChannel:event', listener); + }, qchatFileSelect: () => ipcRenderer.invoke('reticulum:qchatFileSelect') as Promise<{ ok: boolean; diff --git a/electron/src/private-channel-bootstrap.ts b/electron/src/private-channel-bootstrap.ts new file mode 100644 index 00000000..58480ca0 --- /dev/null +++ b/electron/src/private-channel-bootstrap.ts @@ -0,0 +1,182 @@ +import { createHash, randomBytes } from 'crypto'; +import net from 'net'; +import { + PrivateChannelError, + type PrivateTransportContext, +} from './private-channel-manager'; +import { + QAppReticulumManager, + type QAppReticulumOwner, +} from './qapp-reticulum-manager'; + +export const PRIVATE_BOOTSTRAP_VERSION = 1; +export const PRIVATE_BOOTSTRAP_TRANSPORT = 'quic-masque-inner-v1'; +export const PRIVATE_BOOTSTRAP_PATH = '/qortal/private-transport/bootstrap/v1'; + +export type PrivateBootstrapDescriptor = Readonly<{ + version: 1; + transport: 'quic-masque-inner-v1'; + logicalSessionId: string; + backendRnsDestination: string; + backendTransportEndpoint: string; + backendTransportServerName: string; + backendTransportCertSha256: string; + attachToken: string; + expiresAt: number; + nonce: string; + ownerBindingHash: string; + supportedFeatures: { reliable: true; datagrams: true }; +}>; + +export interface PrivateChannelBootstrapProvider { + getBootstrap( + context: PrivateTransportContext + ): Promise; +} + +export class ReticulumPrivateChannelBootstrapProvider implements PrivateChannelBootstrapProvider { + private readonly consumedNonces = new Set(); + + constructor(private readonly manager: QAppReticulumManager) {} + + async getBootstrap( + context: PrivateTransportContext + ): Promise { + const destination = this.manager.connectionDestination( + context.owner, + context.rnsConnectionId + ); + const nonce = randomBytes(24).toString('base64url'); + let value: unknown; + try { + value = await this.manager.request(context.owner, { + destination, + path: PRIVATE_BOOTSTRAP_PATH, + payload: { + version: PRIVATE_BOOTSTRAP_VERSION, + transport: PRIVATE_BOOTSTRAP_TRANSPORT, + nonce, + purpose: context.purpose, + }, + timeoutMs: 15_000, + maxResponseBytes: 16 * 1024, + connectionId: context.rnsConnectionId, + }); + } catch { + throw new PrivateChannelError('BOOTSTRAP_FAILED'); + } + return validatePrivateBootstrapDescriptor(value, { + context, + authenticatedRnsDestination: destination, + requestNonce: nonce, + consumedNonces: this.consumedNonces, + }); + } +} + +export function validatePrivateBootstrapDescriptor( + value: unknown, + expected: { + context: PrivateTransportContext; + authenticatedRnsDestination: string; + requestNonce: string; + consumedNonces: Set; + now?: number; + } +): PrivateBootstrapDescriptor { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new PrivateChannelError('BOOTSTRAP_FAILED'); + } + const d = value as Record; + if (d.error && typeof d.error === 'object') { + throw new PrivateChannelError('BOOTSTRAP_FAILED'); + } + if (d.version !== PRIVATE_BOOTSTRAP_VERSION) { + throw new PrivateChannelError('PROTOCOL_MISMATCH'); + } + if (d.transport !== PRIVATE_BOOTSTRAP_TRANSPORT) { + throw new PrivateChannelError('UNSUPPORTED_TRANSPORT'); + } + const now = expected.now ?? Date.now(); + if ( + typeof d.expiresAt !== 'number' || + !Number.isSafeInteger(d.expiresAt) || + d.expiresAt <= now || + d.expiresAt > now + 5 * 60_000 + ) + throw new PrivateChannelError('BOOTSTRAP_EXPIRED'); + if ( + d.backendRnsDestination !== expected.authenticatedRnsDestination || + d.nonce !== expected.requestNonce + ) + throw new PrivateChannelError('BOOTSTRAP_BINDING_MISMATCH'); + if (expected.consumedNonces.has(expected.requestNonce)) { + throw new PrivateChannelError('BOOTSTRAP_REUSED'); + } + for (const key of [ + 'logicalSessionId', + 'backendTransportServerName', + 'attachToken', + ] as const) { + if ( + typeof d[key] !== 'string' || + !(d[key] as string).trim() || + (d[key] as string).length > 512 + ) { + throw new PrivateChannelError('BOOTSTRAP_FAILED'); + } + } + const expectedOwnerBinding = privateBootstrapOwnerBindingHash( + expected.context.owner, + expected.context.rnsConnectionId, + expected.authenticatedRnsDestination, + d.logicalSessionId as string, + expected.requestNonce, + expected.context.purpose + ); + if (d.ownerBindingHash !== expectedOwnerBinding) { + throw new PrivateChannelError('BOOTSTRAP_BINDING_MISMATCH'); + } + if ( + typeof d.backendTransportEndpoint !== 'string' || + !isLiteralEndpoint(d.backendTransportEndpoint) || + typeof d.backendTransportCertSha256 !== 'string' || + !/^[a-f0-9]{64}$/.test(d.backendTransportCertSha256) || + !d.supportedFeatures || + typeof d.supportedFeatures !== 'object' || + (d.supportedFeatures as Record).reliable !== true || + (d.supportedFeatures as Record).datagrams !== true + ) + throw new PrivateChannelError('BOOTSTRAP_FAILED'); + expected.consumedNonces.add(expected.requestNonce); + while (expected.consumedNonces.size > 4096) { + const oldest = expected.consumedNonces.values().next().value; + if (typeof oldest !== 'string') break; + expected.consumedNonces.delete(oldest); + } + return d as unknown as PrivateBootstrapDescriptor; +} + +export function privateBootstrapOwnerBindingHash( + owner: QAppReticulumOwner, + rnsConnectionId: string, + destination: string, + logicalSessionId: string, + nonce: string, + purpose: string +): string { + return createHash('sha256') + .update( + `qortal-private-owner-v2\0${owner.name}\0${owner.service}\0${rnsConnectionId}\0${destination}\0${logicalSessionId}\0${nonce}\0${purpose}` + ) + .digest('hex'); +} + +function isLiteralEndpoint(value: string): boolean { + const match = value.startsWith('[') + ? /^\[([^\]]+)\]:(\d+)$/.exec(value) + : /^([^:]+):(\d+)$/.exec(value); + if (!match) return false; + const port = Number(match[2]); + return net.isIP(match[1]) !== 0 && port >= 1 && port <= 65535; +} diff --git a/electron/src/private-channel-manager.test.ts b/electron/src/private-channel-manager.test.ts new file mode 100644 index 00000000..177c2d06 --- /dev/null +++ b/electron/src/private-channel-manager.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + MockPrivateTransport, + PRIVATE_CHANNEL_LIMITS, + PrivateChannelManager, + type PrivateTransport, + type PrivateTransportContext, + type PrivateTransportEvent, + type PrivateTransportMessage, +} from './private-channel-manager'; +import type { + QAppReticulumConnectionOwnership, + QAppReticulumOwner, +} from './qapp-reticulum-manager'; + +const alice: QAppReticulumOwner = { + tabId: 'tab-a', + name: 'alice-app', + service: 'APP', +}; +const bob: QAppReticulumOwner = { + tabId: 'tab-b', + name: 'bob-app', + service: 'APP', +}; +const aliceRns = 'rns-alice'; +const bobRns = 'rns-bob'; + +function ownership( + owner: QAppReticulumOwner, + connectionId: string +): QAppReticulumConnectionOwnership { + if (connectionId === aliceRns) { + return owner.tabId === alice.tabId ? 'owned' : 'not-owned'; + } + if (connectionId === bobRns) { + return owner.tabId === bob.tabId ? 'owned' : 'not-owned'; + } + return 'missing'; +} + +class BlockingTransport implements PrivateTransport { + releases: Array<() => void> = []; + open = vi.fn(async (_context: PrivateTransportContext) => undefined); + close = vi.fn(async () => undefined); + sendReliable = vi.fn((_message: PrivateTransportMessage) => this.block()); + sendDatagram = vi.fn((_message: PrivateTransportMessage) => this.block()); + + private block(): Promise { + return new Promise((resolve) => this.releases.push(resolve)); + } +} + +describe('PrivateChannelManager', () => { + it('opens an owner-scoped mock channel with an opaque unique handle', async () => { + const manager = new PrivateChannelManager(ownership); + const first = await manager.open(alice, aliceRns, 'game'); + const second = await manager.open(alice, aliceRns, 'realtime'); + + expect(first).toMatchObject({ state: 'OPEN', purpose: 'game' }); + expect(first.channelId).toMatch(/^private-[0-9a-f-]{36}$/); + expect(second.channelId).not.toBe(first.channelId); + expect(first).not.toHaveProperty('rnsConnectionId'); + expect(first).not.toHaveProperty('destination'); + }); + + it('rejects missing and cross-owner Reticulum connections', async () => { + const manager = new PrivateChannelManager(ownership); + await expect(manager.open(alice, 'missing', 'game')).rejects.toMatchObject({ + code: 'INVALID_RNS_CONNECTION', + }); + await expect(manager.open(bob, aliceRns, 'game')).rejects.toMatchObject({ + code: 'RNS_CONNECTION_NOT_OWNED', + }); + }); + + it('validates purpose and canonical owner identity', async () => { + const manager = new PrivateChannelManager(ownership); + await expect(manager.open(alice, aliceRns, 'video')).rejects.toMatchObject({ + code: 'INVALID_PURPOSE', + }); + await expect( + manager.open({ ...alice, name: 'Alice-App' }, aliceRns, 'game') + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + }); + + it('prevents another Q-App from using or closing a channel', async () => { + const manager = new PrivateChannelManager(ownership); + const { channelId } = await manager.open(alice, aliceRns, 'game'); + await expect( + manager.send(bob, channelId, 'reliable', '1', 'hello') + ).rejects.toMatchObject({ code: 'CHANNEL_NOT_OWNED' }); + await expect(manager.close(bob, channelId)).rejects.toMatchObject({ + code: 'CHANNEL_NOT_OWNED', + }); + }); + + it('echoes binary-friendly mock messages only to the owning event key', async () => { + const manager = new PrivateChannelManager(ownership); + const listener = vi.fn(); + manager.on('event', listener); + const { channelId } = await manager.open(alice, aliceRns, 'realtime'); + listener.mockClear(); + const data = new Uint8Array([1, 2, 3]); + + await manager.send(alice, channelId, 'datagram', 'message-1', data); + + expect(listener).toHaveBeenCalledWith({ + ownerKey: 'tab-a\u0000APP\u0000alice-app', + channelId, + action: 'PRIVATE_CHANNEL_MESSAGE', + lane: 'datagram', + messageId: 'message-1', + data, + }); + }); + + it('enforces per-owner and global channel limits', async () => { + const manager = new PrivateChannelManager(() => 'owned'); + for ( + let index = 0; + index < PRIVATE_CHANNEL_LIMITS.maxChannelsPerOwner; + index += 1 + ) { + await manager.open(alice, aliceRns, 'game'); + } + await expect(manager.open(alice, aliceRns, 'game')).rejects.toMatchObject({ + code: 'CHANNEL_LIMIT_REACHED', + }); + + const globalManager = new PrivateChannelManager(() => 'owned'); + for ( + let index = 0; + index < PRIVATE_CHANNEL_LIMITS.maxChannelsGlobal; + index += 1 + ) { + await globalManager.open( + { tabId: `tab-${index}`, name: `app-${index}`, service: 'APP' }, + `rns-${index}`, + 'game' + ); + } + await expect( + globalManager.open( + { tabId: 'overflow', name: 'overflow', service: 'APP' }, + 'rns-overflow', + 'game' + ) + ).rejects.toMatchObject({ code: 'CHANNEL_LIMIT_REACHED' }); + }); + + it('rejects oversized messages', async () => { + const manager = new PrivateChannelManager(ownership); + const { channelId } = await manager.open(alice, aliceRns, 'game'); + await expect( + manager.send( + alice, + channelId, + 'reliable', + 'large', + new Uint8Array(PRIVATE_CHANNEL_LIMITS.maxMessageBytes + 1) + ) + ).rejects.toMatchObject({ code: 'MESSAGE_TOO_LARGE' }); + }); + + it('rejects values outside the bounded JSON or binary message model', async () => { + const manager = new PrivateChannelManager(ownership); + const { channelId } = await manager.open(alice, aliceRns, 'game'); + await expect( + manager.send(alice, channelId, 'reliable', 'map', new Map([['x', 1]])) + ).rejects.toMatchObject({ code: 'INVALID_MESSAGE' }); + await expect( + manager.send(alice, channelId, 'reliable', 'infinite', { + value: Number.POSITIVE_INFINITY, + }) + ).rejects.toMatchObject({ code: 'INVALID_MESSAGE' }); + }); + + it('enforces channel and owner queued-byte limits while sends are pending', async () => { + const transports: BlockingTransport[] = []; + const manager = new PrivateChannelManager(ownership, () => { + const transport = new BlockingTransport(); + transports.push(transport); + return transport; + }); + const first = await manager.open(alice, aliceRns, 'game'); + const second = await manager.open(alice, aliceRns, 'realtime'); + const payload = new Uint8Array(PRIVATE_CHANNEL_LIMITS.maxMessageBytes); + const pending: Array> = []; + + for (let index = 0; index < 4; index += 1) { + pending.push( + manager.send(alice, first.channelId, 'reliable', `a-${index}`, payload) + ); + } + await expect( + manager.send( + alice, + first.channelId, + 'reliable', + 'channel-overflow', + payload + ) + ).rejects.toMatchObject({ code: 'QUEUE_LIMIT_REACHED' }); + + for (let index = 0; index < 4; index += 1) { + pending.push( + manager.send(alice, second.channelId, 'reliable', `b-${index}`, payload) + ); + } + const third = await manager.open(alice, aliceRns, 'file-transfer'); + await expect( + manager.send( + alice, + third.channelId, + 'reliable', + 'owner-overflow', + payload + ) + ).rejects.toMatchObject({ code: 'QUEUE_LIMIT_REACHED' }); + + for (const transport of transports) { + for (const release of transport.releases) release(); + } + await Promise.all(pending); + }); + + it('invalidates closed handles and makes close idempotent', async () => { + const manager = new PrivateChannelManager(ownership); + const { channelId } = await manager.open(alice, aliceRns, 'game'); + await expect(manager.close(alice, channelId)).resolves.toEqual({ + channelId, + state: 'CLOSED', + }); + await expect(manager.close(alice, channelId)).resolves.toEqual({ + channelId, + state: 'CLOSED', + }); + expect(manager.status(alice, channelId).state).toBe('CLOSED'); + await expect( + manager.send(alice, channelId, 'reliable', 'after-close', 'x') + ).rejects.toMatchObject({ code: 'CHANNEL_CLOSED' }); + }); + + it('cleans all channels for an owner without affecting another owner', async () => { + const manager = new PrivateChannelManager(ownership); + const first = await manager.open(alice, aliceRns, 'game'); + const second = await manager.open(alice, aliceRns, 'realtime'); + const other = await manager.open(bob, bobRns, 'game'); + await manager.cleanupOwner(alice); + + expect(manager.status(alice, first.channelId).state).toBe('CLOSED'); + expect(manager.status(alice, second.channelId).state).toBe('CLOSED'); + expect(manager.status(bob, other.channelId).state).toBe('OPEN'); + }); + + it('closes channels when their Reticulum control connection closes', async () => { + const manager = new PrivateChannelManager(ownership); + const first = await manager.open(alice, aliceRns, 'game'); + const second = await manager.open(alice, aliceRns, 'realtime'); + const other = await manager.open(bob, bobRns, 'game'); + + await manager.cleanupRnsConnection(alice, aliceRns); + + expect(manager.status(alice, first.channelId).state).toBe('CLOSED'); + expect(manager.status(alice, second.channelId).state).toBe('CLOSED'); + expect(manager.status(bob, other.channelId).state).toBe('OPEN'); + }); + + it('drops late transport events after channel cleanup', async () => { + let emit: ((event: PrivateTransportEvent) => void) | undefined; + const manager = new PrivateChannelManager(ownership, (listener) => { + emit = listener; + return new MockPrivateTransport(listener); + }); + const listener = vi.fn(); + manager.on('event', listener); + const { channelId } = await manager.open(alice, aliceRns, 'game'); + await manager.cleanupOwner(alice); + listener.mockClear(); + + emit?.({ + kind: 'message', + lane: 'reliable', + messageId: 'late', + data: 'ignored', + }); + expect(listener).not.toHaveBeenCalled(); + expect(() => manager.status(alice, channelId)).not.toThrow(); + }); +}); diff --git a/electron/src/private-channel-manager.ts b/electron/src/private-channel-manager.ts new file mode 100644 index 00000000..fffd253d --- /dev/null +++ b/electron/src/private-channel-manager.ts @@ -0,0 +1,538 @@ +import { EventEmitter } from 'events'; +import { randomUUID } from 'crypto'; +import type { + QAppReticulumConnectionOwnership, + QAppReticulumOwner, +} from './qapp-reticulum-manager'; + +export type PrivateChannelPurpose = 'game' | 'file-transfer' | 'realtime'; +export type PrivateChannelLane = 'reliable' | 'datagram'; +export type PrivateChannelState = 'OPENING' | 'OPEN' | 'CLOSING' | 'CLOSED'; + +export const PRIVATE_CHANNEL_LIMITS = Object.freeze({ + // Logical channels are intentionally scarce until a real transport exists. + maxChannelsPerOwner: 4, + maxChannelsGlobal: 32, + // Large files will use a separate bounded streaming API in a later step. + maxMessageBytes: 64 * 1024, + maxQueuedBytesPerChannel: 256 * 1024, + maxQueuedBytesPerOwner: 512 * 1024, +}); + +export const PRIVATE_CHANNEL_FEATURES = Object.freeze({ + reliableMessages: true, + datagrams: true, + maxMessageBytes: PRIVATE_CHANNEL_LIMITS.maxMessageBytes, +}); + +export class PrivateChannelError extends Error { + constructor( + public readonly code: string, + message = code + ) { + super(message); + } +} + +export type PrivateTransportMessage = { + lane: PrivateChannelLane; + messageId: string; + data: unknown; +}; + +export type PrivateTransportEvent = + | ({ kind: 'message' } & PrivateTransportMessage) + | { kind: 'error'; code: string; message?: string }; + +export type PrivateTransportContext = { + channelId: string; + rnsConnectionId: string; + purpose: PrivateChannelPurpose; + generation: number; + owner: QAppReticulumOwner; +}; + +/** Transport-neutral seam for the future native QUIC/MASQUE implementation. */ +export interface PrivateTransport { + open(context: PrivateTransportContext): Promise; + sendReliable(message: PrivateTransportMessage): Promise; + sendDatagram(message: PrivateTransportMessage): Promise; + close(): Promise; +} + +export type PrivateTransportFactory = ( + emit: (event: PrivateTransportEvent) => void +) => PrivateTransport; + +/** Step-1 transport: acknowledges sends by echoing them back in memory. */ +export class MockPrivateTransport implements PrivateTransport { + private openState = false; + + constructor(private readonly emit: (event: PrivateTransportEvent) => void) {} + + async open(_context: PrivateTransportContext): Promise { + this.openState = true; + } + + async sendReliable(message: PrivateTransportMessage): Promise { + this.send(message); + } + + async sendDatagram(message: PrivateTransportMessage): Promise { + this.send(message); + } + + async close(): Promise { + this.openState = false; + } + + private send(message: PrivateTransportMessage): void { + if (!this.openState) throw new PrivateChannelError('CHANNEL_CLOSED'); + this.emit({ kind: 'message', ...message }); + } +} + +type ChannelRecord = { + channelId: string; + ownerKey: string; + rnsConnectionId: string; + purpose: PrivateChannelPurpose; + state: PrivateChannelState; + createdAt: number; + queuedBytes: number; + generation: number; + transport: PrivateTransport; +}; + +type ClosedChannel = { ownerKey: string; purpose: PrivateChannelPurpose }; + +const VALID_PURPOSES = new Set([ + 'game', + 'file-transfer', + 'realtime', +]); +const VALID_LANES = new Set(['reliable', 'datagram']); +const MAX_MESSAGE_ID_LENGTH = 128; +const MAX_CLOSED_CHANNEL_TOMBSTONES = 256; + +export function privateChannelOwnerKey(owner: QAppReticulumOwner): string { + if ( + !owner || + typeof owner.tabId !== 'string' || + !owner.tabId || + owner.tabId.length > 128 || + typeof owner.name !== 'string' || + !owner.name || + owner.name.length > 128 || + owner.name !== owner.name.trim().toLowerCase() || + typeof owner.service !== 'string' || + owner.service !== owner.service.trim().toUpperCase() || + !/^[A-Z0-9_]{1,32}$/.test(owner.service) + ) { + throw new PrivateChannelError('PERMISSION_DENIED'); + } + return `${owner.tabId}\u0000${owner.service}\u0000${owner.name}`; +} + +function byteLength(data: unknown): number { + if (typeof data === 'string') return Buffer.byteLength(data, 'utf8'); + if (data instanceof ArrayBuffer) return data.byteLength; + if (ArrayBuffer.isView(data)) return data.byteLength; + if ( + typeof SharedArrayBuffer !== 'undefined' && + data instanceof SharedArrayBuffer + ) { + throw new PrivateChannelError('INVALID_MESSAGE'); + } + try { + validateJsonValue(data, new WeakSet(), 0); + const serialized = JSON.stringify(data ?? null); + if (serialized === undefined) throw new Error('not serializable'); + return Buffer.byteLength(serialized, 'utf8'); + } catch { + throw new PrivateChannelError('INVALID_MESSAGE'); + } +} + +function validateJsonValue( + value: unknown, + ancestors: WeakSet, + depth: number +): void { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return; + } + if (depth >= 32 || typeof value !== 'object') { + throw new PrivateChannelError('INVALID_MESSAGE'); + } + const object = value as object; + const prototype = Object.getPrototypeOf(object); + if (!Array.isArray(object) && prototype !== Object.prototype) { + throw new PrivateChannelError('INVALID_MESSAGE'); + } + if (ancestors.has(object)) throw new PrivateChannelError('INVALID_MESSAGE'); + ancestors.add(object); + const children = Array.isArray(object) + ? object + : Object.values(object as Record); + for (const child of children) { + validateJsonValue(child, ancestors, depth + 1); + } + ancestors.delete(object); +} + +export class PrivateChannelManager extends EventEmitter { + private readonly channels = new Map(); + private readonly channelsByOwner = new Map>(); + private readonly queuedBytesByOwner = new Map(); + private readonly closedChannels = new Map(); + + constructor( + private readonly getRnsConnectionOwnership: ( + owner: QAppReticulumOwner, + connectionId: string + ) => QAppReticulumConnectionOwnership, + private readonly transportFactory: PrivateTransportFactory = (emit) => + new MockPrivateTransport(emit) + ) { + super(); + } + + async open( + owner: QAppReticulumOwner, + rnsConnectionIdValue: unknown, + purposeValue: unknown + ) { + const key = privateChannelOwnerKey(owner); + const rnsConnectionId = + typeof rnsConnectionIdValue === 'string' + ? rnsConnectionIdValue.trim() + : ''; + if (!rnsConnectionId) { + throw new PrivateChannelError('INVALID_RNS_CONNECTION'); + } + const ownership = this.getRnsConnectionOwnership(owner, rnsConnectionId); + if (ownership === 'missing') { + throw new PrivateChannelError('INVALID_RNS_CONNECTION'); + } + if (ownership !== 'owned') { + throw new PrivateChannelError('RNS_CONNECTION_NOT_OWNED'); + } + if ( + typeof purposeValue !== 'string' || + !VALID_PURPOSES.has(purposeValue as PrivateChannelPurpose) + ) { + throw new PrivateChannelError('INVALID_PURPOSE'); + } + if ( + (this.channelsByOwner.get(key)?.size ?? 0) >= + PRIVATE_CHANNEL_LIMITS.maxChannelsPerOwner || + this.channels.size >= PRIVATE_CHANNEL_LIMITS.maxChannelsGlobal + ) { + throw new PrivateChannelError('CHANNEL_LIMIT_REACHED'); + } + + const purpose = purposeValue as PrivateChannelPurpose; + const channelId = `private-${randomUUID()}`; + const generation = 1; + const transport = this.transportFactory((event) => + this.handleTransportEvent(channelId, generation, event) + ); + const channel: ChannelRecord = { + channelId, + ownerKey: key, + rnsConnectionId, + purpose, + state: 'OPENING', + createdAt: Date.now(), + queuedBytes: 0, + generation, + transport, + }; + this.channels.set(channelId, channel); + const owned = this.channelsByOwner.get(key) ?? new Set(); + owned.add(channelId); + this.channelsByOwner.set(key, owned); + + try { + await transport.open({ + channelId, + rnsConnectionId, + purpose, + generation, + owner, + }); + if ( + this.channels.get(channelId) !== channel || + channel.state !== 'OPENING' + ) { + throw new PrivateChannelError('CHANNEL_CLOSED'); + } + channel.state = 'OPEN'; + this.emitState(channel, 'OPEN'); + return this.safeStatus(channel); + } catch (error) { + this.removeChannel(channel); + void transport.close().catch(() => undefined); + if (error instanceof PrivateChannelError) throw error; + throw new PrivateChannelError('CHANNEL_OPEN_FAILED'); + } + } + + async send( + owner: QAppReticulumOwner, + channelIdValue: unknown, + laneValue: unknown, + messageIdValue: unknown, + data: unknown + ): Promise<{ channelId: string; messageId: string; accepted: true }> { + const channelId = this.validateChannelId(channelIdValue); + const channel = this.requireOwned(owner, channelId, true); + if (channel.state !== 'OPEN') { + throw new PrivateChannelError('CHANNEL_CLOSED'); + } + if ( + typeof laneValue !== 'string' || + !VALID_LANES.has(laneValue as PrivateChannelLane) + ) { + throw new PrivateChannelError('INVALID_LANE'); + } + const messageId = + typeof messageIdValue === 'string' ? messageIdValue.trim() : ''; + if (!messageId || messageId.length > MAX_MESSAGE_ID_LENGTH) { + throw new PrivateChannelError('INVALID_MESSAGE_ID'); + } + const size = byteLength(data); + if (size > PRIVATE_CHANNEL_LIMITS.maxMessageBytes) { + throw new PrivateChannelError('MESSAGE_TOO_LARGE'); + } + const ownerQueued = this.queuedBytesByOwner.get(channel.ownerKey) ?? 0; + if ( + channel.queuedBytes + size > + PRIVATE_CHANNEL_LIMITS.maxQueuedBytesPerChannel || + ownerQueued + size > PRIVATE_CHANNEL_LIMITS.maxQueuedBytesPerOwner + ) { + throw new PrivateChannelError('QUEUE_LIMIT_REACHED'); + } + + channel.queuedBytes += size; + this.queuedBytesByOwner.set(channel.ownerKey, ownerQueued + size); + const lane = laneValue as PrivateChannelLane; + const message = { lane, messageId, data }; + try { + if (lane === 'reliable') await channel.transport.sendReliable(message); + else await channel.transport.sendDatagram(message); + return { channelId, messageId, accepted: true }; + } catch (error) { + if (error instanceof PrivateChannelError) throw error; + throw new PrivateChannelError('CHANNEL_SEND_FAILED'); + } finally { + channel.queuedBytes = Math.max(0, channel.queuedBytes - size); + const remaining = Math.max( + 0, + (this.queuedBytesByOwner.get(channel.ownerKey) ?? 0) - size + ); + if (remaining === 0) this.queuedBytesByOwner.delete(channel.ownerKey); + else this.queuedBytesByOwner.set(channel.ownerKey, remaining); + } + } + + status(owner: QAppReticulumOwner, channelIdValue: unknown) { + const channelId = this.validateChannelId(channelIdValue); + const channel = this.requireOwned(owner, channelId, false); + if (channel) return this.safeStatus(channel); + const closed = this.closedChannels.get(channelId)!; + return { + channelId, + state: 'CLOSED' as const, + purpose: closed.purpose, + features: PRIVATE_CHANNEL_FEATURES, + queuedBytes: 0, + }; + } + + async close(owner: QAppReticulumOwner, channelIdValue: unknown) { + const channelId = this.validateChannelId(channelIdValue); + const channel = this.requireOwned(owner, channelId, false); + if (!channel) { + return { channelId, state: 'CLOSED' as const }; + } + channel.state = 'CLOSING'; + this.emitState(channel, 'CLOSING'); + this.removeChannel(channel); + this.rememberClosed(channel); + try { + await channel.transport.close(); + } catch { + // The logical handle is invalid even if transport teardown fails. + } + this.emitState(channel, 'CLOSED'); + return { channelId, state: 'CLOSED' as const }; + } + + async cleanupOwner(owner: QAppReticulumOwner): Promise { + const key = privateChannelOwnerKey(owner); + const ids = [...(this.channelsByOwner.get(key) ?? [])]; + await Promise.allSettled(ids.map((id) => this.close(owner, id))); + } + + async cleanupRnsConnection( + owner: QAppReticulumOwner, + rnsConnectionId: string + ): Promise { + const key = privateChannelOwnerKey(owner); + const ids = [...(this.channelsByOwner.get(key) ?? [])].filter( + (id) => this.channels.get(id)?.rnsConnectionId === rnsConnectionId + ); + await Promise.allSettled(ids.map((id) => this.close(owner, id))); + } + + destroy(): void { + for (const channel of this.channels.values()) { + channel.state = 'CLOSED'; + void channel.transport.close().catch(() => undefined); + } + this.channels.clear(); + this.channelsByOwner.clear(); + this.queuedBytesByOwner.clear(); + this.closedChannels.clear(); + this.removeAllListeners(); + } + + private handleTransportEvent( + channelId: string, + generation: number, + event: PrivateTransportEvent + ): void { + const channel = this.channels.get(channelId); + if ( + !channel || + channel.state !== 'OPEN' || + channel.generation !== generation + ) { + return; + } + if (event.kind === 'message') { + let size: number; + try { + size = byteLength(event.data); + } catch { + this.failTransport(channel); + return; + } + if ( + size > PRIVATE_CHANNEL_LIMITS.maxMessageBytes || + !VALID_LANES.has(event.lane) || + !event.messageId || + event.messageId.length > MAX_MESSAGE_ID_LENGTH + ) { + this.failTransport(channel); + return; + } + this.emit('event', { + ownerKey: channel.ownerKey, + channelId, + action: 'PRIVATE_CHANNEL_MESSAGE', + lane: event.lane, + messageId: event.messageId, + data: event.data, + }); + return; + } + this.failTransport(channel); + } + + private emitTransportError(channel: ChannelRecord): void { + this.emit('event', { + ownerKey: channel.ownerKey, + channelId: channel.channelId, + action: 'PRIVATE_CHANNEL_ERROR', + code: 'TRANSPORT_ERROR', + message: 'Private channel transport error', + }); + } + + private failTransport(channel: ChannelRecord): void { + if (this.channels.get(channel.channelId) !== channel) return; + this.emitTransportError(channel); + channel.state = 'CLOSED'; + this.removeChannel(channel); + this.rememberClosed(channel); + this.emitState(channel, 'CLOSED'); + void channel.transport.close().catch(() => undefined); + } + + private requireOwned( + owner: QAppReticulumOwner, + channelId: string, + rejectClosed: boolean + ): ChannelRecord | undefined { + const key = privateChannelOwnerKey(owner); + const channel = this.channels.get(channelId); + if (channel) { + if (channel.ownerKey !== key) { + throw new PrivateChannelError('CHANNEL_NOT_OWNED'); + } + return channel; + } + const closed = this.closedChannels.get(channelId); + if (closed) { + if (closed.ownerKey !== key) { + throw new PrivateChannelError('CHANNEL_NOT_OWNED'); + } + if (rejectClosed) throw new PrivateChannelError('CHANNEL_CLOSED'); + return undefined; + } + throw new PrivateChannelError('INVALID_CHANNEL'); + } + + private validateChannelId(value: unknown): string { + if (typeof value !== 'string' || !value.startsWith('private-')) { + throw new PrivateChannelError('INVALID_CHANNEL'); + } + return value; + } + + private safeStatus(channel: ChannelRecord) { + return { + channelId: channel.channelId, + state: channel.state, + purpose: channel.purpose, + features: PRIVATE_CHANNEL_FEATURES, + queuedBytes: channel.queuedBytes, + }; + } + + private emitState(channel: ChannelRecord, state: PrivateChannelState): void { + this.emit('event', { + ownerKey: channel.ownerKey, + channelId: channel.channelId, + action: 'PRIVATE_CHANNEL_STATE', + state, + }); + } + + private removeChannel(channel: ChannelRecord): void { + this.channels.delete(channel.channelId); + const owned = this.channelsByOwner.get(channel.ownerKey); + owned?.delete(channel.channelId); + if (owned?.size === 0) this.channelsByOwner.delete(channel.ownerKey); + } + + private rememberClosed(channel: ChannelRecord): void { + this.closedChannels.set(channel.channelId, { + ownerKey: channel.ownerKey, + purpose: channel.purpose, + }); + while (this.closedChannels.size > MAX_CLOSED_CHANNEL_TOMBSTONES) { + const oldest = this.closedChannels.keys().next().value; + if (typeof oldest !== 'string') break; + this.closedChannels.delete(oldest); + } + } +} diff --git a/electron/src/private-channel-step3.test.ts b/electron/src/private-channel-step3.test.ts new file mode 100644 index 00000000..42cdf4f3 --- /dev/null +++ b/electron/src/private-channel-step3.test.ts @@ -0,0 +1,583 @@ +import { + spawn, + spawnSync, + type ChildProcessWithoutNullStreams, +} from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + PrivateChannelManager, + type PrivateTransportContext, +} from './private-channel-manager'; +import { + privateBootstrapOwnerBindingHash, + validatePrivateBootstrapDescriptor, + type PrivateBootstrapDescriptor, + type PrivateChannelBootstrapProvider, +} from './private-channel-bootstrap'; +import { + PrivateTransportSidecar, + type PrivateSessionConfig, + type PrivateTransportSidecarEvent, +} from './private-transport-sidecar'; +import { QuicMasqueTransport } from './quic-masque-transport'; + +type Startup = PrivateSessionConfig & { + expiresAt: number; + backendRnsDestination: string; + transport: 'quic-masque-inner-v1'; +}; +type Stats = { + backendSource: string; + relayEgress: string; + reliableCount: number; + datagramCount: number; + tokenUsed: boolean; +}; + +const goBinary = process.env.QORTAL_GO_BINARY || 'go'; +const goAvailable = + spawnSync(goBinary, ['version'], { windowsHide: true }).status === 0; +const integration = goAvailable ? describe.sequential : describe.skip; +let tempDir = ''; +let sidecarBinary = ''; +let fixtureBinary = ''; + +class Fixture { + readonly child: ChildProcessWithoutNullStreams; + private buffer = ''; + private lines: string[] = []; + private waiters: Array<(line: string) => void> = []; + constructor(wrongAlpn = false) { + this.child = spawn(fixtureBinary, [], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + QORTAL_STEP3_TEST_WRONG_ALPN: wrongAlpn ? '1' : '0', + }, + }); + this.child.stdout.setEncoding('utf8'); + this.child.stderr.resume(); + this.child.stdout.on('data', (chunk: string) => { + this.buffer += chunk; + for (;;) { + const nl = this.buffer.indexOf('\n'); + if (nl < 0) return; + const line = this.buffer.slice(0, nl); + this.buffer = this.buffer.slice(nl + 1); + const waiter = this.waiters.shift(); + if (waiter) waiter(line); + else this.lines.push(line); + } + }); + } + next(): Promise { + const line = this.lines.shift(); + if (line !== undefined) return Promise.resolve(JSON.parse(line)); + return new Promise((resolve) => + this.waiters.push((value) => resolve(JSON.parse(value))) + ); + } + async command(operation: string): Promise { + this.child.stdin.write(`${JSON.stringify({ operation })}\n`); + return this.next(); + } + async close() { + if (this.child.exitCode !== null) return; + await this.command('shutdown').catch(() => undefined); + this.child.kill(); + } +} + +integration('Step 3 inner QUIC through MASQUE', () => { + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qortal-step3-')); + sidecarBinary = path.join(tempDir, 'sidecar'); + fixtureBinary = path.join(tempDir, 'fixture'); + const root = path.join( + process.cwd(), + 'electron', + 'native', + 'private-transport' + ); + for (const [output, pkg] of [ + [sidecarBinary, './cmd/qortal-private-transport'], + [fixtureBinary, './cmd/qortal-private-transport-step3-fixture'], + ]) { + const result = spawnSync( + goBinary, + ['build', '-trimpath', '-o', output, pkg], + { cwd: root, encoding: 'utf8' } + ); + if (result.status !== 0) + throw new Error(result.stderr || result.error?.message); + } + }, 60_000); + afterAll(() => { + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('establishes, attaches, and carries ordered streams plus QUIC datagrams', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + const started = Date.now(); + const opened = await sidecar.openPrivateSession(sessionConfig(config)); + const handshakeMs = Date.now() - started; + const events: PrivateTransportSidecarEvent[] = []; + const received = new Promise((resolve) => + sidecar.on('event', (event: PrivateTransportSidecarEvent) => { + if (event.sessionId === opened.sessionId) { + events.push(event); + if (events.length === 3) resolve(); + } + }) + ); + const rttStart = Date.now(); + await Promise.all([ + sidecar.sendPrivateReliable( + opened.sessionId, + 'r1', + Buffer.from('reliable-one') + ), + sidecar.sendPrivateReliable( + opened.sessionId, + 'r2', + Buffer.from('reliable-two') + ), + sidecar.sendPrivateDatagram( + opened.sessionId, + 'd1', + Buffer.from('datagram-one') + ), + ]); + await received; + const applicationRttMs = Date.now() - rttStart; + expect( + events + .filter((e) => e.event === 'reliableMessage') + .map((e) => e.messageId) + ).toEqual(['r1', 'r2']); + expect(events.find((e) => e.messageId === 'r1')?.data.toString()).toBe( + 'reliable-one' + ); + expect(events.find((e) => e.messageId === 'd1')?.event).toBe('datagram'); + const stats = await fixture.command('stats'); + expect(stats.backendSource).toBe(stats.relayEgress); + expect(stats.tokenUsed).toBe(true); + const metrics = await sidecar.sessionMetrics(opened.sessionId); + expect(metrics.bytesSent).toBeGreaterThan(0); + expect(metrics.bytesReceived).toBeGreaterThan(0); + expect({ + handshakeMs, + applicationRttMs, + innerRttMs: metrics.innerRttMillis, + }).toEqual( + expect.objectContaining({ + handshakeMs: expect.any(Number), + applicationRttMs: expect.any(Number), + innerRttMs: expect.any(Number), + }) + ); + await sidecar.closePrivateSession(opened.sessionId); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + + it('fails closed on backend identity mismatch before attach', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + await expect( + sidecar.openPrivateSession({ + ...sessionConfig(config), + backendCertSha256: '00'.repeat(32), + }) + ).rejects.toMatchObject({ code: 'BACKEND_IDENTITY_MISMATCH' }); + expect((await fixture.command('stats')).tokenUsed).toBe(false); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + it('rejects a wrong attach token and a reused valid token', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + await expect( + sidecar.openPrivateSession({ + ...sessionConfig(config), + attachToken: 'wrong-token', + }) + ).rejects.toMatchObject({ code: 'ATTACH_TOKEN_REJECTED' }); + const opened = await sidecar.openPrivateSession(sessionConfig(config)); + await sidecar.closePrivateSession(opened.sessionId); + await expect( + sidecar.openPrivateSession(sessionConfig(config)) + ).rejects.toMatchObject({ code: 'ATTACH_TOKEN_REJECTED' }); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + it('rejects unsupported ALPN', async () => { + const fixture = new Fixture(true); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + await expect( + sidecar.openPrivateSession(sessionConfig(config)) + ).rejects.toMatchObject({ code: 'INNER_QUIC_FAILED' }); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + it('keeps reliable delivery working when an application datagram is dropped', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + const opened = await sidecar.openPrivateSession(sessionConfig(config)); + await fixture.command('dropNextDatagram'); + const reliable = new Promise((resolve) => + sidecar.on('event', (e: PrivateTransportSidecarEvent) => { + if (e.messageId === 'survives') resolve(e); + }) + ); + await sidecar.sendPrivateDatagram( + opened.sessionId, + 'lost', + Buffer.from('drop-me') + ); + await sidecar.sendPrivateReliable( + opened.sessionId, + 'survives', + Buffer.from('still-reliable') + ); + expect((await reliable).data.toString()).toBe('still-reliable'); + await expect( + sidecar.sendPrivateDatagram( + opened.sessionId, + 'oversized', + Buffer.alloc(1025) + ) + ).rejects.toMatchObject({ code: 'FRAME_TOO_LARGE' }); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + + it('maps the transport through opaque PrivateChannelManager handles', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + const descriptor = descriptorFrom(config); + const provider: PrivateChannelBootstrapProvider = { + getBootstrap: async () => descriptor, + }; + const manager = new PrivateChannelManager( + () => 'owned', + (emit) => new QuicMasqueTransport(emit, sidecar, config, provider) + ); + const owner = { tabId: 'tab-step3', name: 'test-app', service: 'APP' }; + const opened = await manager.open(owner, 'rns-owned', 'realtime'); + expect(opened.channelId).toMatch(/^private-/); + const messages: unknown[] = []; + manager.on('event', (event) => { + if (event.action === 'PRIVATE_CHANNEL_MESSAGE') messages.push(event); + }); + await Promise.all([ + manager.send(owner, opened.channelId, 'reliable', 'm1', { + lane: 'reliable', + }), + manager.send(owner, opened.channelId, 'datagram', 'm2', { + lane: 'datagram', + }), + ]); + for (let i = 0; i < 50 && messages.length < 2; i++) + await new Promise((r) => setTimeout(r, 20)); + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ messageId: 'm1', lane: 'reliable' }), + expect.objectContaining({ messageId: 'm2', lane: 'datagram' }), + ]) + ); + await manager.close(owner, opened.channelId); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + + it('closes the affected logical channel when the sidecar dies', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + const provider: PrivateChannelBootstrapProvider = { + getBootstrap: async () => descriptorFrom(config), + }; + const manager = new PrivateChannelManager( + () => 'owned', + (emit) => new QuicMasqueTransport(emit, sidecar, config, provider) + ); + const owner = { tabId: 'tab-death', name: 'test-app', service: 'APP' }; + const opened = await manager.open(owner, 'rns-owned', 'realtime'); + const closed = new Promise((resolve) => + manager.on('event', (event) => { + if ( + event.channelId === opened.channelId && + event.action === 'PRIVATE_CHANNEL_STATE' && + event.state === 'CLOSED' + ) + resolve(); + }) + ); + sidecar.terminateForTest(); + await closed; + expect(manager.status(owner, opened.channelId).state).toBe('CLOSED'); + await fixture.close(); + }, 20_000); + + it('fails the active connection when the relay dies without a direct fallback', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + const opened = await sidecar.openPrivateSession(sessionConfig(config)); + const failed = new Promise((resolve) => + sidecar.on('event', (event: PrivateTransportSidecarEvent) => { + if (event.sessionId === opened.sessionId && event.event === 'error') + resolve(event); + }) + ); + await fixture.command('stopRelay'); + expect( + ( + (await Promise.race([ + failed, + new Promise((_, reject) => + setTimeout(() => reject(new Error('relay death timeout')), 3000) + ), + ])) as PrivateTransportSidecarEvent + ).code + ).toBeTruthy(); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + + it('propagates backend death as a clean transport error', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + const opened = await sidecar.openPrivateSession(sessionConfig(config)); + const failed = new Promise((resolve) => + sidecar.on('event', (event: PrivateTransportSidecarEvent) => { + if (event.sessionId === opened.sessionId && event.event === 'error') + resolve(event); + }) + ); + await fixture.command('stopBackend'); + expect( + ( + (await Promise.race([ + failed, + new Promise((_, reject) => + setTimeout(() => reject(new Error('backend death timeout')), 3000) + ), + ])) as PrivateTransportSidecarEvent + ).code + ).toBeTruthy(); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + + it('runs a bounded local small-message sanity diagnostic', async () => { + const fixture = new Fixture(); + const config = await fixture.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + const start = Date.now(); + const opened = await sidecar.openPrivateSession(sessionConfig(config)); + const handshakeMs = Date.now() - start; + const waitForEcho = ( + messageId: string, + lane: PrivateTransportSidecarEvent['event'] + ) => + new Promise((resolve) => { + const handler = (event: PrivateTransportSidecarEvent) => { + if ( + event.sessionId === opened.sessionId && + event.messageId === messageId && + event.event === lane + ) { + sidecar.off('event', handler); + resolve(); + } + }; + sidecar.on('event', handler); + }); + const reliableEcho = waitForEcho('diagnostic-reliable', 'reliableMessage'); + const reliableStart = performance.now(); + await sidecar.sendPrivateReliable( + opened.sessionId, + 'diagnostic-reliable', + Buffer.from('ping') + ); + await reliableEcho; + const reliableRttMs = performance.now() - reliableStart; + const datagramEcho = waitForEcho('diagnostic-datagram', 'datagram'); + const datagramStart = performance.now(); + await sidecar.sendPrivateDatagram( + opened.sessionId, + 'diagnostic-datagram', + Buffer.from('ping') + ); + await datagramEcho; + const datagramRttMs = performance.now() - datagramStart; + let received = 0; + const done = new Promise((resolve) => + sidecar.on('event', (event: PrivateTransportSidecarEvent) => { + if ( + event.sessionId === opened.sessionId && + event.event === 'reliableMessage' && + ++received === 100 + ) + resolve(); + }) + ); + const cpuStart = process.cpuUsage(); + const heapStart = process.memoryUsage().heapUsed; + const sendStart = Date.now(); + await Promise.all( + Array.from({ length: 100 }, (_, i) => + sidecar.sendPrivateReliable( + opened.sessionId, + `bench-${i}`, + Buffer.from('small-message') + ) + ) + ); + await done; + const elapsedMs = Date.now() - sendStart; + const cpu = process.cpuUsage(cpuStart); + const heapDelta = process.memoryUsage().heapUsed - heapStart; + console.info('[Step3Sanity]', { + handshakeMs, + reliableRttMs: Number(reliableRttMs.toFixed(2)), + datagramRttMs: Number(datagramRttMs.toFixed(2)), + messages: 100, + elapsedMs, + messagesPerSecond: Math.round(100000 / Math.max(1, elapsedMs)), + parentCpuMs: Math.round((cpu.user + cpu.system) / 1000), + parentHeapDeltaBytes: heapDelta, + }); + expect(received).toBe(100); + expect(elapsedMs).toBeLessThan(10_000); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); +}); + +function descriptorFrom(config: Startup): PrivateBootstrapDescriptor { + return { + version: 1, + transport: 'quic-masque-inner-v1', + logicalSessionId: config.logicalSessionId, + backendRnsDestination: config.backendRnsDestination, + backendTransportEndpoint: config.backendAddress, + backendTransportServerName: config.backendServerName, + backendTransportCertSha256: config.backendCertSha256, + attachToken: config.attachToken, + expiresAt: config.expiresAt, + nonce: config.nonce, + ownerBindingHash: config.ownerBindingHash, + supportedFeatures: { reliable: true, datagrams: true }, + }; +} +function sessionConfig(config: Startup): PrivateSessionConfig { + return { + relayAddress: config.relayAddress, + relayServerName: config.relayServerName, + relayCertSha256: config.relayCertSha256, + backendAddress: config.backendAddress, + backendServerName: config.backendServerName, + backendCertSha256: config.backendCertSha256, + logicalSessionId: config.logicalSessionId, + attachToken: config.attachToken, + nonce: config.nonce, + purpose: config.purpose, + ownerBindingHash: config.ownerBindingHash, + }; +} + +describe('private bootstrap validation', () => { + const owner = { tabId: 'tab', name: 'app', service: 'APP' }; + const context = { + channelId: 'private-test', + rnsConnectionId: 'rns-test', + purpose: 'realtime', + generation: 1, + owner, + } satisfies PrivateTransportContext; + const destination = '0123456789abcdef0123456789abcdef'; + const nonce = 'nonce'; + const binding = privateBootstrapOwnerBindingHash( + owner, + 'rns-test', + destination, + 'logical', + nonce, + 'realtime' + ); + const base = { + version: 1, + transport: 'quic-masque-inner-v1', + logicalSessionId: 'logical', + backendRnsDestination: destination, + backendTransportEndpoint: '127.0.0.1:9000', + backendTransportServerName: 'backend', + backendTransportCertSha256: 'ab'.repeat(32), + attachToken: 'token', + expiresAt: 2000, + nonce, + ownerBindingHash: binding, + supportedFeatures: { reliable: true, datagrams: true }, + }; + const validate = (value: unknown, used = new Set()) => + validatePrivateBootstrapDescriptor(value, { + context, + authenticatedRnsDestination: destination, + requestNonce: nonce, + consumedNonces: used, + now: 1000, + }); + it('accepts a bound descriptor and rejects expiry', () => { + expect(validate(base).logicalSessionId).toBe('logical'); + expect(() => validate({ ...base, expiresAt: 999 })).toThrowError( + expect.objectContaining({ code: 'BOOTSTRAP_EXPIRED' }) + ); + }); + it('rejects wrong RNS/owner binding and reuse', () => { + expect(() => + validate({ ...base, backendRnsDestination: 'f'.repeat(32) }) + ).toThrowError( + expect.objectContaining({ code: 'BOOTSTRAP_BINDING_MISMATCH' }) + ); + expect(() => + validate({ ...base, ownerBindingHash: '0'.repeat(64) }) + ).toThrowError( + expect.objectContaining({ code: 'BOOTSTRAP_BINDING_MISMATCH' }) + ); + const used = new Set(); + validate(base, used); + expect(() => validate(base, used)).toThrowError( + expect.objectContaining({ code: 'BOOTSTRAP_REUSED' }) + ); + }); + it('rejects unsupported bootstrap versions and transports', () => { + expect(() => validate({ ...base, version: 2 })).toThrowError( + expect.objectContaining({ code: 'PROTOCOL_MISMATCH' }) + ); + expect(() => validate({ ...base, transport: 'direct-quic' })).toThrowError( + expect.objectContaining({ code: 'UNSUPPORTED_TRANSPORT' }) + ); + }); +}); diff --git a/electron/src/private-channel-step4.test.ts b/electron/src/private-channel-step4.test.ts new file mode 100644 index 00000000..59178657 --- /dev/null +++ b/electron/src/private-channel-step4.test.ts @@ -0,0 +1,371 @@ +import { + spawn, + spawnSync, + type ChildProcessWithoutNullStreams, +} from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PrivateChannelManager } from './private-channel-manager'; +import { + ReticulumPrivateChannelBootstrapProvider, + type PrivateBootstrapDescriptor, +} from './private-channel-bootstrap'; +import { PrivateTransportSidecar } from './private-transport-sidecar'; +import { QuicMasqueTransport } from './quic-masque-transport'; +import { + QAppReticulumManager, + type QAppReticulumNativeEvent, + type QAppReticulumTransport, +} from './qapp-reticulum-manager'; + +const desktopRoot = process.cwd(); +const backendRoot = + process.env.QORTAL_QAPP_BACKEND_REPO ?? + '/home/qortal/Documents/qapp-backend-call'; +const goBinary = process.env.QORTAL_GO_BINARY || 'go'; +const uvBinary = process.env.QORTAL_UV_BINARY || 'uv'; +const available = + spawnSync(goBinary, ['version']).status === 0 && + spawnSync(uvBinary, ['--version']).status === 0 && + fs.existsSync(path.join(backendRoot, 'scripts/private_transport_fixture.py')); +const integration = available ? describe.sequential : describe.skip; + +type BackendStartup = { + backendAddress: string; + backendDestination: string; + backendCertSha256: string; +}; +type RelayStartup = { + relayAddress: string; + relayServerName: string; + relayCertSha256: string; +}; + +class JsonProcess { + private buffer = ''; + private readonly lines: string[] = []; + private readonly waiters: Array<(value: unknown) => void> = []; + + constructor(readonly child: ChildProcessWithoutNullStreams) { + child.stdout.setEncoding('utf8'); + child.stderr.resume(); + child.stdout.on('data', (chunk: string) => { + this.buffer += chunk; + for (;;) { + const newline = this.buffer.indexOf('\n'); + if (newline < 0) return; + const line = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + const value = JSON.parse(line); + const waiter = this.waiters.shift(); + if (waiter) waiter(value); + else this.lines.push(line); + } + }); + } + + next(): Promise { + const line = this.lines.shift(); + if (line !== undefined) return Promise.resolve(JSON.parse(line) as T); + return new Promise((resolve) => + this.waiters.push((value) => resolve(value as T)) + ); + } + + command( + operation: string, + values: Record = {} + ): Promise { + this.child.stdin.write(`${JSON.stringify({ operation, ...values })}\n`); + return this.next(); + } + + async close(): Promise { + if (this.child.exitCode !== null) return; + await this.command('shutdown').catch(() => undefined); + this.child.kill(); + } +} + +class RealBackendRnsHarness implements QAppReticulumTransport { + readonly listeners = new Set<(event: QAppReticulumNativeEvent) => void>(); + authenticatedLogicalSessionId = ''; + constructor(readonly backend: JsonProcess) {} + + onEvent(listener: (event: QAppReticulumNativeEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async invoke(action: string, payload: Record) { + if (action === 'qapp_rns_connect') { + const authenticated = await this.backend.command<{ + authentication: string; + logicalSessionId: string; + ok: boolean; + }>('bindAuthenticatedSession', { + logicalConnectionId: payload.connectionId, + }); + expect(authenticated).toMatchObject({ + authentication: 'signed-qapp-auth', + ok: true, + }); + this.authenticatedLogicalSessionId = authenticated.logicalSessionId; + return { ok: true, payload: { state: 'CONNECTED' } }; + } + if (action === 'qapp_rns_request') { + expect(payload.path).toBe('/qortal/private-transport/bootstrap/v1'); + expect(payload.logicalConnectionId).toEqual(expect.any(String)); + const request = JSON.parse( + Buffer.from(String(payload.payloadBase64), 'base64').toString('utf8') + ); + const response = await this.backend.command('bootstrap', { + payload: request, + }); + return { + ok: true, + payload: { + payloadBase64: Buffer.from(JSON.stringify(response)).toString( + 'base64' + ), + encoding: 'json', + }, + }; + } + if (action === 'qapp_rns_close') { + await this.backend.command('disconnectReticulum'); + return { ok: true, payload: { state: 'CLOSED' } }; + } + if (action === 'qapp_rns_send') { + return { ok: true, payload: { messageId: 'reticulum-still-usable' } }; + } + return { ok: false, code: 'RNS_PROTOCOL_ERROR' }; + } +} + +integration('Step 4 real backend bootstrap and attachment', () => { + let temporaryDirectory = ''; + let sidecarBinary = ''; + let relayBinary = ''; + + beforeAll(() => { + temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'qortal-step4-') + ); + sidecarBinary = path.join(temporaryDirectory, 'private-transport-sidecar'); + relayBinary = path.join(temporaryDirectory, 'private-transport-relay'); + const nativeRoot = path.join( + desktopRoot, + 'electron', + 'native', + 'private-transport' + ); + for (const [output, source] of [ + [sidecarBinary, './cmd/qortal-private-transport'], + [relayBinary, './cmd/qortal-private-transport-step4-relay'], + ]) { + const result = spawnSync( + goBinary, + ['build', '-trimpath', '-o', output, source], + { cwd: nativeRoot, encoding: 'utf8' } + ); + if (result.status !== 0) + throw new Error(result.stderr || result.error?.message); + } + }, 60_000); + + afterAll(() => { + if (temporaryDirectory) + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + it('uses the real RNS handler to attach MASQUE QUIC to the same session', async () => { + const backend = new JsonProcess( + spawn( + uvBinary, + ['run', 'python', 'scripts/private_transport_fixture.py'], + { cwd: backendRoot, stdio: ['pipe', 'pipe', 'pipe'] } + ) + ); + const backendStartup = await backend.next(); + const relay = new JsonProcess( + spawn(relayBinary, [], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + QORTAL_STEP4_BACKEND_ADDRESS: backendStartup.backendAddress, + }, + }) + ); + const relayStartup = await relay.next(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + const owner = { tabId: 'step4-tab', name: 'qapp-ui-call', service: 'APP' }; + const nativeTransport = new RealBackendRnsHarness(backend); + const rnsManager = new QAppReticulumManager(nativeTransport); + const rns = await rnsManager.connect( + owner, + backendStartup.backendDestination + ); + const bootstrapProvider = new ReticulumPrivateChannelBootstrapProvider( + rnsManager + ); + const channels = new PrivateChannelManager( + (candidateOwner, connectionId) => + rnsManager.connectionOwnership(candidateOwner, connectionId), + (emit) => + new QuicMasqueTransport(emit, sidecar, relayStartup, bootstrapProvider) + ); + try { + const directDescriptor = await bootstrapProvider.getBootstrap({ + channelId: 'identity-and-token-probe', + rnsConnectionId: rns.connectionId, + purpose: 'game', + generation: 1, + owner, + }); + await expect( + sidecar.openPrivateSession({ + ...privateSessionConfig(directDescriptor, relayStartup), + backendCertSha256: '00'.repeat(32), + }) + ).rejects.toMatchObject({ code: 'BACKEND_IDENTITY_MISMATCH' }); + await expect( + sidecar.openPrivateSession({ + ...privateSessionConfig(directDescriptor, relayStartup), + attachToken: 'missing-attach-token', + }) + ).rejects.toMatchObject({ code: 'ATTACH_TOKEN_REJECTED' }); + const directSession = await sidecar.openPrivateSession( + privateSessionConfig(directDescriptor, relayStartup) + ); + await sidecar.closePrivateSession(directSession.sessionId); + await expect( + sidecar.openPrivateSession( + privateSessionConfig(directDescriptor, relayStartup) + ) + ).rejects.toMatchObject({ code: 'ATTACH_TOKEN_REJECTED' }); + for (let index = 0; index < 100; index += 1) { + const detached = await backend.command<{ privateAttached: boolean }>( + 'stats' + ); + if (!detached.privateAttached) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(await backend.command('stats')).toMatchObject({ + privateAttached: false, + outstandingTokens: 0, + }); + + const opened = await channels.open(owner, rns.connectionId, 'game'); + expect(opened.state).toBe('OPEN'); + const stats = await backend.command<{ + logicalSessionId: string; + privateAttached: boolean; + backendPeer: string; + outstandingTokens: number; + }>('stats'); + expect(stats.logicalSessionId).toBe( + nativeTransport.authenticatedLogicalSessionId + ); + expect(stats.privateAttached).toBe(true); + expect(stats.outstandingTokens).toBe(0); + const relayStats = await relay.command<{ relayEgress: string }>('stats'); + expect(stats.backendPeer).toBe(relayStats.relayEgress); + + const messages: Array> = []; + channels.on('event', (event) => { + if (event.action === 'PRIVATE_CHANNEL_MESSAGE') messages.push(event); + }); + await Promise.all([ + channels.send(owner, opened.channelId, 'reliable', 'real-r', { + type: 'private_transport_echo', + value: 'reliable-real-session', + }), + channels.send(owner, opened.channelId, 'datagram', 'real-d', { + type: 'private_transport_echo', + value: 'datagram-real-session', + }), + ]); + for (let index = 0; index < 100 && messages.length < 2; index += 1) + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + channelId: opened.channelId, + lane: 'reliable', + messageId: 'real-r', + }), + expect.objectContaining({ + channelId: opened.channelId, + lane: 'datagram', + messageId: 'real-d', + }), + ]) + ); + expect(await backend.command('reticulumStatus')).toMatchObject({ + usable: true, + logicalSessionId: stats.logicalSessionId, + }); + + await channels.close(owner, opened.channelId); + for (let index = 0; index < 100; index += 1) { + const detached = await backend.command<{ privateAttached: boolean }>( + 'stats' + ); + if (!detached.privateAttached) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(await backend.command('stats')).toMatchObject({ + logicalSessionId: stats.logicalSessionId, + privateAttached: false, + }); + expect(await backend.command('reticulumStatus')).toMatchObject({ + usable: true, + logicalSessionId: stats.logicalSessionId, + }); + + await bootstrapProvider.getBootstrap({ + channelId: 'unused-token', + rnsConnectionId: rns.connectionId, + purpose: 'game', + generation: 1, + owner, + }); + expect(await backend.command('stats')).toMatchObject({ + outstandingTokens: 1, + }); + await rnsManager.close(owner, rns.connectionId); + expect(await backend.command('stats')).toMatchObject({ + outstandingTokens: 0, + }); + expect(backendStartup.backendCertSha256).toMatch(/^[a-f0-9]{64}$/); + } finally { + channels.destroy(); + rnsManager.destroy(); + await sidecar.shutdown(); + await relay.close(); + await backend.close(); + } + }, 30_000); +}); + +function privateSessionConfig( + descriptor: PrivateBootstrapDescriptor, + relay: RelayStartup +) { + return { + relayAddress: relay.relayAddress, + relayServerName: relay.relayServerName, + relayCertSha256: relay.relayCertSha256, + backendAddress: descriptor.backendTransportEndpoint, + backendServerName: descriptor.backendTransportServerName, + backendCertSha256: descriptor.backendTransportCertSha256, + logicalSessionId: descriptor.logicalSessionId, + attachToken: descriptor.attachToken, + nonce: descriptor.nonce, + purpose: 'game', + ownerBindingHash: descriptor.ownerBindingHash, + }; +} diff --git a/electron/src/private-transport-runtime.test.ts b/electron/src/private-transport-runtime.test.ts new file mode 100644 index 00000000..cc2d4a56 --- /dev/null +++ b/electron/src/private-transport-runtime.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { + getExperimentalPrivateTransportFactory, + readTrustedRelayConfig, +} from './private-transport-runtime'; + +describe('private transport runtime selection', () => { + it('uses Reticulum discovery without requiring test relay variables', () => { + expect( + getExperimentalPrivateTransportFactory({} as never, () => null, { + QORTAL_PRIVATE_TRANSPORT: 'masque', + }) + ).toBeTypeOf('function'); + }); + + it('requires pinned relay values in explicit test mode', () => { + expect(() => + getExperimentalPrivateTransportFactory({} as never, () => null, { + QORTAL_PRIVATE_TRANSPORT: 'masque-test', + }) + ).toThrow('Incomplete MASQUE prototype configuration'); + }); + + it('parses the explicit test-mode relay boundary', () => { + expect( + readTrustedRelayConfig({ + QORTAL_MASQUE_TEST_RELAY_ADDRESS: '127.0.0.1:47322', + QORTAL_MASQUE_TEST_RELAY_SERVER_NAME: 'relay.test', + QORTAL_MASQUE_TEST_RELAY_CERT_SHA256: 'ab'.repeat(32), + }) + ).toEqual({ + relayAddress: '127.0.0.1:47322', + relayServerName: 'relay.test', + relayCertSha256: 'ab'.repeat(32), + }); + }); +}); diff --git a/electron/src/private-transport-runtime.ts b/electron/src/private-transport-runtime.ts new file mode 100644 index 00000000..c8d640aa --- /dev/null +++ b/electron/src/private-transport-runtime.ts @@ -0,0 +1,134 @@ +import { app } from 'electron'; +import net from 'net'; +import type { PrivateTransportFactory } from './private-channel-manager'; +import { + PrivateTransportSidecar, + type MasqueTestConfig, +} from './private-transport-sidecar'; +import { QuicMasqueTransport } from './quic-masque-transport'; +import { ReticulumPrivateChannelBootstrapProvider } from './private-channel-bootstrap'; +import type { QAppReticulumManager } from './qapp-reticulum-manager'; +import type { ReticulumBridge } from './reticulum-bridge'; +import { discoverCommunityMasqueRelay } from './masque-relay-discovery'; + +let prototypeSidecar: PrivateTransportSidecar | null = null; +let sidecarShutdownPromise: Promise | null = null; + +/** + * Main-process-only prototype selection. Renderer/Q-App payloads are never + * consulted and production builds deliberately remain on the mock transport. + */ +export function getExperimentalPrivateTransportFactory( + reticulumManager: QAppReticulumManager, + reticulumBridgeProvider: () => ReticulumBridge | null = () => null, + environment: NodeJS.ProcessEnv = process.env +): PrivateTransportFactory | undefined { + const mode = environment.QORTAL_PRIVATE_TRANSPORT; + if (app.isPackaged || (mode !== 'masque' && mode !== 'masque-test')) { + return undefined; + } + const explicitRelay = + mode === 'masque-test' ? readTrustedRelayConfig(environment) : null; + const bootstrapProvider = new ReticulumPrivateChannelBootstrapProvider( + reticulumManager + ); + prototypeSidecar ??= new PrivateTransportSidecar(); + return (emit) => + new QuicMasqueTransport( + emit, + prototypeSidecar!, + explicitRelay ?? + (async () => { + const reticulumBridge = reticulumBridgeProvider(); + if (!reticulumBridge) throw new Error('MASQUE_RELAY_UNAVAILABLE'); + return discoverCommunityMasqueRelay(reticulumBridge, { + allowLoopback: + environment.QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY === '1', + }); + }), + bootstrapProvider + ); +} + +export function readTrustedRelayConfig(environment: NodeJS.ProcessEnv) { + const relayAddress = required(environment.QORTAL_MASQUE_TEST_RELAY_ADDRESS); + assertLiteralEndpoint(relayAddress, 'relay'); + const relayServerName = required( + environment.QORTAL_MASQUE_TEST_RELAY_SERVER_NAME + ); + const relayCertSha256 = required( + environment.QORTAL_MASQUE_TEST_RELAY_CERT_SHA256 + ).toLowerCase(); + if (!/^[a-f0-9]{64}$/.test(relayCertSha256)) { + throw new Error('Invalid MASQUE test certificate pin'); + } + return { relayAddress, relayServerName, relayCertSha256 }; +} + +export async function shutdownPrivateTransportSidecar(): Promise { + if (sidecarShutdownPromise) return sidecarShutdownPromise; + const sidecar = prototypeSidecar; + prototypeSidecar = null; + if (!sidecar) return; + sidecarShutdownPromise = sidecar.shutdown().finally(() => { + sidecarShutdownPromise = null; + }); + return sidecarShutdownPromise; +} + +export function readMasqueTestConfig( + environment: NodeJS.ProcessEnv +): MasqueTestConfig { + const relayAddress = required(environment.QORTAL_MASQUE_TEST_RELAY_ADDRESS); + const targetAddress = required(environment.QORTAL_MASQUE_TEST_TARGET_ADDRESS); + assertLiteralEndpoint(relayAddress, 'relay'); + assertLiteralEndpoint(targetAddress, 'target'); + const relayServerName = required( + environment.QORTAL_MASQUE_TEST_RELAY_SERVER_NAME + ); + const relayCertSha256 = required( + environment.QORTAL_MASQUE_TEST_RELAY_CERT_SHA256 + ).toLowerCase(); + if (!/^[a-f0-9]{64}$/.test(relayCertSha256)) { + throw new Error('Invalid MASQUE test certificate pin'); + } + return { + relayAddress, + relayServerName, + relayCertSha256, + targetAddress, + }; +} + +function required(value: string | undefined): string { + const result = String(value ?? '').trim(); + if (!result) throw new Error('Incomplete MASQUE prototype configuration'); + return result; +} + +function assertLiteralEndpoint(value: string, label: string): void { + let host = ''; + let portText = ''; + if (value.startsWith('[')) { + const end = value.indexOf(']:'); + if (end > 0) { + host = value.slice(1, end); + portText = value.slice(end + 2); + } + } else { + const separator = value.lastIndexOf(':'); + if (separator > 0) { + host = value.slice(0, separator); + portText = value.slice(separator + 1); + } + } + const port = Number(portText); + if ( + net.isIP(host) === 0 || + !Number.isInteger(port) || + port < 1 || + port > 65535 + ) { + throw new Error(`Invalid literal ${label} endpoint`); + } +} diff --git a/electron/src/private-transport-sidecar.test.ts b/electron/src/private-transport-sidecar.test.ts new file mode 100644 index 00000000..d002267c --- /dev/null +++ b/electron/src/private-transport-sidecar.test.ts @@ -0,0 +1,235 @@ +import { + spawn, + spawnSync, + type ChildProcessWithoutNullStreams, +} from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + PrivateTransportSidecar, + PrivateTransportSidecarError, + type MasqueTestConfig, +} from './private-transport-sidecar'; +import { readMasqueTestConfig } from './private-transport-runtime'; + +type FixtureStartup = MasqueTestConfig; +type FixtureStats = { + echoCount: number; + echoSource: string; + relayEgress: string; +}; + +let temporaryDirectory = ''; +let sidecarBinary = ''; +let fixtureBinary = ''; +const goBinary = process.env.QORTAL_GO_BINARY || 'go'; +const goAvailable = + spawnSync(goBinary, ['version'], { windowsHide: true }).status === 0; + +class FixtureProcess { + readonly child: ChildProcessWithoutNullStreams; + private buffer = ''; + private lines: string[] = []; + private waiters: Array<(line: string) => void> = []; + + constructor() { + this.child = spawn(fixtureBinary, [], { stdio: ['pipe', 'pipe', 'pipe'] }); + this.child.stdout.setEncoding('utf8'); + this.child.stderr.resume(); + this.child.stdout.on('data', (chunk: string) => { + this.buffer += chunk; + for (;;) { + const newline = this.buffer.indexOf('\n'); + if (newline < 0) return; + const line = this.buffer.slice(0, newline); + this.buffer = this.buffer.slice(newline + 1); + const waiter = this.waiters.shift(); + if (waiter) waiter(line); + else this.lines.push(line); + } + }); + } + + async startup(): Promise { + return JSON.parse(await this.nextLine()) as FixtureStartup; + } + + async command(operation: string): Promise { + this.child.stdin.write(`${JSON.stringify({ operation })}\n`); + return JSON.parse(await this.nextLine()) as T; + } + + async close(): Promise { + if (this.child.exitCode !== null) return; + await this.command('shutdown').catch(() => undefined); + this.child.kill(); + } + + private nextLine(): Promise { + const line = this.lines.shift(); + if (line !== undefined) return Promise.resolve(line); + return new Promise((resolve) => this.waiters.push(resolve)); + } +} + +const describeIntegration = goAvailable ? describe.sequential : describe.skip; + +describeIntegration('private transport sidecar integration', () => { + beforeAll(() => { + temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'qortal-private-transport-') + ); + sidecarBinary = path.join(temporaryDirectory, 'qortal-private-transport'); + fixtureBinary = path.join( + temporaryDirectory, + 'qortal-private-transport-test-fixture' + ); + const moduleRoot = path.join( + process.cwd(), + 'electron', + 'native', + 'private-transport' + ); + for (const [output, pkg] of [ + [sidecarBinary, './cmd/qortal-private-transport'], + [fixtureBinary, './cmd/qortal-private-transport-test-fixture'], + ]) { + const result = spawnSync( + goBinary, + ['build', '-trimpath', '-o', output, pkg], + { + cwd: moduleRoot, + encoding: 'utf8', + windowsHide: true, + } + ); + if (result.status !== 0) { + throw new Error( + `Go fixture build failed: ${result.error?.message ?? result.stderr}` + ); + } + } + }, 60_000); + + afterAll(() => { + if (temporaryDirectory) + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + it('handshakes, tunnels a datagram, and shuts down cleanly', async () => { + const fixture = new FixtureProcess(); + const config = await fixture.startup(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + expect(await sidecar.health()).toEqual({ + service: 'qortal-private-transport', + sidecarVersion: '0.2.0', + protocolVersion: 2, + innerAlpn: 'qortal-private/1', + }); + + const tunnelId = await sidecar.openMasqueTunnel(config); + expect(tunnelId).toMatch(/^tunnel-/); + const payload = Buffer.from('known-connect-udp-datagram'); + await sidecar.sendDatagram(tunnelId, payload); + expect(await sidecar.receiveDatagram(tunnelId)).toEqual(payload); + const stats = await fixture.command('stats'); + expect(stats.echoCount).toBe(1); + expect(stats.echoSource).toBe(stats.relayEgress); + + await sidecar.closeTunnel(tunnelId); + await sidecar.shutdown(); + expect(sidecar.isRunning()).toBe(false); + await fixture.close(); + }, 20_000); + + it('fails closed on a certificate pin mismatch', async () => { + const fixture = new FixtureProcess(); + const config = await fixture.startup(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + await expect( + sidecar.openMasqueTunnel({ + ...config, + relayCertSha256: '00'.repeat(32), + timeoutMs: 1_000, + }) + ).rejects.toMatchObject({ code: 'MASQUE_OPEN_FAILED' }); + expect((await fixture.command('stats')).echoCount).toBe(0); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + + it('never falls back directly when the literal-IP relay is unavailable', async () => { + const fixture = new FixtureProcess(); + const config = await fixture.startup(); + await fixture.command('stopRelay'); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + await expect( + sidecar.openMasqueTunnel({ ...config, timeoutMs: 500 }) + ).rejects.toMatchObject({ code: 'MASQUE_OPEN_FAILED' }); + expect((await fixture.command('stats')).echoCount).toBe(0); + await sidecar.shutdown(); + await fixture.close(); + }, 20_000); + + it('rejects pending work when the sidecar exits mid-request', async () => { + const fixture = new FixtureProcess(); + const config = await fixture.startup(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + await sidecar.start(); + const tunnelId = await sidecar.openMasqueTunnel(config); + const pending = sidecar.receiveDatagram(tunnelId, 10_000); + await new Promise((resolve) => setTimeout(resolve, 50)); + sidecar.terminateForTest(); + await expect(pending).rejects.toMatchObject({ code: 'SIDECAR_EXITED' }); + expect(sidecar.isRunning()).toBe(false); + await fixture.close(); + }, 20_000); + + it('kills a sidecar that emits malformed IPC without crashing Electron', async () => { + const script = ` + const readline = require('readline'); + let count = 0; + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const request = JSON.parse(line); + if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.2.0',protocolVersion:2,innerAlpn:'qortal-private/1'}}) + '\\n'); + else process.stdout.write('{not-json\\n'); + }); + `; + const sidecar = new PrivateTransportSidecar({ + command: process.execPath, + args: ['-e', script], + }); + await sidecar.start(); + await expect( + sidecar.openMasqueTunnel({ + relayAddress: '127.0.0.1:1', + relayServerName: 'test', + relayCertSha256: '00'.repeat(32), + targetAddress: '127.0.0.1:2', + }) + ).rejects.toMatchObject({ code: 'MALFORMED_RESPONSE' }); + expect(sidecar.isRunning()).toBe(false); + }); +}); + +describe('MASQUE prototype configuration boundary', () => { + it('accepts only trusted literal endpoints', () => { + expect(() => + readMasqueTestConfig({ + QORTAL_MASQUE_TEST_RELAY_ADDRESS: 'relay.example:443', + QORTAL_MASQUE_TEST_TARGET_ADDRESS: '127.0.0.1:9000', + QORTAL_MASQUE_TEST_RELAY_SERVER_NAME: 'relay.example', + QORTAL_MASQUE_TEST_RELAY_CERT_SHA256: 'ab'.repeat(32), + }) + ).toThrow('Invalid literal relay endpoint'); + }); + + it('uses stable coded sidecar errors', () => { + expect(new PrivateTransportSidecarError('TEST').code).toBe('TEST'); + }); +}); diff --git a/electron/src/private-transport-sidecar.ts b/electron/src/private-transport-sidecar.ts new file mode 100644 index 00000000..86e42505 --- /dev/null +++ b/electron/src/private-transport-sidecar.ts @@ -0,0 +1,483 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; +import { randomUUID } from 'crypto'; +import { app } from 'electron'; +import { EventEmitter } from 'events'; +import path from 'path'; + +export const PRIVATE_TRANSPORT_PROTOCOL_VERSION = 2; +export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.2.0'; +const MAX_RESPONSE_LINE_BYTES = 64 * 1024; +const DEFAULT_REQUEST_TIMEOUT_MS = 7_000; + +export class PrivateTransportSidecarError extends Error { + constructor( + public readonly code: string, + message = code + ) { + super(message); + } +} + +export type MasqueTestConfig = Readonly<{ + relayAddress: string; + relayServerName: string; + relayCertSha256: string; + targetAddress: string; + timeoutMs?: number; +}>; + +export type PrivateSessionConfig = Readonly<{ + relayAddress: string; + relayServerName: string; + relayCertSha256: string; + backendAddress: string; + backendServerName: string; + backendCertSha256: string; + logicalSessionId: string; + attachToken: string; + nonce: string; + purpose: string; + ownerBindingHash: string; + timeoutMs?: number; +}>; + +export type PrivateTransportSidecarEvent = { + event: 'reliableMessage' | 'datagram' | 'error'; + sessionId: string; + messageId?: string; + code?: string; + data: Buffer; +}; + +type SidecarOperation = + | 'health' + | 'openMasqueTunnel' + | 'sendDatagram' + | 'receiveDatagram' + | 'closeTunnel' + | 'openPrivateSession' + | 'sendPrivateReliable' + | 'sendPrivateDatagram' + | 'sessionMetrics' + | 'closePrivateSession' + | 'shutdown'; + +type SidecarResponse = { + version: number; + requestId: string; + ok: boolean; + result?: unknown; + error?: { code?: unknown; message?: unknown }; + type?: unknown; + event?: unknown; + sessionId?: unknown; + messageId?: unknown; + code?: unknown; + binaryLength?: unknown; +}; + +type PendingRequest = { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; +}; + +export type PrivateTransportSidecarTestOptions = { + /** Test-only trusted launch override. Never populated from renderer input. */ + command?: string; + args?: string[]; + requestTimeoutMs?: number; +}; + +function platformDirectory(): string { + const platform = + process.platform === 'win32' + ? 'windows' + : process.platform === 'darwin' + ? 'darwin' + : 'linux'; + const arch = process.arch === 'arm64' ? 'arm64' : 'x64'; + return `${platform}-${arch}`; +} + +export function getPrivateTransportSidecarPath(): string { + const executable = + process.platform === 'win32' + ? 'qortal-private-transport.exe' + : 'qortal-private-transport'; + const root = app.isPackaged ? process.resourcesPath : app.getAppPath(); + return path.join( + root, + 'resources', + 'private-transport', + platformDirectory(), + executable + ); +} + +export class PrivateTransportSidecar extends EventEmitter { + private child: ChildProcessWithoutNullStreams | null = null; + private startPromise: Promise | null = null; + private stdoutBuffer = Buffer.alloc(0); + private pendingBinaryEvent: + | (Omit & { + binaryLength: number; + }) + | null = null; + private readonly pending = new Map(); + private stopping = false; + + constructor( + private readonly testOptions: PrivateTransportSidecarTestOptions = {} + ) { + super(); + } + + async start(): Promise { + if (this.startPromise) return this.startPromise; + if (this.child) return; + this.startPromise = this.spawnAndHandshake().finally(() => { + this.startPromise = null; + }); + return this.startPromise; + } + + async health(): Promise<{ + service: string; + sidecarVersion: string; + protocolVersion: number; + innerAlpn?: string; + }> { + const result = await this.request('health', {}); + return result as { + service: string; + sidecarVersion: string; + protocolVersion: number; + innerAlpn?: string; + }; + } + + async openMasqueTunnel(config: MasqueTestConfig): Promise { + await this.start(); + const result = (await this.request('openMasqueTunnel', config)) as { + tunnelId?: unknown; + }; + if (typeof result?.tunnelId !== 'string' || !result.tunnelId) { + throw new PrivateTransportSidecarError('MALFORMED_RESPONSE'); + } + return result.tunnelId; + } + + async sendDatagram(tunnelId: string, data: Buffer): Promise { + await this.request('sendDatagram', { + tunnelId, + dataBase64: data.toString('base64'), + }); + } + + async receiveDatagram(tunnelId: string, timeoutMs = 5_000): Promise { + const result = (await this.request( + 'receiveDatagram', + { tunnelId, timeoutMs }, + timeoutMs + 1_000 + )) as { dataBase64?: unknown }; + if (typeof result?.dataBase64 !== 'string') { + throw new PrivateTransportSidecarError('MALFORMED_RESPONSE'); + } + return Buffer.from(result.dataBase64, 'base64'); + } + + async closeTunnel(tunnelId: string): Promise { + if (!this.child) return; + await this.request('closeTunnel', { tunnelId }); + } + + async openPrivateSession(config: PrivateSessionConfig): Promise<{ + sessionId: string; + innerQuicConnectionId: string; + logicalSessionId: string; + transportGeneration: number; + }> { + await this.start(); + const result = (await this.request('openPrivateSession', config)) as Record< + string, + unknown + >; + if ( + typeof result?.sessionId !== 'string' || + typeof result?.innerQuicConnectionId !== 'string' || + typeof result?.logicalSessionId !== 'string' || + result?.transportGeneration !== 1 + ) + throw new PrivateTransportSidecarError('MALFORMED_RESPONSE'); + return result as { + sessionId: string; + innerQuicConnectionId: string; + logicalSessionId: string; + transportGeneration: number; + }; + } + + async sendPrivateReliable( + sessionId: string, + messageId: string, + data: Buffer + ): Promise { + await this.request( + 'sendPrivateReliable', + { sessionId, messageId }, + undefined, + data + ); + } + + async sendPrivateDatagram( + sessionId: string, + messageId: string, + data: Buffer + ): Promise { + await this.request( + 'sendPrivateDatagram', + { sessionId, messageId }, + undefined, + data + ); + } + + async sessionMetrics(sessionId: string): Promise> { + return (await this.request('sessionMetrics', { sessionId })) as Record< + string, + number + >; + } + + async closePrivateSession(sessionId: string): Promise { + if (this.child) await this.request('closePrivateSession', { sessionId }); + } + + async shutdown(): Promise { + const child = this.child; + if (!child) return; + this.stopping = true; + try { + await this.request('shutdown', {}, 1_000); + } catch { + // Exit/kill below remains authoritative. + } + if (this.child === child && child.exitCode === null) { + await Promise.race([ + new Promise((resolve) => child.once('exit', () => resolve())), + new Promise((resolve) => setTimeout(resolve, 500)), + ]); + } + if (this.child === child && child.exitCode === null) child.kill(); + this.child = null; + this.rejectPending('SIDECAR_STOPPED'); + this.stopping = false; + } + + /** Exposed for lifecycle tests; production callers use the typed methods. */ + isRunning(): boolean { + return this.child !== null; + } + + /** Test-only fault injection; not reachable from preload or renderer IPC. */ + terminateForTest(): void { + this.child?.kill(); + } + + private async spawnAndHandshake(): Promise { + const command = + this.testOptions.command ?? getPrivateTransportSidecarPath(); + const args = this.testOptions.args ?? []; + const child = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + shell: false, + }); + this.child = child; + this.stopping = false; + child.stderr.resume(); + child.stdout.on('data', (chunk: Buffer) => this.handleStdout(child, chunk)); + child.on('error', () => this.handleDeath(child, 'SIDECAR_SPAWN_FAILED')); + child.on('exit', () => this.handleDeath(child, 'SIDECAR_EXITED')); + + try { + const health = await this.health(); + if ( + health.service !== 'qortal-private-transport' || + health.sidecarVersion !== PRIVATE_TRANSPORT_SIDECAR_VERSION || + health.protocolVersion !== PRIVATE_TRANSPORT_PROTOCOL_VERSION + ) { + throw new PrivateTransportSidecarError('SIDECAR_VERSION_MISMATCH'); + } + } catch (error) { + if (this.child === child) child.kill(); + this.child = null; + throw error; + } + } + + private request( + operation: SidecarOperation, + params: unknown, + timeoutMs = this.testOptions.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, + binary: Uint8Array = Buffer.alloc(0) + ): Promise { + const child = this.child; + if (!child || !child.stdin.writable) { + return Promise.reject( + new PrivateTransportSidecarError('SIDECAR_NOT_RUNNING') + ); + } + const requestId = randomUUID(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId); + reject(new PrivateTransportSidecarError('SIDECAR_REQUEST_TIMEOUT')); + }, timeoutMs); + this.pending.set(requestId, { resolve, reject, timer }); + const frame = JSON.stringify({ + version: PRIVATE_TRANSPORT_PROTOCOL_VERSION, + requestId, + operation, + params, + binaryLength: binary.length || undefined, + }); + child.stdin.write( + Buffer.concat([Buffer.from(`${frame}\n`), binary]), + (error) => { + if (!error) return; + const pending = this.pending.get(requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(requestId); + pending.reject( + new PrivateTransportSidecarError('SIDECAR_WRITE_FAILED') + ); + } + ); + }); + } + + private handleStdout( + child: ChildProcessWithoutNullStreams, + chunk: Buffer + ): void { + if (this.child !== child) return; + this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]); + for (;;) { + if (this.pendingBinaryEvent) { + if (this.stdoutBuffer.length < this.pendingBinaryEvent.binaryLength) + return; + const pending = this.pendingBinaryEvent; + const data = this.stdoutBuffer.subarray(0, pending.binaryLength); + this.stdoutBuffer = this.stdoutBuffer.subarray(pending.binaryLength); + this.pendingBinaryEvent = null; + this.emit('event', { ...pending, binaryLength: undefined, data }); + continue; + } + const newline = this.stdoutBuffer.indexOf(0x0a); + if (newline === -1) { + if (this.stdoutBuffer.length > MAX_RESPONSE_LINE_BYTES) { + this.failMalformed(child); + } + return; + } + const line = this.stdoutBuffer + .subarray(0, newline) + .toString('utf8') + .trim(); + this.stdoutBuffer = this.stdoutBuffer.subarray(newline + 1); + if (!line) continue; + if (Buffer.byteLength(line, 'utf8') > MAX_RESPONSE_LINE_BYTES) { + this.failMalformed(child); + return; + } + let response: SidecarResponse; + try { + response = JSON.parse(line) as SidecarResponse; + } catch { + this.failMalformed(child); + return; + } + if (response.type === 'event') { + if ( + response.version !== PRIVATE_TRANSPORT_PROTOCOL_VERSION || + typeof response.event !== 'string' || + !['reliableMessage', 'datagram', 'error'].includes(response.event) || + typeof response.sessionId !== 'string' || + (response.binaryLength !== undefined && + (!Number.isInteger(response.binaryLength) || + (response.binaryLength as number) < 0 || + (response.binaryLength as number) > 64 * 1024)) + ) { + this.failMalformed(child); + return; + } + const event = { + event: response.event as PrivateTransportSidecarEvent['event'], + sessionId: response.sessionId, + messageId: + typeof response.messageId === 'string' + ? response.messageId + : undefined, + code: typeof response.code === 'string' ? response.code : undefined, + binaryLength: Number(response.binaryLength ?? 0), + }; + if (event.binaryLength === 0) + this.emit('event', { ...event, data: Buffer.alloc(0) }); + else this.pendingBinaryEvent = event; + continue; + } + if ( + response.version !== PRIVATE_TRANSPORT_PROTOCOL_VERSION || + typeof response.requestId !== 'string' || + typeof response.ok !== 'boolean' + ) { + this.failMalformed(child); + return; + } + const pending = this.pending.get(response.requestId); + if (!pending) { + this.failMalformed(child); + return; + } + clearTimeout(pending.timer); + this.pending.delete(response.requestId); + if (response.ok) pending.resolve(response.result); + else { + const code = + typeof response.error?.code === 'string' + ? response.error.code + : 'SIDECAR_OPERATION_FAILED'; + pending.reject(new PrivateTransportSidecarError(code)); + } + } + } + + private failMalformed(child: ChildProcessWithoutNullStreams): void { + this.handleDeath(child, 'MALFORMED_RESPONSE'); + child.kill(); + } + + private handleDeath( + child: ChildProcessWithoutNullStreams, + code: string + ): void { + if (this.child !== child) return; + this.child = null; + this.stdoutBuffer = Buffer.alloc(0); + this.pendingBinaryEvent = null; + this.emit('death', code); + this.rejectPending(this.stopping ? 'SIDECAR_STOPPED' : code); + } + + private rejectPending(code: string): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new PrivateTransportSidecarError(code)); + } + this.pending.clear(); + } +} diff --git a/electron/src/qapp-reticulum-manager.test.ts b/electron/src/qapp-reticulum-manager.test.ts index b8b71602..b39cdd34 100644 --- a/electron/src/qapp-reticulum-manager.test.ts +++ b/electron/src/qapp-reticulum-manager.test.ts @@ -41,6 +41,14 @@ const bob: QAppReticulumOwner = { }; describe('QAppReticulumManager', () => { + it('reports connection ownership without exposing its destination', async () => { + const manager = new QAppReticulumManager(new FakeTransport()); + const { connectionId } = await manager.connect(alice, destination); + expect(manager.connectionOwnership(alice, connectionId)).toBe('owned'); + expect(manager.connectionOwnership(bob, connectionId)).toBe('not-owned'); + expect(manager.connectionOwnership(alice, 'missing')).toBe('missing'); + }); + it('uses the same isolated manager key for RPC and realtime', async () => { const transport = new FakeTransport(); const manager = new QAppReticulumManager(transport); @@ -51,6 +59,28 @@ describe('QAppReticulumManager', () => { expect(connectPayload.managerKey).toBe(requestPayload.managerKey); }); + it('binds reserved main-process RPCs to an owned logical connection', async () => { + const transport = new FakeTransport(); + const manager = new QAppReticulumManager(transport); + const { connectionId } = await manager.connect(alice, destination); + await manager.request(alice, { + destination, + path: '/qortal/private-transport/bootstrap/v1', + payload: {}, + connectionId, + }); + expect(transport.invoke.mock.calls[1][1]).toMatchObject({ + logicalConnectionId: connectionId, + }); + await expect( + manager.request(bob, { + destination, + path: '/qortal/private-transport/bootstrap/v1', + connectionId, + }) + ).rejects.toMatchObject({ code: 'RNS_INVALID_CONNECTION' }); + }); + it('encodes the frozen RPC request payload and decodes its JSON response', async () => { const transport = new FakeTransport(); const manager = new QAppReticulumManager(transport); diff --git a/electron/src/qapp-reticulum-manager.ts b/electron/src/qapp-reticulum-manager.ts index bc64f594..90f4b787 100644 --- a/electron/src/qapp-reticulum-manager.ts +++ b/electron/src/qapp-reticulum-manager.ts @@ -16,6 +16,11 @@ export type QAppReticulumOwner = { service: string; }; +export type QAppReticulumConnectionOwnership = + | 'owned' + | 'not-owned' + | 'missing'; + export type QAppReticulumNativeEvent = { managerKey: string; connectionId?: string; @@ -122,9 +127,17 @@ export class QAppReticulumManager extends EventEmitter { timeoutMs?: number; maxResponseBytes?: number; requestId?: string; + /** Main-process binding for reserved RPCs on an owned logical session. */ + connectionId?: string; } ): Promise { const destination = this.validateDestination(options.destination); + if (options.connectionId) { + const connection = this.requireOwned(owner, options.connectionId); + if (connection.destination !== destination) { + throw new QAppReticulumError('RNS_PERMISSION_DENIED'); + } + } const path = String(options.path ?? '').trim(); if (!path.startsWith('/') || path.length > 512) { throw new QAppReticulumError( @@ -158,6 +171,7 @@ export class QAppReticulumManager extends EventEmitter { MAX_RPC_RESPONSE_BYTES ), requestId: String(options.requestId ?? randomUUID()), + logicalConnectionId: options.connectionId, }); if (!result.ok || !result.payload) { throw new QAppReticulumError(result.code ?? 'RNS_REQUEST_TIMEOUT'); @@ -277,6 +291,29 @@ export class QAppReticulumManager extends EventEmitter { await Promise.allSettled(ids.map((id) => this.close(owner, id))); } + connectionOwnership( + owner: QAppReticulumOwner, + connectionIdValue: unknown + ): QAppReticulumConnectionOwnership { + const connectionId = + typeof connectionIdValue === 'string' ? connectionIdValue : ''; + const connection = this.connections.get(connectionId); + if (!connection) return 'missing'; + if (connection.ownerKey !== ownerKey(owner)) return 'not-owned'; + return connection.state === 'CONNECTED' || + connection.state === 'RECONNECTING' + ? 'owned' + : 'missing'; + } + + /** Electron-main-only lookup used by reserved authenticated services. */ + connectionDestination( + owner: QAppReticulumOwner, + connectionId: string + ): string { + return this.requireOwned(owner, connectionId).destination; + } + destroy(): void { this.detachTransport(); this.connections.clear(); diff --git a/electron/src/quic-masque-transport.ts b/electron/src/quic-masque-transport.ts new file mode 100644 index 00000000..78596610 --- /dev/null +++ b/electron/src/quic-masque-transport.ts @@ -0,0 +1,190 @@ +import { + PrivateChannelError, + type PrivateTransport, + type PrivateTransportContext, + type PrivateTransportEvent, + type PrivateTransportMessage, +} from './private-channel-manager'; +import type { PrivateChannelBootstrapProvider } from './private-channel-bootstrap'; +import { + PrivateTransportSidecar, + PrivateTransportSidecarError, + type PrivateTransportSidecarEvent, +} from './private-transport-sidecar'; + +export type TrustedRelayConfig = Readonly<{ + relayAddress: string; + relayServerName: string; + relayCertSha256: string; +}>; +export type TrustedRelayProvider = () => Promise; + +const MAX_RELIABLE_BINARY_BYTES = 64 * 1024; +const MAX_DATAGRAM_BINARY_BYTES = 1024; + +export class QuicMasqueTransport implements PrivateTransport { + private sessionId: string | null = null; + private closed = false; + private readonly onSidecarEvent = (event: PrivateTransportSidecarEvent) => + this.handleSidecarEvent(event); + private readonly onSidecarDeath = () => { + if (!this.closed && this.sessionId) { + this.emit({ kind: 'error', code: 'TRANSPORT_CLOSED' }); + } + }; + + constructor( + private readonly emit: (event: PrivateTransportEvent) => void, + private readonly sidecar: PrivateTransportSidecar, + private readonly relay: TrustedRelayConfig | TrustedRelayProvider, + private readonly bootstrapProvider: PrivateChannelBootstrapProvider + ) { + sidecar.on('event', this.onSidecarEvent); + sidecar.on('death', this.onSidecarDeath); + } + + async open(context: PrivateTransportContext): Promise { + if (this.closed) throw new PrivateChannelError('TRANSPORT_CLOSED'); + const bootstrap = await this.bootstrapProvider.getBootstrap(context); + try { + const relay = + typeof this.relay === 'function' ? await this.relay() : this.relay; + const opened = await this.sidecar.openPrivateSession({ + relayAddress: relay.relayAddress, + relayServerName: relay.relayServerName, + relayCertSha256: relay.relayCertSha256, + backendAddress: bootstrap.backendTransportEndpoint, + backendServerName: bootstrap.backendTransportServerName, + backendCertSha256: bootstrap.backendTransportCertSha256, + logicalSessionId: bootstrap.logicalSessionId, + attachToken: bootstrap.attachToken, + nonce: bootstrap.nonce, + purpose: context.purpose, + ownerBindingHash: bootstrap.ownerBindingHash, + }); + this.sessionId = opened.sessionId; + } catch (error) { + throw translateSidecarError(error); + } + } + + async sendReliable(message: PrivateTransportMessage): Promise { + const sessionId = this.requireSession(); + const encoded = encodeApplicationData(message.data); + if (encoded.length > MAX_RELIABLE_BINARY_BYTES) { + throw new PrivateChannelError('MESSAGE_TOO_LARGE_FOR_TRANSPORT'); + } + try { + await this.sidecar.sendPrivateReliable( + sessionId, + message.messageId, + encoded + ); + } catch (error) { + throw translateSidecarError(error); + } + } + + async sendDatagram(message: PrivateTransportMessage): Promise { + const sessionId = this.requireSession(); + const encoded = encodeApplicationData(message.data); + if (encoded.length > MAX_DATAGRAM_BINARY_BYTES) { + throw new PrivateChannelError('MESSAGE_TOO_LARGE_FOR_TRANSPORT'); + } + try { + await this.sidecar.sendPrivateDatagram( + sessionId, + message.messageId, + encoded + ); + } catch (error) { + throw translateSidecarError(error); + } + } + + async close(): Promise { + this.closed = true; + const sessionId = this.sessionId; + this.sessionId = null; + this.sidecar.off('event', this.onSidecarEvent); + this.sidecar.off('death', this.onSidecarDeath); + if (sessionId) + await this.sidecar.closePrivateSession(sessionId).catch(() => undefined); + } + + private requireSession(): string { + if (this.closed || !this.sessionId) + throw new PrivateChannelError('TRANSPORT_CLOSED'); + return this.sessionId; + } + + private handleSidecarEvent(event: PrivateTransportSidecarEvent): void { + if (event.sessionId !== this.sessionId || this.closed) return; + if (event.event === 'error') { + this.emit({ kind: 'error', code: event.code ?? 'TRANSPORT_ERROR' }); + return; + } + if (!event.messageId) { + this.emit({ kind: 'error', code: 'PROTOCOL_MISMATCH' }); + return; + } + try { + this.emit({ + kind: 'message', + lane: event.event === 'datagram' ? 'datagram' : 'reliable', + messageId: event.messageId, + data: decodeApplicationData(event.data), + }); + } catch { + this.emit({ kind: 'error', code: 'PROTOCOL_MISMATCH' }); + } + } +} + +function encodeApplicationData(value: unknown): Buffer { + if (value instanceof ArrayBuffer) + return Buffer.concat([Buffer.from([1]), Buffer.from(value)]); + if (ArrayBuffer.isView(value)) { + return Buffer.concat([ + Buffer.from([1]), + Buffer.from(value.buffer, value.byteOffset, value.byteLength), + ]); + } + try { + return Buffer.concat([ + Buffer.from([0]), + Buffer.from(JSON.stringify(value ?? null), 'utf8'), + ]); + } catch { + throw new PrivateChannelError('INVALID_MESSAGE'); + } +} + +function decodeApplicationData(value: Buffer): unknown { + if (value.length < 1) throw new Error('missing application encoding'); + if (value[0] === 1) return new Uint8Array(value.subarray(1)); + if (value[0] === 0) return JSON.parse(value.subarray(1).toString('utf8')); + throw new Error('unsupported application encoding'); +} + +function translateSidecarError(error: unknown): PrivateChannelError { + const code = + error instanceof PrivateTransportSidecarError + ? error.code + : error instanceof Error && error.message === 'MASQUE_RELAY_UNAVAILABLE' + ? error.message + : 'TRANSPORT_ERROR'; + const allowed = new Set([ + 'BACKEND_IDENTITY_MISMATCH', + 'MASQUE_TUNNEL_FAILED', + 'MASQUE_RELAY_UNAVAILABLE', + 'INNER_QUIC_FAILED', + 'SESSION_ATTACH_FAILED', + 'ATTACH_TOKEN_REJECTED', + 'DATAGRAM_UNSUPPORTED', + 'TRANSPORT_CLOSED', + 'PROTOCOL_MISMATCH', + 'FRAME_TOO_LARGE', + ]); + return new PrivateChannelError(allowed.has(code) ? code : 'TRANSPORT_ERROR'); +} diff --git a/electron/src/reticulum-bridge.ts b/electron/src/reticulum-bridge.ts index e8b4f758..2cd3b4f6 100644 --- a/electron/src/reticulum-bridge.ts +++ b/electron/src/reticulum-bridge.ts @@ -201,6 +201,7 @@ type BridgeCmdFrame = { | 'overlay_note_candidate_failure' | 'configure_community_stun' | 'get_community_stun_endpoints' + | 'get_community_masque_relays' | 'configure_developer_log_filter' | 'stop' | 'send_call' @@ -863,6 +864,17 @@ type BridgeEventFrame = event: 'community_stun_endpoint'; payload?: { host?: string; port?: number; expiresAt?: number }; } + | { + type: 'event'; + event: 'community_masque_relay'; + payload?: { + host?: string; + port?: number; + serverName?: string; + certSha256?: string; + expiresAt?: number; + }; + } | { type: 'event'; event: 'error'; @@ -1162,6 +1174,7 @@ function commandPriorityForAction( case 'overlay_note_candidate_failure': case 'configure_community_stun': case 'get_community_stun_endpoints': + case 'get_community_masque_relays': case 'configure_developer_log_filter': return 'low'; default: @@ -3373,6 +3386,37 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { ); } + async getCommunityMasqueRelays(): Promise< + Array<{ + host: string; + port: number; + serverName: string; + certSha256: string; + expiresAt: number; + }> + > { + await this.start(); + if (this.state !== 'ready') return []; + const resp = await this.sendCommand('get_community_masque_relays', {}); + if (!resp.ok || !Array.isArray(resp.payload?.relays)) return []; + return resp.payload.relays.filter( + (value): value is { + host: string; + port: number; + serverName: string; + certSha256: string; + expiresAt: number; + } => + value != null && + typeof value === 'object' && + typeof (value as { host?: unknown }).host === 'string' && + typeof (value as { port?: unknown }).port === 'number' && + typeof (value as { serverName?: unknown }).serverName === 'string' && + typeof (value as { certSha256?: unknown }).certSha256 === 'string' && + typeof (value as { expiresAt?: unknown }).expiresAt === 'number' + ); + } + async setDeveloperLogsFiltered(filtered: boolean): Promise { const changed = this.developerLogsFiltered !== filtered; this.developerLogsFiltered = filtered; @@ -5411,6 +5455,13 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { ); return; } + case 'community_masque_relay': { + this.emitBridgeFrameEvent( + 'community-masque-relay', + frame.payload ?? {} + ); + return; + } case 'error': { const payload = frame.payload; const message = diff --git a/electron/src/setup.ts b/electron/src/setup.ts index 54e616e6..01dab95c 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -137,6 +137,14 @@ import { type QAppReticulumNativeEvent, type QAppReticulumOwner, } from './qapp-reticulum-manager'; +import { + PrivateChannelError, + PrivateChannelManager, +} from './private-channel-manager'; +import { + getExperimentalPrivateTransportFactory, + shutdownPrivateTransportSidecar, +} from './private-transport-runtime'; import { attachReticulumStatusBridgeEvents } from './reticulum-daemon'; import { startReticulumMeshCoordinator, @@ -2833,6 +2841,9 @@ export function stopReticulumManagers(): void { stopReticulumChatManager(); reticulumChatListenersAttached = false; stopPresenceManager(); + privateChannelManager?.destroy(); + privateChannelManager = null; + void shutdownPrivateTransportSidecar(); qAppReticulumManager?.destroy(); qAppReticulumManager = null; shutdownReticulumResourceStore(); @@ -2841,6 +2852,7 @@ export function stopReticulumManagers(): void { } let qAppReticulumManager: QAppReticulumManager | null = null; +let privateChannelManager: PrivateChannelManager | null = null; let qAppReticulumBridge: ReturnType = null; const qAppReticulumNativeListeners = new Set< (event: QAppReticulumNativeEvent) => void @@ -2873,6 +2885,23 @@ function getQAppReticulumManager(): QAppReticulumManager { return qAppReticulumManager; } +function getPrivateChannelManager(): PrivateChannelManager { + if (privateChannelManager) return privateChannelManager; + privateChannelManager = new PrivateChannelManager( + (owner, connectionId) => + getQAppReticulumManager().connectionOwnership(owner, connectionId), + getExperimentalPrivateTransportFactory( + getQAppReticulumManager(), + () => getReticulumBridge() + ) + ); + privateChannelManager.on('event', (event) => { + const win = myCapacitorApp.getMainWindow(); + if (!win.isDestroyed()) win.webContents.send('privateChannel:event', event); + }); + return privateChannelManager; +} + function validateQAppReticulumIpcSender( event: Electron.IpcMainInvokeEvent ): void { @@ -2907,6 +2936,7 @@ ipcMain.handle( 'qappReticulum:close', async (event, owner: QAppReticulumOwner, connectionId: string) => { validateQAppReticulumIpcSender(event); + await privateChannelManager?.cleanupRnsConnection(owner, connectionId); await getQAppReticulumManager().close(owner, connectionId); return true; } @@ -2915,11 +2945,97 @@ ipcMain.handle( 'qappReticulum:cleanupOwner', async (event, owner: QAppReticulumOwner) => { validateQAppReticulumIpcSender(event); + await privateChannelManager?.cleanupOwner(owner); await getQAppReticulumManager().cleanupOwner(owner); return true; } ); +function validatePrivateChannelIpcSender( + event: Electron.IpcMainInvokeEvent +): void { + const win = myCapacitorApp.getMainWindow(); + if (win.isDestroyed() || event.sender.id !== win.webContents.id) { + throw new Error('PERMISSION_DENIED'); + } +} + +async function privateChannelIpcResult( + operation: () => Promise | T +): Promise< + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string } } +> { + try { + return { ok: true, value: await operation() }; + } catch (error) { + const code = + error instanceof PrivateChannelError + ? error.code + : 'PRIVATE_CHANNEL_ERROR'; + return { ok: false, error: { code, message: code } }; + } +} + +ipcMain.handle( + 'privateChannel:open', + async ( + event, + owner: QAppReticulumOwner, + rnsConnectionId: unknown, + purpose: unknown + ) => { + validatePrivateChannelIpcSender(event); + return privateChannelIpcResult(() => + getPrivateChannelManager().open(owner, rnsConnectionId, purpose) + ); + } +); +ipcMain.handle( + 'privateChannel:send', + async ( + event, + owner: QAppReticulumOwner, + channelId: unknown, + lane: unknown, + messageId: unknown, + data: unknown + ) => { + validatePrivateChannelIpcSender(event); + return privateChannelIpcResult(() => + getPrivateChannelManager().send(owner, channelId, lane, messageId, data) + ); + } +); +ipcMain.handle( + 'privateChannel:status', + (event, owner: QAppReticulumOwner, channelId: unknown) => { + validatePrivateChannelIpcSender(event); + return privateChannelIpcResult(() => + getPrivateChannelManager().status(owner, channelId) + ); + } +); +ipcMain.handle( + 'privateChannel:close', + async (event, owner: QAppReticulumOwner, channelId: unknown) => { + validatePrivateChannelIpcSender(event); + return privateChannelIpcResult(() => + getPrivateChannelManager().close(owner, channelId) + ); + } +); +ipcMain.handle( + 'privateChannel:cleanupOwner', + async (event, owner: QAppReticulumOwner) => { + validatePrivateChannelIpcSender(event); + return privateChannelIpcResult(async () => { + await privateChannelManager?.cleanupOwner(owner); + return true; + }); + } +); + async function getReadyReticulumChatManager(): Promise< ReturnType > { diff --git a/package.json b/package.json index 4915a43a..e3ac1b56 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "preview": "vite preview", "pretest": "tsc electron/src/reticulum-resource.worker.ts electron/src/reticulum-chat.worker.ts --outDir electron/build/src --target ES2020 --module CommonJS --moduleResolution node --esModuleInterop --skipLibCheck --types node", "test": "vitest", + "test:private-transport": "vitest run electron/src/private-channel-step3.test.ts electron/src/private-channel-step4.test.ts electron/src/private-transport-sidecar.test.ts electron/src/private-channel-manager.test.ts electron/src/masque-relay-discovery.test.ts electron/src/private-transport-runtime.test.ts src/qortal/qapp-private-channel-request.test.ts", "test:gcall:e2e": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest run src/lib/group-call/v2/groupCallE2eQuality.test.ts", "test:gcall:e2e:offline": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest run src/lib/group-call/v2/groupCallE2eQuality.test.ts src/lib/group-call/v2/groupCallE2eLiveExport.test.ts src/lib/group-call/v2/replayHarness.test.ts electron/src/group-call-e2e-electron.test.ts", "test:gcall:e2e:watch": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest src/lib/group-call/v2/groupCallE2eQuality.test.ts", diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index 68736dc1..becae4ba 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -21,6 +21,11 @@ import { dispatchQAppReticulumRequest, isQAppReticulumAction, } from '../qortal/qapp-reticulum-request'; +import { + dispatchQAppPrivateChannelRequest, + isQAppPrivateChannelAction, +} from '../qortal/qapp-private-channel-request'; +import { normalizeQappIdentityContext } from '../qortal/qapp-identity'; import { serializeQortalRequestError } from '../qortal/qortal-request-errors'; export const saveFileInChunks = async ( @@ -187,6 +192,10 @@ export const listOfAllQortalRequests = [ 'RNS_CONNECT', 'RNS_REQUEST', 'RNS_SEND', + 'PRIVATE_CHANNEL_CLOSE', + 'PRIVATE_CHANNEL_OPEN', + 'PRIVATE_CHANNEL_SEND', + 'PRIVATE_CHANNEL_STATUS', 'ADD_FOREIGN_SERVER', 'ADD_GROUP_ADMIN', 'ADD_LIST_ITEMS', @@ -299,6 +308,10 @@ export const UIQortalRequests = [ 'RNS_CONNECT', 'RNS_REQUEST', 'RNS_SEND', + 'PRIVATE_CHANNEL_CLOSE', + 'PRIVATE_CHANNEL_OPEN', + 'PRIVATE_CHANNEL_SEND', + 'PRIVATE_CHANNEL_STATUS', 'ADD_FOREIGN_SERVER', 'ADD_GROUP_ADMIN', 'ADD_LIST_ITEMS', @@ -769,24 +782,27 @@ export const useQortalMessageListener = ( return; } + const requestContext = { + appName, + appService, + isFromExtension: message.isExtension, + tabId, + }; const requestPromise = isQAppReticulumAction(message?.action) - ? dispatchQAppReticulumRequest(message.payload, { - appName, - appService, - isFromExtension: message.isExtension, - tabId, - }) - : window.sendMessage( - message.action, - message.payload, - timeout, - message.isExtension, - { - name: appName, - service: appService, - tabId, - } - ); + ? dispatchQAppReticulumRequest(message.payload, requestContext) + : isQAppPrivateChannelAction(message?.action) + ? dispatchQAppPrivateChannelRequest(message.payload, requestContext) + : window.sendMessage( + message.action, + message.payload, + timeout, + message.isExtension, + { + name: appName, + service: appService, + tabId, + } + ); // Store the promise for deduplication if (isDeduplicable) { @@ -983,10 +999,19 @@ export const useQortalMessageListener = ( const api = window.electronAPI; const iframe = iframeRef.current; if (!api || !iframe || tabId == null || !appName) return; + let identity; + try { + identity = normalizeQappIdentityContext({ + name: appName, + service: appService, + }); + } catch { + return; + } const owner = { tabId: String(tabId), - name: String(appName), - service: String(appService ?? ''), + name: identity.name, + service: identity.service, }; const expectedOwnerKey = `${owner.tabId}\u0000${owner.service}\u0000${owner.name}`; const unsubscribe = api.onQAppReticulumEvent?.((payload) => { @@ -1010,18 +1035,50 @@ export const useQortalMessageListener = ( targetOrigin ); }); + const unsubscribePrivateChannel = api.onPrivateChannelEvent?.((payload) => { + if (payload?.ownerKey !== expectedOwnerKey || !iframe.contentWindow) + return; + let targetOrigin: string; + try { + targetOrigin = new URL(iframe.src).origin; + } catch { + return; + } + const eventPayload = + payload.action === 'PRIVATE_CHANNEL_MESSAGE' + ? { + lane: payload.lane, + messageId: payload.messageId, + data: payload.data, + } + : payload.action === 'PRIVATE_CHANNEL_ERROR' + ? { code: payload.code, message: payload.message } + : { state: payload.state }; + iframe.contentWindow.postMessage( + { + action: payload.action, + channelId: payload.channelId, + ...eventPayload, + requestedHandler: 'UI', + }, + targetOrigin + ); + }); const handleLoad = () => { if (!hasLoadedFrameRef.current) { hasLoadedFrameRef.current = true; return; } void api.qappReticulumCleanupOwner?.(owner); + void api.privateChannelCleanupOwner?.(owner); }; iframe.addEventListener('load', handleLoad); return () => { unsubscribe?.(); + unsubscribePrivateChannel?.(); iframe.removeEventListener('load', handleLoad); void api.qappReticulumCleanupOwner?.(owner); + void api.privateChannelCleanupOwner?.(owner); }; }, [appName, appService, iframeRef, tabId]); diff --git a/src/qortal/qapp-private-channel-request.test.ts b/src/qortal/qapp-private-channel-request.test.ts new file mode 100644 index 00000000..c8883275 --- /dev/null +++ b/src/qortal/qapp-private-channel-request.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { hasSessionPermission } = vi.hoisted(() => ({ + hasSessionPermission: vi.fn(), +})); + +vi.mock('./qortal-requests.ts', () => ({ hasSessionPermission })); + +import { dispatchQAppPrivateChannelRequest } from './qapp-private-channel-request'; + +const context = { + appName: ' Example-App ', + appService: 'app', + tabId: 42, +}; +const owner = { name: 'example-app', service: 'APP', tabId: '42' }; + +describe('Q-App private channel request dispatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + hasSessionPermission.mockReturnValue(true); + Object.assign(window, { + electronAPI: { + privateChannelOpen: vi + .fn() + .mockResolvedValue({ channelId: 'private-1' }), + privateChannelSend: vi.fn().mockResolvedValue({ accepted: true }), + privateChannelStatus: vi.fn().mockResolvedValue({ state: 'OPEN' }), + privateChannelClose: vi.fn().mockResolvedValue({ state: 'CLOSED' }), + }, + }); + }); + + it('requires the explicit PRIVATE_DATA_CHANNEL session permission', async () => { + hasSessionPermission.mockReturnValue(false); + await expect( + dispatchQAppPrivateChannelRequest( + { + action: 'PRIVATE_CHANNEL_OPEN', + rnsConnectionId: 'rns-1', + purpose: 'game', + }, + context + ) + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(window.electronAPI.privateChannelOpen).not.toHaveBeenCalled(); + }); + + it('uses normalized trusted context and forwards no arbitrary network fields', async () => { + await dispatchQAppPrivateChannelRequest( + { + action: 'PRIVATE_CHANNEL_OPEN', + rnsConnectionId: 'rns-1', + purpose: 'game', + owner: { tabId: 'attacker' }, + destination: 'attacker.example', + relay: '192.0.2.1', + port: 443, + }, + context + ); + + expect(window.electronAPI.privateChannelOpen).toHaveBeenCalledWith( + owner, + 'rns-1', + 'game' + ); + }); + + it('uses narrow methods for send, status, and close', async () => { + const data = new Uint8Array([1, 2]); + await dispatchQAppPrivateChannelRequest( + { + action: 'PRIVATE_CHANNEL_SEND', + channelId: 'private-1', + lane: 'reliable', + messageId: 'message-1', + data, + destination: 'ignored', + }, + context + ); + await dispatchQAppPrivateChannelRequest( + { action: 'PRIVATE_CHANNEL_STATUS', channelId: 'private-1' }, + context + ); + await dispatchQAppPrivateChannelRequest( + { action: 'PRIVATE_CHANNEL_CLOSE', channelId: 'private-1' }, + context + ); + + expect(window.electronAPI.privateChannelSend).toHaveBeenCalledWith( + owner, + 'private-1', + 'reliable', + 'message-1', + data + ); + expect(window.electronAPI.privateChannelStatus).toHaveBeenCalledWith( + owner, + 'private-1' + ); + expect(window.electronAPI.privateChannelClose).toHaveBeenCalledWith( + owner, + 'private-1' + ); + }); +}); diff --git a/src/qortal/qapp-private-channel-request.ts b/src/qortal/qapp-private-channel-request.ts new file mode 100644 index 00000000..eb6e2a94 --- /dev/null +++ b/src/qortal/qapp-private-channel-request.ts @@ -0,0 +1,117 @@ +import { normalizeQappIdentityContext } from './qapp-identity.ts'; +import { codedQortalRequestError } from './qortal-request-errors.ts'; +import { hasSessionPermission } from './qortal-requests.ts'; + +const QAPP_PRIVATE_CHANNEL_ACTIONS = new Set([ + 'PRIVATE_CHANNEL_OPEN', + 'PRIVATE_CHANNEL_SEND', + 'PRIVATE_CHANNEL_STATUS', + 'PRIVATE_CHANNEL_CLOSE', +]); + +type QAppPrivateChannelContext = { + appName: string; + appService?: string; + tabId: string | number; +}; + +type QAppPrivateChannelMessage = { + action?: string; + rnsConnectionId?: unknown; + purpose?: unknown; + channelId?: unknown; + lane?: unknown; + messageId?: unknown; + data?: unknown; + [key: string]: unknown; +}; + +export function isQAppPrivateChannelAction(action: unknown): action is string { + return typeof action === 'string' && QAPP_PRIVATE_CHANNEL_ACTIONS.has(action); +} + +export async function dispatchQAppPrivateChannelRequest( + message: QAppPrivateChannelMessage, + context: QAppPrivateChannelContext +) { + if (!isQAppPrivateChannelAction(message.action)) { + throw codedQortalRequestError( + 'INVALID_ACTION', + 'Invalid private channel action' + ); + } + if ( + !hasSessionPermission( + context.tabId, + context.appName, + 'PRIVATE_DATA_CHANNEL' + ) + ) { + throw codedQortalRequestError( + 'PERMISSION_DENIED', + 'Private data channel permission is required' + ); + } + + const api = window.electronAPI; + if (!api) { + throw codedQortalRequestError( + 'PRIVATE_TRANSPORT_UNAVAILABLE', + 'Private channel transport is unavailable' + ); + } + const identity = normalizeQappIdentityContext({ + name: context.appName, + service: context.appService, + }); + const owner = { + tabId: String(context.tabId), + name: identity.name, + service: identity.service, + }; + + switch (message.action) { + case 'PRIVATE_CHANNEL_OPEN': + if (!api.privateChannelOpen) { + throw codedQortalRequestError( + 'PRIVATE_TRANSPORT_UNAVAILABLE', + 'Private channel transport is unavailable' + ); + } + return api.privateChannelOpen( + owner, + message.rnsConnectionId, + message.purpose + ); + case 'PRIVATE_CHANNEL_SEND': + if (!api.privateChannelSend) { + throw codedQortalRequestError( + 'PRIVATE_TRANSPORT_UNAVAILABLE', + 'Private channel transport is unavailable' + ); + } + return api.privateChannelSend( + owner, + message.channelId, + message.lane, + message.messageId, + message.data + ); + case 'PRIVATE_CHANNEL_STATUS': + if (!api.privateChannelStatus) { + throw codedQortalRequestError( + 'PRIVATE_TRANSPORT_UNAVAILABLE', + 'Private channel transport is unavailable' + ); + } + return api.privateChannelStatus(owner, message.channelId); + case 'PRIVATE_CHANNEL_CLOSE': + if (!api.privateChannelClose) { + throw codedQortalRequestError( + 'PRIVATE_TRANSPORT_UNAVAILABLE', + 'Private channel transport is unavailable' + ); + } + return api.privateChannelClose(owner, message.channelId); + } +} diff --git a/src/qortal/qapp-reticulum-request.test.ts b/src/qortal/qapp-reticulum-request.test.ts index 6fab4e6a..f63a5642 100644 --- a/src/qortal/qapp-reticulum-request.test.ts +++ b/src/qortal/qapp-reticulum-request.test.ts @@ -15,7 +15,7 @@ const context = { isFromExtension: true, tabId: 42, }; -const owner = { name: 'ExampleApp', service: 'APP', tabId: '42' }; +const owner = { name: 'exampleapp', service: 'APP', tabId: '42' }; describe('dedicated Q-App Reticulum request dispatch', () => { beforeEach(() => { @@ -46,11 +46,10 @@ describe('dedicated Q-App Reticulum request dispatch', () => { dispatchQAppReticulumRequest(message, context) ).resolves.toEqual({ ok: true }); - expect(authorizeRnsDestination).toHaveBeenCalledWith( - destination, - true, - { name: 'ExampleApp', tabId: 42 } - ); + expect(authorizeRnsDestination).toHaveBeenCalledWith(destination, true, { + name: 'ExampleApp', + tabId: 42, + }); expect(window.electronAPI.qappReticulumRequest).toHaveBeenCalledWith( owner, message diff --git a/src/qortal/qapp-reticulum-request.ts b/src/qortal/qapp-reticulum-request.ts index bb0dbfe6..9d02edff 100644 --- a/src/qortal/qapp-reticulum-request.ts +++ b/src/qortal/qapp-reticulum-request.ts @@ -1,4 +1,5 @@ import { authorizeRnsDestination } from './get.ts'; +import { normalizeQappIdentityContext } from './qapp-identity.ts'; const QAPP_RETICULUM_ACTIONS = new Set([ 'RNS_CLOSE', @@ -42,10 +43,14 @@ export async function dispatchQAppReticulumRequest( const api = window.electronAPI; if (!api) throw new Error('RNS_NATIVE_TRANSPORT_UNAVAILABLE'); + const identity = normalizeQappIdentityContext({ + name: context.appName, + service: context.appService, + }); const owner = { tabId: String(context.tabId), - name: String(context.appName), - service: String(context.appService ?? ''), + name: identity.name, + service: identity.service, }; const appInfo = { tabId: context.tabId, name: context.appName }; @@ -80,11 +85,7 @@ export async function dispatchQAppReticulumRequest( throw new Error('RNS_NATIVE_TRANSPORT_UNAVAILABLE'); if (typeof message.connectionId !== 'string' || !message.connectionId) throw new Error('RNS_UNKNOWN_CONNECTION'); - return api.qappReticulumSend( - owner, - message.connectionId, - message.payload - ); + return api.qappReticulumSend(owner, message.connectionId, message.payload); } if (!api.qappReticulumClose) diff --git a/src/qortal/qortal-requests.ts b/src/qortal/qortal-requests.ts index 29cdc98d..0366e69a 100644 --- a/src/qortal/qortal-requests.ts +++ b/src/qortal/qortal-requests.ts @@ -414,6 +414,7 @@ export const VALID_SESSION_PERMISSIONS = [ 'REENCRYPT_GROUP_KEYS', 'START_CROSSCHAIN_SERVER', 'NOTIFICATION_PERMISSION', + 'PRIVATE_DATA_CHANNEL', ]; // Permissions automatically granted for the same Q-App tab session when diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 8381df43..ddda4ba2 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -361,6 +361,22 @@ declare global { ) => Promise; qappReticulumCleanupOwner?: (owner: any) => Promise; onQAppReticulumEvent?: (callback: (payload: any) => void) => () => void; + privateChannelOpen?: ( + owner: any, + rnsConnectionId: unknown, + purpose: unknown + ) => Promise; + privateChannelSend?: ( + owner: any, + channelId: unknown, + lane: unknown, + messageId: unknown, + data: unknown + ) => Promise; + privateChannelStatus?: (owner: any, channelId: unknown) => Promise; + privateChannelClose?: (owner: any, channelId: unknown) => Promise; + privateChannelCleanupOwner?: (owner: any) => Promise; + onPrivateChannelEvent?: (callback: (payload: any) => void) => () => void; /** Hidden audio-surface: proxy signing to the main shell (wallet key in-memory). */ gcallProxySignPresenceMessage?: ( payload: Record From 564f21c6f9c711eaa88ced995afab1ab39e0c6d4 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Mon, 7 Sep 2026 21:49:05 +0300 Subject: [PATCH 14/32] fixes --- electron/native/private-transport/README.md | 6 +- electron/scripts/run-electron-package.mjs | 9 +++ electron/src/masque-relay-discovery.test.ts | 1 + electron/src/masque-relay-discovery.ts | 1 + .../src/private-transport-runtime.test.ts | 10 ++- electron/src/private-transport-runtime.ts | 10 +-- electron/src/private-transport-sidecar.ts | 11 +++- electron/src/quic-masque-transport.test.ts | 61 +++++++++++++++++++ electron/src/quic-masque-transport.ts | 43 +++++++++---- electron/src/setup.ts | 4 +- package.json | 2 +- 11 files changed, 124 insertions(+), 34 deletions(-) create mode 100644 electron/src/quic-masque-transport.test.ts diff --git a/electron/native/private-transport/README.md b/electron/native/private-transport/README.md index 5787268f..af6839e3 100644 --- a/electron/native/private-transport/README.md +++ b/electron/native/private-transport/README.md @@ -77,6 +77,6 @@ Python QUIC listener. Large-file framing and resumability remain out of scope. The operator-run relay is maintained as the separate `qortal-masque-relay` project. Hub contains only the client, Reticulum discovery, and private-channel -coordination code. `QORTAL_PRIVATE_TRANSPORT=masque` selects discovered relays; -`QORTAL_PRIVATE_TRANSPORT=masque-test` retains the explicit development -override. +coordination code. Discovered MASQUE relays are the default in development and +packaged builds. `QORTAL_PRIVATE_TRANSPORT=masque-test` exists only for the +explicit local test-relay override. diff --git a/electron/scripts/run-electron-package.mjs b/electron/scripts/run-electron-package.mjs index 8661ff2d..8fb817f8 100644 --- a/electron/scripts/run-electron-package.mjs +++ b/electron/scripts/run-electron-package.mjs @@ -180,6 +180,14 @@ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; run(npmCmd, ['run', 'build']); if (cfg.universal) { + runWithEnv(npmCmd, ['run', 'build:private-transport'], { + QORTAL_PRIVATE_TRANSPORT_GOOS: 'darwin', + QORTAL_PRIVATE_TRANSPORT_GOARCH: 'arm64', + }); + runWithEnv(npmCmd, ['run', 'build:private-transport'], { + QORTAL_PRIVATE_TRANSPORT_GOOS: 'darwin', + QORTAL_PRIVATE_TRANSPORT_GOARCH: 'amd64', + }); runWithEnv(npmCmd, ['run', 'bundle:reticulum'], { QORTAL_RETICULUM_OUTPUT_DIR: 'resources/reticulum/darwin-arm64', }); @@ -188,6 +196,7 @@ if (cfg.universal) { QORTAL_RETICULUM_OUTPUT_DIR: 'resources/reticulum/darwin-x64', }); } else { + run(npmCmd, ['run', 'build:private-transport']); run(npmCmd, ['run', 'bundle:reticulum']); } diff --git a/electron/src/masque-relay-discovery.test.ts b/electron/src/masque-relay-discovery.test.ts index b677d3f6..e0e11f1d 100644 --- a/electron/src/masque-relay-discovery.test.ts +++ b/electron/src/masque-relay-discovery.test.ts @@ -49,6 +49,7 @@ describe('community MASQUE relay discovery', () => { relayAddress: '8.8.8.8:47322', relayServerName: 'relay.example', relayCertSha256: 'ab'.repeat(32), + localFallbackAddress: '127.0.0.1:47322', }); }); diff --git a/electron/src/masque-relay-discovery.ts b/electron/src/masque-relay-discovery.ts index 18882410..2c989886 100644 --- a/electron/src/masque-relay-discovery.ts +++ b/electron/src/masque-relay-discovery.ts @@ -129,5 +129,6 @@ export async function discoverCommunityMasqueRelay( relayAddress: `${host}:${selected.port}`, relayServerName: selected.serverName, relayCertSha256: selected.certSha256, + localFallbackAddress: `127.0.0.1:${selected.port}`, }; } diff --git a/electron/src/private-transport-runtime.test.ts b/electron/src/private-transport-runtime.test.ts index cc2d4a56..7c0b9268 100644 --- a/electron/src/private-transport-runtime.test.ts +++ b/electron/src/private-transport-runtime.test.ts @@ -1,21 +1,19 @@ import { describe, expect, it } from 'vitest'; import { - getExperimentalPrivateTransportFactory, + getPrivateTransportFactory, readTrustedRelayConfig, } from './private-transport-runtime'; describe('private transport runtime selection', () => { - it('uses Reticulum discovery without requiring test relay variables', () => { + it('uses Reticulum discovery by default without a feature flag', () => { expect( - getExperimentalPrivateTransportFactory({} as never, () => null, { - QORTAL_PRIVATE_TRANSPORT: 'masque', - }) + getPrivateTransportFactory({} as never, () => null, {}) ).toBeTypeOf('function'); }); it('requires pinned relay values in explicit test mode', () => { expect(() => - getExperimentalPrivateTransportFactory({} as never, () => null, { + getPrivateTransportFactory({} as never, () => null, { QORTAL_PRIVATE_TRANSPORT: 'masque-test', }) ).toThrow('Incomplete MASQUE prototype configuration'); diff --git a/electron/src/private-transport-runtime.ts b/electron/src/private-transport-runtime.ts index c8d640aa..f8357082 100644 --- a/electron/src/private-transport-runtime.ts +++ b/electron/src/private-transport-runtime.ts @@ -1,4 +1,3 @@ -import { app } from 'electron'; import net from 'net'; import type { PrivateTransportFactory } from './private-channel-manager'; import { @@ -15,18 +14,15 @@ let prototypeSidecar: PrivateTransportSidecar | null = null; let sidecarShutdownPromise: Promise | null = null; /** - * Main-process-only prototype selection. Renderer/Q-App payloads are never - * consulted and production builds deliberately remain on the mock transport. + * Main-process-only transport selection. Renderer/Q-App payloads are never + * consulted. Reticulum relay discovery is the normal production path. */ -export function getExperimentalPrivateTransportFactory( +export function getPrivateTransportFactory( reticulumManager: QAppReticulumManager, reticulumBridgeProvider: () => ReticulumBridge | null = () => null, environment: NodeJS.ProcessEnv = process.env ): PrivateTransportFactory | undefined { const mode = environment.QORTAL_PRIVATE_TRANSPORT; - if (app.isPackaged || (mode !== 'masque' && mode !== 'masque-test')) { - return undefined; - } const explicitRelay = mode === 'masque-test' ? readTrustedRelayConfig(environment) : null; const bootstrapProvider = new ReticulumPrivateChannelBootstrapProvider( diff --git a/electron/src/private-transport-sidecar.ts b/electron/src/private-transport-sidecar.ts index 86e42505..80164a12 100644 --- a/electron/src/private-transport-sidecar.ts +++ b/electron/src/private-transport-sidecar.ts @@ -105,9 +105,16 @@ export function getPrivateTransportSidecarPath(): string { process.platform === 'win32' ? 'qortal-private-transport.exe' : 'qortal-private-transport'; - const root = app.isPackaged ? process.resourcesPath : app.getAppPath(); + if (app.isPackaged) { + return path.join( + process.resourcesPath, + 'private-transport', + platformDirectory(), + executable + ); + } return path.join( - root, + app.getAppPath(), 'resources', 'private-transport', platformDirectory(), diff --git a/electron/src/quic-masque-transport.test.ts b/electron/src/quic-masque-transport.test.ts new file mode 100644 index 00000000..3e388234 --- /dev/null +++ b/electron/src/quic-masque-transport.test.ts @@ -0,0 +1,61 @@ +import { EventEmitter } from 'events'; +import { describe, expect, it, vi } from 'vitest'; +import type { PrivateChannelBootstrapProvider } from './private-channel-bootstrap'; +import { + PrivateTransportSidecarError, + type PrivateSessionConfig, +} from './private-transport-sidecar'; +import { QuicMasqueTransport } from './quic-masque-transport'; + +class SidecarStub extends EventEmitter { + attempts: PrivateSessionConfig[] = []; + + async openPrivateSession(config: PrivateSessionConfig) { + this.attempts.push(config); + if (this.attempts.length === 1) { + throw new PrivateTransportSidecarError('MASQUE_TUNNEL_FAILED'); + } + return { sessionId: 'local-session' }; + } +} + +describe('QUIC MASQUE transport', () => { + it('falls back to the pinned same-host relay when public NAT hairpinning fails', async () => { + const sidecar = new SidecarStub(); + const bootstrapProvider: PrivateChannelBootstrapProvider = { + getBootstrap: vi.fn(async () => ({ + backendRnsDestination: 'ab'.repeat(16), + backendTransportEndpoint: '127.0.0.1:4445', + backendTransportServerName: 'backend.test', + backendTransportCertSha256: 'cd'.repeat(32), + logicalSessionId: 'logical-session', + attachToken: 'attach-token', + nonce: 'n'.repeat(32), + ownerBindingHash: 'ef'.repeat(32), + expiresAt: Date.now() + 10_000, + })), + }; + const transport = new QuicMasqueTransport( + vi.fn(), + sidecar as never, + { + relayAddress: '8.8.8.8:47322', + relayServerName: 'relay.test', + relayCertSha256: '12'.repeat(32), + localFallbackAddress: '127.0.0.1:47322', + }, + bootstrapProvider + ); + + await transport.open({ + owner: { tabId: 'tab', name: 'qapp-ui-call', service: 'APP' }, + rnsConnectionId: 'rns-connection', + purpose: 'realtime', + }); + + expect(sidecar.attempts.map((attempt) => attempt.relayAddress)).toEqual([ + '8.8.8.8:47322', + '127.0.0.1:47322', + ]); + }); +}); diff --git a/electron/src/quic-masque-transport.ts b/electron/src/quic-masque-transport.ts index 78596610..f20ea540 100644 --- a/electron/src/quic-masque-transport.ts +++ b/electron/src/quic-masque-transport.ts @@ -16,6 +16,8 @@ export type TrustedRelayConfig = Readonly<{ relayAddress: string; relayServerName: string; relayCertSha256: string; + /** Same-host fallback, protected by the advertised certificate pin. */ + localFallbackAddress?: string; }>; export type TrustedRelayProvider = () => Promise; @@ -49,19 +51,34 @@ export class QuicMasqueTransport implements PrivateTransport { try { const relay = typeof this.relay === 'function' ? await this.relay() : this.relay; - const opened = await this.sidecar.openPrivateSession({ - relayAddress: relay.relayAddress, - relayServerName: relay.relayServerName, - relayCertSha256: relay.relayCertSha256, - backendAddress: bootstrap.backendTransportEndpoint, - backendServerName: bootstrap.backendTransportServerName, - backendCertSha256: bootstrap.backendTransportCertSha256, - logicalSessionId: bootstrap.logicalSessionId, - attachToken: bootstrap.attachToken, - nonce: bootstrap.nonce, - purpose: context.purpose, - ownerBindingHash: bootstrap.ownerBindingHash, - }); + const openAt = (relayAddress: string) => + this.sidecar.openPrivateSession({ + relayAddress, + relayServerName: relay.relayServerName, + relayCertSha256: relay.relayCertSha256, + backendAddress: bootstrap.backendTransportEndpoint, + backendServerName: bootstrap.backendTransportServerName, + backendCertSha256: bootstrap.backendTransportCertSha256, + logicalSessionId: bootstrap.logicalSessionId, + attachToken: bootstrap.attachToken, + nonce: bootstrap.nonce, + purpose: context.purpose, + ownerBindingHash: bootstrap.ownerBindingHash, + }); + let opened; + try { + opened = await openAt(relay.relayAddress); + } catch (error) { + if ( + !(error instanceof PrivateTransportSidecarError) || + error.code !== 'MASQUE_TUNNEL_FAILED' || + !relay.localFallbackAddress || + relay.localFallbackAddress === relay.relayAddress + ) { + throw error; + } + opened = await openAt(relay.localFallbackAddress); + } this.sessionId = opened.sessionId; } catch (error) { throw translateSidecarError(error); diff --git a/electron/src/setup.ts b/electron/src/setup.ts index 01dab95c..01f7f872 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -142,7 +142,7 @@ import { PrivateChannelManager, } from './private-channel-manager'; import { - getExperimentalPrivateTransportFactory, + getPrivateTransportFactory, shutdownPrivateTransportSidecar, } from './private-transport-runtime'; import { attachReticulumStatusBridgeEvents } from './reticulum-daemon'; @@ -2890,7 +2890,7 @@ function getPrivateChannelManager(): PrivateChannelManager { privateChannelManager = new PrivateChannelManager( (owner, connectionId) => getQAppReticulumManager().connectionOwnership(owner, connectionId), - getExperimentalPrivateTransportFactory( + getPrivateTransportFactory( getQAppReticulumManager(), () => getReticulumBridge() ) diff --git a/package.json b/package.json index e3ac1b56..cc2e18fb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "preview": "vite preview", "pretest": "tsc electron/src/reticulum-resource.worker.ts electron/src/reticulum-chat.worker.ts --outDir electron/build/src --target ES2020 --module CommonJS --moduleResolution node --esModuleInterop --skipLibCheck --types node", "test": "vitest", - "test:private-transport": "vitest run electron/src/private-channel-step3.test.ts electron/src/private-channel-step4.test.ts electron/src/private-transport-sidecar.test.ts electron/src/private-channel-manager.test.ts electron/src/masque-relay-discovery.test.ts electron/src/private-transport-runtime.test.ts src/qortal/qapp-private-channel-request.test.ts", + "test:private-transport": "vitest run electron/src/private-channel-step3.test.ts electron/src/private-channel-step4.test.ts electron/src/private-transport-sidecar.test.ts electron/src/private-channel-manager.test.ts electron/src/masque-relay-discovery.test.ts electron/src/private-transport-runtime.test.ts electron/src/quic-masque-transport.test.ts src/qortal/qapp-private-channel-request.test.ts", "test:gcall:e2e": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest run src/lib/group-call/v2/groupCallE2eQuality.test.ts", "test:gcall:e2e:offline": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest run src/lib/group-call/v2/groupCallE2eQuality.test.ts src/lib/group-call/v2/groupCallE2eLiveExport.test.ts src/lib/group-call/v2/replayHarness.test.ts electron/src/group-call-e2e-electron.test.ts", "test:gcall:e2e:watch": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest src/lib/group-call/v2/groupCallE2eQuality.test.ts", From fd1b4291d764d26efc770bd7ef6fcbb0b45ca936 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Mon, 7 Sep 2026 22:50:36 +0300 Subject: [PATCH 15/32] fixes --- electron/src/masque-relay-discovery.test.ts | 11 +++++++++ electron/src/masque-relay-discovery.ts | 25 ++++++++++++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/electron/src/masque-relay-discovery.test.ts b/electron/src/masque-relay-discovery.test.ts index e0e11f1d..4600706a 100644 --- a/electron/src/masque-relay-discovery.test.ts +++ b/electron/src/masque-relay-discovery.test.ts @@ -66,6 +66,17 @@ describe('community MASQUE relay discovery', () => { }); }); + it('retries discovery while waiting for an announcement', async () => { + const bridge = new BridgeStub(); + await expect( + discoverCommunityMasqueRelay(bridge as never, { + now: () => 1_000_000, + timeoutMs: 100, + }) + ).rejects.toThrow('MASQUE_RELAY_UNAVAILABLE'); + expect(bridge.getCommunityMasqueRelays).toHaveBeenCalledTimes(2); + }); + it('allows loopback only for an explicit development override', () => { expect( validateCommunityMasqueRelay( diff --git a/electron/src/masque-relay-discovery.ts b/electron/src/masque-relay-discovery.ts index 2c989886..b2da3779 100644 --- a/electron/src/masque-relay-discovery.ts +++ b/electron/src/masque-relay-discovery.ts @@ -2,7 +2,8 @@ import net from 'net'; import type { ReticulumBridge } from './reticulum-bridge'; import type { TrustedRelayConfig } from './quic-masque-transport'; -export const MASQUE_RELAY_DISCOVERY_TIMEOUT_MS = 2_500; +export const MASQUE_RELAY_DISCOVERY_TIMEOUT_MS = 15_000; +const MASQUE_RELAY_QUERY_INTERVAL_MS = 3_000; const MAX_LEASE_AHEAD_MS = 60 * 60_000; const MIN_REMAINING_LEASE_MS = 5_000; @@ -99,14 +100,22 @@ export async function discoverCommunityMasqueRelay( const onRelay = (value: unknown) => accept(value); bridge.on('community-masque-relay', onRelay); try { - const cached = await bridge.getCommunityMasqueRelays(); - for (const relay of cached) accept(relay); - if (candidates.size === 0) { + const timeoutMs = Math.max( + 100, + options.timeoutMs ?? MASQUE_RELAY_DISCOVERY_TIMEOUT_MS + ); + const deadline = Date.now() + timeoutMs; + while (candidates.size === 0) { + const cached = await bridge.getCommunityMasqueRelays(); + for (const relay of cached) accept(relay); + if (candidates.size > 0) break; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; await new Promise((resolve) => { - const timer = setTimeout( - resolve, - Math.max(100, options.timeoutMs ?? MASQUE_RELAY_DISCOVERY_TIMEOUT_MS) - ); + const timer = setTimeout(() => { + wake = null; + resolve(); + }, Math.min(MASQUE_RELAY_QUERY_INTERVAL_MS, remainingMs)); timer.unref?.(); wake = () => { clearTimeout(timer); From 3a4fe78c9b0481e49ce5f7e29efc65bac1575fd8 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Mon, 7 Sep 2026 23:10:00 +0300 Subject: [PATCH 16/32] fixes --- electron/resources/presence_bridge.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index 18547cc0..c0c994eb 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -9378,6 +9378,12 @@ def __init__(self): def received_announce(self, destination_hash, announced_identity, app_data): if destination_hash in _community_masque_local_hashes: return + self.received_payload(app_data) + + def received_packet(self, data, packet): + self.received_payload(data) + + def received_payload(self, app_data): try: raw = bytes(app_data or b"") if not raw or len(raw) > 512: @@ -9480,6 +9486,9 @@ def _ensure_community_masque_discovery(config_dir: str) -> None: COMMUNITY_MASQUE_ASPECT, COMMUNITY_MASQUE_VERSION, ) + _community_masque_destination.set_packet_callback( + _community_masque_announce_handler.received_packet + ) _community_masque_local_hashes.append(_community_masque_destination.hash) From fe022938bb992156916e83fed361f909ef459a94 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Mon, 7 Sep 2026 23:25:23 +0300 Subject: [PATCH 17/32] fixes --- electron/package.json | 2 +- electron/resources/presence_bridge.py | 81 ++++++++++----------- electron/src/masque-relay-discovery.test.ts | 9 ++- electron/src/masque-relay-discovery.ts | 9 ++- electron/src/reticulum-bridge.ts | 6 +- 5 files changed, 56 insertions(+), 51 deletions(-) diff --git a/electron/package.json b/electron/package.json index 3628d437..26daced2 100644 --- a/electron/package.json +++ b/electron/package.json @@ -30,7 +30,7 @@ "build": "tsc && electron-rebuild", "test:reticulum-chat-worker": "npm run build && electron ./scripts/test-reticulum-chat-worker.cjs", "electron:start-live": "node ./live-runner.js", - "electron:start": "npm run build && electron --inspect=5858 ./", + "electron:start": "npm run build:private-transport && npm run build && electron --inspect=5858 ./", "electron:pack": "node ./scripts/run-electron-package.mjs pack", "electron:make": "node ./scripts/run-electron-package.mjs local", "electron:make-local": "node ./scripts/run-electron-package.mjs local", diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index c0c994eb..58190787 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -61,12 +61,6 @@ COMMUNITY_STUN_PORT = 47321 COMMUNITY_MASQUE_ASPECT = "community-masque-relay" COMMUNITY_MASQUE_VERSION = "v1" -COMMUNITY_MASQUE_IDENTITY_FILENAME = ( - "community-masque-relay." - + hashlib.sha256(APP_NAMESPACE.encode("utf-8")).hexdigest()[:12] - + ".identity" -) -COMMUNITY_MASQUE_IDENTITY_MAX_AGE_SECONDS = 24 * 60 * 60 disable_bootstrap = False _developer_logs_filtered = ( str(os.environ.get("QORTAL_FILTER_DEVELOPER_LOGS", "1")).strip().lower() @@ -9448,37 +9442,11 @@ def received_payload(self, app_data): return -def _ensure_community_masque_discovery(config_dir: str) -> None: +def _replace_community_masque_destination() -> None: global _community_masque_identity, _community_masque_destination - global _community_masque_announce_handler - if _community_masque_announce_handler is None: - _community_masque_announce_handler = CommunityMasqueRelayAnnounceHandler() - RNS.Transport.register_announce_handler(_community_masque_announce_handler) - identity_path = os.path.join(config_dir, COMMUNITY_MASQUE_IDENTITY_FILENAME) - if _community_masque_destination is not None: - try: - age = time.time() - os.path.getmtime(identity_path) - if 0 <= age < COMMUNITY_MASQUE_IDENTITY_MAX_AGE_SECONDS: - return - except Exception: - pass - try: - age = time.time() - os.path.getmtime(identity_path) - if 0 <= age < COMMUNITY_MASQUE_IDENTITY_MAX_AGE_SECONDS: - identity = RNS.Identity.from_file(identity_path) - else: - identity = None - except Exception: - identity = None - if identity is None: - identity = RNS.Identity() - identity.to_file(identity_path) - try: - os.chmod(identity_path, 0o600) - except Exception: - pass - _community_masque_identity = identity - _community_masque_destination = RNS.Destination( + previous_destination = _community_masque_destination + identity = RNS.Identity() + destination = RNS.Destination( identity, RNS.Destination.IN, RNS.Destination.SINGLE, @@ -9486,13 +9454,32 @@ def _ensure_community_masque_discovery(config_dir: str) -> None: COMMUNITY_MASQUE_ASPECT, COMMUNITY_MASQUE_VERSION, ) - _community_masque_destination.set_packet_callback( + destination.set_packet_callback( _community_masque_announce_handler.received_packet ) - _community_masque_local_hashes.append(_community_masque_destination.hash) + _community_masque_identity = identity + _community_masque_destination = destination + _community_masque_local_hashes.append(destination.hash) + if previous_destination is not None: + try: + RNS.Transport.deregister_destination(previous_destination) + except Exception: + pass -def handle_get_community_masque_relays(req_id: str) -> None: +def _ensure_community_masque_discovery(config_dir: str) -> None: + global _community_masque_identity, _community_masque_destination + global _community_masque_announce_handler + if _community_masque_announce_handler is None: + _community_masque_announce_handler = CommunityMasqueRelayAnnounceHandler() + RNS.Transport.register_announce_handler(_community_masque_announce_handler) + if _community_masque_destination is None: + _replace_community_masque_destination() + + +def handle_get_community_masque_relays( + req_id: str, payload: Dict[str, Any] +) -> None: now_ms = int(time.time() * 1000) with _state_lock: expired = [ @@ -9506,10 +9493,16 @@ def handle_get_community_masque_relays(req_id: str) -> None: endpoints = list(_community_masque_recent_endpoints.values())[:128] try: _ensure_community_masque_discovery(_reticulum_config_dir) - query_data = json.dumps( - {"v": 1, "q": True}, separators=(",", ":") - ).encode("utf-8") - _community_masque_destination.announce(app_data=query_data) + if payload.get("announce") is True: + # Each bounded discovery operation gets one fresh anonymous return + # destination. Reusing a prior destination causes Reticulum nodes + # to suppress the query as a repeated announce. + _replace_community_masque_destination() + query_data = json.dumps( + {"v": 1, "q": True}, separators=(",", ":") + ).encode("utf-8") + _community_masque_destination.announce(app_data=query_data) + log("MASQUE relay discovery query announced") except Exception: pass emit_resp(req_id, True, payload={"relays": endpoints}) @@ -27685,7 +27678,7 @@ def handle_command(message: Dict[str, Any]) -> None: elif action == "get_community_stun_endpoints": handle_get_community_stun_endpoints(req_id) elif action == "get_community_masque_relays": - handle_get_community_masque_relays(req_id) + handle_get_community_masque_relays(req_id, payload) elif action == "configure_developer_log_filter": handle_configure_developer_log_filter(req_id, payload) elif action == "stop": diff --git a/electron/src/masque-relay-discovery.test.ts b/electron/src/masque-relay-discovery.test.ts index 4600706a..e0f9ace5 100644 --- a/electron/src/masque-relay-discovery.test.ts +++ b/electron/src/masque-relay-discovery.test.ts @@ -6,7 +6,7 @@ import { } from './masque-relay-discovery'; class BridgeStub extends EventEmitter { - getCommunityMasqueRelays = vi.fn(async () => []); + getCommunityMasqueRelays = vi.fn(async (_announce = false) => []); } const relay = { @@ -66,7 +66,7 @@ describe('community MASQUE relay discovery', () => { }); }); - it('retries discovery while waiting for an announcement', async () => { + it('uses one fresh query and only polls the cache while waiting', async () => { const bridge = new BridgeStub(); await expect( discoverCommunityMasqueRelay(bridge as never, { @@ -74,7 +74,10 @@ describe('community MASQUE relay discovery', () => { timeoutMs: 100, }) ).rejects.toThrow('MASQUE_RELAY_UNAVAILABLE'); - expect(bridge.getCommunityMasqueRelays).toHaveBeenCalledTimes(2); + expect(bridge.getCommunityMasqueRelays).toHaveBeenCalledTimes(3); + expect(bridge.getCommunityMasqueRelays).toHaveBeenNthCalledWith(1, false); + expect(bridge.getCommunityMasqueRelays).toHaveBeenNthCalledWith(2, true); + expect(bridge.getCommunityMasqueRelays).toHaveBeenNthCalledWith(3, false); }); it('allows loopback only for an explicit development override', () => { diff --git a/electron/src/masque-relay-discovery.ts b/electron/src/masque-relay-discovery.ts index b2da3779..140ace4d 100644 --- a/electron/src/masque-relay-discovery.ts +++ b/electron/src/masque-relay-discovery.ts @@ -105,10 +105,17 @@ export async function discoverCommunityMasqueRelay( options.timeoutMs ?? MASQUE_RELAY_DISCOVERY_TIMEOUT_MS ); const deadline = Date.now() + timeoutMs; + let queryAnnounced = false; while (candidates.size === 0) { - const cached = await bridge.getCommunityMasqueRelays(); + const cached = await bridge.getCommunityMasqueRelays(false); for (const relay of cached) accept(relay); if (candidates.size > 0) break; + if (!queryAnnounced) { + queryAnnounced = true; + const discovered = await bridge.getCommunityMasqueRelays(true); + for (const relay of discovered) accept(relay); + if (candidates.size > 0) break; + } const remainingMs = deadline - Date.now(); if (remainingMs <= 0) break; await new Promise((resolve) => { diff --git a/electron/src/reticulum-bridge.ts b/electron/src/reticulum-bridge.ts index 2cd3b4f6..27f639df 100644 --- a/electron/src/reticulum-bridge.ts +++ b/electron/src/reticulum-bridge.ts @@ -3386,7 +3386,7 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { ); } - async getCommunityMasqueRelays(): Promise< + async getCommunityMasqueRelays(announce = false): Promise< Array<{ host: string; port: number; @@ -3397,7 +3397,9 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { > { await this.start(); if (this.state !== 'ready') return []; - const resp = await this.sendCommand('get_community_masque_relays', {}); + const resp = await this.sendCommand('get_community_masque_relays', { + announce, + }); if (!resp.ok || !Array.isArray(resp.payload?.relays)) return []; return resp.payload.relays.filter( (value): value is { From 0a44f06436d85d136ecb31e1e7cdbf463afafe85 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Tue, 8 Sep 2026 13:48:03 +0300 Subject: [PATCH 18/32] test --- electron/native/private-transport/README.md | 55 +- electron/native/private-transport/go.mod | 15 +- electron/native/private-transport/go.sum | 38 +- .../internal/moqclient/session.go | 356 ++++++++++++ .../moqclient/session_integration_test.go | 226 ++++++++ .../internal/moqproof/compatibility_test.go | 405 +++++++++++++ .../internal/protocol/server.go | 219 ++++++- .../internal/protocol/server_test.go | 38 ++ .../third_party/moqtransport/LICENSE | 21 + .../moqtransport/QORTAL-PATCHES.md | 23 + .../third_party/moqtransport/README.md | 55 ++ .../third_party/moqtransport/connection.go | 137 +++++ .../moqtransport/datagram_send_test.go | 57 ++ .../third_party/moqtransport/errors.go | 83 +++ .../third_party/moqtransport/go.mod | 28 + .../third_party/moqtransport/go.sum | 47 ++ .../third_party/moqtransport/goleak_test.go | 11 + .../third_party/moqtransport/handler.go | 7 + .../incoming_subscribe_request.go | 124 ++++ .../moqtransport/internal/wire/appender.go | 83 +++ .../internal/wire/control_message.go | 45 ++ .../internal/wire/datagram_object.go | 81 +++ .../internal/wire/datagram_object_test.go | 57 ++ .../internal/wire/datagram_object_v18.go | 79 +++ .../internal/wire/fetch_header_v18.go | 21 + .../internal/wire/fetch_object.go | 142 +++++ .../internal/wire/fetch_object_v18.go | 80 +++ .../internal/wire/fetch_ok_v18.go | 52 ++ .../internal/wire/fetch_stream_test.go | 196 +++++++ .../moqtransport/internal/wire/fetch_v18.go | 120 ++++ .../internal/wire/go_away_ctrl_v18.go | 42 ++ .../internal/wire/go_away_req_v18.go | 36 ++ .../internal/wire/key_value_pair.go | 123 ++++ .../internal/wire/key_value_pair_test.go | 238 ++++++++ .../internal/wire/key_value_pair_v18.go | 42 ++ .../moqtransport/internal/wire/location.go | 6 + .../internal/wire/location_v18.go | 27 + .../internal/wire/namespace_done_v18.go | 42 ++ .../internal/wire/namespace_v18.go | 42 ++ .../moqtransport/internal/wire/padding_v18.go | 11 + .../moqtransport/internal/wire/parameters.go | 7 + .../moqtransport/internal/wire/parser.go | 262 +++++++++ .../internal/wire/publish_blocked_v18.go | 55 ++ .../internal/wire/publish_done_v18.go | 42 ++ .../internal/wire/publish_namespace_v18.go | 55 ++ .../internal/wire/publish_ok_v18.go | 28 + .../moqtransport/internal/wire/publish_v18.go | 80 +++ .../moqtransport/internal/wire/reader.go | 177 ++++++ .../moqtransport/internal/wire/reader_test.go | 143 +++++ .../internal/wire/request_error_v18.go | 42 ++ .../internal/wire/request_ok_v18.go | 28 + .../internal/wire/request_update_v18.go | 28 + .../internal/wire/roundtrip_test.go | 246 ++++++++ .../moqtransport/internal/wire/setup_v18.go | 19 + .../internal/wire/subgroup_header.go | 126 ++++ .../internal/wire/subgroup_header_v18.go | 47 ++ .../internal/wire/subgroup_object.go | 26 + .../internal/wire/subgroup_object_v18.go | 56 ++ .../internal/wire/subgroup_stream_test.go | 207 +++++++ .../internal/wire/subscribe_namespace_v18.go | 55 ++ .../internal/wire/subscribe_ok_v18.go | 34 ++ .../internal/wire/subscribe_tracks_v18.go | 55 ++ .../internal/wire/subscribe_v18.go | 68 +++ .../internal/wire/track_status_v18.go | 68 +++ .../moqtransport/internal/wire/wire.go | 246 ++++++++ .../moqtransport/local_control_stream.go | 15 + .../third_party/moqtransport/logging.go | 44 ++ .../moqtransport/mock_connection_test.go | 541 ++++++++++++++++++ .../moqtransport/mock_handler_test.go | 111 ++++ .../moqtransport/mock_receive_stream_test.go | 152 +++++ .../moqtransport/mock_send_stream_test.go | 190 ++++++ .../moqtransport/mock_stream_test.go | 265 +++++++++ .../third_party/moqtransport/mockgen.go | 7 + .../third_party/moqtransport/moqtransport.go | 1 + .../third_party/moqtransport/object.go | 17 + .../outgoing_subscribe_request.go | 114 ++++ .../moqtransport/quicmoq/connection.go | 113 ++++ .../moqtransport/quicmoq/receive_stream.go | 27 + .../moqtransport/quicmoq/send_stream.go | 32 ++ .../moqtransport/quicmoq/stream.go | 42 ++ .../moqtransport/remote_control_stream.go | 46 ++ .../third_party/moqtransport/request_id.go | 26 + .../third_party/moqtransport/session.go | 525 +++++++++++++++++ .../session_control_stream_test.go | 46 ++ .../moqtransport/session_helpers.go | 34 ++ .../moqtransport/session_helpers_test.go | 62 ++ .../moqtransport/session_objects_test.go | 272 +++++++++ .../moqtransport/session_shutdown_test.go | 365 ++++++++++++ .../third_party/moqtransport/session_test.go | 55 ++ .../third_party/moqtransport/subgroup.go | 52 ++ .../third_party/moqtransport/subgroup_test.go | 57 ++ .../third_party/moqtransport/tracks.go | 94 +++ .../third_party/moqtransport/varint/varint.go | 104 ++++ .../moqtransport/varint/varint_test.go | 125 ++++ .../third_party/moqtransport/version.go | 20 + electron/scripts/build-private-transport.mjs | 64 ++- electron/src/moq-masque-transport.test.ts | 171 ++++++ electron/src/moq-masque-transport.ts | 217 +++++++ electron/src/moq-transport-manager.test.ts | 110 ++++ electron/src/moq-transport-manager.ts | 435 ++++++++++++++ electron/src/preload.ts | 62 ++ electron/src/private-channel-bootstrap.ts | 3 +- electron/src/private-transport-runtime.ts | 35 ++ .../src/private-transport-sidecar.test.ts | 26 +- electron/src/private-transport-sidecar.ts | 168 +++++- electron/src/reticulum-resource.worker.ts | 30 +- electron/src/setup.ts | 128 ++++- package.json | 2 +- src/components/Apps/AppViewer.tsx | 2 +- src/hooks/useQortalMessageListener.tsx | 74 ++- src/qortal/qapp-moq-request.test.ts | 98 ++++ src/qortal/qapp-moq-request.ts | 102 ++++ src/types/window.d.ts | 22 + 113 files changed, 10925 insertions(+), 88 deletions(-) create mode 100644 electron/native/private-transport/internal/moqclient/session.go create mode 100644 electron/native/private-transport/internal/moqclient/session_integration_test.go create mode 100644 electron/native/private-transport/internal/moqproof/compatibility_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/LICENSE create mode 100644 electron/native/private-transport/third_party/moqtransport/QORTAL-PATCHES.md create mode 100644 electron/native/private-transport/third_party/moqtransport/README.md create mode 100644 electron/native/private-transport/third_party/moqtransport/connection.go create mode 100644 electron/native/private-transport/third_party/moqtransport/datagram_send_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/errors.go create mode 100644 electron/native/private-transport/third_party/moqtransport/go.mod create mode 100644 electron/native/private-transport/third_party/moqtransport/go.sum create mode 100644 electron/native/private-transport/third_party/moqtransport/goleak_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/handler.go create mode 100644 electron/native/private-transport/third_party/moqtransport/incoming_subscribe_request.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/appender.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/control_message.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_header_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_object.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_object_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_ok_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_stream_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/go_away_ctrl_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/go_away_req_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/location.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/location_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/namespace_done_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/namespace_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/padding_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/parameters.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/parser.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/publish_blocked_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/publish_done_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/publish_namespace_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/publish_ok_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/publish_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/reader.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/reader_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/request_error_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/request_ok_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/request_update_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/roundtrip_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/setup_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subgroup_header.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subgroup_header_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subgroup_object.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subgroup_object_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subgroup_stream_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subscribe_namespace_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subscribe_ok_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subscribe_tracks_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/subscribe_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/track_status_v18.go create mode 100644 electron/native/private-transport/third_party/moqtransport/internal/wire/wire.go create mode 100644 electron/native/private-transport/third_party/moqtransport/local_control_stream.go create mode 100644 electron/native/private-transport/third_party/moqtransport/logging.go create mode 100644 electron/native/private-transport/third_party/moqtransport/mock_connection_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/mock_handler_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/mock_receive_stream_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/mock_send_stream_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/mock_stream_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/mockgen.go create mode 100644 electron/native/private-transport/third_party/moqtransport/moqtransport.go create mode 100644 electron/native/private-transport/third_party/moqtransport/object.go create mode 100644 electron/native/private-transport/third_party/moqtransport/outgoing_subscribe_request.go create mode 100644 electron/native/private-transport/third_party/moqtransport/quicmoq/connection.go create mode 100644 electron/native/private-transport/third_party/moqtransport/quicmoq/receive_stream.go create mode 100644 electron/native/private-transport/third_party/moqtransport/quicmoq/send_stream.go create mode 100644 electron/native/private-transport/third_party/moqtransport/quicmoq/stream.go create mode 100644 electron/native/private-transport/third_party/moqtransport/remote_control_stream.go create mode 100644 electron/native/private-transport/third_party/moqtransport/request_id.go create mode 100644 electron/native/private-transport/third_party/moqtransport/session.go create mode 100644 electron/native/private-transport/third_party/moqtransport/session_control_stream_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/session_helpers.go create mode 100644 electron/native/private-transport/third_party/moqtransport/session_helpers_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/session_objects_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/session_shutdown_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/session_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/subgroup.go create mode 100644 electron/native/private-transport/third_party/moqtransport/subgroup_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/tracks.go create mode 100644 electron/native/private-transport/third_party/moqtransport/varint/varint.go create mode 100644 electron/native/private-transport/third_party/moqtransport/varint/varint_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/version.go create mode 100644 electron/src/moq-masque-transport.test.ts create mode 100644 electron/src/moq-masque-transport.ts create mode 100644 electron/src/moq-transport-manager.test.ts create mode 100644 electron/src/moq-transport-manager.ts create mode 100644 src/qortal/qapp-moq-request.test.ts create mode 100644 src/qortal/qapp-moq-request.ts diff --git a/electron/native/private-transport/README.md b/electron/native/private-transport/README.md index af6839e3..6cb52d54 100644 --- a/electron/native/private-transport/README.md +++ b/electron/native/private-transport/README.md @@ -6,8 +6,8 @@ The sidecar carries authenticated inner QUIC through RFC 9298 CONNECT-UDP: Electron -> sidecar -> outer QUIC/HTTP3 -> MASQUE -> inner QUIC -> backend ``` -It opens no listening socket. `masque-go v0.4.0` supplies the CONNECT-UDP -`net.PacketConn`; `quic-go v0.60.0` dials inner QUIC directly over it. There is +It opens no listening socket. `masque-go v0.5.0` supplies the CONNECT-UDP +`net.PacketConn`; `quic-go v0.62.0` dials inner QUIC directly over it. There is no direct backend dial or fallback. ## Authentication and bootstrap @@ -59,12 +59,59 @@ QUIC to the relay. No nested-congestion tuning is attempted. Metrics include inner smoothed RTT, application bytes, datagram drops, stream errors, and connection errors; endpoints are excluded. +## MoQT compatibility + +The MOQT compatibility spike pins `github.com/mengelbart/moqtransport` at commit +`9eaf40a4dedd` (MOQT draft 18). The compatibility test in +`internal/moqproof` establishes two real MOQT sessions over MASQUE-provided +`net.PacketConn` instances. It blindly relays an opaque application object +between the clients and verifies that the backend observes relay egress +addresses. Keep MOQT behind the +private-transport boundary; its wire version is not part of the Q-App API. + +The pinned source is selected from `third_party/moqtransport` using a Go +`replace` directive. Its previously empty SendDatagram method now encodes +OBJECT_DATAGRAM and sends it using QUIC DATAGRAM. Transport errors propagate; +there is no retry or stream fallback. Provenance and patch scope are recorded +in `third_party/moqtransport/QORTAL-PATCHES.md`. + +The production sidecar also owns a generic trusted MOQT client. It: + +- consumes the backend's authenticated, one-time `realtime` bootstrap; +- establishes inner `moqt-18` QUIC only through CONNECT-UDP; +- verifies the backend name and exact certificate pin; +- accepts a bounded publication namespace and track instead of embedding any + application-specific room, participant, or content meaning; +- publishes and receives only opaque application objects; +- caps objects at 1024 bytes, namespace components at eight, and subscriptions + at 64; and +- emits received objects only to the Electron main process. + +The sidecar does not capture, encode, encrypt, decrypt, sign, inspect, or play +application content. Those responsibilities belong to the application using +the generic transport. A capability-scoped Q-App API exposes only logical +session open, track subscribe, opaque object publish, metrics, and close +operations. It requires the existing `PRIVATE_DATA_CHANNEL` session permission, +binds each MOQT session to the requesting Q-App and its owned Reticulum +connection, and never returns backend endpoints, relay endpoints, credentials, +or raw sockets. + +The opaque-object two-client test runs both subgroup-stream and datagram delivery. +The datagram case deliberately loses the first object and verifies the next +object arrives as a datagram, with no resend of the lost object. Oversized +sends must return an error. These are local integration tests, not a completed +Q-App/backend integration or a full MoQT interoperability audit. + +Run `go test ./...` here and in `third_party/moqtransport`. The latter +includes upstream regression tests and byte-level datagram sender tests. + ## Build and scope -The module requires Go 1.25+ and was tested with Go 1.26.5. +The module requires Go 1.26+. The normal build uses local Go when available and +automatically falls back to `golang:1.26-bookworm` through Docker otherwise. ``` -QORTAL_GO_BINARY=/path/to/go npm run build:private-transport --prefix electron +npm run build:private-transport --prefix electron QORTAL_GO_BINARY=/path/to/go npm run test:private-transport ``` diff --git a/electron/native/private-transport/go.mod b/electron/native/private-transport/go.mod index 88fdbeab..f9f599f7 100644 --- a/electron/native/private-transport/go.mod +++ b/electron/native/private-transport/go.mod @@ -1,18 +1,21 @@ module qortal.org/qortal-hub/private-transport -go 1.25.0 +go 1.26.0 + +replace github.com/mengelbart/moqtransport => ./third_party/moqtransport require ( - github.com/quic-go/masque-go v0.4.0 - github.com/quic-go/quic-go v0.60.0 + github.com/mengelbart/moqtransport v0.5.1-0.20260831154657-9eaf40a4dedd + github.com/quic-go/masque-go v0.5.0 + github.com/quic-go/quic-go v0.62.0 github.com/yosida95/uritemplate/v3 v3.0.2 ) require ( github.com/dunglas/httpsfv v1.1.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect - golang.org/x/crypto v0.53.0 // indirect + golang.org/x/crypto v0.54.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect ) diff --git a/electron/native/private-transport/go.sum b/electron/native/private-transport/go.sum index 900bff1d..9f8c8e74 100644 --- a/electron/native/private-transport/go.sum +++ b/electron/native/private-transport/go.sum @@ -1,32 +1,30 @@ -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/mengelbart/moqtransport v0.5.1-0.20260831154657-9eaf40a4dedd h1:1WwmLXspjPZ4sBUrWB49rb0/wATktw00sP+MTiz8l5U= +github.com/mengelbart/moqtransport v0.5.1-0.20260831154657-9eaf40a4dedd/go.mod h1:wyWZNeIt4JfbDBNXKa2zU+pVHMh7qGF8eDJQyMEraLc= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= -github.com/quic-go/masque-go v0.4.0 h1:7elD7yLRK2+84IUWtrfxqzJVu3o+6wNQzpOpBjlCGcU= -github.com/quic-go/masque-go v0.4.0/go.mod h1:4B6icFXeaiql1M0dFUDjs7mxHuFYt79L43cxq5PEd5M= +github.com/quic-go/masque-go v0.5.0 h1:Ii6HUr/oRIROG9+xnGCMTKo7N8fb/J3hZOh1AMPH36M= +github.com/quic-go/masque-go v0.5.0/go.mod h1:1o0Hj5Myu9wysEqY7oTyEgPEQjl2rQ2CKYkO5WrtZ7E= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= -github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/electron/native/private-transport/internal/moqclient/session.go b/electron/native/private-transport/internal/moqclient/session.go new file mode 100644 index 00000000..4982179d --- /dev/null +++ b/electron/native/private-transport/internal/moqclient/session.go @@ -0,0 +1,356 @@ +package moqclient + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "encoding/hex" + "errors" + "fmt" + "regexp" + "sync" + "sync/atomic" + "time" + + "github.com/mengelbart/moqtransport" + "github.com/mengelbart/moqtransport/quicmoq" + "github.com/quic-go/quic-go" + masqueclient "qortal.org/qortal-hub/private-transport/internal/masque" +) + +const ( + ALPN = "moqt-18" + MaxObjectBytes = 1024 + MaxSubscriptions = 64 + MaxNamespaceComponents = 8 + defaultConnectTimeout = 8 * time.Second + maxSafeJSONInteger = uint64(1<<53 - 1) +) + +var safeName = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) + +type Config struct { + Relay masqueclient.Config + BackendServerName string + BackendCertSHA256 string + LogicalSessionID string + AttachToken string + PublicationNamespace []string + PublicationTrack string + Timeout time.Duration +} + +type Event struct { + Kind string + SubscriptionID string + Namespace []string + TrackName string + GroupID uint64 + ObjectID uint64 + Data []byte + Code string +} + +type Metrics struct { + InnerRTTMillis int64 `json:"innerRttMillis"` + ObjectsSent uint64 `json:"objectsSent"` + ObjectsRead uint64 `json:"objectsReceived"` + BytesSent uint64 `json:"bytesSent"` + BytesRead uint64 `json:"bytesReceived"` + ObjectErrors uint64 `json:"objectErrors"` +} + +type publicationHandler struct { + namespace [][]byte + track string + publication chan *moqtransport.IncomingSubscribeRequest +} + +func (h *publicationHandler) HandleGoAway(string) {} + +func (h *publicationHandler) HandleSubscribe(request *moqtransport.IncomingSubscribeRequest) { + if !equalNamespace(request.Namespace(), h.namespace) || string(request.Name()) != h.track { + request.Reject(moqtransport.RequestErrorCodeUnauthorized, "publication is not authorized") + return + } + request.Accept(1) + select { + case h.publication <- request: + default: + _ = request.Close() + } +} + +type subscription struct { + namespace []string + track string + request *moqtransport.OutgoingSubscribeRequest +} + +type Session struct { + tunnel *masqueclient.Tunnel + conn *quic.Conn + moq *moqtransport.Session + publication *moqtransport.IncomingSubscribeRequest + onEvent func(Event) + + mu sync.Mutex + subscriptions map[string]subscription + closed atomic.Bool + nextObjectID atomic.Uint64 + objectsSent atomic.Uint64 + objectsRead atomic.Uint64 + bytesSent atomic.Uint64 + bytesRead atomic.Uint64 + objectErrors atomic.Uint64 +} + +func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error) { + if !validNamespace(cfg.PublicationNamespace) || !safeName.MatchString(cfg.PublicationTrack) || + cfg.LogicalSessionID == "" || len(cfg.LogicalSessionID) > 512 || + len(cfg.AttachToken) < 32 || len(cfg.AttachToken) > 128 { + return nil, errors.New("INVALID_MOQ_CONFIG") + } + pin, err := hex.DecodeString(cfg.BackendCertSHA256) + if err != nil || len(pin) != sha256.Size { + return nil, errors.New("INVALID_MOQ_CONFIG") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = defaultConnectTimeout + } + openCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + tunnel, err := masqueclient.Open(openCtx, cfg.Relay) + if err != nil { + return nil, fmt.Errorf("MASQUE_TUNNEL_FAILED: %w", err) + } + fail := func(openErr error) (*Session, error) { + _ = tunnel.Close() + return nil, openErr + } + if tunnel.RemoteAddr() == nil { + return fail(errors.New("MOQ_QUIC_FAILED: missing proxied backend address")) + } + connection, err := quic.Dial( + openCtx, + tunnel.PacketConn(), + tunnel.RemoteAddr(), + backendTLSConfig(cfg.BackendServerName, pin), + &quic.Config{ + EnableDatagrams: true, + InitialPacketSize: 1200, + DisablePathMTUDiscovery: true, + }, + ) + if err != nil { + return fail(fmt.Errorf("MOQ_QUIC_FAILED: %w", err)) + } + handler := &publicationHandler{ + namespace: stringsToNamespace(cfg.PublicationNamespace), + track: cfg.PublicationTrack, publication: make(chan *moqtransport.IncomingSubscribeRequest, 1), + } + moq, err := moqtransport.NewSession( + quicmoq.NewClient(connection), "attach/"+cfg.AttachToken, + moqtransport.WithHandler(handler), + ) + if err != nil { + _ = connection.CloseWithError(1, "MOQT setup failed") + return fail(fmt.Errorf("MOQ_SESSION_FAILED: %w", err)) + } + select { + case publication := <-handler.publication: + if !connection.ConnectionState().SupportsDatagrams.Remote { + moq.CloseWithError(1, "datagrams required") + return fail(errors.New("DATAGRAM_UNSUPPORTED")) + } + return &Session{ + tunnel: tunnel, conn: connection, moq: moq, publication: publication, + onEvent: onEvent, subscriptions: make(map[string]subscription), + }, nil + case <-moq.Context().Done(): + moq.CloseWithError(1, "MOQT attach failed") + return fail(errors.New("MOQ_ATTACH_FAILED")) + case <-openCtx.Done(): + moq.CloseWithError(1, "MOQT attach timed out") + return fail(errors.New("MOQ_ATTACH_FAILED")) + } +} + +func backendTLSConfig(serverName string, pin []byte) *tls.Config { + return &tls.Config{ + MinVersion: tls.VersionTLS13, NextProtos: []string{ALPN}, ServerName: serverName, + InsecureSkipVerify: true, // Replaced by the mandatory identity and leaf-pin checks below. + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.PeerCertificates) == 0 { + return errors.New("backend certificate missing") + } + leaf := state.PeerCertificates[0] + if now := time.Now(); now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) { + return errors.New("backend certificate expired") + } + if err := leaf.VerifyHostname(serverName); err != nil { + return errors.New("backend certificate name mismatch") + } + observed := sha256.Sum256(leaf.Raw) + if subtle.ConstantTimeCompare(observed[:], pin) != 1 { + return errors.New("backend certificate pin mismatch") + } + return nil + }, + } +} + +func validNamespace(namespace []string) bool { + if len(namespace) == 0 || len(namespace) > MaxNamespaceComponents { + return false + } + total := 0 + for _, component := range namespace { + if !safeName.MatchString(component) { + return false + } + total += len(component) + } + return total <= 512 +} + +func stringsToNamespace(namespace []string) [][]byte { + result := make([][]byte, len(namespace)) + for index, component := range namespace { + result[index] = []byte(component) + } + return result +} + +func equalNamespace(left, right [][]byte) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if string(left[index]) != string(right[index]) { + return false + } + } + return true +} + +func (s *Session) Subscribe(subscriptionID string, namespace []string, track string) error { + if s.closed.Load() { + return errors.New("MOQ_SESSION_CLOSED") + } + if !safeName.MatchString(subscriptionID) || !validNamespace(namespace) || !safeName.MatchString(track) { + return errors.New("INVALID_MOQ_SUBSCRIPTION") + } + s.mu.Lock() + defer s.mu.Unlock() + if existing, found := s.subscriptions[subscriptionID]; found { + if equalStringNamespace(existing.namespace, namespace) && existing.track == track { + return nil + } + return errors.New("MOQ_SUBSCRIPTION_ID_REUSED") + } + if len(s.subscriptions) >= MaxSubscriptions { + return errors.New("MOQ_SUBSCRIPTION_LIMIT") + } + request, err := s.moq.Subscribe(s.moq.Context(), stringsToNamespace(namespace), track) + if err != nil { + return fmt.Errorf("MOQ_SUBSCRIBE_FAILED: %w", err) + } + entry := subscription{namespace: append([]string(nil), namespace...), track: track, request: request} + s.subscriptions[subscriptionID] = entry + go s.readSubscription(subscriptionID, entry) + return nil +} + +func equalStringNamespace(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func (s *Session) PublishObject(payload []byte) error { + if s.closed.Load() { + return errors.New("MOQ_SESSION_CLOSED") + } + if len(payload) == 0 || len(payload) > MaxObjectBytes { + return errors.New("MOQ_OBJECT_TOO_LARGE") + } + objectID := s.nextObjectID.Add(1) - 1 + err := s.publication.SendDatagram(moqtransport.Object{ + GroupID: 0, ObjectID: objectID, + ForwardingPreference: moqtransport.ObjectForwardingPreferenceDatagram, + Payload: append([]byte(nil), payload...), + }) + if err != nil { + s.objectErrors.Add(1) + return fmt.Errorf("MOQ_SEND_FAILED: %w", err) + } + s.objectsSent.Add(1) + s.bytesSent.Add(uint64(len(payload))) + return nil +} + +func (s *Session) readSubscription(subscriptionID string, entry subscription) { + for { + object, err := entry.request.ReadObject(s.moq.Context()) + if err != nil { + if !s.closed.Load() { + s.objectErrors.Add(1) + s.emit(Event{Kind: "error", SubscriptionID: subscriptionID, Code: "MOQ_READ_FAILED"}) + } + return + } + if len(object.Payload) == 0 || len(object.Payload) > MaxObjectBytes || + object.GroupID > maxSafeJSONInteger || object.ObjectID > maxSafeJSONInteger { + s.objectErrors.Add(1) + continue + } + s.objectsRead.Add(1) + s.bytesRead.Add(uint64(len(object.Payload))) + s.emit(Event{ + Kind: "object", SubscriptionID: subscriptionID, + Namespace: append([]string(nil), entry.namespace...), TrackName: entry.track, + GroupID: object.GroupID, ObjectID: object.ObjectID, + Data: append([]byte(nil), object.Payload...), + }) + } +} + +func (s *Session) Metrics() Metrics { + stats := s.conn.ConnectionStats() + return Metrics{ + InnerRTTMillis: stats.SmoothedRTT.Milliseconds(), + ObjectsSent: s.objectsSent.Load(), ObjectsRead: s.objectsRead.Load(), + BytesSent: s.bytesSent.Load(), BytesRead: s.bytesRead.Load(), + ObjectErrors: s.objectErrors.Load(), + } +} + +func (s *Session) Close() error { + if !s.closed.CompareAndSwap(false, true) { + return nil + } + s.mu.Lock() + for _, entry := range s.subscriptions { + _ = entry.request.Close() + } + s.subscriptions = make(map[string]subscription) + s.mu.Unlock() + s.moq.CloseWithError(0, "closed") + time.Sleep(50 * time.Millisecond) + return s.tunnel.Close() +} + +func (s *Session) emit(event Event) { + if s.onEvent != nil { + s.onEvent(event) + } +} diff --git a/electron/native/private-transport/internal/moqclient/session_integration_test.go b/electron/native/private-transport/internal/moqclient/session_integration_test.go new file mode 100644 index 00000000..25aab920 --- /dev/null +++ b/electron/native/private-transport/internal/moqclient/session_integration_test.go @@ -0,0 +1,226 @@ +package moqclient + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "math/big" + "net" + "net/http" + "testing" + "time" + + "github.com/mengelbart/moqtransport" + "github.com/mengelbart/moqtransport/quicmoq" + masque "github.com/quic-go/masque-go" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" + masqueclient "qortal.org/qortal-hub/private-transport/internal/masque" +) + +type integrationHandler struct { + subscriptions chan *moqtransport.IncomingSubscribeRequest +} + +func (h *integrationHandler) HandleGoAway(string) {} + +func (h *integrationHandler) HandleSubscribe(request *moqtransport.IncomingSubscribeRequest) { + h.subscriptions <- request +} + +func TestSessionTransportsOpaqueMOQTObjectOnlyThroughMasque(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + backendCertificate := integrationCertificate(t, "moq-backend") + backendSocket, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer backendSocket.Close() + backendListener, err := quic.Listen( + backendSocket, + &tls.Config{Certificates: []tls.Certificate{backendCertificate}, NextProtos: []string{ALPN}}, + &quic.Config{EnableDatagrams: true, InitialPacketSize: 1200, DisablePathMTUDiscovery: true}, + ) + if err != nil { + t.Fatal(err) + } + defer backendListener.Close() + + relayCertificate := integrationCertificate(t, "moq-relay") + relaySocket, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer relaySocket.Close() + relayAddress := relaySocket.LocalAddr().String() + backendAddress := backendSocket.LocalAddr().String() + template := uritemplate.MustNew("https://" + relayAddress + "/.well-known/masque/udp/{target_host}/{target_port}/") + proxy := masque.Proxy{} + defer proxy.Close() + relayEgress := make(chan string, 1) + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, r *http.Request) { + request, parseErr := masque.ParseProxyRequest(r, template) + if parseErr != nil || request.Target != backendAddress { + w.WriteHeader(http.StatusBadRequest) + return + } + target, resolveErr := net.ResolveUDPAddr("udp", backendAddress) + if resolveErr != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + egress, dialErr := net.DialUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}, target) + if dialErr != nil { + w.WriteHeader(http.StatusBadGateway) + return + } + relayEgress <- egress.LocalAddr().String() + _ = proxy.ProxyConnectedSocket(w, request, egress) + }) + relayServer := &http3.Server{ + TLSConfig: &tls.Config{Certificates: []tls.Certificate{relayCertificate}, NextProtos: []string{http3.NextProtoH3}}, + QUICConfig: &quic.Config{EnableDatagrams: true}, EnableDatagrams: true, Handler: mux, + } + defer relayServer.Close() + go func() { _ = relayServer.Serve(relaySocket) }() + + attachToken := "integration-one-time-token-000000000000" + publicationNamespace := []string{"qortal", "apps", "sample", "publisher-123"} + publicationTrack := "realtime-data" + backendSource := make(chan string, 1) + backendErrors := make(chan error, 1) + go func() { + connection, acceptErr := backendListener.Accept(ctx) + if acceptErr != nil { + backendErrors <- acceptErr + return + } + backendSource <- connection.RemoteAddr().String() + handler := &integrationHandler{subscriptions: make(chan *moqtransport.IncomingSubscribeRequest, 1)} + session, sessionErr := moqtransport.NewSession(quicmoq.NewServer(connection), "", moqtransport.WithHandler(handler)) + if sessionErr != nil { + backendErrors <- sessionErr + return + } + defer session.CloseWithError(0, "test complete") + for session.Path() == "" && ctx.Err() == nil { + time.Sleep(time.Millisecond) + } + if session.Path() != "attach/"+attachToken { + backendErrors <- context.Canceled + return + } + source, subscribeErr := session.Subscribe(ctx, stringsToNamespace(publicationNamespace), publicationTrack) + if subscribeErr != nil { + backendErrors <- subscribeErr + return + } + object, readErr := source.ReadObject(ctx) + if readErr != nil { + backendErrors <- readErr + return + } + var downstream *moqtransport.IncomingSubscribeRequest + select { + case downstream = <-handler.subscriptions: + case <-ctx.Done(): + backendErrors <- ctx.Err() + return + } + downstream.Accept(2) + if sendErr := downstream.SendDatagram(*object); sendErr != nil { + backendErrors <- sendErr + return + } + time.Sleep(100 * time.Millisecond) + }() + + events := make(chan Event, 1) + session, err := Open(ctx, Config{ + Relay: masqueclient.Config{ + RelayAddress: relayAddress, RelayServerName: "moq-relay", + RelayCertSHA256: integrationCertificatePin(t, relayCertificate), + TargetAddress: backendAddress, Timeout: 5 * time.Second, + }, + BackendServerName: "moq-backend", BackendCertSHA256: integrationCertificatePin(t, backendCertificate), + LogicalSessionID: "logical-session", AttachToken: attachToken, + PublicationNamespace: publicationNamespace, PublicationTrack: publicationTrack, Timeout: 5 * time.Second, + }, func(event Event) { events <- event }) + if err != nil { + t.Fatal(err) + } + defer session.Close() + if err = session.Subscribe("self-subscription", publicationNamespace, publicationTrack); err != nil { + t.Fatal(err) + } + payload := []byte("opaque-application-object") + if err = session.PublishObject(payload); err != nil { + t.Fatal(err) + } + select { + case event := <-events: + if event.Kind != "object" || event.SubscriptionID != "self-subscription" || + event.TrackName != publicationTrack || !equalStringNamespace(event.Namespace, publicationNamespace) || + !bytes.Equal(event.Data, payload) { + t.Fatalf("unexpected MOQT event: %#v", event) + } + case err = <-backendErrors: + t.Fatal(err) + case <-ctx.Done(): + t.Fatal("timed out waiting for relayed object") + } + var observedBackendSource, observedRelayEgress string + select { + case observedBackendSource = <-backendSource: + case <-ctx.Done(): + t.Fatal("backend did not observe a source") + } + select { + case observedRelayEgress = <-relayEgress: + case <-ctx.Done(): + t.Fatal("relay did not report its egress") + } + if observedBackendSource != observedRelayEgress { + t.Fatalf("backend saw %q instead of relay %q", observedBackendSource, observedRelayEgress) + } +} + +func integrationCertificate(t *testing.T, name string) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(now.UnixNano()), Subject: pkix.Name{CommonName: name}, DNSNames: []string{name}, + NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +func integrationCertificatePin(t *testing.T, certificate tls.Certificate) string { + t.Helper() + parsed, err := x509.ParseCertificate(certificate.Certificate[0]) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(parsed.Raw) + return hex.EncodeToString(digest[:]) +} diff --git a/electron/native/private-transport/internal/moqproof/compatibility_test.go b/electron/native/private-transport/internal/moqproof/compatibility_test.go new file mode 100644 index 00000000..d1c99c3e --- /dev/null +++ b/electron/native/private-transport/internal/moqproof/compatibility_test.go @@ -0,0 +1,405 @@ +package moqproof + +import ( + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "math/big" + "net" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/mengelbart/moqtransport" + "github.com/mengelbart/moqtransport/quicmoq" + masque "github.com/quic-go/masque-go" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" + masqueclient "qortal.org/qortal-hub/private-transport/internal/masque" +) + +const proofTimeout = 5 * time.Second + +type proofHandler struct { + subscriptions chan *moqtransport.IncomingSubscribeRequest +} + +func (h *proofHandler) HandleGoAway(string) {} + +func (h *proofHandler) HandleSubscribe(request *moqtransport.IncomingSubscribeRequest) { + h.subscriptions <- request +} + +func TestEncryptedMOQTObjectIsBlindlyRelayedBetweenTwoMasqueClients(t *testing.T) { + t.Run("streams", func(t *testing.T) { testEncryptedRelay(t, false) }) + t.Run("datagrams_with_loss", func(t *testing.T) { testEncryptedRelay(t, true) }) +} + +// Drop one MoQ datagram before it reaches QUIC to model packet loss without +// disrupting TLS or MoQ control messages. Subsequent packets use the real tunnel. +type dropFirstDatagram struct { + moqtransport.Connection + sends atomic.Int32 +} + +func (c *dropFirstDatagram) SendDatagram(data []byte) error { + if c.sends.Add(1) == 1 { + return nil + } + return c.Connection.SendDatagram(data) +} + +func sendProofObject(r *moqtransport.IncomingSubscribeRequest, o moqtransport.Object, datagrams bool) error { + if datagrams { + return r.SendDatagram(o) + } + sg, err := r.OpenSubgroup(o.GroupID, o.SubGroupID, 0) + if err != nil { + return err + } + if _, err = sg.WriteObject(o.ObjectID, o.Payload); err != nil { + return err + } + return sg.Close() +} + +func testEncryptedRelay(t *testing.T, datagrams bool) { + ctx, cancel := context.WithTimeout(context.Background(), proofTimeout) + defer cancel() + + backendCertificate := testCertificate(t, "moq-proof-backend") + backendSocket, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer backendSocket.Close() + backendListener, err := quic.Listen( + backendSocket, + &tls.Config{ + Certificates: []tls.Certificate{backendCertificate}, + NextProtos: []string{moqtransport.MOQT18.String()}, + }, + &quic.Config{ + EnableDatagrams: true, + InitialPacketSize: 1200, + DisablePathMTUDiscovery: true, + }, + ) + if err != nil { + t.Fatal(err) + } + defer backendListener.Close() + + relayCertificate := testCertificate(t, "moq-proof-relay") + relaySocket, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer relaySocket.Close() + relayAddress := relaySocket.LocalAddr().String() + backendAddress := backendSocket.LocalAddr().String() + template := uritemplate.MustNew( + "https://" + relayAddress + "/.well-known/masque/udp/{target_host}/{target_port}/", + ) + proxy := masque.Proxy{} + defer proxy.Close() + relayEgress := make(chan string, 2) + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, r *http.Request) { + request, parseErr := masque.ParseProxyRequest(r, template) + if parseErr != nil || request.Target != backendAddress { + w.WriteHeader(http.StatusBadRequest) + return + } + target, resolveErr := net.ResolveUDPAddr("udp", backendAddress) + if resolveErr != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + egress, dialErr := net.DialUDP( + "udp", + &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}, + target, + ) + if dialErr != nil { + w.WriteHeader(http.StatusBadGateway) + return + } + select { + case relayEgress <- egress.LocalAddr().String(): + default: + } + _ = proxy.ProxyConnectedSocket(w, request, egress) + }) + relayServer := &http3.Server{ + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{relayCertificate}, + NextProtos: []string{http3.NextProtoH3}, + }, + QUICConfig: &quic.Config{EnableDatagrams: true}, + EnableDatagrams: true, + Handler: mux, + } + defer relayServer.Close() + go func() { _ = relayServer.Serve(relaySocket) }() + + serverObjects := make(chan *moqtransport.Object, 1) + serverRemoteAddress := make(chan string, 2) + backendSubscriptions := make(chan *moqtransport.IncomingSubscribeRequest, 1) + go func() { + connection, acceptErr := backendListener.Accept(ctx) + if acceptErr != nil { + return + } + serverRemoteAddress <- connection.RemoteAddr().String() + session, sessionErr := moqtransport.NewSession( + quicmoq.NewServer(connection), + "", + moqtransport.WithHandler(&proofHandler{subscriptions: backendSubscriptions}), + ) + if sessionErr != nil { + return + } + subscription, subscribeErr := session.Subscribe( + ctx, + [][]byte{[]byte("qortal"), []byte("apps"), []byte("proof")}, + "objects", + ) + if subscribeErr != nil { + return + } + object, readErr := subscription.ReadObject(ctx) + if readErr != nil { + return + } + var downstream *moqtransport.IncomingSubscribeRequest + select { + case downstream = <-backendSubscriptions: + case <-ctx.Done(): + return + } + downstream.Accept(2) + if writeErr := sendProofObject(downstream, *object, datagrams); writeErr != nil { + return + } + serverObjects <- object + }() + + tunnel, err := masqueclient.Open(ctx, masqueclient.Config{ + RelayAddress: relayAddress, + RelayServerName: "moq-proof-relay", + RelayCertSHA256: certificatePin(t, relayCertificate), + TargetAddress: backendAddress, + Timeout: proofTimeout, + }) + if err != nil { + t.Fatal(err) + } + defer tunnel.Close() + connection, err := quic.Dial( + ctx, + tunnel.PacketConn(), + tunnel.RemoteAddr(), + &tls.Config{ + InsecureSkipVerify: true, + ServerName: "moq-proof-backend", + NextProtos: []string{moqtransport.MOQT18.String()}, + }, + &quic.Config{ + EnableDatagrams: true, + InitialPacketSize: 1200, + DisablePathMTUDiscovery: true, + }, + ) + if err != nil { + t.Fatal(err) + } + clientHandler := &proofHandler{ + subscriptions: make(chan *moqtransport.IncomingSubscribeRequest, 1), + } + publisherConnection := &dropFirstDatagram{Connection: quicmoq.NewClient(connection)} + publisherSession, err := moqtransport.NewSession( + publisherConnection, + "publisher", + moqtransport.WithHandler(clientHandler), + ) + if err != nil { + t.Fatal(err) + } + defer publisherSession.CloseWithError(0, "proof complete") + + var publication *moqtransport.IncomingSubscribeRequest + select { + case publication = <-clientHandler.subscriptions: + case <-ctx.Done(): + t.Fatal("backend did not subscribe to the client track") + } + publication.Accept(1) + + go func() { + connection, acceptErr := backendListener.Accept(ctx) + if acceptErr != nil { + return + } + serverRemoteAddress <- connection.RemoteAddr().String() + _, _ = moqtransport.NewSession( + quicmoq.NewServer(connection), + "", + moqtransport.WithHandler(&proofHandler{subscriptions: backendSubscriptions}), + ) + }() + subscriberTunnel, err := masqueclient.Open(ctx, masqueclient.Config{ + RelayAddress: relayAddress, + RelayServerName: "moq-proof-relay", + RelayCertSHA256: certificatePin(t, relayCertificate), + TargetAddress: backendAddress, + Timeout: proofTimeout, + }) + if err != nil { + t.Fatal(err) + } + defer subscriberTunnel.Close() + subscriberConnection, err := quic.Dial( + ctx, + subscriberTunnel.PacketConn(), + subscriberTunnel.RemoteAddr(), + &tls.Config{ + InsecureSkipVerify: true, + ServerName: "moq-proof-backend", + NextProtos: []string{moqtransport.MOQT18.String()}, + }, + &quic.Config{ + EnableDatagrams: true, + InitialPacketSize: 1200, + DisablePathMTUDiscovery: true, + }, + ) + if err != nil { + t.Fatal(err) + } + subscriberSession, err := moqtransport.NewSession( + quicmoq.NewClient(subscriberConnection), + "subscriber", + moqtransport.WithHandler(&proofHandler{subscriptions: make(chan *moqtransport.IncomingSubscribeRequest, 1)}), + ) + if err != nil { + t.Fatal(err) + } + defer subscriberSession.CloseWithError(0, "proof complete") + downstream, err := subscriberSession.Subscribe( + ctx, + [][]byte{[]byte("qortal"), []byte("apps"), []byte("proof")}, + "objects", + ) + if err != nil { + t.Fatal(err) + } + + payload := make([]byte, 600) + if _, err = rand.Read(payload); err != nil { + t.Fatal(err) + } + if datagrams { + if err = sendProofObject(publication, moqtransport.Object{GroupID: 1, ObjectID: 0, Payload: payload}, true); err != nil { + t.Fatal(err) + } + } + if err = sendProofObject(publication, moqtransport.Object{GroupID: 1, ObjectID: 1, Payload: payload}, datagrams); err != nil { + t.Fatal(err) + } + + var relayedObject *moqtransport.Object + select { + case object := <-serverObjects: + relayedObject = object + if !bytes.Equal(object.Payload, payload) { + t.Fatal("backend changed the opaque MOQT payload") + } + case <-ctx.Done(): + t.Fatal("backend did not receive the MoQT object") + } + received, err := downstream.ReadObject(ctx) + if err != nil { + t.Fatal(err) + } + if datagrams { + if received.ForwardingPreference != moqtransport.ObjectForwardingPreferenceDatagram || received.ObjectID != 1 || publisherConnection.sends.Load() != 2 { + t.Fatal("lost object was retried or delivery changed to a stream") + } + if err := publication.SendDatagram(moqtransport.Object{GroupID: 2, Payload: make([]byte, 65536)}); err == nil { + t.Fatal("oversized datagram falsely reported success") + } + } + if !bytes.Equal(received.Payload, relayedObject.Payload) { + t.Fatal("subscriber received a different opaque object") + } + + for range 2 { + var observedBackendSource, observedRelayEgress string + select { + case observedBackendSource = <-serverRemoteAddress: + case <-ctx.Done(): + t.Fatal("backend did not observe a source address") + } + select { + case observedRelayEgress = <-relayEgress: + case <-ctx.Done(): + t.Fatal("relay did not report its egress address") + } + if observedBackendSource != observedRelayEgress { + t.Fatalf( + "backend source %q was not the relay egress %q", + observedBackendSource, + observedRelayEgress, + ) + } + } +} + +func testCertificate(t *testing.T, name string) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(now.UnixNano()), + Subject: pkix.Name{CommonName: name}, + DNSNames: []string{name}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate( + rand.Reader, + template, + template, + &key.PublicKey, + key, + ) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +func certificatePin(t *testing.T, certificate tls.Certificate) string { + t.Helper() + parsed, err := x509.ParseCertificate(certificate.Certificate[0]) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(parsed.Raw) + return hex.EncodeToString(digest[:]) +} diff --git a/electron/native/private-transport/internal/protocol/server.go b/electron/native/private-transport/internal/protocol/server.go index b04a7703..0ebc1056 100644 --- a/electron/native/private-transport/internal/protocol/server.go +++ b/electron/native/private-transport/internal/protocol/server.go @@ -16,11 +16,12 @@ import ( "qortal.org/qortal-hub/private-transport/internal/innerquic" masqueclient "qortal.org/qortal-hub/private-transport/internal/masque" + "qortal.org/qortal-hub/private-transport/internal/moqclient" ) const ( Version = 2 - SidecarVersion = "0.2.0" + SidecarVersion = "0.4.0" MaxControlMessageBytes = 64 * 1024 MaxBinaryMessageBytes = innerquic.MaxReliablePayloadBytes maxRememberedRequestIDs = 4096 @@ -46,27 +47,36 @@ type Response struct { Error *Error `json:"error,omitempty"` } type Event struct { - Version int `json:"version"` - Type string `json:"type"` - Event string `json:"event"` - SessionID string `json:"sessionId"` - MessageID string `json:"messageId,omitempty"` - Code string `json:"code,omitempty"` - BinaryLength int `json:"binaryLength,omitempty"` + Version int `json:"version"` + Type string `json:"type"` + Event string `json:"event"` + SessionID string `json:"sessionId"` + MessageID string `json:"messageId,omitempty"` + SubscriptionID string `json:"subscriptionId,omitempty"` + Namespace []string `json:"namespace,omitempty"` + TrackName string `json:"trackName,omitempty"` + GroupID uint64 `json:"groupId"` + ObjectID uint64 `json:"objectId"` + Code string `json:"code,omitempty"` + BinaryLength int `json:"binaryLength,omitempty"` } type Server struct { - mu sync.Mutex - tunnels map[string]*masqueclient.Tunnel - sessions map[string]*innerquic.Session - seen map[string]struct{} - seenIDs []string - writeMu sync.Mutex - emit func(Event, []byte) + mu sync.Mutex + tunnels map[string]*masqueclient.Tunnel + sessions map[string]*innerquic.Session + moqSessions map[string]*moqclient.Session + seen map[string]struct{} + seenIDs []string + writeMu sync.Mutex + emit func(Event, []byte) } func NewServer() *Server { - return &Server{tunnels: map[string]*masqueclient.Tunnel{}, sessions: map[string]*innerquic.Session{}, seen: map[string]struct{}{}} + return &Server{ + tunnels: map[string]*masqueclient.Tunnel{}, sessions: map[string]*innerquic.Session{}, + moqSessions: map[string]*moqclient.Session{}, seen: map[string]struct{}{}, + } } func (s *Server) Serve(ctx context.Context, input io.Reader, output io.Writer) error { @@ -172,7 +182,7 @@ func (s *Server) handleRequest(ctx context.Context, req Request, binary []byte) } switch req.Operation { case "health": - return success(req.RequestID, map[string]interface{}{"service": "qortal-private-transport", "sidecarVersion": SidecarVersion, "protocolVersion": Version, "innerAlpn": innerquic.ALPN}), false + return success(req.RequestID, map[string]interface{}{"service": "qortal-private-transport", "sidecarVersion": SidecarVersion, "protocolVersion": Version, "innerAlpn": innerquic.ALPN, "moqAlpn": moqclient.ALPN}), false case "openMasqueTunnel": return s.openTunnel(ctx, req), false case "sendDatagram": @@ -191,6 +201,16 @@ func (s *Server) handleRequest(ctx context.Context, req Request, binary []byte) return s.sessionMetrics(req), false case "closePrivateSession": return s.closePrivateSession(req), false + case "openMoqSession": + return s.openMoqSession(ctx, req), false + case "subscribeMoqTrack": + return s.subscribeMoqTrack(req), false + case "publishMoqObject": + return s.publishMoqObject(req, binary), false + case "moqSessionMetrics": + return s.moqSessionMetrics(req), false + case "closeMoqSession": + return s.closeMoqSession(req), false case "shutdown": return success(req.RequestID, map[string]bool{"shuttingDown": true}), true default: @@ -198,6 +218,166 @@ func (s *Server) handleRequest(ctx context.Context, req Request, binary []byte) } } +type moqOpenParams struct { + RelayAddress string `json:"relayAddress"` + RelayServerName string `json:"relayServerName"` + RelayCertSHA256 string `json:"relayCertSha256"` + BackendAddress string `json:"backendAddress"` + BackendServerName string `json:"backendServerName"` + BackendCertSHA256 string `json:"backendCertSha256"` + LogicalSessionID string `json:"logicalSessionId"` + AttachToken string `json:"attachToken"` + PublicationNamespace []string `json:"publicationNamespace"` + PublicationTrack string `json:"publicationTrack"` + TimeoutMS int `json:"timeoutMs"` +} + +func (s *Server) openMoqSession(ctx context.Context, req Request) Response { + var p moqOpenParams + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + sessionID, err := randomID("moq-") + if err != nil { + return failure(req.RequestID, "INTERNAL_ERROR", "failed to allocate MOQT session ID") + } + session, err := moqclient.Open(ctx, moqclient.Config{ + Relay: masqueclient.Config{ + RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, + RelayCertSHA256: p.RelayCertSHA256, TargetAddress: p.BackendAddress, + Timeout: duration(p.TimeoutMS), + }, + BackendServerName: p.BackendServerName, BackendCertSHA256: p.BackendCertSHA256, + LogicalSessionID: p.LogicalSessionID, AttachToken: p.AttachToken, + PublicationNamespace: p.PublicationNamespace, PublicationTrack: p.PublicationTrack, + Timeout: duration(p.TimeoutMS), + }, func(event moqclient.Event) { s.emitMoq(sessionID, event) }) + if err != nil { + return failure(req.RequestID, moqErrorCode(err), "MOQT session establishment failed") + } + s.mu.Lock() + s.moqSessions[sessionID] = session + s.mu.Unlock() + return success(req.RequestID, map[string]interface{}{ + "moqSessionId": sessionID, "logicalSessionId": p.LogicalSessionID, + "publicationNamespace": p.PublicationNamespace, "publicationTrack": p.PublicationTrack, + "applicationProtocol": moqclient.ALPN, + }) +} + +func moqErrorCode(err error) string { + text := err.Error() + if strings.Contains(text, "MASQUE_TUNNEL_FAILED") { + return "MASQUE_TUNNEL_FAILED" + } + if strings.Contains(text, "certificate") { + return "BACKEND_IDENTITY_MISMATCH" + } + for _, code := range []string{ + "INVALID_MOQ_CONFIG", "MOQ_QUIC_FAILED", "MOQ_SESSION_FAILED", + "MOQ_ATTACH_FAILED", "DATAGRAM_UNSUPPORTED", "INVALID_MOQ_SUBSCRIPTION", + "MOQ_SUBSCRIBE_FAILED", "MOQ_SESSION_CLOSED", "MOQ_SUBSCRIPTION_LIMIT", + "MOQ_SUBSCRIPTION_ID_REUSED", "MOQ_OBJECT_TOO_LARGE", "MOQ_SEND_FAILED", + } { + if strings.Contains(text, code) { + return code + } + } + return "MOQ_SESSION_FAILED" +} + +func (s *Server) emitMoq(sessionID string, event moqclient.Event) { + if s.emit == nil { + return + } + s.emit(Event{ + Version: Version, Type: "event", Event: event.Kind, SessionID: sessionID, + SubscriptionID: event.SubscriptionID, Namespace: event.Namespace, TrackName: event.TrackName, + GroupID: event.GroupID, ObjectID: event.ObjectID, Code: event.Code, + BinaryLength: len(event.Data), + }, event.Data) +} + +func (s *Server) subscribeMoqTrack(req Request) Response { + var p struct { + MoqSessionID string `json:"moqSessionId"` + SubscriptionID string `json:"subscriptionId"` + Namespace []string `json:"namespace"` + TrackName string `json:"trackName"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + session := s.getMoqSession(p.MoqSessionID) + if session == nil { + return failure(req.RequestID, "MOQ_SESSION_CLOSED", "MOQT session does not exist") + } + if err := session.Subscribe(p.SubscriptionID, p.Namespace, p.TrackName); err != nil { + return failure(req.RequestID, moqErrorCode(err), "MOQT subscription failed") + } + return success(req.RequestID, map[string]interface{}{ + "subscribed": true, "subscriptionId": p.SubscriptionID, + "namespace": p.Namespace, "trackName": p.TrackName, + }) +} + +func (s *Server) publishMoqObject(req Request, binary []byte) Response { + var p struct { + MoqSessionID string `json:"moqSessionId"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + session := s.getMoqSession(p.MoqSessionID) + if session == nil { + return failure(req.RequestID, "MOQ_SESSION_CLOSED", "MOQT session does not exist") + } + if err := session.PublishObject(binary); err != nil { + return failure(req.RequestID, moqErrorCode(err), "MOQT object publish failed") + } + return success(req.RequestID, map[string]interface{}{ + "accepted": true, "bytesSent": len(binary), + }) +} + +func (s *Server) moqSessionMetrics(req Request) Response { + var p struct { + MoqSessionID string `json:"moqSessionId"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + session := s.getMoqSession(p.MoqSessionID) + if session == nil { + return failure(req.RequestID, "MOQ_SESSION_CLOSED", "MOQT session does not exist") + } + return success(req.RequestID, session.Metrics()) +} + +func (s *Server) closeMoqSession(req Request) Response { + var p struct { + MoqSessionID string `json:"moqSessionId"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid params") + } + s.mu.Lock() + session := s.moqSessions[p.MoqSessionID] + delete(s.moqSessions, p.MoqSessionID) + s.mu.Unlock() + if session == nil { + return failure(req.RequestID, "MOQ_SESSION_CLOSED", "MOQT session does not exist") + } + _ = session.Close() + return success(req.RequestID, map[string]bool{"closed": true}) +} + +func (s *Server) getMoqSession(id string) *moqclient.Session { + s.mu.Lock() + defer s.mu.Unlock() + return s.moqSessions[id] +} + type privateOpenParams struct { RelayAddress string `json:"relayAddress"` RelayServerName string `json:"relayServerName"` @@ -406,9 +586,14 @@ func (s *Server) Close() { s.mu.Lock() ts := s.tunnels ss := s.sessions + ms := s.moqSessions s.tunnels = map[string]*masqueclient.Tunnel{} s.sessions = map[string]*innerquic.Session{} + s.moqSessions = map[string]*moqclient.Session{} s.mu.Unlock() + for _, x := range ms { + _ = x.Close() + } for _, x := range ss { _ = x.Close() } diff --git a/electron/native/private-transport/internal/protocol/server_test.go b/electron/native/private-transport/internal/protocol/server_test.go index de4c9ed5..668cc25d 100644 --- a/electron/native/private-transport/internal/protocol/server_test.go +++ b/electron/native/private-transport/internal/protocol/server_test.go @@ -39,6 +39,44 @@ func TestInnerErrorCodeDistinguishesRelayAndBackendCertificates(t *testing.T) { } } +func TestMoqErrorCodeDistinguishesRelayAndBackendCertificates(t *testing.T) { + if got := moqErrorCode(errors.New("MASQUE_TUNNEL_FAILED: relay certificate pin mismatch")); got != "MASQUE_TUNNEL_FAILED" { + t.Fatalf("relay error mapped to %q", got) + } + if got := moqErrorCode(errors.New("MOQ_QUIC_FAILED: backend certificate pin mismatch")); got != "BACKEND_IDENTITY_MISMATCH" { + t.Fatalf("backend error mapped to %q", got) + } +} + +func TestMoqOperationsRejectUnknownSessions(t *testing.T) { + server := NewServer() + for index, request := range []string{ + `{"version":2,"requestId":"subscribe","operation":"subscribeMoqTrack","params":{"moqSessionId":"missing","subscriptionId":"subscription-1","namespace":["qortal","apps"],"trackName":"realtime"}}`, + `{"version":2,"requestId":"publish","operation":"publishMoqObject","params":{"moqSessionId":"missing"}}`, + `{"version":2,"requestId":"metrics","operation":"moqSessionMetrics","params":{"moqSessionId":"missing"}}`, + `{"version":2,"requestId":"close","operation":"closeMoqSession","params":{"moqSessionId":"missing"}}`, + } { + response, shutdown := server.Handle(context.Background(), []byte(request)) + if shutdown || response.OK || response.Error == nil || response.Error.Code != "MOQ_SESSION_CLOSED" { + t.Fatalf("request %d did not fail closed: %#v", index, response) + } + } +} + +func TestAudioSpecificMoqOperationsAreNotAvailable(t *testing.T) { + for _, operation := range []string{ + "openCallMediaSession", "subscribeCallMediaTrack", "publishCallMediaDatagram", + "setCallMediaSendKey", "setCallMediaReceiveKey", "removeCallMediaReceiveKey", + "publishCallAudioFrame", "callMediaSessionMetrics", "closeCallMediaSession", + } { + request := `{"version":2,"requestId":"old-` + operation + `","operation":"` + operation + `","params":{}}` + response, shutdown := NewServer().Handle(context.Background(), []byte(request)) + if shutdown || response.OK || response.Error == nil || response.Error.Code != "UNKNOWN_OPERATION" { + t.Fatalf("operation %q did not fail closed: %#v", operation, response) + } + } +} + func TestDuplicateRequestIDFails(t *testing.T) { server := NewServer() request := []byte(`{"version":2,"requestId":"same","operation":"health"}`) diff --git a/electron/native/private-transport/third_party/moqtransport/LICENSE b/electron/native/private-transport/third_party/moqtransport/LICENSE new file mode 100644 index 00000000..30c77be0 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Mathis Engelbart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/electron/native/private-transport/third_party/moqtransport/QORTAL-PATCHES.md b/electron/native/private-transport/third_party/moqtransport/QORTAL-PATCHES.md new file mode 100644 index 00000000..5ce3038a --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/QORTAL-PATCHES.md @@ -0,0 +1,23 @@ +# Local MoQT dependency + +Upstream: https://github.com/mengelbart/moqtransport +Revision: 9eaf40a4dedd549b6838cba1a900f9b382d722e8 +Wire profile: moqt-18, the profile implemented by that revision. + +This directory contains the core, QUIC adapter, wire codec, varint package, +upstream tests and license. Examples, WebTransport adapter and development +generators are omitted. The parent module selects this copy using Go replace; +there are no module-cache edits or install-time patches. + +Local change: complete IncomingSubscribeRequest.SendDatagram using the existing +draft-18 OBJECT_DATAGRAM encoder and Connection.SendDatagram. Zero object IDs +use the defined flag; explicit publisher priority is 0. Subgroup IDs are +rejected because they have no datagram wire field. Transport errors, including +oversized packets, propagate without retries or conversion to streams. + +Subscription response write failures are logged instead of panicking the +native sidecar when a remote peer disconnects during authorization. + +Run the parent module tests and this module's tests when updating the pin. +This patch does not complete the library's other unfinished APIs or establish +interoperability with another implementation. diff --git a/electron/native/private-transport/third_party/moqtransport/README.md b/electron/native/private-transport/third_party/moqtransport/README.md new file mode 100644 index 00000000..500260a9 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/README.md @@ -0,0 +1,55 @@ +# Media over QUIC Transport (MoQT) + +[![Go Reference](https://pkg.go.dev/badge/github.com/mengelbart/moqtransport.svg)](https://pkg.go.dev/github.com/mengelbart/moqtransport) + +`moqtransport` is a Go implementation of [Media over QUIC Transport](https://datatracker.ietf.org/doc/draft-ietf-moq-transport/) on top of [quic-go](https://github.com/quic-go/quic-go) and optionally [webtransport-go](https://github.com/quic-go/webtransport-go/). + +## Overview + +This library implements the Media over QUIC Transport (MoQT) protocol as defined in [draft-ietf-moq-transport-11](https://www.ietf.org/archive/id/draft-ietf-moq-transport-11.txt). MoQT is designed to operate over QUIC or WebTransport for efficient media delivery with a publish/subscribe model. + +### Implementation Status + +This code, as well as the specification, is work in progress. +The implementation currently covers most aspects of the MoQT specification (draft-11), including: + + Session establishment and initialization + Control message encoding and handling + Data stream management + Track announcement and subscription + Error handling + Support for both QUIC and WebTransport + +### Areas for Future Development + + Implementation of FETCH + Exposure of more parameters + ... + +## Usage + +See the [date examples in the examples directory](examples/date/README.md) for a simple demonstration of how to use this library. + +Basic usage involves: + +1. Creating a connection using either QUIC or WebTransport +2. Establishing a MoQT session +3. Implementing handlers for various MoQT messages +4. Publishing or subscribing to tracks + +## Project Structure + +- `quicmoq/`: QUIC-specific implementation +- `webtransportmoq/`: WebTransport-specific implementation +- `internal/`: Internal implementation details +- `examples/`: Example applications demonstrating usage +- `integrationtests/`: Integration tests + +## Requirements + +- Go 1.23.6 or later +- Dependencies are managed via Go modules + +## License + +See the [LICENSE](LICENSE) file for details. diff --git a/electron/native/private-transport/third_party/moqtransport/connection.go b/electron/native/private-transport/third_party/moqtransport/connection.go new file mode 100644 index 00000000..f173cf27 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/connection.go @@ -0,0 +1,137 @@ +package moqtransport + +import ( + "context" + "errors" + "io" +) + +// Protocol is a transport protocol supported by MoQ. +type Protocol int + +// The supported protocols +const ( + ProtocolQUIC Protocol = iota + ProtocolWebTransport +) + +func (p Protocol) String() string { + switch p { + case ProtocolQUIC: + return "quic" + case ProtocolWebTransport: + return "webtransport" + default: + return "invalid protocol" + } +} + +// Perspective indicates whether the connection is a client or a server +type Perspective int + +// The perspectives +const ( + PerspectiveClient Perspective = iota + PerspectiveServer +) + +func (p Perspective) String() string { + switch p { + case PerspectiveServer: + return "server" + case PerspectiveClient: + return "client" + default: + return "invalid perspective" + } +} + +// A Stream is the interface implemented by bidirectional streams. +type Stream interface { + ReceiveStream + SendStream +} + +// ReceiveStream is the interface implemented by the receiving end of unidirectional +// streams. +type ReceiveStream interface { + // Read reads from the stream. + io.Reader + + // Stop stops reading from the stream and sends a signal to the sender to + // stop sending on the stream. + Stop(uint32) + + // StreamID returns the ID of the stream + StreamID() uint64 +} + +// SendStream is the interface implemented by the sending end of unidirectional +// streams. +type SendStream interface { + // Write writes to the stream. + // Close closes the stream and guarantees retransmissions until all data has + // been received by the receiver or the stream is reset. + io.WriteCloser + + // Reset closes the stream and stops retransmitting outstanding data. + Reset(uint32) + + // StreamID returns the ID of the stream + StreamID() uint64 +} + +var ErrDatagramSupportDisabled = errors.New("datagram support disabled") + +// Connection is the interface of a QUIC/WebTransport connection. New Transports +// expect an implementation of this interface as the underlying connection. +// Implementations based on quic-go and webtransport-go are provided in quicmoq +// and webTransportmoq. +type Connection interface { + // AcceptStream returns the next stream opened by the peer, blocking until + // one is available. + AcceptStream(context.Context) (Stream, error) + + // AcceptUniStream returns the next unidirectional stream opened by the + // peer, blocking until one is available. + AcceptUniStream(context.Context) (ReceiveStream, error) + + // OpenStream opens a new bidirectional stream. + OpenStream() (Stream, error) + + // OpenStreamSync opens a new bidirectional stream, blocking until it can be + // opened. + OpenStreamSync(context.Context) (Stream, error) + + // OpenUniStream opens a new unidirectional stream. + OpenUniStream() (SendStream, error) + + // OpenUniStream opens a new unidirectional stream, blocking until it can be + // opened. + OpenUniStreamSync(context.Context) (SendStream, error) + + // SendDatagram sends a datagram. + SendDatagram([]byte) error + + // ReceiveDatagram receives the next datagram, blocking until one is + // available. + ReceiveDatagram(context.Context) ([]byte, error) + + // CloseWithError closes the connection with an error code and a reason + // string. + CloseWithError(uint64, string) error + + // Context returns a context that will be canceled when the connection is + // closed. + Context() context.Context + + // Protocol returns the underlying transport protocol of the connection. + Protocol() Protocol + + // ApplicationProtocol returns the application protocol negotiated for the + // connection. + ApplicationProtocol() ApplicationProtocol + + // Perspective returns the perspective of the connection. + Perspective() Perspective +} diff --git a/electron/native/private-transport/third_party/moqtransport/datagram_send_test.go b/electron/native/private-transport/third_party/moqtransport/datagram_send_test.go new file mode 100644 index 00000000..19c46957 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/datagram_send_test.go @@ -0,0 +1,57 @@ +package moqtransport + +import ( + "bytes" + "context" + "errors" + "testing" +) + +type datagramCapture struct { + Connection + data []byte + calls int + err error +} + +func (c *datagramCapture) SendDatagram(data []byte) error { + c.calls++ + c.data = append([]byte(nil), data...) + return c.err +} + +func TestDatagramSendWireAndErrors(t *testing.T) { + for _, tc := range []struct { + name string + id uint64 + want []byte + }{ + {"zero", 0, []byte{0x04, 0x01, 0x02, 0x00, 0xaa, 0xbb}}, + {"nonzero", 3, []byte{0x00, 0x01, 0x02, 0x03, 0x00, 0xaa, 0xbb}}, + {"multibyte", 128, []byte{0x00, 0x01, 0x02, 0x80, 0x80, 0x00, 0xaa, 0xbb}}, + } { + t.Run(tc.name, func(t *testing.T) { + conn := &datagramCapture{} + r := &IncomingSubscribeRequest{session: &Session{ctx: context.Background(), conn: conn}, trackAlias: 1} + if err := r.SendDatagram(Object{GroupID: 2, ObjectID: tc.id, Payload: []byte{0xaa, 0xbb}}); err != nil { + t.Fatal(err) + } + if conn.calls != 1 || !bytes.Equal(conn.data, tc.want) { + t.Fatalf("got %x (%d sends), want %x", conn.data, conn.calls, tc.want) + } + conn.err = errors.New("datagram too large") + if err := r.SendDatagram(Object{}); !errors.Is(err, conn.err) || conn.calls != 2 { + t.Fatalf("transport error must propagate after exactly one attempt: %v", err) + } + if err := r.SendDatagram(Object{SubGroupID: 1}); err == nil || conn.calls != 2 { + t.Fatal("subgroup was silently discarded") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + r.session.ctx = ctx + if err := r.SendDatagram(Object{}); !errors.Is(err, context.Canceled) || conn.calls != 2 { + t.Fatal("closed session sent a datagram") + } + }) + } +} diff --git a/electron/native/private-transport/third_party/moqtransport/errors.go b/electron/native/private-transport/third_party/moqtransport/errors.go new file mode 100644 index 00000000..7fa05f5e --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/errors.go @@ -0,0 +1,83 @@ +package moqtransport + +// ErrorCode is a session termination error code. +type ErrorCode uint64 + +const ( + ErrorCodeNoError ErrorCode = 0x00 + ErrorCodeInternal ErrorCode = 0x01 + ErrorCodeUnauthorized ErrorCode = 0x02 + ErrorCodeProtocolViolation ErrorCode = 0x03 + ErrorCodeInvalidRequestID ErrorCode = 0x04 + ErrorCodeDuplicateTrackAlias ErrorCode = 0x05 + ErrorCodeKeyValueFormattingError ErrorCode = 0x06 + ErrorCodeInvalidPath ErrorCode = 0x08 + ErrorCodeMalformedPath ErrorCode = 0x09 + ErrorCodeGoAwayTimeout ErrorCode = 0x10 + ErrorCodeControlMessageTimeout ErrorCode = 0x11 + ErrorCodeDataStreamTimeout ErrorCode = 0x12 + ErrorCodeAuthTokenCacheOverflow ErrorCode = 0x13 + ErrorCodeDuplicateAuthTokenAlias ErrorCode = 0x14 + ErrorCodeVersionNegotiationFailed ErrorCode = 0x15 + ErrorCodeMalformedAuthToken ErrorCode = 0x16 + ErrorCodeUnknownAuthTokenAlias ErrorCode = 0x17 + ErrorCodeExpiredAuthToken ErrorCode = 0x18 + ErrorCodeInvalidAuthority ErrorCode = 0x19 + ErrorCodeMalformedAuthority ErrorCode = 0x1A +) + +// RequestErrorCode is a REQUEST_ERROR error code. +type RequestErrorCode uint64 + +const ( + RequestErrorCodeInternal RequestErrorCode = 0x00 + RequestErrorCodeUnauthorized RequestErrorCode = 0x01 + RequestErrorCodeTimeout RequestErrorCode = 0x02 + RequestErrorCodeNotSupported RequestErrorCode = 0x03 + RequestErrorCodeMalformedAuthToken RequestErrorCode = 0x04 + RequestErrorCodeExpiredAuthToken RequestErrorCode = 0x05 + RequestErrorCodeGoingAway RequestErrorCode = 0x06 + RequestErrorCodeExcessiveLoad RequestErrorCode = 0x09 + RequestErrorCodeDoesNotExist RequestErrorCode = 0x10 + RequestErrorCodeInvalidRange RequestErrorCode = 0x11 + RequestErrorCodeMalformedTrack RequestErrorCode = 0x12 + RequestErrorCodeDuplicateSubscription RequestErrorCode = 0x19 + RequestErrorCodeUninterested RequestErrorCode = 0x20 + RequestErrorCodePrefixOverlap RequestErrorCode = 0x30 + RequestErrorCodeNamespaceTooLarge RequestErrorCode = 0x31 + RequestErrorCodeInvalidJoiningRequestID RequestErrorCode = 0x32 + RequestErrorCodeUnsupportedExtension RequestErrorCode = 0x33 + RequestErrorCodeRedirect RequestErrorCode = 0x34 +) + +// StreamResetErrorCode is used when resetting a stream or sending STOP_SENDING. +type StreamResetErrorCode uint32 + +const ( + StreamResetErrorCodeInternal StreamResetErrorCode = 0x00 + StreamResetErrorCodeCancelled StreamResetErrorCode = 0x01 + StreamResetErrorCodeDeliveryTimeout StreamResetErrorCode = 0x02 + StreamResetErrorCodeSessionClosed StreamResetErrorCode = 0x03 + StreamResetErrorCodeGoingAway StreamResetErrorCode = 0x04 + StreamResetErrorCodeTooFarBehind StreamResetErrorCode = 0x05 + StreamResetErrorCodeUnknownObjectStatus StreamResetErrorCode = 0x06 + StreamResetErrorCodeExpiredAuthToken StreamResetErrorCode = 0x07 + StreamResetErrorCodeExcessiveLoad StreamResetErrorCode = 0x09 + StreamResetErrorCodeMalformedTrack StreamResetErrorCode = 0x12 +) + +// PublishDoneStatusCode says why a subscription ended. +type PublishDoneStatusCode uint64 + +const ( + PublishDoneStatusCodeInternal PublishDoneStatusCode = 0x00 + PublishDoneStatusCodeUnauthorized PublishDoneStatusCode = 0x01 + PublishDoneStatusCodeTrackEnded PublishDoneStatusCode = 0x02 + PublishDoneStatusCodeSubscriptionEnded PublishDoneStatusCode = 0x03 + PublishDoneStatusCodeGoingAway PublishDoneStatusCode = 0x04 + PublishDoneStatusCodeTooFarBehind PublishDoneStatusCode = 0x05 + PublishDoneStatusCodeExpired PublishDoneStatusCode = 0x06 + PublishDoneStatusCodeUpdateFailed PublishDoneStatusCode = 0x08 + PublishDoneStatusCodeExcessiveLoad PublishDoneStatusCode = 0x09 + PublishDoneStatusCodeMalformedTrack PublishDoneStatusCode = 0x12 +) diff --git a/electron/native/private-transport/third_party/moqtransport/go.mod b/electron/native/private-transport/third_party/moqtransport/go.mod new file mode 100644 index 00000000..ec71c77f --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/go.mod @@ -0,0 +1,28 @@ +module github.com/mengelbart/moqtransport + +go 1.25.12 + +require ( + github.com/quic-go/quic-go v0.61.0 + github.com/quic-go/webtransport-go v0.12.0 + github.com/stretchr/testify v1.11.1 + go.uber.org/goleak v1.3.0 + go.uber.org/mock v0.6.0 + golang.org/x/tools v0.47.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool go.uber.org/mock/mockgen diff --git a/electron/native/private-transport/third_party/moqtransport/go.sum b/electron/native/private-transport/third_party/moqtransport/go.sum new file mode 100644 index 00000000..1d5807a6 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/go.sum @@ -0,0 +1,47 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/quic-go/webtransport-go v0.12.0 h1:CpnKNwZvdV0LD73xoHO8QaR0NI3llqpWRwnazdZS0sE= +github.com/quic-go/webtransport-go v0.12.0/go.mod h1:GHne8aRFJ24h73pAMrcywXtuaz/ShBXCLXLvG/NPFdU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/electron/native/private-transport/third_party/moqtransport/goleak_test.go b/electron/native/private-transport/third_party/moqtransport/goleak_test.go new file mode 100644 index 00000000..37eea6da --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/goleak_test.go @@ -0,0 +1,11 @@ +package moqtransport + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/electron/native/private-transport/third_party/moqtransport/handler.go b/electron/native/private-transport/third_party/moqtransport/handler.go new file mode 100644 index 00000000..418d550b --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/handler.go @@ -0,0 +1,7 @@ +package moqtransport + +// Handler is the handler interface for non-specific MoQ messages. +type Handler interface { + HandleGoAway(string) + HandleSubscribe(*IncomingSubscribeRequest) +} diff --git a/electron/native/private-transport/third_party/moqtransport/incoming_subscribe_request.go b/electron/native/private-transport/third_party/moqtransport/incoming_subscribe_request.go new file mode 100644 index 00000000..56ad1964 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/incoming_subscribe_request.go @@ -0,0 +1,124 @@ +package moqtransport + +import ( + "errors" + "fmt" + "io" + "log/slog" + + "github.com/mengelbart/moqtransport/internal/wire" +) + +type IncomingSubscribeRequest struct { + logger *slog.Logger + session *Session + streamWriter messageWriter + streamReader messageReader + + namespace [][]byte + name []byte + + trackAlias uint64 +} + +func newIncomingSubscribeRequest(msg *wire.Subscribe, session *Session, streamWriter messageWriter, streamReader messageReader) *IncomingSubscribeRequest { + isr := &IncomingSubscribeRequest{ + logger: defaultLogger, + session: session, + streamWriter: streamWriter, + streamReader: streamReader, + namespace: msg.TrackNamespace, + name: msg.TrackName, + trackAlias: 0, + } + isr.logger.Debug("incoming subscribe request created", "requestID", msg.RequestID, "namespace", msg.TrackNamespace, "trackName", msg.TrackName) + return isr +} + +// readMessages reads from the request stream until it fails. It must be called +// from a goroutine tracked by the session WaitGroup. +func (r *IncomingSubscribeRequest) readMessages() { + for { + msg, err := r.streamReader.Read() + if err != nil { + if !errors.Is(err, io.EOF) { + r.session.handleReaderError(err) + } + return + } + switch msg := msg.(type) { + case *wire.RequestUpdate: + // TODO + default: + r.session.closeWithError(&SessionError{ + Code: uint64(ErrorCodeProtocolViolation), + Reason: fmt.Sprintf("unexpected message type: %T", msg), + }) + return + } + } +} + +func (r *IncomingSubscribeRequest) Accept(trackAlias uint64) { + r.logger.Debug("accepting subscribe request") + r.trackAlias = trackAlias + err := r.streamWriter.Write(&wire.SubscribeOk{ + TrackAlias: trackAlias, + }) + if err != nil { + r.logger.Debug("failed to accept subscribe request", "error", err) + } +} + +func (r *IncomingSubscribeRequest) Reject(code RequestErrorCode, reason string) { + err := r.streamWriter.Write(&wire.RequestError{ + ErrorCode: uint64(code), + RetryInterval: 0, // TODO: Add retry interval if needed + ErrorReason: reason, + }) + if err != nil { + r.logger.Debug("failed to reject subscribe request", "error", err) + } +} + +func (r *IncomingSubscribeRequest) SendDatagram(o Object) error { + // A datagram carries one complete object. Do not emulate unreliable + // delivery with a stream or retry a rejected send. + if err := r.session.ctx.Err(); err != nil { + return err + } + if o.SubGroupID != 0 { + return errors.New("datagram objects have no subgroup ID") + } + message := &wire.DatagramObject{ + TrackAlias: r.trackAlias, + GroupID: o.GroupID, + ObjectID: o.ObjectID, + PublisherPriority: 0, + ObjectPayload: o.Payload, + } + message.SetZeroObjectID(o.ObjectID == 0) + return r.session.conn.SendDatagram(message.AppendDatagram(nil)) +} + +func (r *IncomingSubscribeRequest) OpenSubgroup(groupID, subgroupID uint64, priority uint8) (*Subgroup, error) { + stream, err := r.session.conn.OpenUniStream() + if err != nil { + return nil, err + } + appender := wire.NewAppender(stream, r.session.version) + return newSubgroup(appender, r.trackAlias, groupID, subgroupID, priority) +} + +func (r *IncomingSubscribeRequest) Close() error { + // TODO + return nil +} + +func (r *IncomingSubscribeRequest) Namespace() [][]byte { + return r.namespace +} + +func (r *IncomingSubscribeRequest) Name() []byte { + return r.name +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/appender.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/appender.go new file mode 100644 index 00000000..10967328 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/appender.go @@ -0,0 +1,83 @@ +package wire + +import ( + "encoding/binary" + "fmt" + "io" + "math" + + "github.com/mengelbart/moqtransport/varint" +) + +type Appender struct { + writer io.Writer + version uint64 +} + +func NewAppender(w io.Writer, version uint64) *Appender { + return &Appender{ + writer: w, + version: version, + } +} + +func (a *Appender) Write(msg ControlMessage) error { + buf := make([]byte, 0, 4096) + + switch msg.(type) { + case *SubgroupObject: + // Objects on a subgroup stream are written without type and length. + case *SubgroupHeader, *FetchHeader, *Padding, *FetchObject: + // Data stream headers are written with a type but without a length, and + // a fetch object with its serialization flags in place of a type. + buf = varint.Append(buf, uint64(msg.Type())) + default: + return a.writeControlMessage(buf, msg) + } + + buf, err := a.serializeMessage(buf, msg) + if err != nil { + return err + } + return a.writeBuffer(buf) +} + +func (a *Appender) writeControlMessage(buf []byte, msg ControlMessage) error { + buf = varint.Append(buf, uint64(msg.Type())) + tl := len(buf) + buf = append(buf, 0x00, 0x00) // length placeholder + + buf, err := a.serializeMessage(buf, msg) + if err != nil { + return err + } + + length := len(buf[tl+2:]) + if length > math.MaxUint16 { + return fmt.Errorf("control message too large") + } + binary.BigEndian.PutUint16(buf[tl:tl+2], uint16(length)) + + return a.writeBuffer(buf) +} + +func (a *Appender) serializeMessage(buf []byte, msg ControlMessage) ([]byte, error) { + switch a.version { + case 18: + buf = msg.append_v18(buf) + default: + return nil, fmt.Errorf("unsupported version: %d", a.version) + } + return buf, nil +} + +func (a *Appender) writeBuffer(buf []byte) error { + n, err := a.writer.Write(buf) + if err != nil { + return err + } + if n != len(buf) { + return fmt.Errorf("failed to write complete message") + } + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/control_message.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/control_message.go new file mode 100644 index 00000000..8642d6db --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/control_message.go @@ -0,0 +1,45 @@ +package wire + +const ( + // Bidirectional stream messages + ControlMessageTypeSubscribe ControlMessageType = 0x3 + ControlMessageTypeSubscribeOk ControlMessageType = 0x4 + + ControlMessageTypePublish ControlMessageType = 0x1d + ControlMessageTypePublishOk ControlMessageType = 0x1e + ControlMessageTypePublishDone ControlMessageType = 0xb + + ControlMessageTypeFetch ControlMessageType = 0x16 + ControlMessageTypeFetchOk ControlMessageType = 0x18 + + ControlMessageTypeTrackStatus ControlMessageType = 0xd + + ControlMessageTypePublishNamespace ControlMessageType = 0x6 + ControlMessageTypeSubscribeNamespace ControlMessageType = 0x50 + ControlMessageTypeSubscribeTracks ControlMessageType = 0x51 + + ControlMessageTypeNamespace ControlMessageType = 0x8 + ControlMessageTypeNamespaceDone ControlMessageType = 0xe + + ControlMessageTypePublishBlocked ControlMessageType = 0xf + + ControlMessageTypeRequestUpdate ControlMessageType = 0x2 + ControlMessageTypeRequestOk ControlMessageType = 0x7 + ControlMessageTypeRequestError ControlMessageType = 0x5 + + // Unidirectional stream messages + ControlMessageTypeSetup ControlMessageType = 0x2f00 + + ControlMessageTypeGoAway ControlMessageType = 0x10 + + ControlMessageTypeFetchHeader ControlMessageType = 0x5 + ControlMessageTypePadding ControlMessageType = 0x132b3e28 +) + +type ControlMessageType uint64 + +type ControlMessage interface { + Type() ControlMessageType + parse_v18(messageReader) error + append_v18([]byte) []byte +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object.go new file mode 100644 index 00000000..1d445adb --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object.go @@ -0,0 +1,81 @@ +package wire + +import ( + "bytes" + + "github.com/mengelbart/moqtransport/varint" +) + +type DatagramObject struct { + typ uint64 + TrackAlias uint64 `proto:"varint"` + GroupID uint64 `proto:"varint"` + ObjectID uint64 `proto:"varint,if=!ZeroObjectID"` + PublisherPriority uint8 `proto:"byte,if=!DefaultPriority"` + Properties []KeyValuePair `proto:"kvp_list_tlv,if=HasProperties"` + ObjectStatus uint64 `proto:"varint,if=Status"` + ObjectPayload []byte `proto:"remaining_bytes,if=!Status"` +} + +func (m *DatagramObject) Type() ControlMessageType { + return ControlMessageType(m.typ) +} + +func (m *DatagramObject) HasProperties() bool { + return getBit(m.typ, 0) +} + +func (m *DatagramObject) SetHasProperties(v bool) { + m.typ = setBit(m.typ, 0, v) +} + +func (m *DatagramObject) EndOfGroup() bool { + return getBit(m.typ, 1) +} + +func (m *DatagramObject) SetEndOfGroup(v bool) { + m.typ = setBit(m.typ, 1, v) +} + +func (m *DatagramObject) ZeroObjectID() bool { + return getBit(m.typ, 2) +} + +func (m *DatagramObject) SetZeroObjectID(v bool) { + m.typ = setBit(m.typ, 2, v) +} + +func (m *DatagramObject) DefaultPriority() bool { + return getBit(m.typ, 3) +} + +func (m *DatagramObject) SetDefaultPriority(v bool) { + m.typ = setBit(m.typ, 3, v) +} + +func (m *DatagramObject) Status() bool { + return getBit(m.typ, 5) +} + +func (m *DatagramObject) SetStatus(v bool) { + m.typ = setBit(m.typ, 5, v) +} + +func (m *DatagramObject) AppendDatagram(buf []byte) []byte { + buf = varint.Append(buf, m.typ) + return m.append_v18(buf) +} + +func (m *DatagramObject) Parse(data []byte) error { + br := bytes.NewReader(data) + + typ, err := varint.Read(br) + if err != nil { + return err + } + m.typ = typ + + r := &boundedReader{reader: br} + r.reset(int64(br.Len())) + return m.parse_v18(r) +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object_test.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object_test.go new file mode 100644 index 00000000..0044d7da --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object_test.go @@ -0,0 +1,57 @@ +package wire + +import ( + "io" + "testing" + + "github.com/mengelbart/moqtransport/varint" + "github.com/stretchr/testify/assert" +) + +func TestDatagramObjectParseLargePropertiesLength(t *testing.T) { + // typ bit 0 set: the datagram claims to carry properties. + data := []byte{0x01, 0x00, 0x00, 0x00, 0x00} + data = varint.Append(data, 1<<40) // properties length in bytes + + m := DatagramObject{} + err := m.Parse(data) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Empty(t, m.Properties) +} + +func TestDatagramObjectParseTruncatedProperty(t *testing.T) { + data := []byte{0x01, 0x00, 0x00, 0x00, 0x00} + data = varint.Append(data, 3) // properties length in bytes + data = varint.Append(data, 1) // property type 1: length-prefixed bytes + data = varint.Append(data, 8) // claims 8 bytes + data = append(data, 'A') + + m := DatagramObject{} + err := m.Parse(data) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + assert.Empty(t, m.Properties) +} + +func TestDatagramObjectParseProperties(t *testing.T) { + data := []byte{0x01, 0x04, 0x05, 0x06, 0x07} + data = varint.Append(data, 5) // properties length in bytes + data = varint.Append(data, 1) // delta 1, type 1: length-prefixed bytes + data = varint.Append(data, 1) + data = append(data, 'A') + data = varint.Append(data, 1) // delta 1, type 2: varint value + data = varint.Append(data, 42) + data = append(data, "payload"...) + + m := DatagramObject{} + err := m.Parse(data) + assert.NoError(t, err) + assert.Equal(t, uint64(4), m.TrackAlias) + assert.Equal(t, uint64(5), m.GroupID) + assert.Equal(t, uint64(6), m.ObjectID) + assert.Equal(t, uint8(7), m.PublisherPriority) + assert.Equal(t, []KeyValuePair{ + {Type: 1, Bytes: []byte("A")}, + {Type: 2, Varint: 42}, + }, m.Properties) + assert.Equal(t, []byte("payload"), m.ObjectPayload) +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object_v18.go new file mode 100644 index 00000000..24102b55 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/datagram_object_v18.go @@ -0,0 +1,79 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *DatagramObject) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.TrackAlias)) + buf = varint.Append(buf, uint64(m.GroupID)) + if !m.ZeroObjectID() { + buf = varint.Append(buf, uint64(m.ObjectID)) + } + if !m.DefaultPriority() { + buf = append(buf, m.PublisherPriority) + } + if m.HasProperties() { + PropertiesBuf := appendKeyValuePairs_v18(nil, m.Properties) + buf = varint.Append(buf, uint64(len(PropertiesBuf))) + buf = append(buf, PropertiesBuf...) + } + if m.Status() { + buf = varint.Append(buf, uint64(m.ObjectStatus)) + } + if !m.Status() { + buf = append(buf, m.ObjectPayload...) + } + return buf +} + +func (m *DatagramObject) parse_v18(r messageReader) error { + var err error + + m.TrackAlias, err = varint.Read(r) + if err != nil { + return err + } + + m.GroupID, err = varint.Read(r) + if err != nil { + return err + } + + if !m.ZeroObjectID() { + m.ObjectID, err = varint.Read(r) + if err != nil { + return err + } + } + + if !m.DefaultPriority() { + m.PublisherPriority, err = r.ReadByte() + if err != nil { + return err + } + } + + if m.HasProperties() { + m.Properties, err = parseKeyValuePairsTLV_v18(r) + if err != nil { + return err + } + } + + if m.Status() { + m.ObjectStatus, err = varint.Read(r) + if err != nil { + return err + } + } + + if !m.Status() { + m.ObjectPayload, err = readRemaining(r) + if err != nil { + return err + } + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_header_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_header_v18.go new file mode 100644 index 00000000..3c0d43f9 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_header_v18.go @@ -0,0 +1,21 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *FetchHeader) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.RequestID)) + return buf +} + +func (m *FetchHeader) parse_v18(r messageReader) error { + var err error + + m.RequestID, err = varint.Read(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_object.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_object.go new file mode 100644 index 00000000..9ad78fb5 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_object.go @@ -0,0 +1,142 @@ +package wire + +import "fmt" + +// Bit positions and masks of the fetch object serialization flags. +const ( + fetchBitObjectIDDelta uint = 2 + fetchBitGroupIDDelta uint = 3 + fetchBitPriority uint = 4 + fetchBitProperties uint = 5 + fetchBitDatagram uint = 6 + + fetchSubgroupIDModeMask uint64 = 0b0000_0011 + + // Serialization flags below 128 are a bit field. + fetchFlagsMax uint64 = 0x7f +) + +// Encodings of the subgroup ID, the two least significant bits of the +// serialization flags. +const ( + // FetchSubgroupIDModeZero omits the subgroup ID, it is 0. + FetchSubgroupIDModeZero uint8 = 0 + // FetchSubgroupIDModePrior omits the subgroup ID, it is the prior object's. + FetchSubgroupIDModePrior uint8 = 1 + // FetchSubgroupIDModePriorPlusOne omits the subgroup ID, it is the prior + // object's plus one. + FetchSubgroupIDModePriorPlusOne uint8 = 2 + // FetchSubgroupIDModeExplicit carries the subgroup ID in the object. + FetchSubgroupIDModeExplicit uint8 = 3 +) + +// Serialization flag values at or above 128. +const ( + FetchEndOfNonExistentRange uint64 = 0x8c + FetchEndOfUnknownRange uint64 = 0x10c +) + +var errInvalidSerializationFlags = fmt.Errorf("invalid fetch object serialization flags") + +type FetchObject struct { + flags uint64 + + GroupIDDelta uint64 `proto:"varint,if=HasGroupIDDelta"` + SubgroupID uint64 `proto:"varint,if=explicitSubgroupID"` + ObjectIDDelta uint64 `proto:"varint,if=HasObjectIDDelta"` + PublisherPriority uint8 `proto:"byte,if=HasPriority"` + Properties []KeyValuePair `proto:"kvp_list_tlv,if=HasProperties"` + ObjectPayload []byte `proto:"tlv_bytes"` +} + +func NewEndOfNonExistentRange(groupIDDelta, objectIDDelta uint64) *FetchObject { + return newEndOfRange(FetchEndOfNonExistentRange, groupIDDelta, objectIDDelta) +} + +func NewEndOfUnknownRange(groupIDDelta, objectIDDelta uint64) *FetchObject { + return newEndOfRange(FetchEndOfUnknownRange, groupIDDelta, objectIDDelta) +} + +func newEndOfRange(flags, groupIDDelta, objectIDDelta uint64) *FetchObject { + return &FetchObject{ + flags: flags, + GroupIDDelta: groupIDDelta, + ObjectIDDelta: objectIDDelta, + } +} + +func (m *FetchObject) Type() ControlMessageType { + return ControlMessageType(m.flags) +} + +// validate rejects a serialization flags value that is neither a bit field nor +// one of the two end of range markers. +func (m *FetchObject) validate() error { + if m.flags <= fetchFlagsMax || m.IsEndOfRange() { + return nil + } + return fmt.Errorf("%w: %v", errInvalidSerializationFlags, m.flags) +} + +func (m *FetchObject) IsEndOfRange() bool { + return m.flags == FetchEndOfNonExistentRange || m.flags == FetchEndOfUnknownRange +} + +func (m *FetchObject) SubgroupIDMode() uint8 { + return uint8(m.flags & fetchSubgroupIDModeMask) +} + +func (m *FetchObject) SetSubgroupIDMode(v uint8) { + switch v { + case FetchSubgroupIDModeZero, FetchSubgroupIDModePrior, FetchSubgroupIDModePriorPlusOne, FetchSubgroupIDModeExplicit: + m.flags = m.flags&^fetchSubgroupIDModeMask | uint64(v) + default: + panic(fmt.Sprintf("invalid fetch subgroup id mode: %d", v)) + } +} + +// explicitSubgroupID reports whether the subgroup ID field is present. A +// datagram object has no subgroup ID and the mode bits are ignored. +func (m *FetchObject) explicitSubgroupID() bool { + return !m.Datagram() && m.SubgroupIDMode() == FetchSubgroupIDModeExplicit +} + +func (m *FetchObject) HasObjectIDDelta() bool { + return getBit(m.flags, fetchBitObjectIDDelta) +} + +func (m *FetchObject) SetHasObjectIDDelta(v bool) { + m.flags = setBit(m.flags, fetchBitObjectIDDelta, v) +} + +func (m *FetchObject) HasGroupIDDelta() bool { + return getBit(m.flags, fetchBitGroupIDDelta) +} + +func (m *FetchObject) SetHasGroupIDDelta(v bool) { + m.flags = setBit(m.flags, fetchBitGroupIDDelta, v) +} + +func (m *FetchObject) HasPriority() bool { + return getBit(m.flags, fetchBitPriority) +} + +func (m *FetchObject) SetHasPriority(v bool) { + m.flags = setBit(m.flags, fetchBitPriority, v) +} + +func (m *FetchObject) HasProperties() bool { + return getBit(m.flags, fetchBitProperties) +} + +func (m *FetchObject) SetHasProperties(v bool) { + m.flags = setBit(m.flags, fetchBitProperties, v) +} + +func (m *FetchObject) Datagram() bool { + return getBit(m.flags, fetchBitDatagram) +} + +func (m *FetchObject) SetDatagram(v bool) { + m.flags = setBit(m.flags, fetchBitDatagram, v) +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_object_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_object_v18.go new file mode 100644 index 00000000..9e3c6329 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_object_v18.go @@ -0,0 +1,80 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *FetchObject) append_v18(buf []byte) []byte { + if m.HasGroupIDDelta() { + buf = varint.Append(buf, uint64(m.GroupIDDelta)) + } + if m.explicitSubgroupID() { + buf = varint.Append(buf, uint64(m.SubgroupID)) + } + if m.HasObjectIDDelta() { + buf = varint.Append(buf, uint64(m.ObjectIDDelta)) + } + if m.HasPriority() { + buf = append(buf, m.PublisherPriority) + } + if m.HasProperties() { + PropertiesBuf := appendKeyValuePairs_v18(nil, m.Properties) + buf = varint.Append(buf, uint64(len(PropertiesBuf))) + buf = append(buf, PropertiesBuf...) + } + buf = varint.Append(buf, uint64(len(m.ObjectPayload))) + buf = append(buf, m.ObjectPayload...) + return buf +} + +func (m *FetchObject) parse_v18(r messageReader) error { + var err error + + if m.HasGroupIDDelta() { + m.GroupIDDelta, err = varint.Read(r) + if err != nil { + return err + } + } + + if m.explicitSubgroupID() { + m.SubgroupID, err = varint.Read(r) + if err != nil { + return err + } + } + + if m.HasObjectIDDelta() { + m.ObjectIDDelta, err = varint.Read(r) + if err != nil { + return err + } + } + + if m.HasPriority() { + m.PublisherPriority, err = r.ReadByte() + if err != nil { + return err + } + } + + if m.HasProperties() { + m.Properties, err = parseKeyValuePairsTLV_v18(r) + if err != nil { + return err + } + } + + var ObjectPayloadLength uint64 + ObjectPayloadLength, err = varint.Read(r) + if err != nil { + return err + } + + m.ObjectPayload, err = readBytes(r, ObjectPayloadLength) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_ok_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_ok_v18.go new file mode 100644 index 00000000..23086737 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_ok_v18.go @@ -0,0 +1,52 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import ( + "errors" + + "github.com/mengelbart/moqtransport/varint" +) + +func (m *FetchOk) append_v18(buf []byte) []byte { + if m.EndOfTrack { + buf = append(buf, byte(1)) + } else { + buf = append(buf, byte(0)) + } + buf = m.EndLocation.append_v18(buf) + buf = varint.Append(buf, uint64(len(m.Parameters))) + buf = appendKeyValuePairs_v18(buf, m.Parameters) + buf = appendKeyValuePairs_v18(buf, m.Properties) + return buf +} + +func (m *FetchOk) parse_v18(r messageReader) error { + var err error + + var EndOfTrackByte byte + EndOfTrackByte, err = r.ReadByte() + if err != nil { + return err + } + if EndOfTrackByte > 1 { + return errors.New("invalid bool flag value") + } + m.EndOfTrack = EndOfTrackByte > 0 + + if err = m.EndLocation.parse_v18(r); err != nil { + return err + } + + m.Parameters, err = parseKeyValuePairsCount_v18(r) + if err != nil { + return err + } + + m.Properties, err = parseKeyValuePairsRemaining_v18(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_stream_test.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_stream_test.go new file mode 100644 index 00000000..741876d0 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_stream_test.go @@ -0,0 +1,196 @@ +package wire + +import ( + "bytes" + "io" + "testing" + + "github.com/mengelbart/moqtransport/varint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFetchStreamBytes(t *testing.T) { + var buf bytes.Buffer + appender := NewAppender(&buf, 18) + + require.NoError(t, appender.Write(&FetchHeader{RequestID: 42})) + + first := &FetchObject{ + GroupIDDelta: 1, + SubgroupID: 9, + ObjectIDDelta: 0, + PublisherPriority: 200, + ObjectPayload: []byte("ab"), + } + first.SetHasGroupIDDelta(true) + first.SetSubgroupIDMode(FetchSubgroupIDModeExplicit) + first.SetHasObjectIDDelta(true) + first.SetHasPriority(true) + require.NoError(t, appender.Write(first)) + + require.NoError(t, appender.Write(&FetchObject{ObjectPayload: []byte("c")})) + require.NoError(t, appender.Write(NewEndOfNonExistentRange(2, 5))) + + assert.Equal(t, []byte{ + 0x05, // FETCH_HEADER + 0x2a, // request ID + + 0x1f, // flags: group delta, subgroup ID mode 0b11, object delta, priority + 0x01, // group ID delta + 0x09, // subgroup ID + 0x00, // object ID delta + 200, // publisher priority + 0x02, 'a', 'b', + + 0x00, // flags: subgroup ID mode 0b00, nothing present + 0x01, 'c', + + 0x80, 0x8c, // flags: end of non-existent range + 0x02, // group ID delta + 0x05, // object ID delta + 0x00, // payload length + }, buf.Bytes()) +} + +// fetchObjects covers every serialization flag, one combination per object. +func fetchObjects() []*FetchObject { + explicit := &FetchObject{ + GroupIDDelta: 1, + SubgroupID: 9, + ObjectIDDelta: 0, + PublisherPriority: 200, + ObjectPayload: []byte("hello"), + } + explicit.SetHasGroupIDDelta(true) + explicit.SetSubgroupIDMode(FetchSubgroupIDModeExplicit) + explicit.SetHasObjectIDDelta(true) + explicit.SetHasPriority(true) + + prior := &FetchObject{ObjectIDDelta: 3, ObjectPayload: []byte("world")} + prior.SetSubgroupIDMode(FetchSubgroupIDModePrior) + prior.SetHasObjectIDDelta(true) + + priorPlusOne := &FetchObject{ObjectPayload: []byte("!")} + priorPlusOne.SetSubgroupIDMode(FetchSubgroupIDModePriorPlusOne) + + properties := &FetchObject{ + Properties: []KeyValuePair{ + {Type: 1, Bytes: []byte("A")}, + {Type: 2, Varint: 42}, + }, + ObjectPayload: []byte("ab"), + } + properties.SetHasProperties(true) + + // A datagram object has no subgroup ID, the mode bits are ignored. + datagram := &FetchObject{ObjectPayload: []byte("d")} + datagram.SetDatagram(true) + datagram.SetSubgroupIDMode(FetchSubgroupIDModeExplicit) + + return []*FetchObject{ + explicit, + prior, + priorPlusOne, + properties, + datagram, + {}, // an empty object, every field absent + NewEndOfNonExistentRange(2, 5), + NewEndOfUnknownRange(0, 1), + } +} + +func TestFetchStreamRoundTrip(t *testing.T) { + var buf bytes.Buffer + appender := NewAppender(&buf, 18) + + header := &FetchHeader{RequestID: 42} + require.NoError(t, appender.Write(header)) + + objects := fetchObjects() + for _, o := range objects { + require.NoError(t, appender.Write(o)) + } + + parser := mustParser(t, &buf, 18, StreamTypeData) + + msg, err := parser.Read() + require.NoError(t, err) + assert.Equal(t, header, msg) + + for i, want := range objects { + msg, err := parser.Read() + require.NoError(t, err, "object %v", i) + assert.Equal(t, want, msg, "object %v", i) + } + + _, err = parser.Read() + assert.ErrorIs(t, err, io.EOF) +} + +func TestParseInvalidSerializationFlags(t *testing.T) { + cases := map[string]struct { + flags uint64 + fields []byte + }{ + // No field is present. + "128": {flags: 0x80, fields: []byte{0x00}}, + // No field is present. + "256": {flags: 0x100, fields: []byte{0x00}}, + // Group delta, object delta, priority, properties and a payload length. + // The datagram bit suppresses the subgroup ID. + "255": {flags: 0xff, fields: []byte{0x00, 0x00, 0x00, 0x00, 0x00}}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := []byte{0x05, 0x2a} // FETCH_HEADER + buf = varint.Append(buf, tc.flags) + buf = append(buf, tc.fields...) + + parser := mustParser(t, bytes.NewReader(buf), 18, StreamTypeData) + + _, err := parser.Read() + require.NoError(t, err) + + _, err = parser.Read() + assert.ErrorIs(t, err, errInvalidSerializationFlags) + }) + } +} + +func TestParseTruncatedFetchObject(t *testing.T) { + full := []byte{ + 0x05, 0x2a, // FETCH_HEADER + 0x3f, // flags: group delta, subgroup mode 0b11, object delta, priority, properties + 0x01, // group ID delta + 0x09, // subgroup ID + 0x00, // object ID delta + 200, // publisher priority + 0x03, // properties length in bytes + 0x01, 0x01, 'A', // property type delta 1, type 1: one byte, 'A' + 0x02, 'a', 'b', // payload + } + for i := 3; i < len(full); i++ { + parser := mustParser(t, bytes.NewReader(full[:i]), 18, StreamTypeData) + + _, err := parser.Read() + require.NoError(t, err, "truncated after %v bytes", i) + + _, err = parser.Read() + assert.ErrorIs(t, err, io.ErrUnexpectedEOF, "truncated after %v bytes", i) + } +} + +func TestParseFetchObjectLargePropertiesLength(t *testing.T) { + buf := []byte{0x05, 0x2a} // FETCH_HEADER + buf = append(buf, 0x20) // flags: properties present + buf = varint.Append(buf, 1<<40) + + parser := mustParser(t, bytes.NewReader(buf), 18, StreamTypeData) + + _, err := parser.Read() + require.NoError(t, err) + + _, err = parser.Read() + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_v18.go new file mode 100644 index 00000000..4f5b94ac --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/fetch_v18.go @@ -0,0 +1,120 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *Fetch) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.RequestID)) + buf = varint.Append(buf, uint64(m.FetchType)) + if m.isStandalone() { + buf = varint.Append(buf, uint64(len(m.TrackNamespace))) + for _, v := range m.TrackNamespace { + buf = varint.Append(buf, uint64(len(v))) + buf = append(buf, v...) + } + } + if m.isStandalone() { + buf = varint.Append(buf, uint64(len(m.TrackName))) + buf = append(buf, m.TrackName...) + } + if m.isStandalone() { + buf = m.StartLocation.append_v18(buf) + } + if m.isStandalone() { + buf = m.EndLocation.append_v18(buf) + } + if m.isJoining() { + buf = varint.Append(buf, uint64(m.JoiningRequestID)) + } + if m.isJoining() { + buf = varint.Append(buf, uint64(m.JoiningStart)) + } + buf = varint.Append(buf, uint64(len(m.Parameters))) + buf = appendKeyValuePairs_v18(buf, m.Parameters) + return buf +} + +func (m *Fetch) parse_v18(r messageReader) error { + var err error + + m.RequestID, err = varint.Read(r) + if err != nil { + return err + } + + m.FetchType, err = varint.Read(r) + if err != nil { + return err + } + + if m.isStandalone() { + var numTrackNamespace uint64 + numTrackNamespace, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackNamespace = make([][]byte, 0) + for range numTrackNamespace { + var length uint64 + length, err = varint.Read(r) + if err != nil { + return err + } + + var value []byte + value, err = readBytes(r, length) + if err != nil { + return err + } + m.TrackNamespace = append(m.TrackNamespace, value) + } + } + + if m.isStandalone() { + var TrackNameLength uint64 + TrackNameLength, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackName, err = readBytes(r, TrackNameLength) + if err != nil { + return err + } + } + + if m.isStandalone() { + if err = m.StartLocation.parse_v18(r); err != nil { + return err + } + } + + if m.isStandalone() { + if err = m.EndLocation.parse_v18(r); err != nil { + return err + } + } + + if m.isJoining() { + m.JoiningRequestID, err = varint.Read(r) + if err != nil { + return err + } + } + + if m.isJoining() { + m.JoiningStart, err = varint.Read(r) + if err != nil { + return err + } + } + + m.Parameters, err = parseKeyValuePairsCount_v18(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/go_away_ctrl_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/go_away_ctrl_v18.go new file mode 100644 index 00000000..2b80896a --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/go_away_ctrl_v18.go @@ -0,0 +1,42 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *GoAwayCtrl) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(len(m.NewSessionURI))) + buf = append(buf, []byte(m.NewSessionURI)...) + buf = varint.Append(buf, uint64(m.Timeout)) + buf = varint.Append(buf, uint64(m.RequestID)) + return buf +} + +func (m *GoAwayCtrl) parse_v18(r messageReader) error { + var err error + + var NewSessionURILength uint64 + NewSessionURILength, err = varint.Read(r) + if err != nil { + return err + } + + var NewSessionURIBytes []byte + NewSessionURIBytes, err = readBytes(r, NewSessionURILength) + if err != nil { + return err + } + m.NewSessionURI = string(NewSessionURIBytes) + + m.Timeout, err = varint.Read(r) + if err != nil { + return err + } + + m.RequestID, err = varint.Read(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/go_away_req_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/go_away_req_v18.go new file mode 100644 index 00000000..95553758 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/go_away_req_v18.go @@ -0,0 +1,36 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *GoAwayReq) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(len(m.NewSessionURI))) + buf = append(buf, []byte(m.NewSessionURI)...) + buf = varint.Append(buf, uint64(m.Timeout)) + return buf +} + +func (m *GoAwayReq) parse_v18(r messageReader) error { + var err error + + var NewSessionURILength uint64 + NewSessionURILength, err = varint.Read(r) + if err != nil { + return err + } + + var NewSessionURIBytes []byte + NewSessionURIBytes, err = readBytes(r, NewSessionURILength) + if err != nil { + return err + } + m.NewSessionURI = string(NewSessionURIBytes) + + m.Timeout, err = varint.Read(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair.go new file mode 100644 index 00000000..40828868 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair.go @@ -0,0 +1,123 @@ +package wire + +import ( + "errors" + "math" + "slices" + + "github.com/mengelbart/moqtransport/varint" +) + +// maxKeyValuePairValueLength is the largest value a pair may carry. +const maxKeyValuePairValueLength = 1<<16 - 1 + +var ( + errKeyValuePairTypeOverflow = errors.New("key value pair type exceeds 2^64-1") + errKeyValuePairValueTooLong = errors.New("key value pair value exceeds 2^16-1 bytes") +) + +type KeyValuePair struct { + Type uint64 + Bytes []byte `proto:"tlv_bytes,if=hasBytes"` + Varint uint64 `proto:"varint,if=!hasBytes"` +} + +// hasBytes reports whether the pair carries a byte string rather than a varint. +func (p *KeyValuePair) hasBytes() bool { + return p.Type%2 == 1 +} + +func (p *KeyValuePair) validate() error { + if len(p.Bytes) > maxKeyValuePairValueLength { + return errKeyValuePairValueTooLong + } + return nil +} + +func compareKeyValuePairs(a, b KeyValuePair) int { + if a.Type < b.Type { + return -1 + } + if a.Type > b.Type { + return 1 + } + return 0 +} + +// appendKeyValuePairs_v18 writes pairs without any outer framing, encoding each +// type as a delta from the previous one. The pairs are sorted by type first, +// because a delta encoding cannot express a decreasing sequence. +func appendKeyValuePairs_v18(buf []byte, pairs []KeyValuePair) []byte { + if !slices.IsSortedFunc(pairs, compareKeyValuePairs) { + pairs = slices.Clone(pairs) + slices.SortStableFunc(pairs, compareKeyValuePairs) + } + prev := uint64(0) + for i := range pairs { + buf = varint.Append(buf, pairs[i].Type-prev) + buf = pairs[i].append_v18(buf) + prev = pairs[i].Type + } + return buf +} + +func parseKeyValuePair_v18(r messageReader, prev uint64) (KeyValuePair, error) { + delta, err := varint.Read(r) + if err != nil { + return KeyValuePair{}, err + } + if delta > math.MaxUint64-prev { + return KeyValuePair{}, errKeyValuePairTypeOverflow + } + p := KeyValuePair{Type: prev + delta} + if err := p.parse_v18(r); err != nil { + return KeyValuePair{}, err + } + return p, p.validate() +} + +// parseKeyValuePairsCount_v18 reads a count prefixed list of pairs. +func parseKeyValuePairsCount_v18(r messageReader) ([]KeyValuePair, error) { + count, err := varint.Read(r) + if err != nil { + return nil, err + } + pairs := make([]KeyValuePair, 0) + prev := uint64(0) + for range count { + p, err := parseKeyValuePair_v18(r, prev) + if err != nil { + return nil, err + } + prev = p.Type + pairs = append(pairs, p) + } + return pairs, nil +} + +// parseKeyValuePairsTLV_v18 reads a byte length prefixed block of pairs. +func parseKeyValuePairsTLV_v18(r messageReader) ([]KeyValuePair, error) { + br, err := tlvReader(r) + if err != nil { + return nil, err + } + return parseKeyValuePairsRemaining_v18(br) +} + +// parseKeyValuePairsRemaining_v18 reads pairs until the reader is exhausted. +func parseKeyValuePairsRemaining_v18(r messageReader) ([]KeyValuePair, error) { + if r.remaining() < 0 { + return nil, errNoMessageLength + } + pairs := make([]KeyValuePair, 0) + prev := uint64(0) + for r.remaining() > 0 { + p, err := parseKeyValuePair_v18(r, prev) + if err != nil { + return nil, err + } + prev = p.Type + pairs = append(pairs, p) + } + return pairs, nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair_test.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair_test.go new file mode 100644 index 00000000..163463eb --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair_test.go @@ -0,0 +1,238 @@ +package wire + +import ( + "fmt" + "io" + "testing" + + "github.com/mengelbart/moqtransport/varint" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAppendKeyValuePairs(t *testing.T) { + cases := []struct { + pairs []KeyValuePair + buf []byte + expect []byte + }{ + { + pairs: []KeyValuePair{{ + Type: 1, + Bytes: []byte(""), + }}, + buf: nil, + expect: []byte{0x01, 0x00}, + }, + { + pairs: []KeyValuePair{{ + Type: 1, + Bytes: []byte("A"), + }}, + buf: nil, + expect: []byte{0x01, 0x01, 'A'}, + }, + { + pairs: []KeyValuePair{{ + Type: 1, + Bytes: []byte("A"), + }}, + buf: []byte{0x01, 0x02}, + expect: []byte{0x01, 0x02, 0x01, 0x01, 'A'}, + }, + { + pairs: []KeyValuePair{{ + Type: 2, + Varint: uint64(1), + }}, + buf: nil, + expect: []byte{0x02, 0x01}, + }, + { + pairs: []KeyValuePair{{ + Type: MaxRequestIDParameterKey, + Varint: uint64(2), + }}, + buf: []byte{}, + expect: []byte{0x02, 0x02}, + }, + { + pairs: []KeyValuePair{{ + Type: MaxRequestIDParameterKey, + Varint: uint64(3), + }}, + buf: []byte{0x01, 0x02}, + expect: []byte{0x01, 0x02, 0x02, 0x03}, + }, + { + pairs: []KeyValuePair{ + {Type: 2, Varint: 42}, + {Type: 77, Bytes: []byte("A")}, + }, + buf: nil, + expect: []byte{0x02, 0x2a, 0x4b, 0x01, 'A'}, + }, + { + pairs: []KeyValuePair{ + {Type: 3, Bytes: []byte("A")}, + {Type: 3, Bytes: []byte("B")}, + }, + buf: nil, + expect: []byte{0x03, 0x01, 'A', 0x00, 0x01, 'B'}, + }, + { + pairs: []KeyValuePair{ + {Type: 2, Varint: 42}, + {Type: 1, Bytes: []byte("A")}, + }, + buf: nil, + expect: []byte{0x01, 0x01, 'A', 0x01, 0x2a}, + }, + } + for i, tc := range cases { + t.Run(fmt.Sprintf("%v", i), func(t *testing.T) { + in := append([]KeyValuePair{}, tc.pairs...) + res := appendKeyValuePairs_v18(tc.buf, tc.pairs) + assert.Equal(t, tc.expect, res) + assert.Equal(t, in, tc.pairs, "input must not be reordered in place") + }) + } +} + +func TestParseKeyValuePair(t *testing.T) { + cases := []struct { + data []byte + prev uint64 + expect KeyValuePair + err error + consumed int64 + }{ + { + data: []byte{byte(MaxRequestIDParameterKey), 0x01}, + expect: KeyValuePair{ + Type: MaxRequestIDParameterKey, + Varint: uint64(1), + }, + err: nil, + consumed: 2, + }, + { + data: append([]byte{byte(PathParameterKey), 11}, "/path/param"...), + expect: KeyValuePair{ + Type: 1, + Bytes: []byte("/path/param"), + }, + err: nil, + consumed: 13, + }, + { + data: []byte{}, + expect: KeyValuePair{}, + err: io.ErrUnexpectedEOF, + consumed: 0, + }, + { + data: []byte{0x05, 0x01, 0x00}, + expect: KeyValuePair{ + Type: 5, + Bytes: []byte{0x00}, + }, + err: nil, + consumed: 3, + }, + { + data: []byte{0x01, 0x01, 'A'}, + expect: KeyValuePair{ + Type: PathParameterKey, + Bytes: []byte("A"), + }, + err: nil, + consumed: 3, + }, + { + data: []byte{0x01, 0x08, 'A'}, + expect: KeyValuePair{}, + err: io.ErrUnexpectedEOF, + consumed: 2, + }, + { + data: []byte{0x01, 0x2a}, + prev: 1, + expect: KeyValuePair{ + Type: 2, + Varint: 42, + }, + err: nil, + consumed: 2, + }, + { + data: []byte{0x00, 0x01, 'A'}, + prev: 3, + expect: KeyValuePair{ + Type: 3, + Bytes: []byte("A"), + }, + err: nil, + consumed: 3, + }, + { + data: varint.Append(nil, 2), + prev: 1<<64 - 1, + expect: KeyValuePair{}, + err: errKeyValuePairTypeOverflow, + consumed: 1, + }, + } + for i, tc := range cases { + t.Run(fmt.Sprintf("%v", i), func(t *testing.T) { + r := newTestBoundedReader(t, tc.data, int64(len(tc.data))) + + res, err := parseKeyValuePair_v18(r, tc.prev) + assert.Equal(t, tc.expect, res) + assert.Equal(t, tc.consumed, int64(len(tc.data))-r.remaining()) + if tc.err != nil { + assert.ErrorIs(t, err, tc.err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestParseKeyValuePairValueTooLong(t *testing.T) { + value := make([]byte, maxKeyValuePairValueLength+1) + data := varint.Append(nil, 1) + data = varint.Append(data, uint64(len(value))) + data = append(data, value...) + + r := newTestBoundedReader(t, data, int64(len(data))) + _, err := parseKeyValuePair_v18(r, 0) + assert.ErrorIs(t, err, errKeyValuePairValueTooLong) +} + +func TestParseKeyValuePairsAccumulatesTypes(t *testing.T) { + pairs := []KeyValuePair{ + {Type: 2, Varint: 42}, + {Type: 77, Bytes: []byte("A")}, + {Type: 77, Bytes: []byte("B")}, + } + buf := varint.Append(nil, uint64(len(pairs))) + buf = appendKeyValuePairs_v18(buf, pairs) + + assert.Equal(t, []byte{ + 0x03, // number of pairs + 0x02, 0x2a, // type 2, varint 42 + 0x4b, 0x01, 'A', // delta 75, type 77, one byte + 0x00, 0x01, 'B', // delta 0, type 77, one byte + }, buf) + + r := newTestBoundedReader(t, buf, int64(len(buf))) + got, err := parseKeyValuePairsCount_v18(r) + require.NoError(t, err) + assert.Equal(t, pairs, got) +} + +func TestParseKeyValuePairsRemainingNeedsLength(t *testing.T) { + _, err := parseKeyValuePairsRemaining_v18(&unboundedReader{}) + assert.ErrorIs(t, err, errNoMessageLength) +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair_v18.go new file mode 100644 index 00000000..7e4cd45b --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/key_value_pair_v18.go @@ -0,0 +1,42 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *KeyValuePair) append_v18(buf []byte) []byte { + if m.hasBytes() { + buf = varint.Append(buf, uint64(len(m.Bytes))) + buf = append(buf, m.Bytes...) + } + if !m.hasBytes() { + buf = varint.Append(buf, uint64(m.Varint)) + } + return buf +} + +func (m *KeyValuePair) parse_v18(r messageReader) error { + var err error + + if m.hasBytes() { + var BytesLength uint64 + BytesLength, err = varint.Read(r) + if err != nil { + return err + } + + m.Bytes, err = readBytes(r, BytesLength) + if err != nil { + return err + } + } + + if !m.hasBytes() { + m.Varint, err = varint.Read(r) + if err != nil { + return err + } + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/location.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/location.go new file mode 100644 index 00000000..ca4b9129 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/location.go @@ -0,0 +1,6 @@ +package wire + +type Location struct { + Group uint64 `proto:"varint"` + Object uint64 `proto:"varint"` +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/location_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/location_v18.go new file mode 100644 index 00000000..5a8afe3d --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/location_v18.go @@ -0,0 +1,27 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *Location) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.Group)) + buf = varint.Append(buf, uint64(m.Object)) + return buf +} + +func (m *Location) parse_v18(r messageReader) error { + var err error + + m.Group, err = varint.Read(r) + if err != nil { + return err + } + + m.Object, err = varint.Read(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/namespace_done_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/namespace_done_v18.go new file mode 100644 index 00000000..c07669fd --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/namespace_done_v18.go @@ -0,0 +1,42 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *NamespaceDone) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(len(m.TrackNamespaceSuffix))) + for _, v := range m.TrackNamespaceSuffix { + buf = varint.Append(buf, uint64(len(v))) + buf = append(buf, v...) + } + return buf +} + +func (m *NamespaceDone) parse_v18(r messageReader) error { + var err error + + var numTrackNamespaceSuffix uint64 + numTrackNamespaceSuffix, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackNamespaceSuffix = make([][]byte, 0) + for range numTrackNamespaceSuffix { + var length uint64 + length, err = varint.Read(r) + if err != nil { + return err + } + + var value []byte + value, err = readBytes(r, length) + if err != nil { + return err + } + m.TrackNamespaceSuffix = append(m.TrackNamespaceSuffix, value) + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/namespace_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/namespace_v18.go new file mode 100644 index 00000000..a62ed523 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/namespace_v18.go @@ -0,0 +1,42 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *Namespace) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(len(m.TrackNamespaceSuffix))) + for _, v := range m.TrackNamespaceSuffix { + buf = varint.Append(buf, uint64(len(v))) + buf = append(buf, v...) + } + return buf +} + +func (m *Namespace) parse_v18(r messageReader) error { + var err error + + var numTrackNamespaceSuffix uint64 + numTrackNamespaceSuffix, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackNamespaceSuffix = make([][]byte, 0) + for range numTrackNamespaceSuffix { + var length uint64 + length, err = varint.Read(r) + if err != nil { + return err + } + + var value []byte + value, err = readBytes(r, length) + if err != nil { + return err + } + m.TrackNamespaceSuffix = append(m.TrackNamespaceSuffix, value) + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/padding_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/padding_v18.go new file mode 100644 index 00000000..74057ea6 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/padding_v18.go @@ -0,0 +1,11 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +func (m *Padding) append_v18(buf []byte) []byte { + return buf +} + +func (m *Padding) parse_v18(r messageReader) error { + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/parameters.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/parameters.go new file mode 100644 index 00000000..819943e7 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/parameters.go @@ -0,0 +1,7 @@ +package wire + +const ( + PathParameterKey = 0x01 + MaxRequestIDParameterKey = 0x02 + AuthorizationTokenParameterKey = 0x03 +) diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/parser.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/parser.go new file mode 100644 index 00000000..76b490d1 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/parser.go @@ -0,0 +1,262 @@ +package wire + +import ( + "bufio" + "errors" + "fmt" + "io" + + "github.com/mengelbart/moqtransport/varint" +) + +var errLengthMismatch = errors.New("length mismatch") + +type streamReader interface { + io.Reader + io.ByteReader +} + +type StreamType uint8 + +const ( + StreamTypeControl StreamType = iota + StreamTypeRequest + StreamTypeData +) + +// validator is implemented by messages with well formedness rules the wire +// format alone cannot express. +type validator interface { + validate() error +} + +func parseMessage(m ControlMessage, r messageReader, version uint64) error { + switch version { + case 18: + if err := m.parse_v18(r); err != nil { + return err + } + default: + return fmt.Errorf("unsupported version: %d", version) + } + if v, ok := m.(validator); ok { + return v.validate() + } + return nil +} + +type Parser struct { + bounded *boundedReader + unbounded *unboundedReader + version uint64 + streamType StreamType + objects objectParser + closed error +} + +func NewParser(r io.Reader, version uint64, streamType StreamType) (*Parser, error) { + switch streamType { + case StreamTypeControl, StreamTypeRequest, StreamTypeData: + default: + return nil, fmt.Errorf("unknown stream type: %d", streamType) + } + reader := bufio.NewReader(r) + return &Parser{ + bounded: &boundedReader{reader: reader}, + unbounded: &unboundedReader{reader: reader}, + version: version, + streamType: streamType, + objects: nil, + }, nil +} + +func (p *Parser) Read() (ControlMessage, error) { + if p.closed != nil { + return nil, p.closed + } + p.unbounded.reset() + + if p.objects != nil { + return p.objects.parse() + } + mt, err := varint.Read(p.unbounded) + if err != nil { + return nil, err + } + if p.streamType == StreamTypeData { + return p.readDataHeader(mt) + } + + length, err := p.readLength() + if err != nil { + return nil, err + } + + m, err := p.controlMessage(mt) + if err != nil { + return nil, err + } + + p.bounded.reset(int64(length)) + if err := parseMessage(m, p.bounded, p.version); err != nil { + return nil, err + } + // A message that under-reads its body would leave the rest of it in the + // stream and desync every message after it. + if p.bounded.remaining() != 0 { + if err := p.bounded.discard(); err != nil { + return nil, err + } + return nil, errLengthMismatch + } + return m, nil +} + +func (p *Parser) readLength() (uint16, error) { + hi, err := p.unbounded.ReadByte() + if err != nil { + return 0, err + } + lo, err := p.unbounded.ReadByte() + if err != nil { + return 0, err + } + return uint16(hi)<<8 | uint16(lo), nil +} + +func (p *Parser) controlMessage(mt uint64) (ControlMessage, error) { + switch p.streamType { + case StreamTypeControl: + switch ControlMessageType(mt) { + case ControlMessageTypeSetup: + return &Setup{}, nil + case ControlMessageTypeGoAway: + return &GoAwayCtrl{}, nil + } + case StreamTypeRequest: + switch ControlMessageType(mt) { + case ControlMessageTypeGoAway: + return &GoAwayReq{}, nil + + case ControlMessageTypeFetch: + return &Fetch{}, nil + case ControlMessageTypeFetchOk: + return &FetchOk{}, nil + + case ControlMessageTypeNamespace: + return &Namespace{}, nil + case ControlMessageTypeNamespaceDone: + return &NamespaceDone{}, nil + + case ControlMessageTypePublish: + return &Publish{}, nil + case ControlMessageTypePublishBlocked: + return &PublishBlocked{}, nil + case ControlMessageTypePublishDone: + return &PublishDone{}, nil + case ControlMessageTypePublishNamespace: + return &PublishNamespace{}, nil + case ControlMessageTypePublishOk: + return &PublishOk{}, nil + + case ControlMessageTypeRequestError: + return &RequestError{}, nil + case ControlMessageTypeRequestOk: + return &RequestOk{}, nil + case ControlMessageTypeRequestUpdate: + return &RequestUpdate{}, nil + + case ControlMessageTypeSubscribe: + return &Subscribe{}, nil + case ControlMessageTypeSubscribeNamespace: + return &SubscribeNamespace{}, nil + case ControlMessageTypeSubscribeOk: + return &SubscribeOk{}, nil + case ControlMessageTypeSubscribeTracks: + return &SubscribeTracks{}, nil + + case ControlMessageTypeTrackStatus: + return &TrackStatus{}, nil + } + default: + return nil, fmt.Errorf("stream type carries no control messages: %d", p.streamType) + } + return nil, fmt.Errorf("unknown control message type: %d", mt) +} + +func (p *Parser) readDataHeader(mt uint64) (ControlMessage, error) { + switch ControlMessageType(mt) { + case ControlMessageTypeFetchHeader: + m := &FetchHeader{} + if err := parseMessage(m, p.unbounded, p.version); err != nil { + return nil, err + } + p.objects = &fetchObjectParser{ + reader: p.unbounded, + version: p.version, + } + return m, nil + + case ControlMessageTypePadding: + m := &Padding{} + p.closed = fmt.Errorf("Read called on parser after %T, which starts no object sequence", m) + return m, nil + + default: + m := &SubgroupHeader{ + typ: mt, + } + if !m.validType() { + return nil, fmt.Errorf("unknown data stream type: %d", mt) + } + if err := parseMessage(m, p.unbounded, p.version); err != nil { + return nil, err + } + p.objects = &subgroupObjectParser{ + reader: p.unbounded, + version: p.version, + hasProperties: m.Properties(), + } + return m, nil + } +} + +// objectParser reads the objects that follow a data stream header. +type objectParser interface { + parse() (ControlMessage, error) +} + +type subgroupObjectParser struct { + reader messageReader + version uint64 + hasProperties bool +} + +func (p *subgroupObjectParser) parse() (ControlMessage, error) { + o := &SubgroupObject{ + hasProperties: p.hasProperties, + } + if err := parseMessage(o, p.reader, p.version); err != nil { + return nil, err + } + return o, nil +} + +type fetchObjectParser struct { + reader messageReader + version uint64 +} + +func (p *fetchObjectParser) parse() (ControlMessage, error) { + flags, err := varint.Read(p.reader) + if err != nil { + return nil, err + } + o := &FetchObject{ + flags: flags, + } + if err := parseMessage(o, p.reader, p.version); err != nil { + return nil, err + } + return o, nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_blocked_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_blocked_v18.go new file mode 100644 index 00000000..c172e47d --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_blocked_v18.go @@ -0,0 +1,55 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *PublishBlocked) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(len(m.TrackNamespaceSuffix))) + for _, v := range m.TrackNamespaceSuffix { + buf = varint.Append(buf, uint64(len(v))) + buf = append(buf, v...) + } + buf = varint.Append(buf, uint64(len(m.TrackName))) + buf = append(buf, m.TrackName...) + return buf +} + +func (m *PublishBlocked) parse_v18(r messageReader) error { + var err error + + var numTrackNamespaceSuffix uint64 + numTrackNamespaceSuffix, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackNamespaceSuffix = make([][]byte, 0) + for range numTrackNamespaceSuffix { + var length uint64 + length, err = varint.Read(r) + if err != nil { + return err + } + + var value []byte + value, err = readBytes(r, length) + if err != nil { + return err + } + m.TrackNamespaceSuffix = append(m.TrackNamespaceSuffix, value) + } + + var TrackNameLength uint64 + TrackNameLength, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackName, err = readBytes(r, TrackNameLength) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_done_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_done_v18.go new file mode 100644 index 00000000..ac35a0bc --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_done_v18.go @@ -0,0 +1,42 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *PublishDone) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.StatusCode)) + buf = varint.Append(buf, uint64(m.StreamCount)) + buf = varint.Append(buf, uint64(len(m.ErrorReason))) + buf = append(buf, []byte(m.ErrorReason)...) + return buf +} + +func (m *PublishDone) parse_v18(r messageReader) error { + var err error + + m.StatusCode, err = varint.Read(r) + if err != nil { + return err + } + + m.StreamCount, err = varint.Read(r) + if err != nil { + return err + } + + var ErrorReasonLength uint64 + ErrorReasonLength, err = varint.Read(r) + if err != nil { + return err + } + + var ErrorReasonBytes []byte + ErrorReasonBytes, err = readBytes(r, ErrorReasonLength) + if err != nil { + return err + } + m.ErrorReason = string(ErrorReasonBytes) + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_namespace_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_namespace_v18.go new file mode 100644 index 00000000..90211c87 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_namespace_v18.go @@ -0,0 +1,55 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *PublishNamespace) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.RequestID)) + buf = varint.Append(buf, uint64(len(m.TrackNamespace))) + for _, v := range m.TrackNamespace { + buf = varint.Append(buf, uint64(len(v))) + buf = append(buf, v...) + } + buf = varint.Append(buf, uint64(len(m.Parameters))) + buf = appendKeyValuePairs_v18(buf, m.Parameters) + return buf +} + +func (m *PublishNamespace) parse_v18(r messageReader) error { + var err error + + m.RequestID, err = varint.Read(r) + if err != nil { + return err + } + + var numTrackNamespace uint64 + numTrackNamespace, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackNamespace = make([][]byte, 0) + for range numTrackNamespace { + var length uint64 + length, err = varint.Read(r) + if err != nil { + return err + } + + var value []byte + value, err = readBytes(r, length) + if err != nil { + return err + } + m.TrackNamespace = append(m.TrackNamespace, value) + } + + m.Parameters, err = parseKeyValuePairsCount_v18(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_ok_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_ok_v18.go new file mode 100644 index 00000000..3b163309 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_ok_v18.go @@ -0,0 +1,28 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *PublishOk) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(len(m.Parameters))) + buf = appendKeyValuePairs_v18(buf, m.Parameters) + buf = appendKeyValuePairs_v18(buf, m.Properties) + return buf +} + +func (m *PublishOk) parse_v18(r messageReader) error { + var err error + + m.Parameters, err = parseKeyValuePairsCount_v18(r) + if err != nil { + return err + } + + m.Properties, err = parseKeyValuePairsRemaining_v18(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_v18.go new file mode 100644 index 00000000..da28514c --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/publish_v18.go @@ -0,0 +1,80 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *Publish) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.RequestID)) + buf = varint.Append(buf, uint64(len(m.TrackNamespace))) + for _, v := range m.TrackNamespace { + buf = varint.Append(buf, uint64(len(v))) + buf = append(buf, v...) + } + buf = varint.Append(buf, uint64(len(m.TrackName))) + buf = append(buf, m.TrackName...) + buf = varint.Append(buf, uint64(m.TrackAlias)) + buf = varint.Append(buf, uint64(len(m.Parameters))) + buf = appendKeyValuePairs_v18(buf, m.Parameters) + buf = appendKeyValuePairs_v18(buf, m.Properties) + return buf +} + +func (m *Publish) parse_v18(r messageReader) error { + var err error + + m.RequestID, err = varint.Read(r) + if err != nil { + return err + } + + var numTrackNamespace uint64 + numTrackNamespace, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackNamespace = make([][]byte, 0) + for range numTrackNamespace { + var length uint64 + length, err = varint.Read(r) + if err != nil { + return err + } + + var value []byte + value, err = readBytes(r, length) + if err != nil { + return err + } + m.TrackNamespace = append(m.TrackNamespace, value) + } + + var TrackNameLength uint64 + TrackNameLength, err = varint.Read(r) + if err != nil { + return err + } + + m.TrackName, err = readBytes(r, TrackNameLength) + if err != nil { + return err + } + + m.TrackAlias, err = varint.Read(r) + if err != nil { + return err + } + + m.Parameters, err = parseKeyValuePairsCount_v18(r) + if err != nil { + return err + } + + m.Properties, err = parseKeyValuePairsRemaining_v18(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/reader.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/reader.go new file mode 100644 index 00000000..01b0f9b0 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/reader.go @@ -0,0 +1,177 @@ +package wire + +import ( + "errors" + "io" + + "github.com/mengelbart/moqtransport/varint" +) + +var errNoMessageLength = errors.New("message is not length delimited") + +// messageReader reads the body of a single message. +type messageReader interface { + io.Reader + io.ByteReader + remaining() int64 +} + +// boundedReader reads at most n bytes of a length delimited message body. +// Running out of budget, or reaching the end of the underlying stream mid +// message, is io.ErrUnexpectedEOF, so that io.EOF keeps meaning that a stream +// ended between messages. +type boundedReader struct { + reader streamReader + n int64 +} + +func (r *boundedReader) reset(n int64) { + r.n = n +} + +func (r *boundedReader) remaining() int64 { + return r.n +} + +func (r *boundedReader) Read(buf []byte) (int, error) { + if len(buf) == 0 { + return 0, nil + } + if r.n <= 0 { + return 0, io.ErrUnexpectedEOF + } + if int64(len(buf)) > r.n { + buf = buf[:r.n] + } + n, err := r.reader.Read(buf) + r.n -= int64(n) + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return n, err +} + +func (r *boundedReader) ReadByte() (byte, error) { + if r.n <= 0 { + return 0, io.ErrUnexpectedEOF + } + b, err := r.reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return 0, err + } + r.n-- + return b, nil +} + +func (r *boundedReader) discard() error { + var scratch [512]byte + for r.n > 0 { + buf := scratch[:] + if int64(len(buf)) > r.n { + buf = buf[:r.n] + } + n, err := io.ReadFull(r.reader, buf) + r.n -= int64(n) + if err != nil { + if errors.Is(err, io.EOF) { + return io.ErrUnexpectedEOF + } + return err + } + } + return nil +} + +// unboundedReader reads a message that is not length delimited. The stream +// ending before the message starts is io.EOF, once the message has begun it is +// io.ErrUnexpectedEOF. +type unboundedReader struct { + reader streamReader + started bool +} + +func (r *unboundedReader) reset() { + r.started = false +} + +func (r *unboundedReader) remaining() int64 { + return -1 +} + +func (r *unboundedReader) Read(buf []byte) (int, error) { + n, err := r.reader.Read(buf) + if n > 0 { + r.started = true + } + if errors.Is(err, io.EOF) && r.started { + err = io.ErrUnexpectedEOF + } + return n, err +} + +func (r *unboundedReader) ReadByte() (byte, error) { + b, err := r.reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) && r.started { + err = io.ErrUnexpectedEOF + } + return 0, err + } + r.started = true + return b, nil +} + +// readBytes reads n bytes. A length delimited message bounds the allocation by +// its remaining budget, otherwise the buffer grows in capped chunks so that a +// bogus length allocates only what actually arrives. +func readBytes(r messageReader, n uint64) ([]byte, error) { + if n == 0 { + return nil, nil + } + if rem := r.remaining(); rem >= 0 { + if n > uint64(rem) { + return nil, io.ErrUnexpectedEOF + } + buf := make([]byte, n) + if _, err := io.ReadFull(r, buf); err != nil { + return nil, err + } + return buf, nil + } + + const chunk = 4096 + buf := make([]byte, 0, min(n, chunk)) + for uint64(len(buf)) < n { + want := min(n-uint64(len(buf)), chunk) + buf = append(buf, make([]byte, want)...) + if _, err := io.ReadFull(r, buf[uint64(len(buf))-want:]); err != nil { + return nil, err + } + } + return buf, nil +} + +// tlvReader reads a byte length and returns a reader bounded to it, for a block +// that is length delimited inside a message. +func tlvReader(r messageReader) (*boundedReader, error) { + n, err := varint.Read(r) + if err != nil { + return nil, err + } + if rem := r.remaining(); rem >= 0 && n > uint64(rem) { + return nil, io.ErrUnexpectedEOF + } + return &boundedReader{reader: r, n: int64(n)}, nil +} + +// readRemaining reads the rest of a length delimited message body. +func readRemaining(r messageReader) ([]byte, error) { + n := r.remaining() + if n < 0 { + return nil, errNoMessageLength + } + return readBytes(r, uint64(n)) +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/reader_test.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/reader_test.go new file mode 100644 index 00000000..4ac73208 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/reader_test.go @@ -0,0 +1,143 @@ +package wire + +import ( + "bufio" + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestBoundedReader(t *testing.T, data []byte, n int64) *boundedReader { + t.Helper() + r := &boundedReader{reader: bufio.NewReader(bytes.NewReader(data))} + r.reset(n) + return r +} + +func TestBoundedReaderBudget(t *testing.T) { + r := newTestBoundedReader(t, []byte("abcdef"), 3) + + for _, want := range []byte("abc") { + b, err := r.ReadByte() + require.NoError(t, err) + assert.Equal(t, want, b) + } + assert.Equal(t, int64(0), r.remaining()) + + _, err := r.ReadByte() + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) +} + +func TestBoundedReaderReadTruncatesToBudget(t *testing.T) { + r := newTestBoundedReader(t, []byte("abcdef"), 2) + + buf := make([]byte, 6) + n, err := r.Read(buf) + require.NoError(t, err) + assert.Equal(t, []byte("ab"), buf[:n]) + assert.Equal(t, int64(0), r.remaining()) +} + +func TestBoundedReaderShortStreamIsUnexpectedEOF(t *testing.T) { + t.Run("ReadByte", func(t *testing.T) { + r := newTestBoundedReader(t, []byte("ab"), 5) + for range 2 { + _, err := r.ReadByte() + require.NoError(t, err) + } + _, err := r.ReadByte() + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + }) + + t.Run("Read", func(t *testing.T) { + r := newTestBoundedReader(t, []byte("ab"), 5) + _, err := io.ReadFull(r, make([]byte, 5)) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + }) +} + +func TestBoundedReaderDiscard(t *testing.T) { + underlying := bufio.NewReader(bytes.NewReader([]byte("abcdef"))) + r := &boundedReader{reader: underlying} + r.reset(4) + + _, err := r.ReadByte() + require.NoError(t, err) + require.NoError(t, r.discard()) + assert.Equal(t, int64(0), r.remaining()) + + // The bytes after the message body are still there for the next read. + rest, err := io.ReadAll(underlying) + require.NoError(t, err) + assert.Equal(t, []byte("ef"), rest) +} + +func TestBoundedReaderDiscardShortStream(t *testing.T) { + r := newTestBoundedReader(t, []byte("ab"), 5) + assert.ErrorIs(t, r.discard(), io.ErrUnexpectedEOF) +} + +func TestUnboundedReaderEOFOnlyBeforeTheMessage(t *testing.T) { + newReader := func(data []byte) *unboundedReader { + return &unboundedReader{reader: bufio.NewReader(bytes.NewReader(data))} + } + + assert.Equal(t, int64(-1), newReader(nil).remaining()) + + t.Run("nothing read is a clean end", func(t *testing.T) { + _, err := newReader(nil).ReadByte() + assert.ErrorIs(t, err, io.EOF) + }) + + t.Run("ending after a byte is unexpected", func(t *testing.T) { + r := newReader([]byte("a")) + _, err := r.ReadByte() + require.NoError(t, err) + + _, err = r.ReadByte() + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + }) + + t.Run("reset starts a new message", func(t *testing.T) { + r := newReader([]byte("a")) + _, err := r.ReadByte() + require.NoError(t, err) + + r.reset() + _, err = r.ReadByte() + assert.ErrorIs(t, err, io.EOF) + }) +} + +func TestReadBytes(t *testing.T) { + t.Run("bounded", func(t *testing.T) { + r := newTestBoundedReader(t, []byte("abcdef"), 4) + buf, err := readBytes(r, 3) + require.NoError(t, err) + assert.Equal(t, []byte("abc"), buf) + assert.Equal(t, int64(1), r.remaining()) + }) + + t.Run("longer than the budget", func(t *testing.T) { + r := newTestBoundedReader(t, []byte("abcdef"), 2) + _, err := readBytes(r, 3) + assert.ErrorIs(t, err, io.ErrUnexpectedEOF) + }) + + t.Run("unbounded", func(t *testing.T) { + r := &unboundedReader{reader: bufio.NewReader(bytes.NewReader([]byte("abcdef")))} + buf, err := readBytes(r, 3) + require.NoError(t, err) + assert.Equal(t, []byte("abc"), buf) + }) + + // A length no stream could deliver must not be allocated up front. + t.Run("unbounded bogus length", func(t *testing.T) { + r := &unboundedReader{reader: bufio.NewReader(bytes.NewReader([]byte("abcdef")))} + _, err := readBytes(r, 1<<60) + assert.Error(t, err) + }) +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/request_error_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/request_error_v18.go new file mode 100644 index 00000000..d0771e74 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/request_error_v18.go @@ -0,0 +1,42 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *RequestError) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.ErrorCode)) + buf = varint.Append(buf, uint64(m.RetryInterval)) + buf = varint.Append(buf, uint64(len(m.ErrorReason))) + buf = append(buf, []byte(m.ErrorReason)...) + return buf +} + +func (m *RequestError) parse_v18(r messageReader) error { + var err error + + m.ErrorCode, err = varint.Read(r) + if err != nil { + return err + } + + m.RetryInterval, err = varint.Read(r) + if err != nil { + return err + } + + var ErrorReasonLength uint64 + ErrorReasonLength, err = varint.Read(r) + if err != nil { + return err + } + + var ErrorReasonBytes []byte + ErrorReasonBytes, err = readBytes(r, ErrorReasonLength) + if err != nil { + return err + } + m.ErrorReason = string(ErrorReasonBytes) + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/request_ok_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/request_ok_v18.go new file mode 100644 index 00000000..b4fe2137 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/request_ok_v18.go @@ -0,0 +1,28 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *RequestOk) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(len(m.Parameters))) + buf = appendKeyValuePairs_v18(buf, m.Parameters) + buf = appendKeyValuePairs_v18(buf, m.Properties) + return buf +} + +func (m *RequestOk) parse_v18(r messageReader) error { + var err error + + m.Parameters, err = parseKeyValuePairsCount_v18(r) + if err != nil { + return err + } + + m.Properties, err = parseKeyValuePairsRemaining_v18(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/request_update_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/request_update_v18.go new file mode 100644 index 00000000..53e2245b --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/request_update_v18.go @@ -0,0 +1,28 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +import "github.com/mengelbart/moqtransport/varint" + +func (m *RequestUpdate) append_v18(buf []byte) []byte { + buf = varint.Append(buf, uint64(m.RequestID)) + buf = varint.Append(buf, uint64(len(m.Parameters))) + buf = appendKeyValuePairs_v18(buf, m.Parameters) + return buf +} + +func (m *RequestUpdate) parse_v18(r messageReader) error { + var err error + + m.RequestID, err = varint.Read(r) + if err != nil { + return err + } + + m.Parameters, err = parseKeyValuePairsCount_v18(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/roundtrip_test.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/roundtrip_test.go new file mode 100644 index 00000000..ff969493 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/roundtrip_test.go @@ -0,0 +1,246 @@ +package wire + +import ( + "bytes" + "fmt" + "io" + "testing" + + "github.com/mengelbart/moqtransport/varint" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func kvps() []KeyValuePair { + return []KeyValuePair{ + {Type: 2, Varint: 4200}, + {Type: 3, Bytes: []byte("value")}, + } +} + +func roundTripCases() []struct { + name string + streamType StreamType + msg ControlMessage +} { + return []struct { + name string + streamType StreamType + msg ControlMessage + }{ + {"Setup", StreamTypeControl, &Setup{Options: kvps()}}, + {"GoAwayCtrl", StreamTypeControl, &GoAwayCtrl{NewSessionURI: "moqt://example", Timeout: 17, RequestID: 3}}, + + {"GoAwayReq", StreamTypeRequest, &GoAwayReq{NewSessionURI: "moqt://example", Timeout: 17}}, + {"Subscribe", StreamTypeRequest, &Subscribe{ + RequestID: 7, + TrackNamespace: [][]byte{[]byte("ns"), []byte("sub")}, + TrackName: []byte("track"), + Parameters: kvps(), + }}, + {"SubscribeOk", StreamTypeRequest, &SubscribeOk{TrackAlias: 9, Parameters: kvps(), Properties: kvps()}}, + {"Publish", StreamTypeRequest, &Publish{ + RequestID: 7, + TrackNamespace: [][]byte{[]byte("ns")}, + TrackName: []byte("track"), + TrackAlias: 9, + Parameters: kvps(), + Properties: kvps(), + }}, + {"PublishOk", StreamTypeRequest, &PublishOk{Parameters: kvps(), Properties: kvps()}}, + {"PublishDone", StreamTypeRequest, &PublishDone{StatusCode: 2, StreamCount: 11, ErrorReason: "done"}}, + {"FetchStandalone", StreamTypeRequest, &Fetch{ + RequestID: 7, + FetchType: FetchTypeStandalone, + TrackNamespace: [][]byte{[]byte("ns"), []byte("sub")}, + TrackName: []byte("track"), + StartLocation: Location{Group: 1, Object: 2}, + EndLocation: Location{Group: 3, Object: 4}, + Parameters: kvps(), + }}, + {"FetchRelativeJoining", StreamTypeRequest, &Fetch{ + RequestID: 7, + FetchType: FetchTypeRelativeJoining, + JoiningRequestID: 3, + JoiningStart: 2, + Parameters: kvps(), + }}, + {"FetchAbsoluteJoining", StreamTypeRequest, &Fetch{ + RequestID: 7, + FetchType: FetchTypeAbsoluteJoining, + JoiningRequestID: 3, + JoiningStart: 9, + Parameters: kvps(), + }}, + {"FetchOk", StreamTypeRequest, &FetchOk{ + EndOfTrack: true, + EndLocation: Location{Group: 4, Object: 5}, + Parameters: kvps(), + Properties: kvps(), + }}, + {"TrackStatus", StreamTypeRequest, &TrackStatus{ + RequestID: 7, + TrackNamespace: [][]byte{[]byte("ns")}, + TrackName: []byte("track"), + Parameters: kvps(), + }}, + {"PublishNamespace", StreamTypeRequest, &PublishNamespace{ + RequestID: 7, + TrackNamespace: [][]byte{[]byte("ns")}, + Parameters: kvps(), + }}, + {"SubscribeNamespace", StreamTypeRequest, &SubscribeNamespace{ + RequestID: 7, + TrackNamespacePrefix: [][]byte{[]byte("ns")}, + Parameters: kvps(), + }}, + {"SubscribeTracks", StreamTypeRequest, &SubscribeTracks{ + RequestID: 7, + TrackNamespacePrefix: [][]byte{[]byte("ns")}, + Parameters: kvps(), + }}, + {"Namespace", StreamTypeRequest, &Namespace{TrackNamespaceSuffix: [][]byte{[]byte("a"), []byte("b")}}}, + {"NamespaceDone", StreamTypeRequest, &NamespaceDone{TrackNamespaceSuffix: [][]byte{[]byte("a")}}}, + {"PublishBlocked", StreamTypeRequest, &PublishBlocked{ + TrackNamespaceSuffix: [][]byte{[]byte("a")}, + TrackName: []byte("track"), + }}, + {"RequestUpdate", StreamTypeRequest, &RequestUpdate{RequestID: 7, Parameters: kvps()}}, + {"RequestOk", StreamTypeRequest, &RequestOk{Parameters: kvps(), Properties: kvps()}}, + {"RequestError", StreamTypeRequest, &RequestError{ErrorCode: 3, RetryInterval: 8, ErrorReason: "nope"}}, + + {"FetchHeader", StreamTypeData, &FetchHeader{RequestID: 42}}, + {"Padding", StreamTypeData, &Padding{}}, + {"SubgroupHeader", StreamTypeData, NewSubgroupHeader(4, 7, 9, 200)}, + } +} + +func encode(t *testing.T, msg ControlMessage) []byte { + t.Helper() + var buf bytes.Buffer + require.NoError(t, NewAppender(&buf, 18).Write(msg)) + return buf.Bytes() +} + +func mustParser(t *testing.T, r io.Reader, version uint64, streamType StreamType) *Parser { + t.Helper() + p, err := NewParser(r, version, streamType) + require.NoError(t, err) + return p +} + +func TestMessageRoundTrip(t *testing.T) { + for _, tc := range roundTripCases() { + t.Run(tc.name, func(t *testing.T) { + encoded := encode(t, tc.msg) + + got, err := mustParser(t, bytes.NewReader(encoded), 18, tc.streamType).Read() + require.NoError(t, err) + assert.Equal(t, tc.msg, got) + }) + } +} + +func deltaPairs() []KeyValuePair { + return []KeyValuePair{ + {Type: 2, Varint: 4200}, + {Type: 77, Bytes: []byte("value")}, + {Type: 77, Bytes: []byte("other")}, + } +} + +// deltaPairBytes is the delta encoding of deltaPairs, without any framing. +var deltaPairBytes = []byte{ + 0x02, 0x90, 0x68, // delta 2, type 2: varint 4200 + 0x4b, 0x05, 'v', 'a', 'l', 'u', 'e', // delta 75, type 77: five bytes + 0x00, 0x05, 'o', 't', 'h', 'e', 'r', // delta 0, type 77: five bytes +} + +func TestSetupOptionsBytes(t *testing.T) { + msg := &Setup{Options: deltaPairs()} + + assert.Equal(t, deltaPairBytes, msg.append_v18(nil)) + + got, err := mustParser(t, bytes.NewReader(encode(t, msg)), 18, StreamTypeControl).Read() + require.NoError(t, err) + assert.Equal(t, msg, got) +} + +func TestParameterListBytes(t *testing.T) { + msg := &RequestUpdate{RequestID: 7, Parameters: deltaPairs()} + + want := []byte{0x07, 0x03} // request ID, number of parameters + want = append(want, deltaPairBytes...) + assert.Equal(t, want, msg.append_v18(nil)) + + got, err := mustParser(t, bytes.NewReader(encode(t, msg)), 18, StreamTypeRequest).Read() + require.NoError(t, err) + assert.Equal(t, msg, got) +} + +// TestMessageTruncated cuts every encoding at every prefix. io.EOF means only +// that no byte of a message was read, so it is expected at offset 0 and nowhere +// else: once a message has begun, ending early is io.ErrUnexpectedEOF. +func TestMessageTruncated(t *testing.T) { + for _, tc := range roundTripCases() { + t.Run(tc.name, func(t *testing.T) { + encoded := encode(t, tc.msg) + for i := range encoded { + want := io.ErrUnexpectedEOF + if i == 0 { + want = io.EOF + } + _, err := mustParser(t, bytes.NewReader(encoded[:i]), 18, tc.streamType).Read() + assert.ErrorIs(t, err, want, fmt.Sprintf("truncated to %v of %v bytes", i, len(encoded))) + } + }) + } +} + +// TestParseBodyLongerThanMessage rejects a body the parser under-reads, whose +// leftover bytes would otherwise stay in the stream and desync every message +// after it. +func TestParseBodyLongerThanMessage(t *testing.T) { + msg := &GoAwayReq{NewSessionURI: "moqt://example", Timeout: 17} + body := msg.append_v18(nil) + + encoded := varint.Append(nil, uint64(msg.Type())) + encoded = append(encoded, byte((len(body)+1)>>8), byte(len(body)+1)) + encoded = append(encoded, body...) + encoded = append(encoded, 0xff) + + _, err := mustParser(t, bytes.NewReader(encoded), 18, StreamTypeRequest).Read() + assert.ErrorIs(t, err, errLengthMismatch) +} + +// TestParseFetchUnknownType covers the one conditional whose discriminator is a +// field of the message rather than a bit of the type varint: an unknown fetch +// type carries neither structure, and must not be read as if it carried the +// standalone one. +func TestParseFetchUnknownType(t *testing.T) { + standalone := &Fetch{ + RequestID: 7, + FetchType: FetchTypeStandalone, + TrackNamespace: [][]byte{[]byte("ns")}, + TrackName: []byte("track"), + StartLocation: Location{Group: 1, Object: 2}, + EndLocation: Location{Group: 3, Object: 4}, + Parameters: kvps(), + } + encoded := encode(t, standalone) + + // Rewrite the fetch type in place. It is the second varint of the body, + // after the one byte request ID. + body := len(varint.Append(nil, uint64(standalone.Type()))) + 2 + require.Equal(t, byte(FetchTypeStandalone), encoded[body+1]) + encoded[body+1] = 0x04 + + _, err := mustParser(t, bytes.NewReader(encoded), 18, StreamTypeRequest).Read() + assert.Error(t, err) +} + +func TestNewParserRejectsUnknownStreamType(t *testing.T) { + _, err := NewParser(bytes.NewReader(nil), 18, StreamType(7)) + assert.Error(t, err) +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/setup_v18.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/setup_v18.go new file mode 100644 index 00000000..e502b89c --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/setup_v18.go @@ -0,0 +1,19 @@ +// Code generated by "wiregen -dir ./internal/wire/"; DO NOT EDIT. + +package wire + +func (m *Setup) append_v18(buf []byte) []byte { + buf = appendKeyValuePairs_v18(buf, m.Options) + return buf +} + +func (m *Setup) parse_v18(r messageReader) error { + var err error + + m.Options, err = parseKeyValuePairsRemaining_v18(r) + if err != nil { + return err + } + + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/internal/wire/subgroup_header.go b/electron/native/private-transport/third_party/moqtransport/internal/wire/subgroup_header.go new file mode 100644 index 00000000..efd31333 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/internal/wire/subgroup_header.go @@ -0,0 +1,126 @@ +package wire + +import "fmt" + +func setBit(x uint64, bit uint, v bool) uint64 { + if v { + return x | (1 << bit) + } + return x & ^(1 << bit) +} + +func getBit(x uint64, bit uint) bool { + return (x & (1 << bit)) != 0 +} + +// Bit positions of the flags in the subgroup header type. +const ( + subgroupBitProperties uint = 0 + subgroupBitSubgroupIDMode uint = 1 // two bits wide + subgroupBitEndOfGroup uint = 3 + subgroupBitDefaultPriority uint = 5 + subgroupBitFirstObject uint = 6 + + subgroupIDModeMask uint64 = 0b0000_0110 + + // Subgroup header types take the form 0b0XX1XXXX and fit in a single byte. + subgroupTypeFormMask uint64 = 0b1001_0000 + subgroupTypeForm uint64 = 0b0001_0000 + subgroupTypeMax uint64 = 0xff +) + +// Modes of the two bit SUBGROUP_ID_MODE field of the subgroup header type. +const ( + // SubgroupIDModeZero omits the subgroup ID from the header, it is 0. + SubgroupIDModeZero uint8 = 0 + // SubgroupIDModeFirstObject omits the subgroup ID from the header, it is the + // object ID of the first object on the stream. + SubgroupIDModeFirstObject uint8 = 1 + // SubgroupIDModeExplicit carries the subgroup ID in the header. + SubgroupIDModeExplicit uint8 = 2 + // SubgroupIDModeReserved is reserved for future use and invalid on the wire. + SubgroupIDModeReserved uint8 = 3 +) + +type SubgroupHeader struct { + typ uint64 + TrackAlias uint64 `proto:"varint"` + GroupID uint64 `proto:"varint"` + SubgroupID uint64 `proto:"varint,if=explicitSubgroupID"` + PublisherPriority uint8 `proto:"byte,if=!DefaultPriority"` +} + +func NewSubgroupHeader(trackAlias, groupID, subgroupID uint64, publisherPriority uint8) *SubgroupHeader { + m := &SubgroupHeader{ + typ: subgroupTypeForm, + TrackAlias: trackAlias, + GroupID: groupID, + SubgroupID: subgroupID, + PublisherPriority: publisherPriority, + } + m.SetSubgroupIDMode(SubgroupIDModeExplicit) + return m +} + +func (m *SubgroupHeader) Type() ControlMessageType { + return ControlMessageType(m.typ) +} + +func (m *SubgroupHeader) validType() bool { + if m.typ > subgroupTypeMax { + return false + } + if m.typ&subgroupTypeFormMask != subgroupTypeForm { + return false + } + return m.SubgroupIDMode() != SubgroupIDModeReserved +} + +func (m *SubgroupHeader) Properties() bool { + return getBit(m.typ, subgroupBitProperties) +} + +func (m *SubgroupHeader) SetProperties(v bool) { + m.typ = setBit(m.typ, subgroupBitProperties, v) +} + +func (m *SubgroupHeader) explicitSubgroupID() bool { + return m.SubgroupIDMode() == SubgroupIDModeExplicit +} + +func (m *SubgroupHeader) SubgroupIDMode() uint8 { + return uint8((m.typ & subgroupIDModeMask) >> subgroupBitSubgroupIDMode) +} + +func (m *SubgroupHeader) SetSubgroupIDMode(v uint8) { + switch v { + case SubgroupIDModeZero, SubgroupIDModeFirstObject, SubgroupIDModeExplicit: + m.typ = m.typ&^subgroupIDModeMask | uint64(v)<= 0; i-- { + if (firstByte[0] & (1 << uint(i))) == 0 { + break + } + needed++ + } + + return br.Peek(needed) +} + +func (s *Session) handleBidiStream(stream Stream) { + s.logger.Debug("accepted new bidi stream", "streamID", stream.StreamID()) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + defer wg.Wait() + wg.Go(func() { + select { + case <-ctx.Done(): + case <-s.ctx.Done(): + stream.Stop(0) // TODO: Set correct error code? + stream.Reset(0) // TODO: Set correct error code? + } + }) + + parser, err := wire.NewParser(stream, uint64(s.version), wire.StreamTypeRequest) + if err != nil { + stream.Stop(uint32(StreamResetErrorCodeInternal)) + stream.Reset(uint32(StreamResetErrorCodeInternal)) + return + } + msg, err := parser.Read() + if err != nil { + stream.Stop(uint32(StreamResetErrorCodeInternal)) + stream.Reset(uint32(StreamResetErrorCodeInternal)) + return + } + switch m := msg.(type) { + case *wire.TrackStatus: + case *wire.Subscribe: + // TODO: Handle incoming request + if s.handler == nil { + return + } + request := newIncomingSubscribeRequest(m, s, wire.NewAppender(stream, uint64(s.version)), parser) + s.handler.HandleSubscribe(request) + request.readMessages() + case *wire.Publish: + case *wire.Fetch: + case *wire.PublishNamespace: + case *wire.SubscribeNamespace: + case *wire.SubscribeTracks: + default: + s.closeWithError(&SessionError{Code: uint64(ErrorCodeProtocolViolation), Reason: fmt.Sprintf("unexpected message type: %T", m), Remote: false}) + return + } +} + +// readDataStream reads objects from a subgroup stream until it ends and routes +// them by track alias. It must be called from a goroutine tracked by the +// session WaitGroup. +func (s *Session) readDataStream(header *wire.SubgroupHeader, parser messageReader) { + var ( + firstObject = true + lastObjectID uint64 + subgroupID = header.SubgroupID + ) + for { + m, err := parser.Read() + if err != nil { + if !errors.Is(err, io.EOF) { + s.handleReaderError(err) + } + return + } + o, ok := m.(*wire.SubgroupObject) + if !ok { + s.closeWithError(&SessionError{ + Code: uint64(ErrorCodeProtocolViolation), + Reason: fmt.Sprintf("unexpected message type: %T", m), + }) + return + } + objectID := o.ObjectIDDelta + if firstObject { + if header.SubgroupIDMode() == wire.SubgroupIDModeFirstObject { + subgroupID = objectID + } + } else { + if o.ObjectIDDelta >= math.MaxUint64-lastObjectID { + s.closeWithError(&SessionError{ + Code: uint64(ErrorCodeProtocolViolation), + Reason: "object ID out of range", + }) + return + } + objectID = lastObjectID + o.ObjectIDDelta + 1 + } + firstObject = false + lastObjectID = objectID + + payload := make([]byte, len(o.ObjectPayload)) + copy(payload, o.ObjectPayload) + s.logger.Debug("received object", "groupID", header.GroupID, "subgroupID", subgroupID, "objectID", objectID, "payloadLength", len(payload)) + s.pushObject(header.TrackAlias, &Object{ + GroupID: header.GroupID, + ObjectID: objectID, + ForwardingPreference: ObjectForwardingPreferenceSubgroup, + SubGroupID: subgroupID, + Payload: payload, + }) + } +} + +func (s *Session) receiveDatagram(msg *wire.DatagramObject) { + payload := make([]byte, len(msg.ObjectPayload)) + copy(payload, msg.ObjectPayload) + s.pushObject(msg.TrackAlias, &Object{ + GroupID: msg.GroupID, + ObjectID: msg.ObjectID, + ForwardingPreference: ObjectForwardingPreferenceDatagram, + Payload: payload, + }) +} + +func (s *Session) Subscribe( + ctx context.Context, + namespace [][]byte, + name string, +) (*OutgoingSubscribeRequest, error) { + s.closeLock.Lock() + if s.closeErr != nil { + s.closeLock.Unlock() + return nil, s.closeErr + } + s.closeLock.Unlock() + + requestID := s.requestIDs.next() + stream, err := s.conn.OpenStreamSync(ctx) + if err != nil { + return nil, err + } + s.logger.Debug("opened new stream for subscribe request", "requestID", requestID, "namespace", namespace, "name", name) + parser, err := wire.NewParser(stream, uint64(s.version), wire.StreamTypeRequest) + if err != nil { + return nil, err + } + appender := wire.NewAppender(stream, uint64(s.version)) + + request, err := newOutgoingSubscribeRequest(requestID, s, appender, parser, namespace, []byte(name)) + if err != nil { + return nil, err + } + + if err := s.goTracked(request.readMessages); err != nil { + return nil, err + } + return request, nil +} + +func (s *Session) onGoAway(msg *wire.GoAwayCtrl) { + if s.handler == nil { + return + } + s.handler.HandleGoAway(msg.NewSessionURI) +} diff --git a/electron/native/private-transport/third_party/moqtransport/session_control_stream_test.go b/electron/native/private-transport/third_party/moqtransport/session_control_stream_test.go new file mode 100644 index 00000000..11f479a3 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/session_control_stream_test.go @@ -0,0 +1,46 @@ +package moqtransport + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +func sessionCloseError(s *Session) error { + s.closeLock.Lock() + defer s.closeLock.Unlock() + return s.closeErr +} + +func acceptedControlStream(s *Session) *remoteControlStream { + s.controlStreamLock.Lock() + defer s.controlStreamLock.Unlock() + return s.remoteControlStream +} + +// A session has exactly one remote control stream. A second one from the peer +// is a protocol violation and must not replace the first. +func TestDuplicateControlStreamClosesSession(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + first := conn.acceptUniStream(encodeControlMessage(t, setupWithPath("/path"))) + <-first.drained + accepted := acceptedControlStream(session) + require.NotNil(t, accepted) + + conn.acceptUniStream(encodeControlMessage(t, setupWithPath("/path"))) + require.Eventually(t, func() bool { + return sessionCloseError(session) != nil + }, time.Second, time.Millisecond) + + assert.ErrorIs(t, sessionCloseError(session), &SessionError{Code: uint64(ErrorCodeProtocolViolation)}) + assert.Same(t, accepted, acceptedControlStream(session)) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} diff --git a/electron/native/private-transport/third_party/moqtransport/session_helpers.go b/electron/native/private-transport/third_party/moqtransport/session_helpers.go new file mode 100644 index 00000000..bdcc0f26 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/session_helpers.go @@ -0,0 +1,34 @@ +package moqtransport + +import ( + "slices" + + "github.com/mengelbart/moqtransport/internal/wire" +) + +func validatePathParameter(setupParameters []wire.KeyValuePair, protocolIsQUIC bool) (string, error) { + index := slices.IndexFunc(setupParameters, func(p wire.KeyValuePair) bool { + return p.Type == wire.PathParameterKey + }) + if index < 0 { + if protocolIsQUIC { + return "", errMissingPathParameter + } + return "", nil + } + if !protocolIsQUIC { + return "", errUnexpectedPathParameter + } + return string(setupParameters[index].Bytes), nil +} + +//nolint:unused +func validateAuthParameter(subscribeParameters []wire.KeyValuePair) (string, error) { + index := slices.IndexFunc(subscribeParameters, func(p wire.KeyValuePair) bool { + return p.Type == wire.AuthorizationTokenParameterKey + }) + if index < 0 { + return "", nil + } + return string(subscribeParameters[index].Bytes), nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/session_helpers_test.go b/electron/native/private-transport/third_party/moqtransport/session_helpers_test.go new file mode 100644 index 00000000..16b73a67 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/session_helpers_test.go @@ -0,0 +1,62 @@ +package moqtransport + +import ( + "testing" + + "github.com/mengelbart/moqtransport/internal/wire" + "github.com/stretchr/testify/assert" +) + +func TestValidatePathParameter(t *testing.T) { + path := wire.KeyValuePair{Type: wire.PathParameterKey, Bytes: []byte("/path")} + other := wire.KeyValuePair{Type: wire.MaxRequestIDParameterKey, Varint: 1} + + cases := []struct { + name string + parameters []wire.KeyValuePair + protocolIsQUIC bool + expect string + err error + }{ + { + name: "missing on QUIC", + protocolIsQUIC: true, + err: errMissingPathParameter, + }, + { + name: "missing on WebTransport", + protocolIsQUIC: false, + }, + { + name: "first parameter on QUIC", + parameters: []wire.KeyValuePair{path, other}, + protocolIsQUIC: true, + expect: "/path", + }, + { + name: "later parameter on QUIC", + parameters: []wire.KeyValuePair{other, path}, + protocolIsQUIC: true, + expect: "/path", + }, + { + name: "first parameter on WebTransport", + parameters: []wire.KeyValuePair{path, other}, + protocolIsQUIC: false, + err: errUnexpectedPathParameter, + }, + { + name: "later parameter on WebTransport", + parameters: []wire.KeyValuePair{other, path}, + protocolIsQUIC: false, + err: errUnexpectedPathParameter, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := validatePathParameter(tc.parameters, tc.protocolIsQUIC) + assert.Equal(t, tc.expect, res) + assert.Equal(t, tc.err, err) + }) + } +} diff --git a/electron/native/private-transport/third_party/moqtransport/session_objects_test.go b/electron/native/private-transport/third_party/moqtransport/session_objects_test.go new file mode 100644 index 00000000..4bf734ee --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/session_objects_test.go @@ -0,0 +1,272 @@ +package moqtransport + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/mengelbart/moqtransport/internal/wire" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" +) + +type testReceiver struct { + objects []*Object +} + +func (r *testReceiver) push(o *Object) { + r.objects = append(r.objects, o) +} + +// testObject is an object on a subgroup stream, identified by its wire object +// ID delta rather than its object ID. +type testObject struct { + delta uint64 + payload string +} + +func encodeDataStream(t *testing.T, trackAlias, groupID, subgroupID uint64, objects ...testObject) []byte { + t.Helper() + var buf bytes.Buffer + appender := wire.NewAppender(&buf, 18) + require.NoError(t, appender.Write(wire.NewSubgroupHeader(trackAlias, groupID, subgroupID, 0))) + for _, o := range objects { + require.NoError(t, appender.Write(&wire.SubgroupObject{ + ObjectIDDelta: o.delta, + ObjectPayload: []byte(o.payload), + })) + } + return buf.Bytes() +} + +func encodeDatagram(trackAlias, groupID, objectID uint64, payload string) []byte { + msg := &wire.DatagramObject{ + TrackAlias: trackAlias, + GroupID: groupID, + ObjectID: objectID, + ObjectPayload: []byte(payload), + } + return msg.AppendDatagram(nil) +} + +func readObject(t *testing.T, request *OutgoingSubscribeRequest) *Object { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + o, err := request.ReadObject(ctx) + require.NoError(t, err) + return o +} + +func trackCount(s *Session) int { + s.tracksLock.Lock() + defer s.tracksLock.Unlock() + return len(s.tracks) +} + +func hasTrackAlias(s *Session, trackAlias uint64) bool { + s.tracksLock.Lock() + defer s.tracksLock.Unlock() + entry, ok := s.tracks[trackAlias] + return ok && entry.receiver != nil +} + +// subscribe starts a subscription and returns it together with the reader of +// the stream the session opened for it. +func subscribe(t *testing.T, session *Session, conn *testConnection) (*OutgoingSubscribeRequest, *blockingReader) { + t.Helper() + request, err := session.Subscribe(context.Background(), [][]byte{[]byte("namespace")}, "track") + require.NoError(t, err) + return request, <-conn.openedStreams +} + +// The track alias is assigned by the peer in SUBSCRIBE_OK, so a subgroup stream +// can arrive first. Its objects must be delivered once the alias is known. +func TestSubgroupBeforeSubscribeOk(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + request, requestStream := subscribe(t, session, conn) + + reader := conn.acceptUniStream(encodeDataStream(t, 17, 3, 5, testObject{0, "hello"})) + <-reader.drained + + requestStream.feed(encodeControlMessage(t, &wire.SubscribeOk{TrackAlias: 17})) + + o := readObject(t, request) + assert.Equal(t, []byte("hello"), o.Payload) + assert.Equal(t, uint64(3), o.GroupID) + assert.Equal(t, uint64(5), o.SubGroupID) + assert.Equal(t, uint64(0), o.ObjectID) + assert.Equal(t, ObjectForwardingPreferenceSubgroup, o.ForwardingPreference) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +func TestSubgroupAfterSubscribeOk(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + request, requestStream := subscribe(t, session, conn) + requestStream.feed(encodeControlMessage(t, &wire.SubscribeOk{TrackAlias: 17})) + require.Eventually(t, func() bool { + return hasTrackAlias(session, 17) + }, time.Second, time.Millisecond) + + conn.acceptUniStream(encodeDataStream(t, 17, 3, 5, testObject{0, "hello"})) + + assert.Equal(t, []byte("hello"), readObject(t, request).Payload) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// The object ID of the first object on a subgroup stream is its delta, and each +// following object is the previous object ID plus its delta plus one. +func TestSubgroupObjectIDDeltas(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + request, requestStream := subscribe(t, session, conn) + requestStream.feed(encodeControlMessage(t, &wire.SubscribeOk{TrackAlias: 17})) + require.Eventually(t, func() bool { + return hasTrackAlias(session, 17) + }, time.Second, time.Millisecond) + + conn.acceptUniStream(encodeDataStream(t, 17, 3, 5, + testObject{2, "first"}, + testObject{0, "second"}, + testObject{3, "third"}, + )) + + for _, want := range []uint64{2, 3, 7} { + assert.Equal(t, want, readObject(t, request).ObjectID) + } + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// Datagrams carry a track alias too and can arrive before SUBSCRIBE_OK. +func TestDatagramBeforeSubscribeOk(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + request, requestStream := subscribe(t, session, conn) + + conn.sendDatagram(encodeDatagram(17, 3, 4, "hello")) + require.Eventually(t, func() bool { + return trackCount(session) == 1 + }, time.Second, time.Millisecond) + + requestStream.feed(encodeControlMessage(t, &wire.SubscribeOk{TrackAlias: 17})) + + o := readObject(t, request) + assert.Equal(t, []byte("hello"), o.Payload) + assert.Equal(t, uint64(3), o.GroupID) + assert.Equal(t, uint64(4), o.ObjectID) + assert.Equal(t, ObjectForwardingPreferenceDatagram, o.ForwardingPreference) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +func TestDatagramAfterSubscribeOk(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + request, requestStream := subscribe(t, session, conn) + requestStream.feed(encodeControlMessage(t, &wire.SubscribeOk{TrackAlias: 17})) + require.Eventually(t, func() bool { + return hasTrackAlias(session, 17) + }, time.Second, time.Millisecond) + + conn.sendDatagram(encodeDatagram(17, 3, 4, "hello")) + + assert.Equal(t, []byte("hello"), readObject(t, request).Payload) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// Objects buffered for an unbound track alias are capped, and the ones that fit +// are delivered in order. +func TestPendingObjectLimit(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + for i := range maxPendingObjects + 5 { + session.pushObject(17, &Object{Payload: []byte(fmt.Sprintf("object-%d", i))}) + } + + receiver := &testReceiver{} + require.NoError(t, session.bindTrackAlias(17, receiver)) + + require.Len(t, receiver.objects, maxPendingObjects) + assert.Equal(t, []byte("object-0"), receiver.objects[0].Payload) + assert.Equal(t, []byte(fmt.Sprintf("object-%d", maxPendingObjects-1)), receiver.objects[maxPendingObjects-1].Payload) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// The number of unbound track aliases buffering objects is capped. +func TestPendingTrackLimit(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + for i := range uint64(maxPendingTracks) + 1 { + session.pushObject(i, &Object{Payload: []byte("payload")}) + } + assert.Equal(t, maxPendingTracks, trackCount(session)) + + receiver := &testReceiver{} + require.NoError(t, session.bindTrackAlias(maxPendingTracks, receiver)) + assert.Empty(t, receiver.objects) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// A track alias may only be bound once. +func TestBindDuplicateTrackAlias(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + require.NoError(t, session.bindTrackAlias(17, &testReceiver{})) + assert.ErrorIs(t, session.bindTrackAlias(17, &testReceiver{}), errDuplicateTrackAlias) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// Closing a request removes its track alias entry. +func TestCloseRequestRemovesTrackAlias(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + request, requestStream := subscribe(t, session, conn) + requestStream.feed(encodeControlMessage(t, &wire.SubscribeOk{TrackAlias: 17})) + require.Eventually(t, func() bool { + return hasTrackAlias(session, 17) + }, time.Second, time.Millisecond) + + require.NoError(t, request.Close()) + assert.Equal(t, 0, trackCount(session)) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} diff --git a/electron/native/private-transport/third_party/moqtransport/session_shutdown_test.go b/electron/native/private-transport/third_party/moqtransport/session_shutdown_test.go new file mode 100644 index 00000000..ce3e16ec --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/session_shutdown_test.go @@ -0,0 +1,365 @@ +package moqtransport + +import ( + "bytes" + "context" + "errors" + "sync" + "testing" + + "github.com/mengelbart/moqtransport/internal/wire" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "go.uber.org/mock/gomock" +) + +var ( + errTestConnectionClosed = errors.New("test connection closed") + errTestStreamStopped = errors.New("test stream stopped") + errTestOptionFailed = errors.New("test option failed") + errTestSetupWriteFailed = errors.New("test setup write failed") +) + +// blockingReader serves data and then blocks until more is fed or it is closed, +// like a stream that has delivered a message and is waiting for the next one. +type blockingReader struct { + mu sync.Mutex + data []byte + notify chan struct{} + + drainedOnce sync.Once + drained chan struct{} + + closeOnce sync.Once + closed chan struct{} + closeErr error +} + +func newBlockingReader(data []byte) *blockingReader { + return &blockingReader{ + data: append([]byte(nil), data...), + notify: make(chan struct{}, 1), + drained: make(chan struct{}), + closed: make(chan struct{}), + } +} + +func (r *blockingReader) Read(p []byte) (int, error) { + for { + r.mu.Lock() + if len(r.data) > 0 { + n := copy(p, r.data) + r.data = r.data[n:] + r.mu.Unlock() + return n, nil + } + r.mu.Unlock() + + r.drainedOnce.Do(func() { close(r.drained) }) + select { + case <-r.closed: + return 0, r.closeErr + case <-r.notify: + } + } +} + +// feed appends data to the reader and wakes a blocked Read. +func (r *blockingReader) feed(data []byte) { + r.mu.Lock() + r.data = append(r.data, data...) + r.mu.Unlock() + select { + case r.notify <- struct{}{}: + default: + } +} + +func (r *blockingReader) close(err error) { + r.closeOnce.Do(func() { + r.closeErr = err + close(r.closed) + }) +} + +// testConnection drives a MockConnection: it hands out the streams queued on it +// and blocks everywhere else until the connection is closed. +type testConnection struct { + *MockConnection + + ctrl *gomock.Controller + uniStreams chan ReceiveStream + bidiStreams chan Stream + datagrams chan []byte + + // openedStreams carries the readers of the bidirectional streams the + // session opened, so a test can feed responses to its own requests. + openedStreams chan *blockingReader + + // sendStreamWriteErr, when set, is returned by every write on a stream + // opened by the session, e.g. to fail the SETUP message. It must be set + // before the session is created and is read-only afterwards. + sendStreamWriteErr error + + mu sync.Mutex + readers []*blockingReader + lastID uint64 + openedUniCount int + closeCount int +} + +func newTestConnection(t *testing.T) *testConnection { + t.Helper() + ctrl := gomock.NewController(t) + c := &testConnection{ + MockConnection: NewMockConnection(ctrl), + ctrl: ctrl, + uniStreams: make(chan ReceiveStream, 1), + bidiStreams: make(chan Stream, 1), + datagrams: make(chan []byte, 1), + openedStreams: make(chan *blockingReader, 8), + } + c.EXPECT().ApplicationProtocol().Return(MOQT18).AnyTimes() + c.EXPECT().Perspective().Return(PerspectiveServer).AnyTimes() + c.EXPECT().Protocol().Return(ProtocolQUIC).AnyTimes() + c.EXPECT().OpenUniStream().DoAndReturn(func() (SendStream, error) { + return c.newSendStream(), nil + }).AnyTimes() + c.EXPECT().OpenStreamSync(gomock.Any()).DoAndReturn(func(context.Context) (Stream, error) { + stream, reader := c.newStream(nil) + c.openedStreams <- reader + return stream, nil + }).AnyTimes() + c.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(ctx context.Context) (ReceiveStream, error) { + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case stream := <-c.uniStreams: + return stream, nil + } + }).AnyTimes() + c.EXPECT().AcceptStream(gomock.Any()).DoAndReturn(func(ctx context.Context) (Stream, error) { + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case stream := <-c.bidiStreams: + return stream, nil + } + }).AnyTimes() + c.EXPECT().ReceiveDatagram(gomock.Any()).DoAndReturn(func(ctx context.Context) ([]byte, error) { + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case dgram := <-c.datagrams: + return dgram, nil + } + }).AnyTimes() + c.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).DoAndReturn(func(uint64, string) error { + c.mu.Lock() + c.closeCount++ + readers := c.readers + c.mu.Unlock() + for _, r := range readers { + r.close(errTestConnectionClosed) + } + return nil + }).AnyTimes() + return c +} + +func (c *testConnection) openedUniStreams() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.openedUniCount +} + +func (c *testConnection) closes() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.closeCount +} + +func (c *testConnection) newReader(data []byte) (*blockingReader, uint64) { + c.mu.Lock() + defer c.mu.Unlock() + r := newBlockingReader(data) + c.readers = append(c.readers, r) + c.lastID += 4 + return r, c.lastID +} + +func (c *testConnection) newSendStream() *MockSendStream { + c.mu.Lock() + c.lastID += 4 + id := c.lastID + c.openedUniCount++ + c.mu.Unlock() + + stream := NewMockSendStream(c.ctrl) + stream.EXPECT().Write(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { + if c.sendStreamWriteErr != nil { + return 0, c.sendStreamWriteErr + } + return len(p), nil + }).AnyTimes() + stream.EXPECT().Close().Return(nil).AnyTimes() + stream.EXPECT().Reset(gomock.Any()).AnyTimes() + stream.EXPECT().StreamID().Return(id).AnyTimes() + return stream +} + +func (c *testConnection) newReceiveStream(data []byte) (*MockReceiveStream, *blockingReader) { + r, id := c.newReader(data) + stream := NewMockReceiveStream(c.ctrl) + stream.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes() + stream.EXPECT().Stop(gomock.Any()).Do(func(uint32) { r.close(errTestStreamStopped) }).AnyTimes() + stream.EXPECT().StreamID().Return(id).AnyTimes() + return stream, r +} + +func (c *testConnection) newStream(data []byte) (*MockStream, *blockingReader) { + r, id := c.newReader(data) + stream := NewMockStream(c.ctrl) + stream.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes() + stream.EXPECT().Write(gomock.Any()).DoAndReturn(func(p []byte) (int, error) { return len(p), nil }).AnyTimes() + stream.EXPECT().Close().Return(nil).AnyTimes() + stream.EXPECT().Stop(gomock.Any()).Do(func(uint32) { r.close(errTestStreamStopped) }).AnyTimes() + stream.EXPECT().Reset(gomock.Any()).AnyTimes() + stream.EXPECT().StreamID().Return(id).AnyTimes() + return stream, r +} + +// acceptUniStream queues a unidirectional stream carrying data as if the peer +// had opened it. +func (c *testConnection) acceptUniStream(data []byte) *blockingReader { + stream, r := c.newReceiveStream(data) + c.uniStreams <- stream + return r +} + +// acceptStream queues a bidirectional stream carrying data as if the peer had +// opened it. +func (c *testConnection) acceptStream(data []byte) *blockingReader { + stream, r := c.newStream(data) + c.bidiStreams <- stream + return r +} + +// sendDatagram delivers a datagram as if the peer had sent it. +func (c *testConnection) sendDatagram(data []byte) { + c.datagrams <- data +} + +func encodeControlMessage(t *testing.T, msg wire.ControlMessage) []byte { + t.Helper() + var buf bytes.Buffer + require.NoError(t, wire.NewAppender(&buf, 18).Write(msg)) + return buf.Bytes() +} + +func setupWithPath(path string) *wire.Setup { + return &wire.Setup{ + Options: []wire.KeyValuePair{ + {Type: wire.PathParameterKey, Bytes: []byte(path)}, + }, + } +} + +// The remote control stream reader must not panic when the session is closed, +// and CloseWithError must not return before it is done. +func TestCloseSessionWithRemoteControlStreamReader(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + reader := conn.acceptUniStream(encodeControlMessage(t, setupWithPath("/path"))) + <-reader.drained + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// The outgoing subscribe request reader must not panic when the session is +// closed, and CloseWithError must not return before it is done. +func TestCloseSessionWithOutgoingSubscribeRequestReader(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + request, err := session.Subscribe(context.Background(), [][]byte{[]byte("namespace")}, "track") + require.NoError(t, err) + require.NotNil(t, request) + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// The incoming subscribe request reader must not panic when the session is +// closed, and CloseWithError must not return before it is done. +func TestCloseSessionWithIncomingSubscribeRequestReader(t *testing.T) { + conn := newTestConnection(t) + handler := NewMockHandler(conn.ctrl) + requests := make(chan *IncomingSubscribeRequest, 1) + handler.EXPECT().HandleSubscribe(gomock.Any()).Do(func(r *IncomingSubscribeRequest) { + requests <- r + }) + + session, err := NewSession(conn, "", WithHandler(handler)) + require.NoError(t, err) + + reader := conn.acceptStream(encodeControlMessage(t, &wire.Subscribe{ + TrackNamespace: [][]byte{[]byte("namespace")}, + TrackName: []byte("track"), + })) + assert.Equal(t, []byte("track"), (<-requests).Name()) + <-reader.drained + + session.CloseWithError(0, "closing") + goleak.VerifyNone(t) +} + +// Subscribing on a closed session must not start an untracked reader. +func TestSubscribeAfterCloseFails(t *testing.T) { + conn := newTestConnection(t) + session, err := NewSession(conn, "") + require.NoError(t, err) + + session.CloseWithError(0, "closing") + + _, err = session.Subscribe(context.Background(), [][]byte{[]byte("namespace")}, "track") + assert.Error(t, err) + goleak.VerifyNone(t) +} + +// A failing option must be reported before any stream is opened, so there is +// nothing to clean up and the caller's connection is left alone. +func TestNewSessionOptionErrorOpensNoStream(t *testing.T) { + conn := newTestConnection(t) + + session, err := NewSession(conn, "", func(*Session) error { return errTestOptionFailed }) + assert.ErrorIs(t, err, errTestOptionFailed) + assert.Nil(t, session) + assert.Equal(t, 0, conn.openedUniStreams()) + assert.Equal(t, 0, conn.closes()) + goleak.VerifyNone(t) +} + +// The SETUP write happens after NewSession returned, so a failing write must +// reach the caller through the session context and close the session. +func TestNewSessionSetupWriteErrorClosesSession(t *testing.T) { + conn := newTestConnection(t) + conn.sendStreamWriteErr = errTestSetupWriteFailed + + session, err := NewSession(conn, "") + require.NoError(t, err) + require.NotNil(t, session) + + <-session.Context().Done() + assert.ErrorIs(t, context.Cause(session.Context()), errTestSetupWriteFailed) + + session.CloseWithError(0, "closing") + assert.Equal(t, 1, conn.openedUniStreams()) + assert.Equal(t, 1, conn.closes()) + goleak.VerifyNone(t) +} diff --git a/electron/native/private-transport/third_party/moqtransport/session_test.go b/electron/native/private-transport/third_party/moqtransport/session_test.go new file mode 100644 index 00000000..4e558032 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/session_test.go @@ -0,0 +1,55 @@ +package moqtransport + +import ( + "bufio" + "bytes" + "testing" + + "github.com/mengelbart/moqtransport/varint" +) + +func TestPeekFirstVarint(t *testing.T) { + testCases := []struct { + name string + value uint64 + expect int + }{ + {name: "one byte", value: 42, expect: 1}, + {name: "two bytes", value: 0x2f00, expect: 2}, + {name: "nine bytes", value: 1 << 56, expect: 9}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + encoded := varint.Append(nil, testCase.value) + reader := bufio.NewReader(bytes.NewReader(encoded)) + + peeked, err := peekFirstVarint(reader) + if err != nil { + t.Fatalf("peekFirstVarint() error = %v", err) + } + if len(peeked) != testCase.expect { + t.Fatalf("peekFirstVarint() length = %d, want %d", len(peeked), testCase.expect) + } + parsed, n, err := varint.Parse(peeked) + if err != nil { + t.Fatalf("varint.Parse() error = %v", err) + } + if parsed != testCase.value { + t.Fatalf("varint.Parse() value = %d, want %d", parsed, testCase.value) + } + if n != testCase.expect { + t.Fatalf("varint.Parse() len = %d, want %d", n, testCase.expect) + } + }) + } +} + +func TestPeekFirstVarintTooShort(t *testing.T) { + reader := bufio.NewReader(bytes.NewReader([]byte{0x80})) + + _, err := peekFirstVarint(reader) + if err == nil { + t.Fatal("peekFirstVarint() error = nil, want error") + } +} diff --git a/electron/native/private-transport/third_party/moqtransport/subgroup.go b/electron/native/private-transport/third_party/moqtransport/subgroup.go new file mode 100644 index 00000000..43dd6108 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/subgroup.go @@ -0,0 +1,52 @@ +package moqtransport + +import ( + "fmt" + + "github.com/mengelbart/moqtransport/internal/wire" +) + +type Subgroup struct { + stream messageWriter + + firstObject bool + lastObjectID uint64 +} + +func newSubgroup(stream messageWriter, trackAlias, groupID, subgroupID uint64, publisherPriority uint8) (*Subgroup, error) { + if err := stream.Write(wire.NewSubgroupHeader(trackAlias, groupID, subgroupID, publisherPriority)); err != nil { + return nil, err + } + return &Subgroup{ + stream: stream, + firstObject: true, + }, nil +} + +// WriteObject writes an object to the subgroup. Object IDs must increase +// strictly monotonically along the subgroup. +func (s *Subgroup) WriteObject(objectID uint64, payload []byte) (int, error) { + delta := objectID + if !s.firstObject { + if objectID <= s.lastObjectID { + return 0, fmt.Errorf("object ID %v not greater than previous object ID %v", objectID, s.lastObjectID) + } + delta = objectID - s.lastObjectID - 1 + } + o := &wire.SubgroupObject{ + ObjectIDDelta: delta, + ObjectPayload: payload, + } + if err := s.stream.Write(o); err != nil { + return 0, err + } + s.firstObject = false + s.lastObjectID = objectID + return len(payload), nil +} + +// Close closes the subgroup. +func (s *Subgroup) Close() error { + // TODO + return nil +} diff --git a/electron/native/private-transport/third_party/moqtransport/subgroup_test.go b/electron/native/private-transport/third_party/moqtransport/subgroup_test.go new file mode 100644 index 00000000..3fba17b3 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/subgroup_test.go @@ -0,0 +1,57 @@ +package moqtransport + +import ( + "bytes" + "testing" + + "github.com/mengelbart/moqtransport/internal/wire" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSubgroupWriteObject(t *testing.T) { + var buf bytes.Buffer + subgroup, err := newSubgroup(wire.NewAppender(&buf, 18), 4, 7, 9, 200) + require.NoError(t, err) + + for _, objectID := range []uint64{0, 1, 5} { + n, err := subgroup.WriteObject(objectID, []byte("payload")) + require.NoError(t, err) + assert.Equal(t, len("payload"), n) + } + + parser, err := wire.NewParser(&buf, 18, wire.StreamTypeData) + require.NoError(t, err) + + msg, err := parser.Read() + require.NoError(t, err) + header, ok := msg.(*wire.SubgroupHeader) + require.True(t, ok) + assert.Equal(t, uint64(4), header.TrackAlias) + assert.Equal(t, uint64(7), header.GroupID) + assert.Equal(t, uint64(9), header.SubgroupID) + assert.Equal(t, uint8(200), header.PublisherPriority) + + for _, want := range []uint64{0, 0, 3} { + msg, err := parser.Read() + require.NoError(t, err) + o, ok := msg.(*wire.SubgroupObject) + require.True(t, ok) + assert.Equal(t, want, o.ObjectIDDelta) + } +} + +func TestSubgroupWriteObjectOutOfOrder(t *testing.T) { + var buf bytes.Buffer + subgroup, err := newSubgroup(wire.NewAppender(&buf, 18), 4, 7, 9, 0) + require.NoError(t, err) + + _, err = subgroup.WriteObject(3, []byte("payload")) + require.NoError(t, err) + + _, err = subgroup.WriteObject(3, []byte("payload")) + assert.Error(t, err) + + _, err = subgroup.WriteObject(2, []byte("payload")) + assert.Error(t, err) +} diff --git a/electron/native/private-transport/third_party/moqtransport/tracks.go b/electron/native/private-transport/third_party/moqtransport/tracks.go new file mode 100644 index 00000000..3bc35305 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/tracks.go @@ -0,0 +1,94 @@ +package moqtransport + +import ( + "errors" +) + +// maxPendingObjects is the number of objects buffered per track alias that is +// not bound to a receiver yet. +// TODO: Make configurable. +const maxPendingObjects = 100 + +// maxPendingTracks is the number of unbound track aliases that may buffer +// objects at the same time. +// TODO: Make configurable. +const maxPendingTracks = 16 + +var errDuplicateTrackAlias = errors.New("track alias already in use") + +type objectReceiver interface { + push(*Object) +} + +// trackEntry routes the objects of one track alias. Objects that arrive before +// the alias is bound to a receiver are buffered in pending. +type trackEntry struct { + receiver objectReceiver + pending []*Object +} + +// pushObject delivers o to the receiver of trackAlias. The track alias is +// assigned by the peer in SUBSCRIBE_OK, so objects can arrive before it is +// known. They are buffered until the alias is bound to a receiver. +func (s *Session) pushObject(trackAlias uint64, o *Object) { + s.tracksLock.Lock() + defer s.tracksLock.Unlock() + + entry, ok := s.tracks[trackAlias] + if ok && entry.receiver != nil { + entry.receiver.push(o) + return + } + // TODO: Decide whether exceeding a limit should close the session with a + // protocol violation instead of dropping the object. + if !ok && s.pendingTracks >= maxPendingTracks { + s.logger.Info("too many unbound track aliases: dropping incoming object", "trackAlias", trackAlias) + return + } + if ok && len(entry.pending) >= maxPendingObjects { + s.logger.Info("pending object buffer overflow: dropping incoming object", "trackAlias", trackAlias) + return + } + if !ok { + entry = &trackEntry{} + s.tracks[trackAlias] = entry + s.pendingTracks++ + } + entry.pending = append(entry.pending, o) +} + +// bindTrackAlias attaches r to trackAlias and hands it the objects that +// arrived before the alias was known. +func (s *Session) bindTrackAlias(trackAlias uint64, r objectReceiver) error { + s.tracksLock.Lock() + defer s.tracksLock.Unlock() + + entry, ok := s.tracks[trackAlias] + if !ok { + s.tracks[trackAlias] = &trackEntry{receiver: r} + return nil + } + if entry.receiver != nil { + return errDuplicateTrackAlias + } + entry.receiver = r + s.pendingTracks-- + for _, o := range entry.pending { + r.push(o) + } + entry.pending = nil + return nil +} + +// removeReceiver drops the track alias entry of r, if it has one. +func (s *Session) removeReceiver(r objectReceiver) { + s.tracksLock.Lock() + defer s.tracksLock.Unlock() + + for trackAlias, entry := range s.tracks { + if entry.receiver == r { + delete(s.tracks, trackAlias) + return + } + } +} diff --git a/electron/native/private-transport/third_party/moqtransport/varint/varint.go b/electron/native/private-transport/third_party/moqtransport/varint/varint.go new file mode 100644 index 00000000..1542f068 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/varint/varint.go @@ -0,0 +1,104 @@ +// Package varint implements variable length integers as defined in +// https://datatracker.ietf.org/doc/html/draft-ietf-moq-transport-19#name-variable-length-integers +package varint + +import ( + "io" +) + +const ( + maxVarint1 = 127 + maxVarint2 = 16383 + maxVarint3 = 2097151 + maxVarint4 = 268435455 + maxVarint5 = 34359738367 + maxVarint6 = 4398046511103 + maxVarint7 = 562949953421311 + maxVarint8 = 72057594037927935 + maxVarint9 = 18446744073709551615 +) + +func Read(r io.ByteReader) (uint64, error) { + b0, err := r.ReadByte() + if err != nil { + return 0, err + } + + leadingOnes := 0 + for i := 7; i >= 0; i-- { + if (b0 & (1 << uint(i))) == 0 { + break + } + leadingOnes++ + } + + if leadingOnes == 0 { + return uint64(b0), nil + } + + result := uint64(b0 & ((1 << uint(7-leadingOnes)) - 1)) + for i := 0; i < leadingOnes; i++ { + b, err := r.ReadByte() + if err != nil { + return 0, err + } + result = (result << 8) | uint64(b) + } + return result, nil +} + +func Parse(b []byte) (uint64, int, error) { + if len(b) == 0 { + return 0, 0, io.EOF + } + // Count leading ones in the first byte + leadingOnes := 0 + for i := 7; i >= 0; i-- { + if (b[0] & (1 << uint(i))) == 0 { + break + } + leadingOnes++ + } + + if len(b) < 1+leadingOnes { + return 0, 0, io.EOF + } + + if leadingOnes == 0 { + return uint64(b[0]), 1, nil + } + + result := uint64(b[0] & ((1 << uint(7-leadingOnes)) - 1)) + for i := 1; i <= leadingOnes; i++ { + result = (result << 8) | uint64(b[i]) + } + return result, 1 + leadingOnes, nil +} + +func Append(b []byte, value uint64) []byte { + if value <= maxVarint1 { + return append(b, byte(value)) + } + if value <= maxVarint2 { + return append(b, byte(0x80|(value>>8)), byte(value)) + } + if value <= maxVarint3 { + return append(b, byte(0xC0|(value>>16)), byte(value>>8), byte(value)) + } + if value <= maxVarint4 { + return append(b, byte(0xE0|(value>>24)), byte(value>>16), byte(value>>8), byte(value)) + } + if value <= maxVarint5 { + return append(b, byte(0xF0|(value>>32)), byte(value>>24), byte(value>>16), byte(value>>8), byte(value)) + } + if value <= maxVarint6 { + return append(b, byte(0xF8|(value>>40)), byte(value>>32), byte(value>>24), byte(value>>16), byte(value>>8), byte(value)) + } + if value <= maxVarint7 { + return append(b, byte(0xFC|(value>>48)), byte(value>>40), byte(value>>32), byte(value>>24), byte(value>>16), byte(value>>8), byte(value)) + } + if value <= maxVarint8 { + return append(b, byte(0xFE|(value>>56)), byte(value>>48), byte(value>>40), byte(value>>32), byte(value>>24), byte(value>>16), byte(value>>8), byte(value)) + } + return append(b, byte(0xFF), byte(value>>56), byte(value>>48), byte(value>>40), byte(value>>32), byte(value>>24), byte(value>>16), byte(value>>8), byte(value)) +} diff --git a/electron/native/private-transport/third_party/moqtransport/varint/varint_test.go b/electron/native/private-transport/third_party/moqtransport/varint/varint_test.go new file mode 100644 index 00000000..f2e9a362 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/varint/varint_test.go @@ -0,0 +1,125 @@ +package varint + +import ( + "bytes" + "fmt" + "io" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseVarint(t *testing.T) { + cases := []struct { + bytes []byte + value uint64 + count int + err error + }{ + {[]byte{}, 0, 0, io.EOF}, + {[]byte{0x00}, 0, 1, nil}, + {[]byte{0x01}, 1, 1, nil}, + {[]byte{0x7F}, 127, 1, nil}, + {[]byte{0x25}, 37, 1, nil}, + {[]byte{0x80, 0x25}, 37, 2, nil}, + {[]byte{0x80, 0x00}, 0, 2, nil}, + {[]byte{0xED, 0x7F, 0x3E, 0x7D}, 226_442_877, 4, nil}, + {[]byte{0xFA, 0xA1, 0xA0, 0xE4, 0x03, 0xD8}, 2_893_212_287_960, 6, nil}, + {[]byte{0xFC, 0x89, 0x98, 0xAB, 0xC6, 0x6B, 0xC0}, 151_288_809_941_952, 7, nil}, + {[]byte{0xFE, 0xFA, 0x31, 0x8F, 0xA8, 0xE3, 0xCA, 0x11}, 70_423_237_261_249_041, 8, nil}, + {[]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, 18_446_744_073_709_551_615, 9, nil}, + {[]byte{0x80}, 0, 0, io.EOF}, + {[]byte{0xC0, 0x01}, 0, 0, io.EOF}, + {[]byte{0xFF, 0xFF, 0xFF}, 0, 0, io.EOF}, + {[]byte{0xFE, 0xFA, 0x31, 0x8F, 0xA8, 0xE3, 0xCA}, 0, 0, io.EOF}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%v", c.bytes), func(t *testing.T) { + value, bytes, err := Parse(c.bytes) + assert.Equal(t, c.value, value, "Parse(%v) = %d, want %d", c.bytes, value, c.value) + assert.Equal(t, c.count, bytes, "Parse(%v) = %d bytes, want %d", c.bytes, bytes, c.count) + assert.Equal(t, c.err, err, "Parse(%v) = %v, want %v", c.bytes, err, c.err) + }) + } +} + +func TestReadVarint(t *testing.T) { + cases := []struct { + bytes []byte + value uint64 + err error + }{ + {[]byte{}, 0, io.EOF}, + {[]byte{0x00}, 0, nil}, + {[]byte{0x01}, 1, nil}, + {[]byte{0x7F}, 127, nil}, + {[]byte{0x25}, 37, nil}, + {[]byte{0x80, 0x25}, 37, nil}, + {[]byte{0x80, 0x00}, 0, nil}, + {[]byte{0xED, 0x7F, 0x3E, 0x7D}, 226_442_877, nil}, + {[]byte{0xFA, 0xA1, 0xA0, 0xE4, 0x03, 0xD8}, 2_893_212_287_960, nil}, + {[]byte{0xFC, 0x89, 0x98, 0xAB, 0xC6, 0x6B, 0xC0}, 151_288_809_941_952, nil}, + {[]byte{0xFE, 0xFA, 0x31, 0x8F, 0xA8, 0xE3, 0xCA, 0x11}, 70_423_237_261_249_041, nil}, + {[]byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, 18_446_744_073_709_551_615, nil}, + {[]byte{0x80}, 0, io.EOF}, + {[]byte{0xFF, 0xFF, 0xFF}, 0, io.EOF}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%v", c.bytes), func(t *testing.T) { + value, err := Read(bytes.NewReader(c.bytes)) + assert.Equal(t, c.value, value, "Read(%v) = %d, want %d", c.bytes, value, c.value) + assert.Equal(t, c.err, err, "Read(%v) = %v, want %v", c.bytes, err, c.err) + }) + } +} + +// FuzzParse checks that Parse never panics on arbitrary input and stays +// consistent with Read, which sees the same bytes through an io.ByteReader. +func FuzzParse(f *testing.F) { + for _, seed := range [][]byte{ + {}, + {0x25}, + {0x80, 0x25}, + {0xC0}, + {0xFF, 0xFF, 0xFF}, + {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, + } { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, b []byte) { + value, n, err := Parse(b) + readValue, readErr := Read(bytes.NewReader(b)) + if err != nil { + assert.Equal(t, 0, n, "Parse(%v) consumed %d bytes despite failing", b, n) + assert.Error(t, readErr, "Parse(%v) failed but Read succeeded", b) + return + } + assert.GreaterOrEqual(t, n, 1, "Parse(%v) succeeded without consuming a byte", b) + assert.LessOrEqual(t, n, len(b), "Parse(%v) consumed more bytes than it was given", b) + assert.NoError(t, readErr, "Parse(%v) succeeded but Read failed", b) + assert.Equal(t, value, readValue, "Parse(%v) = %d, but Read = %d", b, value, readValue) + }) +} + +func TestAppendVarint(t *testing.T) { + cases := []struct { + value uint64 + bytes []byte + }{ + {0, []byte{0x00}}, + {1, []byte{0x01}}, + {37, []byte{0x25}}, + {127, []byte{0x7F}}, + {226_442_877, []byte{0xED, 0x7F, 0x3E, 0x7D}}, + {2_893_212_287_960, []byte{0xFA, 0xA1, 0xA0, 0xE4, 0x03, 0xD8}}, + {151_288_809_941_952, []byte{0xFC, 0x89, 0x98, 0xAB, 0xC6, 0x6B, 0xC0}}, + {70_423_237_261_249_041, []byte{0xFE, 0xFA, 0x31, 0x8F, 0xA8, 0xE3, 0xCA, 0x11}}, + {18_446_744_073_709_551_615, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}}, + } + for _, c := range cases { + t.Run(fmt.Sprintf("%d", c.value), func(t *testing.T) { + bytes := Append([]byte{}, c.value) + assert.Equal(t, c.bytes, bytes, "Append(%d) = %v, want %v", c.value, bytes, c.bytes) + }) + } +} diff --git a/electron/native/private-transport/third_party/moqtransport/version.go b/electron/native/private-transport/third_party/moqtransport/version.go new file mode 100644 index 00000000..cec7fb4a --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/version.go @@ -0,0 +1,20 @@ +package moqtransport + +const ( + MOQT18 ApplicationProtocol = "moqt-18" +) + +type ApplicationProtocol string + +func (ap ApplicationProtocol) String() string { + return string(ap) +} + +func (ap ApplicationProtocol) versionNumber() uint64 { + switch ap { + case MOQT18: + return 18 + default: + return 0 + } +} diff --git a/electron/scripts/build-private-transport.mjs b/electron/scripts/build-private-transport.mjs index d562f3a5..d8c8efd3 100644 --- a/electron/scripts/build-private-transport.mjs +++ b/electron/scripts/build-private-transport.mjs @@ -31,25 +31,67 @@ const output = path.join( ); fs.mkdirSync(outputDir, { recursive: true }); -const result = spawnSync( - go, - [ +const buildArguments = [ + 'build', + '-trimpath', + '-ldflags=-s -w', + '-o', + output, + './cmd/qortal-private-transport', +]; +let result = spawnSync(go, buildArguments, { + cwd: moduleRoot, + stdio: 'inherit', + windowsHide: true, + env: { ...process.env, GOOS: goos, GOARCH: goarch, CGO_ENABLED: '0' }, +}); +if (result.error?.code === 'ENOENT' && go === 'go') { + console.log( + 'Go is not installed; building the private transport with Docker.' + ); + const dockerArguments = ['run', '--rm']; + if (process.platform !== 'win32' && process.getuid && process.getgid) { + dockerArguments.push('--user', `${process.getuid()}:${process.getgid()}`); + } + dockerArguments.push( + '-e', + `GOOS=${goos}`, + '-e', + `GOARCH=${goarch}`, + '-e', + 'CGO_ENABLED=0', + '-e', + 'GOCACHE=/tmp/go-cache', + '-e', + 'GOMODCACHE=/tmp/go-mod', + '-v', + `${moduleRoot}:/src`, + '-v', + `${outputDir}:/out`, + '-w', + '/src', + 'golang:1.26-bookworm', + 'go', 'build', '-trimpath', '-ldflags=-s -w', '-o', - output, - './cmd/qortal-private-transport', - ], - { - cwd: moduleRoot, + `/out/${path.basename(output)}`, + './cmd/qortal-private-transport' + ); + result = spawnSync('docker', dockerArguments, { stdio: 'inherit', windowsHide: true, - env: { ...process.env, GOOS: goos, GOARCH: goarch, CGO_ENABLED: '0' }, + }); + if (result.error?.code === 'ENOENT') { + console.error( + 'Unable to build private transport: install Go 1.26+ or Docker.' + ); + process.exit(1); } -); +} if (result.error) { - console.error(`Unable to run Go compiler (${go}):`, result.error.message); + console.error('Unable to build private transport:', result.error.message); process.exit(1); } if (result.status !== 0) process.exit(result.status ?? 1); diff --git a/electron/src/moq-masque-transport.test.ts b/electron/src/moq-masque-transport.test.ts new file mode 100644 index 00000000..33faef0c --- /dev/null +++ b/electron/src/moq-masque-transport.test.ts @@ -0,0 +1,171 @@ +import { EventEmitter } from 'events'; +import { describe, expect, it, vi } from 'vitest'; +import { + MoqMasqueTransport, + type MoqTransportEvent, +} from './moq-masque-transport'; +import type { + PrivateBootstrapDescriptor, + PrivateChannelBootstrapProvider, +} from './private-channel-bootstrap'; +import { + PrivateTransportSidecar, + PrivateTransportSidecarError, +} from './private-transport-sidecar'; + +const descriptor: PrivateBootstrapDescriptor = { + version: 1, + transport: 'quic-masque-inner-v1', + logicalSessionId: 'logical-session', + backendRnsDestination: 'ab'.repeat(16), + backendTransportEndpoint: '127.0.0.1:4446', + backendTransportServerName: 'application-private-backend', + backendTransportCertSha256: 'cd'.repeat(32), + attachToken: 'one-time-transport-token', + expiresAt: Date.now() + 30_000, + nonce: 'n'.repeat(32), + ownerBindingHash: 'ef'.repeat(32), + applicationProtocol: 'moqt-18', + supportedFeatures: { reliable: true, datagrams: true, moqt: true }, +}; + +class FakeSidecar extends EventEmitter { + openMoqSession = vi.fn(async (config) => ({ + moqSessionId: 'moq-session', + logicalSessionId: config.logicalSessionId, + publicationNamespace: config.publicationNamespace, + publicationTrack: config.publicationTrack, + applicationProtocol: 'moqt-18' as const, + })); + subscribeMoqTrack = vi.fn(async () => undefined); + publishMoqObject = vi.fn(async () => undefined); + moqSessionMetrics = vi.fn(async () => ({ objectsSent: 1 })); + closeMoqSession = vi.fn(async () => undefined); +} + +function setup(overrides: Partial = {}) { + const sidecar = new FakeSidecar(); + const provider: PrivateChannelBootstrapProvider = { + getBootstrap: vi.fn(async () => ({ ...descriptor, ...overrides })), + }; + const events: MoqTransportEvent[] = []; + const transport = new MoqMasqueTransport( + (event) => events.push(event), + sidecar as unknown as PrivateTransportSidecar, + { + relayAddress: '192.0.2.1:47322', + relayServerName: 'qortal-masque-relay', + relayCertSha256: '12'.repeat(32), + }, + provider + ); + return { sidecar, provider, events, transport }; +} + +const context = { + owner: { tabId: 'tab-1', name: 'sample-app', service: 'APP' }, + rnsConnectionId: 'rns-connection', + publicationNamespace: ['qortal', 'apps', 'sample', 'publisher-123'], + publicationTrack: 'realtime-data', +}; + +describe('generic trusted MOQT transport', () => { + it('uses an authenticated realtime bootstrap and transports opaque objects', async () => { + const { sidecar, provider, transport } = setup(); + await transport.open(context); + expect(provider.getBootstrap).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'trusted-moq-transport', + rnsConnectionId: context.rnsConnectionId, + purpose: 'realtime', + owner: context.owner, + }) + ); + expect(sidecar.openMoqSession).toHaveBeenCalledWith({ + relayAddress: '192.0.2.1:47322', + relayServerName: 'qortal-masque-relay', + relayCertSha256: '12'.repeat(32), + backendAddress: '127.0.0.1:4446', + backendServerName: 'application-private-backend', + backendCertSha256: 'cd'.repeat(32), + logicalSessionId: 'logical-session', + attachToken: 'one-time-transport-token', + publicationNamespace: context.publicationNamespace, + publicationTrack: 'realtime-data', + }); + await transport.subscribe( + 'subscription-1', + ['qortal', 'apps', 'sample', 'peer-456'], + 'events' + ); + const opaquePayload = new Uint8Array([1, 2, 3]); + await transport.publish(opaquePayload); + expect(sidecar.subscribeMoqTrack).toHaveBeenCalledWith( + 'moq-session', + 'subscription-1', + ['qortal', 'apps', 'sample', 'peer-456'], + 'events' + ); + expect(sidecar.publishMoqObject).toHaveBeenCalledWith( + 'moq-session', + opaquePayload + ); + }); + + it('rejects a descriptor that does not explicitly advertise MOQT', async () => { + const { sidecar, transport } = setup({ applicationProtocol: undefined }); + await expect(transport.open(context)).rejects.toMatchObject({ + code: 'UNSUPPORTED_MOQ_TRANSPORT', + }); + expect(sidecar.openMoqSession).not.toHaveBeenCalled(); + }); + + it('delivers only bounded generic objects belonging to its native session', async () => { + const { sidecar, transport, events } = setup(); + await transport.open(context); + sidecar.emit('event', { + event: 'object', + sessionId: 'another-session', + subscriptionId: 'subscription-1', + namespace: ['qortal', 'apps'], + trackName: 'events', + groupId: 0, + objectId: 1, + data: Buffer.from([9]), + }); + sidecar.emit('event', { + event: 'object', + sessionId: 'moq-session', + subscriptionId: 'subscription-1', + namespace: ['qortal', 'apps'], + trackName: 'events', + groupId: 0, + objectId: 1, + data: Buffer.from([7, 8]), + }); + expect(events).toEqual([ + { + kind: 'object', + subscriptionId: 'subscription-1', + namespace: ['qortal', 'apps'], + trackName: 'events', + groupId: 0, + objectId: 1, + payload: new Uint8Array([7, 8]), + }, + ]); + await transport.close(); + expect(sidecar.closeMoqSession).toHaveBeenCalledWith('moq-session'); + }); + + it('does not retry an attach failure as a direct connection', async () => { + const { sidecar, transport } = setup(); + sidecar.openMoqSession.mockRejectedValueOnce( + new PrivateTransportSidecarError('MOQ_ATTACH_FAILED') + ); + await expect(transport.open(context)).rejects.toMatchObject({ + code: 'MOQ_ATTACH_FAILED', + }); + expect(sidecar.openMoqSession).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/src/moq-masque-transport.ts b/electron/src/moq-masque-transport.ts new file mode 100644 index 00000000..aba66a05 --- /dev/null +++ b/electron/src/moq-masque-transport.ts @@ -0,0 +1,217 @@ +import type { PrivateChannelBootstrapProvider } from './private-channel-bootstrap'; +import type { PrivateTransportContext } from './private-channel-manager'; +import { + MAX_MOQ_OBJECT_BYTES, + PrivateTransportSidecar, + PrivateTransportSidecarError, + type PrivateTransportSidecarEvent, +} from './private-transport-sidecar'; +import type { + TrustedRelayConfig, + TrustedRelayProvider, +} from './quic-masque-transport'; +import type { QAppReticulumOwner } from './qapp-reticulum-manager'; + +const MOQ_NAME = /^[A-Za-z0-9._-]{1,128}$/; + +function validNamespace(namespace: readonly string[]): boolean { + return ( + namespace.length >= 1 && + namespace.length <= 8 && + namespace.every((component) => MOQ_NAME.test(component)) && + namespace.reduce((total, component) => total + component.length, 0) <= 512 + ); +} + +export type MoqOpenContext = Readonly<{ + owner: QAppReticulumOwner; + rnsConnectionId: string; + publicationNamespace: readonly string[]; + publicationTrack: string; +}>; + +export type MoqTransportEvent = + | Readonly<{ + kind: 'object'; + subscriptionId: string; + namespace: readonly string[]; + trackName: string; + groupId: number; + objectId: number; + payload: Uint8Array; + }> + | Readonly<{ kind: 'error'; code: string; subscriptionId?: string }>; + +/** + * Main-process-only generic MOQT transport. Payload meaning and protection are + * deliberately outside this layer; endpoints and sockets remain private. + */ +export class MoqMasqueTransport { + private moqSessionId: string | null = null; + private closed = false; + private readonly onSidecarEvent = (event: PrivateTransportSidecarEvent) => + this.handleSidecarEvent(event); + private readonly onSidecarDeath = () => { + if (!this.closed && this.moqSessionId) { + this.emit({ kind: 'error', code: 'MOQ_TRANSPORT_CLOSED' }); + } + }; + + constructor( + private readonly emit: (event: MoqTransportEvent) => void, + private readonly sidecar: PrivateTransportSidecar, + private readonly relay: TrustedRelayConfig | TrustedRelayProvider, + private readonly bootstrapProvider: PrivateChannelBootstrapProvider + ) { + sidecar.on('event', this.onSidecarEvent); + sidecar.on('death', this.onSidecarDeath); + } + + async open(context: MoqOpenContext): Promise { + if (this.closed || this.moqSessionId) { + throw new PrivateTransportSidecarError('MOQ_SESSION_CLOSED'); + } + if ( + !validNamespace(context.publicationNamespace) || + !MOQ_NAME.test(context.publicationTrack) + ) { + throw new PrivateTransportSidecarError('INVALID_MOQ_CONFIG'); + } + const relay = await this.resolveRelay(); + const bootstrapContext: PrivateTransportContext = { + channelId: 'trusted-moq-transport', + rnsConnectionId: context.rnsConnectionId, + purpose: 'realtime', + generation: 1, + owner: context.owner, + }; + const bootstrap = + await this.bootstrapProvider.getBootstrap(bootstrapContext); + if ( + bootstrap.applicationProtocol !== 'moqt-18' || + bootstrap.supportedFeatures.moqt !== true + ) { + throw new PrivateTransportSidecarError('UNSUPPORTED_MOQ_TRANSPORT'); + } + const openAt = (relayAddress: string) => + this.sidecar.openMoqSession({ + relayAddress, + relayServerName: relay.relayServerName, + relayCertSha256: relay.relayCertSha256, + backendAddress: bootstrap.backendTransportEndpoint, + backendServerName: bootstrap.backendTransportServerName, + backendCertSha256: bootstrap.backendTransportCertSha256, + logicalSessionId: bootstrap.logicalSessionId, + attachToken: bootstrap.attachToken, + publicationNamespace: context.publicationNamespace, + publicationTrack: context.publicationTrack, + }); + let opened; + try { + opened = await openAt(relay.relayAddress); + } catch (error) { + if ( + !(error instanceof PrivateTransportSidecarError) || + error.code !== 'MASQUE_TUNNEL_FAILED' || + !relay.localFallbackAddress || + relay.localFallbackAddress === relay.relayAddress + ) { + throw error; + } + opened = await openAt(relay.localFallbackAddress); + } + this.moqSessionId = opened.moqSessionId; + } + + async subscribe( + subscriptionId: string, + namespace: readonly string[], + trackName: string + ): Promise { + if ( + !MOQ_NAME.test(subscriptionId) || + !validNamespace(namespace) || + !MOQ_NAME.test(trackName) + ) { + throw new PrivateTransportSidecarError('INVALID_MOQ_SUBSCRIPTION'); + } + await this.sidecar.subscribeMoqTrack( + this.requireSession(), + subscriptionId, + namespace, + trackName + ); + } + + async publish(payload: Uint8Array): Promise { + if (payload.byteLength < 1 || payload.byteLength > MAX_MOQ_OBJECT_BYTES) { + throw new PrivateTransportSidecarError('MOQ_OBJECT_TOO_LARGE'); + } + await this.sidecar.publishMoqObject(this.requireSession(), payload); + } + + async metrics(): Promise> { + return this.sidecar.moqSessionMetrics(this.requireSession()); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + const moqSessionId = this.moqSessionId; + this.moqSessionId = null; + this.sidecar.off('event', this.onSidecarEvent); + this.sidecar.off('death', this.onSidecarDeath); + if (moqSessionId) { + await this.sidecar.closeMoqSession(moqSessionId).catch(() => undefined); + } + } + + private async resolveRelay(): Promise { + return typeof this.relay === 'function' ? this.relay() : this.relay; + } + + private requireSession(): string { + if (this.closed || !this.moqSessionId) { + throw new PrivateTransportSidecarError('MOQ_SESSION_CLOSED'); + } + return this.moqSessionId; + } + + private handleSidecarEvent(event: PrivateTransportSidecarEvent): void { + if (event.sessionId !== this.moqSessionId || this.closed) return; + if (event.event === 'error') { + this.emit({ + kind: 'error', + code: event.code ?? 'MOQ_TRANSPORT_ERROR', + subscriptionId: event.subscriptionId, + }); + return; + } + if ( + event.event !== 'object' || + !event.subscriptionId || + !event.namespace || + !validNamespace(event.namespace) || + !event.trackName || + !MOQ_NAME.test(event.trackName) || + typeof event.groupId !== 'number' || + typeof event.objectId !== 'number' || + !Number.isSafeInteger(event.groupId) || + !Number.isSafeInteger(event.objectId) || + event.data.length < 1 || + event.data.length > MAX_MOQ_OBJECT_BYTES + ) { + this.emit({ kind: 'error', code: 'MOQ_PROTOCOL_MISMATCH' }); + return; + } + this.emit({ + kind: 'object', + subscriptionId: event.subscriptionId, + namespace: [...event.namespace], + trackName: event.trackName, + groupId: event.groupId, + objectId: event.objectId, + payload: new Uint8Array(event.data), + }); + } +} diff --git a/electron/src/moq-transport-manager.test.ts b/electron/src/moq-transport-manager.test.ts new file mode 100644 index 00000000..3c08e992 --- /dev/null +++ b/electron/src/moq-transport-manager.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + QAppMoqTransportManager, + type ManagedMoqTransport, +} from './moq-transport-manager'; +import type { MoqTransportEvent } from './moq-masque-transport'; + +const owner = { tabId: 'tab-1', name: 'call-app', service: 'APP' }; + +class FakeTransport implements ManagedMoqTransport { + open = vi.fn(async () => undefined); + subscribe = vi.fn(async () => undefined); + publish = vi.fn(async () => undefined); + metrics = vi.fn(async () => ({ objectsSent: 1 })); + close = vi.fn(async () => undefined); + + constructor(readonly emit: (event: MoqTransportEvent) => void) {} +} + +function setup(ownership: 'owned' | 'missing' | 'not-owned' = 'owned') { + const transports: FakeTransport[] = []; + const manager = new QAppMoqTransportManager( + () => ownership, + (emit) => { + const transport = new FakeTransport(emit); + transports.push(transport); + return transport; + } + ); + const events: unknown[] = []; + manager.on('event', (event) => events.push(event)); + return { manager, transports, events }; +} + +async function open(manager: QAppMoqTransportManager) { + return manager.open( + owner, + 'rns-1', + ['qortal', 'call', 'room-123', 'QAlice123'], + 'audio' + ); +} + +describe('Q-App generic MOQT manager', () => { + it('binds a session to the Q-App-owned Reticulum connection', async () => { + const { manager, transports } = setup(); + const opened = await open(manager); + expect(opened.state).toBe('OPEN'); + expect(transports[0].open).toHaveBeenCalledWith({ + owner, + rnsConnectionId: 'rns-1', + publicationNamespace: ['qortal', 'call', 'room-123', 'QAlice123'], + publicationTrack: 'audio', + }); + + const missing = setup('missing'); + await expect(open(missing.manager)).rejects.toMatchObject({ + code: 'INVALID_RNS_CONNECTION', + }); + }); + + it('subscribes and carries only bounded opaque binary objects', async () => { + const { manager, transports, events } = setup(); + const opened = await open(manager); + await manager.subscribe( + owner, + opened.sessionId, + 'peer-QBob456', + ['qortal', 'call', 'room-123', 'QBob456'], + 'audio' + ); + await manager.publish(owner, opened.sessionId, new Uint8Array([1, 2, 3])); + expect(transports[0].publish).toHaveBeenCalledWith( + new Uint8Array([1, 2, 3]) + ); + + transports[0].emit({ + kind: 'object', + subscriptionId: 'peer-QBob456', + namespace: ['qortal', 'call', 'room-123', 'QBob456'], + trackName: 'audio', + groupId: 0, + objectId: 1, + payload: new Uint8Array([7, 8]), + }); + expect(events).toContainEqual( + expect.objectContaining({ + action: 'MOQ_OBJECT', + sessionId: opened.sessionId, + payload: new Uint8Array([7, 8]), + }) + ); + await expect( + manager.publish(owner, opened.sessionId, new Uint8Array(1025)) + ).rejects.toMatchObject({ code: 'MOQ_OBJECT_TOO_LARGE' }); + }); + + it('isolates owners and closes sessions with their Reticulum connection', async () => { + const { manager, transports } = setup(); + const opened = await open(manager); + await expect( + manager.metrics({ ...owner, tabId: 'other-tab' }, opened.sessionId) + ).rejects.toMatchObject({ code: 'MOQ_SESSION_NOT_OWNED' }); + await manager.cleanupRnsConnection(owner, 'rns-1'); + expect(transports[0].close).toHaveBeenCalledOnce(); + await expect( + manager.metrics(owner, opened.sessionId) + ).rejects.toMatchObject({ code: 'MOQ_SESSION_CLOSED' }); + }); +}); diff --git a/electron/src/moq-transport-manager.ts b/electron/src/moq-transport-manager.ts new file mode 100644 index 00000000..54d68ad1 --- /dev/null +++ b/electron/src/moq-transport-manager.ts @@ -0,0 +1,435 @@ +import { EventEmitter } from 'events'; +import { randomUUID } from 'crypto'; +import type { + QAppReticulumConnectionOwnership, + QAppReticulumOwner, +} from './qapp-reticulum-manager'; +import { privateChannelOwnerKey } from './private-channel-manager'; +import type { MoqOpenContext, MoqTransportEvent } from './moq-masque-transport'; + +export const QAPP_MOQ_LIMITS = Object.freeze({ + maxSessionsPerOwner: 2, + maxSessionsGlobal: 16, + maxSubscriptionsPerSession: 64, + maxObjectBytes: 1024, + maxQueuedBytesPerSession: 64 * 1024, +}); + +const MOQ_NAME = /^[A-Za-z0-9._-]{1,128}$/; + +export class QAppMoqError extends Error { + constructor( + public readonly code: string, + message = code + ) { + super(message); + } +} + +export interface ManagedMoqTransport { + open(context: MoqOpenContext): Promise; + subscribe( + subscriptionId: string, + namespace: readonly string[], + trackName: string + ): Promise; + publish(payload: Uint8Array): Promise; + metrics(): Promise>; + close(): Promise; +} + +export type ManagedMoqTransportFactory = ( + emit: (event: MoqTransportEvent) => void +) => ManagedMoqTransport; + +type SessionRecord = { + sessionId: string; + ownerKey: string; + owner: QAppReticulumOwner; + rnsConnectionId: string; + state: 'OPENING' | 'OPEN' | 'CLOSED'; + transport: ManagedMoqTransport; + subscriptions: Set; + queuedBytes: number; + generation: number; +}; + +export class QAppMoqTransportManager extends EventEmitter { + private readonly sessions = new Map(); + private readonly sessionsByOwner = new Map>(); + + constructor( + private readonly getRnsConnectionOwnership: ( + owner: QAppReticulumOwner, + connectionId: string + ) => QAppReticulumConnectionOwnership, + private readonly transportFactory: ManagedMoqTransportFactory + ) { + super(); + } + + async open( + owner: QAppReticulumOwner, + rnsConnectionIdValue: unknown, + publicationNamespaceValue: unknown, + publicationTrackValue: unknown + ) { + const ownerKey = validatedOwnerKey(owner); + const rnsConnectionId = requireNonemptyString( + rnsConnectionIdValue, + 'INVALID_RNS_CONNECTION' + ); + const ownership = this.getRnsConnectionOwnership(owner, rnsConnectionId); + if (ownership === 'missing') + throw new QAppMoqError('INVALID_RNS_CONNECTION'); + if (ownership !== 'owned') + throw new QAppMoqError('RNS_CONNECTION_NOT_OWNED'); + const publicationNamespace = requireNamespace( + publicationNamespaceValue, + 'INVALID_MOQ_CONFIG' + ); + const publicationTrack = requireName( + publicationTrackValue, + 'INVALID_MOQ_CONFIG' + ); + if ( + (this.sessionsByOwner.get(ownerKey)?.size ?? 0) >= + QAPP_MOQ_LIMITS.maxSessionsPerOwner || + this.sessions.size >= QAPP_MOQ_LIMITS.maxSessionsGlobal + ) { + throw new QAppMoqError('MOQ_SESSION_LIMIT'); + } + + const sessionId = `qapp-moq-${randomUUID()}`; + const generation = 1; + const transport = this.transportFactory((event) => + this.handleTransportEvent(sessionId, generation, event) + ); + const session: SessionRecord = { + sessionId, + ownerKey, + owner, + rnsConnectionId, + state: 'OPENING', + transport, + subscriptions: new Set(), + queuedBytes: 0, + generation, + }; + this.sessions.set(sessionId, session); + const owned = this.sessionsByOwner.get(ownerKey) ?? new Set(); + owned.add(sessionId); + this.sessionsByOwner.set(ownerKey, owned); + try { + await transport.open({ + owner, + rnsConnectionId, + publicationNamespace, + publicationTrack, + }); + if (this.sessions.get(sessionId) !== session) + throw new QAppMoqError('MOQ_SESSION_CLOSED'); + session.state = 'OPEN'; + this.emitState(session, 'OPEN'); + return { + sessionId, + state: 'OPEN' as const, + publicationNamespace, + publicationTrack, + limits: QAPP_MOQ_LIMITS, + }; + } catch (error) { + this.remove(session); + void transport.close().catch(() => undefined); + throw normalizeError(error, 'MOQ_SESSION_OPEN_FAILED'); + } + } + + async subscribe( + owner: QAppReticulumOwner, + sessionIdValue: unknown, + subscriptionIdValue: unknown, + namespaceValue: unknown, + trackNameValue: unknown + ) { + const session = this.requireOwned(owner, sessionIdValue); + const subscriptionId = requireName( + subscriptionIdValue, + 'INVALID_MOQ_SUBSCRIPTION' + ); + const namespace = requireNamespace( + namespaceValue, + 'INVALID_MOQ_SUBSCRIPTION' + ); + const trackName = requireName(trackNameValue, 'INVALID_MOQ_SUBSCRIPTION'); + if ( + !session.subscriptions.has(subscriptionId) && + session.subscriptions.size >= QAPP_MOQ_LIMITS.maxSubscriptionsPerSession + ) { + throw new QAppMoqError('MOQ_SUBSCRIPTION_LIMIT'); + } + try { + await session.transport.subscribe(subscriptionId, namespace, trackName); + session.subscriptions.add(subscriptionId); + return { sessionId: session.sessionId, subscriptionId, subscribed: true }; + } catch (error) { + throw normalizeError(error, 'MOQ_SUBSCRIBE_FAILED'); + } + } + + async publish( + owner: QAppReticulumOwner, + sessionIdValue: unknown, + payloadValue: unknown + ) { + const session = this.requireOwned(owner, sessionIdValue); + const payload = requirePayload(payloadValue); + if ( + session.queuedBytes + payload.byteLength > + QAPP_MOQ_LIMITS.maxQueuedBytesPerSession + ) { + throw new QAppMoqError('MOQ_QUEUE_LIMIT'); + } + session.queuedBytes += payload.byteLength; + try { + await session.transport.publish(payload); + return { + sessionId: session.sessionId, + accepted: true as const, + bytes: payload.byteLength, + }; + } catch (error) { + throw normalizeError(error, 'MOQ_SEND_FAILED'); + } finally { + session.queuedBytes = Math.max( + 0, + session.queuedBytes - payload.byteLength + ); + } + } + + async metrics(owner: QAppReticulumOwner, sessionIdValue: unknown) { + const session = this.requireOwned(owner, sessionIdValue); + try { + return await session.transport.metrics(); + } catch (error) { + throw normalizeError(error, 'MOQ_METRICS_FAILED'); + } + } + + async close(owner: QAppReticulumOwner, sessionIdValue: unknown) { + const session = this.requireOwned(owner, sessionIdValue); + this.remove(session); + try { + await session.transport.close(); + } catch { + // The Q-App handle is closed even if native teardown has already happened. + } + this.emitState(session, 'CLOSED'); + return { sessionId: session.sessionId, state: 'CLOSED' as const }; + } + + async cleanupOwner(owner: QAppReticulumOwner): Promise { + const ownerKey = validatedOwnerKey(owner); + const sessions = [...(this.sessionsByOwner.get(ownerKey) ?? [])] + .map((id) => this.sessions.get(id)) + .filter((session): session is SessionRecord => Boolean(session)); + await Promise.allSettled( + sessions.map((session) => this.terminate(session)) + ); + } + + async cleanupRnsConnection( + owner: QAppReticulumOwner, + rnsConnectionId: string + ): Promise { + const ownerKey = validatedOwnerKey(owner); + const sessions = [...(this.sessionsByOwner.get(ownerKey) ?? [])] + .map((id) => this.sessions.get(id)) + .filter( + (session): session is SessionRecord => + Boolean(session) && session?.rnsConnectionId === rnsConnectionId + ); + await Promise.allSettled( + sessions.map((session) => this.terminate(session)) + ); + } + + destroy(): void { + for (const session of this.sessions.values()) { + void session.transport.close().catch(() => undefined); + } + this.sessions.clear(); + this.sessionsByOwner.clear(); + this.removeAllListeners(); + } + + private requireOwned( + owner: QAppReticulumOwner, + sessionIdValue: unknown + ): SessionRecord { + const ownerKey = validatedOwnerKey(owner); + const sessionId = + typeof sessionIdValue === 'string' ? sessionIdValue.trim() : ''; + if (!sessionId.startsWith('qapp-moq-')) + throw new QAppMoqError('INVALID_MOQ_SESSION'); + const session = this.sessions.get(sessionId); + if (!session) throw new QAppMoqError('MOQ_SESSION_CLOSED'); + if (session.ownerKey !== ownerKey) + throw new QAppMoqError('MOQ_SESSION_NOT_OWNED'); + if (session.state !== 'OPEN') throw new QAppMoqError('MOQ_SESSION_CLOSED'); + return session; + } + + private handleTransportEvent( + sessionId: string, + generation: number, + event: MoqTransportEvent + ): void { + const session = this.sessions.get(sessionId); + if ( + !session || + session.generation !== generation || + session.state !== 'OPEN' + ) + return; + if (event.kind === 'object') { + if ( + !session.subscriptions.has(event.subscriptionId) || + event.payload.byteLength < 1 || + event.payload.byteLength > QAPP_MOQ_LIMITS.maxObjectBytes + ) { + this.fail(session, 'MOQ_PROTOCOL_MISMATCH'); + return; + } + this.emit('event', { + ownerKey: session.ownerKey, + action: 'MOQ_OBJECT', + sessionId, + subscriptionId: event.subscriptionId, + namespace: [...event.namespace], + trackName: event.trackName, + groupId: event.groupId, + objectId: event.objectId, + payload: Uint8Array.from(event.payload), + }); + return; + } + this.emit('event', { + ownerKey: session.ownerKey, + action: 'MOQ_ERROR', + sessionId, + subscriptionId: event.subscriptionId, + code: event.code, + }); + if (!event.subscriptionId) this.fail(session, event.code, false); + } + + private fail(session: SessionRecord, code: string, emitError = true): void { + if (this.sessions.get(session.sessionId) !== session) return; + if (emitError) { + this.emit('event', { + ownerKey: session.ownerKey, + action: 'MOQ_ERROR', + sessionId: session.sessionId, + code, + }); + } + this.remove(session); + this.emitState(session, 'CLOSED'); + void session.transport.close().catch(() => undefined); + } + + private remove(session: SessionRecord): void { + session.state = 'CLOSED'; + this.sessions.delete(session.sessionId); + const owned = this.sessionsByOwner.get(session.ownerKey); + owned?.delete(session.sessionId); + if (owned?.size === 0) this.sessionsByOwner.delete(session.ownerKey); + } + + private async terminate(session: SessionRecord): Promise { + if (this.sessions.get(session.sessionId) !== session) return; + this.remove(session); + await session.transport.close().catch(() => undefined); + this.emitState(session, 'CLOSED'); + } + + private emitState(session: SessionRecord, state: 'OPEN' | 'CLOSED'): void { + this.emit('event', { + ownerKey: session.ownerKey, + action: 'MOQ_STATE', + sessionId: session.sessionId, + state, + }); + } +} + +function validatedOwnerKey(owner: QAppReticulumOwner): string { + try { + return privateChannelOwnerKey(owner); + } catch { + throw new QAppMoqError('PERMISSION_DENIED'); + } +} + +function requireNonemptyString(value: unknown, code: string): string { + const result = typeof value === 'string' ? value.trim() : ''; + if (!result || result.length > 256) throw new QAppMoqError(code); + return result; +} + +function requireName(value: unknown, code: string): string { + if (typeof value !== 'string' || !MOQ_NAME.test(value)) + throw new QAppMoqError(code); + return value; +} + +function requireNamespace(value: unknown, code: string): string[] { + if ( + !Array.isArray(value) || + value.length < 1 || + value.length > 8 || + !value.every((component) => + typeof component === 'string' ? MOQ_NAME.test(component) : false + ) || + value.reduce((total, component) => total + component.length, 0) > + 512 + ) { + throw new QAppMoqError(code); + } + return [...value]; +} + +function requirePayload(value: unknown): Uint8Array { + let payload: Uint8Array; + if (value instanceof Uint8Array) payload = Uint8Array.from(value); + else if (value instanceof ArrayBuffer) + payload = new Uint8Array(value.slice(0)); + else throw new QAppMoqError('INVALID_MOQ_OBJECT'); + if ( + payload.byteLength < 1 || + payload.byteLength > QAPP_MOQ_LIMITS.maxObjectBytes + ) { + throw new QAppMoqError('MOQ_OBJECT_TOO_LARGE'); + } + return payload; +} + +function normalizeError(error: unknown, fallback: string): QAppMoqError { + if (error instanceof QAppMoqError) return error; + const propertyCode = + typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' && + /^[A-Z0-9_]{3,64}$/.test(error.code) + ? error.code + : ''; + const messageCode = + error instanceof Error && /^[A-Z0-9_]{3,64}$/.test(error.message) + ? error.message + : ''; + const code = propertyCode || messageCode || fallback; + return new QAppMoqError(code); +} diff --git a/electron/src/preload.ts b/electron/src/preload.ts index b8c011c9..9d885046 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -89,6 +89,26 @@ async function invokePrivateChannel( throw error; } +async function invokeMoqTransport( + ipcChannel: string, + ...args: unknown[] +): Promise { + const result = (await ipcRenderer.invoke(ipcChannel, ...args)) as + | { ok: true; value: unknown } + | { ok: false; error: { code?: string; message?: string } }; + if (result.ok === true) return result.value; + const failure = result as { + ok: false; + error: { code?: string; message?: string }; + }; + const code = failure.error?.code || 'MOQ_ERROR'; + const error = new Error(failure.error?.message || code) as Error & { + code?: string; + }; + error.code = code; + throw error; +} + const hubP2pBootstrapIceServers = isDisabledLegacy ? [] : buildBootstrapIceServers(parseHubBootstrapSeedsFromArgv()); @@ -732,6 +752,48 @@ try { ipcRenderer.on('privateChannel:event', listener); return () => ipcRenderer.removeListener('privateChannel:event', listener); }, + qappMoqOpen: ( + owner, + rnsConnectionId, + publicationNamespace, + publicationTrack + ) => + invokeMoqTransport( + 'qappMoq:open', + owner, + rnsConnectionId, + publicationNamespace, + publicationTrack + ), + qappMoqSubscribe: ( + owner, + sessionId, + subscriptionId, + namespace, + trackName + ) => + invokeMoqTransport( + 'qappMoq:subscribe', + owner, + sessionId, + subscriptionId, + namespace, + trackName + ), + qappMoqPublish: (owner, sessionId, payload) => + invokeMoqTransport('qappMoq:publish', owner, sessionId, payload), + qappMoqMetrics: (owner, sessionId) => + invokeMoqTransport('qappMoq:metrics', owner, sessionId), + qappMoqClose: (owner, sessionId) => + invokeMoqTransport('qappMoq:close', owner, sessionId), + qappMoqCleanupOwner: (owner) => + invokeMoqTransport('qappMoq:cleanupOwner', owner), + onQAppMoqEvent: (callback: (payload: unknown) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => + callback(payload); + ipcRenderer.on('qappMoq:event', listener); + return () => ipcRenderer.removeListener('qappMoq:event', listener); + }, qchatFileSelect: () => ipcRenderer.invoke('reticulum:qchatFileSelect') as Promise<{ ok: boolean; diff --git a/electron/src/private-channel-bootstrap.ts b/electron/src/private-channel-bootstrap.ts index 58480ca0..e446fcc0 100644 --- a/electron/src/private-channel-bootstrap.ts +++ b/electron/src/private-channel-bootstrap.ts @@ -25,7 +25,8 @@ export type PrivateBootstrapDescriptor = Readonly<{ expiresAt: number; nonce: string; ownerBindingHash: string; - supportedFeatures: { reliable: true; datagrams: true }; + applicationProtocol?: string; + supportedFeatures: { reliable: true; datagrams: true; moqt?: boolean }; }>; export interface PrivateChannelBootstrapProvider { diff --git a/electron/src/private-transport-runtime.ts b/electron/src/private-transport-runtime.ts index f8357082..ab7c90b4 100644 --- a/electron/src/private-transport-runtime.ts +++ b/electron/src/private-transport-runtime.ts @@ -9,6 +9,10 @@ import { ReticulumPrivateChannelBootstrapProvider } from './private-channel-boot import type { QAppReticulumManager } from './qapp-reticulum-manager'; import type { ReticulumBridge } from './reticulum-bridge'; import { discoverCommunityMasqueRelay } from './masque-relay-discovery'; +import { + MoqMasqueTransport, + type MoqTransportEvent, +} from './moq-masque-transport'; let prototypeSidecar: PrivateTransportSidecar | null = null; let sidecarShutdownPromise: Promise | null = null; @@ -46,6 +50,37 @@ export function getPrivateTransportFactory( ); } +/** + * Trusted main-process generic MOQT transport. The capability-scoped Q-App + * manager owns instances and keeps relay/bootstrap details out of renderers. + */ +export function createMoqTransport( + emit: (event: MoqTransportEvent) => void, + reticulumManager: QAppReticulumManager, + reticulumBridgeProvider: () => ReticulumBridge | null = () => null, + environment: NodeJS.ProcessEnv = process.env +): MoqMasqueTransport { + const explicitRelay = + environment.QORTAL_PRIVATE_TRANSPORT === 'masque-test' + ? readTrustedRelayConfig(environment) + : null; + prototypeSidecar ??= new PrivateTransportSidecar(); + return new MoqMasqueTransport( + emit, + prototypeSidecar, + explicitRelay ?? + (async () => { + const reticulumBridge = reticulumBridgeProvider(); + if (!reticulumBridge) throw new Error('MASQUE_RELAY_UNAVAILABLE'); + return discoverCommunityMasqueRelay(reticulumBridge, { + allowLoopback: + environment.QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY === '1', + }); + }), + new ReticulumPrivateChannelBootstrapProvider(reticulumManager) + ); +} + export function readTrustedRelayConfig(environment: NodeJS.ProcessEnv) { const relayAddress = required(environment.QORTAL_MASQUE_TEST_RELAY_ADDRESS); assertLiteralEndpoint(relayAddress, 'relay'); diff --git a/electron/src/private-transport-sidecar.test.ts b/electron/src/private-transport-sidecar.test.ts index d002267c..44b6325c 100644 --- a/electron/src/private-transport-sidecar.test.ts +++ b/electron/src/private-transport-sidecar.test.ts @@ -125,9 +125,10 @@ describeIntegration('private transport sidecar integration', () => { await sidecar.start(); expect(await sidecar.health()).toEqual({ service: 'qortal-private-transport', - sidecarVersion: '0.2.0', + sidecarVersion: '0.4.0', protocolVersion: 2, innerAlpn: 'qortal-private/1', + moqAlpn: 'moqt-18', }); const tunnelId = await sidecar.openMasqueTunnel(config); @@ -196,7 +197,7 @@ describeIntegration('private transport sidecar integration', () => { let count = 0; readline.createInterface({ input: process.stdin }).on('line', (line) => { const request = JSON.parse(line); - if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.2.0',protocolVersion:2,innerAlpn:'qortal-private/1'}}) + '\\n'); + if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.4.0',protocolVersion:2,innerAlpn:'qortal-private/1',moqAlpn:'moqt-18'}}) + '\\n'); else process.stdout.write('{not-json\\n'); }); `; @@ -232,4 +233,25 @@ describe('MASQUE prototype configuration boundary', () => { it('uses stable coded sidecar errors', () => { expect(new PrivateTransportSidecarError('TEST').code).toBe('TEST'); }); + + it('rejects invalid MOQT names and oversized objects before IPC', async () => { + const sidecar = new PrivateTransportSidecar(); + await expect( + sidecar.openMoqSession({ + relayAddress: '127.0.0.1:1', + relayServerName: 'relay', + relayCertSha256: '00'.repeat(32), + backendAddress: '127.0.0.1:2', + backendServerName: 'backend', + backendCertSha256: '11'.repeat(32), + logicalSessionId: 'logical', + attachToken: 'x'.repeat(32), + publicationNamespace: ['invalid component'], + publicationTrack: 'events', + }) + ).rejects.toMatchObject({ code: 'INVALID_MOQ_CONFIG' }); + await expect( + sidecar.publishMoqObject('moq-session', new Uint8Array(1025)) + ).rejects.toMatchObject({ code: 'MOQ_OBJECT_TOO_LARGE' }); + }); }); diff --git a/electron/src/private-transport-sidecar.ts b/electron/src/private-transport-sidecar.ts index 80164a12..4cfd980c 100644 --- a/electron/src/private-transport-sidecar.ts +++ b/electron/src/private-transport-sidecar.ts @@ -5,7 +5,18 @@ import { EventEmitter } from 'events'; import path from 'path'; export const PRIVATE_TRANSPORT_PROTOCOL_VERSION = 2; -export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.2.0'; +export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.4.0'; +export const MAX_MOQ_OBJECT_BYTES = 1024; +const MOQ_NAME = /^[A-Za-z0-9._-]{1,128}$/; + +function validMoqNamespace(namespace: readonly string[]): boolean { + return ( + namespace.length >= 1 && + namespace.length <= 8 && + namespace.every((component) => MOQ_NAME.test(component)) && + namespace.reduce((total, component) => total + component.length, 0) <= 512 + ); +} const MAX_RESPONSE_LINE_BYTES = 64 * 1024; const DEFAULT_REQUEST_TIMEOUT_MS = 7_000; @@ -41,10 +52,37 @@ export type PrivateSessionConfig = Readonly<{ timeoutMs?: number; }>; +export type MoqSessionConfig = Readonly<{ + relayAddress: string; + relayServerName: string; + relayCertSha256: string; + backendAddress: string; + backendServerName: string; + backendCertSha256: string; + logicalSessionId: string; + attachToken: string; + publicationNamespace: readonly string[]; + publicationTrack: string; + timeoutMs?: number; +}>; + +export type MoqSessionInfo = Readonly<{ + moqSessionId: string; + logicalSessionId: string; + publicationNamespace: string[]; + publicationTrack: string; + applicationProtocol: 'moqt-18'; +}>; + export type PrivateTransportSidecarEvent = { - event: 'reliableMessage' | 'datagram' | 'error'; + event: 'reliableMessage' | 'datagram' | 'object' | 'error'; sessionId: string; messageId?: string; + subscriptionId?: string; + namespace?: string[]; + trackName?: string; + groupId?: number; + objectId?: number; code?: string; data: Buffer; }; @@ -60,6 +98,11 @@ type SidecarOperation = | 'sendPrivateDatagram' | 'sessionMetrics' | 'closePrivateSession' + | 'openMoqSession' + | 'subscribeMoqTrack' + | 'publishMoqObject' + | 'moqSessionMetrics' + | 'closeMoqSession' | 'shutdown'; type SidecarResponse = { @@ -72,6 +115,11 @@ type SidecarResponse = { event?: unknown; sessionId?: unknown; messageId?: unknown; + subscriptionId?: unknown; + namespace?: unknown; + trackName?: unknown; + groupId?: unknown; + objectId?: unknown; code?: unknown; binaryLength?: unknown; }; @@ -154,6 +202,7 @@ export class PrivateTransportSidecar extends EventEmitter { sidecarVersion: string; protocolVersion: number; innerAlpn?: string; + moqAlpn?: string; }> { const result = await this.request('health', {}); return result as { @@ -161,6 +210,7 @@ export class PrivateTransportSidecar extends EventEmitter { sidecarVersion: string; protocolVersion: number; innerAlpn?: string; + moqAlpn?: string; }; } @@ -262,6 +312,84 @@ export class PrivateTransportSidecar extends EventEmitter { if (this.child) await this.request('closePrivateSession', { sessionId }); } + async openMoqSession(config: MoqSessionConfig): Promise { + if ( + !validMoqNamespace(config.publicationNamespace) || + !MOQ_NAME.test(config.publicationTrack) + ) { + throw new PrivateTransportSidecarError('INVALID_MOQ_CONFIG'); + } + await this.start(); + const result = (await this.request('openMoqSession', config)) as Record< + string, + unknown + >; + if ( + typeof result?.moqSessionId !== 'string' || + !result.moqSessionId.startsWith('moq-') || + typeof result?.logicalSessionId !== 'string' || + !Array.isArray(result?.publicationNamespace) || + result.publicationNamespace.length !== + config.publicationNamespace.length || + !result.publicationNamespace.every( + (component, index) => component === config.publicationNamespace[index] + ) || + result?.publicationTrack !== config.publicationTrack || + result?.applicationProtocol !== 'moqt-18' + ) { + throw new PrivateTransportSidecarError('MALFORMED_RESPONSE'); + } + return result as MoqSessionInfo; + } + + async subscribeMoqTrack( + moqSessionId: string, + subscriptionId: string, + namespace: readonly string[], + trackName: string + ): Promise { + if ( + !MOQ_NAME.test(subscriptionId) || + !validMoqNamespace(namespace) || + !MOQ_NAME.test(trackName) + ) { + throw new PrivateTransportSidecarError('INVALID_MOQ_SUBSCRIPTION'); + } + await this.request('subscribeMoqTrack', { + moqSessionId, + subscriptionId, + namespace, + trackName, + }); + } + + async publishMoqObject( + moqSessionId: string, + payload: Uint8Array + ): Promise { + if (payload.byteLength < 1 || payload.byteLength > MAX_MOQ_OBJECT_BYTES) { + throw new PrivateTransportSidecarError('MOQ_OBJECT_TOO_LARGE'); + } + await this.request( + 'publishMoqObject', + { moqSessionId }, + undefined, + payload + ); + } + + async moqSessionMetrics( + moqSessionId: string + ): Promise> { + return (await this.request('moqSessionMetrics', { + moqSessionId, + })) as Record; + } + + async closeMoqSession(moqSessionId: string): Promise { + if (this.child) await this.request('closeMoqSession', { moqSessionId }); + } + async shutdown(): Promise { const child = this.child; if (!child) return; @@ -314,7 +442,9 @@ export class PrivateTransportSidecar extends EventEmitter { if ( health.service !== 'qortal-private-transport' || health.sidecarVersion !== PRIVATE_TRANSPORT_SIDECAR_VERSION || - health.protocolVersion !== PRIVATE_TRANSPORT_PROTOCOL_VERSION + health.protocolVersion !== PRIVATE_TRANSPORT_PROTOCOL_VERSION || + health.innerAlpn !== 'qortal-private/1' || + health.moqAlpn !== 'moqt-18' ) { throw new PrivateTransportSidecarError('SIDECAR_VERSION_MISMATCH'); } @@ -412,8 +542,15 @@ export class PrivateTransportSidecar extends EventEmitter { if ( response.version !== PRIVATE_TRANSPORT_PROTOCOL_VERSION || typeof response.event !== 'string' || - !['reliableMessage', 'datagram', 'error'].includes(response.event) || + !['reliableMessage', 'datagram', 'object', 'error'].includes( + response.event + ) || typeof response.sessionId !== 'string' || + (response.namespace !== undefined && + (!Array.isArray(response.namespace) || + !response.namespace.every( + (component) => typeof component === 'string' + ))) || (response.binaryLength !== undefined && (!Number.isInteger(response.binaryLength) || (response.binaryLength as number) < 0 || @@ -429,6 +566,29 @@ export class PrivateTransportSidecar extends EventEmitter { typeof response.messageId === 'string' ? response.messageId : undefined, + subscriptionId: + typeof response.subscriptionId === 'string' + ? response.subscriptionId + : undefined, + namespace: Array.isArray(response.namespace) + ? (response.namespace as string[]) + : undefined, + trackName: + typeof response.trackName === 'string' + ? response.trackName + : undefined, + groupId: + typeof response.groupId === 'number' && + Number.isSafeInteger(response.groupId) && + response.groupId >= 0 + ? response.groupId + : undefined, + objectId: + typeof response.objectId === 'number' && + Number.isSafeInteger(response.objectId) && + response.objectId >= 0 + ? response.objectId + : undefined, code: typeof response.code === 'string' ? response.code : undefined, binaryLength: Number(response.binaryLength ?? 0), }; diff --git a/electron/src/reticulum-resource.worker.ts b/electron/src/reticulum-resource.worker.ts index 70cbc998..ae04843f 100644 --- a/electron/src/reticulum-resource.worker.ts +++ b/electron/src/reticulum-resource.worker.ts @@ -54,11 +54,20 @@ export type ReticulumResourceWorkerTask = }; export type ReticulumResourceWorkerTaskInput = - | Omit, 'id'> + | Omit< + Extract, + 'id' + > | Omit, 'id'> | Omit, 'id'> - | Omit, 'id'> - | Omit, 'id'> + | Omit< + Extract, + 'id' + > + | Omit< + Extract, + 'id' + > | Omit, 'id'>; export type ReticulumResourceWorkerResult = @@ -186,7 +195,8 @@ export function writeReticulumResourceRange( while (remaining > 0) { const readSize = Math.min(buffer.length, remaining); const bytesRead = fs.readSync(source, buffer, 0, readSize, offset); - if (bytesRead <= 0) throw new Error('Unexpected EOF while reading resource range'); + if (bytesRead <= 0) + throw new Error('Unexpected EOF while reading resource range'); const slice = buffer.subarray(0, bytesRead); fs.writeSync(output, slice); hash.update(slice); @@ -218,15 +228,20 @@ parentPort?.on('message', (task: ReticulumResourceWorkerTask) => { } if (task.kind === 'read_and_hash_file') { const { bytes, hash } = readAndHashReticulumResourceFile(task.path); + // Own the exact ArrayBuffer sent to the parent. Node Buffers may expose + // ArrayBufferLike (including pooled/shared backing), which is not a safe + // transferable and also fails Electron's Transferable contract. + const transferableBytes = new Uint8Array(bytes.byteLength); + transferableBytes.set(bytes); const result = { id: task.id, kind: task.kind, ok: true, - bytes, + bytes: transferableBytes, hash, durationMs: Date.now() - startedAt, } satisfies ReticulumResourceWorkerResult; - parentPort?.postMessage(result, [bytes.buffer]); + parentPort?.postMessage(result, [transferableBytes.buffer]); return; } if (task.kind === 'write_range_file') { @@ -261,7 +276,8 @@ parentPort?.on('message', (task: ReticulumResourceWorkerTask) => { stat.isFile() && stat.size === entry.expectedSize && (entry.expectComplete || - hashReticulumResourceFile(entry.assembledPath) === entry.expectedHash); + hashReticulumResourceFile(entry.assembledPath) === + entry.expectedHash); } catch { assembledValid = false; } diff --git a/electron/src/setup.ts b/electron/src/setup.ts index 01f7f872..f6b6a24b 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -142,9 +142,11 @@ import { PrivateChannelManager, } from './private-channel-manager'; import { + createMoqTransport, getPrivateTransportFactory, shutdownPrivateTransportSidecar, } from './private-transport-runtime'; +import { QAppMoqError, QAppMoqTransportManager } from './moq-transport-manager'; import { attachReticulumStatusBridgeEvents } from './reticulum-daemon'; import { startReticulumMeshCoordinator, @@ -2843,6 +2845,8 @@ export function stopReticulumManagers(): void { stopPresenceManager(); privateChannelManager?.destroy(); privateChannelManager = null; + qAppMoqTransportManager?.destroy(); + qAppMoqTransportManager = null; void shutdownPrivateTransportSidecar(); qAppReticulumManager?.destroy(); qAppReticulumManager = null; @@ -2853,6 +2857,7 @@ export function stopReticulumManagers(): void { let qAppReticulumManager: QAppReticulumManager | null = null; let privateChannelManager: PrivateChannelManager | null = null; +let qAppMoqTransportManager: QAppMoqTransportManager | null = null; let qAppReticulumBridge: ReturnType = null; const qAppReticulumNativeListeners = new Set< (event: QAppReticulumNativeEvent) => void @@ -2890,9 +2895,8 @@ function getPrivateChannelManager(): PrivateChannelManager { privateChannelManager = new PrivateChannelManager( (owner, connectionId) => getQAppReticulumManager().connectionOwnership(owner, connectionId), - getPrivateTransportFactory( - getQAppReticulumManager(), - () => getReticulumBridge() + getPrivateTransportFactory(getQAppReticulumManager(), () => + getReticulumBridge() ) ); privateChannelManager.on('event', (event) => { @@ -2902,6 +2906,23 @@ function getPrivateChannelManager(): PrivateChannelManager { return privateChannelManager; } +function getQAppMoqTransportManager(): QAppMoqTransportManager { + if (qAppMoqTransportManager) return qAppMoqTransportManager; + qAppMoqTransportManager = new QAppMoqTransportManager( + (owner, connectionId) => + getQAppReticulumManager().connectionOwnership(owner, connectionId), + (emit) => + createMoqTransport(emit, getQAppReticulumManager(), () => + getReticulumBridge() + ) + ); + qAppMoqTransportManager.on('event', (event) => { + const win = myCapacitorApp.getMainWindow(); + if (!win.isDestroyed()) win.webContents.send('qappMoq:event', event); + }); + return qAppMoqTransportManager; +} + function validateQAppReticulumIpcSender( event: Electron.IpcMainInvokeEvent ): void { @@ -2937,6 +2958,7 @@ ipcMain.handle( async (event, owner: QAppReticulumOwner, connectionId: string) => { validateQAppReticulumIpcSender(event); await privateChannelManager?.cleanupRnsConnection(owner, connectionId); + await qAppMoqTransportManager?.cleanupRnsConnection(owner, connectionId); await getQAppReticulumManager().close(owner, connectionId); return true; } @@ -2946,11 +2968,111 @@ ipcMain.handle( async (event, owner: QAppReticulumOwner) => { validateQAppReticulumIpcSender(event); await privateChannelManager?.cleanupOwner(owner); + await qAppMoqTransportManager?.cleanupOwner(owner); await getQAppReticulumManager().cleanupOwner(owner); return true; } ); +async function qAppMoqIpcResult( + operation: () => Promise | T +): Promise< + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string } } +> { + try { + return { ok: true, value: await operation() }; + } catch (error) { + const code = error instanceof QAppMoqError ? error.code : 'MOQ_ERROR'; + return { ok: false, error: { code, message: code } }; + } +} + +ipcMain.handle( + 'qappMoq:open', + async ( + event, + owner: QAppReticulumOwner, + rnsConnectionId: unknown, + publicationNamespace: unknown, + publicationTrack: unknown + ) => { + validatePrivateChannelIpcSender(event); + return qAppMoqIpcResult(() => + getQAppMoqTransportManager().open( + owner, + rnsConnectionId, + publicationNamespace, + publicationTrack + ) + ); + } +); +ipcMain.handle( + 'qappMoq:subscribe', + async ( + event, + owner: QAppReticulumOwner, + sessionId: unknown, + subscriptionId: unknown, + namespace: unknown, + trackName: unknown + ) => { + validatePrivateChannelIpcSender(event); + return qAppMoqIpcResult(() => + getQAppMoqTransportManager().subscribe( + owner, + sessionId, + subscriptionId, + namespace, + trackName + ) + ); + } +); +ipcMain.handle( + 'qappMoq:publish', + async ( + event, + owner: QAppReticulumOwner, + sessionId: unknown, + payload: unknown + ) => { + validatePrivateChannelIpcSender(event); + return qAppMoqIpcResult(() => + getQAppMoqTransportManager().publish(owner, sessionId, payload) + ); + } +); +ipcMain.handle( + 'qappMoq:metrics', + (event, owner: QAppReticulumOwner, sessionId: unknown) => { + validatePrivateChannelIpcSender(event); + return qAppMoqIpcResult(() => + getQAppMoqTransportManager().metrics(owner, sessionId) + ); + } +); +ipcMain.handle( + 'qappMoq:close', + async (event, owner: QAppReticulumOwner, sessionId: unknown) => { + validatePrivateChannelIpcSender(event); + return qAppMoqIpcResult(() => + getQAppMoqTransportManager().close(owner, sessionId) + ); + } +); +ipcMain.handle( + 'qappMoq:cleanupOwner', + async (event, owner: QAppReticulumOwner) => { + validatePrivateChannelIpcSender(event); + return qAppMoqIpcResult(async () => { + await qAppMoqTransportManager?.cleanupOwner(owner); + return true; + }); + } +); + function validatePrivateChannelIpcSender( event: Electron.IpcMainInvokeEvent ): void { diff --git a/package.json b/package.json index cc2e18fb..bb04f6a1 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "preview": "vite preview", "pretest": "tsc electron/src/reticulum-resource.worker.ts electron/src/reticulum-chat.worker.ts --outDir electron/build/src --target ES2020 --module CommonJS --moduleResolution node --esModuleInterop --skipLibCheck --types node", "test": "vitest", - "test:private-transport": "vitest run electron/src/private-channel-step3.test.ts electron/src/private-channel-step4.test.ts electron/src/private-transport-sidecar.test.ts electron/src/private-channel-manager.test.ts electron/src/masque-relay-discovery.test.ts electron/src/private-transport-runtime.test.ts electron/src/quic-masque-transport.test.ts src/qortal/qapp-private-channel-request.test.ts", + "test:private-transport": "vitest run electron/src/private-channel-step3.test.ts electron/src/private-channel-step4.test.ts electron/src/private-transport-sidecar.test.ts electron/src/private-channel-manager.test.ts electron/src/masque-relay-discovery.test.ts electron/src/private-transport-runtime.test.ts electron/src/quic-masque-transport.test.ts electron/src/moq-masque-transport.test.ts electron/src/moq-transport-manager.test.ts src/qortal/qapp-private-channel-request.test.ts src/qortal/qapp-moq-request.test.ts", "test:gcall:e2e": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest run src/lib/group-call/v2/groupCallE2eQuality.test.ts", "test:gcall:e2e:offline": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest run src/lib/group-call/v2/groupCallE2eQuality.test.ts src/lib/group-call/v2/groupCallE2eLiveExport.test.ts src/lib/group-call/v2/replayHarness.test.ts electron/src/group-call-e2e-electron.test.ts", "test:gcall:e2e:watch": "GCALL_E2E_ARTIFACT_DIR=${GCALL_E2E_ARTIFACT_DIR:-.artifacts/gcall-e2e} vitest src/lib/group-call/v2/groupCallE2eQuality.test.ts", diff --git a/src/components/Apps/AppViewer.tsx b/src/components/Apps/AppViewer.tsx index 34257196..f40961a2 100644 --- a/src/components/Apps/AppViewer.tsx +++ b/src/components/Apps/AppViewer.tsx @@ -435,7 +435,7 @@ export const AppViewer = forwardRef( tabIndex={-1} src={defaultUrl} sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-modals" - allow="fullscreen; clipboard-read; clipboard-write; screen-wake-lock" + allow="fullscreen; clipboard-read; clipboard-write; microphone; screen-wake-lock" > ); diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index becae4ba..0bf2dbb8 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -25,6 +25,10 @@ import { dispatchQAppPrivateChannelRequest, isQAppPrivateChannelAction, } from '../qortal/qapp-private-channel-request'; +import { + dispatchQAppMoqRequest, + isQAppMoqAction, +} from '../qortal/qapp-moq-request'; import { normalizeQappIdentityContext } from '../qortal/qapp-identity'; import { serializeQortalRequestError } from '../qortal/qortal-request-errors'; @@ -196,6 +200,11 @@ export const listOfAllQortalRequests = [ 'PRIVATE_CHANNEL_OPEN', 'PRIVATE_CHANNEL_SEND', 'PRIVATE_CHANNEL_STATUS', + 'MOQ_OBJECT_PUBLISH', + 'MOQ_SESSION_CLOSE', + 'MOQ_SESSION_METRICS', + 'MOQ_SESSION_OPEN', + 'MOQ_TRACK_SUBSCRIBE', 'ADD_FOREIGN_SERVER', 'ADD_GROUP_ADMIN', 'ADD_LIST_ITEMS', @@ -312,6 +321,11 @@ export const UIQortalRequests = [ 'PRIVATE_CHANNEL_OPEN', 'PRIVATE_CHANNEL_SEND', 'PRIVATE_CHANNEL_STATUS', + 'MOQ_OBJECT_PUBLISH', + 'MOQ_SESSION_CLOSE', + 'MOQ_SESSION_METRICS', + 'MOQ_SESSION_OPEN', + 'MOQ_TRACK_SUBSCRIBE', 'ADD_FOREIGN_SERVER', 'ADD_GROUP_ADMIN', 'ADD_LIST_ITEMS', @@ -792,17 +806,19 @@ export const useQortalMessageListener = ( ? dispatchQAppReticulumRequest(message.payload, requestContext) : isQAppPrivateChannelAction(message?.action) ? dispatchQAppPrivateChannelRequest(message.payload, requestContext) - : window.sendMessage( - message.action, - message.payload, - timeout, - message.isExtension, - { - name: appName, - service: appService, - tabId, - } - ); + : isQAppMoqAction(message?.action) + ? dispatchQAppMoqRequest(message.payload, requestContext) + : window.sendMessage( + message.action, + message.payload, + timeout, + message.isExtension, + { + name: appName, + service: appService, + tabId, + } + ); // Store the promise for deduplication if (isDeduplicable) { @@ -1064,6 +1080,39 @@ export const useQortalMessageListener = ( targetOrigin ); }); + const unsubscribeMoq = api.onQAppMoqEvent?.((payload) => { + if (payload?.ownerKey !== expectedOwnerKey || !iframe.contentWindow) + return; + let targetOrigin: string; + try { + targetOrigin = new URL(iframe.src).origin; + } catch { + return; + } + iframe.contentWindow.postMessage( + { + action: payload.action, + sessionId: payload.sessionId, + ...(payload.action === 'MOQ_OBJECT' + ? { + subscriptionId: payload.subscriptionId, + namespace: payload.namespace, + trackName: payload.trackName, + groupId: payload.groupId, + objectId: payload.objectId, + payload: payload.payload, + } + : payload.action === 'MOQ_ERROR' + ? { + subscriptionId: payload.subscriptionId, + code: payload.code, + } + : { state: payload.state }), + requestedHandler: 'UI', + }, + targetOrigin + ); + }); const handleLoad = () => { if (!hasLoadedFrameRef.current) { hasLoadedFrameRef.current = true; @@ -1071,14 +1120,17 @@ export const useQortalMessageListener = ( } void api.qappReticulumCleanupOwner?.(owner); void api.privateChannelCleanupOwner?.(owner); + void api.qappMoqCleanupOwner?.(owner); }; iframe.addEventListener('load', handleLoad); return () => { unsubscribe?.(); unsubscribePrivateChannel?.(); + unsubscribeMoq?.(); iframe.removeEventListener('load', handleLoad); void api.qappReticulumCleanupOwner?.(owner); void api.privateChannelCleanupOwner?.(owner); + void api.qappMoqCleanupOwner?.(owner); }; }, [appName, appService, iframeRef, tabId]); diff --git a/src/qortal/qapp-moq-request.test.ts b/src/qortal/qapp-moq-request.test.ts new file mode 100644 index 00000000..228386b0 --- /dev/null +++ b/src/qortal/qapp-moq-request.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { hasSessionPermission } = vi.hoisted(() => ({ + hasSessionPermission: vi.fn(), +})); + +vi.mock('./qortal-requests.ts', () => ({ hasSessionPermission })); + +import { dispatchQAppMoqRequest } from './qapp-moq-request'; + +const context = { appName: ' Call-App ', appService: 'app', tabId: 42 }; +const owner = { name: 'call-app', service: 'APP', tabId: '42' }; + +describe('Q-App MOQT request dispatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + hasSessionPermission.mockReturnValue(true); + Object.assign(window, { + electronAPI: { + qappMoqOpen: vi.fn().mockResolvedValue({ sessionId: 'qapp-moq-1' }), + qappMoqSubscribe: vi.fn().mockResolvedValue({ subscribed: true }), + qappMoqPublish: vi.fn().mockResolvedValue({ accepted: true }), + qappMoqMetrics: vi.fn().mockResolvedValue({ objectsSent: 1 }), + qappMoqClose: vi.fn().mockResolvedValue({ state: 'CLOSED' }), + }, + }); + }); + + it('requires private-data permission', async () => { + hasSessionPermission.mockReturnValue(false); + await expect( + dispatchQAppMoqRequest( + { + action: 'MOQ_SESSION_OPEN', + rnsConnectionId: 'rns-1', + publicationNamespace: ['qortal', 'call', 'room', 'Alice'], + publicationTrack: 'audio', + }, + context + ) + ).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(window.electronAPI.qappMoqOpen).not.toHaveBeenCalled(); + }); + + it('uses trusted normalized ownership and ignores network injection fields', async () => { + await dispatchQAppMoqRequest( + { + action: 'MOQ_SESSION_OPEN', + rnsConnectionId: 'rns-1', + publicationNamespace: ['qortal', 'call', 'room', 'Alice'], + publicationTrack: 'audio', + relayAddress: 'attacker.example:443', + backendAddress: 'attacker.example:4446', + }, + context + ); + expect(window.electronAPI.qappMoqOpen).toHaveBeenCalledWith( + owner, + 'rns-1', + ['qortal', 'call', 'room', 'Alice'], + 'audio' + ); + }); + + it('forwards only logical handles, tracks, and opaque payloads', async () => { + const payload = new Uint8Array([1, 2]); + await dispatchQAppMoqRequest( + { + action: 'MOQ_TRACK_SUBSCRIBE', + sessionId: 'qapp-moq-1', + subscriptionId: 'peer-Alice', + namespace: ['qortal', 'call', 'room', 'Alice'], + trackName: 'audio', + }, + context + ); + await dispatchQAppMoqRequest( + { + action: 'MOQ_OBJECT_PUBLISH', + sessionId: 'qapp-moq-1', + payload, + }, + context + ); + expect(window.electronAPI.qappMoqSubscribe).toHaveBeenCalledWith( + owner, + 'qapp-moq-1', + 'peer-Alice', + ['qortal', 'call', 'room', 'Alice'], + 'audio' + ); + expect(window.electronAPI.qappMoqPublish).toHaveBeenCalledWith( + owner, + 'qapp-moq-1', + payload + ); + }); +}); diff --git a/src/qortal/qapp-moq-request.ts b/src/qortal/qapp-moq-request.ts new file mode 100644 index 00000000..2987e1b3 --- /dev/null +++ b/src/qortal/qapp-moq-request.ts @@ -0,0 +1,102 @@ +import { normalizeQappIdentityContext } from './qapp-identity.ts'; +import { codedQortalRequestError } from './qortal-request-errors.ts'; +import { hasSessionPermission } from './qortal-requests.ts'; + +const QAPP_MOQ_ACTIONS = new Set([ + 'MOQ_SESSION_OPEN', + 'MOQ_TRACK_SUBSCRIBE', + 'MOQ_OBJECT_PUBLISH', + 'MOQ_SESSION_METRICS', + 'MOQ_SESSION_CLOSE', +]); + +type QAppMoqContext = { + appName: string; + appService?: string; + tabId: string | number; +}; + +type QAppMoqMessage = { + action?: string; + rnsConnectionId?: unknown; + sessionId?: unknown; + publicationNamespace?: unknown; + publicationTrack?: unknown; + subscriptionId?: unknown; + namespace?: unknown; + trackName?: unknown; + payload?: unknown; + [key: string]: unknown; +}; + +export function isQAppMoqAction(action: unknown): action is string { + return typeof action === 'string' && QAPP_MOQ_ACTIONS.has(action); +} + +export async function dispatchQAppMoqRequest( + message: QAppMoqMessage, + context: QAppMoqContext +) { + if (!isQAppMoqAction(message.action)) { + throw codedQortalRequestError('INVALID_ACTION', 'Invalid MOQT action'); + } + if ( + !hasSessionPermission( + context.tabId, + context.appName, + 'PRIVATE_DATA_CHANNEL' + ) + ) { + throw codedQortalRequestError( + 'PERMISSION_DENIED', + 'Private data channel permission is required' + ); + } + const api = window.electronAPI; + if (!api) unavailable(); + const identity = normalizeQappIdentityContext({ + name: context.appName, + service: context.appService, + }); + const owner = { + tabId: String(context.tabId), + name: identity.name, + service: identity.service, + }; + + switch (message.action) { + case 'MOQ_SESSION_OPEN': + if (!api.qappMoqOpen) unavailable(); + return api.qappMoqOpen( + owner, + message.rnsConnectionId, + message.publicationNamespace, + message.publicationTrack + ); + case 'MOQ_TRACK_SUBSCRIBE': + if (!api.qappMoqSubscribe) unavailable(); + return api.qappMoqSubscribe( + owner, + message.sessionId, + message.subscriptionId, + message.namespace, + message.trackName + ); + case 'MOQ_OBJECT_PUBLISH': + if (!api.qappMoqPublish) unavailable(); + return api.qappMoqPublish(owner, message.sessionId, message.payload); + case 'MOQ_SESSION_METRICS': + if (!api.qappMoqMetrics) unavailable(); + return api.qappMoqMetrics(owner, message.sessionId); + case 'MOQ_SESSION_CLOSE': + if (!api.qappMoqClose) unavailable(); + return api.qappMoqClose(owner, message.sessionId); + } +} + +function unavailable(): never { + throw codedQortalRequestError( + 'MOQ_TRANSPORT_UNAVAILABLE', + 'MOQT transport is unavailable' + ); +} diff --git a/src/types/window.d.ts b/src/types/window.d.ts index ddda4ba2..f09ee23b 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -377,6 +377,28 @@ declare global { privateChannelClose?: (owner: any, channelId: unknown) => Promise; privateChannelCleanupOwner?: (owner: any) => Promise; onPrivateChannelEvent?: (callback: (payload: any) => void) => () => void; + qappMoqOpen?: ( + owner: any, + rnsConnectionId: unknown, + publicationNamespace: unknown, + publicationTrack: unknown + ) => Promise; + qappMoqSubscribe?: ( + owner: any, + sessionId: unknown, + subscriptionId: unknown, + namespace: unknown, + trackName: unknown + ) => Promise; + qappMoqPublish?: ( + owner: any, + sessionId: unknown, + payload: unknown + ) => Promise; + qappMoqMetrics?: (owner: any, sessionId: unknown) => Promise; + qappMoqClose?: (owner: any, sessionId: unknown) => Promise; + qappMoqCleanupOwner?: (owner: any) => Promise; + onQAppMoqEvent?: (callback: (payload: any) => void) => () => void; /** Hidden audio-surface: proxy signing to the main shell (wallet key in-memory). */ gcallProxySignPresenceMessage?: ( payload: Record From 68be79b09544c959fda007b89aa4770bc2ecfea5 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Tue, 8 Sep 2026 16:30:54 +0300 Subject: [PATCH 19/32] fixes --- docs/qapp-reticulum.md | 25 +- .../internal/innerquic/session.go | 25 +- .../internal/innerquic/session_test.go | 11 + .../internal/masque/client.go | 17 +- .../internal/masque/client_test.go | 11 + .../internal/moqclient/session.go | 18 +- .../internal/moqclient/session_test.go | 11 + electron/src/display-media-picker.test.ts | 129 ++++++++++ electron/src/display-media-picker.ts | 137 +++++++++++ electron/src/masque-relay-discovery.test.ts | 15 ++ electron/src/masque-relay-discovery.ts | 15 +- electron/src/moq-masque-transport.test.ts | 62 ++++- electron/src/moq-masque-transport.ts | 203 +++++++++++++++- electron/src/preload.ts | 12 + electron/src/private-transport-runtime.ts | 6 +- electron/src/quic-masque-transport.test.ts | 78 +++++++ electron/src/quic-masque-transport.ts | 221 +++++++++++++++--- electron/src/setup.ts | 2 + src/App.tsx | 2 + src/components/Apps/AppViewer.tsx | 2 +- .../Apps/QAppScreenCapturePermission.tsx | 79 +++++++ src/hooks/useQortalMessageListener.tsx | 20 +- src/i18n/locales/ar/question.json | 5 + src/i18n/locales/de/question.json | 5 + src/i18n/locales/en/question.json | 5 + src/i18n/locales/es/question.json | 5 + src/i18n/locales/et/question.json | 5 + src/i18n/locales/fi/question.json | 5 + src/i18n/locales/fr/question.json | 5 + src/i18n/locales/it/question.json | 5 + src/i18n/locales/ja/question.json | 5 + src/i18n/locales/pt/question.json | 5 + src/i18n/locales/ru/question.json | 5 + src/i18n/locales/zh/question.json | 5 + .../__tests__/backendPermission.test.ts | 207 ++++++++++++++++ src/qortal/get.ts | 72 +++--- src/qortal/qapp-backend-permission.test.ts | 124 ++++++++++ src/qortal/qapp-backend-permission.ts | 77 ++++++ src/qortal/qapp-screen-capture-request.ts | 22 ++ src/test/mocks/electron.ts | 6 +- src/types/window.d.ts | 4 + 41 files changed, 1564 insertions(+), 109 deletions(-) create mode 100644 electron/native/private-transport/internal/innerquic/session_test.go create mode 100644 electron/native/private-transport/internal/masque/client_test.go create mode 100644 electron/native/private-transport/internal/moqclient/session_test.go create mode 100644 electron/src/display-media-picker.test.ts create mode 100644 electron/src/display-media-picker.ts create mode 100644 src/components/Apps/QAppScreenCapturePermission.tsx create mode 100644 src/qortal/__tests__/backendPermission.test.ts create mode 100644 src/qortal/qapp-backend-permission.test.ts create mode 100644 src/qortal/qapp-backend-permission.ts create mode 100644 src/qortal/qapp-screen-capture-request.ts diff --git a/docs/qapp-reticulum.md b/docs/qapp-reticulum.md index 9b10890c..39a17fae 100644 --- a/docs/qapp-reticulum.md +++ b/docs/qapp-reticulum.md @@ -20,7 +20,14 @@ The host posts `RNS_MESSAGE` and `RNS_CONNECTION_STATE` events to the owning Q-App iframe. States are `CONNECTING`, `CONNECTED`, `RECONNECTING`, `DISCONNECTED`, `CLOSING`, `CLOSED`, and `ERROR`. -The first operation for a destination in a tab displays a permission prompt. +The first operation for a destination in a tab asks whether the app may connect +to its backend and exchange data. This single approval also grants +`PRIVATE_DATA_CHANNEL` for that app's tab, covering private channels and MoQ. +The dialog does not expose transport names or destination hashes. Subsequent +`SESSION_PERMISSIONS` requests for already-approved capabilities return without +another dialog; unrelated capabilities still require approval. Account access +and microphone permission remain separate. + Permission and connections are isolated by trusted tab, service, application name, and destination. Closing or reloading the tab removes its logical connections. This initial version does not consume a destination declared in @@ -117,3 +124,19 @@ Current limitations: messages and RPC bodies are bounded and are not suitable for large files. - Request cancellation and a bulk-priority scheduler are not exposed yet. - Application session/subscription resumption remains the Q-App/backend's job. +## Generic Q-App screen capture + +Q-Apps may call `navigator.mediaDevices.getDisplayMedia({video: true, audio: false})` +from a user gesture. Hub requests one-time capture permission before exposing any +screen/window previews. After approval, it sends `QAPP_SCREEN_CAPTURE_SOURCES` +directly to the requesting frame, containing a random one-use `requestId` and +`sources: [{id, name, thumbnail}]`. The Q-App renders its own source picker and +calls `qortalRequest({action: 'SCREEN_CAPTURE_SELECT', requestId, sourceId})`. +Omitting `sourceId` cancels. Completion also emits `QAPP_SCREEN_CAPTURE_CANCEL` +to dismiss any remaining picker. The original browser capture promise resolves +to a local MediaStream; streams are never passed through IPC. + +Requests expire after 60 seconds. Native code validates the requesting frame, +unchanged URL, approved source list, main-shell IPC sender, and one-use capability. +Only one pending capture request is allowed. Hub does not encode, encrypt, or +interpret this app's screen frames; application media logic stays in the Q-App. diff --git a/electron/native/private-transport/internal/innerquic/session.go b/electron/native/private-transport/internal/innerquic/session.go index 3280a1dc..813beba0 100644 --- a/electron/native/private-transport/internal/innerquic/session.go +++ b/electron/native/private-transport/internal/innerquic/session.go @@ -17,6 +17,21 @@ import ( masqueclient "qortal.org/qortal-hub/private-transport/internal/masque" ) +const ( + privateKeepAlivePeriod = 15 * time.Second + privateMaxIdleTimeout = 2 * time.Minute +) + +func privateQUICConfig() *quic.Config { + return &quic.Config{ + EnableDatagrams: true, + InitialPacketSize: InnerPacketSize, + DisablePathMTUDiscovery: true, + KeepAlivePeriod: privateKeepAlivePeriod, + MaxIdleTimeout: privateMaxIdleTimeout, + } +} + type Config struct { Relay masqueclient.Config BackendServerName string @@ -116,9 +131,13 @@ func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error if tunnel.RemoteAddr() == nil { return fail(errors.New("INNER_QUIC_FAILED: missing proxied backend address")) } - conn, err := quic.Dial(ctx, tunnel.PacketConn(), tunnel.RemoteAddr(), tlsConf, &quic.Config{ - EnableDatagrams: true, InitialPacketSize: InnerPacketSize, DisablePathMTUDiscovery: true, - }) + conn, err := quic.Dial( + ctx, + tunnel.PacketConn(), + tunnel.RemoteAddr(), + tlsConf, + privateQUICConfig(), + ) if err != nil { return fail(fmt.Errorf("INNER_QUIC_FAILED: %w", err)) } diff --git a/electron/native/private-transport/internal/innerquic/session_test.go b/electron/native/private-transport/internal/innerquic/session_test.go new file mode 100644 index 00000000..c029feda --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/session_test.go @@ -0,0 +1,11 @@ +package innerquic + +import "testing" + +func TestPrivateQUICConfigKeepsIdleChannelAlive(t *testing.T) { + config := privateQUICConfig() + if !config.EnableDatagrams || config.KeepAlivePeriod != privateKeepAlivePeriod || + config.MaxIdleTimeout != privateMaxIdleTimeout { + t.Fatalf("unexpected idle private-channel config: %#v", config) + } +} diff --git a/electron/native/private-transport/internal/masque/client.go b/electron/native/private-transport/internal/masque/client.go index b7c8017c..e02a56d1 100644 --- a/electron/native/private-transport/internal/masque/client.go +++ b/electron/native/private-transport/internal/masque/client.go @@ -20,6 +20,19 @@ import ( const MaxDatagramBytes = 1200 +const ( + tunnelKeepAlivePeriod = 15 * time.Second + tunnelMaxIdleTimeout = 2 * time.Minute +) + +func tunnelQUICConfig() *quic.Config { + return &quic.Config{ + EnableDatagrams: true, + KeepAlivePeriod: tunnelKeepAlivePeriod, + MaxIdleTimeout: tunnelMaxIdleTimeout, + } +} + type Config struct { RelayAddress string RelayServerName string @@ -103,9 +116,7 @@ func Open(ctx context.Context, cfg Config) (*Tunnel, error) { } transport := masque.Transport{ TLSClientConfig: tlsConfig, - QUICConfig: &quic.Config{ - EnableDatagrams: true, - }, + QUICConfig: tunnelQUICConfig(), // Pin the network destination as well as its certificate identity. This // prevents a library change from resolving or substituting a hostname. DialAddr: func(dialCtx context.Context, addr string, tlsConf *tls.Config, quicConf *quic.Config) (*quic.Conn, error) { diff --git a/electron/native/private-transport/internal/masque/client_test.go b/electron/native/private-transport/internal/masque/client_test.go new file mode 100644 index 00000000..913ffbd6 --- /dev/null +++ b/electron/native/private-transport/internal/masque/client_test.go @@ -0,0 +1,11 @@ +package masqueclient + +import "testing" + +func TestTunnelQUICConfigKeepsIdleCallTunnelAlive(t *testing.T) { + config := tunnelQUICConfig() + if !config.EnableDatagrams || config.KeepAlivePeriod != tunnelKeepAlivePeriod || + config.MaxIdleTimeout != tunnelMaxIdleTimeout { + t.Fatalf("unexpected idle tunnel config: %#v", config) + } +} diff --git a/electron/native/private-transport/internal/moqclient/session.go b/electron/native/private-transport/internal/moqclient/session.go index 4982179d..669db931 100644 --- a/electron/native/private-transport/internal/moqclient/session.go +++ b/electron/native/private-transport/internal/moqclient/session.go @@ -26,10 +26,22 @@ const ( MaxNamespaceComponents = 8 defaultConnectTimeout = 8 * time.Second maxSafeJSONInteger = uint64(1<<53 - 1) + mediaKeepAlivePeriod = 15 * time.Second + mediaMaxIdleTimeout = 2 * time.Minute ) var safeName = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`) +func mediaQUICConfig() *quic.Config { + return &quic.Config{ + EnableDatagrams: true, + InitialPacketSize: 1200, + DisablePathMTUDiscovery: true, + KeepAlivePeriod: mediaKeepAlivePeriod, + MaxIdleTimeout: mediaMaxIdleTimeout, + } +} + type Config struct { Relay masqueclient.Config BackendServerName string @@ -138,11 +150,7 @@ func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error tunnel.PacketConn(), tunnel.RemoteAddr(), backendTLSConfig(cfg.BackendServerName, pin), - &quic.Config{ - EnableDatagrams: true, - InitialPacketSize: 1200, - DisablePathMTUDiscovery: true, - }, + mediaQUICConfig(), ) if err != nil { return fail(fmt.Errorf("MOQ_QUIC_FAILED: %w", err)) diff --git a/electron/native/private-transport/internal/moqclient/session_test.go b/electron/native/private-transport/internal/moqclient/session_test.go new file mode 100644 index 00000000..ad2f6fb1 --- /dev/null +++ b/electron/native/private-transport/internal/moqclient/session_test.go @@ -0,0 +1,11 @@ +package moqclient + +import "testing" + +func TestMediaQUICConfigKeepsSilentCallAlive(t *testing.T) { + config := mediaQUICConfig() + if !config.EnableDatagrams || config.KeepAlivePeriod != mediaKeepAlivePeriod || + config.MaxIdleTimeout != mediaMaxIdleTimeout { + t.Fatalf("unexpected idle media config: %#v", config) + } +} diff --git a/electron/src/display-media-picker.test.ts b/electron/src/display-media-picker.test.ts new file mode 100644 index 00000000..906829c1 --- /dev/null +++ b/electron/src/display-media-picker.test.ts @@ -0,0 +1,129 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'events'; +import { desktopCapturer, ipcMain } from 'electron'; +import { installDisplayMediaPicker } from './display-media-picker'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); +function fixture() { + const bus = new EventEmitter(); + vi.spyOn(ipcMain, 'on').mockImplementation(((...args: any[]) => + bus.on(args[0], args[1])) as any); + vi.spyOn(ipcMain, 'removeListener').mockImplementation(((...args: any[]) => + bus.removeListener(args[0], args[1])) as any); + const getSources = vi + .spyOn(desktopCapturer, 'getSources') + .mockResolvedValue([ + { + id: 'screen:1', + name: 'Screen', + thumbnail: { toDataURL: () => 'data:image/png;base64,AA==' }, + }, + ] as any); + let handler: any; + const root = {}; + const frame = { + detached: false, + top: root, + url: 'https://app.test', + executeJavaScript: vi.fn().mockResolvedValue(undefined), + }; + const webContents = { + mainFrame: root, + send: vi.fn(), + session: { + setDisplayMediaRequestHandler: (fn: any) => { + handler = fn; + }, + }, + }; + const window = { webContents, isDestroyed: () => false, once: vi.fn() }; + installDisplayMediaPicker(window as any); + const callback = vi.fn(); + const request = { + frame, + userGesture: true, + videoRequested: true, + securityOrigin: 'https://app.test', + }; + handler(request, callback); + const requestId = webContents.send.mock.calls[0][1].requestId; + const send = (channel: string, payload: object, trusted = true) => + bus.emit( + channel, + { sender: trusted ? webContents : {}, senderFrame: root }, + { requestId, ...payload } + ); + return { + frame, + callback, + send, + getSources, + handler, + request, + webContents, + bus, + }; +} + +it('does not enumerate before approval and grants only the selected one-use source', async () => { + const f = fixture(); + expect(f.getSources).not.toHaveBeenCalled(); + f.send('display-media:select', { sourceId: 'screen:1' }); + f.send('display-media:authorize', { accepted: true }, false); + expect(f.callback).not.toHaveBeenCalled(); + expect(f.getSources).not.toHaveBeenCalled(); + f.send('display-media:authorize', { accepted: true }); + await vi.waitFor(() => expect(f.frame.executeJavaScript).toHaveBeenCalled()); + expect(f.frame.executeJavaScript.mock.calls[0][0]).toContain( + 'QAPP_SCREEN_CAPTURE_SOURCES' + ); + f.send('display-media:select', { sourceId: 'screen:1', requestId: 'wrong' }); + expect(f.callback).not.toHaveBeenCalled(); + f.send('display-media:select', { sourceId: 'screen:1' }); + expect(f.callback).toHaveBeenCalledWith({ + video: expect.objectContaining({ id: 'screen:1' }), + }); + f.send('display-media:select', { sourceId: 'screen:1' }); + expect(f.callback).toHaveBeenCalledTimes(1); + expect(f.bus.listenerCount('display-media:authorize')).toBe(0); +}); + +it('denies rejection without disclosing thumbnails', () => { + const f = fixture(); + f.send('display-media:authorize', { accepted: false }); + expect(f.callback).toHaveBeenCalledWith({}); + expect(f.getSources).not.toHaveBeenCalled(); +}); + +it.each(['navigation', 'detached', 'unknown-source'])( + 'denies %s after approval', + async (failure) => { + const f = fixture(); + f.send('display-media:authorize', { accepted: true }); + await vi.waitFor(() => + expect(f.frame.executeJavaScript).toHaveBeenCalled() + ); + if (failure === 'navigation') f.frame.url = 'https://elsewhere.test'; + if (failure === 'detached') f.frame.detached = true; + f.send('display-media:select', { + sourceId: failure === 'unknown-source' ? 'window:99' : 'screen:1', + }); + expect(f.callback).toHaveBeenCalledWith({}); + } +); + +it('times out and denies concurrent requests and missing gestures', () => { + vi.useFakeTimers(); + const f = fixture(); + const second = vi.fn(); + f.handler(f.request, second); + expect(second).toHaveBeenCalledWith({}); + vi.advanceTimersByTime(60_000); + expect(f.callback).toHaveBeenCalledWith({}); + const third = vi.fn(); + f.handler({ ...f.request, userGesture: false }, third); + expect(third).toHaveBeenCalledWith({}); +}); diff --git a/electron/src/display-media-picker.ts b/electron/src/display-media-picker.ts new file mode 100644 index 00000000..04579f88 --- /dev/null +++ b/electron/src/display-media-picker.ts @@ -0,0 +1,137 @@ +import { + desktopCapturer, + ipcMain, + type BrowserWindow, + type DesktopCapturerSource, +} from 'electron'; +import { randomUUID } from 'crypto'; + +/** Permission-controlled OS bridge. Source selection UI lives in the requesting app. */ +export function installDisplayMediaPicker(window: BrowserWindow): void { + let cancelPending: (() => void) | null = null; + window.webContents.session.setDisplayMediaRequestHandler( + (request, callback) => { + const frame = request.frame; + if ( + !frame || + frame.detached || + !request.userGesture || + !request.videoRequested || + frame.top !== window.webContents.mainFrame || + window.isDestroyed() + ) { + callback({}); + return; + } + // One chooser at a time; a new request cannot replace another app's prompt. + if (cancelPending) { + callback({}); + return; + } + const requestId = randomUUID(); + const originalUrl = frame.url; + let sources: DesktopCapturerSource[] = []; + let settled = false; + let authorized = false; + const deliver = (payload: unknown) => + frame + .executeJavaScript( + `window.postMessage(${JSON.stringify(payload)}, window.location.origin);` + ) + .catch(() => undefined); + const finish = (sourceId?: string) => { + if (settled) return; + settled = true; + clearTimeout(timer); + ipcMain.removeListener('display-media:select', selected); + ipcMain.removeListener('display-media:authorize', authorize); + cancelPending = null; + const source = sources.find((value) => value.id === sourceId); + const valid = + !window.isDestroyed() && + !frame.detached && + frame.url === originalUrl && + frame.top === window.webContents.mainFrame; + callback(valid && source ? { video: source } : {}); + if (!frame.detached) + void deliver({ action: 'QAPP_SCREEN_CAPTURE_CANCEL', requestId }); + if (!window.isDestroyed()) + window.webContents.send('display-media:cancel', requestId); + }; + const selected = ( + event: Electron.IpcMainEvent, + payload: { requestId?: string; sourceId?: string } + ) => { + if ( + event.sender !== window.webContents || + event.senderFrame !== window.webContents.mainFrame || + payload?.requestId !== requestId + ) + return; + if (!authorized) return; + finish(payload.sourceId); + }; + const authorize = ( + event: Electron.IpcMainEvent, + payload: { requestId?: string; accepted?: boolean } + ) => { + if ( + event.sender !== window.webContents || + event.senderFrame !== window.webContents.mainFrame || + payload?.requestId !== requestId || + authorized + ) + return; + if (payload.accepted !== true) { + finish(); + return; + } + authorized = true; + void enumerate(); + }; + const timer = setTimeout(() => finish(), 60_000); + cancelPending = () => finish(); + ipcMain.on('display-media:select', selected); + ipcMain.on('display-media:authorize', authorize); + const enumerate = () => + desktopCapturer + .getSources({ + types: ['screen', 'window'], + thumbnailSize: { width: 240, height: 135 }, + }) + .then((available) => { + if (settled) return; + if ( + window.isDestroyed() || + frame.detached || + frame.url !== originalUrl + ) { + finish(); + return; + } + sources = available.slice(0, 60); + if (!sources.length) { + finish(); + return; + } + return deliver({ + action: 'QAPP_SCREEN_CAPTURE_SOURCES', + requestId, + sources: sources.map((source) => ({ + id: source.id, + name: source.name.slice(0, 160), + thumbnail: source.thumbnail.toDataURL(), + })), + }); + }) + .catch(() => finish()); + // The random, one-use request ID is disclosed only to the requesting frame + // after permission. Other Q-Apps cannot select a source for this request. + window.webContents.send('display-media:request', { + requestId, + origin: request.securityOrigin, + }); + } + ); + window.once('closed', () => cancelPending?.()); +} diff --git a/electron/src/masque-relay-discovery.test.ts b/electron/src/masque-relay-discovery.test.ts index e0f9ace5..afb34b36 100644 --- a/electron/src/masque-relay-discovery.test.ts +++ b/electron/src/masque-relay-discovery.test.ts @@ -66,6 +66,21 @@ describe('community MASQUE relay discovery', () => { }); }); + it('excludes a failed endpoint when selecting a replacement relay', async () => { + const bridge = new BridgeStub(); + bridge.getCommunityMasqueRelays.mockResolvedValueOnce([ + relay, + { ...relay, host: '1.1.1.1', certSha256: 'cd'.repeat(32) }, + ]); + await expect( + discoverCommunityMasqueRelay(bridge as never, { + now: () => 1_000_000, + random: () => 0, + excludeRelayAddresses: new Set(['8.8.8.8:47322']), + }) + ).resolves.toMatchObject({ relayAddress: '1.1.1.1:47322' }); + }); + it('uses one fresh query and only polls the cache while waiting', async () => { const bridge = new BridgeStub(); await expect( diff --git a/electron/src/masque-relay-discovery.ts b/electron/src/masque-relay-discovery.ts index 140ace4d..55941da9 100644 --- a/electron/src/masque-relay-discovery.ts +++ b/electron/src/masque-relay-discovery.ts @@ -18,6 +18,8 @@ export type CommunityMasqueRelay = { export type MasqueRelayDiscoveryOptions = { timeoutMs?: number; allowLoopback?: boolean; + /** Relay endpoints already known to have failed for this logical session. */ + excludeRelayAddresses?: ReadonlySet; now?: () => number; random?: () => number; }; @@ -94,6 +96,8 @@ export async function discoverCommunityMasqueRelay( const accept = (value: unknown) => { const relay = validateCommunityMasqueRelay(value, options); if (!relay) return; + const host = net.isIP(relay.host) === 6 ? `[${relay.host}]` : relay.host; + if (options.excludeRelayAddresses?.has(`${host}:${relay.port}`)) return; candidates.set(`${relay.host}:${relay.port}:${relay.certSha256}`, relay); wake?.(); }; @@ -119,10 +123,13 @@ export async function discoverCommunityMasqueRelay( const remainingMs = deadline - Date.now(); if (remainingMs <= 0) break; await new Promise((resolve) => { - const timer = setTimeout(() => { - wake = null; - resolve(); - }, Math.min(MASQUE_RELAY_QUERY_INTERVAL_MS, remainingMs)); + const timer = setTimeout( + () => { + wake = null; + resolve(); + }, + Math.min(MASQUE_RELAY_QUERY_INTERVAL_MS, remainingMs) + ); timer.unref?.(); wake = () => { clearTimeout(timer); diff --git a/electron/src/moq-masque-transport.test.ts b/electron/src/moq-masque-transport.test.ts index 33faef0c..f280f8c3 100644 --- a/electron/src/moq-masque-transport.test.ts +++ b/electron/src/moq-masque-transport.test.ts @@ -30,8 +30,9 @@ const descriptor: PrivateBootstrapDescriptor = { }; class FakeSidecar extends EventEmitter { + private opened = 0; openMoqSession = vi.fn(async (config) => ({ - moqSessionId: 'moq-session', + moqSessionId: `moq-session${++this.opened === 1 ? '' : `-${this.opened}`}`, logicalSessionId: config.logicalSessionId, publicationNamespace: config.publicationNamespace, publicationTrack: config.publicationTrack, @@ -168,4 +169,63 @@ describe('generic trusted MOQT transport', () => { }); expect(sidecar.openMoqSession).toHaveBeenCalledTimes(1); }); + + it('moves to another relay with a fresh token and restores subscriptions', async () => { + const sidecar = new FakeSidecar(); + let bootstrapNumber = 0; + const provider: PrivateChannelBootstrapProvider = { + getBootstrap: vi.fn(async () => ({ + ...descriptor, + logicalSessionId: `logical-${++bootstrapNumber}`, + attachToken: `one-time-token-${bootstrapNumber}`, + })), + }; + const relayProvider = vi.fn(async (excluded?: ReadonlySet) => ({ + relayAddress: excluded?.has('8.8.8.8:47322') + ? '1.1.1.1:47322' + : '8.8.8.8:47322', + relayServerName: 'relay.test', + relayCertSha256: '12'.repeat(32), + })); + const events: MoqTransportEvent[] = []; + const transport = new MoqMasqueTransport( + (event) => events.push(event), + sidecar as never, + relayProvider, + provider + ); + await transport.open(context); + await transport.subscribe( + 'subscription-1', + ['qortal', 'apps', 'sample', 'peer-456'], + 'events' + ); + + sidecar.emit('event', { + event: 'error', + sessionId: 'moq-session', + code: 'MOQ_READ_FAILED', + subscriptionId: 'subscription-1', + data: Buffer.alloc(0), + }); + await transport.publish(new Uint8Array([9])); + + expect( + sidecar.openMoqSession.mock.calls.map(([config]) => config.relayAddress) + ).toEqual(['8.8.8.8:47322', '1.1.1.1:47322']); + expect( + sidecar.openMoqSession.mock.calls.map(([config]) => config.attachToken) + ).toEqual(['one-time-token-1', 'one-time-token-2']); + expect(sidecar.subscribeMoqTrack).toHaveBeenLastCalledWith( + 'moq-session-2', + 'subscription-1', + ['qortal', 'apps', 'sample', 'peer-456'], + 'events' + ); + expect(sidecar.publishMoqObject).toHaveBeenCalledWith( + 'moq-session-2', + new Uint8Array([9]) + ); + expect(events).toEqual([]); + }); }); diff --git a/electron/src/moq-masque-transport.ts b/electron/src/moq-masque-transport.ts index aba66a05..ca781e79 100644 --- a/electron/src/moq-masque-transport.ts +++ b/electron/src/moq-masque-transport.ts @@ -13,6 +13,8 @@ import type { import type { QAppReticulumOwner } from './qapp-reticulum-manager'; const MOQ_NAME = /^[A-Za-z0-9._-]{1,128}$/; +const RECOVERY_DELAYS_MS = [0, 500, 2_000] as const; +const FAILED_RELAY_COOLDOWN_MS = 60_000; function validNamespace(namespace: readonly string[]): boolean { return ( @@ -48,12 +50,21 @@ export type MoqTransportEvent = */ export class MoqMasqueTransport { private moqSessionId: string | null = null; + private context: MoqOpenContext | null = null; + private activeRelayAddress: string | null = null; + private lastAttemptedRelayAddress: string | null = null; + private recoveryPromise: Promise | null = null; + private readonly failedRelayAddresses = new Map(); + private readonly subscriptions = new Map< + string, + { namespace: readonly string[]; trackName: string } + >(); private closed = false; private readonly onSidecarEvent = (event: PrivateTransportSidecarEvent) => this.handleSidecarEvent(event); private readonly onSidecarDeath = () => { if (!this.closed && this.moqSessionId) { - this.emit({ kind: 'error', code: 'MOQ_TRANSPORT_CLOSED' }); + void this.recover('MOQ_TRANSPORT_CLOSED'); } }; @@ -77,6 +88,12 @@ export class MoqMasqueTransport { ) { throw new PrivateTransportSidecarError('INVALID_MOQ_CONFIG'); } + this.context = context; + await this.connect(context); + } + + private async connect(context: MoqOpenContext): Promise { + this.lastAttemptedRelayAddress = null; const relay = await this.resolveRelay(); const bootstrapContext: PrivateTransportContext = { channelId: 'trusted-moq-transport', @@ -93,6 +110,7 @@ export class MoqMasqueTransport { ) { throw new PrivateTransportSidecarError('UNSUPPORTED_MOQ_TRANSPORT'); } + this.lastAttemptedRelayAddress = relay.relayAddress; const openAt = (relayAddress: string) => this.sidecar.openMoqSession({ relayAddress, @@ -120,7 +138,31 @@ export class MoqMasqueTransport { } opened = await openAt(relay.localFallbackAddress); } + if (this.closed) { + await this.sidecar + .closeMoqSession(opened.moqSessionId) + .catch(() => undefined); + throw new PrivateTransportSidecarError('MOQ_SESSION_CLOSED'); + } this.moqSessionId = opened.moqSessionId; + this.activeRelayAddress = relay.relayAddress; + try { + for (const [subscriptionId, subscription] of this.subscriptions) { + await this.sidecar.subscribeMoqTrack( + opened.moqSessionId, + subscriptionId, + subscription.namespace, + subscription.trackName + ); + } + } catch (error) { + this.moqSessionId = null; + this.activeRelayAddress = null; + await this.sidecar + .closeMoqSession(opened.moqSessionId) + .catch(() => undefined); + throw error; + } } async subscribe( @@ -135,28 +177,67 @@ export class MoqMasqueTransport { ) { throw new PrivateTransportSidecarError('INVALID_MOQ_SUBSCRIPTION'); } - await this.sidecar.subscribeMoqTrack( - this.requireSession(), - subscriptionId, - namespace, - trackName - ); + const subscription = { namespace: [...namespace], trackName }; + this.subscriptions.set(subscriptionId, subscription); + try { + const sessionId = await this.requireSession(); + await this.sidecar.subscribeMoqTrack( + sessionId, + subscriptionId, + namespace, + trackName + ); + } catch (error) { + if (isRecoverableMoqError(error)) { + try { + // Recovery restores the subscription recorded above. + await this.recover(errorCode(error)); + return; + } catch (recoveryError) { + this.subscriptions.delete(subscriptionId); + throw recoveryError; + } + } + this.subscriptions.delete(subscriptionId); + throw error; + } } async publish(payload: Uint8Array): Promise { if (payload.byteLength < 1 || payload.byteLength > MAX_MOQ_OBJECT_BYTES) { throw new PrivateTransportSidecarError('MOQ_OBJECT_TOO_LARGE'); } - await this.sidecar.publishMoqObject(this.requireSession(), payload); + try { + await this.sidecar.publishMoqObject(await this.requireSession(), payload); + } catch (error) { + if (isRecoverableMoqError(error)) { + // A media object is stale by the time a replacement path opens. Drop + // this object after recovery instead of creating an audible late frame. + await this.recover(errorCode(error)); + return; + } + throw error; + } } async metrics(): Promise> { - return this.sidecar.moqSessionMetrics(this.requireSession()); + try { + return await this.sidecar.moqSessionMetrics(await this.requireSession()); + } catch (error) { + if (isRecoverableMoqError(error)) { + const replacementSessionId = await this.recover(errorCode(error)); + return this.sidecar.moqSessionMetrics(replacementSessionId); + } + throw error; + } } async close(): Promise { if (this.closed) return; this.closed = true; + this.context = null; + this.recoveryPromise = null; + this.subscriptions.clear(); const moqSessionId = this.moqSessionId; this.moqSessionId = null; this.sidecar.off('event', this.onSidecarEvent); @@ -167,10 +248,17 @@ export class MoqMasqueTransport { } private async resolveRelay(): Promise { - return typeof this.relay === 'function' ? this.relay() : this.relay; + const now = Date.now(); + for (const [address, failedAt] of this.failedRelayAddresses) { + if (now - failedAt >= FAILED_RELAY_COOLDOWN_MS) + this.failedRelayAddresses.delete(address); + } + const excluded = new Set(this.failedRelayAddresses.keys()); + return typeof this.relay === 'function' ? this.relay(excluded) : this.relay; } - private requireSession(): string { + private async requireSession(): Promise { + if (this.recoveryPromise) await this.recoveryPromise; if (this.closed || !this.moqSessionId) { throw new PrivateTransportSidecarError('MOQ_SESSION_CLOSED'); } @@ -180,6 +268,10 @@ export class MoqMasqueTransport { private handleSidecarEvent(event: PrivateTransportSidecarEvent): void { if (event.sessionId !== this.moqSessionId || this.closed) return; if (event.event === 'error') { + if (isRecoverableMoqCode(event.code)) { + void this.recover(event.code ?? 'MOQ_TRANSPORT_CLOSED'); + return; + } this.emit({ kind: 'error', code: event.code ?? 'MOQ_TRANSPORT_ERROR', @@ -214,4 +306,93 @@ export class MoqMasqueTransport { payload: new Uint8Array(event.data), }); } + + private recover(_reason: string): Promise { + if (this.closed) + return Promise.reject( + new PrivateTransportSidecarError('MOQ_SESSION_CLOSED') + ); + if (this.recoveryPromise) return this.recoveryPromise; + const context = this.context; + if (!context) + return Promise.reject( + new PrivateTransportSidecarError('MOQ_SESSION_CLOSED') + ); + if (this.activeRelayAddress) + this.failedRelayAddresses.set(this.activeRelayAddress, Date.now()); + const oldSessionId = this.moqSessionId; + this.moqSessionId = null; + this.activeRelayAddress = null; + if (oldSessionId) + void this.sidecar.closeMoqSession(oldSessionId).catch(() => undefined); + const recovery = (async (): Promise => { + let lastError: unknown = new PrivateTransportSidecarError( + 'MOQ_TRANSPORT_CLOSED' + ); + for (const delayMs of RECOVERY_DELAYS_MS) { + if (delayMs) await delay(delayMs); + if (this.closed) + throw new PrivateTransportSidecarError('MOQ_SESSION_CLOSED'); + try { + await this.connect(context); + if (!this.moqSessionId) + throw new PrivateTransportSidecarError('MOQ_SESSION_CLOSED'); + return this.moqSessionId; + } catch (error) { + lastError = error; + if (this.lastAttemptedRelayAddress) + this.failedRelayAddresses.set( + this.lastAttemptedRelayAddress, + Date.now() + ); + this.moqSessionId = null; + this.activeRelayAddress = null; + if (!isRecoverableMoqError(error)) break; + } + } + throw lastError; + })(); + this.recoveryPromise = recovery; + void recovery + .catch((error) => { + if (!this.closed) this.emit({ kind: 'error', code: errorCode(error) }); + }) + .finally(() => { + if (this.recoveryPromise === recovery) this.recoveryPromise = null; + }); + return recovery; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function errorCode(error: unknown): string { + return error instanceof PrivateTransportSidecarError + ? error.code + : error instanceof Error && /^[A-Z0-9_]{3,64}$/.test(error.message) + ? error.message + : 'MOQ_TRANSPORT_ERROR'; +} + +function isRecoverableMoqCode(code: string | undefined): boolean { + return ( + code === 'MOQ_TRANSPORT_CLOSED' || + code === 'MOQ_QUIC_FAILED' || + code === 'MOQ_SESSION_CLOSED' || + code === 'MOQ_READ_FAILED' || + code === 'MOQ_SEND_FAILED' || + code === 'MASQUE_TUNNEL_FAILED' || + code === 'MASQUE_RELAY_UNAVAILABLE' || + code === 'SIDECAR_EXITED' || + code === 'SIDECAR_NOT_RUNNING' || + code === 'SIDECAR_REQUEST_TIMEOUT' || + code === 'SIDECAR_WRITE_FAILED' || + code === 'BOOTSTRAP_FAILED' + ); +} + +function isRecoverableMoqError(error: unknown): boolean { + return isRecoverableMoqCode(errorCode(error)); } diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 9d885046..b446581f 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -489,6 +489,18 @@ try { return () => ipcRenderer.removeListener('system:lock-requested', handler); }, getPlatform: () => ipcRenderer.invoke('window:getPlatform'), + onDisplayMediaRequest: (callback: (request: { requestId: string; origin: string }) => void) => { + const handler = (_event, request) => callback(request); + ipcRenderer.on('display-media:request', handler); + return () => ipcRenderer.removeListener('display-media:request', handler); + }, + onDisplayMediaCancel: (callback: (requestId: string) => void) => { + const handler = (_event, requestId) => callback(requestId); + ipcRenderer.on('display-media:cancel', handler); + return () => ipcRenderer.removeListener('display-media:cancel', handler); + }, + selectDisplayMedia: (requestId: string, sourceId?: string) => ipcRenderer.send('display-media:select', { requestId, sourceId }), + authorizeDisplayMedia: (requestId: string, accepted: boolean) => ipcRenderer.send('display-media:authorize', { requestId, accepted }), listScreenShareSources: () => ipcRenderer.invoke('screenShare:listSources') as Promise<{ success: boolean; diff --git a/electron/src/private-transport-runtime.ts b/electron/src/private-transport-runtime.ts index ab7c90b4..a8dcc317 100644 --- a/electron/src/private-transport-runtime.ts +++ b/electron/src/private-transport-runtime.ts @@ -38,12 +38,13 @@ export function getPrivateTransportFactory( emit, prototypeSidecar!, explicitRelay ?? - (async () => { + (async (excludedRelayAddresses) => { const reticulumBridge = reticulumBridgeProvider(); if (!reticulumBridge) throw new Error('MASQUE_RELAY_UNAVAILABLE'); return discoverCommunityMasqueRelay(reticulumBridge, { allowLoopback: environment.QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY === '1', + excludeRelayAddresses: excludedRelayAddresses, }); }), bootstrapProvider @@ -69,12 +70,13 @@ export function createMoqTransport( emit, prototypeSidecar, explicitRelay ?? - (async () => { + (async (excludedRelayAddresses) => { const reticulumBridge = reticulumBridgeProvider(); if (!reticulumBridge) throw new Error('MASQUE_RELAY_UNAVAILABLE'); return discoverCommunityMasqueRelay(reticulumBridge, { allowLoopback: environment.QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY === '1', + excludeRelayAddresses: excludedRelayAddresses, }); }), new ReticulumPrivateChannelBootstrapProvider(reticulumManager) diff --git a/electron/src/quic-masque-transport.test.ts b/electron/src/quic-masque-transport.test.ts index 3e388234..91d0fc9c 100644 --- a/electron/src/quic-masque-transport.test.ts +++ b/electron/src/quic-masque-transport.test.ts @@ -19,6 +19,18 @@ class SidecarStub extends EventEmitter { } } +class RecoveringSidecarStub extends EventEmitter { + attempts: PrivateSessionConfig[] = []; + closePrivateSession = vi.fn(async () => undefined); + sendPrivateReliable = vi.fn(async () => undefined); + sendPrivateDatagram = vi.fn(async () => undefined); + + async openPrivateSession(config: PrivateSessionConfig) { + this.attempts.push(config); + return { sessionId: `native-${this.attempts.length}` }; + } +} + describe('QUIC MASQUE transport', () => { it('falls back to the pinned same-host relay when public NAT hairpinning fails', async () => { const sidecar = new SidecarStub(); @@ -58,4 +70,70 @@ describe('QUIC MASQUE transport', () => { '127.0.0.1:47322', ]); }); + + it('re-discovers another relay with fresh credentials without replacing the logical channel', async () => { + const sidecar = new RecoveringSidecarStub(); + let bootstrapNumber = 0; + const bootstrapProvider: PrivateChannelBootstrapProvider = { + getBootstrap: vi.fn(async () => ({ + backendRnsDestination: 'ab'.repeat(16), + backendTransportEndpoint: '127.0.0.1:4445', + backendTransportServerName: 'backend.test', + backendTransportCertSha256: 'cd'.repeat(32), + logicalSessionId: `logical-${++bootstrapNumber}`, + attachToken: `attach-token-${bootstrapNumber}`, + nonce: `nonce-${bootstrapNumber}`, + ownerBindingHash: 'ef'.repeat(32), + expiresAt: Date.now() + 10_000, + })), + }; + const relayProvider = vi.fn(async (excluded?: ReadonlySet) => ({ + relayAddress: excluded?.has('8.8.8.8:47322') + ? '1.1.1.1:47322' + : '8.8.8.8:47322', + relayServerName: 'relay.test', + relayCertSha256: '12'.repeat(32), + })); + const events = vi.fn(); + const transport = new QuicMasqueTransport( + events, + sidecar as never, + relayProvider, + bootstrapProvider + ); + await transport.open({ + channelId: 'private-logical', + owner: { tabId: 'tab', name: 'qapp-ui-call', service: 'APP' }, + rnsConnectionId: 'rns-connection', + purpose: 'realtime', + generation: 1, + }); + + sidecar.emit('event', { + event: 'error', + sessionId: 'native-1', + code: 'INNER_QUIC_FAILED', + data: Buffer.alloc(0), + }); + await transport.sendReliable({ + lane: 'reliable', + messageId: 'after-recovery', + data: { ok: true }, + }); + + expect(sidecar.attempts.map((attempt) => attempt.relayAddress)).toEqual([ + '8.8.8.8:47322', + '1.1.1.1:47322', + ]); + expect(sidecar.attempts.map((attempt) => attempt.attachToken)).toEqual([ + 'attach-token-1', + 'attach-token-2', + ]); + expect(sidecar.sendPrivateReliable).toHaveBeenCalledWith( + 'native-2', + 'after-recovery', + expect.any(Buffer) + ); + expect(events).not.toHaveBeenCalled(); + }); }); diff --git a/electron/src/quic-masque-transport.ts b/electron/src/quic-masque-transport.ts index f20ea540..009fb835 100644 --- a/electron/src/quic-masque-transport.ts +++ b/electron/src/quic-masque-transport.ts @@ -19,19 +19,29 @@ export type TrustedRelayConfig = Readonly<{ /** Same-host fallback, protected by the advertised certificate pin. */ localFallbackAddress?: string; }>; -export type TrustedRelayProvider = () => Promise; +export type TrustedRelayProvider = ( + excludedRelayAddresses?: ReadonlySet +) => Promise; + +const RECOVERY_DELAYS_MS = [0, 500, 2_000] as const; +const FAILED_RELAY_COOLDOWN_MS = 60_000; const MAX_RELIABLE_BINARY_BYTES = 64 * 1024; const MAX_DATAGRAM_BINARY_BYTES = 1024; export class QuicMasqueTransport implements PrivateTransport { private sessionId: string | null = null; + private context: PrivateTransportContext | null = null; + private activeRelayAddress: string | null = null; + private lastAttemptedRelayAddress: string | null = null; + private recoveryPromise: Promise | null = null; + private readonly failedRelayAddresses = new Map(); private closed = false; private readonly onSidecarEvent = (event: PrivateTransportSidecarEvent) => this.handleSidecarEvent(event); private readonly onSidecarDeath = () => { if (!this.closed && this.sessionId) { - this.emit({ kind: 'error', code: 'TRANSPORT_CLOSED' }); + void this.recover('TRANSPORT_CLOSED'); } }; @@ -47,80 +57,75 @@ export class QuicMasqueTransport implements PrivateTransport { async open(context: PrivateTransportContext): Promise { if (this.closed) throw new PrivateChannelError('TRANSPORT_CLOSED'); - const bootstrap = await this.bootstrapProvider.getBootstrap(context); + this.context = context; try { - const relay = - typeof this.relay === 'function' ? await this.relay() : this.relay; - const openAt = (relayAddress: string) => - this.sidecar.openPrivateSession({ - relayAddress, - relayServerName: relay.relayServerName, - relayCertSha256: relay.relayCertSha256, - backendAddress: bootstrap.backendTransportEndpoint, - backendServerName: bootstrap.backendTransportServerName, - backendCertSha256: bootstrap.backendTransportCertSha256, - logicalSessionId: bootstrap.logicalSessionId, - attachToken: bootstrap.attachToken, - nonce: bootstrap.nonce, - purpose: context.purpose, - ownerBindingHash: bootstrap.ownerBindingHash, - }); - let opened; - try { - opened = await openAt(relay.relayAddress); - } catch (error) { - if ( - !(error instanceof PrivateTransportSidecarError) || - error.code !== 'MASQUE_TUNNEL_FAILED' || - !relay.localFallbackAddress || - relay.localFallbackAddress === relay.relayAddress - ) { - throw error; - } - opened = await openAt(relay.localFallbackAddress); - } - this.sessionId = opened.sessionId; + await this.connect(context); } catch (error) { throw translateSidecarError(error); } } async sendReliable(message: PrivateTransportMessage): Promise { - const sessionId = this.requireSession(); const encoded = encodeApplicationData(message.data); if (encoded.length > MAX_RELIABLE_BINARY_BYTES) { throw new PrivateChannelError('MESSAGE_TOO_LARGE_FOR_TRANSPORT'); } try { + const sessionId = await this.requireSession(); await this.sidecar.sendPrivateReliable( sessionId, message.messageId, encoded ); } catch (error) { + if (isRecoverableTransportError(error)) { + try { + const replacementSessionId = await this.recover(errorCode(error)); + await this.sidecar.sendPrivateReliable( + replacementSessionId, + message.messageId, + encoded + ); + return; + } catch (recoveryError) { + throw translateSidecarError(recoveryError); + } + } throw translateSidecarError(error); } } async sendDatagram(message: PrivateTransportMessage): Promise { - const sessionId = this.requireSession(); const encoded = encodeApplicationData(message.data); if (encoded.length > MAX_DATAGRAM_BINARY_BYTES) { throw new PrivateChannelError('MESSAGE_TOO_LARGE_FOR_TRANSPORT'); } try { + const sessionId = await this.requireSession(); await this.sidecar.sendPrivateDatagram( sessionId, message.messageId, encoded ); } catch (error) { + if (isRecoverableTransportError(error)) { + try { + // Datagram delivery is intentionally best-effort. Recover the path, + // but do not replay a stale real-time packet on the replacement. + await this.recover(errorCode(error)); + return; + } catch (recoveryError) { + throw translateSidecarError(recoveryError); + } + } throw translateSidecarError(error); } } async close(): Promise { this.closed = true; + this.context = null; + this.recoveryPromise = null; const sessionId = this.sessionId; this.sessionId = null; this.sidecar.off('event', this.onSidecarEvent); @@ -129,7 +134,8 @@ export class QuicMasqueTransport implements PrivateTransport { await this.sidecar.closePrivateSession(sessionId).catch(() => undefined); } - private requireSession(): string { + private async requireSession(): Promise { + if (this.recoveryPromise) await this.recoveryPromise; if (this.closed || !this.sessionId) throw new PrivateChannelError('TRANSPORT_CLOSED'); return this.sessionId; @@ -138,6 +144,10 @@ export class QuicMasqueTransport implements PrivateTransport { private handleSidecarEvent(event: PrivateTransportSidecarEvent): void { if (event.sessionId !== this.sessionId || this.closed) return; if (event.event === 'error') { + if (isRecoverableCode(event.code)) { + void this.recover(event.code ?? 'TRANSPORT_CLOSED'); + return; + } this.emit({ kind: 'error', code: event.code ?? 'TRANSPORT_ERROR' }); return; } @@ -156,6 +166,143 @@ export class QuicMasqueTransport implements PrivateTransport { this.emit({ kind: 'error', code: 'PROTOCOL_MISMATCH' }); } } + + private async connect(context: PrivateTransportContext): Promise { + this.lastAttemptedRelayAddress = null; + const relay = await this.resolveRelay(); + const bootstrap = await this.bootstrapProvider.getBootstrap(context); + const openAt = (relayAddress: string) => + this.sidecar.openPrivateSession({ + relayAddress, + relayServerName: relay.relayServerName, + relayCertSha256: relay.relayCertSha256, + backendAddress: bootstrap.backendTransportEndpoint, + backendServerName: bootstrap.backendTransportServerName, + backendCertSha256: bootstrap.backendTransportCertSha256, + logicalSessionId: bootstrap.logicalSessionId, + attachToken: bootstrap.attachToken, + nonce: bootstrap.nonce, + purpose: context.purpose, + ownerBindingHash: bootstrap.ownerBindingHash, + }); + let opened; + this.lastAttemptedRelayAddress = relay.relayAddress; + try { + opened = await openAt(relay.relayAddress); + } catch (error) { + if ( + !(error instanceof PrivateTransportSidecarError) || + error.code !== 'MASQUE_TUNNEL_FAILED' || + !relay.localFallbackAddress || + relay.localFallbackAddress === relay.relayAddress + ) + throw error; + opened = await openAt(relay.localFallbackAddress); + } + if (this.closed) { + await this.sidecar + .closePrivateSession(opened.sessionId) + .catch(() => undefined); + throw new PrivateChannelError('TRANSPORT_CLOSED'); + } + this.sessionId = opened.sessionId; + this.activeRelayAddress = relay.relayAddress; + } + + private resolveRelay(): Promise { + const now = Date.now(); + for (const [address, failedAt] of this.failedRelayAddresses) { + if (now - failedAt >= FAILED_RELAY_COOLDOWN_MS) + this.failedRelayAddresses.delete(address); + } + const excluded = new Set(this.failedRelayAddresses.keys()); + return typeof this.relay === 'function' + ? this.relay(excluded) + : Promise.resolve(this.relay); + } + + private recover(_reason: string): Promise { + if (this.closed) + return Promise.reject(new PrivateChannelError('TRANSPORT_CLOSED')); + if (this.recoveryPromise) return this.recoveryPromise; + const context = this.context; + if (!context) + return Promise.reject(new PrivateChannelError('TRANSPORT_CLOSED')); + if (this.activeRelayAddress) + this.failedRelayAddresses.set(this.activeRelayAddress, Date.now()); + const oldSessionId = this.sessionId; + this.sessionId = null; + this.activeRelayAddress = null; + if (oldSessionId) + void this.sidecar + .closePrivateSession(oldSessionId) + .catch(() => undefined); + const recovery = (async (): Promise => { + let lastError: unknown = new PrivateChannelError('TRANSPORT_CLOSED'); + for (const delayMs of RECOVERY_DELAYS_MS) { + if (delayMs) await delay(delayMs); + if (this.closed) throw new PrivateChannelError('TRANSPORT_CLOSED'); + try { + await this.connect(context); + if (!this.sessionId) + throw new PrivateChannelError('TRANSPORT_CLOSED'); + return this.sessionId; + } catch (error) { + lastError = error; + if (this.lastAttemptedRelayAddress) + this.failedRelayAddresses.set( + this.lastAttemptedRelayAddress, + Date.now() + ); + this.sessionId = null; + this.activeRelayAddress = null; + if (!isRecoverableTransportError(error)) break; + } + } + throw lastError; + })(); + this.recoveryPromise = recovery; + void recovery + .catch((error) => { + if (!this.closed) + this.emit({ kind: 'error', code: translateSidecarError(error).code }); + }) + .finally(() => { + if (this.recoveryPromise === recovery) this.recoveryPromise = null; + }); + return recovery; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function errorCode(error: unknown): string { + return error instanceof PrivateTransportSidecarError || + error instanceof PrivateChannelError + ? error.code + : error instanceof Error + ? error.message + : 'TRANSPORT_ERROR'; +} + +function isRecoverableCode(code: string | undefined): boolean { + return ( + code === 'INNER_QUIC_FAILED' || + code === 'TRANSPORT_CLOSED' || + code === 'MASQUE_TUNNEL_FAILED' || + code === 'SIDECAR_EXITED' || + code === 'SIDECAR_NOT_RUNNING' || + code === 'SIDECAR_REQUEST_TIMEOUT' || + code === 'SIDECAR_WRITE_FAILED' || + code === 'MASQUE_RELAY_UNAVAILABLE' || + code === 'BOOTSTRAP_FAILED' + ); +} + +function isRecoverableTransportError(error: unknown): boolean { + return isRecoverableCode(errorCode(error)); } function encodeApplicationData(value: unknown): Buffer { diff --git a/electron/src/setup.ts b/electron/src/setup.ts index f6b6a24b..bb31bb53 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -26,6 +26,7 @@ import { dirname, join } from 'path'; import { pipeline } from 'stream/promises'; import { pathToFileURL } from 'url'; import { materializeReticulumResourceForOpen } from './reticulum-resource-open'; +import { installDisplayMediaPicker } from './display-media-picker'; import { DEV_LOGS_DISABLED_STORAGE_KEY, log as loggerLog, @@ -1056,6 +1057,7 @@ export class ElectronCapacitorApp { }, }); this.mainWindowState.manage(this.MainWindow); + installDisplayMediaPicker(this.MainWindow); this.MainWindow.on('maximize', () => { this.MainWindow?.webContents.send('window:state-changed', true); }); diff --git a/src/App.tsx b/src/App.tsx index 1eedd15b..e88ab787 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -78,6 +78,7 @@ import { } from './utils/events'; import { stopSharedEarbumpPlayback } from './components/Group/earbumpSharedAudio'; import { Settings } from './components/Group/Settings'; +import { QAppScreenCapturePermission } from './components/Apps/QAppScreenCapturePermission'; import { useRetrieveDataLocalStorage } from './hooks/useRetrieveDataLocalStorage.tsx'; import { useQortalGetSaveSettings } from './hooks/useQortalGetSaveSettings.tsx'; import { @@ -2064,6 +2065,7 @@ function App() { }} > + {extState === 'authenticated' && isMainWindow && ( ( tabIndex={-1} src={defaultUrl} sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-modals" - allow="fullscreen; clipboard-read; clipboard-write; microphone; screen-wake-lock" + allow="fullscreen; clipboard-read; clipboard-write; microphone; speaker-selection; display-capture; screen-wake-lock" > ); diff --git a/src/components/Apps/QAppScreenCapturePermission.tsx b/src/components/Apps/QAppScreenCapturePermission.tsx new file mode 100644 index 00000000..af06ae69 --- /dev/null +++ b/src/components/Apps/QAppScreenCapturePermission.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +type Request = { requestId: string; origin: string }; + +export function QAppScreenCapturePermission({ active }: { active: boolean }) { + const { t } = useTranslation(['question', 'core']); + const [request, setRequest] = useState(null); + const pending = useRef(null); + useEffect(() => { + const api = window.electronAPI; + const stop = api?.onDisplayMediaRequest?.((value) => { + if (!active) { + api.authorizeDisplayMedia?.(value.requestId, false); + return; + } + pending.current = value; + setRequest(value); + }); + const stopCancel = api?.onDisplayMediaCancel?.((id) => { + if (pending.current?.requestId === id) { + pending.current = null; + setRequest(null); + } + }); + return () => { + stop?.(); + stopCancel?.(); + if (pending.current) + api?.authorizeDisplayMedia?.(pending.current.requestId, false); + pending.current = null; + setRequest(null); + }; + }, [active]); + const answer = (accepted: boolean) => { + if (request) + window.electronAPI?.authorizeDisplayMedia?.(request.requestId, accepted); + pending.current = null; + setRequest(null); + }; + return ( + answer(false)} + maxWidth="sm" + fullWidth + aria-labelledby="display-capture-title" + > + + {t('question:permission.screen_capture_title')} + + + + {t('question:permission.screen_capture_description', { + origin: request?.origin, + })} + + + + + + + + ); +} diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index 0bf2dbb8..48c14157 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -30,6 +30,7 @@ import { isQAppMoqAction, } from '../qortal/qapp-moq-request'; import { normalizeQappIdentityContext } from '../qortal/qapp-identity'; +import { dispatchQAppScreenCaptureRequest } from '../qortal/qapp-screen-capture-request'; import { serializeQortalRequestError } from '../qortal/qortal-request-errors'; export const saveFileInChunks = async ( @@ -192,6 +193,7 @@ export function openIndexedDB() { } export const listOfAllQortalRequests = [ + 'SCREEN_CAPTURE_SELECT', 'RNS_CLOSE', 'RNS_CONNECT', 'RNS_REQUEST', @@ -313,6 +315,7 @@ export const listOfAllQortalRequests = [ ]; export const UIQortalRequests = [ + 'SCREEN_CAPTURE_SELECT', 'RNS_CLOSE', 'RNS_CONNECT', 'RNS_REQUEST', @@ -808,6 +811,8 @@ export const useQortalMessageListener = ( ? dispatchQAppPrivateChannelRequest(message.payload, requestContext) : isQAppMoqAction(message?.action) ? dispatchQAppMoqRequest(message.payload, requestContext) + : message?.action === 'SCREEN_CAPTURE_SELECT' + ? dispatchQAppScreenCaptureRequest(message.payload) : window.sendMessage( message.action, message.payload, @@ -1113,14 +1118,19 @@ export const useQortalMessageListener = ( targetOrigin ); }); + const cleanupOwner = () => { + // This single main-process operation owns cleanup across Reticulum, + // private channels, and MOQT. It is deliberately best-effort because a + // tab reload/close can race an Electron main-process restart. + const cleanup = api.qappReticulumCleanupOwner?.(owner); + if (cleanup) void cleanup.catch(() => undefined); + }; const handleLoad = () => { if (!hasLoadedFrameRef.current) { hasLoadedFrameRef.current = true; return; } - void api.qappReticulumCleanupOwner?.(owner); - void api.privateChannelCleanupOwner?.(owner); - void api.qappMoqCleanupOwner?.(owner); + cleanupOwner(); }; iframe.addEventListener('load', handleLoad); return () => { @@ -1128,9 +1138,7 @@ export const useQortalMessageListener = ( unsubscribePrivateChannel?.(); unsubscribeMoq?.(); iframe.removeEventListener('load', handleLoad); - void api.qappReticulumCleanupOwner?.(owner); - void api.privateChannelCleanupOwner?.(owner); - void api.qappMoqCleanupOwner?.(owner); + cleanupOwner(); }; }, [appName, appService, iframeRef, tabId]); diff --git a/src/i18n/locales/ar/question.json b/src/i18n/locales/ar/question.json index a86826d1..457f65a1 100644 --- a/src/i18n/locales/ar/question.json +++ b/src/i18n/locales/ar/question.json @@ -131,6 +131,9 @@ "add_admin": "هل تسمح للتطبيق بإضافة {{ invitee }} كأدمن؟", "all_item_list": "هل تسمح للتطبيق بإضافة الآتي للقائمة {{ name }}:", "authenticate": "هل تسمح للتطبيق بتسجيل الدخول؟", + "backend_connection": "هل تسمح لتطبيق {{appName}} بالاتصال بخادمه؟", + "backend_connection_description": "يتيح هذا للتطبيق إرسال البيانات واستقبالها مع خادمه ما دامت علامة التبويب هذه مفتوحة.", + "backend_connection_understand": "أسمح لهذا التطبيق بالاتصال بخادمه وتبادل البيانات معه.", "ban": "هل تسمح للتطبيق بمنع {{ partecipant }} من الجروب؟", "buy_name": "هل تسمح للتطبيق بشراء اسم؟", "buy_name_detail": "شراء {{ name }} بسعر {{ price }} QORT", @@ -172,6 +175,8 @@ "remove_from_list": "هل تسمح للتطبيق بإزالة الآتي من القائمة {{ name }}:", "reticulum_destination": "يريد {{appName}} الاتصال بخادم Reticulum الخلفي {{destination}}. هل تسمح بهذا الاتصال لعلامة التبويب هذه؟", "reticulum_remote_backend_understand": "أفهم أن تطبيق Q-App هذا سيتبادل البيانات مع واجهة خلفية بعيدة خارج Qortal Hub.", + "screen_capture_description": "هل تسمح لـ {{origin}} بعرض معاينات الشاشات والنوافذ والتقاط المحتوى لهذا الطلب؟ اسمح فقط للتطبيقات التي تثق بها. اختر الشاشة أو النافذة داخل التطبيق.", + "screen_capture_title": "السماح بالتقاط الشاشة؟", "sell_name_cancel": "هل تسمح للتطبيق بإلغاء بيع الاسم؟", "sell_name_transaction": "هل تسمح للتطبيق بعمل صفقة بيع اسم؟", "sell_name_transaction_detail": "بيع {{ name }} بسعر {{ price }} QORT", diff --git a/src/i18n/locales/de/question.json b/src/i18n/locales/de/question.json index 8f3a9f79..214353bb 100644 --- a/src/i18n/locales/de/question.json +++ b/src/i18n/locales/de/question.json @@ -131,6 +131,9 @@ "add_admin": "Möchten Sie dieser Anwendung die Berechtigung geben, den Benutzer {{ invitee }} als Administrator hinzuzufügen?", "all_item_list": "Möchten Sie dieser Anwendung die Berechtigung geben, Folgendes zur Liste {{ name }} hinzuzufügen:", "authenticate": "Möchten Sie dieser Anwendung die Berechtigung zur Authentifizierung geben?", + "backend_connection": "Darf {{appName}} eine Verbindung zu seinem Backend herstellen?", + "backend_connection_description": "Damit kann die App Daten mit ihrem Backend austauschen, solange dieser Tab geöffnet ist.", + "backend_connection_understand": "Ich erlaube dieser App, sich mit ihrem Backend zu verbinden und Daten auszutauschen.", "ban": "Möchten Sie dieser Anwendung die Berechtigung geben, {{ partecipant }} aus der Gruppe zu verbannen?", "buy_name": "Möchten Sie dieser Anwendung die Berechtigung geben, einen Namen zu kaufen?", "buy_name_detail": "{{ name }} für {{ price }} QORT kaufen", @@ -172,6 +175,8 @@ "remove_from_list": "Möchten Sie dieser Anwendung die Berechtigung geben, Folgendes aus der Liste {{ name }} zu entfernen:", "reticulum_destination": "{{appName}} möchte eine Verbindung zum Reticulum-Backend {{destination}} herstellen. Diese Verbindung für diesen Tab zulassen?", "reticulum_remote_backend_understand": "Ich verstehe, dass diese Q-App Daten mit einem entfernten Backend außerhalb von Qortal Hub austauscht.", + "screen_capture_description": "Darf {{origin}} Bildschirm-/Fenstervorschauen sehen und für diese Anfrage Inhalte aufnehmen? Erlaube dies nur vertrauenswürdigen Apps. Wähle den Bildschirm oder das Fenster in der App.", + "screen_capture_title": "Bildschirmaufnahme erlauben?", "sell_name_cancel": "Möchten Sie dieser Anwendung die Berechtigung geben, den Verkauf eines Namens abzubrechen?", "sell_name_transaction": "Möchten Sie dieser Anwendung die Berechtigung geben, eine Verkaufs-Transaktion für einen Namen zu erstellen?", "sell_name_transaction_detail": "{{ name }} für {{ price }} QORT verkaufen", diff --git a/src/i18n/locales/en/question.json b/src/i18n/locales/en/question.json index 6e2150c3..15716395 100644 --- a/src/i18n/locales/en/question.json +++ b/src/i18n/locales/en/question.json @@ -131,6 +131,9 @@ "add_admin": "do you give this application permission to add user {{ invitee }} as an admin?", "all_item_list": "do you give this application permission to add the following to the list {{ name }}:", "authenticate": "do you give this application permission to authenticate?", + "backend_connection": "Allow {{appName}} to connect to its backend?", + "backend_connection_description": "This lets the app send and receive data with its backend while this tab is open.", + "backend_connection_understand": "I allow this app to connect and exchange data with its backend.", "ban": "do you give this application permission to ban {{ partecipant }} from the group?", "buy_name": "do you give this application permission to buy a name?", "buy_name_detail": "buying {{ name }} for {{ price }} QORT", @@ -172,6 +175,8 @@ "remove_from_list": "do you give this application permission to remove the following from the list {{ name }}:", "reticulum_destination": "{{appName}} wants to connect to the Reticulum backend {{destination}}. Allow this connection for this tab?", "reticulum_remote_backend_understand": "I understand this Q-App will exchange data with a remote backend outside Qortal Hub.", + "screen_capture_description": "Allow {{origin}} to see screen/window previews and capture content for this request? Only allow apps you trust. Choose the screen or window inside the app.", + "screen_capture_title": "Allow screen capture?", "sell_name_cancel": "do you give this application permission to cancel the selling of a name?", "sell_name_transaction": "do you give this application permission to create a sell name transaction?", "sell_name_transaction_detail": "sell {{ name }} for {{ price }} QORT", diff --git a/src/i18n/locales/es/question.json b/src/i18n/locales/es/question.json index c62e8c26..8c6ed0f4 100644 --- a/src/i18n/locales/es/question.json +++ b/src/i18n/locales/es/question.json @@ -131,6 +131,9 @@ "add_admin": "¿Deseas permitir que esta aplicación agregue al usuario {{ invitee }} como administrador?", "all_item_list": "¿Deseas permitir que esta aplicación agregue lo siguiente a la lista {{ name }}:", "authenticate": "¿Deseas permitir que esta aplicación se autentique?", + "backend_connection": "¿Permitir que {{appName}} se conecte a su servidor?", + "backend_connection_description": "Esto permite que la aplicación envíe y reciba datos de su servidor mientras esta pestaña esté abierta.", + "backend_connection_understand": "Permito que esta aplicación se conecte e intercambie datos con su servidor.", "ban": "¿Deseas permitir que esta aplicación expulse a {{ partecipant }} del grupo?", "buy_name": "¿Deseas permitir que esta aplicación compre un nombre?", "buy_name_detail": "comprando {{ name }} por {{ price }} QORT", @@ -172,6 +175,8 @@ "remove_from_list": "¿Deseas permitir que esta aplicación elimine lo siguiente de la lista {{ name }}?", "reticulum_destination": "{{appName}} quiere conectarse al backend de Reticulum {{destination}}. ¿Permitir esta conexión para esta pestaña?", "reticulum_remote_backend_understand": "Entiendo que esta Q-App intercambiará datos con un backend remoto externo a Qortal Hub.", + "screen_capture_description": "¿Permitir que {{origin}} vea vistas previas de pantallas y ventanas y capture contenido para esta solicitud? Permite solo aplicaciones de confianza. Elige la pantalla o ventana dentro de la aplicación.", + "screen_capture_title": "¿Permitir la captura de pantalla?", "sell_name_cancel": "¿Deseas permitir que esta aplicación cancele la venta de un nombre?", "sell_name_transaction": "¿Deseas permitir que esta aplicación cree una transacción para vender un nombre?", "sell_name_transaction_detail": "vender {{ name }} por {{ price }} QORT", diff --git a/src/i18n/locales/et/question.json b/src/i18n/locales/et/question.json index ac01acbf..6c8a3b61 100644 --- a/src/i18n/locales/et/question.json +++ b/src/i18n/locales/et/question.json @@ -131,6 +131,9 @@ "add_admin": "kas sa annad sellele rakendusele loa lisada kasutaja {{ invitee }} administraatoriks?", "all_item_list": "kas sa annad sellele rakendusele loa lisada järgnevad asjad nimekirja {{ name }}:", "authenticate": "kas sa annad sellele rakendusele loa autentida?", + "backend_connection": "Kas lubada rakendusel {{appName}} oma serveriga ühenduda?", + "backend_connection_description": "See lubab rakendusel oma serveriga andmeid vahetada, kuni see vahekaart on avatud.", + "backend_connection_understand": "Luban sellel rakendusel oma serveriga ühenduda ja andmeid vahetada.", "ban": "kas sa annad sellele rakendusele loa keelata {{ partecipant }} rühmast?", "buy_name": "kas sa annad sellele rakendusele loa nime osta?", "buy_name_detail": "ostan {{ name }} hinnaga {{ price }} QORT", @@ -172,6 +175,8 @@ "remove_from_list": "kas sa annad sellele rakendusele loa eemaldada järgmised asjad nimekirjast {{ name }}:", "reticulum_destination": "{{appName}} soovib luua ühenduse Reticulumi taustserveriga {{destination}}. Kas lubada see ühendus sellel vahekaardil?", "reticulum_remote_backend_understand": "Mõistan, et see Q-App vahetab andmeid Qortal Hubist väljaspool asuva kaugtaustateenusega.", + "screen_capture_description": "Kas lubada rakendusel {{origin}} näha ekraanide ja akende eelvaateid ning jäädvustada selle päringu jaoks sisu? Luba ainult usaldusväärseid rakendusi. Vali ekraan või aken rakenduses.", + "screen_capture_title": "Kas lubada ekraani jäädvustamine?", "sell_name_cancel": "kas sa annad sellele rakendusele loa tühistada nime müümise?", "sell_name_transaction": "kas sa annad sellele rakendusele loa luua nime müümise tehing?", "sell_name_transaction_detail": "müü {{ name }} hinnaga {{ price }} QORT", diff --git a/src/i18n/locales/fi/question.json b/src/i18n/locales/fi/question.json index 4dbf00da..0935915b 100644 --- a/src/i18n/locales/fi/question.json +++ b/src/i18n/locales/fi/question.json @@ -131,6 +131,9 @@ "add_admin": "annatko tälle sovellukselle luvan lisätä käyttäjä {{ invitee }} järjestelmänvalvojaksi?", "all_item_list": "annatko tälle sovellukselle luvan lisätä seuraavat kohteet luetteloon {{ name }}:", "authenticate": "annatko tälle sovellukselle luvan todentaa?", + "backend_connection": "Sallitaanko sovelluksen {{appName}} muodostaa yhteys taustapalvelimeensa?", + "backend_connection_description": "Tämä sallii sovelluksen lähettää ja vastaanottaa tietoja taustapalvelimensa kanssa tämän välilehden ollessa avoinna.", + "backend_connection_understand": "Sallin tämän sovelluksen muodostaa yhteyden taustapalvelimeensa ja vaihtaa tietoja sen kanssa.", "ban": "annatko tälle sovellukselle luvan kieltää {{ partecipant }} ryhmästä?", "buy_name": "annatko tälle sovellukselle luvan ostaa nimen?", "buy_name_detail": "ostetaan {{ name }} hintaan {{ price }} QORT", @@ -172,6 +175,8 @@ "remove_from_list": "annatko tälle sovellukselle luvan poistaa seuraavat kohteet luettelosta {{ name }}:", "reticulum_destination": "{{appName}} haluaa muodostaa yhteyden Reticulum-taustapalvelimeen {{destination}}. Sallitaanko yhteys tässä välilehdessä?", "reticulum_remote_backend_understand": "Ymmärrän, että tämä Q-App vaihtaa tietoja Qortal Hubin ulkopuolisen etätaustapalvelun kanssa.", + "screen_capture_description": "Saako {{origin}} nähdä näyttöjen ja ikkunoiden esikatselut ja tallentaa sisältöä tätä pyyntöä varten? Salli vain luotetut sovellukset. Valitse näyttö tai ikkuna sovelluksessa.", + "screen_capture_title": "Sallitaanko näytön tallennus?", "sell_name_cancel": "annatko tälle sovellukselle luvan peruuttaa nimen myynnin?", "sell_name_transaction": "annatko tälle sovellukselle luvan luoda nimenmyyntitransaktion?", "sell_name_transaction_detail": "myy {{ name }} hintaan {{ price }} QORT", diff --git a/src/i18n/locales/fr/question.json b/src/i18n/locales/fr/question.json index 1aa12e4e..8f627215 100644 --- a/src/i18n/locales/fr/question.json +++ b/src/i18n/locales/fr/question.json @@ -131,6 +131,9 @@ "add_admin": "Souhaitez-vous autoriser cette application à ajouter l'utilisateur {{ invitee }} comme administrateur ?", "all_item_list": "Souhaitez-vous autoriser cette application à ajouter les éléments suivants à la liste {{ name }} :", "authenticate": "Souhaitez-vous autoriser cette application à s'authentifier ?", + "backend_connection": "Autoriser {{appName}} à se connecter à son serveur ?", + "backend_connection_description": "Cela permet à l’application d’envoyer et de recevoir des données avec son serveur tant que cet onglet est ouvert.", + "backend_connection_understand": "J’autorise cette application à se connecter à son serveur et à échanger des données avec lui.", "ban": "Souhaitez-vous autoriser cette application à bannir {{ partecipant }} du groupe ?", "buy_name": "Souhaitez-vous autoriser cette application à acheter un nom ?", "buy_name_detail": "achat de {{ name }} pour {{ price }} QORT", @@ -172,6 +175,8 @@ "remove_from_list": "Souhaitez-vous autoriser cette application à supprimer les éléments suivants de la liste {{ name }} :", "reticulum_destination": "{{appName}} souhaite se connecter au serveur Reticulum {{destination}}. Autoriser cette connexion pour cet onglet ?", "reticulum_remote_backend_understand": "Je comprends que cette Q-App échangera des données avec un serveur distant externe à Qortal Hub.", + "screen_capture_description": "Autoriser {{origin}} à voir les aperçus des écrans et fenêtres et à capturer du contenu pour cette demande ? N’autorisez que les applications de confiance. Choisissez l’écran ou la fenêtre dans l’application.", + "screen_capture_title": "Autoriser la capture d’écran ?", "sell_name_cancel": "Souhaitez-vous autoriser cette application à annuler la vente d'un nom ?", "sell_name_transaction": "Souhaitez-vous autoriser cette application à créer une transaction de vente de nom ?", "sell_name_transaction_detail": "vente de {{ name }} pour {{ price }} QORT", diff --git a/src/i18n/locales/it/question.json b/src/i18n/locales/it/question.json index 39314929..162da42f 100644 --- a/src/i18n/locales/it/question.json +++ b/src/i18n/locales/it/question.json @@ -131,6 +131,9 @@ "add_admin": "consenti a questa applicazione di aggiungere l'utente {{ invitee }} come amministratore?", "all_item_list": "consenti a questa applicazione di aggiungere i seguenti elementi alla lista {{ name }}:", "authenticate": "consenti a questa applicazione di autenticarti?", + "backend_connection": "Consentire a {{appName}} di connettersi al proprio server?", + "backend_connection_description": "Questo consente all’app di inviare e ricevere dati dal proprio server finché questa scheda rimane aperta.", + "backend_connection_understand": "Consento a questa app di connettersi al proprio server e scambiare dati con esso.", "ban": "consenti a questa applicazione di escludere {{ partecipant }} dal gruppo?", "buy_name": "consenti a questa applicazione di acquistare un nome?", "buy_name_detail": "acquisto di {{ name }} per {{ price }} QORT", @@ -172,6 +175,8 @@ "remove_from_list": "consenti a questa applicazione di rimuovere i seguenti elementi dalla lista {{ name }}?", "reticulum_destination": "{{appName}} vuole connettersi al backend Reticulum {{destination}}. Consentire questa connessione per questa scheda?", "reticulum_remote_backend_understand": "Comprendo che questa Q-App scambierà dati con un backend remoto esterno a Qortal Hub.", + "screen_capture_description": "Consentire a {{origin}} di vedere le anteprime di schermi e finestre e acquisire contenuti per questa richiesta? Autorizza solo app affidabili. Scegli lo schermo o la finestra nell’app.", + "screen_capture_title": "Consentire la cattura dello schermo?", "sell_name_cancel": "consenti a questa applicazione di annullare la vendita di un nome?", "sell_name_transaction": "consenti a questa applicazione di creare una transazione di vendita nome?", "sell_name_transaction_detail": "vendi {{ name }} per {{ price }} QORT", diff --git a/src/i18n/locales/ja/question.json b/src/i18n/locales/ja/question.json index 141af33b..cdc9bace 100644 --- a/src/i18n/locales/ja/question.json +++ b/src/i18n/locales/ja/question.json @@ -131,6 +131,9 @@ "add_admin": "このアプリにユーザー {{ invitee }} を管理者として追加することを許可しますか?", "all_item_list": "このアプリに以下をリスト {{ name }} に追加することを許可しますか?", "authenticate": "このアプリに認証を許可しますか?", + "backend_connection": "{{appName}}がバックエンドに接続することを許可しますか?", + "backend_connection_description": "このタブが開いている間、アプリがバックエンドとデータを送受信できるようになります。", + "backend_connection_understand": "このアプリがバックエンドに接続し、データを交換することを許可します。", "ban": "このアプリに {{ partecipant }} をグループから追放することを許可しますか?", "buy_name": "このアプリに名前を購入することを許可しますか?", "buy_name_detail": "{{ price }} QORTで{{ name }}を購入", @@ -172,6 +175,8 @@ "remove_from_list": "このアプリにリスト {{ name }} から以下を削除することを許可しますか?", "reticulum_destination": "{{appName}} が Reticulum バックエンド {{destination}} への接続を要求しています。このタブで接続を許可しますか?", "reticulum_remote_backend_understand": "このQ-AppがQortal Hub外部のリモートバックエンドとデータを交換することを理解しています。", + "screen_capture_description": "{{origin}} に画面やウィンドウのプレビューの表示と、このリクエストでのコンテンツのキャプチャを許可しますか?信頼できるアプリのみに許可してください。画面やウィンドウはアプリ内で選択します。", + "screen_capture_title": "画面キャプチャを許可しますか?", "sell_name_cancel": "このアプリに名前の販売をキャンセルすることを許可しますか?", "sell_name_transaction": "このアプリに名前の販売取引を作成することを許可しますか?", "sell_name_transaction_detail": "{{ price }} QORTで{{ name }}を販売", diff --git a/src/i18n/locales/pt/question.json b/src/i18n/locales/pt/question.json index 2cb06fca..881700bf 100644 --- a/src/i18n/locales/pt/question.json +++ b/src/i18n/locales/pt/question.json @@ -131,6 +131,9 @@ "add_admin": "você dá permissão para que este aplicativo adicione o usuário {{ invitee }} como administrador?", "all_item_list": "você dá permissão para que este aplicativo adicione o seguinte à lista {{ name }}:", "authenticate": "você dá permissão para que este aplicativo autentique?", + "backend_connection": "Permitir que {{appName}} se conecte ao seu servidor?", + "backend_connection_description": "Isto permite que a aplicação envie e receba dados do seu servidor enquanto este separador estiver aberto.", + "backend_connection_understand": "Permito que esta aplicação se conecte ao seu servidor e troque dados com ele.", "ban": "você dá permissão para que este aplicativo bloqueie {{ partecipant }} do grupo?", "buy_name": "você dá permissão para que este aplicativo compre um nome?", "buy_name_detail": "comprando {{ name }} por {{ price }} QORTs", @@ -172,6 +175,8 @@ "remove_from_list": "você dá permissão para que este aplicativo remova o seguinte da lista {{ name }}:", "reticulum_destination": "{{appName}} quer se conectar ao backend Reticulum {{destination}}. Permitir esta conexão para esta aba?", "reticulum_remote_backend_understand": "Entendo que este Q-App trocará dados com um backend remoto externo ao Qortal Hub.", + "screen_capture_description": "Permitir que {{origin}} veja pré-visualizações de ecrãs e janelas e capture conteúdo para este pedido? Autorize apenas aplicações de confiança. Escolha o ecrã ou a janela na aplicação.", + "screen_capture_title": "Permitir a captura de ecrã?", "sell_name_cancel": "você dá permissão para que este aplicativo cancele a venda de um nome?", "sell_name_transaction": "você dá permissão para que este aplicativo crie uma transação de venda de nome?", "sell_name_transaction_detail": "vender {{ name }} por {{ price }} QORTs", diff --git a/src/i18n/locales/ru/question.json b/src/i18n/locales/ru/question.json index 508f87a7..f63f17ca 100644 --- a/src/i18n/locales/ru/question.json +++ b/src/i18n/locales/ru/question.json @@ -131,6 +131,9 @@ "add_admin": "Разрешить этому приложению назначить пользователя {{ invitee }} администратором?", "all_item_list": "Разрешить этому приложению добавить следующее в список {{ name }}:", "authenticate": "Разрешить этому приложению выполнить аутентификацию?", + "backend_connection": "Разрешить {{appName}} подключаться к своему серверу?", + "backend_connection_description": "Это позволит приложению отправлять и получать данные от своего сервера, пока эта вкладка открыта.", + "backend_connection_understand": "Я разрешаю этому приложению подключаться к своему серверу и обмениваться с ним данными.", "ban": "Разрешить этому приложению забанить {{ partecipant }} в группе?", "buy_name": "Разрешить этому приложению купить имя?", "buy_name_detail": "покупка {{ name }} за {{ price }} QORT", @@ -172,6 +175,8 @@ "remove_from_list": "Разрешить этому приложению удалить следующее из списка {{ name }}:", "reticulum_destination": "{{appName}} запрашивает подключение к серверу Reticulum {{destination}}. Разрешить это подключение для данной вкладки?", "reticulum_remote_backend_understand": "Я понимаю, что это Q-App будет обмениваться данными с удалённым сервером за пределами Qortal Hub.", + "screen_capture_description": "Разрешить {{origin}} видеть предварительные просмотры экранов и окон и захватывать содержимое для этого запроса? Доверяйте только проверенным приложениям. Выберите экран или окно в приложении.", + "screen_capture_title": "Разрешить захват экрана?", "sell_name_cancel": "Разрешить этому приложению отменить продажу имени?", "sell_name_transaction": "Разрешить этому приложению создать транзакцию продажи имени?", "sell_name_transaction_detail": "продажа {{ name }} за {{ price }} QORT", diff --git a/src/i18n/locales/zh/question.json b/src/i18n/locales/zh/question.json index 6464b5ad..00ddcc93 100644 --- a/src/i18n/locales/zh/question.json +++ b/src/i18n/locales/zh/question.json @@ -131,6 +131,9 @@ "add_admin": "是否允许该应用将用户 {{ invitee }} 添加为管理员?", "all_item_list": "是否允许该应用将以下内容添加到列表 {{ name }} 中?", "authenticate": "是否允许该应用进行身份验证?", + "backend_connection": "允许{{appName}}连接其后端吗?", + "backend_connection_description": "在此标签页打开期间,应用可以与其后端收发数据。", + "backend_connection_understand": "我允许此应用连接其后端并交换数据。", "ban": "是否允许该应用将 {{ partecipant }} 从群组中封禁?", "buy_name": "是否允许该应用购买名称?", "buy_name_detail": "以 {{ price }} QORT 购买 {{ name }}", @@ -172,6 +175,8 @@ "remove_from_list": "是否允许该应用从列表 {{ name }} 中移除以下内容?", "reticulum_destination": "{{appName}} 想要连接到 Reticulum 后端 {{destination}}。是否允许此标签页建立连接?", "reticulum_remote_backend_understand": "我了解此 Q-App 将与 Qortal Hub 外部的远程后端交换数据。", + "screen_capture_description": "是否允许 {{origin}} 查看屏幕和窗口预览,并为此次请求捕获内容?请仅允许您信任的应用。在应用内选择屏幕或窗口。", + "screen_capture_title": "允许屏幕捕获?", "sell_name_cancel": "是否允许该应用取消名称的出售?", "sell_name_transaction": "是否允许该应用创建出售名称的交易?", "sell_name_transaction_detail": "以 {{ price }} QORT 出售 {{ name }}", diff --git a/src/qortal/__tests__/backendPermission.test.ts b/src/qortal/__tests__/backendPermission.test.ts new file mode 100644 index 00000000..b68e85eb --- /dev/null +++ b/src/qortal/__tests__/backendPermission.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Module mocks (hoisted before any import) ──────────────────────────────── +// Factories live in ./common.ts; async dynamic import is used because vi.mock() +// is hoisted before regular imports. + +vi.mock('../qortal-requests', async () => + (await import('./common')).qortalRequestsFactory() +); +vi.mock('../../background/background', async () => + (await import('./common')).backgroundFactory() +); +vi.mock('../../encryption/encryption', async () => + (await import('./common')).encryptionFactory() +); +vi.mock('../../components/Chat/AdminSpaceInner', async () => + (await import('./common')).adminSpaceFactory() +); +vi.mock('../../components/Chat/MessageDisplay', async () => + (await import('./common')).messageDisplayFactory() +); +vi.mock('../../components/Group/Group', async () => + (await import('./common')).groupFactory() +); +vi.mock('../../qdn/encryption/group-encryption', async () => + (await import('./common')).groupEncryptionFactory() +); +vi.mock('../../qdn/publish/publish', async () => + (await import('./common')).publishFactory() +); +vi.mock('../../transactions/TradeBotCreateRequest', async () => + (await import('./common')).tradeBotCreateFactory() +); +vi.mock('../../transactions/TradeBotDeleteRequest', async () => + (await import('./common')).tradeBotDeleteFactory() +); +vi.mock('../../transactions/signTradeBotTransaction', async () => + (await import('./common')).signTradeBotFactory() +); +vi.mock('../../transactions/transactions', async () => + (await import('./common')).transactionsFactory() +); +vi.mock('../../utils/events', async () => + (await import('./common')).eventsFactory() +); +vi.mock('../../utils/fileReading/index', async () => + (await import('./common')).fileReadingFactory() +); +vi.mock('../../utils/memeTypes', async () => + (await import('./common')).mimeTypesFactory() +); +vi.mock('../../utils/queue/queue', async () => + (await import('./common')).queueFactory() +); +vi.mock('../../utils/utils', async () => + (await import('./common')).utilsFactory() +); +vi.mock('short-unique-id', async () => + (await import('./common')).shortUidFactory() +); +vi.mock('../../utils/decode', async () => + (await import('./common')).decodeFactory() +); +vi.mock('i18next', async () => (await import('./common')).i18nFactory()); +vi.mock('aes-js', async () => (await import('./common')).aesFactory()); +vi.mock('../../encryption/Base58', async () => + (await import('./common')).base58Factory() +); +vi.mock('../../encryption/ed2curve', async () => + (await import('./common')).ed2curveFactory() +); +vi.mock('../../encryption/nacl-fast', async () => + (await import('./common')).naclFactory() +); +vi.mock('asmcrypto.js', async () => + (await import('./common')).asmcryptoFactory() +); +vi.mock('../../hooks/useQortalMessageListener', async () => + (await import('./common')).messageListenerFactory() +); + +import { + authorizeRnsDestination, + clearRnsDestinationPermissionsByTabId, + sessionPermissions, +} from '../get'; +import { + hasSessionPermission, + setSessionPermissions, + VALID_SESSION_PERMISSIONS, +} from '../qortal-requests'; +import english from '../../i18n/locales/en/question.json'; +import i18n from 'i18next'; + +const app = { tabId: 42, name: 'ExampleApp' }; +const destination = 'a'.repeat(32); +let prompts: Array<{ + text1: string; + text2?: string; + details?: { permissions: string[] }; +}>; +let accepted = true; + +describe('backend approval through qortalRequest permission dialogs', () => { + beforeEach(() => { + vi.useFakeTimers(); + prompts = []; + accepted = true; + const granted = new Set(); + vi.mocked(setSessionPermissions).mockImplementation( + (tab, name, permissions) => { + for (const permission of permissions) + granted.add(`${tab}:${name}:${permission}`); + } + ); + vi.mocked(hasSessionPermission).mockImplementation( + (tab, name, permission) => granted.has(`${tab}:${name}:${permission}`) + ); + VALID_SESSION_PERMISSIONS.splice( + 0, + VALID_SESSION_PERMISSIONS.length, + 'PRIVATE_DATA_CHANNEL', + 'SEND_COIN' + ); + vi.mocked(i18n.t).mockImplementation( + (key: string, options?: { appName?: string }) => + key.startsWith('question:permission.') + ? (english.permission[ + key.slice('question:permission.'.length) + ]?.replace('{{appName}}', options?.appName ?? '') ?? key) + : key + ); + Object.assign(window, { electronAPI: { qappReticulumConnect: vi.fn() } }); + vi.spyOn(window, 'postMessage').mockImplementation((message) => { + if (message.action !== 'QORTAL_REQUEST_PERMISSION') return; + prompts.push(message.payload); + window.dispatchEvent( + new MessageEvent('message', { + origin: window.location.origin, + source: window, + data: { + action: 'QORTAL_REQUEST_PERMISSION_RESPONSE', + requestId: message.requestId, + result: { accepted }, + }, + }) + ); + }); + }); + + afterEach(() => { + clearRnsDestinationPermissionsByTabId(app.tabId); + vi.restoreAllMocks(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('shows one plain backend prompt, then skips the private transport dialog', async () => { + await expect( + authorizeRnsDestination(destination.toUpperCase(), false, app) + ).resolves.toBe(destination); + await expect( + sessionPermissions({ permissions: ['PRIVATE_DATA_CHANNEL'] }, false, app) + ).resolves.toBe(true); + expect(prompts).toHaveLength(1); + expect(prompts[0].text1).toBe( + 'Allow ExampleApp to connect to its backend?' + ); + expect(JSON.stringify(prompts[0])).not.toMatch( + /Reticulum|destination|PRIVATE_DATA_CHANNEL/ + ); + expect(JSON.stringify(prompts[0])).not.toContain(destination); + }); + + it('still prompts for other requested capabilities after approving the backend', async () => { + await authorizeRnsDestination(destination, false, app); + await sessionPermissions( + { permissions: ['PRIVATE_DATA_CHANNEL', 'SEND_COIN'] }, + false, + app + ); + expect(prompts).toHaveLength(2); + expect(prompts[1].details?.permissions).toEqual(['SEND_COIN']); + }); + + it('declining the backend does not grant private transport', async () => { + accepted = false; + await expect( + authorizeRnsDestination(destination, false, app) + ).rejects.toThrow('RNS_PERMISSION_DENIED'); + expect(setSessionPermissions).not.toHaveBeenCalled(); + expect( + hasSessionPermission(app.tabId, app.name, 'PRIVATE_DATA_CHANNEL') + ).toBe(false); + }); + + it('validates destination and permission names before approval', async () => { + await expect( + authorizeRnsDestination('invalid', false, app) + ).rejects.toThrow('RNS_DESTINATION_UNREACHABLE'); + await expect( + sessionPermissions({ permissions: ['INVALID'] }, false, app) + ).rejects.toThrow(); + expect(prompts).toHaveLength(0); + }); +}); diff --git a/src/qortal/get.ts b/src/qortal/get.ts index 2c7d6cb9..0706b5ea 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -116,10 +116,11 @@ import { getElectronPersistentStorage } from '../utils/electronPersistentStorage import { fileToBase64 } from '../utils/fileReading/index.ts'; import { mimeToExtensionMap } from '../utils/memeTypes.ts'; import { RequestQueueWithPromise } from '../utils/queue/queue.ts'; +import { normalizeQappIdentityContext } from './qapp-identity.ts'; import { - normalizeQappIdentityContext, - qappReticulumSessionPermissionKey, -} from './qapp-identity.ts'; + createBackendPermissions, + unapprovedSessionPermissions, +} from './qapp-backend-permission.ts'; import utils from '../utils/utils.ts'; import ShortUniqueId from 'short-unique-id'; import { @@ -460,7 +461,25 @@ function getFileFromContentScript(fileId) { const responseResolvers = new Map(); const notificationPermissionResolvers = new Map(); -const rnsDestinationPermissions = new Set(); +const backendPermissions = createBackendPermissions( + async (appInfo, isFromExtension) => { + const response = await getUserPermission( + { + text1: i18n.t('question:permission.backend_connection', { + appName: appInfo.name, + }), + text2: i18n.t('question:permission.backend_connection_description'), + confirmCheckbox: true, + confirmCheckboxLabel: i18n.t( + 'question:permission.backend_connection_understand' + ), + }, + isFromExtension + ); + return response?.accepted === true; + }, + (tabId, name, permissions) => setSessionPermissions(tabId, name, permissions) +); function generatePermissionRequestId(): string { if (globalThis.crypto?.randomUUID) { @@ -541,37 +560,18 @@ export const authorizeRnsDestination = async ( if (appInfo?.tabId == null || !appInfo?.name) { throw new Error('RNS_PERMISSION_DENIED'); } - const key = qappReticulumSessionPermissionKey( - appInfo.tabId, - appInfo.name, - normalized - ); - if (rnsDestinationPermissions.has(key)) return normalized; - const response = await getUserPermission( - { - text1: i18n.t('question:permission.reticulum_destination', { - appName: appInfo.name, - destination: normalized, - }), - confirmCheckbox: true, - confirmCheckboxLabel: i18n.t( - 'question:permission.reticulum_remote_backend_understand' - ), - }, + await backendPermissions.authorize( + { tabId: appInfo.tabId, name: appInfo.name }, + normalized, isFromExtension ); - if (response?.accepted !== true) throw new Error('RNS_PERMISSION_DENIED'); - rnsDestinationPermissions.add(key); return normalized; }; export const clearRnsDestinationPermissionsByTabId = ( tabId: string | number ) => { - const prefix = `${tabId}\u0000`; - for (const key of rnsDestinationPermissions) { - if (key.startsWith(prefix)) rnsDestinationPermissions.delete(key); - } + backendPermissions.clearByTabId(tabId); }; export const getWhichUI = async () => { @@ -702,12 +702,7 @@ export const signQappIdentityProof = async ( i18n.t('auth:message.error.qapp_account_authentication_required') ); } - const reticulumPermissionKey = qappReticulumSessionPermissionKey( - appInfo.tabId, - appInfo.name, - backendDestination - ); - if (!rnsDestinationPermissions.has(reticulumPermissionKey)) + if (!backendPermissions.has(appInfo, backendDestination)) throw new Error('RNS_PERMISSION_DENIED'); const wallet = await getSaveWallet(); const account = { @@ -871,6 +866,15 @@ export const sessionPermissions = async (data, isFromExtension, appInfo) => { ); } + // RNS_CONNECT already approves private transport for this app's tab. + // Keep legacy SESSION_PERMISSIONS requests compatible without a second dialog. + const pendingPermissions = unapprovedSessionPermissions( + { tabId, name: appInfo.name }, + permissions, + hasSessionPermission + ); + if (pendingPermissions.length === 0) return true; + const resPermission = await getUserPermission( { text1: i18n.t('question:permission.session_permissions', { @@ -885,7 +889,7 @@ export const sessionPermissions = async (data, isFromExtension, appInfo) => { }), details: { type: 'sessionPermissions', - permissions, + permissions: pendingPermissions, }, confirmCheckbox: true, confirmCheckboxLabel: i18n.t('question:permission.session_understand', { diff --git a/src/qortal/qapp-backend-permission.test.ts b/src/qortal/qapp-backend-permission.test.ts new file mode 100644 index 00000000..58a64a94 --- /dev/null +++ b/src/qortal/qapp-backend-permission.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createBackendPermissions, + unapprovedSessionPermissions, +} from './qapp-backend-permission'; + +const app = { tabId: 42, name: 'ExampleApp' }; +const destination = 'a'.repeat(32); + +const setup = () => { + const granted = new Map>(); + const request = vi.fn().mockResolvedValue(true); + const grant = vi.fn((tabId, name, permissions: string[]) => { + granted.set(`${tabId}:${name}`, new Set(permissions)); + }); + const has = (tabId, name, permission) => + granted.get(`${tabId}:${name}`)?.has(permission) ?? false; + const backend = createBackendPermissions(request, grant); + return { backend, request, grant, has }; +}; + +describe('combined backend connection approval', () => { + it('one approval covers the backend and subsequent private transport permission', async () => { + const { backend, request, grant, has } = setup(); + expect( + unapprovedSessionPermissions(app, ['PRIVATE_DATA_CHANNEL'], has) + ).toEqual(['PRIVATE_DATA_CHANNEL']); + await backend.authorize(app, destination, false); + expect(request).toHaveBeenCalledExactlyOnceWith(app, false); + expect(backend.has(app, destination)).toBe(true); + expect(grant).toHaveBeenCalledWith(42, 'ExampleApp', [ + 'PRIVATE_DATA_CHANNEL', + ]); + expect( + unapprovedSessionPermissions(app, ['PRIVATE_DATA_CHANNEL'], has) + ).toEqual([]); + await backend.authorize(app, destination, false); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('does not approve unrelated permissions in a mixed request', async () => { + const { backend, has } = setup(); + await backend.authorize(app, destination, false); + expect( + unapprovedSessionPermissions( + app, + ['PRIVATE_DATA_CHANNEL', 'SEND_COIN', 'SEND_COIN'], + has + ) + ).toEqual(['SEND_COIN']); + expect( + unapprovedSessionPermissions( + { ...app, tabId: 43 }, + ['PRIVATE_DATA_CHANNEL'], + has + ) + ).toEqual(['PRIVATE_DATA_CHANNEL']); + }); + + it('requires another approval for another backend, app, or tab', async () => { + const { backend, request } = setup(); + await backend.authorize(app, destination, false); + for (const [owner, target] of [ + [app, 'b'.repeat(32)], + [{ ...app, tabId: 43 }, destination], + [{ ...app, name: 'OtherApp' }, destination], + ] as const) { + expect(backend.has(owner, target)).toBe(false); + await backend.authorize(owner, target, false); + } + expect(request).toHaveBeenCalledTimes(4); + }); + + it('declining grants neither permission and allows a later retry', async () => { + const { backend, request, grant } = setup(); + request.mockResolvedValueOnce(false); + await expect(backend.authorize(app, destination, true)).rejects.toThrow( + 'RNS_PERMISSION_DENIED' + ); + expect(backend.has(app, destination)).toBe(false); + expect(grant).not.toHaveBeenCalled(); + await backend.authorize(app, destination, true); + expect(request).toHaveBeenCalledTimes(2); + }); + + it('coalesces simultaneous requests for the same backend', async () => { + const { backend, request } = setup(); + await Promise.all([ + backend.authorize(app, destination, false), + backend.authorize(app, destination, false), + ]); + expect(request).toHaveBeenCalledTimes(1); + }); + + it('clears only the closed tab and asks again when reopened', async () => { + const { backend, request } = setup(); + const other = { ...app, tabId: 420 }; + await backend.authorize(app, destination, false); + await backend.authorize(other, destination, false); + backend.clearByTabId(42); + expect(backend.has(app, destination)).toBe(false); + expect(backend.has(other, destination)).toBe(true); + await backend.authorize(app, destination, false); + expect(request).toHaveBeenCalledTimes(3); + }); + + it('ignores approval responses arriving after the tab was closed', async () => { + const { backend, request, grant } = setup(); + let accept!: (value: boolean) => void; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + accept = resolve; + }) + ); + const pending = backend.authorize(app, destination, false); + await Promise.resolve(); + backend.clearByTabId(42); + accept(true); + await expect(pending).rejects.toThrow('RNS_PERMISSION_DENIED'); + expect(backend.has(app, destination)).toBe(false); + expect(grant).not.toHaveBeenCalled(); + }); +}); diff --git a/src/qortal/qapp-backend-permission.ts b/src/qortal/qapp-backend-permission.ts new file mode 100644 index 00000000..e1620704 --- /dev/null +++ b/src/qortal/qapp-backend-permission.ts @@ -0,0 +1,77 @@ +import { qappReticulumSessionPermissionKey } from './qapp-identity.ts'; + +type AppInfo = { tabId: string | number; name: string }; +type HasPermission = ( + tabId: string | number, + appName: string, + permission: string +) => boolean; + +// Backend approval covers both regular messages and private data transport. +// Destination checks and native connection ownership still apply independently. +export const createBackendPermissions = ( + requestApproval: (app: AppInfo, isFromExtension: boolean) => Promise, + grantSessionPermissions: ( + tabId: string | number, + name: string, + permissions: string[] + ) => unknown +) => { + const approved = new Set(); + const pending = new Map>(); + const keyFor = (app: AppInfo, destination: string) => + qappReticulumSessionPermissionKey(app.tabId, app.name, destination); + + return { + has(app: AppInfo, destination: string) { + return approved.has(keyFor(app, destination)); + }, + async authorize( + app: AppInfo, + destination: string, + isFromExtension: boolean + ) { + const key = keyFor(app, destination); + if (!approved.has(key)) { + let approval = pending.get(key); + if (!approval) { + approval = Promise.resolve().then(async () => { + if ( + !(await requestApproval(app, isFromExtension)) || + pending.get(key) !== approval + ) { + throw new Error('RNS_PERMISSION_DENIED'); + } + approved.add(key); + }); + pending.set(key, approval); + } + try { + await approval; + } finally { + if (pending.get(key) === approval) pending.delete(key); + } + } + grantSessionPermissions(app.tabId, app.name, ['PRIVATE_DATA_CHANNEL']); + }, + clearByTabId(tabId: string | number) { + const prefix = `${tabId}\u0000`; + for (const key of approved) { + if (key.startsWith(prefix)) approved.delete(key); + } + // A response arriving after the tab closes must not restore its grant. + for (const key of pending.keys()) { + if (key.startsWith(prefix)) pending.delete(key); + } + }, + }; +}; + +export const unapprovedSessionPermissions = ( + app: AppInfo, + permissions: string[], + hasPermission: HasPermission +) => + [...new Set(permissions)].filter( + (permission) => !hasPermission(app.tabId, app.name, permission) + ); diff --git a/src/qortal/qapp-screen-capture-request.ts b/src/qortal/qapp-screen-capture-request.ts new file mode 100644 index 00000000..000bedb1 --- /dev/null +++ b/src/qortal/qapp-screen-capture-request.ts @@ -0,0 +1,22 @@ +export async function dispatchQAppScreenCaptureRequest( + message: Record +) { + if ( + message.action !== 'SCREEN_CAPTURE_SELECT' || + typeof message.requestId !== 'string' || + !/^[0-9a-f-]{36}$/.test(message.requestId) || + (message.sourceId !== undefined && + (typeof message.sourceId !== 'string' || message.sourceId.length > 256)) + ) { + throw new Error('INVALID_SCREEN_CAPTURE_REQUEST'); + } + if (!window.electronAPI?.selectDisplayMedia) + throw new Error('SCREEN_CAPTURE_UNSUPPORTED'); + // An unguessable, short-lived capability is delivered directly to the requesting + // native frame. Main revalidates that frame and the approved source list. + window.electronAPI.selectDisplayMedia( + message.requestId, + message.sourceId as string | undefined + ); + return { accepted: true }; +} diff --git a/src/test/mocks/electron.ts b/src/test/mocks/electron.ts index dc06729b..9b22abf0 100644 --- a/src/test/mocks/electron.ts +++ b/src/test/mocks/electron.ts @@ -17,12 +17,17 @@ export const dialog = { }; export const ipcMain = { + removeListener: () => undefined, handle: () => undefined, on: () => undefined, removeAllListeners: () => undefined, removeHandler: () => undefined, }; +export const desktopCapturer = { + getSources: async (_options?: unknown): Promise => [], +}; + export const ipcRenderer = { invoke: async () => undefined, on: () => undefined, @@ -37,4 +42,3 @@ export const contextBridge = { export const shell = { openExternal: async () => undefined, }; - diff --git a/src/types/window.d.ts b/src/types/window.d.ts index f09ee23b..5754a55f 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -130,6 +130,10 @@ declare global { ) => () => void; onSystemLockRequested?: (callback: () => void) => () => void; getPlatform?: () => Promise; + onDisplayMediaRequest?: (callback: (request: { requestId: string; origin: string }) => void) => () => void; + onDisplayMediaCancel?: (callback: (requestId: string) => void) => () => void; + selectDisplayMedia?: (requestId: string, sourceId?: string) => void; + authorizeDisplayMedia?: (requestId: string, accepted: boolean) => void; listScreenShareSources?: () => Promise<{ success: boolean; error?: string; From 9faa7f192e5d2c9c0d0bd137118f92fa2bfe6e4c Mon Sep 17 00:00:00 2001 From: PhilReact Date: Tue, 8 Sep 2026 16:55:33 +0300 Subject: [PATCH 20/32] fixes --- electron/src/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/electron/src/index.ts b/electron/src/index.ts index 67637c08..f2756673 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -119,6 +119,20 @@ app.commandLine.appendSwitch( 'BlockInsecurePrivateNetworkRequests' ); +// Chromium exposes AudioContext.setSinkId but gates the delegable permission +// behind SpeakerSelection. Without it, cross-origin Q-Apps cannot select an +// output even with allow="speaker-selection". Enable only this API feature; +// normal frame policy and device permissions still apply. +app.commandLine.appendSwitch( + 'enable-blink-features', + Array.from( + new Set([ + ...app.commandLine.getSwitchValue('enable-blink-features').split(',').filter(Boolean), + 'SpeakerSelection', + ]) + ).join(',') +); + // app.commandLine.appendSwitch('ignore-certificate-errors'); // Graceful handling of unhandled errors. Route electron-unhandled logging From 2d4765d2c98a9053a394418fff4640e7b451fb78 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Tue, 8 Sep 2026 18:15:03 +0300 Subject: [PATCH 21/32] fixes --- docs/qapp-moq-transport.md | 45 +++++++++ .../internal/moqclient/session.go | 97 ++++++++++++++----- .../moqclient/session_integration_test.go | 85 ++++++++-------- .../internal/protocol/server.go | 76 ++++++++++++--- .../internal/protocol/server_test.go | 12 +++ electron/src/moq-masque-transport.ts | 26 ++++- electron/src/moq-transport-manager.test.ts | 66 +++++++++++++ electron/src/moq-transport-manager.ts | 80 ++++++++++++--- .../src/private-transport-sidecar.test.ts | 4 +- electron/src/private-transport-sidecar.ts | 45 +++++++-- 10 files changed, 429 insertions(+), 107 deletions(-) create mode 100644 docs/qapp-moq-transport.md diff --git a/docs/qapp-moq-transport.md b/docs/qapp-moq-transport.md new file mode 100644 index 00000000..28b177b5 --- /dev/null +++ b/docs/qapp-moq-transport.md @@ -0,0 +1,45 @@ +# Generic multi-track Q-App transport + +Sidecar 0.5.0 extends the existing owner-bound `PRIVATE_DATA_CHANNEL` capability. +No capture, codec, media encryption, jitter, call membership, or media-specific +priority policy lives in Hub. + +`MOQ_SESSION_OPEN` accepts `publicationTrack` as either the existing single +string or an array of 1–8 distinct names, all under `publicationNamespace`. +One logical session uses one inner QUIC/MoQ connection through MASQUE. The +authenticated backend must subscribe to every declared publication before +opening completes. Undeclared publications are rejected; relay recovery +restores the same declarations and subscriptions with a fresh attach token. + +`MOQ_OBJECT_PUBLISH` continues to accept one `Uint8Array`. For explicit track +selection and batching, `payload` may instead be: + +```js +{ trackName: 'bulk', objects: [encryptedObject1, encryptedObject2] } +``` + +Batches have 1–8 objects, each 1–1024 bytes. They cross renderer/main/native IPC +in one request; each remains a separate QUIC datagram, not a larger datagram. +The native binary envelope prefixes each object with its uint16 big-endian +length and validates the whole envelope before sending anything. A batch may +partially send if the transport fails; it is never retried as reliable data. + +Admission is bounded at 16 KiB pending per publication and 64 KiB per session, +with at most 128 subscriptions. A bulk publication cannot consume another +publication's entire per-track allowance. Limits and owner checks apply equally +to legacy and batch calls. The app should keep at most one bulk batch in flight; +small batches give other publications opportunities to send between them. +No stream has independent physical bandwidth: QUIC/MASQUE congestion control +still applies to the connection. Track separation alone does not promise QoS. + +Recovered connections report interrupted publications as failed instead of +claiming dropped stale objects were delivered. Media apps can request a fresh +reference object; Hub does not interpret that object or decide media recovery. + +Tests cover owner isolation, declaration/batch limits, independent queue +admission, malformed binary framing, and three opaque publication tracks on a +real QUIC-through-MASQUE connection with a verified relay egress address. + +Deployment requires updated native Hub code and sidecar together; strict sidecar +version checking rejects stale binaries. Existing single-track apps remain +supported. Backends must implement routing for their own declared track names. diff --git a/electron/native/private-transport/internal/moqclient/session.go b/electron/native/private-transport/internal/moqclient/session.go index 669db931..412aefdd 100644 --- a/electron/native/private-transport/internal/moqclient/session.go +++ b/electron/native/private-transport/internal/moqclient/session.go @@ -22,7 +22,7 @@ import ( const ( ALPN = "moqt-18" MaxObjectBytes = 1024 - MaxSubscriptions = 64 + MaxSubscriptions = 128 MaxNamespaceComponents = 8 defaultConnectTimeout = 8 * time.Second maxSafeJSONInteger = uint64(1<<53 - 1) @@ -50,6 +50,7 @@ type Config struct { AttachToken string PublicationNamespace []string PublicationTrack string + PublicationTracks []string Timeout time.Duration } @@ -76,17 +77,28 @@ type Metrics struct { type publicationHandler struct { namespace [][]byte track string + tracks []string publication chan *moqtransport.IncomingSubscribeRequest } func (h *publicationHandler) HandleGoAway(string) {} func (h *publicationHandler) HandleSubscribe(request *moqtransport.IncomingSubscribeRequest) { - if !equalNamespace(request.Namespace(), h.namespace) || string(request.Name()) != h.track { + alias := uint64(0) + tracks := h.tracks + if len(tracks) == 0 { + tracks = []string{h.track} + } + for index, track := range tracks { + if string(request.Name()) == track { + alias = uint64(index + 1) + } + } + if !equalNamespace(request.Namespace(), h.namespace) || alias == 0 { request.Reject(moqtransport.RequestErrorCodeUnauthorized, "publication is not authorized") return } - request.Accept(1) + request.Accept(alias) select { case h.publication <- request: default: @@ -101,11 +113,13 @@ type subscription struct { } type Session struct { - tunnel *masqueclient.Tunnel - conn *quic.Conn - moq *moqtransport.Session - publication *moqtransport.IncomingSubscribeRequest - onEvent func(Event) + tunnel *masqueclient.Tunnel + conn *quic.Conn + moq *moqtransport.Session + publication *moqtransport.IncomingSubscribeRequest + publications map[string]*moqtransport.IncomingSubscribeRequest + defaultTrack string + onEvent func(Event) mu sync.Mutex subscriptions map[string]subscription @@ -119,6 +133,21 @@ type Session struct { } func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error) { + tracks := cfg.PublicationTracks + if len(tracks) == 0 { + tracks = []string{cfg.PublicationTrack} + } + if len(tracks) > 8 { + return nil, errors.New("INVALID_MOQ_CONFIG") + } + seen := map[string]bool{} + for _, track := range tracks { + if !safeName.MatchString(track) || seen[track] { + return nil, errors.New("INVALID_MOQ_CONFIG") + } + seen[track] = true + } + cfg.PublicationTrack = tracks[0] if !validNamespace(cfg.PublicationNamespace) || !safeName.MatchString(cfg.PublicationTrack) || cfg.LogicalSessionID == "" || len(cfg.LogicalSessionID) > 512 || len(cfg.AttachToken) < 32 || len(cfg.AttachToken) > 128 { @@ -157,7 +186,7 @@ func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error } handler := &publicationHandler{ namespace: stringsToNamespace(cfg.PublicationNamespace), - track: cfg.PublicationTrack, publication: make(chan *moqtransport.IncomingSubscribeRequest, 1), + track: cfg.PublicationTrack, tracks: tracks, publication: make(chan *moqtransport.IncomingSubscribeRequest, len(tracks)), } moq, err := moqtransport.NewSession( quicmoq.NewClient(connection), "attach/"+cfg.AttachToken, @@ -167,23 +196,30 @@ func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error _ = connection.CloseWithError(1, "MOQT setup failed") return fail(fmt.Errorf("MOQ_SESSION_FAILED: %w", err)) } - select { - case publication := <-handler.publication: - if !connection.ConnectionState().SupportsDatagrams.Remote { - moq.CloseWithError(1, "datagrams required") - return fail(errors.New("DATAGRAM_UNSUPPORTED")) + publications := make(map[string]*moqtransport.IncomingSubscribeRequest) + for len(publications) < len(tracks) { + select { + case publication := <-handler.publication: + if !connection.ConnectionState().SupportsDatagrams.Remote { + moq.CloseWithError(1, "datagrams required") + return fail(errors.New("DATAGRAM_UNSUPPORTED")) + } + name := string(publication.Name()) + if publications[name] != nil { + _ = publication.Close() + continue + } + publications[name] = publication + case <-moq.Context().Done(): + moq.CloseWithError(1, "MOQT attach failed") + return fail(errors.New("MOQ_ATTACH_FAILED")) + case <-openCtx.Done(): + moq.CloseWithError(1, "MOQT attach timed out") + return fail(errors.New("MOQ_ATTACH_FAILED")) } - return &Session{ - tunnel: tunnel, conn: connection, moq: moq, publication: publication, - onEvent: onEvent, subscriptions: make(map[string]subscription), - }, nil - case <-moq.Context().Done(): - moq.CloseWithError(1, "MOQT attach failed") - return fail(errors.New("MOQ_ATTACH_FAILED")) - case <-openCtx.Done(): - moq.CloseWithError(1, "MOQT attach timed out") - return fail(errors.New("MOQ_ATTACH_FAILED")) } + return &Session{tunnel: tunnel, conn: connection, moq: moq, publication: publications[tracks[0]], + publications: publications, defaultTrack: tracks[0], onEvent: onEvent, subscriptions: make(map[string]subscription)}, nil } func backendTLSConfig(serverName string, pin []byte) *tls.Config { @@ -285,6 +321,10 @@ func equalStringNamespace(left, right []string) bool { } func (s *Session) PublishObject(payload []byte) error { + return s.PublishTrackObject(s.defaultTrack, payload) +} + +func (s *Session) PublishTrackObject(track string, payload []byte) error { if s.closed.Load() { return errors.New("MOQ_SESSION_CLOSED") } @@ -292,7 +332,14 @@ func (s *Session) PublishObject(payload []byte) error { return errors.New("MOQ_OBJECT_TOO_LARGE") } objectID := s.nextObjectID.Add(1) - 1 - err := s.publication.SendDatagram(moqtransport.Object{ + publication := s.publication + if s.publications != nil { + publication = s.publications[track] + } + if publication == nil { + return errors.New("INVALID_MOQ_CONFIG") + } + err := publication.SendDatagram(moqtransport.Object{ GroupID: 0, ObjectID: objectID, ForwardingPreference: moqtransport.ObjectForwardingPreferenceDatagram, Payload: append([]byte(nil), payload...), diff --git a/electron/native/private-transport/internal/moqclient/session_integration_test.go b/electron/native/private-transport/internal/moqclient/session_integration_test.go index 25aab920..eba2fa8d 100644 --- a/electron/native/private-transport/internal/moqclient/session_integration_test.go +++ b/electron/native/private-transport/internal/moqclient/session_integration_test.go @@ -98,6 +98,7 @@ func TestSessionTransportsOpaqueMOQTObjectOnlyThroughMasque(t *testing.T) { attachToken := "integration-one-time-token-000000000000" publicationNamespace := []string{"qortal", "apps", "sample", "publisher-123"} publicationTrack := "realtime-data" + tracks := []string{publicationTrack, "auxiliary", "status"} backendSource := make(chan string, 1) backendErrors := make(chan error, 1) go func() { @@ -121,27 +122,33 @@ func TestSessionTransportsOpaqueMOQTObjectOnlyThroughMasque(t *testing.T) { backendErrors <- context.Canceled return } - source, subscribeErr := session.Subscribe(ctx, stringsToNamespace(publicationNamespace), publicationTrack) - if subscribeErr != nil { - backendErrors <- subscribeErr - return - } - object, readErr := source.ReadObject(ctx) - if readErr != nil { - backendErrors <- readErr - return - } - var downstream *moqtransport.IncomingSubscribeRequest - select { - case downstream = <-handler.subscriptions: - case <-ctx.Done(): - backendErrors <- ctx.Err() - return + sources := make([]*moqtransport.OutgoingSubscribeRequest, 0, len(tracks)) + for _, track := range tracks { + source, subscribeErr := session.Subscribe(ctx, stringsToNamespace(publicationNamespace), track) + if subscribeErr != nil { + backendErrors <- subscribeErr + return + } + sources = append(sources, source) } - downstream.Accept(2) - if sendErr := downstream.SendDatagram(*object); sendErr != nil { - backendErrors <- sendErr - return + for index, source := range sources { + object, readErr := source.ReadObject(ctx) + if readErr != nil { + backendErrors <- readErr + return + } + var downstream *moqtransport.IncomingSubscribeRequest + select { + case downstream = <-handler.subscriptions: + case <-ctx.Done(): + backendErrors <- ctx.Err() + return + } + downstream.Accept(uint64(index + 10)) + if sendErr := downstream.SendDatagram(*object); sendErr != nil { + backendErrors <- sendErr + return + } } time.Sleep(100 * time.Millisecond) }() @@ -155,30 +162,32 @@ func TestSessionTransportsOpaqueMOQTObjectOnlyThroughMasque(t *testing.T) { }, BackendServerName: "moq-backend", BackendCertSHA256: integrationCertificatePin(t, backendCertificate), LogicalSessionID: "logical-session", AttachToken: attachToken, - PublicationNamespace: publicationNamespace, PublicationTrack: publicationTrack, Timeout: 5 * time.Second, + PublicationNamespace: publicationNamespace, PublicationTrack: publicationTrack, PublicationTracks: tracks, Timeout: 5 * time.Second, }, func(event Event) { events <- event }) if err != nil { t.Fatal(err) } defer session.Close() - if err = session.Subscribe("self-subscription", publicationNamespace, publicationTrack); err != nil { - t.Fatal(err) - } - payload := []byte("opaque-application-object") - if err = session.PublishObject(payload); err != nil { - t.Fatal(err) - } - select { - case event := <-events: - if event.Kind != "object" || event.SubscriptionID != "self-subscription" || - event.TrackName != publicationTrack || !equalStringNamespace(event.Namespace, publicationNamespace) || - !bytes.Equal(event.Data, payload) { - t.Fatalf("unexpected MOQT event: %#v", event) + for _, publicationTrack := range tracks { + if err = session.Subscribe("self-"+publicationTrack, publicationNamespace, publicationTrack); err != nil { + t.Fatal(err) + } + payload := []byte("opaque-application-object/" + publicationTrack) + if err = session.PublishTrackObject(publicationTrack, payload); err != nil { + t.Fatal(err) + } + select { + case event := <-events: + if event.Kind != "object" || event.SubscriptionID != "self-"+publicationTrack || + event.TrackName != publicationTrack || !equalStringNamespace(event.Namespace, publicationNamespace) || + !bytes.Equal(event.Data, payload) { + t.Fatalf("unexpected MOQT event: %#v", event) + } + case err = <-backendErrors: + t.Fatal(err) + case <-ctx.Done(): + t.Fatal("timed out waiting for relayed object") } - case err = <-backendErrors: - t.Fatal(err) - case <-ctx.Done(): - t.Fatal("timed out waiting for relayed object") } var observedBackendSource, observedRelayEgress string select { diff --git a/electron/native/private-transport/internal/protocol/server.go b/electron/native/private-transport/internal/protocol/server.go index 0ebc1056..34287da0 100644 --- a/electron/native/private-transport/internal/protocol/server.go +++ b/electron/native/private-transport/internal/protocol/server.go @@ -6,6 +6,7 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/binary" "encoding/hex" "encoding/json" "errors" @@ -21,7 +22,7 @@ import ( const ( Version = 2 - SidecarVersion = "0.4.0" + SidecarVersion = "0.5.0" MaxControlMessageBytes = 64 * 1024 MaxBinaryMessageBytes = innerquic.MaxReliablePayloadBytes maxRememberedRequestIDs = 4096 @@ -219,17 +220,17 @@ func (s *Server) handleRequest(ctx context.Context, req Request, binary []byte) } type moqOpenParams struct { - RelayAddress string `json:"relayAddress"` - RelayServerName string `json:"relayServerName"` - RelayCertSHA256 string `json:"relayCertSha256"` - BackendAddress string `json:"backendAddress"` - BackendServerName string `json:"backendServerName"` - BackendCertSHA256 string `json:"backendCertSha256"` - LogicalSessionID string `json:"logicalSessionId"` - AttachToken string `json:"attachToken"` - PublicationNamespace []string `json:"publicationNamespace"` - PublicationTrack string `json:"publicationTrack"` - TimeoutMS int `json:"timeoutMs"` + RelayAddress string `json:"relayAddress"` + RelayServerName string `json:"relayServerName"` + RelayCertSHA256 string `json:"relayCertSha256"` + BackendAddress string `json:"backendAddress"` + BackendServerName string `json:"backendServerName"` + BackendCertSHA256 string `json:"backendCertSha256"` + LogicalSessionID string `json:"logicalSessionId"` + AttachToken string `json:"attachToken"` + PublicationNamespace []string `json:"publicationNamespace"` + PublicationTrack json.RawMessage `json:"publicationTrack"` + TimeoutMS int `json:"timeoutMs"` } func (s *Server) openMoqSession(ctx context.Context, req Request) Response { @@ -237,6 +238,13 @@ func (s *Server) openMoqSession(ctx context.Context, req Request) Response { if decodeParams(req.Params, &p) != nil { return failure(req.RequestID, "INVALID_PARAMS", "invalid params") } + var track string + var tracks []string + if json.Unmarshal(p.PublicationTrack, &track) == nil { + tracks = []string{track} + } else if json.Unmarshal(p.PublicationTrack, &tracks) != nil || len(tracks) == 0 { + return failure(req.RequestID, "INVALID_PARAMS", "invalid publication tracks") + } sessionID, err := randomID("moq-") if err != nil { return failure(req.RequestID, "INTERNAL_ERROR", "failed to allocate MOQT session ID") @@ -249,7 +257,7 @@ func (s *Server) openMoqSession(ctx context.Context, req Request) Response { }, BackendServerName: p.BackendServerName, BackendCertSHA256: p.BackendCertSHA256, LogicalSessionID: p.LogicalSessionID, AttachToken: p.AttachToken, - PublicationNamespace: p.PublicationNamespace, PublicationTrack: p.PublicationTrack, + PublicationNamespace: p.PublicationNamespace, PublicationTrack: tracks[0], PublicationTracks: tracks, Timeout: duration(p.TimeoutMS), }, func(event moqclient.Event) { s.emitMoq(sessionID, event) }) if err != nil { @@ -324,6 +332,8 @@ func (s *Server) subscribeMoqTrack(req Request) Response { func (s *Server) publishMoqObject(req Request, binary []byte) Response { var p struct { MoqSessionID string `json:"moqSessionId"` + TrackName string `json:"trackName"` + Batched bool `json:"batched"` } if decodeParams(req.Params, &p) != nil { return failure(req.RequestID, "INVALID_PARAMS", "invalid params") @@ -332,14 +342,50 @@ func (s *Server) publishMoqObject(req Request, binary []byte) Response { if session == nil { return failure(req.RequestID, "MOQ_SESSION_CLOSED", "MOQT session does not exist") } - if err := session.PublishObject(binary); err != nil { - return failure(req.RequestID, moqErrorCode(err), "MOQT object publish failed") + objects := [][]byte{binary} + if p.Batched { + var err error + objects, err = parseMoqBatch(binary) + if err != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid object batch") + } + } + for _, object := range objects { + var err error + if p.TrackName == "" { + err = session.PublishObject(object) + } else { + err = session.PublishTrackObject(p.TrackName, object) + } + if err != nil { + return failure(req.RequestID, moqErrorCode(err), "MOQT object publish failed") + } } return success(req.RequestID, map[string]interface{}{ "accepted": true, "bytesSent": len(binary), }) } +func parseMoqBatch(data []byte) ([][]byte, error) { + var objects [][]byte + for len(data) > 0 { + if len(data) < 2 || len(objects) >= 8 { + return nil, errors.New("invalid batch") + } + size := int(binary.BigEndian.Uint16(data[:2])) + data = data[2:] + if size == 0 || size > moqclient.MaxObjectBytes || size > len(data) { + return nil, errors.New("invalid batch") + } + objects = append(objects, data[:size]) + data = data[size:] + } + if len(objects) == 0 { + return nil, errors.New("empty batch") + } + return objects, nil +} + func (s *Server) moqSessionMetrics(req Request) Response { var p struct { MoqSessionID string `json:"moqSessionId"` diff --git a/electron/native/private-transport/internal/protocol/server_test.go b/electron/native/private-transport/internal/protocol/server_test.go index 668cc25d..2ab67a30 100644 --- a/electron/native/private-transport/internal/protocol/server_test.go +++ b/electron/native/private-transport/internal/protocol/server_test.go @@ -30,6 +30,18 @@ func TestMalformedAndUnsupportedRequestsFailSafely(t *testing.T) { } } +func TestMoqBatchValidation(t *testing.T) { + objects, err := parseMoqBatch([]byte{0, 2, 1, 2, 0, 1, 3}) + if err != nil || len(objects) != 2 || !bytes.Equal(objects[0], []byte{1, 2}) { + t.Fatal("valid batch rejected", err) + } + for _, invalid := range [][]byte{nil, {0}, {0, 0}, {0, 2, 1}, {4, 1}, bytes.Repeat([]byte{0, 1, 42}, 9)} { + if _, err = parseMoqBatch(invalid); err == nil { + t.Fatal("invalid batch accepted", invalid) + } + } +} + func TestInnerErrorCodeDistinguishesRelayAndBackendCertificates(t *testing.T) { if got := innerErrorCode(errors.New("MASQUE_TUNNEL_FAILED: relay certificate pin mismatch")); got != "MASQUE_TUNNEL_FAILED" { t.Fatalf("relay error mapped to %q", got) diff --git a/electron/src/moq-masque-transport.ts b/electron/src/moq-masque-transport.ts index ca781e79..12653ea2 100644 --- a/electron/src/moq-masque-transport.ts +++ b/electron/src/moq-masque-transport.ts @@ -29,7 +29,7 @@ export type MoqOpenContext = Readonly<{ owner: QAppReticulumOwner; rnsConnectionId: string; publicationNamespace: readonly string[]; - publicationTrack: string; + publicationTrack: string | readonly string[]; }>; export type MoqTransportEvent = @@ -84,7 +84,11 @@ export class MoqMasqueTransport { } if ( !validNamespace(context.publicationNamespace) || - !MOQ_NAME.test(context.publicationTrack) + !( + typeof context.publicationTrack === 'string' + ? [context.publicationTrack] + : context.publicationTrack + ).every((track) => MOQ_NAME.test(track)) ) { throw new PrivateTransportSidecarError('INVALID_MOQ_CONFIG'); } @@ -203,18 +207,30 @@ export class MoqMasqueTransport { } } - async publish(payload: Uint8Array): Promise { + async publish( + payload: Uint8Array, + trackName?: string, + batch?: readonly Uint8Array[] + ): Promise { if (payload.byteLength < 1 || payload.byteLength > MAX_MOQ_OBJECT_BYTES) { throw new PrivateTransportSidecarError('MOQ_OBJECT_TOO_LARGE'); } try { - await this.sidecar.publishMoqObject(await this.requireSession(), payload); + const sessionId = await this.requireSession(); + if (batch) + await this.sidecar.publishMoqObject( + sessionId, + payload, + trackName, + batch + ); + else await this.sidecar.publishMoqObject(sessionId, payload); } catch (error) { if (isRecoverableMoqError(error)) { // A media object is stale by the time a replacement path opens. Drop // this object after recovery instead of creating an audible late frame. await this.recover(errorCode(error)); - return; + throw new PrivateTransportSidecarError('MOQ_SEND_FAILED'); } throw error; } diff --git a/electron/src/moq-transport-manager.test.ts b/electron/src/moq-transport-manager.test.ts index 3c08e992..1a65ad6f 100644 --- a/electron/src/moq-transport-manager.test.ts +++ b/electron/src/moq-transport-manager.test.ts @@ -42,6 +42,72 @@ async function open(manager: QAppMoqTransportManager) { } describe('Q-App generic MOQT manager', () => { + it('isolates bounded publication batches by track and rejects undeclared tracks', async () => { + const { manager, transports } = setup(); + const opened = await manager.open( + owner, + 'rns-1', + ['example'], + ['fast', 'bulk'] + ); + const objects = [new Uint8Array([1]), new Uint8Array([2])]; + await manager.publish(owner, opened.sessionId, { + trackName: 'bulk', + objects, + }); + expect(transports[0].publish).toHaveBeenCalledWith( + objects[0], + 'bulk', + objects + ); + await expect( + manager.publish(owner, opened.sessionId, { + trackName: 'undeclared', + objects, + }) + ).rejects.toMatchObject({ code: 'INVALID_MOQ_CONFIG' }); + await expect( + manager.publish(owner, opened.sessionId, { + trackName: 'bulk', + objects: Array(9).fill(objects[0]), + }) + ).rejects.toMatchObject({ code: 'MOQ_OBJECT_TOO_LARGE' }); + await expect( + manager.open(owner, 'rns-1', ['example'], ['same', 'same']) + ).rejects.toMatchObject({ code: 'INVALID_MOQ_CONFIG' }); + }); + it('reserves independent queue capacity when a bulk track is blocked', async () => { + const { manager, transports } = setup(); + const opened = await manager.open( + owner, + 'rns-1', + ['example'], + ['fast', 'bulk'] + ); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + transports[0].publish.mockImplementation(() => blocked); + const request = { + trackName: 'bulk', + objects: Array.from({ length: 8 }, () => new Uint8Array(1024)), + }; + const one = manager.publish(owner, opened.sessionId, request); + const two = manager.publish(owner, opened.sessionId, request); + await expect( + manager.publish(owner, opened.sessionId, request) + ).rejects.toMatchObject({ code: 'MOQ_QUEUE_LIMIT' }); + transports[0].publish.mockImplementation(async () => undefined); + await expect( + manager.publish(owner, opened.sessionId, { + trackName: 'fast', + objects: [new Uint8Array([1])], + }) + ).resolves.toMatchObject({ accepted: true }); + release(); + await Promise.all([one, two]); + }); it('binds a session to the Q-App-owned Reticulum connection', async () => { const { manager, transports } = setup(); const opened = await open(manager); diff --git a/electron/src/moq-transport-manager.ts b/electron/src/moq-transport-manager.ts index 54d68ad1..3b9b01b4 100644 --- a/electron/src/moq-transport-manager.ts +++ b/electron/src/moq-transport-manager.ts @@ -10,7 +10,10 @@ import type { MoqOpenContext, MoqTransportEvent } from './moq-masque-transport'; export const QAPP_MOQ_LIMITS = Object.freeze({ maxSessionsPerOwner: 2, maxSessionsGlobal: 16, - maxSubscriptionsPerSession: 64, + maxSubscriptionsPerSession: 128, + maxPublicationTracks: 8, + maxBatchObjects: 8, + maxQueuedBytesPerTrack: 16 * 1024, maxObjectBytes: 1024, maxQueuedBytesPerSession: 64 * 1024, }); @@ -33,7 +36,11 @@ export interface ManagedMoqTransport { namespace: readonly string[], trackName: string ): Promise; - publish(payload: Uint8Array): Promise; + publish( + payload: Uint8Array, + trackName?: string, + batch?: readonly Uint8Array[] + ): Promise; metrics(): Promise>; close(): Promise; } @@ -51,6 +58,8 @@ type SessionRecord = { transport: ManagedMoqTransport; subscriptions: Set; queuedBytes: number; + publicationTracks: readonly string[]; + queuedByTrack: Map; generation: number; }; @@ -88,10 +97,22 @@ export class QAppMoqTransportManager extends EventEmitter { publicationNamespaceValue, 'INVALID_MOQ_CONFIG' ); - const publicationTrack = requireName( - publicationTrackValue, - 'INVALID_MOQ_CONFIG' + const trackValues = Array.isArray(publicationTrackValue) + ? publicationTrackValue + : [publicationTrackValue]; + if ( + !trackValues.length || + trackValues.length > QAPP_MOQ_LIMITS.maxPublicationTracks + ) + throw new QAppMoqError('INVALID_MOQ_CONFIG'); + const publicationTracks = trackValues.map((value) => + requireName(value, 'INVALID_MOQ_CONFIG') ); + if (new Set(publicationTracks).size !== publicationTracks.length) + throw new QAppMoqError('INVALID_MOQ_CONFIG'); + const publicationTrack = Array.isArray(publicationTrackValue) + ? publicationTracks + : publicationTracks[0]; if ( (this.sessionsByOwner.get(ownerKey)?.size ?? 0) >= QAPP_MOQ_LIMITS.maxSessionsPerOwner || @@ -114,6 +135,8 @@ export class QAppMoqTransportManager extends EventEmitter { transport, subscriptions: new Set(), queuedBytes: 0, + publicationTracks, + queuedByTrack: new Map(), generation, }; this.sessions.set(sessionId, session); @@ -183,27 +206,56 @@ export class QAppMoqTransportManager extends EventEmitter { payloadValue: unknown ) { const session = this.requireOwned(owner, sessionIdValue); - const payload = requirePayload(payloadValue); + const batchRequest = + payloadValue && + typeof payloadValue === 'object' && + 'objects' in payloadValue + ? (payloadValue as { objects: unknown; trackName: unknown }) + : null; + const track = batchRequest + ? requireName(batchRequest.trackName, 'INVALID_MOQ_CONFIG') + : session.publicationTracks[0]; + if (!session.publicationTracks.includes(track)) + throw new QAppMoqError('INVALID_MOQ_CONFIG'); if ( - session.queuedBytes + payload.byteLength > - QAPP_MOQ_LIMITS.maxQueuedBytesPerSession + batchRequest && + (!Array.isArray(batchRequest.objects) || + !batchRequest.objects.length || + batchRequest.objects.length > QAPP_MOQ_LIMITS.maxBatchObjects) + ) + throw new QAppMoqError('MOQ_OBJECT_TOO_LARGE'); + const objects = batchRequest + ? (batchRequest.objects as unknown[]).map(requirePayload) + : [requirePayload(payloadValue)]; + const bytes = objects.reduce((sum, payload) => sum + payload.byteLength, 0); + if ( + session.queuedBytes + bytes > QAPP_MOQ_LIMITS.maxQueuedBytesPerSession || + (session.queuedByTrack.get(track) ?? 0) + bytes > + QAPP_MOQ_LIMITS.maxQueuedBytesPerTrack ) { throw new QAppMoqError('MOQ_QUEUE_LIMIT'); } - session.queuedBytes += payload.byteLength; + session.queuedBytes += bytes; + session.queuedByTrack.set( + track, + (session.queuedByTrack.get(track) ?? 0) + bytes + ); try { - await session.transport.publish(payload); + if (batchRequest) + await session.transport.publish(objects[0], track, objects); + else await session.transport.publish(objects[0]); return { sessionId: session.sessionId, accepted: true as const, - bytes: payload.byteLength, + bytes, }; } catch (error) { throw normalizeError(error, 'MOQ_SEND_FAILED'); } finally { - session.queuedBytes = Math.max( - 0, - session.queuedBytes - payload.byteLength + session.queuedBytes = Math.max(0, session.queuedBytes - bytes); + session.queuedByTrack.set( + track, + Math.max(0, (session.queuedByTrack.get(track) ?? 0) - bytes) ); } } diff --git a/electron/src/private-transport-sidecar.test.ts b/electron/src/private-transport-sidecar.test.ts index 44b6325c..c0032bc9 100644 --- a/electron/src/private-transport-sidecar.test.ts +++ b/electron/src/private-transport-sidecar.test.ts @@ -125,7 +125,7 @@ describeIntegration('private transport sidecar integration', () => { await sidecar.start(); expect(await sidecar.health()).toEqual({ service: 'qortal-private-transport', - sidecarVersion: '0.4.0', + sidecarVersion: '0.5.0', protocolVersion: 2, innerAlpn: 'qortal-private/1', moqAlpn: 'moqt-18', @@ -197,7 +197,7 @@ describeIntegration('private transport sidecar integration', () => { let count = 0; readline.createInterface({ input: process.stdin }).on('line', (line) => { const request = JSON.parse(line); - if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.4.0',protocolVersion:2,innerAlpn:'qortal-private/1',moqAlpn:'moqt-18'}}) + '\\n'); + if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.5.0',protocolVersion:2,innerAlpn:'qortal-private/1',moqAlpn:'moqt-18'}}) + '\\n'); else process.stdout.write('{not-json\\n'); }); `; diff --git a/electron/src/private-transport-sidecar.ts b/electron/src/private-transport-sidecar.ts index 4cfd980c..d6ce05c1 100644 --- a/electron/src/private-transport-sidecar.ts +++ b/electron/src/private-transport-sidecar.ts @@ -5,7 +5,7 @@ import { EventEmitter } from 'events'; import path from 'path'; export const PRIVATE_TRANSPORT_PROTOCOL_VERSION = 2; -export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.4.0'; +export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.5.0'; export const MAX_MOQ_OBJECT_BYTES = 1024; const MOQ_NAME = /^[A-Za-z0-9._-]{1,128}$/; @@ -62,7 +62,7 @@ export type MoqSessionConfig = Readonly<{ logicalSessionId: string; attachToken: string; publicationNamespace: readonly string[]; - publicationTrack: string; + publicationTrack: string | readonly string[]; timeoutMs?: number; }>; @@ -70,7 +70,7 @@ export type MoqSessionInfo = Readonly<{ moqSessionId: string; logicalSessionId: string; publicationNamespace: string[]; - publicationTrack: string; + publicationTrack: string | readonly string[]; applicationProtocol: 'moqt-18'; }>; @@ -315,7 +315,11 @@ export class PrivateTransportSidecar extends EventEmitter { async openMoqSession(config: MoqSessionConfig): Promise { if ( !validMoqNamespace(config.publicationNamespace) || - !MOQ_NAME.test(config.publicationTrack) + !( + typeof config.publicationTrack === 'string' + ? [config.publicationTrack] + : config.publicationTrack + ).every((track) => MOQ_NAME.test(track)) ) { throw new PrivateTransportSidecarError('INVALID_MOQ_CONFIG'); } @@ -334,7 +338,8 @@ export class PrivateTransportSidecar extends EventEmitter { !result.publicationNamespace.every( (component, index) => component === config.publicationNamespace[index] ) || - result?.publicationTrack !== config.publicationTrack || + JSON.stringify(result?.publicationTrack) !== + JSON.stringify(config.publicationTrack) || result?.applicationProtocol !== 'moqt-18' ) { throw new PrivateTransportSidecarError('MALFORMED_RESPONSE'); @@ -365,16 +370,40 @@ export class PrivateTransportSidecar extends EventEmitter { async publishMoqObject( moqSessionId: string, - payload: Uint8Array + payload: Uint8Array, + trackName?: string, + batch?: readonly Uint8Array[] ): Promise { if (payload.byteLength < 1 || payload.byteLength > MAX_MOQ_OBJECT_BYTES) { throw new PrivateTransportSidecarError('MOQ_OBJECT_TOO_LARGE'); } + let binary = payload; + if (batch) { + if ( + !batch.length || + batch.length > 8 || + !trackName || + !MOQ_NAME.test(trackName) || + batch.some((item) => !item.length || item.length > MAX_MOQ_OBJECT_BYTES) + ) + throw new PrivateTransportSidecarError('MOQ_OBJECT_TOO_LARGE'); + binary = Buffer.concat( + batch.map((item) => { + const size = Buffer.alloc(2); + size.writeUInt16BE(item.length); + return Buffer.concat([size, Buffer.from(item)]); + }) + ); + } await this.request( 'publishMoqObject', - { moqSessionId }, + { + moqSessionId, + ...(trackName ? { trackName } : {}), + ...(batch ? { batched: true } : {}), + }, undefined, - payload + binary ); } From 68b975fd6f1a0df4d075788f24b2aa17de465217 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Tue, 8 Sep 2026 19:31:10 +0300 Subject: [PATCH 22/32] fixes --- electron/src/display-media-picker.test.ts | 35 +++++++++++++++-------- electron/src/display-media-picker.ts | 29 +++++++++++++++---- src/test/mocks/electron.ts | 4 +++ 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/electron/src/display-media-picker.test.ts b/electron/src/display-media-picker.test.ts index 906829c1..2ac5a4a4 100644 --- a/electron/src/display-media-picker.test.ts +++ b/electron/src/display-media-picker.test.ts @@ -1,27 +1,25 @@ import { afterEach, expect, it, vi } from 'vitest'; import { EventEmitter } from 'events'; -import { desktopCapturer, ipcMain } from 'electron'; +import { desktopCapturer, ipcMain, systemPreferences } from 'electron'; import { installDisplayMediaPicker } from './display-media-picker'; afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); -function fixture() { +function fixture(platform: NodeJS.Platform = 'linux') { const bus = new EventEmitter(); vi.spyOn(ipcMain, 'on').mockImplementation(((...args: any[]) => bus.on(args[0], args[1])) as any); vi.spyOn(ipcMain, 'removeListener').mockImplementation(((...args: any[]) => bus.removeListener(args[0], args[1])) as any); - const getSources = vi - .spyOn(desktopCapturer, 'getSources') - .mockResolvedValue([ - { - id: 'screen:1', - name: 'Screen', - thumbnail: { toDataURL: () => 'data:image/png;base64,AA==' }, - }, - ] as any); + const getSources = vi.spyOn(desktopCapturer, 'getSources').mockResolvedValue([ + { + id: 'screen:1', + name: 'Screen', + thumbnail: { toDataURL: () => 'data:image/png;base64,AA==' }, + }, + ] as any); let handler: any; const root = {}; const frame = { @@ -40,7 +38,7 @@ function fixture() { }, }; const window = { webContents, isDestroyed: () => false, once: vi.fn() }; - installDisplayMediaPicker(window as any); + installDisplayMediaPicker(window as any, platform); const callback = vi.fn(); const request = { frame, @@ -68,6 +66,17 @@ function fixture() { }; } +it('reports missing macOS screen permission before denying capture', async () => { + vi.spyOn(systemPreferences, 'getMediaAccessStatus').mockReturnValue('denied'); + const f = fixture('darwin'); + f.send('display-media:authorize', { accepted: true }); + await vi.waitFor(() => expect(f.callback).toHaveBeenCalledWith({})); + expect(f.getSources).not.toHaveBeenCalled(); + expect(f.frame.executeJavaScript.mock.calls[0][0]).toContain( + 'SCREEN_OS_PERMISSION_REQUIRED' + ); +}); + it('does not enumerate before approval and grants only the selected one-use source', async () => { const f = fixture(); expect(f.getSources).not.toHaveBeenCalled(); @@ -89,6 +98,8 @@ it('does not enumerate before approval and grants only the selected one-use sour f.send('display-media:select', { sourceId: 'screen:1' }); expect(f.callback).toHaveBeenCalledTimes(1); expect(f.bus.listenerCount('display-media:authorize')).toBe(0); + for (const [script] of f.frame.executeJavaScript.mock.calls) + expect(script).toContain('"requestedHandler":"UI"'); }); it('denies rejection without disclosing thumbnails', () => { diff --git a/electron/src/display-media-picker.ts b/electron/src/display-media-picker.ts index 04579f88..b576d075 100644 --- a/electron/src/display-media-picker.ts +++ b/electron/src/display-media-picker.ts @@ -1,13 +1,17 @@ import { desktopCapturer, ipcMain, + systemPreferences, type BrowserWindow, type DesktopCapturerSource, } from 'electron'; import { randomUUID } from 'crypto'; /** Permission-controlled OS bridge. Source selection UI lives in the requesting app. */ -export function installDisplayMediaPicker(window: BrowserWindow): void { +export function installDisplayMediaPicker( + window: BrowserWindow, + platform: NodeJS.Platform = process.platform +): void { let cancelPending: (() => void) | null = null; window.webContents.session.setDisplayMediaRequestHandler( (request, callback) => { @@ -36,7 +40,9 @@ export function installDisplayMediaPicker(window: BrowserWindow): void { const deliver = (payload: unknown) => frame .executeJavaScript( - `window.postMessage(${JSON.stringify(payload)}, window.location.origin);` + // Core's q-apps.js treats unmarked messages as RPC requests and + // tries to transfer a nonexistent reply port. These are UI events. + `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify({ ...(payload as object), requestedHandler: 'UI' })}, origin: window.location.origin, source: window }));` ) .catch(() => undefined); const finish = (sourceId?: string) => { @@ -87,14 +93,26 @@ export function installDisplayMediaPicker(window: BrowserWindow): void { return; } authorized = true; - void enumerate(); + void enumerate().catch(() => finish()); }; const timer = setTimeout(() => finish(), 60_000); cancelPending = () => finish(); ipcMain.on('display-media:select', selected); ipcMain.on('display-media:authorize', authorize); - const enumerate = () => - desktopCapturer + const enumerate = async () => { + if (platform === 'darwin') { + const status = systemPreferences.getMediaAccessStatus('screen'); + if (status === 'denied' || status === 'restricted') { + await deliver({ + action: 'QAPP_SCREEN_CAPTURE_ERROR', + requestId, + code: 'SCREEN_OS_PERMISSION_REQUIRED', + }); + finish(); + return; + } + } + return desktopCapturer .getSources({ types: ['screen', 'window'], thumbnailSize: { width: 240, height: 135 }, @@ -125,6 +143,7 @@ export function installDisplayMediaPicker(window: BrowserWindow): void { }); }) .catch(() => finish()); + }; // The random, one-use request ID is disclosed only to the requesting frame // after permission. Other Q-Apps cannot select a source for this request. window.webContents.send('display-media:request', { diff --git a/src/test/mocks/electron.ts b/src/test/mocks/electron.ts index 9b22abf0..25df0ecc 100644 --- a/src/test/mocks/electron.ts +++ b/src/test/mocks/electron.ts @@ -24,6 +24,10 @@ export const ipcMain = { removeHandler: () => undefined, }; +export const systemPreferences = { + getMediaAccessStatus: (_type: string) => 'granted', +}; + export const desktopCapturer = { getSources: async (_options?: unknown): Promise => [], }; From d26a2de6a5b907ad6b3a6d5fb2403f134c2f956d Mon Sep 17 00:00:00 2001 From: PhilReact Date: Wed, 9 Sep 2026 00:45:00 +0300 Subject: [PATCH 23/32] sign BTC-family transactions locally --- .gitignore | 3 + docs/local-wallet-signing.md | 517 +++++++++++ electron/package.json | 5 +- electron/scripts/build-wallet-engine.mjs | 45 + electron/src/foreign-wallet-journal.test.ts | 52 ++ electron/src/foreign-wallet-journal.ts | 98 +++ electron/src/foreign-wallet-signer.ts | 36 + electron/src/preload.ts | 16 + electron/src/setup.ts | 77 ++ package-lock.json | 829 +++++++++++++++--- package.json | 8 +- src/background/background.ts | 18 +- src/i18n/locales/ar/question.json | 11 + src/i18n/locales/de/question.json | 11 + src/i18n/locales/en/question.json | 11 + src/i18n/locales/es/question.json | 11 + src/i18n/locales/et/question.json | 11 + src/i18n/locales/fi/question.json | 11 + src/i18n/locales/fr/question.json | 11 + src/i18n/locales/it/question.json | 11 + src/i18n/locales/ja/question.json | 11 + src/i18n/locales/pt/question.json | 11 + src/i18n/locales/ru/question.json | 11 + src/i18n/locales/zh/question.json | 11 + src/lib/foreign-wallet/desktop-engine.ts | 115 +++ src/lib/foreign-wallet/desktop-i18n.ts | 110 +++ .../foreign-wallet-policy-bounds.ts | 47 + .../foreign-wallet-spend-context.ts | 141 +++ .../foreign-wallet-spend-plan.ts | 166 ++++ .../foreign-wallet-transaction.ts | 341 +++++++ src/lib/foreign-wallet/foreign-wallets.ts | 98 +++ src/lib/foreign-wallet/psbt.test.ts | 286 ++++++ src/lib/foreign-wallet/send.test.ts | 521 +++++++++++ src/lib/foreign-wallet/send.ts | 302 +++++++ src/lib/foreign-wallet/trade-plan.test.ts | 66 ++ src/lib/foreign-wallet/trade-plan.ts | 69 ++ src/qortal/__tests__/createBuyOrder.test.ts | 113 ++- src/qortal/foreign-coin-send.test.ts | 164 ++++ src/qortal/foreign-coin-send.ts | 224 +++++ src/qortal/get.ts | 407 +-------- src/qortal/local-trade-funding.test.ts | 60 ++ src/qortal/local-trade-funding.ts | 149 ++++ src/qortal/wallet-response.test.ts | 29 + src/qortal/wallet-response.ts | 31 + src/types/window.d.ts | 19 + .../chromeStorage.foreign-wallet.test.ts | 92 ++ src/utils/chromeStorage.ts | 40 +- vite.config.ts | 34 + 48 files changed, 4908 insertions(+), 552 deletions(-) create mode 100644 docs/local-wallet-signing.md create mode 100644 electron/scripts/build-wallet-engine.mjs create mode 100644 electron/src/foreign-wallet-journal.test.ts create mode 100644 electron/src/foreign-wallet-journal.ts create mode 100644 electron/src/foreign-wallet-signer.ts create mode 100644 src/lib/foreign-wallet/desktop-engine.ts create mode 100644 src/lib/foreign-wallet/desktop-i18n.ts create mode 100644 src/lib/foreign-wallet/foreign-wallet-policy-bounds.ts create mode 100644 src/lib/foreign-wallet/foreign-wallet-spend-context.ts create mode 100644 src/lib/foreign-wallet/foreign-wallet-spend-plan.ts create mode 100644 src/lib/foreign-wallet/foreign-wallet-transaction.ts create mode 100644 src/lib/foreign-wallet/foreign-wallets.ts create mode 100644 src/lib/foreign-wallet/psbt.test.ts create mode 100644 src/lib/foreign-wallet/send.test.ts create mode 100644 src/lib/foreign-wallet/send.ts create mode 100644 src/lib/foreign-wallet/trade-plan.test.ts create mode 100644 src/lib/foreign-wallet/trade-plan.ts create mode 100644 src/qortal/foreign-coin-send.test.ts create mode 100644 src/qortal/foreign-coin-send.ts create mode 100644 src/qortal/local-trade-funding.test.ts create mode 100644 src/qortal/local-trade-funding.ts create mode 100644 src/qortal/wallet-response.test.ts create mode 100644 src/qortal/wallet-response.ts create mode 100644 src/utils/chromeStorage.foreign-wallet.test.ts diff --git a/.gitignore b/.gitignore index cb08c34d..69cdadeb 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ electron/resources/reticulum-runtime/* # PyInstaller / venv build scratch (build-rnsd-frozen.py) electron/.build/ + +# Dependency notices generated by the wallet engine build +/public/wallet-dependency-licenses.txt diff --git a/docs/local-wallet-signing.md b/docs/local-wallet-signing.md new file mode 100644 index 00000000..a8853f42 --- /dev/null +++ b/docs/local-wallet-signing.md @@ -0,0 +1,517 @@ +# Local signing for Bitcoin-derived wallets + +## Purpose + +Qortal Hub previously sent a wallet's extended private key to Qortal Core when +it needed Core to create and sign a BTC-family transaction. This worked, but it +gave Core everything needed to spend from that wallet. + +This change moves transaction construction, approval and signing into Hub for: + +- Bitcoin (BTC) +- Litecoin (LTC) +- Dogecoin (DOGE) +- DigiByte (DGB) +- Ravencoin (RVN) + +Core now receives an extended **public** key (`xpub`) to find wallet activity and +an already-signed transaction to broadcast. Hub does not send the wallet's +extended **private** key (`xprv`) to Core, and it does not fall back to the old +private-key endpoints when the new endpoints are unavailable. + +The main security property is simple: + +> Core can help find coins and publish a payment, but it no longer receives the +> wallet key that can create payments. + +## Plain-language explanation + +Before this change, sending LTC was similar to handing Core the wallet key and +asking Core to prepare, sign and send the payment. + +After this change, Core supplies public information about the wallet's coins. +Hub prepares the payment, shows the existing Hub permission prompt and signs the +approved payment locally. Core receives only the finished signed transaction and +broadcasts it to the Litecoin network. + +A transaction signature is tied to the exact transaction. Core cannot change a +recipient, amount, fee or input without making the signature invalid. The signed +transaction also cannot be reused to authorize an unrelated future payment. + +Core still learns the wallet's public key and transaction history because it +performs discovery. This is a privacy consideration, but an xpub cannot sign or +spend funds. + +## What changed + +| Feature | Previous behavior | New behavior | +| ----------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| Normal BTC-family sends | Hub sent the xprv to a Core send endpoint; Core constructed, signed and broadcast the transaction | Core supplies public spend data; Hub constructs, approves and signs locally; Core broadcasts signed bytes | +| Q-App `SEND_COIN` | The request eventually used Core's private-key send API | The Q-App contract stays the same; Hub routes it through local signing | +| Wallet Send UI | Core performed signing | The existing Hub permission prompt authorizes a locally signed transaction | +| Buy-order funding | Core used the wallet key to fund the trade | Hub validates the returned HTLC plan and signs its funding transaction locally | +| Fees | A caller could provide a fee rate, with Hub defaults otherwise | Custom fee rates remain supported; when omitted, Hub uses Core's configured rate and applies local safety limits | +| Send maximum | No common local-signing path | `sendMax: true` spends available confirmed funds minus the calculated fee | +| Uncertain broadcasts | A retry could risk constructing another payment | Hub journals the exact signed bytes and only offers to rebroadcast that transaction | +| Concurrent sends | Requests could overlap | Overlapping sends for the same wallet and coin are rejected | + +## What did not change + +- Q-Apps continue to call `qortalRequest()` with `SEND_COIN` or + `CREATE_TRADE_BUY_ORDER`. +- Q-Apps never receive wallet private keys or raw signing access. +- The user still receives the normal Hub permission request. There is no second + Electron confirmation dialog. +- QORT sending is unchanged. +- Pirate Chain (ARRR) keeps its separate wallet and transaction implementation. +- Core's existing trade state machine still watches, redeems and refunds swaps. +- Legacy Core send endpoints remain available for older clients, but this Hub + does not use them for BTC, LTC, DOGE, DGB or RVN. +- Existing wallet derivation remains unchanged, so existing addresses and funds + remain in the same wallets. + +## Ordinary send flow + +```mermaid +sequenceDiagram + participant User + participant Caller as Wallet UI / Q-App + participant Hub + participant Core + participant Network as BTC-family network + + Caller->>Hub: Request payment + Hub->>Core: xpub + expected chain ID + Core->>Network: Discover address history and confirmed UTXOs + Core-->>Hub: Public spend context + Hub->>Hub: Validate data and build transaction plan + Hub->>User: Existing amount, recipient and fee approval + User-->>Hub: Approve + Hub->>Core: Refresh public spend context + Core-->>Hub: Current spend context + Hub->>Hub: Revalidate and sign locally + Hub->>Hub: Persist exact signed bytes + Hub->>Core: Signed transaction bytes + Core->>Network: Broadcast exact transaction + Network-->>Core: Transaction ID + Core-->>Hub: Transaction ID + Hub-->>Caller: Transaction ID +``` + +Hub refreshes the spend context after approval. If the wallet, selected node, +fee recommendation, inputs, outputs or trade intent changed during approval, Hub +stops instead of signing stale information. + +## Q-App compatibility + +An ordinary Q-App send keeps the existing request shape: + +```js +const txId = await qortalRequest({ + action: 'SEND_COIN', + coin: 'LTC', + destinationAddress: 'ltc1...', + amount: '0.10000000', +}); +``` + +`recipient` is also accepted by the existing handler. A Q-App can continue to +supply `fee` in coin-per-byte units: + +```js +const txId = await qortalRequest({ + action: 'SEND_COIN', + coin: 'LTC', + recipient: 'ltc1...', + amount: '0.10000000', + fee: '0.00000020', +}); +``` + +To spend the confirmed available balance minus the transaction fee: + +```js +const txId = await qortalRequest({ + action: 'SEND_COIN', + coin: 'LTC', + destinationAddress: 'ltc1...', + sendMax: true, +}); +``` + +`sendMax: true` must not be combined with `amount`. Q-Apps do not construct a +PSBT, provide an xpub or call the new Core endpoints themselves. Hub owns those +details and returns the resulting transaction ID through the existing Q-App +response path. + +## Fee handling + +Private keys are unrelated to fee discovery. Core already has a configured fee +rate for each supported network, expressed per 1,000 bytes. The public spend +context exposes this as an integer atomic-unit rate per byte, rounded upward. + +Hub uses that rate when the request omits `fee`. If the request supplies a custom +rate, Hub currently requires it to be at least Core's configured rate and no more +than ten times that rate. Independent per-coin ceilings limit the rate and total +fee. Hub calculates the fee from the actual transaction plan and displays the +planned fee in its normal permission prompt. + +The value is a configured recommendation, not a live mempool estimate. A future +fee-estimation change can improve that recommendation without changing where the +private key lives. + +## Core API contract + +All endpoints are authenticated with the normal Core API key. + +### Public spend context + +```http +POST /crosschain/{btc|ltc|doge|dgb|rvn}/wallet/public/spend-context +Content-Type: application/json +``` + +```json +{ + "xpub58": "", + "expectedChainId": "bip122:" +} +``` + +The version 1 response contains: + +- the currency, mainnet identity and transaction-format constants; +- the current chain height; +- the minimum non-dust output and recommended fee rate as decimal strings; +- confirmed UTXOs with their receive/change derivation paths; +- the public scripts, values and full previous transactions needed to validate + and sign legacy inputs. + +Core rejects private extended keys and mismatched chain IDs. Discovery scans +both receive and change branches with a 20-address gap limit. It is bounded to +2,000 keys per branch, 1,000 outputs and 8 MB of distinct previous transaction +data. Exceeding a bound fails the request instead of returning a partial wallet. + +For performance, Core checks address history, including mempool history, for the +full gap. It only requests unspent outputs for addresses that have history. A +normal wallet with one used address therefore needs roughly 42 address queries +instead of the original implementation's 82. Hub intentionally performs a fresh +scan after approval so it does not sign using stale inputs. + +### Broadcast + +```http +POST /crosschain/{btc|ltc|doge|dgb|rvn}/send/broadcast +Content-Type: application/json +``` + +```json +{ + "expectedChainId": "bip122:", + "rawTransactionHex": "" +} +``` + +Core parses and structurally verifies the transaction, broadcasts the exact +bytes, and requires the Electrum server to return the transaction ID calculated +from those bytes. It returns that transaction ID to Hub. A broadcast response is +an acknowledgement, not a blockchain confirmation. + +### Local trade preparation + +```http +POST /crosschain/tradebot/respond/local +Content-Type: application/json +``` + +```json +{ + "addresses": [""], + "xpub58": "", + "receivingAddress": "", + "expectedChainId": "bip122:" +} +``` + +The response contains the HTLC destinations, amounts, redeem scripts, refund +public-key hashes, secret hashes and lock times that Hub must validate before it +funds the trades. + +## Local transaction construction and signing + +Hub uses pinned `@scure/btc-signer` 2.4.1 for transaction parsing, +address/script encoding, BIP 174 PSBT handling, signature hashes, signing and +finalization. It uses the existing HD wallet roots and derivation scheme, so the +change does not create new wallets. + +The local flow performs these checks: + +1. Parse the public spend context with strict type, size and network checks. +2. Verify each previous transaction hash and the referenced output index. +3. Verify each UTXO's value and locking script against its previous transaction. +4. Derive the public child key at the supplied path and verify that it owns the + input address and script. +5. Select confirmed inputs and calculate outputs, change and fee with integer + atomic units (`bigint`), avoiding floating-point money calculations. +6. Validate recipient formats and per-coin policy bounds. +7. Obtain permission through Hub's existing approval dialog. +8. Fetch current spend data and require the planned transaction to remain the + same. +9. Build a PSBT containing full previous transactions, derivation metadata, + final sequences and `SIGHASH_ALL`. +10. Rebuild and compare the expected PSBT at the signer boundary before accessing + the key. +11. Sign locally, finalize the transaction and compare its unsigned bytes with + the approved unsigned transaction. +12. Verify the final fee, transaction ID and serialized size before persistence + and broadcast. + +The PSBT and private key are never included in a Core request. + +## Desktop, browser and Android behavior + +On Electron, Hub transfers the five BTC-family roots to an in-memory main-process +signer session when the wallet unlocks. The signer provides public-key lookup and +transaction signing operations; it does not expose key export or arbitrary +message/digest signing. IPC is restricted to the main Hub window's main frame. + +The normal Hub permission prompt occurs before the signing request. The signer +does not display a duplicate native confirmation. It still reconstructs and +validates the expected transaction before signing. Logout, account replacement, +navigation, renderer failure or window destruction clears the signer session. +The signer does not persist its private keys. + +Browser and Android builds perform the same transaction construction, checking +and signing locally in their application context. They also keep the private key +out of Core requests. + +## Trade funding + +Local funding supports ACCTv3 buy orders for the five listed coins. Core first +creates and durably stores a pending trade record in +`ALICE_WAITING_FOR_FUNDING` (state 80), including the watch-only wallet xpub and +the separate trade key and secret. It then returns the funding plan. + +Hub binds that plan to the selected offers and verifies: + +- AT identity and requested Qortal receiving address; +- seller public-key hash; +- HTLC redeem script and derived P2SH address; +- foreign amount plus the bounded funding reserve; +- lock time and expiration window. + +Hub signs one transaction containing the validated HTLC outputs. Core observes +confirmed funding and continues the existing offer-message, AT-lock, redeem and +refund state machine, including when Hub is closed. + +The trade-specific private key and secret remain in Core because Core needs them +to complete or refund an active trade. They are newly generated for that trade +and are not the wallet's root private key. This change is specifically about +keeping the reusable BTC-family wallet xprv out of Core. + +Repeated preparation of the same pending trade is idempotent. Existing trades +are not rewritten or deleted. An older Core must not replace an updated Core +while state 80 trades exist because it does not understand that state. + +## Broadcast journal and duplicate-payment prevention + +A network timeout does not prove that a broadcast failed. Creating a new payment +after a timeout can spend a second set of inputs and pay twice. + +Before broadcast, Hub saves: + +- the transaction ID; +- the exact signed transaction bytes; +- the input outpoints reserved by the transaction; +- the broadcast deadline for time-sensitive trade funding. + +Electron stores this in a dedicated fsynced journal and uses exclusive creation +to arbitrate concurrent application instances. Browser and Android use local +storage and Web Locks where available. Overlapping sends for the same wallet and +coin are rejected rather than queued. + +Hub keeps a reservation after a successful broadcast acknowledgement until +wallet history sees the transaction and the inputs disappear from discovery. If +the outcome remains uncertain, the next send can offer to rebroadcast only the +exact saved bytes with explicit permission. Recovery never builds a replacement +transaction and never reports an older recovered payment as success for a new +request. + +Corrupt journal entries, old entries without saved bytes, expired trade-funding +transactions and abandoned desktop lock files fail closed and require manual +investigation. + +## Security properties and trust boundaries + +This change provides the property it was designed for: **Core does not receive +the reusable BTC, LTC, DOGE, DGB or RVN wallet private key from the updated +Hub.** + +It also limits what Core can do with a valid signed transaction. The signature +commits to its inputs and outputs, so changing payment details invalidates it. +Hub independently enforces fee and response-size ceilings before signing. + +The remaining trust boundaries are: + +- Core supplies chain data. Hub validates internal consistency and ownership, + but does not independently synchronize block headers or verify inclusion + proofs. +- Core sees the wallet xpub and can observe its derived address history. +- Core retains separate per-trade keys and secrets for swap execution and + refunds. +- Electron's main process holds the BTC-family roots in memory while the wallet + is unlocked. +- The existing Qortal unlock and seed-derivation flow still runs in the renderer, + and Qortal's Ed25519 secret contains the account seed used to derive these + wallets. This is not a hardware-backed or fully isolated seed vault. +- JavaScript strings cannot be reliably zeroized; clearing a session releases + references but cannot guarantee immediate memory erasure. + +These limits do not change the primary result: no updated Hub request sends the +wallet xprv to Core. + +## Supported transaction scope + +The current implementation supports: + +- mainnet BTC, LTC, DOGE, DGB and RVN; +- confirmed legacy P2PKH wallet inputs using existing receive/change derivation; +- P2PKH and P2SH recipients for all five coins; +- witness-v0 recipient outputs for BTC, LTC and DGB; +- ordinary coin transfers and ACCTv3 trade funding. + +It does not add: + +- SegWit wallet-input derivation; +- Taproot; +- Litecoin MWEB; +- Ravencoin asset transactions; +- testnet wallets; +- ACCTv1 local funding; +- public-gateway or proxy-node trade buys; +- fresh change-address allocation (change currently returns to a verified + selected input address). + +PSBT expansion is limited to 8 MB of repeated previous-transaction data and 256 +MiB of repeated validation work. Very fragmented wallets may need smaller +payments. + +## Failure behavior and compatibility + +Hub fails rather than sending a private key when Core does not support the new +endpoints. Users must run matching updated Hub and Core versions. + +Typical failures include: + +- `upgrade`: Core or the desktop bridge does not expose the required API; +- `changed`: the wallet, node, spend data or trade intent changed during the + approval/signing flow; +- `declined`: the user rejected Hub's normal permission request; +- `pending`: another send is active or a previous transaction remains reserved; +- `unknown`: broadcast outcome is uncertain, so the exact transaction remains + journaled for safe recovery; +- `invalid`: input, response, address, amount, fee or signed transaction + validation failed. + +## Implementation map + +The main Hub components are: + +- `src/qortal/get.ts`: routes normal sends and trade buys to local signing; +- `src/qortal/foreign-coin-send.ts`: connects Q-App/UI requests to the signer, + Core endpoints, permission flow and journal; +- `src/qortal/local-trade-funding.ts`: prepares and validates local ACCTv3 + funding; +- `src/lib/foreign-wallet/send.ts`: approval, refresh, signing, persistence, + recovery and broadcast orchestration; +- `src/lib/foreign-wallet/foreign-wallet-spend-plan.ts`: public-only input and + output planning; +- `src/lib/foreign-wallet/foreign-wallet-transaction.ts`: PSBT construction, + signing and signed-transaction inspection; +- `src/lib/foreign-wallet/foreign-wallet-spend-context.ts`: strict Core response + parsing; +- `src/lib/foreign-wallet/trade-plan.ts`: HTLC-plan verification; +- `src/lib/foreign-wallet/desktop-engine.ts`: Electron signer state and signing + validation; +- `electron/src/foreign-wallet-signer.ts`: restricted main-process signer + adapter; +- `electron/src/foreign-wallet-journal.ts`: durable pending-transaction journal. + +The main Core components are: + +- `LocalWalletSupport`: public wallet discovery and signed-byte broadcast; +- `ForeignWalletRequest`: discovery and broadcast request model; +- the five `CrossChain*Resource` classes: coin-specific endpoint exposure; +- `LocalTradeFunding`: durable local trade preparation and progression; +- `CrossChainTradeBotResource`: local funding endpoint; +- the five ACCTv3 trade bots and `TradeStates`: state 80 handling; +- `LocalWalletResponseWriter`: exact JSON serialization for public wallet and + trade-plan responses. + +## Validation + +Hub's focused tests cover all five networks, PSBT construction and signing, +public-only planning, forged previous transactions and amounts, wallet changes, +fee limits, trade-plan validation, response-size limits, journal persistence, +ambiguous broadcasts, exact-byte recovery, concurrent requests and permission +declines. + +Core's tests use bitcoinj independently to verify the Hub-generated signatures +for all five networks. They also test private-key rejection, chain mismatch, +forged data, exact-byte broadcasting, query behavior, idempotent trade +preparation and the actual Jersey HTTP serialization used by Hub. Test fixtures +under `src/test/resources/crosschain/local-wallet/` use synthetic, publicly known +keys and must never receive real funds. + +Run the focused checks with: + +```bash +# Hub +npx vitest run src/lib/foreign-wallet \ + src/qortal/foreign-coin-send.test.ts \ + src/qortal/local-trade-funding.test.ts \ + src/qortal/wallet-response.test.ts \ + src/qortal/__tests__/createBuyOrder.test.ts \ + src/utils/chromeStorage.foreign-wallet.test.ts \ + electron/src/foreign-wallet-journal.test.ts + +# Core (Core skips JUnit unless explicitly enabled) +mvn -DskipJUnitTests=false \ + -Dtest=LocalWalletTests,LocalWalletApiTests test +``` + +Automated tests do not send real funds. A controlled release test should still +cover a funded send and complete trade/redeem/refund lifecycle. + +## Build and deployment + +Both Hub and Core must be deployed together. + +For Hub: + +```bash +npm run build +npx cap sync @capacitor-community/electron +``` + +The Hub build bundles the shared signing engine and its pinned dependencies into +`electron/build/foreign-wallet-engine.cjs`. The Electron build invokes the same +bundle step. Dependency license notices are generated from the bundled packages. + +For Core: + +```bash +mvn -DskipJUnitTests=true package +``` + +Install the resulting Core JAR and restart Core. An existing running process +continues using its old classes until it is restarted. + +## References + +- [BIP 174: Partially Signed Bitcoin Transaction Format](https://bips.dev/174/) +- [Scure BTC Signer security documentation](https://github.com/paulmillr/scure-btc-signer#security) +- [Electron context isolation](https://www.electronjs.org/docs/latest/tutorial/context-isolation) + +Using an audited library reduces custom cryptographic code, but a library audit +does not audit Hub's integration or future dependency versions. diff --git a/electron/package.json b/electron/package.json index 26daced2..16a7e56e 100644 --- a/electron/package.json +++ b/electron/package.json @@ -27,7 +27,7 @@ }, "main": "build/src/index.js", "scripts": { - "build": "tsc && electron-rebuild", + "build": "npm run build:wallet && tsc && electron-rebuild", "test:reticulum-chat-worker": "npm run build && electron ./scripts/test-reticulum-chat-worker.cjs", "electron:start-live": "node ./live-runner.js", "electron:start": "npm run build:private-transport && npm run build && electron --inspect=5858 ./", @@ -48,7 +48,8 @@ "bundle:reticulum": "node ./scripts/run-build-rnsd-frozen.mjs", "bundle:reticulum-venv": "node ./scripts/bundle-reticulum-runtime.mjs", "build:private-transport": "node ./scripts/build-private-transport.mjs", - "generate:mesh-network-identity": "python3 -c \"import RNS; RNS.Identity().to_file('resources/mesh-network.identity')\"" + "generate:mesh-network-identity": "python3 -c \"import RNS; RNS.Identity().to_file('resources/mesh-network.identity')\"", + "build:wallet": "node ./scripts/build-wallet-engine.mjs" }, "dependencies": { "@capacitor-community/electron": "^5.0.0", diff --git a/electron/scripts/build-wallet-engine.mjs b/electron/scripts/build-wallet-engine.mjs new file mode 100644 index 00000000..5c6db99a --- /dev/null +++ b/electron/scripts/build-wallet-engine.mjs @@ -0,0 +1,45 @@ +import { build } from '../../node_modules/esbuild/lib/main.js'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const outfile = path.join(root, 'electron/build/foreign-wallet-engine.cjs'); +const result = await build({ + absWorkingDir: root, + entryPoints: [path.join(root, 'src/lib/foreign-wallet/desktop-engine.ts')], + outfile, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + sourcemap: false, + legalComments: 'eof', + metafile: true, +}); +// Preserve the licenses of bundled dependencies on every supported platform. +const packages = new Set(); +for (const input of Object.keys(result.metafile.inputs)) { + const match = input.match(/^(.*node_modules\/(?:@[^/]+\/)?[^/]+)\//); + if (match) packages.add(match[1]); +} +const notices = []; +for (const pkg of [...packages].sort()) { + const metadata = JSON.parse( + await fs.readFile(path.join(root, pkg, 'package.json'), 'utf8') + ); + const names = await fs.readdir(path.join(root, pkg)); + const license = names.find((name) => + /^licen[sc]e(?:\.(?:md|txt))?$/i.test(name) + ); + if (!license) throw new Error(`Missing dependency license: ${metadata.name}`); + notices.push( + `${metadata.name} ${metadata.version}\n${await fs.readFile(path.join(root, pkg, license), 'utf8')}` + ); +} +const text = notices.join('\n\n'); +await fs.appendFile(outfile, `\n/*\n${text.replaceAll('*/', '* /')}\n*/\n`); +await fs.mkdir(path.join(root, 'public'), { recursive: true }); +await fs.writeFile( + path.join(root, 'public/wallet-dependency-licenses.txt'), + text +); diff --git a/electron/src/foreign-wallet-journal.test.ts b/electron/src/foreign-wallet-journal.test.ts new file mode 100644 index 00000000..20dfdcb3 --- /dev/null +++ b/electron/src/foreign-wallet-journal.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { createForeignWalletJournal } from './foreign-wallet-journal'; +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })) + ); +}); +it('persists reservations across restarts and refuses concurrent reservations', async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'foreign-wallet-test-') + ); + directories.push(directory); + const key = `foreign-send-v1:LTC:${'a'.repeat(64)}`; + const raw = JSON.stringify({ + txId: 'b'.repeat(64), + outpoints: [`${'c'.repeat(64)}:0`], + }); + const first = createForeignWalletJournal(directory); + const second = createForeignWalletJournal(directory); + const results = await Promise.allSettled([ + first.set(key, raw), + second.set(key, raw), + ]); + expect( + results.filter((result) => result.status === 'fulfilled') + ).toHaveLength(1); + expect(await createForeignWalletJournal(directory).get(key)).toBe(raw); + await expect(first.delete(key, 'd'.repeat(64))).rejects.toThrow(); + await first.delete(key, 'b'.repeat(64)); + expect(await second.get(key)).toBeNull(); +}); +it('refuses corrupt journals and path traversal instead of reporting no pending send', async () => { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'foreign-wallet-test-') + ); + directories.push(directory); + const key = `foreign-send-v1:BTC:${'a'.repeat(64)}`; + const journal = createForeignWalletJournal(directory); + await fs.writeFile( + path.join(directory, key.replace(/:/g, '-') + '.json'), + '{' + ); + await expect(journal.get(key)).rejects.toThrow(); + await expect(journal.get('../wallet')).rejects.toThrow(); + await expect(journal.set(key, '{}')).rejects.toThrow(); +}); diff --git a/electron/src/foreign-wallet-journal.ts b/electron/src/foreign-wallet-journal.ts new file mode 100644 index 00000000..7c2054c0 --- /dev/null +++ b/electron/src/foreign-wallet-journal.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** Immutable per-wallet reservations. Corruption and disk errors fail closed. */ +export function createForeignWalletJournal(directory: string) { + const fileFor = (key: string) => { + if ( + typeof key !== 'string' || + !/^foreign-send-v1:(BTC|LTC|DOGE|DGB|RVN):[a-f0-9]{64}$/.test(key) + ) + throw new Error('Invalid journal key'); + return path.join(directory, key.replace(/:/g, '-') + '.json'); + }; + const validate = (raw: string) => { + if (typeof raw !== 'string' || raw.length > 500000) + throw new Error('Invalid journal'); + const entry = JSON.parse(raw); + if ( + !entry || + !/^[a-f0-9]{64}$/.test(entry.txId) || + !Array.isArray(entry.outpoints) || + entry.outpoints.length < 1 || + entry.outpoints.length > 1000 || + (entry.rawTransactionHex !== undefined && + (typeof entry.rawTransactionHex !== 'string' || + entry.rawTransactionHex.length > 400000 || + !/^(?:[a-f0-9]{2})+$/.test(entry.rawTransactionHex))) || + (entry.broadcastBefore !== undefined && + (!Number.isSafeInteger(entry.broadcastBefore) || + entry.broadcastBefore <= 0)) || + entry.outpoints.some( + (point: unknown) => + typeof point !== 'string' || !/^[a-f0-9]{64}:[0-9]{1,10}$/.test(point) + ) + ) + throw new Error('Invalid journal'); + return raw; + }; + const read = async (key: string): Promise => { + try { + return validate(await fs.readFile(fileFor(key), 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + }; + const syncDirectory = async () => { + // Windows does not support opening directories for fsync via Node. + if (process.platform === 'win32') return; + const handle = await fs.open(directory, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + }; + const mutate = async ( + key: string, + operation: () => Promise + ): Promise => { + await fs.mkdir(directory, { recursive: true }); + const lock = fileFor(key) + '.lock'; + await fs.mkdir(lock); + try { + return await operation(); + } finally { + await fs.rmdir(lock); + } + }; + return { + get: read, + set: (key: string, raw: string) => + mutate(key, async () => { + const filename = fileFor(key); + validate(raw); + await fs.mkdir(directory, { recursive: true }); + // O_EXCL also arbitrates independent Electron instances. A partially written + // reservation is deliberately retained on failure and blocks further sends. + const handle = await fs.open(filename, 'wx', 0o600); + try { + await handle.writeFile(raw, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(); + }), + delete: (key: string, expectedTxId: string) => + mutate(key, async () => { + const current = await read(key); + if (!current || JSON.parse(current).txId !== expectedTxId) + throw new Error('Journal changed before reconciliation'); + // Only the host's reconciliation path calls this after observing the spend. + await fs.unlink(fileFor(key)); + await syncDirectory(); + }), + }; +} diff --git a/electron/src/foreign-wallet-signer.ts b/electron/src/foreign-wallet-signer.ts new file mode 100644 index 00000000..37bc5e15 --- /dev/null +++ b/electron/src/foreign-wallet-signer.ts @@ -0,0 +1,36 @@ +import path from 'node:path'; +import { createRequire } from 'node:module'; + +// The bundle is produced from the same implementation used by browser/Android, +// with its dependencies included; Electron never resolves renderer node_modules. +let engine: any; +let signer: any; +function getEngine() { + return (engine ??= createRequire(__filename)( + path.join(__dirname, '..', 'foreign-wallet-engine.cjs') + )); +} +function getSigner() { + return (signer ??= getEngine().createDesktopWalletSigner()); +} +export const clearForeignWalletSigner = () => signer?.clear(); +export const importForeignWalletKeys = (keys: Record) => + getSigner().importKeys(keys); +export const foreignWalletPublicKey = (coin: string) => + getSigner().publicKey(coin); +export async function signForeignWalletPayment(request: unknown) { + try { + // Hub has already obtained permission and revalidated the approved payment. + // Keep the signer's validation/session checks without a second desktop prompt. + const signed = await getSigner().sign(request, async () => true); + return { signed }; + } catch (error) { + // Never forward native errors or key material to renderer error reporting. + const code = (error as { code?: string })?.code; + return { + error: ['invalid', 'changed', 'declined', 'pending'].includes(code) + ? code + : 'invalid', + }; + } +} diff --git a/electron/src/preload.ts b/electron/src/preload.ts index b446581f..acaf37e3 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -1014,6 +1014,22 @@ try { }); // Generic persistent store (persistent-store.json, in-memory cache + debounced writes in main) + contextBridge.exposeInMainWorld('foreignWalletSigner', { + importKeys: (keys: Record) => + ipcRenderer.invoke('foreignWalletSigner:import', keys), + publicKey: (coin: string) => + ipcRenderer.invoke('foreignWalletSigner:publicKey', coin), + clear: () => ipcRenderer.invoke('foreignWalletSigner:clear'), + sign: (request: unknown, language: string) => + ipcRenderer.invoke('foreignWalletSigner:sign', request, language), + }); + contextBridge.exposeInMainWorld('foreignWalletJournal', { + get: (key: string) => ipcRenderer.invoke('foreignWalletJournal:get', key), + set: (key: string, value: string) => + ipcRenderer.invoke('foreignWalletJournal:set', key, value), + delete: (key: string, txId: string) => + ipcRenderer.invoke('foreignWalletJournal:delete', key, txId), + }); contextBridge.exposeInMainWorld('appStorage', { get: async (key) => { return ipcRenderer.invoke('persistentStore:get', key); diff --git a/electron/src/setup.ts b/electron/src/setup.ts index bb31bb53..654c5b6c 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -1,3 +1,10 @@ +import { + clearForeignWalletSigner, + importForeignWalletKeys, + foreignWalletPublicKey, + signForeignWalletPayment, +} from './foreign-wallet-signer'; +import { createForeignWalletJournal } from './foreign-wallet-journal'; import type { CapacitorElectronConfig } from '@capacitor-community/electron'; import { CapElectronEventEmitter, @@ -1708,6 +1715,76 @@ export function flushMiscPersistentStore(): void { miscPersistentStore.flush(); } +let foreignSignerOwner: number | null = null; +function requireForeignWalletHost(event: Electron.IpcMainInvokeEvent) { + if ( + !isMainShellSender(event.sender) || + event.senderFrame !== event.sender.mainFrame + ) + throw new Error('Wallet signer is restricted to the main frame'); + const parent = BrowserWindow.fromWebContents(event.sender); + if (!parent) throw new Error('Wallet window unavailable'); + return parent; +} +ipcMain.handle('foreignWalletSigner:import', (event, keys) => { + requireForeignWalletHost(event); + const publicKeys = importForeignWalletKeys(keys); + foreignSignerOwner = event.sender.id; + return publicKeys; +}); +ipcMain.handle('foreignWalletSigner:publicKey', (event, coin) => { + requireForeignWalletHost(event); + return foreignWalletPublicKey(coin); +}); +ipcMain.handle('foreignWalletSigner:clear', (event) => { + requireForeignWalletHost(event); + clearForeignWalletSigner(); +}); +ipcMain.handle('foreignWalletSigner:sign', (event, request) => { + requireForeignWalletHost(event); + return signForeignWalletPayment(request); +}); +app.on('before-quit', clearForeignWalletSigner); +app.on('web-contents-created', (_event, contents) => { + const contentsId = contents.id; + const clearIfHost = () => { + if (foreignSignerOwner === contentsId) clearForeignWalletSigner(); + }; + contents.on( + 'did-start-navigation', + (_event, _url, isInPlace, isMainFrame) => { + if (isMainFrame && !isInPlace) clearIfHost(); + } + ); + contents.on('render-process-gone', clearIfHost); + contents.on('destroyed', clearIfHost); +}); + +let foreignWalletJournal: ReturnType; +function getForeignWalletJournal(event: Electron.IpcMainInvokeEvent) { + if ( + !isMainShellSender(event.sender) || + event.senderFrame !== event.sender.mainFrame + ) + throw new Error('Foreign wallet journal is restricted to the main frame'); + return (foreignWalletJournal ??= createForeignWalletJournal( + path.join(app.getPath('appData'), 'qortal-hub', 'foreign-wallet-journal') + )); +} +ipcMain.handle('foreignWalletJournal:get', (event, key: string) => + getForeignWalletJournal(event).get(key) +); +ipcMain.handle( + 'foreignWalletJournal:set', + (event, key: string, value: string) => + getForeignWalletJournal(event).set(key, value) +); +ipcMain.handle( + 'foreignWalletJournal:delete', + (event, key: string, txId: string) => + getForeignWalletJournal(event).delete(key, txId) +); + ipcMain.handle('persistentStore:get', async (_event, key: string) => persistentStore.get(key) ); diff --git a/package-lock.json b/package-lock.json index 0787611f..2c75040c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,11 @@ "@mui/icons-material": "^7.0.1", "@mui/lab": "^7.0.0-beta.11", "@mui/material": "^7.0.1", + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", + "@scure/base": "1.2.6", + "@scure/bip32": "1.7.0", + "@scure/btc-signer": "2.4.1", "@tanstack/react-virtual": "^3.13.6", "@testing-library/jest-dom": "^6.4.6", "@testing-library/user-event": "^14.6.1", @@ -106,6 +111,7 @@ "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "@vitejs/plugin-react": "^4.7.0", + "esbuild": "0.28.2", "esbuild-plugin-react-virtualized": "^1.0.5", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -2684,12 +2690,13 @@ "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2700,12 +2707,13 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2716,12 +2724,13 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2732,12 +2741,13 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2748,12 +2758,13 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2764,12 +2775,13 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2780,12 +2792,13 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2796,12 +2809,13 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2812,12 +2826,13 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2828,12 +2843,13 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2844,12 +2860,13 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2860,12 +2877,13 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2876,12 +2894,13 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2892,12 +2911,13 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2908,12 +2928,13 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2924,12 +2945,13 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2940,12 +2962,13 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2956,12 +2979,13 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2972,12 +2996,13 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2988,12 +3013,13 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3004,12 +3030,13 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3020,12 +3047,13 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3036,12 +3064,13 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3052,12 +3081,13 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3068,12 +3098,13 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3084,12 +3115,13 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5105,6 +5137,33 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -5570,6 +5629,80 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.4.1.tgz", + "integrity": "sha512-ch6h66o9nPhEoe/KciMMUJeH2MBPB8UAJI1if35Fxl6885lKTsc9YoCRqTW0/RjI0Vv9GSg1tmKtHHODyAw81g==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0", + "micro-packed": "0.11.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/btc-signer/node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -10166,9 +10299,10 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -10178,32 +10312,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/esbuild-plugin-react-virtualized": { @@ -14026,6 +14160,30 @@ "node": ">= 8" } }, + "node_modules/micro-packed": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.11.1.tgz", + "integrity": "sha512-agQGBnBCw8OoKQZMUxcaQPaOowdfxyEmBsq4CNAXAXCyB8KxlRG0lTl5GADJIsMEDaFb0trhXBHKYsjTWa5kuw==", + "license": "MIT", + "dependencies": { + "@scure/base": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/micro-packed/node_modules/@scure/base": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.4.0.tgz", + "integrity": "sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -21584,6 +21742,463 @@ "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", diff --git a/package.json b/package.json index bb04f6a1..7a88065c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "build": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build", + "build": "node electron/scripts/build-wallet-engine.mjs && node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build", "coverage": "vitest run --coverage", "dev": "vite", "format": "prettier --write .", @@ -38,6 +38,11 @@ "@mui/icons-material": "^7.0.1", "@mui/lab": "^7.0.0-beta.11", "@mui/material": "^7.0.1", + "@noble/curves": "1.9.7", + "@noble/hashes": "1.8.0", + "@scure/base": "1.2.6", + "@scure/bip32": "1.7.0", + "@scure/btc-signer": "2.4.1", "@tanstack/react-virtual": "^3.13.6", "@testing-library/jest-dom": "^6.4.6", "@testing-library/user-event": "^14.6.1", @@ -120,6 +125,7 @@ "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", "@vitejs/plugin-react": "^4.7.0", + "esbuild": "0.28.2", "esbuild-plugin-react-virtualized": "^1.0.5", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", diff --git a/src/background/background.ts b/src/background/background.ts index 8cbb2745..da231770 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -255,20 +255,18 @@ export const clearAllQueues = () => { }; export const getForeignKey = async (foreignBlockchain) => { + if ( + ['BITCOIN', 'LITECOIN', 'DOGECOIN', 'DIGIBYTE', 'RAVENCOIN'].includes( + foreignBlockchain + ) + ) { + throw new Error(i18n.t('question:local_send.upgrade')); + } + const resKeyPair = await getKeyPair(); const parsedData = resKeyPair; switch (foreignBlockchain) { - case 'LITECOIN': - return parsedData.ltcPrivateKey; - case 'DOGECOIN': - return parsedData.dogePrivateKey; - case 'BITCOIN': - return parsedData.btcPrivateKey; - case 'DIGIBYTE': - return parsedData.dgbPrivateKey; - case 'RAVENCOIN': - return parsedData.rvnPrivateKey; case 'PIRATECHAIN': return parsedData.arrrSeed58; default: diff --git a/src/i18n/locales/ar/question.json b/src/i18n/locales/ar/question.json index 457f65a1..8df1ceb9 100644 --- a/src/i18n/locales/ar/question.json +++ b/src/i18n/locales/ar/question.json @@ -13,6 +13,17 @@ "deploy_at": "عايز تنشر الـ AT دا؟", "description": "الوصف: {{ description }}", "download_file": "عايز تحمل الملف دا؟", + "local_send": { + "changed": "تغيرت المحفظة أو العقدة أو الأموال المتاحة. راجع الدفع مرة أخرى.", + "declined": "تم رفض الدفع.", + "invalid": "تعذر إعداد هذا الدفع بأمان. تحقق من العنوان والمبلغ والرصيد والرسوم والاتصال بالعقدة.", + "native_body": "المحفظة: {{wallet}}\n\nالمخرجات:\n{{outputs}}\n\nرسوم الشبكة: {{fee}}", + "native_title": "تأكيد الدفع من المحفظة", + "pending": "يجب التحقق من دفعة سابقة قبل إرسال دفعة أخرى. المعاملة: {{txId}}", + "recovery": "لا تزال دفعة تمت الموافقة عليها سابقًا معلّقة. هل تريد إعادة بث المعاملة نفسها؟ لن يؤدي ذلك إلى إرسال الدفعة الجديدة التي طلبتها.", + "unknown": "نتيجة البث غير معروفة. لا ترسل مرة أخرى. تحقق من المعاملة {{txId}} في سجل المحفظة.", + "upgrade": "يتطلب توقيع معاملات المحفظة محليًا إصدارات محدثة من Qortal Core وQortal Hub." + }, "message": { "error": { "add_to_list": "مش عارف أضيف في القايمة", diff --git a/src/i18n/locales/de/question.json b/src/i18n/locales/de/question.json index 214353bb..3729cbd0 100644 --- a/src/i18n/locales/de/question.json +++ b/src/i18n/locales/de/question.json @@ -13,6 +13,17 @@ "deploy_at": "Möchten Sie dieses AT bereitstellen?", "description": "Beschreibung: {{ description }}", "download_file": "Möchten Sie herunterladen:", + "local_send": { + "changed": "Wallet, Node oder verfügbare Mittel haben sich geändert. Prüfe die Zahlung erneut.", + "declined": "Zahlung abgelehnt.", + "invalid": "Diese Zahlung kann nicht sicher vorbereitet werden. Prüfe Adresse, Betrag, Guthaben, Gebühr und Node-Verbindung.", + "native_body": "Wallet: {{wallet}}\n\nAusgänge:\n{{outputs}}\n\nNetzwerkgebühr: {{fee}}", + "native_title": "Wallet-Zahlung bestätigen", + "pending": "Eine frühere Zahlung muss abgeglichen werden, bevor eine weitere gesendet werden kann. Transaktion: {{txId}}", + "recovery": "Eine zuvor genehmigte Zahlung ist noch ausstehend. Soll dieselbe Transaktion erneut übertragen werden? Die neu angeforderte Zahlung wird dabei nicht gesendet.", + "unknown": "Das Ergebnis der Übertragung ist unbekannt. Nicht erneut senden. Prüfe Transaktion {{txId}} im Wallet-Verlauf.", + "upgrade": "Das lokale Signieren von Wallet-Transaktionen erfordert aktuelle Versionen von Qortal Core und Qortal Hub." + }, "message": { "error": { "add_to_list": "Fehler beim Hinzufügen zur Liste", diff --git a/src/i18n/locales/en/question.json b/src/i18n/locales/en/question.json index 15716395..ce2e643c 100644 --- a/src/i18n/locales/en/question.json +++ b/src/i18n/locales/en/question.json @@ -13,6 +13,17 @@ "deploy_at": "would you like to deploy this AT?", "description": "description: {{ description }}", "download_file": "would you like to download:", + "local_send": { + "changed": "The wallet, node, or spendable funds changed. Please review the payment again.", + "declined": "Payment declined.", + "invalid": "Unable to prepare this payment safely. Check the address, amount, balance, fee, and node connection.", + "native_body": "Wallet: {{wallet}}\n\nOutputs:\n{{outputs}}\n\nNetwork fee: {{fee}}", + "native_title": "Confirm wallet payment", + "pending": "A previous payment needs reconciliation before another can be sent. Transaction: {{txId}}", + "recovery": "A previously approved payment is still pending. Retry broadcasting that same transaction? This will not send the new payment you requested.", + "unknown": "The broadcast outcome is unknown. Do not send again. Check transaction {{txId}} in your wallet history.", + "upgrade": "Local wallet signing requires updated versions of Qortal Core and Qortal Hub." + }, "message": { "error": { "add_to_list": "failed to add to list", diff --git a/src/i18n/locales/es/question.json b/src/i18n/locales/es/question.json index 8c6ed0f4..9c842a35 100644 --- a/src/i18n/locales/es/question.json +++ b/src/i18n/locales/es/question.json @@ -13,6 +13,17 @@ "deploy_at": "¿Desea implementar este AT?", "description": "descripción: {{ description }}", "download_file": "¿Desea descargar:", + "local_send": { + "changed": "La cartera, el nodo o los fondos disponibles han cambiado. Revisa el pago de nuevo.", + "declined": "Pago rechazado.", + "invalid": "No se puede preparar este pago de forma segura. Comprueba la dirección, el importe, el saldo, la comisión y la conexión al nodo.", + "native_body": "Cartera: {{wallet}}\n\nSalidas:\n{{outputs}}\n\nComisión de red: {{fee}}", + "native_title": "Confirmar pago de la cartera", + "pending": "Es necesario comprobar un pago anterior antes de enviar otro. Transacción: {{txId}}", + "recovery": "Un pago autorizado anteriormente sigue pendiente. ¿Volver a transmitir esa misma transacción? Esto no enviará el nuevo pago solicitado.", + "unknown": "Se desconoce el resultado de la transmisión. No vuelvas a enviar el pago. Comprueba la transacción {{txId}} en el historial de la cartera.", + "upgrade": "La firma local de transacciones de la cartera requiere versiones actualizadas de Qortal Core y Qortal Hub." + }, "message": { "error": { "add_to_list": "no se pudo agregar a la lista", diff --git a/src/i18n/locales/et/question.json b/src/i18n/locales/et/question.json index 6c8a3b61..c13fe30a 100644 --- a/src/i18n/locales/et/question.json +++ b/src/i18n/locales/et/question.json @@ -13,6 +13,17 @@ "deploy_at": "kas sa soovid seda AT-d (automaatset tehingut) käivitada?", "description": "kirjeldus: {{ description }}", "download_file": "kas sa soovid allalaadida:", + "local_send": { + "changed": "Rahakott, sõlm või saadaolevad vahendid on muutunud. Vaata makse uuesti üle.", + "declined": "Makse tagasi lükatud.", + "invalid": "Makset ei saa turvaliselt ette valmistada. Kontrolli aadressi, summat, saldot, tasu ja ühendust sõlmega.", + "native_body": "Rahakott: {{wallet}}\n\nVäljundid:\n{{outputs}}\n\nVõrgutasu: {{fee}}", + "native_title": "Kinnita rahakoti makse", + "pending": "Enne uue makse saatmist tuleb eelmine makse üle kontrollida. Tehing: {{txId}}", + "recovery": "Varem kinnitatud makse on endiselt ootel. Kas edastada sama tehing uuesti võrku? See ei saada sinu taotletud uut makset.", + "unknown": "Edastamise tulemus pole teada. Ära saada uuesti. Kontrolli tehingut {{txId}} rahakoti ajaloos.", + "upgrade": "Rahakoti tehingute kohalik allkirjastamine nõuab Qortal Core’i ja Qortal Hubi uuendatud versioone." + }, "message": { "error": { "add_to_list": "nimekirja lisamine ebaõnnestus", diff --git a/src/i18n/locales/fi/question.json b/src/i18n/locales/fi/question.json index 0935915b..94eeef77 100644 --- a/src/i18n/locales/fi/question.json +++ b/src/i18n/locales/fi/question.json @@ -13,6 +13,17 @@ "deploy_at": "haluatko ottaa tämän AT:n käyttöön?", "description": "kuvaus: {{ description }}", "download_file": "haluatko ladata:", + "local_send": { + "changed": "Lompakko, solmu tai käytettävissä olevat varat ovat muuttuneet. Tarkista maksu uudelleen.", + "declined": "Maksu hylätty.", + "invalid": "Maksua ei voi valmistella turvallisesti. Tarkista osoite, summa, saldo, siirtomaksu ja yhteys solmuun.", + "native_body": "Lompakko: {{wallet}}\n\nUlostulot:\n{{outputs}}\n\nVerkkomaksu: {{fee}}", + "native_title": "Vahvista lompakon maksu", + "pending": "Aiempi maksu on tarkistettava ennen uuden lähettämistä. Tapahtuma: {{txId}}", + "recovery": "Aiemmin hyväksytty maksu on yhä odottamassa. Lähetetäänkö sama tapahtuma uudelleen verkkoon? Tämä ei lähetä pyytämääsi uutta maksua.", + "unknown": "Lähetyksen tulos on tuntematon. Älä lähetä uudelleen. Tarkista tapahtuma {{txId}} lompakon historiasta.", + "upgrade": "Lompakon tapahtumien paikallinen allekirjoittaminen edellyttää päivitettyjä Qortal Core- ja Qortal Hub -versioita." + }, "message": { "error": { "add_to_list": "lisäys luetteloon epäonnistui", diff --git a/src/i18n/locales/fr/question.json b/src/i18n/locales/fr/question.json index 8f627215..8b4559e8 100644 --- a/src/i18n/locales/fr/question.json +++ b/src/i18n/locales/fr/question.json @@ -13,6 +13,17 @@ "deploy_at": "Souhaitez-vous déployer cet AT ?", "description": "description : {{ description }}", "download_file": "Souhaitez-vous télécharger :", + "local_send": { + "changed": "Le portefeuille, le nœud ou les fonds disponibles ont changé. Vérifiez à nouveau le paiement.", + "declined": "Paiement refusé.", + "invalid": "Impossible de préparer ce paiement en toute sécurité. Vérifiez l’adresse, le montant, le solde, les frais et la connexion au nœud.", + "native_body": "Portefeuille : {{wallet}}\n\nSorties :\n{{outputs}}\n\nFrais de réseau : {{fee}}", + "native_title": "Confirmer le paiement du portefeuille", + "pending": "Un paiement précédent doit être vérifié avant tout nouvel envoi. Transaction : {{txId}}", + "recovery": "Un paiement précédemment autorisé est toujours en attente. Voulez-vous rediffuser cette même transaction ? Cela n’enverra pas le nouveau paiement demandé.", + "unknown": "Le résultat de la diffusion est inconnu. Ne renvoyez pas le paiement. Vérifiez la transaction {{txId}} dans l’historique du portefeuille.", + "upgrade": "La signature locale des transactions du portefeuille nécessite des versions à jour de Qortal Core et Qortal Hub." + }, "message": { "error": { "add_to_list": "échec de l'ajout à la liste", diff --git a/src/i18n/locales/it/question.json b/src/i18n/locales/it/question.json index 162da42f..4cfe8ded 100644 --- a/src/i18n/locales/it/question.json +++ b/src/i18n/locales/it/question.json @@ -13,6 +13,17 @@ "deploy_at": "vuoi distribuire questo AT?", "description": "descrizione: {{ description }}", "download_file": "vuoi scaricare:", + "local_send": { + "changed": "Il portafoglio, il nodo o i fondi disponibili sono cambiati. Controlla nuovamente il pagamento.", + "declined": "Pagamento rifiutato.", + "invalid": "Impossibile preparare questo pagamento in sicurezza. Controlla indirizzo, importo, saldo, commissione e connessione al nodo.", + "native_body": "Portafoglio: {{wallet}}\n\nUscite:\n{{outputs}}\n\nCommissione di rete: {{fee}}", + "native_title": "Conferma il pagamento dal portafoglio", + "pending": "Un pagamento precedente deve essere verificato prima di inviarne un altro. Transazione: {{txId}}", + "recovery": "Un pagamento autorizzato in precedenza è ancora in sospeso. Vuoi ritrasmettere la stessa transazione? Il nuovo pagamento richiesto non verrà inviato.", + "unknown": "L’esito della trasmissione è sconosciuto. Non inviare di nuovo. Controlla la transazione {{txId}} nella cronologia del portafoglio.", + "upgrade": "La firma locale delle transazioni del portafoglio richiede versioni aggiornate di Qortal Core e Qortal Hub." + }, "message": { "error": { "add_to_list": "impossibile aggiungere alla lista", diff --git a/src/i18n/locales/ja/question.json b/src/i18n/locales/ja/question.json index cdc9bace..31189f21 100644 --- a/src/i18n/locales/ja/question.json +++ b/src/i18n/locales/ja/question.json @@ -13,6 +13,17 @@ "deploy_at": "このATをデプロイしますか?", "description": "説明:{{ description }}", "download_file": "次のファイルをダウンロードしますか:", + "local_send": { + "changed": "ウォレット、ノード、または利用可能な資金が変更されました。支払いを再確認してください。", + "declined": "支払いが拒否されました。", + "invalid": "この支払いを安全に準備できません。アドレス、金額、残高、手数料、ノードへの接続を確認してください。", + "native_body": "ウォレット:{{wallet}}\n\n出力:\n{{outputs}}\n\nネットワーク手数料:{{fee}}", + "native_title": "ウォレットの支払いを確認", + "pending": "次の支払いを送信する前に、以前の支払いを確認する必要があります。トランザクション:{{txId}}", + "recovery": "以前承認した支払いがまだ保留中です。同じトランザクションを再送信しますか?新しく依頼した支払いは送信されません。", + "unknown": "ブロードキャストの結果が不明です。再送信しないでください。ウォレットの履歴でトランザクション{{txId}}を確認してください。", + "upgrade": "ウォレットのトランザクションをローカルで署名するには、Qortal CoreとQortal Hubを更新する必要があります。" + }, "message": { "error": { "add_to_list": "リストへの追加に失敗しました", diff --git a/src/i18n/locales/pt/question.json b/src/i18n/locales/pt/question.json index 881700bf..ff99a5e2 100644 --- a/src/i18n/locales/pt/question.json +++ b/src/i18n/locales/pt/question.json @@ -13,6 +13,17 @@ "deploy_at": "você gostaria de implantar este AT?", "description": "descrição: {{ description }}", "download_file": "você gostaria de baixar:", + "local_send": { + "changed": "A carteira, o nó ou os fundos disponíveis mudaram. Revise o pagamento novamente.", + "declined": "Pagamento recusado.", + "invalid": "Não foi possível preparar este pagamento com segurança. Verifique o endereço, o valor, o saldo, a taxa e a conexão com o nó.", + "native_body": "Carteira: {{wallet}}\n\nSaídas:\n{{outputs}}\n\nTaxa de rede: {{fee}}", + "native_title": "Confirmar pagamento da carteira", + "pending": "É necessário verificar um pagamento anterior antes de enviar outro. Transação: {{txId}}", + "recovery": "Um pagamento autorizado anteriormente ainda está pendente. Retransmitir essa mesma transação? Isto não enviará o novo pagamento solicitado.", + "unknown": "O resultado da transmissão é desconhecido. Não envie novamente. Verifique a transação {{txId}} no histórico da carteira.", + "upgrade": "A assinatura local de transações da carteira requer versões atualizadas do Qortal Core e do Qortal Hub." + }, "message": { "error": { "add_to_list": "falha ao adicionar à lista", diff --git a/src/i18n/locales/ru/question.json b/src/i18n/locales/ru/question.json index f63f17ca..09533eb7 100644 --- a/src/i18n/locales/ru/question.json +++ b/src/i18n/locales/ru/question.json @@ -13,6 +13,17 @@ "deploy_at": "Вы хотите развернуть этот AT?", "description": "описание: {{ description }}", "download_file": "Вы хотите загрузить:", + "local_send": { + "changed": "Кошелёк, узел или доступные средства изменились. Проверьте платёж ещё раз.", + "declined": "Платёж отклонён.", + "invalid": "Не удалось безопасно подготовить платёж. Проверьте адрес, сумму, баланс, комиссию и подключение к узлу.", + "native_body": "Кошелёк: {{wallet}}\n\nВыходы:\n{{outputs}}\n\nКомиссия сети: {{fee}}", + "native_title": "Подтвердить платёж из кошелька", + "pending": "Перед отправкой нового платежа необходимо проверить предыдущий. Транзакция: {{txId}}", + "recovery": "Ранее одобренный платёж всё ещё ожидает обработки. Повторно отправить ту же транзакцию в сеть? Новый запрошенный платёж отправлен не будет.", + "unknown": "Результат отправки неизвестен. Не отправляйте повторно. Проверьте транзакцию {{txId}} в истории кошелька.", + "upgrade": "Для локального подписания транзакций кошелька требуются обновлённые версии Qortal Core и Qortal Hub." + }, "message": { "error": { "add_to_list": "не удалось добавить в список", diff --git a/src/i18n/locales/zh/question.json b/src/i18n/locales/zh/question.json index 00ddcc93..e99e58d0 100644 --- a/src/i18n/locales/zh/question.json +++ b/src/i18n/locales/zh/question.json @@ -13,6 +13,17 @@ "deploy_at": "您想部署此AT吗?", "description": "描述:{{ description }}", "download_file": "您想下载以下内容:", + "local_send": { + "changed": "钱包、节点或可用资金已发生变化。请重新检查付款。", + "declined": "付款已拒绝。", + "invalid": "无法安全地准备此付款。请检查地址、金额、余额、手续费和节点连接。", + "native_body": "钱包:{{wallet}}\n\n输出:\n{{outputs}}\n\n网络手续费:{{fee}}", + "native_title": "确认钱包付款", + "pending": "发送下一笔付款前,需要核实之前的付款。交易:{{txId}}", + "recovery": "之前批准的付款仍在等待处理。是否重新广播同一笔交易?这不会发送您新请求的付款。", + "unknown": "广播结果未知。请勿再次发送。请在钱包历史记录中检查交易{{txId}}。", + "upgrade": "在本地签署钱包交易需要更新至新版Qortal Core和Qortal Hub。" + }, "message": { "error": { "add_to_list": "添加到列表失败", diff --git a/src/lib/foreign-wallet/desktop-engine.ts b/src/lib/foreign-wallet/desktop-engine.ts new file mode 100644 index 00000000..b8522b35 --- /dev/null +++ b/src/lib/foreign-wallet/desktop-engine.ts @@ -0,0 +1,115 @@ +// Bundled for Electron main. This module is never imported by the renderer. +import { hex } from '@scure/base'; +import { + walletPublicKey, + foreignCoins, + type ForeignWalletCoin, +} from './foreign-wallets'; +import { + prepareForeignWalletPsbt, + signForeignWalletPsbt, + type ForeignWalletTransactionRequest, +} from './foreign-wallet-transaction'; +import { assertForeignWalletPlanWithinPolicy } from './foreign-wallet-policy-bounds'; +export { inspectSignedTransaction } from './foreign-wallet-transaction'; +export type DesktopSignRequest = Omit< + ForeignWalletTransactionRequest, + 'xprv' | 'cache' | 'crypto' +> & { psbtHex: string }; +class SigningError extends Error { + constructor(public code: 'invalid' | 'changed' | 'declined' | 'pending') { + super(code); + } +} +export function createDesktopWalletSigner() { + let roots: Partial> = {}; + let epoch = 0; + let busy = false; + const clear = () => { + roots = {}; + epoch++; + }; + return { + clear, + importKeys(payload: Record) { + clear(); + const next: Partial> = {}; + const publicKeys: Partial> = {}; + for (const coin of foreignCoins) { + const key = payload[`${coin.toLowerCase()}PrivateKey`]; + if (typeof key !== 'string') throw new Error('Missing wallet key'); + publicKeys[coin] = walletPublicKey(key, coin); + next[coin] = key; + } + roots = next; + return publicKeys; + }, + publicKey(coin: ForeignWalletCoin) { + if (!foreignCoins.includes(coin) || !roots[coin]) + throw new Error('Wallet is locked'); + return walletPublicKey(roots[coin], coin); + }, + async sign( + rawRequest: DesktopSignRequest, + approve: (details: { + coin: ForeignWalletCoin; + xpub: string; + outputs: { address: string; value: bigint }[]; + fee: bigint; + }) => Promise + ) { + if (busy) throw new SigningError('pending'); + busy = true; + const session = epoch; + const started = Date.now(); + try { + const request = structuredClone(rawRequest); + const { coin } = request; + if ( + !foreignCoins.includes(coin) || + !roots[coin] || + request.xpub !== walletPublicKey(roots[coin], coin) + ) + throw new SigningError('changed'); + // Ignore all caller-supplied fields except the explicitly supported public contract. + const input = { + coin, + xpub: request.xpub, + inputs: request.inputs, + outputs: request.outputs, + transactionVersion: 1, + }; + const expected = prepareForeignWalletPsbt(input); + if (request.psbtHex !== hex.encode(expected)) + throw new SigningError('invalid'); + const inputAmount = input.inputs.reduce((n, u) => n + u.value, 0n); + const outputAmount = input.outputs.reduce((n, u) => n + u.value, 0n); + const fee = inputAmount - outputAmount; + assertForeignWalletPlanWithinPolicy({ + coin, + fee, + amount: outputAmount, + feePerByte: 1n, + estimatedMaximumSize: + 10 + input.inputs.length * 149 + input.outputs.length * 43, + sendMax: true, + }); + if ( + !(await approve({ + coin, + xpub: request.xpub, + outputs: structuredClone([...input.outputs]), + fee, + })) + ) + throw new SigningError('declined'); + if (session !== epoch || Date.now() - started > 300000 || !roots[coin]) + throw new SigningError('changed'); + return signForeignWalletPsbt({ ...input, xprv: roots[coin] }, expected); + } finally { + busy = false; + } + }, + }; +} +export { desktopWalletTranslations } from './desktop-i18n'; diff --git a/src/lib/foreign-wallet/desktop-i18n.ts b/src/lib/foreign-wallet/desktop-i18n.ts new file mode 100644 index 00000000..5ca30a29 --- /dev/null +++ b/src/lib/foreign-wallet/desktop-i18n.ts @@ -0,0 +1,110 @@ +import { createInstance } from 'i18next'; +import enQuestion from '../../i18n/locales/en/question.json'; +import enCore from '../../i18n/locales/en/core.json'; +import deQuestion from '../../i18n/locales/de/question.json'; +import deCore from '../../i18n/locales/de/core.json'; +import esQuestion from '../../i18n/locales/es/question.json'; +import esCore from '../../i18n/locales/es/core.json'; +import frQuestion from '../../i18n/locales/fr/question.json'; +import frCore from '../../i18n/locales/fr/core.json'; +import itQuestion from '../../i18n/locales/it/question.json'; +import itCore from '../../i18n/locales/it/core.json'; +import ptQuestion from '../../i18n/locales/pt/question.json'; +import ptCore from '../../i18n/locales/pt/core.json'; +import ruQuestion from '../../i18n/locales/ru/question.json'; +import ruCore from '../../i18n/locales/ru/core.json'; +import zhQuestion from '../../i18n/locales/zh/question.json'; +import zhCore from '../../i18n/locales/zh/core.json'; +import jaQuestion from '../../i18n/locales/ja/question.json'; +import jaCore from '../../i18n/locales/ja/core.json'; +import arQuestion from '../../i18n/locales/ar/question.json'; +import arCore from '../../i18n/locales/ar/core.json'; +import fiQuestion from '../../i18n/locales/fi/question.json'; +import fiCore from '../../i18n/locales/fi/core.json'; +import etQuestion from '../../i18n/locales/et/question.json'; +import etCore from '../../i18n/locales/et/core.json'; +const resources = { + en: { + question: { local_send: enQuestion.local_send }, + core: { + action: { send: enCore.action.send, cancel: enCore.action.cancel }, + }, + }, + de: { + question: { local_send: deQuestion.local_send }, + core: { + action: { send: deCore.action.send, cancel: deCore.action.cancel }, + }, + }, + es: { + question: { local_send: esQuestion.local_send }, + core: { + action: { send: esCore.action.send, cancel: esCore.action.cancel }, + }, + }, + fr: { + question: { local_send: frQuestion.local_send }, + core: { + action: { send: frCore.action.send, cancel: frCore.action.cancel }, + }, + }, + it: { + question: { local_send: itQuestion.local_send }, + core: { + action: { send: itCore.action.send, cancel: itCore.action.cancel }, + }, + }, + pt: { + question: { local_send: ptQuestion.local_send }, + core: { + action: { send: ptCore.action.send, cancel: ptCore.action.cancel }, + }, + }, + ru: { + question: { local_send: ruQuestion.local_send }, + core: { + action: { send: ruCore.action.send, cancel: ruCore.action.cancel }, + }, + }, + zh: { + question: { local_send: zhQuestion.local_send }, + core: { + action: { send: zhCore.action.send, cancel: zhCore.action.cancel }, + }, + }, + ja: { + question: { local_send: jaQuestion.local_send }, + core: { + action: { send: jaCore.action.send, cancel: jaCore.action.cancel }, + }, + }, + ar: { + question: { local_send: arQuestion.local_send }, + core: { + action: { send: arCore.action.send, cancel: arCore.action.cancel }, + }, + }, + fi: { + question: { local_send: fiQuestion.local_send }, + core: { + action: { send: fiCore.action.send, cancel: fiCore.action.cancel }, + }, + }, + et: { + question: { local_send: etQuestion.local_send }, + core: { + action: { send: etCore.action.send, cancel: etCore.action.cancel }, + }, + }, +}; +export function desktopWalletTranslations(language: string) { + const i18n = createInstance(); + void i18n.init({ + resources, + lng: typeof language === 'string' ? language.slice(0, 20) : 'en', + fallbackLng: 'en', + initImmediate: false, + interpolation: { escapeValue: false }, + }); + return i18n; +} diff --git a/src/lib/foreign-wallet/foreign-wallet-policy-bounds.ts b/src/lib/foreign-wallet/foreign-wallet-policy-bounds.ts new file mode 100644 index 00000000..305e0f79 --- /dev/null +++ b/src/lib/foreign-wallet/foreign-wallet-policy-bounds.ts @@ -0,0 +1,47 @@ +import type { ForeignWalletCoin } from './foreign-wallets'; +// Local ceilings remain independent of Core's estimates. These are safety limits, +// not recommended fees; the user approves the actual total before signing. +const limits = { + BTC: [10000n, 1000000n, 2000n], + LTC: [2000000n, 20000000n, 20000n], + DOGE: [2000000000n, 20000000000n, 200000n], + DGB: [10000n, 100000000n, 200000n], + RVN: [50000n, 100000000n, 200000n], +} as const; +export function getForeignWalletPolicyBounds(coin: ForeignWalletCoin) { + if (!Object.prototype.hasOwnProperty.call(limits, coin)) + throw new Error('Unsupported wallet coin'); + const [maximumDustThreshold, maximumFee, maximumFeePerByte] = limits[coin]; + return { maximumDustThreshold, maximumFee, maximumFeePerByte }; +} +export function assertForeignWalletContextWithinPolicy(context: { + coin: ForeignWalletCoin; + minimumNonDustOutput: bigint; + recommendedFeePerByte: bigint; +}) { + const bounds = getForeignWalletPolicyBounds(context.coin); + if ( + context.minimumNonDustOutput <= 0n || + context.minimumNonDustOutput > bounds.maximumDustThreshold || + context.recommendedFeePerByte <= 0n || + context.recommendedFeePerByte > bounds.maximumFeePerByte + ) + throw new Error('Wallet fee policy exceeded'); +} +export function assertForeignWalletPlanWithinPolicy(plan: { + coin: ForeignWalletCoin; + fee: bigint; + feePerByte: bigint; + amount: bigint; + estimatedMaximumSize: number; + sendMax: boolean; +}) { + const bounds = getForeignWalletPolicyBounds(plan.coin); + if ( + plan.fee < 0n || + plan.fee > bounds.maximumFee || + plan.fee > BigInt(plan.estimatedMaximumSize) * bounds.maximumFeePerByte || + (!plan.sendMax && plan.fee > plan.amount) + ) + throw new Error('Wallet fee policy exceeded'); +} diff --git a/src/lib/foreign-wallet/foreign-wallet-spend-context.ts b/src/lib/foreign-wallet/foreign-wallet-spend-context.ts new file mode 100644 index 00000000..c2ba1811 --- /dev/null +++ b/src/lib/foreign-wallet/foreign-wallet-spend-context.ts @@ -0,0 +1,141 @@ +// Validate the public Core API contract before constructing a transaction. +import { + assertForeignWalletWatchInputBounds, + walletPath, + type ForeignWalletWatchInput, +} from './foreign-wallet-transaction'; +import type { ForeignWalletCoin } from './foreign-wallets'; +export const FOREIGN_WALLET_MAINNET_CHAIN_IDS = Object.freeze({ + BTC: 'bip122:000000000019d6689c085ae165831e93', + LTC: 'bip122:12a765e31ffd4059bada1e25190f6e98', + DOGE: 'bip122:1a91e3dace36e2be3bf030a65679fe82', + DGB: 'bip122:7497ea1b465eb39f1c8f507bc877078f', + RVN: 'bip122:0000006b444bc2f2ffe627be9d9e7e7a', +}); +const blockchains = { + BTC: 'BITCOIN', + LTC: 'LITECOIN', + DOGE: 'DOGECOIN', + DGB: 'DIGIBYTE', + RVN: 'RAVENCOIN', +}; +export const FOREIGN_WALLET_SPEND_CONTEXT_RESPONSE_MAX_BYTES = 20 * 1024 * 1024; +export const FOREIGN_WALLET_SPEND_CONTEXT_MAX_RAW_TRANSACTION_BYTES = 1000000; +export const FOREIGN_WALLET_SPEND_CONTEXT_MAX_TOTAL_RAW_TRANSACTION_BYTES = 8000000; +export type ForeignWalletSpendContext = Readonly<{ + activeNetwork: string; + blockchain: string; + chainId: string; + coin: ForeignWalletCoin; + minimumNonDustOutput: bigint; + recommendedFeePerByte: bigint; + tipHeight: number; + transactionVersion: number; + utxos: readonly ForeignWalletWatchInput[]; +}>; +const assert = (ok: unknown) => { + if (!ok) throw new Error('Invalid public wallet context'); +}; +const record = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v); +const amount = (v: unknown) => { + assert(typeof v === 'string' && /^[1-9][0-9]{0,18}$/.test(v as string)); + const n = BigInt(v as string); + assert(n <= 0x7fffffffffffffffn); + return n; +}; +export function getForeignWalletMainnetChainId(coin: ForeignWalletCoin) { + assert( + Object.prototype.hasOwnProperty.call(FOREIGN_WALLET_MAINNET_CHAIN_IDS, coin) + ); + return FOREIGN_WALLET_MAINNET_CHAIN_IDS[coin]; +} +export function normalizeForeignWalletSpendContext( + raw: unknown, + coin: ForeignWalletCoin +): ForeignWalletSpendContext { + assert(record(raw)); + const v = raw as Record; + const chainId = getForeignWalletMainnetChainId(coin); + assert( + v.version === 1 && + v.chainId === chainId && + v.currencyCode === coin && + v.blockchain === blockchains[coin] && + v.activeNetwork === 'MAIN' + ); + assert( + v.confirmedOnly === true && + v.transactionFormat === 'LEGACY' && + v.transactionVersion === 1 && + v.sighashType === 1 && + v.sequence === 0xffffffff && + v.lockTime === 0 + ); + assert(Number.isSafeInteger(v.tipHeight) && v.tipHeight > 0); + assert( + Array.isArray(v.utxos) && + v.utxos.length <= 1000 && + record(v.previousTransactions) + ); + const hashes = Object.keys(v.previousTransactions); + assert( + hashes.length <= 1000 && hashes.every((h) => /^[a-f0-9]{64}$/.test(h)) + ); + const referenced = new Set(); + const seen = new Set(); + const utxos: ForeignWalletWatchInput[] = v.utxos.map((u: any) => { + assert( + record(u) && + typeof u.txHash === 'string' && + Object.prototype.hasOwnProperty.call(v.previousTransactions, u.txHash) + ); + assert( + Number.isSafeInteger(u.height) && u.height > 0 && u.height <= v.tipHeight + ); + assert( + Number.isSafeInteger(u.outputIndex) && + u.outputIndex >= 0 && + u.outputIndex <= 0xffffffff + ); + assert( + typeof u.address === 'string' && + u.address.length <= 128 && + /^[a-f0-9]{50}$/.test(u.scriptPubKeyHex) + ); + const path = walletPath(u.pathAsString); + assert( + Array.isArray(u.path) && + u.path.length === 2 && + u.path[0] === path.chain && + u.path[1] === path.index + ); + const point = `${u.txHash}:${u.outputIndex}`; + assert(!seen.has(point)); + seen.add(point); + referenced.add(u.txHash); + return Object.freeze({ + address: u.address, + height: u.height, + path: u.pathAsString, + previousTransactionHex: v.previousTransactions[u.txHash], + scriptPubKey: u.scriptPubKeyHex, + txHash: u.txHash, + txPos: u.outputIndex, + value: amount(u.value), + }); + }); + assert(referenced.size === hashes.length); + assertForeignWalletWatchInputBounds(utxos); + return Object.freeze({ + coin, + chainId, + activeNetwork: 'MAIN', + blockchain: blockchains[coin], + transactionVersion: 1, + tipHeight: v.tipHeight, + minimumNonDustOutput: amount(v.minimumNonDustOutput), + recommendedFeePerByte: amount(v.recommendedFeePerByte), + utxos: Object.freeze(utxos), + }); +} diff --git a/src/lib/foreign-wallet/foreign-wallet-spend-plan.ts b/src/lib/foreign-wallet/foreign-wallet-spend-plan.ts new file mode 100644 index 00000000..2332c233 --- /dev/null +++ b/src/lib/foreign-wallet/foreign-wallet-spend-plan.ts @@ -0,0 +1,166 @@ +// Hub payment policy. All amounts are atomic units; planning needs only an xpub. +import { CompactSize } from '@scure/btc-signer'; +import { + assertForeignWalletWatchInputBounds, + assertForeignWalletSigningWorkBounds, + attestForeignWalletWatchInput, + validateForeignWalletRecipient, + positiveAtomic, + type ForeignWalletWatchInput, + type ForeignWalletPaymentOutput, + type ForeignWalletPreviousTransactionCache, +} from './foreign-wallet-transaction'; +import type { ForeignWalletCoin, ForeignWalletCrypto } from './foreign-wallets'; +export type ForeignWalletSpendPlan = { + amount: bigint; + change: bigint; + changeAddress: string | null; + estimatedMaximumSize: number; + fee: bigint; + feePerByte: bigint; + inputAmount: bigint; + inputs: ForeignWalletWatchInput[]; + outputAmount: bigint; + outputs: ForeignWalletPaymentOutput[]; + recipientAddress: string; + sendMax: boolean; +}; +export function estimateMaximumForeignWalletTransactionSize( + count: number, + lengths: readonly number[] +) { + assertForeignWalletSigningWorkBounds(count, lengths); + // Compressed P2PKH: 149 bytes bounds DER signature length without relying on low-R grinding. + return ( + 8 + + CompactSize.encode(BigInt(count)).length + + count * 149 + + CompactSize.encode(BigInt(lengths.length)).length + + lengths.reduce( + (sum, n) => sum + 8 + CompactSize.encode(BigInt(n)).length + n, + 0 + ) + ); +} +export function planForeignWalletSpend(input: { + amount?: bigint; + payments?: readonly ForeignWalletPaymentOutput[]; + cache?: ForeignWalletPreviousTransactionCache; + coin: ForeignWalletCoin; + crypto?: ForeignWalletCrypto; + feePerByte: bigint; + minimumNonDustOutput: bigint; + recipientAddress: string; + xpub?: string; + xprv?: string; + sendMax?: boolean; + utxos: readonly ForeignWalletWatchInput[]; +}): ForeignWalletSpendPlan { + const fail = () => { + throw new Error('Invalid or unfunded wallet payment'); + }; + positiveAtomic(input.feePerByte); + positiveAtomic(input.minimumNonDustOutput); + const sendMax = input.sendMax === true; + if ( + sendMax + ? input.amount !== undefined || !!input.payments + : input.amount === undefined + ) + fail(); + const recipientAddress = validateForeignWalletRecipient({ + coin: input.coin, + address: input.recipientAddress, + }).address; + const payments = input.payments + ? input.payments.map((p) => ({ ...p })) + : [{ address: recipientAddress, value: input.amount }]; + if (!payments.length || payments.length > 20) fail(); + if (!sendMax) { + positiveAtomic(input.amount); + if (payments.reduce((sum, p) => sum + p.value, 0n) !== input.amount) fail(); + payments.forEach((p) => { + positiveAtomic(p.value); + if (p.value < input.minimumNonDustOutput) fail(); + }); + } + const lengths = payments.map( + (p) => + validateForeignWalletRecipient({ coin: input.coin, address: p.address }) + .scriptPubKey.length + ); + assertForeignWalletWatchInputBounds(input.utxos); + const seen = new Set(); + const candidates = input.utxos + .map((u) => { + const point = `${u.txHash}:${u.txPos}`; + if (seen.has(point)) fail(); + seen.add(point); + attestForeignWalletWatchInput({ ...input, watchInput: u }); + return { ...u }; + }) + .sort((a, b) => + a.value !== b.value + ? a.value > b.value + ? -1 + : 1 + : a.txHash.localeCompare(b.txHash) || a.txPos - b.txPos + ); + if (!candidates.length) fail(); + const selected: ForeignWalletWatchInput[] = []; + let inputAmount = 0n; + const finish = ( + amount: bigint, + fee: bigint, + change: bigint, + size: number + ): ForeignWalletSpendPlan => { + const changeAddress = change ? selected[0].address : null; + return { + amount, + fee, + change, + changeAddress, + estimatedMaximumSize: size, + feePerByte: input.feePerByte, + inputAmount, + inputs: selected, + outputAmount: inputAmount - fee, + recipientAddress, + sendMax, + outputs: sendMax + ? [{ address: recipientAddress, value: amount }] + : [ + ...payments, + ...(change ? [{ address: changeAddress, value: change }] : []), + ], + }; + }; + for (const u of candidates) { + selected.push(u); + inputAmount += u.value; + positiveAtomic(inputAmount); + if (sendMax && selected.length !== candidates.length) continue; + const size = estimateMaximumForeignWalletTransactionSize( + selected.length, + lengths + ); + const minimumFee = BigInt(size) * input.feePerByte; + if (sendMax) { + const amount = inputAmount - minimumFee; + if (amount < input.minimumNonDustOutput) fail(); + return finish(amount, minimumFee, 0n, size); + } + if (inputAmount < input.amount + minimumFee) continue; + const changeSize = estimateMaximumForeignWalletTransactionSize( + selected.length, + [...lengths, 25] + ); + const fee = BigInt(changeSize) * input.feePerByte; + const change = inputAmount - input.amount - fee; + if (change >= input.minimumNonDustOutput) + return finish(input.amount, fee, change, changeSize); + return finish(input.amount, inputAmount - input.amount, 0n, size); + } + return fail(); +} diff --git a/src/lib/foreign-wallet/foreign-wallet-transaction.ts b/src/lib/foreign-wallet/foreign-wallet-transaction.ts new file mode 100644 index 00000000..ec702465 --- /dev/null +++ b/src/lib/foreign-wallet/foreign-wallet-transaction.ts @@ -0,0 +1,341 @@ +// Internal errors are mapped to localized messages at the application boundary. +import { Address, OutScript, Transaction, p2pkh } from '@scure/btc-signer'; +import { hex } from '@scure/base'; +import { + deriveForeignWalletLeafPublicData, + withForeignWalletLeaf, + walletPublicKey, + walletFingerprint, + type ForeignWalletCoin, + type ForeignWalletCrypto, +} from './foreign-wallets'; + +export type ForeignWalletOutputType = 'P2PKH' | 'P2SH' | 'P2WPKH' | 'P2WSH'; +export type ForeignWalletRecipient = { + address: string; + outputType: ForeignWalletOutputType; + scriptPubKey: Uint8Array; +}; +export type ForeignWalletWatchInput = { + address: string; + height: number; + path: string; + previousTransactionHex: string; + scriptPubKey: string; + txHash: string; + txPos: number; + value: bigint; +}; +export type ForeignWalletPaymentOutput = { address: string; value: bigint }; +export type ForeignWalletSignedTransaction = { + fee: bigint; + inputAmount: bigint; + outputAmount: bigint; + rawTransactionHex: string; + transactionSize: number; + txId: string; +}; +const networks = { + BTC: { pubKeyHash: 0, scriptHash: 5, bech32: 'bc', wif: 128 }, + LTC: { pubKeyHash: 48, scriptHash: 50, bech32: 'ltc', wif: 176 }, + DOGE: { pubKeyHash: 30, scriptHash: 22, bech32: '', wif: 158 }, + DGB: { pubKeyHash: 30, scriptHash: 63, bech32: 'dgb', wif: 128 }, + RVN: { pubKeyHash: 60, scriptHash: 122, bech32: '', wif: 128 }, +} as const; +const types = { + pkh: 'P2PKH', + sh: 'P2SH', + wpkh: 'P2WPKH', + wsh: 'P2WSH', +} as const; +const strict = { + version: 1, + lockTime: 0, + PSBTVersion: 0, + strictPrevoutValidation: true, +}; +const equal = (a: Uint8Array, b: Uint8Array) => hex.encode(a) === hex.encode(b); +const requireValid = (condition: unknown) => { + if (!condition) throw new Error('Invalid wallet transaction'); +}; +export const positiveAtomic = (value: bigint) => + requireValid( + typeof value === 'bigint' && value > 0n && value <= 0x7fffffffffffffffn + ); +export function walletPath(path: string) { + const match = + typeof path === 'string' && /^M\/([01])\/(0|[1-9][0-9]{0,9})$/.exec(path); + requireValid(match && Number(match[2]) < 0x80000000); + return { chain: Number(match[1]), index: Number(match[2]) }; +} +export function validateForeignWalletRecipient(input: { + address: string; + coin: ForeignWalletCoin; + crypto?: ForeignWalletCrypto; +}): ForeignWalletRecipient { + requireValid( + typeof input.address === 'string' && + input.address.length <= 128 && + networks[input.coin] + ); + const address = input.address.trim(); + const network = networks[input.coin]; + let decoded: ReturnType['decode']>; + try { + decoded = Address(network).decode(address); + } catch (error) { + if (input.coin !== 'LTC') throw error; + // Litecoin also accepts the older Bitcoin-compatible P2SH prefix. + decoded = Address({ ...network, scriptHash: 5 }).decode(address); + requireValid(decoded.type === 'sh'); + } + requireValid(decoded.type in types); + if (decoded.type === 'wpkh' || decoded.type === 'wsh') + requireValid(!!network.bech32); + return { + address, + outputType: types[decoded.type], + scriptPubKey: OutScript.encode(decoded), + }; +} +export type ForeignWalletPreviousTransactionCache = { + entries: Map; + parses: number; +}; +export function createForeignWalletPreviousTransactionCache(): ForeignWalletPreviousTransactionCache { + return { entries: new Map(), parses: 0 }; +} +export function assertForeignWalletWatchInputBounds( + inputs: readonly ForeignWalletWatchInput[] +) { + requireValid(Array.isArray(inputs) && inputs.length <= 1000); + const transactions = new Map(); + let size = 0; + for (const input of inputs) { + requireValid( + typeof input.txHash === 'string' && /^[a-f0-9]{64}$/.test(input.txHash) + ); + const raw = input.previousTransactionHex; + requireValid( + typeof raw === 'string' && + raw.length > 0 && + raw.length <= 2000000 && + raw.length % 2 === 0 && + /^[a-f0-9]+$/i.test(raw) + ); + if (transactions.has(input.txHash)) + requireValid(transactions.get(input.txHash) === raw); + else { + size += raw.length / 2; + transactions.set(input.txHash, raw); + } + requireValid(size <= 8000000); + } +} +export function assertForeignWalletSigningWorkBounds( + count: number, + lengths: readonly number[] +) { + requireValid( + Number.isSafeInteger(count) && + count > 0 && + count <= 1000 && + lengths.length > 0 && + lengths.length <= 21 + ); + requireValid( + lengths.every((n) => Number.isSafeInteger(n) && n >= 0 && n <= 10000) + ); + requireValid( + count * (26 + count * 149 + lengths.reduce((n, len) => n + 17 + len, 0)) <= + 256 * 1024 * 1024 + ); +} +type WalletInput = { + coin: ForeignWalletCoin; + xpub?: string; + xprv?: string; + crypto?: ForeignWalletCrypto; + cache?: ForeignWalletPreviousTransactionCache; + watchInput: ForeignWalletWatchInput; +}; +export function attestForeignWalletWatchInput(input: WalletInput) { + const u = input.watchInput; + assertForeignWalletWatchInputBounds([u]); + positiveAtomic(u.value); + requireValid( + Number.isSafeInteger(u.height) && + u.height > 0 && + Number.isSafeInteger(u.txPos) && + u.txPos >= 0 && + u.txPos <= 0xffffffff + ); + let previous = input.cache?.entries.get(u.txHash); + if (!previous || previous.rawHex !== u.previousTransactionHex) { + // Previous transactions may contain unrelated scripts; only the spent output + // must be P2PKH. Parsing never authorizes signing unknown scripts. + const parsed = Transaction.fromRaw(hex.decode(u.previousTransactionHex), { + allowUnknownOutputs: true, + allowUnknownInputs: true, + allowUnknownVersion: true, + }); + requireValid(parsed.id === u.txHash); + previous = { rawHex: u.previousTransactionHex, parsed }; + if (input.cache) { + input.cache.entries.set(u.txHash, previous); + input.cache.parses++; + } + } + requireValid(u.txPos < previous.parsed.outputsLength); + const output = previous.parsed.getOutput(u.txPos); + const leaf = deriveForeignWalletLeafPublicData({ + ...input, + ...walletPath(u.path), + }); + const script = p2pkh(leaf.publicKey).script; + requireValid( + output.amount === u.value && + equal(output.script, script) && + hex.encode(script) === u.scriptPubKey && + leaf.address === u.address + ); + return leaf; +} +export type ForeignWalletTransactionRequest = { + coin: ForeignWalletCoin; + xpub?: string; + xprv?: string; + crypto?: ForeignWalletCrypto; + inputs: readonly ForeignWalletWatchInput[]; + outputs: readonly ForeignWalletPaymentOutput[]; + cache?: ForeignWalletPreviousTransactionCache; + transactionVersion?: number; +}; +export function prepareForeignWalletPsbt( + input: ForeignWalletTransactionRequest +): Uint8Array { + assertForeignWalletWatchInputBounds(input.inputs); + requireValid( + input.transactionVersion === undefined || input.transactionVersion === 1 + ); + const scripts = input.outputs.map((o) => { + positiveAtomic(o.value); + return validateForeignWalletRecipient({ + address: o.address, + coin: input.coin, + }).scriptPubKey; + }); + assertForeignWalletSigningWorkBounds( + input.inputs.length, + scripts.map((s) => s.length) + ); + // PSBT repeats the full previous transaction per input. Bound both the expanded + // payload and the library's strict all-prevout checks performed for each signature. + const previousBytes = input.inputs.reduce( + (n, u) => n + u.previousTransactionHex.length / 2, + 0 + ); + requireValid( + previousBytes <= 8000000 && + previousBytes * input.inputs.length <= 256 * 1024 * 1024 + ); + const tx = new Transaction(strict); + const xpub = input.xpub ?? walletPublicKey(input.xprv, input.coin); + const fingerprint = walletFingerprint(xpub, input.coin); + const seen = new Set(); + input.inputs.forEach((u) => { + const leaf = attestForeignWalletWatchInput({ + ...input, + xpub, + watchInput: u, + }); + const path = walletPath(u.path); + const point = `${u.txHash}:${u.txPos}`; + requireValid(!seen.has(point)); + seen.add(point); + tx.addInput({ + txid: u.txHash, + index: u.txPos, + sequence: 0xffffffff, + bip32Derivation: [ + [leaf.publicKey, { fingerprint, path: [path.chain, path.index] }], + ], + sighashType: 1, + nonWitnessUtxo: hex.decode(u.previousTransactionHex), + }); + }); + input.outputs.forEach((o, i) => + tx.addOutput({ amount: o.value, script: scripts[i] }) + ); + requireValid(tx.fee >= 0n); + positiveAtomic(input.inputs.reduce((sum, u) => sum + u.value, 0n)); + return tx.toPSBT(0); +} +/** Rebuild the expected PSBT independently before using any private key. */ +export function signForeignWalletPsbt( + input: ForeignWalletTransactionRequest & { xprv: string }, + psbt: Uint8Array +): ForeignWalletSignedTransaction { + const xpub = walletPublicKey(input.xprv, input.coin); + requireValid(!input.xpub || input.xpub === xpub); + const expected = prepareForeignWalletPsbt({ + ...input, + xpub, + xprv: undefined, + }); + requireValid(equal(psbt, expected)); + const tx = Transaction.fromPSBT(psbt, strict); + input.inputs.forEach((u, i) => + withForeignWalletLeaf( + { coin: input.coin, xprv: input.xprv, ...walletPath(u.path) }, + (leaf) => { + requireValid(leaf.privateKey && tx.signIdx(leaf.privateKey, i, [1])); + } + ) + ); + tx.finalize(); + const raw = tx.extract(); + const inputAmount = input.inputs.reduce((sum, u) => sum + u.value, 0n); + const outputAmount = input.outputs.reduce((sum, o) => sum + o.value, 0n); + return { + fee: tx.fee, + inputAmount, + outputAmount, + rawTransactionHex: hex.encode(raw), + transactionSize: raw.length, + txId: tx.id, + }; +} +export function buildForeignWalletSignedTransaction( + input: ForeignWalletTransactionRequest & { xprv: string } +) { + return signForeignWalletPsbt(input, prepareForeignWalletPsbt(input)); +} +export function inspectSignedTransaction(rawHex: string) { + requireValid( + typeof rawHex === 'string' && + rawHex.length <= 400000 && + rawHex.length % 2 === 0 && + /^[a-f0-9]+$/.test(rawHex) + ); + const tx = Transaction.fromRaw(hex.decode(rawHex)); + requireValid( + tx.version === 1 && + tx.lockTime === 0 && + tx.inputsLength > 0 && + tx.inputsLength <= 1000 + ); + const outpoints = Array.from({ length: tx.inputsLength }, (_, i) => { + const u = tx.getInput(i); + requireValid(u.sequence === 0xffffffff && u.finalScriptSig?.length > 0); + return `${hex.encode(u.txid)}:${u.index}`; + }); + return { txId: tx.id, outpoints }; +} + +export function foreignWalletOutputAddress( + script: Uint8Array, + coin: ForeignWalletCoin +) { + return Address(networks[coin]).encode(OutScript.decode(script)); +} diff --git a/src/lib/foreign-wallet/foreign-wallets.ts b/src/lib/foreign-wallet/foreign-wallets.ts new file mode 100644 index 00000000..5fb11135 --- /dev/null +++ b/src/lib/foreign-wallet/foreign-wallets.ts @@ -0,0 +1,98 @@ +import { HDKey } from '@scure/bip32'; +import { base58check } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { sha512 } from '@noble/hashes/sha512'; +import { ripemd160 } from '@noble/hashes/ripemd160'; + +export type ForeignWalletCoin = 'BTC' | 'LTC' | 'DOGE' | 'DGB' | 'RVN'; +export type ForeignWalletCrypto = { + sha256: typeof sha256; + sha512: typeof sha512; + ripemd160: typeof ripemd160; +}; +export const foreignCrypto: ForeignWalletCrypto = { sha256, sha512, ripemd160 }; +export const foreignCoins: readonly ForeignWalletCoin[] = [ + 'BTC', + 'LTC', + 'DOGE', + 'DGB', + 'RVN', +]; +const prefixes = { BTC: 0, LTC: 48, DOGE: 30, DGB: 30, RVN: 60 }; +export const walletVersions = (coin: ForeignWalletCoin) => + coin === 'DOGE' + ? { private: 0x02fac398, public: 0x02facafd } + : { private: 0x0488ade4, public: 0x0488b21e }; + +// Import the existing extended key; never rederive the wallet from a new seed scheme. +export function walletPublicKey(xprv: string, coin: ForeignWalletCoin): string { + const root = HDKey.fromExtendedKey(xprv, walletVersions(coin)); + try { + if (!root.privateKey || root.depth !== 0) + throw new Error('Invalid wallet root'); + return root.publicExtendedKey; + } finally { + root.wipePrivateData(); + } +} + +type LeafInput = { + xprv?: string; + xpub?: string; + coin: ForeignWalletCoin; + chain: number; + index: number; + crypto?: ForeignWalletCrypto; +}; +export function withForeignWalletLeaf( + input: LeafInput, + callback: (key: HDKey) => T +): T { + if ( + ![0, 1].includes(input.chain) || + !Number.isSafeInteger(input.index) || + input.index < 0 || + input.index >= 0x80000000 + ) + throw new Error('Invalid wallet path'); + const root = HDKey.fromExtendedKey( + input.xpub ?? input.xprv, + walletVersions(input.coin) + ); + let branch: HDKey; + let leaf: HDKey; + try { + if (root.depth !== 0) throw new Error('Invalid wallet root'); + branch = root.deriveChild(input.chain); + leaf = branch.deriveChild(input.index); + return callback(leaf); + } finally { + leaf?.wipePrivateData(); + branch?.wipePrivateData(); + root.wipePrivateData(); + } +} +function publicData(input: LeafInput, leaf: HDKey) { + const hash = ripemd160(sha256(leaf.publicKey)); + return { + publicKey: Uint8Array.from(leaf.publicKey), + path: `M/${input.chain}/${input.index}`, + address: base58check(sha256).encode( + Uint8Array.from([prefixes[input.coin], ...hash]) + ), + }; +} +export function deriveForeignWalletLeafPublicData(input: LeafInput) { + return withForeignWalletLeaf(input, (leaf) => publicData(input, leaf)); +} + +export function walletFingerprint(xpub: string, coin: ForeignWalletCoin) { + const root = HDKey.fromExtendedKey(xpub, walletVersions(coin)); + try { + if (root.privateKey || root.depth !== 0) + throw new Error('Expected a public wallet root'); + return root.fingerprint; + } finally { + root.wipePrivateData(); + } +} diff --git a/src/lib/foreign-wallet/psbt.test.ts b/src/lib/foreign-wallet/psbt.test.ts new file mode 100644 index 00000000..f08d9c62 --- /dev/null +++ b/src/lib/foreign-wallet/psbt.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi } from 'vitest'; +import { HDKey } from '@scure/bip32'; +import { hex } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { Transaction, p2pkh } from '@scure/btc-signer'; +import { + createDesktopWalletSigner, + desktopWalletTranslations, +} from './desktop-engine'; +import { + foreignCoins, + walletVersions, + deriveForeignWalletLeafPublicData, +} from './foreign-wallets'; +import { + prepareForeignWalletPsbt, + signForeignWalletPsbt, + inspectSignedTransaction, + validateForeignWalletRecipient, +} from './foreign-wallet-transaction'; +import { normalizeForeignWalletSpendContext } from './foreign-wallet-spend-context'; + +const roots = Object.fromEntries( + foreignCoins.map((coin) => [ + coin, + HDKey.fromMasterSeed(new Uint8Array(32).fill(9), walletVersions(coin)), + ]) +); +const keys = Object.fromEntries( + foreignCoins.map((coin) => [ + `${coin.toLowerCase()}PrivateKey`, + roots[coin].privateExtendedKey, + ]) +); +function fixture(coin: (typeof foreignCoins)[number] = 'LTC') { + const xprv = roots[coin].privateExtendedKey; + const xpub = roots[coin].publicExtendedKey; + const leaf = deriveForeignWalletLeafPublicData({ + coin, + xpub, + chain: 0, + index: 0, + }); + const scriptPubKey = hex.encode(p2pkh(leaf.publicKey).script); + const previousTransactionHex = `0100000001${'11'.repeat(32)}0000000000ffffffff0100ca9a3b0000000019${scriptPubKey}00000000`; + const txHash = hex.encode( + sha256(sha256(hex.decode(previousTransactionHex))).reverse() + ); + const inputs = [ + { + address: leaf.address, + height: 100, + path: 'M/0/0', + previousTransactionHex, + scriptPubKey, + txHash, + txPos: 0, + value: 1000000000n, + }, + ]; + const outputs = [{ address: leaf.address, value: 999990000n }]; + const request = { coin, xpub, inputs, outputs }; + return { ...request, xprv, psbt: prepareForeignWalletPsbt(request) }; +} +describe('PSBT and isolated desktop signing', () => { + it.each(foreignCoins)( + '%s includes full previous transactions and public derivation metadata', + (coin) => { + const f = fixture(coin); + const tx = Transaction.fromPSBT(f.psbt); + expect(tx.getInput(0).nonWitnessUtxo).toBeDefined(); + expect(tx.getInput(0).bip32Derivation[0][1]).toEqual({ + fingerprint: roots[coin].fingerprint, + path: [0, 0], + }); + expect(tx.getInput(0).sighashType).toBe(1); + const signed = signForeignWalletPsbt(f, f.psbt); + expect(inspectSignedTransaction(signed.rawTransactionHex)).toEqual({ + txId: signed.txId, + outpoints: [`${f.inputs[0].txHash}:0`], + }); + } + ); + it('refuses altered outputs, sighash flags, version, and added PSBT metadata', () => { + const f = fixture(); + for (const mutate of [ + (tx: Transaction) => tx.updateOutput(0, { amount: 999980000n }), + (tx: Transaction) => tx.updateInput(0, { sighashType: 2 }), + (tx: Transaction) => tx.updateInput(0, { sequence: 0xfffffffe }), + (tx: Transaction) => + tx.updateInput(0, { + bip32Derivation: [ + [ + new Uint8Array([2, ...new Uint8Array(32).fill(1)]), + { fingerprint: 1, path: [0, 0] }, + ], + ], + }), + ]) { + const tx = Transaction.fromPSBT(f.psbt); + mutate(tx); + expect(() => signForeignWalletPsbt(f, tx.toPSBT())).toThrow(); + } + expect(() => + prepareForeignWalletPsbt({ ...f, transactionVersion: 2 }) + ).toThrow(); + }); + it('rejects missing previous bytes, forged scripts, duplicate inputs and wrong paths', () => { + const f = fixture(); + for (const change of [ + { previousTransactionHex: '' }, + { value: 1n }, + { scriptPubKey: '00'.repeat(25) }, + { path: 'M/0/2147483648' }, + { txPos: 1 }, + { height: 0 }, + ]) + expect(() => + prepareForeignWalletPsbt({ + ...f, + inputs: [{ ...f.inputs[0], ...change }], + }) + ).toThrow(); + expect(() => + prepareForeignWalletPsbt({ ...f, inputs: [f.inputs[0], f.inputs[0]] }) + ).toThrow(); + }); + it('does not accept Bitcoin witness recipients for Dogecoin or Ravencoin', () => { + const f = fixture('BTC'); + expect(() => + validateForeignWalletRecipient({ + coin: 'LTC', + address: f.outputs[0].address, + }) + ).toThrow(); + for (const coin of ['DOGE', 'RVN'] as const) + expect(() => + validateForeignWalletRecipient({ + coin, + address: 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + }) + ).toThrow(); + }); + it('returns public keys only and requires native approval for every signature', async () => { + const signer = createDesktopWalletSigner(); + const f = fixture(); + expect(signer.importKeys(keys)).toEqual( + Object.fromEntries( + foreignCoins.map((coin) => [coin, roots[coin].publicExtendedKey]) + ) + ); + const approve = vi.fn(async () => false); + const request = { + coin: f.coin, + xpub: f.xpub, + inputs: f.inputs, + outputs: f.outputs, + psbtHex: hex.encode(f.psbt), + }; + await expect(signer.sign(request, approve)).rejects.toThrow(); + expect(approve).toHaveBeenCalledOnce(); + const signed = await signer.sign(request, async (details) => { + expect(details.fee).toBe(10000n); + expect(details.outputs).toEqual(f.outputs); + return true; + }); + expect(signed.txId).toMatch(/^[a-f0-9]{64}$/); + signer.clear(); + expect(() => signer.publicKey('LTC')).toThrow(); + await expect(signer.sign(request, approve)).rejects.toThrow(); + expect(approve).toHaveBeenCalledOnce(); + }); + it('binds approval to an immutable snapshot and cancels on logout or account replacement', async () => { + const f = fixture(); + const signer = createDesktopWalletSigner(); + signer.importKeys(keys); + const request = { ...f, psbtHex: hex.encode(f.psbt) }; + const signed = await signer.sign(request, async () => { + request.outputs[0].value = 1n; + return true; + }); + expect(signed.outputAmount).toBe(999990000n); + const next = fixture(); + const req = { ...next, psbtHex: hex.encode(next.psbt) }; + await expect( + signer.sign(req, async () => { + signer.clear(); + return true; + }) + ).rejects.toThrow(); + signer.importKeys(keys); + await expect( + signer.sign(req, async () => { + signer.importKeys(keys); + return true; + }) + ).rejects.toThrow(); + }); + it('rejects concurrent approvals and tampering before showing the native dialog', async () => { + const f = fixture(); + const signer = createDesktopWalletSigner(); + signer.importKeys(keys); + const req = { ...f, psbtHex: hex.encode(f.psbt) }; + const approve = vi.fn(async () => true); + await expect( + signer.sign({ ...req, psbtHex: req.psbtHex + '00' }, approve) + ).rejects.toThrow(); + expect(approve).not.toHaveBeenCalled(); + await signer.sign(req, async () => { + await expect(signer.sign(req, approve)).rejects.toThrow(); + return true; + }); + }); + it('provides localized native confirmations and falls back for unknown languages', () => { + expect( + desktopWalletTranslations('de').t('question:local_send.native_title') + ).toBe('Wallet-Zahlung bestätigen'); + expect( + desktopWalletTranslations('unsupported').t( + 'question:local_send.native_title' + ) + ).toBe('Confirm wallet payment'); + }); + it('rejects unsupported public context contracts', () => { + for (const input of [ + null, + {}, + { version: 1, currencyCode: 'LTC', chainId: 'wrong' }, + ]) + expect(() => normalizeForeignWalletSpendContext(input, 'LTC')).toThrow(); + }); +}); +it('accepts the intended address formats while rejecting unsupported Taproot outputs', async () => { + const { Address, OutScript, p2tr } = await import('@scure/btc-signer'); + const hash = new Uint8Array(20).fill(3); + const legacy = Address({ + pubKeyHash: 48, + scriptHash: 5, + bech32: 'ltc', + wif: 176, + }).encode({ type: 'sh', hash }); + const current = Address({ + pubKeyHash: 48, + scriptHash: 50, + bech32: 'ltc', + wif: 176, + }).encode({ type: 'sh', hash }); + expect( + validateForeignWalletRecipient({ coin: 'LTC', address: legacy }) + .scriptPubKey + ).toEqual( + validateForeignWalletRecipient({ coin: 'LTC', address: current }) + .scriptPubKey + ); + for (const [coin, bech32] of [ + ['BTC', 'bc'], + ['LTC', 'ltc'], + ['DGB', 'dgb'], + ] as const) { + const address = Address({ + pubKeyHash: 0, + scriptHash: 5, + bech32, + wif: 128, + }).encode({ type: 'wpkh', hash }); + expect( + validateForeignWalletRecipient({ coin, address }).scriptPubKey + ).toEqual(OutScript.encode({ type: 'wpkh', hash })); + } + const taproot = p2tr(roots.BTC.publicKey.slice(1)); + expect(() => + validateForeignWalletRecipient({ coin: 'BTC', address: taproot.address }) + ).toThrow(); +}); +it('uses the non-witness transaction ID when spending a P2PKH output funded by a witness transaction', () => { + const f = fixture(); + const original = f.inputs[0].previousTransactionHex; + const previousTransactionHex = + original.slice(0, 8) + + '0001' + + original.slice(8, -8) + + '010101' + + original.slice(-8); + const inputs = [{ ...f.inputs[0], previousTransactionHex }]; + expect(() => prepareForeignWalletPsbt({ ...f, inputs })).not.toThrow(); +}); diff --git a/src/lib/foreign-wallet/send.test.ts b/src/lib/foreign-wallet/send.test.ts new file mode 100644 index 00000000..bdaf084b --- /dev/null +++ b/src/lib/foreign-wallet/send.test.ts @@ -0,0 +1,521 @@ +vi.unmock('asmcrypto.js'); +vi.unmock('asmcrypto.js/asmcrypto.all.js'); +import { describe, expect, it, vi } from 'vitest'; +import { HDKey } from '@scure/bip32'; +import { bytesToHex } from '@noble/hashes/utils'; +import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +import { base58check } from '@scure/base'; +import { + foreignCrypto, + foreignCoins, + deriveForeignWalletLeafPublicData, + walletPublicKey, +} from './foreign-wallets'; +import { buildForeignWalletSignedTransaction } from './foreign-wallet-transaction'; +import { sendForeignCoin, atomicAmount, type PendingSend } from './send'; +import { getForeignWalletMainnetChainId } from './foreign-wallet-spend-context'; +import PhraseWallet from '../../utils/generateWallet/phrase-wallet'; + +function fixture(coin: (typeof foreignCoins)[number]) { + const wallet = new PhraseWallet(new Uint8Array(32).fill(7), 2); + const original = wallet.addresses[0][`${coin.toLowerCase()}Wallet`]; + const xprv = original.derivedMasterPrivateKey; + const leaf = deriveForeignWalletLeafPublicData({ + xprv, + coin, + crypto: foreignCrypto, + chain: 0, + index: 0, + }); + const script = `76a914${bytesToHex(ripemd160(sha256(leaf.publicKey)))}88ac`; + // Non-coinbase funding fixture paying 10 coins to the existing Hub wallet. + const previousTransactionHex = `0100000001${'11'.repeat(32)}0000000000ffffffff0100ca9a3b0000000019${script}00000000`; + const txHash = bytesToHex( + sha256( + sha256( + Uint8Array.from( + previousTransactionHex.match(/../g)!.map((v) => parseInt(v, 16)) + ) + ) + ).reverse() + ); + const input = { + address: leaf.address, + height: 100, + path: 'M/0/0', + previousTransactionHex, + scriptPubKey: script, + txHash, + txPos: 0, + value: 1000000000n, + }; + const context = { + version: 1, + blockchain: { + BTC: 'BITCOIN', + LTC: 'LITECOIN', + DOGE: 'DOGECOIN', + DGB: 'DIGIBYTE', + RVN: 'RAVENCOIN', + }[coin], + currencyCode: coin, + activeNetwork: 'MAIN', + chainId: getForeignWalletMainnetChainId(coin), + tipHeight: 200, + confirmedOnly: true, + transactionFormat: 'LEGACY', + transactionVersion: 1, + sighashType: 1, + sequence: 0xffffffff, + lockTime: 0, + minimumNonDustOutput: '546', + recommendedFeePerByte: '10', + previousTransactions: { [txHash]: previousTransactionHex }, + utxos: [ + { + ...input, + path: [0, 0], + pathAsString: input.path, + outputIndex: input.txPos, + scriptPubKeyHex: input.scriptPubKey, + value: input.value.toString(), + }, + ], + }; + return { xprv, original, leaf, input, context }; +} + +describe('local foreign wallet signing', () => { + for (const coin of foreignCoins) { + it(`${coin}: preserves existing Hub derivation and signs valid input scripts`, async () => { + const { xprv, original, leaf, input } = fixture(coin); + expect(walletPublicKey(xprv, coin)).toBe(original.derivedMasterPublicKey); + expect(leaf.address).toBe(original.address); + const result = buildForeignWalletSignedTransaction({ + coin, + xprv, + crypto: foreignCrypto, + inputs: [input], + outputs: [{ address: leaf.address, value: 999990000n }], + }); + expect(result.fee).toBe(10000n); + expect(result.rawTransactionHex).not.toContain(xprv); + expect(result.txId).toMatch(/^[a-f0-9]{64}$/); + // Independent bitcoinj validation consumes these fixtures in Core tests. + if (process.env.FOREIGN_FIXTURE_DIR) { + const fs = await import('node:fs'); + fs.writeFileSync( + `${process.env.FOREIGN_FIXTURE_DIR}/${coin}.json`, + JSON.stringify({ + coin, + xprv, + publicKey: bytesToHex(leaf.publicKey), + raw: result.rawTransactionHex, + previous: input.previousTransactionHex, + txId: result.txId, + }) + ); + } + }); + } + it('plans multiple trade outputs, change and send-max without floating point fees', async () => { + const { planForeignWalletSpend } = + await import('./foreign-wallet-spend-plan'); + const { xprv, leaf, input } = fixture('LTC'); + const secondAddress = deriveForeignWalletLeafPublicData({ + coin: 'LTC', + xprv, + crypto: foreignCrypto, + chain: 1, + index: 7, + }).address; + const common = { + coin: 'LTC' as const, + xprv, + crypto: foreignCrypto, + feePerByte: 30n, + minimumNonDustOutput: 100000n, + utxos: [input], + recipientAddress: leaf.address, + }; + const payments = [ + { address: leaf.address, value: 100000000n }, + { address: secondAddress, value: 200000000n }, + ]; + const plan = planForeignWalletSpend({ + ...common, + amount: 300000000n, + payments, + }); + expect(plan.outputs.slice(0, 2)).toEqual(payments); + expect(plan.outputs).toHaveLength(3); + expect(plan.amount + plan.change + plan.fee).toBe(input.value); + const signed = buildForeignWalletSignedTransaction({ + ...common, + inputs: plan.inputs, + outputs: plan.outputs, + }); + expect(signed.fee).toBe(plan.fee); + expect(signed.transactionSize).toBeLessThanOrEqual( + plan.estimatedMaximumSize + ); + const max = planForeignWalletSpend({ ...common, sendMax: true }); + expect(max.change).toBe(0n); + expect(max.amount + max.fee).toBe(input.value); + expect(() => planForeignWalletSpend({ ...common, amount: 1n })).toThrow(); + }); + it('does not broadcast when journal persistence fails', async () => { + const { xprv, leaf, context } = fixture('LTC'); + let broadcasts = 0; + const deps = { + approve: async () => true, + stillValid: async () => true, + readPending: async () => null, + writePending: async () => { + throw new Error('disk failure'); + }, + post: async (path: string) => { + if (path.endsWith('/send/broadcast')) broadcasts++; + return context; + }, + }; + await expect( + sendForeignCoin( + { coin: 'LTC', xprv, amount: '1', recipient: leaf.address }, + deps + ) + ).rejects.toThrow('disk failure'); + expect(broadcasts).toBe(0); + }); + it('retains a reservation after acknowledgement until read servers observe the spend', async () => { + const { xprv, leaf, context } = fixture('LTC'); + let pending: PendingSend = null; + const deps = { + approve: async () => true, + stillValid: async () => true, + readPending: async () => pending, + writePending: async (value: PendingSend) => { + pending = value; + }, + post: async (path: string) => + path.endsWith('/send/broadcast') + ? pending.txId + : path.endsWith('wallettransactions') + ? [{ txHash: pending.txId }] + : context, + }; + const req = { + coin: 'LTC' as const, + xprv, + amount: '1', + recipient: leaf.address, + }; + const txId = await sendForeignCoin(req, deps); + expect(pending.txId).toBe(txId); + await expect(sendForeignCoin(req, deps)).rejects.toMatchObject({ + code: 'pending', + }); + expect(pending.txId).toBe(txId); + }); + it('rejects forged amounts and previous transactions', () => { + const { xprv, leaf, input } = fixture('LTC'); + for (const changed of [ + { ...input, value: input.value + 1n }, + { + ...input, + previousTransactionHex: input.previousTransactionHex.replace( + '00ca9a3b', + '01ca9a3b' + ), + }, + { ...input, path: 'M/1/0' }, + ]) { + expect(() => + buildForeignWalletSignedTransaction({ + coin: 'LTC', + xprv, + crypto: foreignCrypto, + inputs: [changed], + outputs: [{ address: leaf.address, value: 100000n }], + }) + ).toThrow(); + } + }); + it('accepts existing numeric fee values and rejects fractional atomic units', () => { + expect(atomicAmount(0.0000003)).toBe(30n); + expect(atomicAmount('0.00000001')).toBe(1n); + for (const raw of [0, -1, NaN, Infinity, '1e3', '0.000000001', 0.000000001]) + expect(() => atomicAmount(raw)).toThrow(); + }); + it('retains ambiguous broadcasts and never sends the key to Core', async () => { + const { xprv, leaf, context } = fixture('LTC'); + let pending: PendingSend = null; + let broadcasts = 0; + const bodies: string[] = []; + const deps = { + stillValid: async () => true, + approve: async () => true, + readPending: async () => pending, + writePending: async (value: PendingSend) => { + pending = value; + }, + post: async (path: string, body: unknown) => { + bodies.push(JSON.stringify(body)); + if (path.endsWith('spend-context')) return context; + if (path.endsWith('wallettransactions')) return []; + broadcasts++; + throw new Error('timeout'); + }, + }; + const req = { + coin: 'LTC' as const, + xprv, + amount: '1', + recipient: leaf.address, + }; + await expect(sendForeignCoin(req, deps)).rejects.toMatchObject({ + code: 'unknown', + }); + expect(pending?.txId).toMatch(/^[a-f0-9]{64}$/); + await expect(sendForeignCoin(req, deps)).rejects.toMatchObject({ + code: 'pending', + }); + expect(broadcasts).toBe(1); + expect(bodies.join('')).not.toContain(xprv); + }); + it('refuses a changed wallet after approval without broadcasting', async () => { + const { xprv, leaf, context } = fixture('BTC'); + const deps = { + post: async () => context, + stillValid: async () => false, + approve: async () => true, + readPending: async () => null, + writePending: async () => { + throw new Error('must not stage'); + }, + }; + await expect( + sendForeignCoin( + { coin: 'BTC', xprv, amount: '1', recipient: leaf.address }, + deps + ) + ).rejects.toMatchObject({ code: 'changed' }); + }); +}); + +it('recovers only the identical persisted bytes and never reports an old payment as a new send', async () => { + const { xprv, leaf, context } = fixture('LTC'); + let pending: PendingSend = null; + const sent: unknown[] = []; + let allowBroadcast = false; + const deps = { + stillValid: async () => true, + approve: vi.fn(async () => true), + approveRecovery: vi.fn(async () => true), + readPending: async () => pending, + writePending: async (value: PendingSend) => { + pending = value; + }, + post: async (path: string, body: any) => { + if (path.endsWith('spend-context')) return context; + if (path.endsWith('wallettransactions')) return []; + sent.push(body); + if (!allowBroadcast) throw new Error('lost connection'); + return pending.txId; + }, + }; + const request = { + coin: 'LTC' as const, + xprv, + recipient: leaf.address, + amount: '1', + }; + await expect(sendForeignCoin(request, deps)).rejects.toMatchObject({ + code: 'unknown', + }); + expect(pending.rawTransactionHex).toBeTruthy(); + allowBroadcast = true; + await expect( + sendForeignCoin({ ...request, amount: '2' }, deps) + ).rejects.toMatchObject({ code: 'pending' }); + expect(sent).toHaveLength(2); + expect(sent[1]).toEqual(sent[0]); + expect(deps.approve).toHaveBeenCalledOnce(); + expect(deps.approveRecovery).toHaveBeenCalledOnce(); +}); +it('rejects corrupt recovery bytes and respects expired trade funding deadlines', async () => { + const { xprv, leaf, input } = fixture('LTC'); + const signed = buildForeignWalletSignedTransaction({ + coin: 'LTC', + xprv, + inputs: [input], + outputs: [{ address: leaf.address, value: 999990000n }], + }); + const approveRecovery = vi.fn(async () => true); + const post = vi.fn(async (_path: string) => []); + for (const entry of [ + { txId: 'a'.repeat(64), rawTransactionHex: signed.rawTransactionHex }, + { + txId: signed.txId, + rawTransactionHex: signed.rawTransactionHex, + broadcastBefore: Date.now() - 1000, + }, + ]) { + await expect( + sendForeignCoin( + { coin: 'LTC', xprv, recipient: leaf.address, amount: '1' }, + { + stillValid: async () => true, + approve: async () => true, + approveRecovery, + post, + readPending: async () => ({ + ...entry, + outpoints: [`${input.txHash}:0`], + }), + writePending: vi.fn(), + } + ) + ).rejects.toMatchObject({ code: 'pending' }); + } + expect(approveRecovery).not.toHaveBeenCalled(); + expect( + post.mock.calls.every(([path]) => path.endsWith('wallettransactions')) + ).toBe(true); +}); +it('plans with only a public key when using the isolated signer', async () => { + const { xprv, leaf, context } = fixture('LTC'); + let pending: PendingSend = null; + const sign = vi.fn(async (plan, psbt) => { + const { signForeignWalletPsbt } = + await import('./foreign-wallet-transaction'); + return signForeignWalletPsbt( + { coin: 'LTC', xprv, inputs: plan.inputs, outputs: plan.outputs }, + psbt + ); + }); + const result = await sendForeignCoin( + { + coin: 'LTC', + xpub: walletPublicKey(xprv, 'LTC'), + recipient: leaf.address, + amount: '1', + }, + { + sign, + stillValid: async () => true, + approve: async () => true, + readPending: async () => null, + writePending: async (value) => { + pending = value; + }, + post: async (path) => + path.endsWith('/send/broadcast') ? pending.txId : context, + } + ); + expect(result).toBe(pending.txId); + expect(sign).toHaveBeenCalledOnce(); + expect( + JSON.stringify(sign.mock.calls, (_, v) => + typeof v === 'bigint' ? v.toString() : v + ) + ).not.toContain(xprv); +}); + +it('rejects a private extended key mislabeled as xpub before making any network request', async () => { + const { xprv, leaf } = fixture('LTC'); + const post = vi.fn(); + await expect( + sendForeignCoin( + { coin: 'LTC', xpub: xprv, recipient: leaf.address, amount: '1' }, + { + post, + stillValid: async () => true, + approve: async () => true, + readPending: async () => null, + writePending: vi.fn(), + } + ) + ).rejects.toThrow(); + expect(post).not.toHaveBeenCalled(); +}); + +it('rejects a busy cross-tab lock without queuing another send', async () => { + const { xprv, leaf } = fixture('LTC'); + const post = vi.fn(); + const requestLock = vi.fn(async (_name, options, callback) => { + expect(options).toEqual({ ifAvailable: true }); + return callback(null); + }); + vi.stubGlobal('navigator', { locks: { request: requestLock } }); + try { + await expect( + sendForeignCoin( + { coin: 'LTC', xprv, amount: '1', recipient: leaf.address }, + { post } as any + ) + ).rejects.toMatchObject({ code: 'pending' }); + expect(post).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } +}); + +it('retains signed bytes but does not broadcast if a trade expires during persistence', async () => { + const { xprv, leaf, context } = fixture('LTC'); + const now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now); + let saved: PendingSend; + const post = vi.fn(async () => context); + try { + await expect( + sendForeignCoin( + { + coin: 'LTC', + xprv, + amount: '1', + recipient: leaf.address, + broadcastBefore: now + 1000, + }, + { + post, + approve: async () => true, + stillValid: async () => true, + readPending: async () => null, + writePending: async (entry) => { + saved = entry; + clock.mockReturnValue(now + 1001); + }, + } + ) + ).rejects.toMatchObject({ code: 'changed' }); + expect(saved.rawTransactionHex).toBeTruthy(); + expect(post.mock.calls).toHaveLength(2); + } finally { + clock.mockRestore(); + } +}); + +it('does not sign or broadcast when the normal Hub permission is declined', async () => { + const { xprv, leaf, context } = fixture('LTC'); + const sign = vi.fn(); + const writePending = vi.fn(); + const post = vi.fn(async () => context); + await expect( + sendForeignCoin( + { coin: 'LTC', xprv, amount: '1', recipient: leaf.address }, + { + post, + sign, + writePending, + readPending: async () => null, + stillValid: async () => true, + approve: async () => false, + } + ) + ).rejects.toMatchObject({ code: 'declined' }); + expect(sign).not.toHaveBeenCalled(); + expect(writePending).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); +}); diff --git a/src/lib/foreign-wallet/send.ts b/src/lib/foreign-wallet/send.ts new file mode 100644 index 00000000..2f5e4793 --- /dev/null +++ b/src/lib/foreign-wallet/send.ts @@ -0,0 +1,302 @@ +import { hex } from '@scure/base'; +import { Transaction } from '@scure/btc-signer'; +import { + buildForeignWalletSignedTransaction, + prepareForeignWalletPsbt, + inspectSignedTransaction, + type ForeignWalletSignedTransaction, + createForeignWalletPreviousTransactionCache, +} from './foreign-wallet-transaction'; +import { + planForeignWalletSpend, + type ForeignWalletSpendPlan, +} from './foreign-wallet-spend-plan'; +import { + normalizeForeignWalletSpendContext, + getForeignWalletMainnetChainId, +} from './foreign-wallet-spend-context'; +import { + assertForeignWalletContextWithinPolicy, + assertForeignWalletPlanWithinPolicy, +} from './foreign-wallet-policy-bounds'; +import { + foreignCrypto, + walletPublicKey, + walletFingerprint, + type ForeignWalletCoin, +} from './foreign-wallets'; + +export class ForeignSendError extends Error { + constructor( + public code: + | 'invalid' + | 'upgrade' + | 'changed' + | 'pending' + | 'unknown' + | 'declined', + public txId?: string + ) { + super(code); + } +} +export type PendingSend = { + txId: string; + outpoints: string[]; + rawTransactionHex?: string; + broadcastBefore?: number; +}; +export type SendDependencies = { + post: (endpoint: string, body: unknown) => Promise; + sign?: ( + plan: ForeignWalletSpendPlan, + psbt: Uint8Array + ) => Promise; + approveRecovery?: (pending: PendingSend) => Promise; + approve: (plan: ForeignWalletSpendPlan) => Promise; + stillValid: () => Promise; + readPending: () => Promise; + writePending: (entry: PendingSend | null) => Promise; +}; +const busy = new Set(); +export function atomicAmount(raw: unknown): bigint { + // Existing q-apps pass numbers; accept their decimal spelling without multiplying floats. + const text = + typeof raw === 'number' && Number.isFinite(raw) + ? Number(raw.toFixed(8)) === raw + ? raw.toFixed(8) + : '' + : raw; + if ( + typeof text !== 'string' || + !/^(0|[1-9][0-9]*)?(\.[0-9]{1,8})?$/.test(text) || + !text || + text === '.' + ) + throw new ForeignSendError('invalid'); + const [whole, decimals = ''] = text.split('.'); + const value = + BigInt(whole || '0') * 100000000n + BigInt(decimals.padEnd(8, '0')); + if (value <= 0n || value > 0x7fffffffffffffffn) + throw new ForeignSendError('invalid'); + return value; +} +export function decimalAmount(value: bigint): string { + return `${value / 100000000n}.${(value % 100000000n).toString().padStart(8, '0')}`; +} +const snapshot = (value: unknown) => + JSON.stringify(value, (_, v) => (typeof v === 'bigint' ? v.toString() : v)); + +export async function sendForeignCoin( + request: { + coin: ForeignWalletCoin; + xprv?: string; + xpub?: string; + broadcastBefore?: number; + amount?: unknown; + recipient: string; + fee?: unknown; + sendMax?: boolean; + payments?: { address: string; value: bigint }[]; + }, + deps: SendDependencies +): Promise { + request = structuredClone(request); + const { coin, xprv } = request; + const xpub = request.xpub ?? walletPublicKey(xprv, coin); + // Reject private extended keys even if a caller accidentally labels one xpub. + walletFingerprint(xpub, coin); + const expectedChainId = getForeignWalletMainnetChainId(coin); + const lock = `${coin}:${xpub}`; + // Reject overlapping sends across tabs as well as within this process. + const run = async () => { + if (busy.has(lock)) throw new ForeignSendError('pending'); + busy.add(lock); + try { + return await execute(); + } finally { + busy.delete(lock); + } + }; + if (typeof navigator !== 'undefined' && navigator.locks) + return navigator.locks.request(lock, { ifAvailable: true }, (held) => { + if (!held) throw new ForeignSendError('pending'); + return run(); + }); + return run(); + + async function execute() { + const pending = await deps.readPending(); + if (pending) { + // A timeout is not evidence of failure. Only the exact txid in wallet history + // clears a reservation; never automatically construct another payment. + const history = await deps.post( + `/crosschain/${coin.toLowerCase()}/wallettransactions`, + xpub + ); + if (!Array.isArray(history)) + throw new ForeignSendError('pending', pending.txId); + if (!history.some((tx) => tx?.txHash === pending.txId)) { + if ( + pending.rawTransactionHex && + deps.approveRecovery && + (!pending.broadcastBefore || Date.now() < pending.broadcastBefore) + ) { + const parsed = inspectSignedTransaction(pending.rawTransactionHex); + if ( + parsed.txId !== pending.txId || + snapshot(parsed.outpoints) !== snapshot(pending.outpoints) + ) + throw new ForeignSendError('pending', pending.txId); + if (await deps.approveRecovery(structuredClone(pending))) { + if ( + !(await deps.stillValid()) || + (pending.broadcastBefore && Date.now() >= pending.broadcastBefore) + ) + throw new ForeignSendError('changed'); + try { + const ack = await deps.post( + `/crosschain/${coin.toLowerCase()}/send/broadcast`, + { + expectedChainId, + rawTransactionHex: pending.rawTransactionHex, + } + ); + if (ack !== pending.txId) + throw new Error('Unconfirmed broadcast'); + } catch { + throw new ForeignSendError('unknown', pending.txId); + } + } + } + // A recovered payment must never be reported as success for a new request. + throw new ForeignSendError('pending', pending.txId); + } + } + const read = async () => { + const context = normalizeForeignWalletSpendContext( + await deps.post( + `/crosschain/${coin.toLowerCase()}/wallet/public/spend-context`, + { xpub58: xpub, expectedChainId } + ), + coin + ); + assertForeignWalletContextWithinPolicy(context); + return context; + }; + const context = await read(); + if (pending) { + if ( + context.utxos.some((input) => + pending.outpoints.includes(`${input.txHash}:${input.txPos}`) + ) + ) + throw new ForeignSendError('pending', pending.txId); + await deps.writePending(null); + } + + if (request.sendMax && request.amount !== undefined) + throw new ForeignSendError('invalid'); + const fee = + request.fee === undefined + ? context.recommendedFeePerByte + : atomicAmount(request.fee); + if ( + fee < context.recommendedFeePerByte || + fee > context.recommendedFeePerByte * 10n + ) + throw new ForeignSendError('invalid'); + const cache = createForeignWalletPreviousTransactionCache(); + const planFor = (state: typeof context) => + planForeignWalletSpend({ + coin, + xpub, + crypto: foreignCrypto, + cache, + utxos: state.utxos, + feePerByte: fee, + minimumNonDustOutput: state.minimumNonDustOutput, + amount: request.sendMax ? undefined : atomicAmount(request.amount), + sendMax: request.sendMax, + recipientAddress: request.recipient, + payments: request.payments, + }); + const plan = planFor(context); + assertForeignWalletPlanWithinPolicy({ ...plan, coin }); + const started = Date.now(); + if (!(await deps.approve(structuredClone(plan)))) + throw new ForeignSendError('declined'); + if (!(await deps.stillValid())) throw new ForeignSendError('changed'); + const after = await read(); + if ( + context.recommendedFeePerByte !== after.recommendedFeePerByte || + context.minimumNonDustOutput !== after.minimumNonDustOutput || + snapshot(planFor(after)) !== snapshot(plan) + ) + throw new ForeignSendError('changed'); + if (!(await deps.stillValid()) || Date.now() - started > 5 * 60 * 1000) + throw new ForeignSendError('changed'); + const transaction = { + coin, + xpub, + inputs: plan.inputs, + outputs: plan.outputs, + transactionVersion: context.transactionVersion, + }; + const psbt = prepareForeignWalletPsbt(transaction); + const signed = deps.sign + ? await deps.sign(structuredClone(plan), psbt) + : buildForeignWalletSignedTransaction({ ...transaction, xprv }); + if ( + !(await deps.stillValid()) || + Date.now() - started > 300000 || + (request.broadcastBefore && Date.now() >= request.broadcastBefore) + ) + throw new ForeignSendError('changed'); + const decoded = Transaction.fromRaw(hex.decode(signed.rawTransactionHex)); + if ( + hex.encode(decoded.unsignedTx) !== + hex.encode(Transaction.fromPSBT(psbt).unsignedTx) || + decoded.id !== signed.txId + ) + throw new ForeignSendError('invalid'); + if ( + signed.fee !== plan.fee || + signed.transactionSize !== signed.rawTransactionHex.length / 2 || + signed.transactionSize > plan.estimatedMaximumSize + ) + throw new ForeignSendError('invalid'); + // Write before network I/O. Keep even an ambiguous storage failure conservative. + await deps.writePending({ + txId: signed.txId, + rawTransactionHex: signed.rawTransactionHex, + ...(request.broadcastBefore + ? { broadcastBefore: request.broadcastBefore } + : {}), + outpoints: plan.inputs.map((i) => `${i.txHash}:${i.txPos}`), + }); + // Persistence can take time: recheck immediately before releasing signed bytes. + if ( + !(await deps.stillValid()) || + Date.now() - started > 300000 || + (request.broadcastBefore && Date.now() >= request.broadcastBefore) + ) + throw new ForeignSendError('changed', signed.txId); + try { + const result = await deps.post( + `/crosschain/${coin.toLowerCase()}/send/broadcast`, + { expectedChainId, rawTransactionHex: signed.rawTransactionHex } + ); + if ( + typeof result !== 'string' || + result.trim().toLowerCase() !== signed.txId + ) + throw new ForeignSendError('unknown', signed.txId); + } catch { + throw new ForeignSendError('unknown', signed.txId); + } + // Keep the reservation until wallet history observes this transaction. An + // acknowledgement can precede propagation to the wallet's read server. + return signed.txId; + } +} diff --git a/src/lib/foreign-wallet/trade-plan.test.ts b/src/lib/foreign-wallet/trade-plan.test.ts new file mode 100644 index 00000000..4a730ece --- /dev/null +++ b/src/lib/foreign-wallet/trade-plan.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { base58check } from '@scure/base'; +import { sha256 } from '@noble/hashes/sha256'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; +import { foreignCoins } from './foreign-wallets'; +import { validateLocalTradePlan } from './trade-plan'; + +describe('trade funding validation', () => { + for (const coin of foreignCoins) + it(`${coin}: verifies the HTLC, amount, seller and refund deadline`, () => { + const offer = { + qortalAtAddress: 'AT', + receivingAddress: 'QORT', + expectedForeignAmount: '1', + tradeTimeout: 60, + creatorForeignPKH: btoa( + String.fromCharCode(...new Uint8Array(20).fill(3)) + ), + }; + const lockTime = Math.floor(Date.now() / 1000) + 3600; + const time = new Uint8Array(4); + new DataView(time.buffer).setUint32(0, lockTime, true); + const redeemScript = `7dada97614${'01'.repeat(20)}87637504${bytesToHex(time)}b16714${'03'.repeat(20)}88a914${'02'.repeat(20)}8768`; + const prefix = { BTC: 5, LTC: 50, DOGE: 22, DGB: 63, RVN: 122 }[coin]; + const address = base58check(sha256).encode( + Uint8Array.from([ + prefix, + ...ripemd160(sha256(hexToBytes(redeemScript))), + ]) + ); + const plan = { + atAddress: 'AT', + receivingAddress: 'QORT', + amount: '100001000', + fundingReserve: '1000', + refundPublicKeyHash: '01'.repeat(20), + hashOfSecret: '02'.repeat(20), + redeemScript, + lockTime, + address, + }; + expect(validateLocalTradePlan([plan], [offer], coin, 'QORT')).toEqual([ + { address, value: 100001000n }, + ]); + for (const changed of [ + { ...plan, amount: '100002000' }, + { ...plan, lockTime: 0 }, + { ...plan, atAddress: 'OTHER' }, + { ...plan, redeemScript: redeemScript.replace('03', '04') }, + { ...plan, receivingAddress: 'OTHER' }, + ]) { + expect(() => + validateLocalTradePlan([changed], [offer], coin, 'QORT') + ).toThrow(); + } + expect(() => + validateLocalTradePlan( + [plan], + [{ ...offer, creatorForeignPKH: btoa('x'.repeat(20)) }], + coin, + 'QORT' + ) + ).toThrow(); + }); +}); diff --git a/src/lib/foreign-wallet/trade-plan.ts b/src/lib/foreign-wallet/trade-plan.ts new file mode 100644 index 00000000..51f5edf0 --- /dev/null +++ b/src/lib/foreign-wallet/trade-plan.ts @@ -0,0 +1,69 @@ +import { atomicAmount } from './send'; +import { type ForeignWalletCoin, foreignCrypto } from './foreign-wallets'; +import { validateForeignWalletRecipient } from './foreign-wallet-transaction'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { ripemd160 } from '@noble/hashes/ripemd160'; +import { sha256 } from '@noble/hashes/sha256'; +// Pure validation before any signing: bind outputs to the selected ATs and HTLC terms. +export function validateLocalTradePlan( + plans: any, + offers: any[], + coin: ForeignWalletCoin, + receivingAddress: string +) { + if ( + !Array.isArray(plans) || + plans.length !== offers.length || + plans.length === 0 || + plans.length > 20 + ) + throw new Error('Invalid trade plan'); + return plans.map((plan, index) => { + const offer = offers[index]; + if ( + plan.atAddress !== offer.qortalAtAddress || + plan.receivingAddress !== receivingAddress || + !Number.isSafeInteger(plan.lockTime) || + plan.lockTime < Date.now() / 1000 + 300 || + plan.lockTime > + Date.now() / 1000 + Number(offer.tradeTimeout) * 60 + 60 || + !/^[a-f0-9]{40}$/.test(plan.refundPublicKeyHash) || + !/^[a-f0-9]{40}$/.test(plan.hashOfSecret) + ) + throw new Error('Invalid trade terms'); + // Core's trade API serializes byte[] as base64. Check the actual seller key hash. + const seller = + typeof offer.creatorForeignPKH === 'string' + ? bytesToHex( + Uint8Array.from(atob(offer.creatorForeignPKH), (c) => + c.charCodeAt(0) + ) + ) + : bytesToHex(Uint8Array.from(offer.creatorForeignPKH || [])); + if (!/^[a-f0-9]{40}$/.test(seller)) throw new Error('Invalid seller'); + const lock = new Uint8Array(4); + new DataView(lock.buffer).setUint32(0, plan.lockTime, true); + const script = `7dada97614${plan.refundPublicKeyHash}87637504${bytesToHex(lock)}b16714${seller}88a914${plan.hashOfSecret}8768`; + if (script !== plan.redeemScript) throw new Error('Invalid HTLC'); + const recipient = validateForeignWalletRecipient({ + address: plan.address, + coin, + crypto: foreignCrypto, + }); + const expectedScript = `a914${bytesToHex(ripemd160(sha256(hexToBytes(script))))}87`; + if (bytesToHex(recipient.scriptPubKey) !== expectedScript) + throw new Error('Invalid funding address'); + if ( + !/^[0-9]{1,18}$/.test(plan.amount) || + !/^[0-9]{1,18}$/.test(plan.fundingReserve) + ) + throw new Error('Invalid funding amount'); + const reserve = BigInt(plan.fundingReserve); + const amount = BigInt(plan.amount); + const value = atomicAmount(offer.expectedForeignAmount); + // Reserve is explicitly shown as part of the total paid; bound it relative to the purchase. + if (reserve <= 0n || reserve > value || amount !== value + reserve) + throw new Error('Invalid funding reserve'); + return { address: recipient.address, value: amount }; + }); +} diff --git a/src/qortal/__tests__/createBuyOrder.test.ts b/src/qortal/__tests__/createBuyOrder.test.ts index 57c4b519..fe7b47bf 100644 --- a/src/qortal/__tests__/createBuyOrder.test.ts +++ b/src/qortal/__tests__/createBuyOrder.test.ts @@ -79,6 +79,12 @@ vi.mock('../../hooks/useQortalMessageListener', async () => (await import('./common')).messageListenerFactory() ); +vi.mock('../local-trade-funding', () => ({ + localTradeCoins: { LITECOIN: 'LTC' }, + fundLocalTrades: vi.fn(), +})); +import { fundLocalTrades } from '../local-trade-funding'; + // ── Imports (after mocks) ──────────────────────────────────────────────────── import { createBuyOrder } from '../get'; @@ -150,7 +156,14 @@ describe('createBuyOrder', () => { vi.mocked(createEndpoint).mockImplementation( async (path: string) => `http://localhost${path}` ); - vi.mocked(createBuyOrderTx).mockResolvedValue({ success: true }); + vi.mocked(fundLocalTrades).mockImplementation( + async (_offers, _coin, approve) => { + const permission = await approve({}); + if (!permission?.accepted) + throw new Error('question:message.generic.user_declined_request'); + return { success: true } as any; + } + ); }); afterEach(() => { @@ -169,7 +182,10 @@ describe('createBuyOrder', () => { it('throws when foreignBlockchain is missing', async () => { await expect( - createBuyOrder({ crosschainAtInfo: [{ qortalAtAddress: AT_ADDRESS }] }, false) + createBuyOrder( + { crosschainAtInfo: [{ qortalAtAddress: AT_ADDRESS }] }, + false + ) ).rejects.toThrow('question:message.error.missing_fields'); }); @@ -186,7 +202,9 @@ describe('createBuyOrder', () => { it('throws when the fetched AT has a different foreignBlockchain', async () => { vi.stubGlobal( 'fetch', - makeFetch({ atData: { ...DEFAULT_AT_DATA, foreignBlockchain: 'BITCOIN' } }) + makeFetch({ + atData: { ...DEFAULT_AT_DATA, foreignBlockchain: 'BITCOIN' }, + }) ); await expect(createBuyOrder(makeInput(), false)).rejects.toThrow( @@ -202,17 +220,25 @@ describe('createBuyOrder', () => { vi.fn(async (url: string) => { if (url.includes('/crosschain/trade/')) { callCount++; - const blockchain = - callCount === 2 ? 'BITCOIN' : FOREIGN_BLOCKCHAIN; + const blockchain = callCount === 2 ? 'BITCOIN' : FOREIGN_BLOCKCHAIN; return { ok: true, - json: async () => ({ ...DEFAULT_AT_DATA, foreignBlockchain: blockchain }), + json: async () => ({ + ...DEFAULT_AT_DATA, + foreignBlockchain: blockchain, + }), }; } if (url.includes('/feerequired')) - return { ok: true, clone: () => ({ json: async () => UNLOCK_FEE_SATS }) }; + return { + ok: true, + clone: () => ({ json: async () => UNLOCK_FEE_SATS }), + }; if (url.includes('/feekb')) - return { ok: true, clone: () => ({ json: async () => LOCK_FEE_SATS }) }; + return { + ok: true, + clone: () => ({ json: async () => LOCK_FEE_SATS }), + }; throw new Error(`Unexpected URL: ${url}`); }) ); @@ -234,22 +260,15 @@ describe('createBuyOrder', () => { // ── Fee-fetching errors ───────────────────────────────────────────────────── - describe('fee-fetching errors', () => { - it('propagates the error message when the fee endpoint returns non-ok', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async (url: string) => { - if (url.includes('/crosschain/trade/')) - return { ok: true, json: async () => DEFAULT_AT_DATA }; - // Both fee endpoints fail - return { ok: false }; - }) - ); - - await expect(createBuyOrder(makeInput(), false)).rejects.toThrow( - 'question:message.error.fetch_generic' - ); - }); + it('propagates a local funding preparation failure without using the legacy sender', async () => { + vi.stubGlobal('fetch', makeFetch()); + vi.mocked(fundLocalTrades).mockRejectedValueOnce( + new Error('local signing unavailable') + ); + await expect(createBuyOrder(makeInput(), false)).rejects.toThrow( + 'local signing unavailable' + ); + expect(createBuyOrderTx).not.toHaveBeenCalled(); }); // ── Permission guard ──────────────────────────────────────────────────────── @@ -268,7 +287,7 @@ describe('createBuyOrder', () => { // ── Successful flow ───────────────────────────────────────────────────────── describe('successful buy order', () => { - it('returns the result from createBuyOrderTx', async () => { + it('returns the local funding result', async () => { vi.stubGlobal('fetch', makeFetch()); simulatePermission(true); @@ -277,32 +296,28 @@ describe('createBuyOrder', () => { expect(result).toEqual({ success: true }); }); - it('passes the resolved crosschainAtInfo and foreignBlockchain to createBuyOrderTx', async () => { + it('routes validated offers to local signing', async () => { vi.stubGlobal('fetch', makeFetch()); simulatePermission(true); await createBuyOrder(makeInput(), false); - expect(createBuyOrderTx).toHaveBeenCalledOnce(); - expect(createBuyOrderTx).toHaveBeenCalledWith( - expect.objectContaining({ - crosschainAtInfo: [DEFAULT_AT_DATA], - foreignBlockchain: FOREIGN_BLOCKCHAIN, - isGateway: false, - }) + expect(fundLocalTrades).toHaveBeenCalledWith( + [DEFAULT_AT_DATA], + 'LTC', + expect.any(Function) ); + expect(createBuyOrderTx).not.toHaveBeenCalled(); }); - it('passes isGateway=true to createBuyOrderTx when on a gateway node', async () => { + it('refuses gateway trading without exposing the wallet key', async () => { vi.mocked(isRunningGateway).mockResolvedValue(true); vi.stubGlobal('fetch', makeFetch()); - simulatePermission(true); - - await createBuyOrder(makeInput(), false); - - expect(createBuyOrderTx).toHaveBeenCalledWith( - expect.objectContaining({ isGateway: true }) + await expect(createBuyOrder(makeInput(), false)).rejects.toThrow( + 'question:message.error.gateway_non_qort_local_node' ); + expect(fundLocalTrades).not.toHaveBeenCalled(); + expect(createBuyOrderTx).not.toHaveBeenCalled(); }); it('aggregates crosschainAtInfo for multiple AT addresses', async () => { @@ -319,9 +334,15 @@ describe('createBuyOrder', () => { if (url.includes(`/crosschain/trade/${AT_ADDRESS_2}`)) return { ok: true, json: async () => at2 }; if (url.includes('/feerequired')) - return { ok: true, clone: () => ({ json: async () => UNLOCK_FEE_SATS }) }; + return { + ok: true, + clone: () => ({ json: async () => UNLOCK_FEE_SATS }), + }; if (url.includes('/feekb')) - return { ok: true, clone: () => ({ json: async () => LOCK_FEE_SATS }) }; + return { + ok: true, + clone: () => ({ json: async () => LOCK_FEE_SATS }), + }; throw new Error(`Unexpected URL: ${url}`); }) ); @@ -338,11 +359,9 @@ describe('createBuyOrder', () => { false ); - const received = vi.mocked(createBuyOrderTx).mock.calls[0][0]; - expect(received.crosschainAtInfo).toHaveLength(2); - expect(received.crosschainAtInfo).toEqual( - expect.arrayContaining([DEFAULT_AT_DATA, at2]) - ); + const received = vi.mocked(fundLocalTrades).mock.calls[0][0]; + expect(received).toHaveLength(2); + expect(received).toEqual(expect.arrayContaining([DEFAULT_AT_DATA, at2])); }); it('fetches the trade endpoint once per AT address', async () => { diff --git a/src/qortal/foreign-coin-send.test.ts b/src/qortal/foreign-coin-send.test.ts new file mode 100644 index 00000000..3d45a2d1 --- /dev/null +++ b/src/qortal/foreign-coin-send.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, afterEach, it, expect, vi } from 'vitest'; +import { HDKey } from '@scure/bip32'; +vi.mock('../background/background', () => ({ + getKeyPair: vi.fn(), + getSaveWallet: vi.fn(), + createEndpoint: vi.fn(), +})); +vi.mock('../lib/foreign-wallet/send', async (original) => ({ + ...(await original()), + sendForeignCoin: vi.fn(), +})); +vi.mock('i18next', () => ({ default: { t: (key: string) => key } })); +import { + getKeyPair, + getSaveWallet, + createEndpoint, +} from '../background/background'; +import { sendForeignCoin } from '../lib/foreign-wallet/send'; +import { sendLocalForeignCoin } from './foreign-coin-send'; + +beforeEach(() => { + const key = HDKey.fromMasterSeed(new Uint8Array(32).fill(5)); + vi.mocked(getKeyPair).mockResolvedValue({ + ltcPrivateKey: key.privateExtendedKey, + }); + vi.mocked(getSaveWallet).mockResolvedValue({ address0: 'QORT' }); + vi.mocked(createEndpoint).mockImplementation( + async (path) => `https://node.test${path}` + ); +}); +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetAllMocks(); + delete window.foreignWalletJournal; + delete window.foreignWalletSigner; + localStorage.clear(); +}); +it('awaits Electron journal persistence before posting signed bytes', async () => { + let stored: string; + const calls: string[] = []; + window.foreignWalletJournal = { + get: vi.fn(async () => stored), + set: vi.fn(async (_key, value) => { + calls.push('persist'); + stored = value; + }), + delete: vi.fn(), + } as any; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + calls.push('broadcast'); + return new Response('a'.repeat(64)); + }) + ); + vi.mocked(sendForeignCoin).mockImplementation(async (_request, deps) => { + const entry = { txId: 'a'.repeat(64), outpoints: ['b'.repeat(64) + ':0'] }; + await deps.writePending(entry); + expect(await deps.readPending()).toEqual(entry); + await deps.post('/crosschain/ltc/send/broadcast', { + rawTransactionHex: '01000000', + }); + return entry.txId; + }); + await expect( + sendLocalForeignCoin( + { coin: 'LTC', recipient: 'LTC', amount: '1' }, + async () => ({ accepted: true }) + ) + ).resolves.toBe('a'.repeat(64)); + expect(calls).toEqual(['persist', 'broadcast']); + expect(localStorage.length).toBe(0); + expect(vi.mocked(fetch).mock.calls[0][1].body).toBe( + '{"rawTransactionHex":"01000000"}' + ); +}); +it('requires upgraded Core without falling back to the private-key send API', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 404 })) + ); + vi.mocked(sendForeignCoin).mockImplementation( + async (_request, deps) => + deps.post('/crosschain/ltc/wallet/public/spend-context', { + xpub58: 'public', + }) as any + ); + await expect(sendLocalForeignCoin({ coin: 'LTC' }, vi.fn())).rejects.toThrow( + 'question:local_send.upgrade' + ); + expect(fetch).toHaveBeenCalledTimes(1); +}); +it('does not post after the selected wallet changes', async () => { + vi.stubGlobal('fetch', vi.fn()); + vi.mocked(sendForeignCoin).mockImplementation(async (_request, deps) => { + vi.mocked(getSaveWallet).mockResolvedValue({ address0: 'OTHER' }); + return deps.post('/crosschain/ltc/send/broadcast', { + rawTransactionHex: '01000000', + }) as any; + }); + await expect(sendLocalForeignCoin({ coin: 'LTC' }, vi.fn())).rejects.toThrow( + 'question:local_send.changed' + ); + expect(fetch).not.toHaveBeenCalled(); +}); + +it('routes desktop signing through native IPC without passing a private key', async () => { + const root = HDKey.fromMasterSeed(new Uint8Array(32).fill(5)); + vi.mocked(getKeyPair).mockResolvedValue({ + ltcPublicKey: root.publicExtendedKey, + }); + const signed = { txId: 'a'.repeat(64) }; + window.foreignWalletSigner = { + importKeys: vi.fn(), + clear: vi.fn(), + publicKey: vi.fn(async () => root.publicExtendedKey), + sign: vi.fn(async () => ({ signed: signed as any })), + }; + vi.mocked(sendForeignCoin).mockImplementation(async (request, deps) => { + expect(request.xprv).toBeUndefined(); + expect(request.xpub).toBe(root.publicExtendedKey); + expect(await deps.stillValid()).toBe(true); + expect( + await deps.sign({ inputs: [], outputs: [] } as any, new Uint8Array([1])) + ).toEqual(signed); + return signed.txId; + }); + expect(await sendLocalForeignCoin({ coin: 'LTC' }, vi.fn())).toBe( + signed.txId + ); + expect( + JSON.stringify(vi.mocked(window.foreignWalletSigner.sign).mock.calls) + ).not.toContain(root.privateExtendedKey); +}); + +it('uses the separate recovery approval without invoking new-payment approval', async () => { + const approve = vi.fn(); + const approveRecovery = vi.fn(async () => ({ accepted: true })); + vi.mocked(sendForeignCoin).mockImplementation(async (_request, deps) => { + expect( + await deps.approveRecovery({ + txId: 'a'.repeat(64), + outpoints: ['b'.repeat(64) + ':0'], + rawTransactionHex: `0100000001${'11'.repeat(32)}0000000000ffffffff0100e1f505000000001976a914${'22'.repeat(20)}88ac00000000`, + }) + ).toBe(true); + return 'recovery-test'; + }); + await sendLocalForeignCoin( + { coin: 'LTC' }, + approve, + undefined, + undefined, + approveRecovery + ); + expect(approve).not.toHaveBeenCalled(); + expect(approveRecovery).toHaveBeenCalledWith( + expect.objectContaining({ + text1: 'question:local_send.recovery', + text2: 'a'.repeat(64), + confirmCheckbox: true, + }) + ); +}); diff --git a/src/qortal/foreign-coin-send.ts b/src/qortal/foreign-coin-send.ts new file mode 100644 index 00000000..e61475cd --- /dev/null +++ b/src/qortal/foreign-coin-send.ts @@ -0,0 +1,224 @@ +import { readBoundedWalletResponse } from './wallet-response'; +import { foreignWalletOutputAddress } from '../lib/foreign-wallet/foreign-wallet-transaction'; +import { hex } from '@scure/base'; +import { Transaction } from '@scure/btc-signer'; +import i18n from 'i18next'; +import { + createEndpoint, + getKeyPair, + getSaveWallet, +} from '../background/background'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; +import { + decimalAmount, + ForeignSendError, + sendForeignCoin, + type PendingSend, +} from '../lib/foreign-wallet/send'; +import { + walletPublicKey, + type ForeignWalletCoin, +} from '../lib/foreign-wallet/foreign-wallets'; + +export class LocalWalletError extends Error { + constructor(code: string, txId = '') { + super(i18n.t(`question:local_send.${code}`, { txId })); + } +} + +/** The app's private key is used only by the local signer, never by post(). */ +export async function sendLocalForeignCoin( + data: Record, + approve: (payload: any) => Promise, + payments?: { address: string; value: bigint }[], + validateIntent?: () => Promise, + approveRecovery: (payload: any) => Promise = approve +) { + try { + if ( + window.appStorage && + (!window.foreignWalletJournal || !window.foreignWalletSigner) + ) + throw new ForeignSendError('upgrade'); + const coin = data.coin as ForeignWalletCoin; + const keys = await getKeyPair(); + const wallet = await getSaveWallet(); + const xprv = keys[`${coin.toLowerCase()}PrivateKey`]; + const xpub = window.foreignWalletSigner + ? await window.foreignWalletSigner.publicKey(coin) + : walletPublicKey(xprv, coin); + if ( + window.foreignWalletSigner && + keys[`${coin.toLowerCase()}PublicKey`] !== xpub + ) + throw new ForeignSendError('changed'); + const endpoint = await createEndpoint( + `/crosschain/${coin.toLowerCase()}/wallet/public/spend-context` + ); + const fingerprint = bytesToHex(sha256(new TextEncoder().encode(xpub))); + let pendingTxId: string; + const storageKey = `foreign-send-v1:${coin}:${fingerprint}`; + const stillValid = async () => { + try { + await validateIntent?.(); + return ( + (await getSaveWallet()).address0 === wallet.address0 && + (window.foreignWalletSigner + ? (await window.foreignWalletSigner.publicKey(coin)) === xpub && + (await getKeyPair())[`${coin.toLowerCase()}PublicKey`] === xpub + : (await getKeyPair())[`${coin.toLowerCase()}PrivateKey`] === + xprv) && + (await createEndpoint( + `/crosschain/${coin.toLowerCase()}/wallet/public/spend-context` + )) === endpoint + ); + } catch { + return false; + } + }; + return await sendForeignCoin( + { + coin, + xprv: window.foreignWalletSigner ? undefined : xprv, + xpub, + broadcastBefore: data.broadcastBefore, + amount: data.amount, + recipient: data.recipient || data.destinationAddress, + fee: data.fee, + sendMax: data.sendMax === true, + payments, + }, + { + stillValid, + sign: window.foreignWalletSigner + ? async (plan, psbt) => { + const result = await window.foreignWalletSigner.sign( + { + coin, + xpub, + inputs: plan.inputs, + outputs: plan.outputs, + psbtHex: hex.encode(psbt), + }, + i18n.language + ); + if (!result.signed || result.error) + throw new ForeignSendError(result.error || 'invalid'); + return result.signed; + } + : undefined, + approveRecovery: async (pending) => { + const tx = Transaction.fromRaw(hex.decode(pending.rawTransactionHex)); + const outputs = Array.from( + { length: tx.outputsLength }, + (_, index) => { + const output = tx.getOutput(index); + return `${decimalAmount(output.amount)} ${coin} → ${foreignWalletOutputAddress(output.script, coin)}`; + } + ).join('\n'); + return ( + await approveRecovery({ + text1: i18n.t('question:local_send.recovery'), + text2: pending.txId, + text3: outputs, + confirmCheckbox: true, + }) + ).accepted; + }, + readPending: async () => { + const raw = window.foreignWalletJournal + ? await window.foreignWalletJournal.get(storageKey) + : localStorage.getItem(storageKey); + if (raw === null || raw === undefined) return null; + const value = JSON.parse(raw); + if ( + !value || + !/^[a-f0-9]{64}$/.test(value.txId) || + !Array.isArray(value.outpoints) || + value.outpoints.length < 1 || + value.outpoints.length > 1000 || + value.outpoints.some( + (p: unknown) => + typeof p !== 'string' || !/^[a-f0-9]{64}:[0-9]{1,10}$/.test(p) + ) || + (value.rawTransactionHex !== undefined && + (typeof value.rawTransactionHex !== 'string' || + value.rawTransactionHex.length > 400000 || + !/^(?:[a-f0-9]{2})+$/.test(value.rawTransactionHex))) || + (value.broadcastBefore !== undefined && + (!Number.isSafeInteger(value.broadcastBefore) || + value.broadcastBefore <= 0)) + ) + throw new ForeignSendError('pending'); + pendingTxId = value.txId; + return value as PendingSend; + }, + writePending: async (entry) => { + if (window.foreignWalletJournal) { + if (entry) + await window.foreignWalletJournal.set( + storageKey, + JSON.stringify(entry) + ); + else + await window.foreignWalletJournal.delete(storageKey, pendingTxId); + } else if (entry) + localStorage.setItem(storageKey, JSON.stringify(entry)); + else localStorage.removeItem(storageKey); + }, + post: async (path, body) => { + if (!(await stillValid())) throw new ForeignSendError('changed'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 90000); + try { + const response = await fetch(await createEndpoint(path), { + method: 'POST', + headers: { + 'Content-Type': + typeof body === 'string' ? 'text/plain' : 'application/json', + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + signal: controller.signal, + }); + if (response.status === 404 || response.status === 405) + throw new ForeignSendError('upgrade'); + if (!response.ok) throw new ForeignSendError('invalid'); + const text = await readBoundedWalletResponse( + response, + 20 * 1024 * 1024 + ); + try { + return JSON.parse(text); + } catch { + return text; + } + } finally { + clearTimeout(timeout); + } + }, + approve: async (plan) => + ( + await approve({ + text1: i18n.t('question:permission.send_coins', { + postProcess: 'capitalizeFirstChar', + }), + text2: i18n.t('question:to_recipient', { + recipient: plan.recipientAddress, + postProcess: 'capitalizeFirstChar', + }), + highlightedText: `${decimalAmount(plan.amount)} ${coin}`, + foreignFee: `${decimalAmount(plan.fee)} ${coin}`, + confirmCheckbox: true, + }) + ).accepted, + } + ); + } catch (error) { + const code = error instanceof ForeignSendError ? error.code : 'invalid'; + throw new LocalWalletError( + code, + error instanceof ForeignSendError ? error.txId || '' : '' + ); + } +} diff --git a/src/qortal/get.ts b/src/qortal/get.ts index 0706b5ea..8ece2646 100644 --- a/src/qortal/get.ts +++ b/src/qortal/get.ts @@ -1,3 +1,5 @@ +import { fundLocalTrades, localTradeCoins } from './local-trade-funding'; +import { sendLocalForeignCoin } from './foreign-coin-send'; import { Sha256 } from 'asmcrypto.js'; import { createEndpoint, @@ -60,17 +62,12 @@ import { validateSecretKey, } from '../components/Group/Group.tsx'; import { - BTC_FEE_PER_BYTE, - DGB_FEE_PER_BYTE, - DOGE_FEE_PER_BYTE, - LTC_FEE_PER_BYTE, MAX_SIZE_PUBLIC_NODE, MAX_SIZE_PUBLISH, MIN_REQUIRED_QORTS, PUBLIC_NOTIFICATION_CODE_FIRST_SECRET_KEY, QORT_DECIMALS, QORTAL_PROTOCOL, - RVN_FEE_PER_BYTE, SELLER_FOREIGN_FEE, TIME_MINUTES_1_IN_MILLISECONDS, TIME_MINUTES_20_IN_MILLISECONDS, @@ -133,7 +130,6 @@ import i18n from 'i18next'; import aesjs from 'aes-js'; import { roundUpToDecimals } from '../utils/numberFunctions.ts'; import { normalizeFilename } from '../utils/downloadFromLocation.ts'; -import { getElectronPersistentStorage } from '../utils/electronPersistentStorage.ts'; const uid = new ShortUniqueId({ length: 6 }); @@ -5639,7 +5635,11 @@ export const getNotificationSubscriptions = async (_payload, appInfo) => { }; export const sendCoin = async (data, isFromExtension) => { - const requiredFields = ['coin', 'amount']; + const requiredFields = + data.sendMax === true && + ['BTC', 'LTC', 'DOGE', 'DGB', 'RVN'].includes(data.coin) + ? ['coin'] + : ['coin', 'amount']; const missingFields: string[] = []; requiredFields.forEach((field) => { if (!data[field]) { @@ -5764,384 +5764,10 @@ export const sendCoin = async (data, isFromExtension) => { }) ); } - } else if (checkCoin === 'BTC') { - const amount = Number(data.amount); - const recipient = data?.recipient || data.destinationAddress; - const xprv58 = parsedData.btcPrivateKey; - const feePerByte = data.fee ? data.fee : BTC_FEE_PER_BYTE; - - const btcWalletBalance = await getWalletBalance({ coin: checkCoin }, true); - - if (isNaN(Number(btcWalletBalance))) { - throw new Error( - i18n.t('question:message.error.fetch_balance_token', { - token: 'BTC', - postProcess: 'capitalizeFirstChar', - }) - ); - } - const btcWalletBalanceDecimals = Number(btcWalletBalance); - const btcAmountDecimals = Number(amount); - const fee = feePerByte * 500; // default 0.00050000 - if (btcAmountDecimals + fee > btcWalletBalanceDecimals) { - throw new Error( - i18n.t('question:message.error.insufficient_funds', { - postProcess: 'capitalizeFirstChar', - }) - ); - } - - const resPermission = await getUserPermission( - { - text1: i18n.t('question:permission.send_coins', { - postProcess: 'capitalizeFirstChar', - }), - text2: i18n.t('question:to_recipient', { - recipient: recipient, - postProcess: 'capitalizeFirstChar', - }), - highlightedText: `${amount} ${checkCoin}`, - foreignFee: `${fee} BTC`, - }, - isFromExtension + } else if (['BTC', 'LTC', 'DOGE', 'DGB', 'RVN'].includes(checkCoin)) { + return sendLocalForeignCoin(data, (payload) => + getUserPermission(payload, isFromExtension) ); - const { accepted } = resPermission; - - if (accepted) { - const opts = { - xprv58: xprv58, - receivingAddress: recipient, - bitcoinAmount: amount, - feePerByte: feePerByte, - }; - const url = await createEndpoint(`/crosschain/btc/send`); - - const response = await fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(opts), - }); - if (!response.ok) - throw new Error( - i18n.t('question:message.error.send', { - postProcess: 'capitalizeFirstChar', - }) - ); - let res; - try { - res = await response.clone().json(); - } catch (e) { - res = await response.text(); - } - return res; - } else { - throw new Error( - i18n.t('question:message.generic.user_declined_request', { - postProcess: 'capitalizeFirstChar', - }) - ); - } - } else if (checkCoin === 'LTC') { - const amount = Number(data.amount); - const recipient = data?.recipient || data.destinationAddress; - const xprv58 = parsedData.ltcPrivateKey; - const feePerByte = data.fee ? data.fee : LTC_FEE_PER_BYTE; - const ltcWalletBalance = await getWalletBalance({ coin: checkCoin }, true); - - if (isNaN(Number(ltcWalletBalance))) { - const errorMsg = i18n.t('question:message.error.fetch_balance_token', { - token: 'LTC', - postProcess: 'capitalizeFirstChar', - }); - throw new Error(errorMsg); - } - const ltcWalletBalanceDecimals = Number(ltcWalletBalance); - const ltcAmountDecimals = Number(amount); - const fee = feePerByte * 1000; // default 0.00030000 - if (ltcAmountDecimals + fee > ltcWalletBalanceDecimals) { - throw new Error( - i18n.t('question:message.error.insufficient_funds', { - postProcess: 'capitalizeFirstChar', - }) - ); - } - const resPermission = await getUserPermission( - { - text1: i18n.t('question:permission.send_coins', { - postProcess: 'capitalizeFirstChar', - }), - text2: i18n.t('question:to_recipient', { - recipient: recipient, - postProcess: 'capitalizeFirstChar', - }), - highlightedText: `${amount} ${checkCoin}`, - foreignFee: `${fee} LTC`, - }, - isFromExtension - ); - const { accepted } = resPermission; - - if (accepted) { - const url = await createEndpoint(`/crosschain/ltc/send`); - const opts = { - xprv58: xprv58, - receivingAddress: recipient, - litecoinAmount: amount, - feePerByte: feePerByte, - }; - const response = await fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(opts), - }); - if (!response.ok) - throw new Error( - i18n.t('question:message.error.send', { - postProcess: 'capitalizeFirstChar', - }) - ); - let res; - try { - res = await response.clone().json(); - } catch (e) { - res = await response.text(); - } - return res; - } else { - throw new Error( - i18n.t('question:message.generic.user_declined_request', { - postProcess: 'capitalizeFirstChar', - }) - ); - } - } else if (checkCoin === 'DOGE') { - const amount = Number(data.amount); - const recipient = data?.recipient || data.destinationAddress; - const xprv58 = parsedData.dogePrivateKey; - const feePerByte = data.fee ? data.fee : DOGE_FEE_PER_BYTE; - const dogeWalletBalance = await getWalletBalance({ coin: checkCoin }, true); - if (isNaN(Number(dogeWalletBalance))) { - const errorMsg = i18n.t('question:message.error.fetch_balance_token', { - token: 'DOGE', - postProcess: 'capitalizeFirstChar', - }); - throw new Error(errorMsg); - } - const dogeWalletBalanceDecimals = Number(dogeWalletBalance); - const dogeAmountDecimals = Number(amount); - const fee = feePerByte * 5000; // default 0.05000000 - if (dogeAmountDecimals + fee > dogeWalletBalanceDecimals) { - const errorMsg = i18n.t('question:message.error.insufficient_funds', { - postProcess: 'capitalizeFirstChar', - }); - throw new Error(errorMsg); - } - - const resPermission = await getUserPermission( - { - text1: i18n.t('question:permission.send_coins', { - postProcess: 'capitalizeFirstChar', - }), - text2: i18n.t('question:to_recipient', { - recipient: recipient, - postProcess: 'capitalizeFirstChar', - }), - highlightedText: `${amount} ${checkCoin}`, - foreignFee: `${fee} DOGE`, - }, - isFromExtension - ); - const { accepted } = resPermission; - - if (accepted) { - const opts = { - xprv58: xprv58, - receivingAddress: recipient, - dogecoinAmount: amount, - feePerByte: feePerByte, - }; - const url = await createEndpoint(`/crosschain/doge/send`); - - const response = await fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(opts), - }); - if (!response.ok) - throw new Error( - i18n.t('question:message.error.send', { - postProcess: 'capitalizeFirstChar', - }) - ); - let res; - try { - res = await response.clone().json(); - } catch (e) { - res = await response.text(); - } - return res; - } else { - throw new Error( - i18n.t('question:message.generic.user_declined_request', { - postProcess: 'capitalizeFirstChar', - }) - ); - } - } else if (checkCoin === 'DGB') { - const amount = Number(data.amount); - const recipient = data?.recipient || data.destinationAddress; - const xprv58 = parsedData.dgbPrivateKey; - const feePerByte = data.fee ? data.fee : DGB_FEE_PER_BYTE; - const dgbWalletBalance = await getWalletBalance({ coin: checkCoin }, true); - if (isNaN(Number(dgbWalletBalance))) { - const errorMsg = i18n.t('question:message.error.fetch_balance_token', { - token: 'DGB', - postProcess: 'capitalizeFirstChar', - }); - throw new Error(errorMsg); - } - const dgbWalletBalanceDecimals = Number(dgbWalletBalance); - const dgbAmountDecimals = Number(amount); - const fee = feePerByte * 500; // default 0.00005000 - if (dgbAmountDecimals + fee > dgbWalletBalanceDecimals) { - const errorMsg = i18n.t('question:message.error.insufficient_funds', { - postProcess: 'capitalizeFirstChar', - }); - throw new Error(errorMsg); - } - - const resPermission = await getUserPermission( - { - text1: i18n.t('question:permission.send_coins', { - postProcess: 'capitalizeFirstChar', - }), - text2: `To: ${recipient}`, - highlightedText: `${amount} ${checkCoin}`, - foreignFee: `${fee} DGB`, - }, - isFromExtension - ); - const { accepted } = resPermission; - - if (accepted) { - const opts = { - xprv58: xprv58, - receivingAddress: recipient, - digibyteAmount: amount, - feePerByte: feePerByte, - }; - const url = await createEndpoint(`/crosschain/dgb/send`); - - const response = await fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(opts), - }); - if (!response.ok) - throw new Error( - i18n.t('question:message.error.send', { - postProcess: 'capitalizeFirstChar', - }) - ); - let res; - try { - res = await response.clone().json(); - } catch (e) { - res = await response.text(); - } - return res; - } else { - throw new Error( - i18n.t('question:message.generic.user_declined_request', { - postProcess: 'capitalizeFirstChar', - }) - ); - } - } else if (checkCoin === 'RVN') { - const amount = Number(data.amount); - const recipient = data?.recipient || data.destinationAddress; - const xprv58 = parsedData.rvnPrivateKey; - const feePerByte = data.fee ? data.fee : RVN_FEE_PER_BYTE; - const rvnWalletBalance = await getWalletBalance({ coin: checkCoin }, true); - if (isNaN(Number(rvnWalletBalance))) { - const errorMsg = i18n.t('question:message.error.fetch_balance_token', { - token: 'RVN', - postProcess: 'capitalizeFirstChar', - }); - throw new Error(errorMsg); - } - const rvnWalletBalanceDecimals = Number(rvnWalletBalance); - const rvnAmountDecimals = Number(amount); - const fee = feePerByte * 500; // default 0.00562500 - if (rvnAmountDecimals + fee > rvnWalletBalanceDecimals) { - const errorMsg = i18n.t('question:message.error.insufficient_funds', { - postProcess: 'capitalizeFirstChar', - }); - throw new Error(errorMsg); - } - - const resPermission = await getUserPermission( - { - text1: i18n.t('question:permission.send_coins', { - postProcess: 'capitalizeFirstChar', - }), - text2: `To: ${recipient}`, - highlightedText: `${amount} ${checkCoin}`, - foreignFee: `${fee} RVN`, - }, - isFromExtension - ); - const { accepted } = resPermission; - - if (accepted) { - const opts = { - xprv58: xprv58, - receivingAddress: recipient, - ravencoinAmount: amount, - feePerByte: feePerByte, - }; - const url = await createEndpoint(`/crosschain/rvn/send`); - - const response = await fetch(url, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(opts), - }); - if (!response.ok) - throw new Error( - i18n.t('question:message.error.send', { - postProcess: 'capitalizeFirstChar', - }) - ); - let res; - try { - res = await response.clone().json(); - } catch (e) { - res = await response.text(); - } - return res; - } else { - throw new Error( - i18n.t('question:message.generic.user_declined_request', { - postProcess: 'capitalizeFirstChar', - }) - ); - } } else if (checkCoin === 'ARRR') { const amount = Number(data.amount); const recipient = data?.recipient || data.destinationAddress; @@ -6282,6 +5908,19 @@ export const createBuyOrder = async (data, isFromExtension) => { ); const crosschainAtInfo = await Promise.all(atPromises); + if (localTradeCoins[foreignBlockchain]) { + if (isGateway) + throw new Error( + i18n.t('question:message.error.gateway_non_qort_local_node', { + postProcess: 'capitalizeFirstChar', + }) + ); + return fundLocalTrades( + crosschainAtInfo, + localTradeCoins[foreignBlockchain], + (payload) => getUserPermission(payload, isFromExtension) + ); + } try { const buyingFees = await getBuyingFees(foreignBlockchain); diff --git a/src/qortal/local-trade-funding.test.ts b/src/qortal/local-trade-funding.test.ts new file mode 100644 index 00000000..eb36c6c3 --- /dev/null +++ b/src/qortal/local-trade-funding.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, it, vi } from 'vitest'; +vi.mock('../background/background', () => ({ + getSaveWallet: async () => ({ address0: 'QORT' }), + getKeyPair: async () => ({ ltcPrivateKey: 'private' }), + createEndpoint: async () => 'https://node.test/respond/local', +})); +vi.mock('../lib/foreign-wallet/foreign-wallets', () => ({ + walletPublicKey: () => 'public', +})); +vi.mock('../lib/foreign-wallet/trade-plan', () => ({ + validateLocalTradePlan: vi.fn(() => [ + { address: 'funding', value: 100000001n }, + ]), +})); +vi.mock('./foreign-coin-send', () => ({ + LocalWalletError: class extends Error {}, + sendLocalForeignCoin: vi.fn(), +})); +import { fundLocalTrades } from './local-trade-funding'; +import { sendLocalForeignCoin } from './foreign-coin-send'; +import { validateLocalTradePlan } from '../lib/foreign-wallet/trade-plan'; +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +it('preserves recovery details and snapshots offers before approval', async () => { + const offers = [{ qortalAtAddress: 'original', expectedForeignAmount: '1' }]; + const recovery = { + text1: 'Recover previous payment', + text2: 'old transaction', + text3: 'old recipient', + }; + const approve = vi.fn(async () => { + offers[0].qortalAtAddress = 'mutated'; + return { accepted: true }; + }); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify([{ lockTime: Date.now() / 1000 + 3600 }])) + ) + ); + vi.mocked(sendLocalForeignCoin).mockImplementation( + async (_data, _approve, _payments, _validate, approveRecovery) => { + await approveRecovery(recovery); + return 'txid'; + } + ); + const result = await fundLocalTrades(offers, 'LTC', approve); + expect(approve).toHaveBeenLastCalledWith(recovery); + expect(result.extra.atAddresses).toEqual(['original']); + expect( + JSON.parse(vi.mocked(fetch).mock.calls[0][1].body as string).addresses + ).toEqual(['original']); + expect( + vi.mocked(validateLocalTradePlan).mock.calls[0][1][0].qortalAtAddress + ).toBe('original'); +}); diff --git a/src/qortal/local-trade-funding.ts b/src/qortal/local-trade-funding.ts new file mode 100644 index 00000000..da65ce58 --- /dev/null +++ b/src/qortal/local-trade-funding.ts @@ -0,0 +1,149 @@ +import { readBoundedWalletResponse } from './wallet-response'; +import { validateLocalTradePlan } from '../lib/foreign-wallet/trade-plan'; +import i18n from 'i18next'; +import { + createEndpoint, + getKeyPair, + getSaveWallet, +} from '../background/background'; +import { atomicAmount, decimalAmount } from '../lib/foreign-wallet/send'; +import { + type ForeignWalletCoin, + walletPublicKey, +} from '../lib/foreign-wallet/foreign-wallets'; +import { getForeignWalletMainnetChainId } from '../lib/foreign-wallet/foreign-wallet-spend-context'; +import { sendLocalForeignCoin, LocalWalletError } from './foreign-coin-send'; + +export const localTradeCoins: Record = { + BITCOIN: 'BTC', + LITECOIN: 'LTC', + DOGECOIN: 'DOGE', + DIGIBYTE: 'DGB', + RAVENCOIN: 'RVN', +}; +export async function fundLocalTrades( + offers: any[], + coin: ForeignWalletCoin, + approve: (payload: any) => Promise +) { + try { + if (!Array.isArray(offers) || !offers.length || offers.length > 20) + throw new LocalWalletError('invalid'); + // Bind both approvals and validation to the original selection. + offers = structuredClone(offers); + const wallet = await getSaveWallet(); + const publicKey = async () => + window.foreignWalletSigner + ? window.foreignWalletSigner.publicKey(coin) + : walletPublicKey( + (await getKeyPair())[`${coin.toLowerCase()}PrivateKey`], + coin + ); + const xpub = await publicKey(); + if ( + window.foreignWalletSigner && + (await getKeyPair())[`${coin.toLowerCase()}PublicKey`] !== xpub + ) + throw new LocalWalletError('changed'); + const endpoint = await createEndpoint('/crosschain/tradebot/respond/local'); + const purchaseAmount = offers.reduce( + (sum, offer) => sum + atomicAmount(offer.expectedForeignAmount), + 0n + ); + const permission = await approve({ + text1: i18n.t('question:permission.buy_order', { + postProcess: 'capitalizeFirstChar', + }), + text2: i18n.t('question:permission.buy_order_quantity', { + count: offers.length, + postProcess: 'capitalizeFirstChar', + }), + highlightedText: `${decimalAmount(purchaseAmount)} ${coin}`, + confirmCheckbox: true, + }); + if (!permission?.accepted) throw new LocalWalletError('declined'); + const assertSession = async () => { + if ( + (await getSaveWallet()).address0 !== wallet.address0 || + (await publicKey()) !== xpub || + (await createEndpoint('/crosschain/tradebot/respond/local')) !== + endpoint + ) + throw new LocalWalletError('changed'); + }; + await assertSession(); + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + addresses: offers.map((o) => o.qortalAtAddress), + xpub58: xpub, + receivingAddress: wallet.address0, + expectedChainId: getForeignWalletMainnetChainId(coin), + }), + signal: AbortSignal.timeout(90000), + }); + if (response.status === 404 || response.status === 405) + throw new LocalWalletError('upgrade'); + if (!response.ok) throw new LocalWalletError('invalid'); + const plans = JSON.parse( + await readBoundedWalletResponse(response, 1024 * 1024) + ); + let payments; + try { + payments = validateLocalTradePlan(plans, offers, coin, wallet.address0); + } catch { + throw new LocalWalletError('invalid'); + } + await assertSession(); + const total = payments.reduce((sum, p) => sum + p.value, 0n); + const txId = await sendLocalForeignCoin( + { + coin, + amount: decimalAmount(total), + recipient: payments[0].address, + broadcastBefore: Math.min( + ...plans.map((p) => (p.lockTime - 300) * 1000) + ), + }, + async (payload) => { + await assertSession(); + const permission = await approve({ + ...payload, + text1: i18n.t('question:permission.buy_order', { + postProcess: 'capitalizeFirstChar', + }), + text2: i18n.t('question:permission.buy_order_quantity', { + count: offers.length, + postProcess: 'capitalizeFirstChar', + }), + text3: payments + .map((p) => `${decimalAmount(p.value)} ${coin} → ${p.address}`) + .join('\n'), + }); + await assertSession(); + // Locktime validation is repeated after approval before allowing a signature. + validateLocalTradePlan(plans, offers, coin, wallet.address0); + return permission; + }, + payments, + async () => { + await assertSession(); + validateLocalTradePlan(plans, offers, coin, wallet.address0); + }, + approve + ); + return { + callResponse: true, + extra: { + txId, + atAddresses: offers.map((o) => o.qortalAtAddress), + senderAddress: wallet.address0, + node: endpoint.split('?')[0], + }, + }; + } catch (error) { + if (error instanceof LocalWalletError) throw error; + throw new LocalWalletError('invalid'); + } +} diff --git a/src/qortal/wallet-response.test.ts b/src/qortal/wallet-response.test.ts new file mode 100644 index 00000000..20b835cb --- /dev/null +++ b/src/qortal/wallet-response.test.ts @@ -0,0 +1,29 @@ +import { expect, it, vi } from 'vitest'; +import { readBoundedWalletResponse } from './wallet-response'; + +it('decodes UTF-8 across chunk boundaries', async () => { + const bytes = new TextEncoder().encode('€'); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice(0, 1)); + controller.enqueue(bytes.slice(1)); + controller.close(); + }, + }); + expect(await readBoundedWalletResponse(new Response(body), 3)).toBe('€'); +}); + +it('cancels an oversized stream without relying on Content-Length', async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(5)); + }, + cancel, + }); + await expect( + readBoundedWalletResponse(new Response(body), 4) + ).rejects.toThrow(); + expect(cancel).toHaveBeenCalledOnce(); + expect(body.locked).toBe(false); +}); diff --git a/src/qortal/wallet-response.ts b/src/qortal/wallet-response.ts new file mode 100644 index 00000000..bcf67f2f --- /dev/null +++ b/src/qortal/wallet-response.ts @@ -0,0 +1,31 @@ +/** Bound untrusted node responses even when Content-Length is absent. */ +export async function readBoundedWalletResponse( + response: Response, + maximumBytes: number +): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error('Invalid wallet response'); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + length += value.length; + if (length > maximumBytes) { + await reader.cancel(); + throw new Error('Wallet response too large'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(bytes); +} diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 5754a55f..a877bbba 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -84,6 +84,25 @@ declare global { isExtension?: unknown, appInfo?: unknown ) => Promise; + foreignWalletSigner?: { + importKeys: ( + keys: Record + ) => Promise>; + publicKey: (coin: string) => Promise; + clear: () => Promise; + sign: ( + request: import('../lib/foreign-wallet/desktop-engine').DesktopSignRequest, + language: string + ) => Promise<{ + signed?: import('../lib/foreign-wallet/foreign-wallet-transaction').ForeignWalletSignedTransaction; + error?: 'invalid' | 'changed' | 'declined' | 'pending'; + }>; + }; + foreignWalletJournal?: { + get: (key: string) => Promise; + set: (key: string, value: string) => Promise; + delete: (key: string, txId: string) => Promise; + }; appStorage?: { get: (key: string) => Promise; set: (key: string, value: unknown) => Promise; diff --git a/src/utils/chromeStorage.foreign-wallet.test.ts b/src/utils/chromeStorage.foreign-wallet.test.ts new file mode 100644 index 00000000..dc7d7574 --- /dev/null +++ b/src/utils/chromeStorage.foreign-wallet.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, afterEach, it, expect, vi } from 'vitest'; +import { webcrypto } from 'node:crypto'; +import { SecureStoragePlugin } from '@evva/capacitor-secure-storage-plugin'; +import { storeData, getData, removeKeysAndLogout } from './chromeStorage'; +const storage = new Map(); +const privateKeys = Object.fromEntries( + ['btc', 'ltc', 'doge', 'dgb', 'rvn'].map((c) => [ + `${c}PrivateKey`, + `${c}-secret`, + ]) +); +const publicKeys = Object.fromEntries( + ['BTC', 'LTC', 'DOGE', 'DGB', 'RVN'].map((c) => [c, `${c}-public`]) +); +beforeEach(() => { + // Node WebCrypto rejects jsdom's cross-realm ArrayBuffer; retain real AES-GCM + // while normalizing that one boundary to a native Buffer. + vi.stubGlobal('crypto', { + getRandomValues: webcrypto.getRandomValues.bind(webcrypto), + subtle: { + generateKey: webcrypto.subtle.generateKey.bind(webcrypto.subtle), + encrypt: webcrypto.subtle.encrypt.bind(webcrypto.subtle), + decrypt: (algorithm, key, data) => + webcrypto.subtle.decrypt( + algorithm, + key, + Buffer.from(new Uint8Array(data)) + ), + }, + }); + storage.clear(); + vi.mocked(SecureStoragePlugin.set).mockImplementation( + async ({ key, value }) => { + storage.set(key, value); + return { value: true }; + } + ); + vi.mocked(SecureStoragePlugin.get).mockImplementation(async ({ key }) => ({ + value: storage.get(key), + })); +}); +afterEach(() => { + delete window.foreignWalletSigner; + vi.unstubAllGlobals(); +}); +function signer() { + window.foreignWalletSigner = { + importKeys: vi.fn(async () => publicKeys), + publicKey: vi.fn(), + sign: vi.fn(), + clear: vi.fn(async () => {}), + }; + return window.foreignWalletSigner; +} +it('passes only foreign roots to native storage and removes them from renderer key records', async () => { + const native = signer(); + await storeData('keyPair', { + ...privateKeys, + privateKey: 'qortal-secret', + arrrSeed58: 'pirate-secret', + }); + expect(native.importKeys).toHaveBeenCalledWith(privateKeys); + const result = await getData('keyPair'); + expect(result.privateKey).toBe('qortal-secret'); + expect(result.arrrSeed58).toBe('pirate-secret'); + for (const [coin, value] of Object.entries(publicKeys)) { + expect(result[`${coin.toLowerCase()}PublicKey`]).toBe(value); + expect(result).not.toHaveProperty(`${coin.toLowerCase()}PrivateKey`); + } +}); +it('migrates existing encrypted renderer records once', async () => { + await storeData('keyPair', privateKeys); + const native = signer(); + expect(await getData('keyPair')).not.toHaveProperty('ltcPrivateKey'); + await getData('keyPair'); + expect(native.importKeys).toHaveBeenCalledOnce(); +}); +it('fails closed if the native key import fails', async () => { + const native = signer(); + vi.mocked(native.importKeys).mockRejectedValue(new Error('failed')); + await expect(storeData('keyPair', privateKeys)).rejects.toThrow(); + expect(storage.has('keyPair')).toBe(false); +}); +it('clears the signing session on logout', async () => { + const native = signer(); + await removeKeysAndLogout( + ['keyPair'], + { source: { postMessage: vi.fn() }, origin: 'test' } as any, + { requestId: 'logout' } + ); + expect(native.clear).toHaveBeenCalledOnce(); +}); diff --git a/src/utils/chromeStorage.ts b/src/utils/chromeStorage.ts index 0b3ff7c2..ef0d1250 100644 --- a/src/utils/chromeStorage.ts +++ b/src/utils/chromeStorage.ts @@ -1,3 +1,4 @@ +import i18n from 'i18next'; import { SecureStoragePlugin } from '@evva/capacitor-secure-storage-plugin'; let inMemoryKey: CryptoKey | null = null; @@ -79,9 +80,39 @@ function base64ToJson(base64) { return JSON.parse(new TextDecoder().decode(bytes)); } +// Keep explicit foreign xprv strings in the native signing session after unlock. +// Qortal's renderer-held secret still contains the shared derivation seed; + +async function isolateForeignWalletKeys(payload: any) { + if (!window.foreignWalletSigner) return payload; + const coins = ['BTC', 'LTC', 'DOGE', 'DGB', 'RVN']; + if (!coins.some((coin) => payload?.[`${coin.toLowerCase()}PrivateKey`])) + return payload; + let publicKeys: Record; + try { + publicKeys = await window.foreignWalletSigner.importKeys( + Object.fromEntries( + coins.map((coin) => { + const field = `${coin.toLowerCase()}PrivateKey`; + return [field, payload[field]]; + }) + ) + ); + } catch { + throw new Error(i18n.t('question:local_send.invalid')); + } + const sanitized = { ...payload }; + for (const coin of coins) { + delete sanitized[`${coin.toLowerCase()}PrivateKey`]; + sanitized[`${coin.toLowerCase()}PublicKey`] = publicKeys[coin]; + } + return sanitized; +} + export const storeData = async (key: string, payload: any): Promise => { await initializeKeyAndIV(); + if (key === 'keyPair') payload = await isolateForeignWalletKeys(payload); const base64Data = jsonToBase64(payload); if (keysToEncrypt.includes(key) && inMemoryKey) { @@ -124,7 +155,13 @@ export const getData = async (key: string): Promise => { inMemoryKey, iv ); - return base64ToJson(decryptedBase64Data); + const payload = base64ToJson(decryptedBase64Data); + if (key === 'keyPair' && window.foreignWalletSigner) { + const sanitized = await isolateForeignWalletKeys(payload); + if (sanitized !== payload) await storeData(key, sanitized); + return sanitized; + } + return payload; } else { // Decode non-encrypted data return base64ToJson(storedDataBase64.value); @@ -144,6 +181,7 @@ export async function removeKeysAndLogout( request: any ) { try { + if (keys.includes('keyPair')) await window.foreignWalletSigner?.clear(); for (const key of keys) { try { await SecureStoragePlugin.remove({ key }); diff --git a/vite.config.ts b/vite.config.ts index 0ad25052..7e344c1a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -43,7 +43,41 @@ export default defineConfig({ : {}, }, build: { + // Explicit runtime baseline for the PSBT dependencies (Object.hasOwn). + // Also prevents the TLA plugin from reverting to its obsolete Safari 14 target. + target: ['es2020', 'chrome94', 'edge94', 'firefox93', 'safari15.4'], rollupOptions: { + onwarn(warning, warn) { + const id = warning.id?.replace(/\\/g, '/'); + // Scure's documentation comment contains an example PURE annotation. + // Keep warnings about actual misplaced annotations everywhere else. + if ( + warning.code === 'INVALID_ANNOTATION' && + id?.endsWith('/node_modules/@scure/base/index.js') && + warning.message.includes('// Freeze the result of a thunk.') + ) + return; + // This Babel-only directive has no runtime effect. + if ( + warning.code === 'MODULE_LEVEL_DIRECTIVE' && + id?.endsWith( + '/node_modules/react-virtualized/dist/es/WindowScroller/utils/onScroll.js' + ) && + warning.message.includes('no babel-plugin-flow-react-proptypes') + ) + return; + // bcrypt 2.4.3 catches its optional Node import and uses Web Crypto. + // Do not hide other modules accidentally importing Node APIs. + if ( + warning.code === 'PLUGIN_WARNING' && + warning.plugin === 'vite:resolve' && + /^(?:\[plugin vite:resolve\] )?Module "crypto" has been externalized for browser compatibility, imported by "[^"]*\/node_modules\/bcryptjs\/dist\/bcrypt\.js"\./.test( + warning.message.replace(/\\/g, '/') + ) + ) + return; + warn(warning); + }, input: { main: resolve(__dirname, 'index.html'), audioSurface: resolve(__dirname, 'audio-surface.html'), From bb62a6195fbeaeed6ca592fdfd76ed50a55ae8de Mon Sep 17 00:00:00 2001 From: PhilReact Date: Wed, 9 Sep 2026 17:14:04 +0300 Subject: [PATCH 24/32] fixes --- docs/local-wallet-signing.md | 45 +- docs/masque-relay-access.md | 110 ++++ electron/electron-builder.config.json | 5 + .../main.go | 4 + electron/native/private-transport/go.mod | 1 + electron/native/private-transport/go.sum | 4 +- .../internal/masque/client.go | 74 ++- .../private-transport/internal/masque/pool.go | 244 +++++++++ .../internal/masque/tickets.go | 124 +++++ .../internal/protocol/relay.go | 67 +++ .../internal/protocol/server.go | 41 +- electron/resources/masque_discovery_codec.py | 84 +++ electron/resources/presence_bridge.py | 79 ++- electron/resources/presence_bridge_test.py | 46 ++ electron/scripts/build-rnsd-frozen.py | 3 + electron/scripts/linux-docker-entry.sh | 30 ++ electron/src/masque-relay-discovery.test.ts | 5 + electron/src/masque-relay-discovery.ts | 53 ++ electron/src/moq-masque-transport.test.ts | 40 ++ electron/src/moq-masque-transport.ts | 38 +- electron/src/private-channel-step3.test.ts | 2 +- electron/src/private-channel-step4.test.ts | 2 +- electron/src/private-transport-runtime.ts | 27 +- .../src/private-transport-sidecar.test.ts | 25 +- electron/src/private-transport-sidecar.ts | 85 ++- electron/src/quic-masque-transport.ts | 54 +- electron/src/relay-access-coordinator.test.ts | 263 +++++++++ electron/src/relay-access-coordinator.ts | 502 ++++++++++++++++++ electron/src/relay-ticket-wallet.test.ts | 141 +++++ electron/src/relay-ticket-wallet.ts | 207 ++++++++ electron/src/reticulum-bridge.ts | 10 +- electron/src/setup.ts | 61 +++ src/App.tsx | 19 + src/background/background-cases.ts | 70 +++ src/background/background.ts | 4 + .../reticulum-signing-policy.test.ts | 20 + src/background/reticulum-signing-policy.ts | 21 + src/components/Group/Group.tsx | 1 - src/i18n/locales/ar/question.json | 2 + src/i18n/locales/de/question.json | 2 + src/i18n/locales/en/question.json | 2 + src/i18n/locales/es/question.json | 2 + src/i18n/locales/et/question.json | 2 + src/i18n/locales/fi/question.json | 2 + src/i18n/locales/fr/question.json | 2 + src/i18n/locales/it/question.json | 2 + src/i18n/locales/ja/question.json | 2 + src/i18n/locales/pt/question.json | 2 + src/i18n/locales/ru/question.json | 2 + src/i18n/locales/zh/question.json | 2 + src/lib/foreign-wallet/send.test.ts | 172 +++++- src/lib/foreign-wallet/send.ts | 154 ++++-- src/lib/foreign-wallet/trade-plan.test.ts | 39 +- src/lib/foreign-wallet/trade-plan.ts | 9 +- src/qortal/foreign-coin-send.test.ts | 58 +- src/qortal/foreign-coin-send.ts | 193 +++++-- 56 files changed, 3107 insertions(+), 153 deletions(-) create mode 100644 docs/masque-relay-access.md create mode 100644 electron/native/private-transport/internal/masque/pool.go create mode 100644 electron/native/private-transport/internal/masque/tickets.go create mode 100644 electron/native/private-transport/internal/protocol/relay.go create mode 100644 electron/resources/masque_discovery_codec.py create mode 100644 electron/src/relay-access-coordinator.test.ts create mode 100644 electron/src/relay-access-coordinator.ts create mode 100644 electron/src/relay-ticket-wallet.test.ts create mode 100644 electron/src/relay-ticket-wallet.ts diff --git a/docs/local-wallet-signing.md b/docs/local-wallet-signing.md index a8853f42..e3f39000 100644 --- a/docs/local-wallet-signing.md +++ b/docs/local-wallet-signing.md @@ -217,6 +217,25 @@ bytes, and requires the Electrum server to return the transaction ID calculated from those bytes. It returns that transaction ID to Hub. A broadcast response is an acknowledgement, not a blockchain confirmation. +### Exact transaction status + +```http +POST /crosschain/{btc|ltc|doge|dgb|rvn}/wallet/public/transaction-status +Content-Type: application/json +``` + +```json +{ + "expectedChainId": "bip122:", + "txId": "" +} +``` + +This lightweight endpoint looks up only the transaction Hub already signed. It +does not receive the wallet xpub or private key. Core returns `UNKNOWN`, +`MEMPOOL` or `CONFIRMED`, together with the exact coin, network, chain ID and +transaction ID so Hub can reject a response for the wrong chain or transaction. + ### Local trade preparation ```http @@ -330,12 +349,20 @@ to arbitrate concurrent application instances. Browser and Android use local storage and Web Locks where available. Overlapping sends for the same wallet and coin are rejected rather than queued. -Hub keeps a reservation after a successful broadcast acknowledgement until -wallet history sees the transaction and the inputs disappear from discovery. If -the outcome remains uncertain, the next send can offer to rebroadcast only the -exact saved bytes with explicit permission. Recovery never builds a replacement -transaction and never reports an older recovered payment as success for a new -request. +Hub keeps a reservation after a successful broadcast acknowledgement. It checks +the exact transaction on login, when the selected Core changes, when the window +regains focus, every minute while the wallet is unlocked, and before another +send. A confirmed transaction clears the reservation automatically. If this +happens during a new send, that send continues to its normal payment approval. +An unconfirmed mempool transaction remains reserved and blocks a new payment. + +If a Core reports `UNKNOWN`, Hub keeps the reservation because a newly selected +or out-of-sync Core might simply not have seen the transaction yet. The next +send can offer to rebroadcast only the exact saved bytes with explicit +permission. Recovery never builds a replacement transaction and never reports +an older recovered payment as success for a new request. Changing Core aborts +an in-flight automatic check, and the result is ignored unless the same wallet +and Core are still selected. Corrupt journal entries, old entries without saved bytes, expired trade-funding transactions and abandoned desktop lock files fail closed and require manual @@ -408,6 +435,7 @@ Typical failures include: approval/signing flow; - `declined`: the user rejected Hub's normal permission request; - `pending`: another send is active or a previous transaction remains reserved; +- `confirming`: the prior payment is in the mempool and remains reserved; - `unknown`: broadcast outcome is uncertain, so the exact transaction remains journaled for safe recovery; - `invalid`: input, response, address, amount, fee or signed transaction @@ -439,8 +467,9 @@ The main Hub components are: The main Core components are: -- `LocalWalletSupport`: public wallet discovery and signed-byte broadcast; -- `ForeignWalletRequest`: discovery and broadcast request model; +- `LocalWalletSupport`: public wallet discovery, exact transaction status and + signed-byte broadcast; +- `ForeignWalletRequest`: discovery, status and broadcast request model; - the five `CrossChain*Resource` classes: coin-specific endpoint exposure; - `LocalTradeFunding`: durable local trade preparation and progression; - `CrossChainTradeBotResource`: local funding endpoint; diff --git a/docs/masque-relay-access.md b/docs/masque-relay-access.md new file mode 100644 index 00000000..61a5181b --- /dev/null +++ b/docs/masque-relay-access.md @@ -0,0 +1,110 @@ +# MASQUE relay admission + +Hub's relay admission is generic transport infrastructure. QApps and application +backends never receive relay tickets and do not participate in authorization. + +## Flow + +1. Discover signed availability over Reticulum. Public relays remain v1/v2; + restricted relays must use v3. Do not fall back to the old account proof. +2. Prefer reusable connections, then public/matching-group relays, then unknown + membership. Coalesce selection and hedge after 300 ms, at most two concurrent + candidates and six attempts within a 60-second cold-path budget. + Fresh membership information excludes nonmatching restricted relays, even + if none remain. That case returns `RELAY_NO_ELIGIBLE_RELAY` without requesting + tickets or opening QUIC. Unknown or stale membership still permits trying + a relay; public relays remain eligible. +3. For restricted relays, fetch the current public ticket descriptor through an + encrypted Reticulum Link. Check its relay pin, group-policy hash and key + commitment against the signed advertisement. Native Go additionally validates + the RSA key, derived key ID and shared hourly expiry. +4. Generate three independent random tickets and blind them with CIRCL v1.6.5 + (RFC 9474 RSABSSA-SHA384-PSS-Deterministic). Sign a one-minute issuance challenge + in the trusted wallet context. The challenge binds the blinded batch. +5. Send the account proof **only through Reticulum**. The relay checks account + ownership and group membership, then blind-signs the batch. Unblind and + verify inside native Go. Store resulting tickets only in Hub main memory. +6. Prepare pinned QUIC and redeem one anonymous ticket. No account address, + public key or wallet signature goes over QUIC. Only then attach a backend. +7. Use spare single-use tickets for reconnects; refresh batches when exhausted + or near expiry. Reuse the authorized outer connection across QApps. Renew + five minutes before expiry without replacing the connection. +8. Logout/account switch clears credentials, pending results, timers and + connections. There is no direct-to-backend fallback. + +Expiry is issuance-hour start plus 12 hours (11–12 hours of actual validity). +Redemption never extends it. The relay commits a spent marker durably before +forwarding, including across crashes/restarts. Existing grants and spare tickets +do not require Core availability. A member removed from a group can retain +access for the remaining window, subject also to Core freshness. + +## Protocol and packaging + +Sidecar version: **0.7.0**; IPC framing remains version 2. Main-only operations: +`prepareRelayTickets`, `finalizeRelayTickets`, `prepareRelay`, `authorizeRelay`, +`closeRelay`, `clearRelays`. Native ticket blinding state is bounded and expires +after a minute. Prepared connection handles remain process-local. + +The Reticulum service uses the persistent discovery identity and destination +`qortal-hub-v3.community-masque-relay.v1`. Request paths are `/catalog`, +`/challenge`, `/issue`; serialized JSON requests are transported as encrypted +RNS Link requests/resources, not announcements. The Hub Python handler uses a +dedicated four-worker bound and tears down each temporary link after response. + +A v3 advertisement has a three-byte header (version; IPv6 bit plus name length +minus one; group count), IP, uint16 port, uint32 lease expiry, TLS pin, name, +canonical LEB128 group IDs, full 64-byte Reticulum public identity, 32-byte +ticket-key commitment, and 64-byte Ed25519 signature. Signature domain: +`Qortal-MASQUE-Discovery-v3\\0`. Total is at most 316 bytes, including 16 +maximum-size IDs with IPv6 and the default relay name. The shared codec ships +beside the bridge and in frozen/packaged builds. + +Policy digest: SHA-256 of compact JSON +`{"Mode":"groups","Groups":[sorted IDs]}`. + +Ticket key ID: SHA-256 of +`Qortal-MASQUE-Ticket-v1\\0 || policyHex || relayPinHex || decimalEpoch || PKCS1PublicDER`. +Epoch is floor(Unix seconds / 3600). Ticket signed message: +`Qortal-MASQUE-Ticket-v1\\0 || keyIdHex || random32`. +Redemption JSON contains only epoch, keyId, base64 message and base64 signature. + +Issuance proof type: `masque-ticket-issue-v1`, with relayPin, policy, binding, +nonce, expiresAt. Binding is SHA-256 of compact JSON +`{"key":keyId,"blinded":[base64 blinded messages]}`. +The wallet adds authorAddress/authorPublicKey and Ed25519-signs the sorted JSON, +excluding signature. Public key and signature are Base58. This domain is only +available to Hub's own wallet context, not an iframe. + +QUIC control still uses `Qortal-Relay-Control: authorize`. A restricted, +unauthorized connection returns 401 and +`Qortal-Relay-Challenge: {"type":"masque-ticket-required-v1"}`. +Main passes a ticket to the sidecar, which transmits `Qortal-Relay-Ticket`. +An authorized connection returns 204 and `Qortal-Relay-Expires`. +Renewal sets `Qortal-Relay-Renew: 1`. Account-shaped proofs are rejected +independently by native Go and the relay. + +## Boundaries + +This assumes the Reticulum authorization path hides the user's IP. Blind signing +prevents direct transcript-to-ticket matching, not timing correlation, small-group +inference, malicious issuer equivocation, or deliberate ticket sharing. A signed +key commitment is not a global key transparency system. Do not claim full anonymity +or non-transferable membership enforcement. + +The standalone relay's ACCESS.md describes operator configuration, persistence, +capacity, Core failover and deployment. Public mode remains the default; enabling +groups requires an explicit operator choice. Update/restart Hub and the relay, +not the Call backend/QApp. Never restore old spent-ticket state alongside retained +signing keys. + +## Verification + +- `npm run test:private-transport` with `QORTAL_GO_BINARY` when necessary. +- `npx vitest run electron/src/relay-access-coordinator.test.ts electron/src/relay-ticket-wallet.test.ts src/background/reticulum-signing-policy.test.ts`. +- `python3 -m unittest presence_bridge_test` from electron/resources. +- `go test -race ./...` from electron/native/private-transport. +- Relay's cross-project Python test exercises the actual Hub Python handler, + isolated Reticulum instances, real sidecar/relay binaries and a mock Core, + including denial, forwarding, spare tickets and replay after restart. + +These checks do not replace a real macOS wallet/cross-network smoke test. diff --git a/electron/electron-builder.config.json b/electron/electron-builder.config.json index 9bcb25ba..c89ae5bf 100644 --- a/electron/electron-builder.config.json +++ b/electron/electron-builder.config.json @@ -15,6 +15,10 @@ "from": "resources/presence_bridge.py", "to": "reticulum/presence_bridge.py" }, + { + "from": "resources/masque_discovery_codec.py", + "to": "reticulum/masque_discovery_codec.py" + }, { "from": "resources/qortal_python_diagnostics.py", "to": "reticulum/qortal_python_diagnostics.py" @@ -45,6 +49,7 @@ "filter": [ "**/*", "!presence_bridge.py", + "!masque_discovery_codec.py", "!qortal_python_diagnostics.py", "!sitecustomize.py", "!qortalland_games.py", diff --git a/electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go b/electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go index e5548b93..c4152a35 100644 --- a/electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go +++ b/electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go @@ -92,6 +92,10 @@ func startFixture() (*fixture, map[string]string, error) { template := uritemplate.MustNew("https://" + relayAddress + "/.well-known/masque/udp/{target_host}/{target_port}/") mux := http.NewServeMux() mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Qortal-Relay-Control") == "authorize" { + w.WriteHeader(http.StatusNoContent) + return + } req, parseErr := masque.ParseProxyRequest(r, template) if parseErr != nil || req.Target != f.targetAddress { w.WriteHeader(http.StatusBadRequest) diff --git a/electron/native/private-transport/go.mod b/electron/native/private-transport/go.mod index f9f599f7..aa2fcc8f 100644 --- a/electron/native/private-transport/go.mod +++ b/electron/native/private-transport/go.mod @@ -5,6 +5,7 @@ go 1.26.0 replace github.com/mengelbart/moqtransport => ./third_party/moqtransport require ( + github.com/cloudflare/circl v1.6.5 github.com/mengelbart/moqtransport v0.5.1-0.20260831154657-9eaf40a4dedd github.com/quic-go/masque-go v0.5.0 github.com/quic-go/quic-go v0.62.0 diff --git a/electron/native/private-transport/go.sum b/electron/native/private-transport/go.sum index 9f8c8e74..ae16ad01 100644 --- a/electron/native/private-transport/go.sum +++ b/electron/native/private-transport/go.sum @@ -1,7 +1,7 @@ +github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= +github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/mengelbart/moqtransport v0.5.1-0.20260831154657-9eaf40a4dedd h1:1WwmLXspjPZ4sBUrWB49rb0/wATktw00sP+MTiz8l5U= -github.com/mengelbart/moqtransport v0.5.1-0.20260831154657-9eaf40a4dedd/go.mod h1:wyWZNeIt4JfbDBNXKa2zU+pVHMh7qGF8eDJQyMEraLc= github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/masque-go v0.5.0 h1:Ii6HUr/oRIROG9+xnGCMTKo7N8fb/J3hZOh1AMPH36M= diff --git a/electron/native/private-transport/internal/masque/client.go b/electron/native/private-transport/internal/masque/client.go index e02a56d1..6becd755 100644 --- a/electron/native/private-transport/internal/masque/client.go +++ b/electron/native/private-transport/internal/masque/client.go @@ -9,7 +9,9 @@ import ( "errors" "fmt" "net" + "net/http" "net/netip" + "sync" "time" masque "github.com/quic-go/masque-go" @@ -34,6 +36,8 @@ func tunnelQUICConfig() *quic.Config { } type Config struct { + PreparedRelay string + LegacyRelay bool RelayAddress string RelayServerName string RelayCertSHA256 string @@ -42,7 +46,9 @@ type Config struct { } type Tunnel struct { - conn net.PacketConn + conn net.PacketConn + onClose func() + closeOnce sync.Once } // PacketConn is the only packet path exposed to the inner QUIC transport. @@ -58,6 +64,9 @@ func (t *Tunnel) RemoteAddr() net.Addr { } func Open(ctx context.Context, cfg Config) (*Tunnel, error) { + if cfg.PreparedRelay != "" { + return openPrepared(ctx, cfg) + } relay, err := parseLiteralAddrPort("relayAddress", cfg.RelayAddress) if err != nil { return nil, err @@ -126,8 +135,11 @@ func Open(ctx context.Context, cfg Config) (*Tunnel, error) { return quic.DialAddr(dialCtx, relayAddress, tlsConf, quicConf) }, } - conn, _, err := transport.Dial(req) + conn, response, err := transport.Dial(req) if err != nil { + if response != nil { + return nil, responseError(response) + } return nil, fmt.Errorf("open CONNECT-UDP tunnel: %w", err) } return &Tunnel{conn: conn}, nil @@ -164,4 +176,60 @@ func (t *Tunnel) Receive(timeout time.Duration) ([]byte, error) { return append([]byte(nil), buf[:n]...), nil } -func (t *Tunnel) Close() error { return t.conn.Close() } +func (t *Tunnel) Close() error { + err := t.conn.Close() + t.closeOnce.Do(func() { + if t.onClose != nil { + t.onClose() + } + }) + return err +} + +func responseError(response *http.Response) error { + code := response.Header.Get("Qortal-Relay-Error") + switch code { + case "RELAY_ACCESS_DENIED", "RELAY_PROOF_INVALID", "RELAY_AUTH_REQUIRED", "RELAY_MEMBERSHIP_UNAVAILABLE", "RELAY_TARGET_DENIED", "RELAY_FULL", "RELAY_AUTH_RATE_LIMITED", "RELAY_AUTH_UNAVAILABLE": + return errors.New(code) + } + return errors.New("RELAY_PROTOCOL_UNSUPPORTED") +} + +// Authenticated QUIC application close codes preserve rejection semantics +// even when an immediate revocation closes before its HTTP response arrives. +func relayConnectionError(err error) error { + var applicationError *quic.ApplicationError + if errors.As(err, &applicationError) { + switch applicationError.ErrorCode { + case 0x515201: + return errors.New("RELAY_ACCESS_DENIED") + case 0x515202: + return errors.New("RELAY_AUTH_REQUIRED") + case 0x515203: + return errors.New("RELAY_FULL") + } + } + return errors.New("RELAY_CONNECT_FAILED") +} + +func pinnedRelayTLS(cfg Config) (*tls.Config, string, error) { + relay, err := parseLiteralAddrPort("relayAddress", cfg.RelayAddress) + if err != nil { + return nil, "", err + } + pin, err := hex.DecodeString(cfg.RelayCertSHA256) + if err != nil || len(pin) != 32 || cfg.RelayServerName == "" { + return nil, "", errors.New("INVALID_RELAY_CONFIG") + } + return &tls.Config{MinVersion: tls.VersionTLS13, NextProtos: []string{http3.NextProtoH3}, ServerName: cfg.RelayServerName, InsecureSkipVerify: true, VerifyConnection: func(s tls.ConnectionState) error { + if len(s.PeerCertificates) == 0 { + return errors.New("RELAY_CERTIFICATE_INVALID") + } + leaf := s.PeerCertificates[0] + hash := sha256.Sum256(leaf.Raw) + if time.Now().Before(leaf.NotBefore) || time.Now().After(leaf.NotAfter) || leaf.VerifyHostname(cfg.RelayServerName) != nil || subtle.ConstantTimeCompare(hash[:], pin) != 1 { + return errors.New("RELAY_CERTIFICATE_INVALID") + } + return nil + }}, relay.String(), nil +} diff --git a/electron/native/private-transport/internal/masque/pool.go b/electron/native/private-transport/internal/masque/pool.go new file mode 100644 index 00000000..47b3bc59 --- /dev/null +++ b/electron/native/private-transport/internal/masque/pool.go @@ -0,0 +1,244 @@ +package masqueclient + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + masque "github.com/quic-go/masque-go" + "github.com/quic-go/quic-go" + "github.com/yosida95/uritemplate/v3" +) + +type PreparedRelay struct { + Handle string `json:"handle"` + Ready bool `json:"ready"` + Challenge json.RawMessage `json:"challenge,omitempty"` + ExpiresAt int64 `json:"expiresAt,omitempty"` +} +type pooledRelay struct { + mu sync.Mutex + config Config + conn *quic.Conn + client *masque.ClientConn + ready bool + expiresAt int64 + touched time.Time + refs int +} + +var relayPool = struct { + sync.Mutex + entries map[string]*pooledRelay + epoch uint64 +}{entries: map[string]*pooledRelay{}} + +// BeginRelay connects without forwarding any destination traffic. Its handle +// is private IPC state; account authorization remains in the wallet process. +func BeginRelay(ctx context.Context, cfg Config) (PreparedRelay, error) { + relayPool.Lock() + epoch := relayPool.epoch + relayPool.Unlock() + tlsConfig, relayAddress, err := pinnedRelayTLS(cfg) + if err != nil { + return PreparedRelay{}, err + } + ctx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + conn, err := quic.DialAddr(ctx, relayAddress, tlsConfig, tunnelQUICConfig()) + if err != nil { + return PreparedRelay{}, fmt.Errorf("RELAY_CONNECT_FAILED: %w", err) + } + tr := &masque.Transport{} + client, err := tr.NewClientConn(conn) + if err != nil { + conn.CloseWithError(0, "") + return PreparedRelay{}, errors.New("RELAY_PROTOCOL_UNSUPPORTED") + } + id := make([]byte, 24) + if _, err = rand.Read(id); err != nil { + conn.CloseWithError(0, "") + return PreparedRelay{}, err + } + handle := hex.EncodeToString(id) + p := &pooledRelay{config: cfg, conn: conn, client: client, touched: time.Now()} + relayPool.Lock() + if epoch != relayPool.epoch { + relayPool.Unlock() + conn.CloseWithError(0, "account changed") + return PreparedRelay{}, errors.New("RELAY_CONNECTION_CLOSED") + } + if len(relayPool.entries) >= 32 { + relayPool.Unlock() + conn.CloseWithError(0, "") + return PreparedRelay{}, errors.New("RELAY_POOL_FULL") + } + relayPool.entries[handle] = p + relayPool.Unlock() + context.AfterFunc(conn.Context(), func() { relayPool.Lock(); delete(relayPool.entries, handle); relayPool.Unlock() }) + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-conn.Context().Done(): + return + case <-ticker.C: + p.mu.Lock() + idle := p.refs == 0 && time.Since(p.touched) > 30*time.Second + p.mu.Unlock() + if idle { + CloseRelay(handle) + return + } + } + } + }() + if cfg.LegacyRelay { + p.mu.Lock() + p.ready = true + p.mu.Unlock() + return PreparedRelay{Handle: handle, Ready: true}, nil + } + result, err := AuthorizeRelay(ctx, handle, "", false) + if err != nil { + CloseRelay(handle) + } + return result, err +} + +func AuthorizeRelay(ctx context.Context, handle, proof string, renew bool) (PreparedRelay, error) { + p := findRelay(handle) + if p == nil { + return PreparedRelay{}, errors.New("RELAY_CONNECTION_CLOSED") + } + p.mu.Lock() + defer p.mu.Unlock() + p.touched = time.Now() + if p.config.LegacyRelay { + if p.conn.Context().Err() != nil { + return PreparedRelay{}, errors.New("RELAY_CONNECTION_CLOSED") + } + return PreparedRelay{Handle: handle, Ready: true}, nil + } + ctx, cancel := context.WithTimeout(ctx, 7*time.Second) + defer cancel() + template := uritemplate.MustNew("https://" + p.config.RelayAddress + "/.well-known/masque/udp/{target_host}/{target_port}/") + req, err := masque.NewRequest(ctx, template, "0.0.0.0:1") + if err != nil { + return PreparedRelay{}, err + } + req.Header().Set("Qortal-Relay-Control", "authorize") + if renew { + req.Header().Set("Qortal-Relay-Renew", "1") + } + if proof != "" { + if len(proof) > 2048 { + return PreparedRelay{}, errors.New("RELAY_PROOF_INVALID") + } + // Never send account proofs on the IP-visible relay connection. + var token map[string]json.RawMessage + if json.Unmarshal([]byte(proof), &token) != nil || len(token) != 4 || token["epoch"] == nil || token["keyId"] == nil || token["message"] == nil || token["signature"] == nil { + return PreparedRelay{}, errors.New("RELAY_PROOF_INVALID") + } + req.Header().Set("Qortal-Relay-Ticket", proof) + } + c, response, err := p.client.Dial(req) + if c != nil { + c.Close() + } + result := PreparedRelay{Handle: handle} + if response != nil { + if value := response.Header.Get("Qortal-Relay-Challenge"); response.StatusCode == 401 && len(value) > 0 && len(value) <= 1024 && json.Valid([]byte(value)) { + if value == `{"type":"masque-ticket-required-v1"}` { + result.Challenge = json.RawMessage(value) + return result, nil + } + // Old identity-bearing challenges are deliberately unsupported. + return result, errors.New("RELAY_PROTOCOL_UNSUPPORTED") + } + if err != nil { + return result, responseError(response) + } + if response.StatusCode != 204 { + return result, errors.New("RELAY_PROTOCOL_UNSUPPORTED") + } + if v := response.Header.Get("Qortal-Relay-Expires"); v != "" { + if _, e := fmt.Sscan(v, &p.expiresAt); e != nil { + return result, errors.New("RELAY_PROTOCOL_UNSUPPORTED") + } + } + } + if err != nil { + return result, relayConnectionError(err) + } + p.ready = true + result.Ready = true + result.ExpiresAt = p.expiresAt + return result, nil +} + +func findRelay(handle string) *pooledRelay { + relayPool.Lock() + defer relayPool.Unlock() + return relayPool.entries[handle] +} +func CloseRelay(handle string) { + relayPool.Lock() + p := relayPool.entries[handle] + delete(relayPool.entries, handle) + relayPool.Unlock() + if p != nil { + p.conn.CloseWithError(0, "relay connection released") + } +} +func CloseAllRelays() { + clearTicketStates() + relayPool.Lock() + relayPool.epoch++ + entries := relayPool.entries + relayPool.entries = map[string]*pooledRelay{} + relayPool.Unlock() + for _, p := range entries { + p.conn.CloseWithError(0, "account cleared") + } +} + +func openPrepared(ctx context.Context, cfg Config) (*Tunnel, error) { + if _, err := parseLiteralAddrPort("targetAddress", cfg.TargetAddress); err != nil { + return nil, err + } + p := findRelay(cfg.PreparedRelay) + if p == nil { + return nil, errors.New("RELAY_CONNECTION_CLOSED") + } + p.mu.Lock() + if !p.ready || p.config.RelayAddress != cfg.RelayAddress || p.config.RelayCertSHA256 != cfg.RelayCertSHA256 || p.config.RelayServerName != cfg.RelayServerName { + p.mu.Unlock() + return nil, errors.New("RELAY_PROOF_INVALID") + } + p.refs++ + p.touched = time.Now() + p.mu.Unlock() + release := func() { p.mu.Lock(); p.refs--; p.touched = time.Now(); p.mu.Unlock() } + template := uritemplate.MustNew("https://" + cfg.RelayAddress + "/.well-known/masque/udp/{target_host}/{target_port}/") + req, err := masque.NewRequest(ctx, template, cfg.TargetAddress) + if err != nil { + release() + return nil, err + } + conn, response, err := p.client.Dial(req) + if err != nil { + release() + if response != nil { + return nil, responseError(response) + } + return nil, relayConnectionError(err) + } + return &Tunnel{conn: conn, onClose: release}, nil +} diff --git a/electron/native/private-transport/internal/masque/tickets.go b/electron/native/private-transport/internal/masque/tickets.go new file mode 100644 index 00000000..9bf16cfc --- /dev/null +++ b/electron/native/private-transport/internal/masque/tickets.go @@ -0,0 +1,124 @@ +package masqueclient + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "github.com/cloudflare/circl/blindsign/blindrsa" + "strconv" + "sync" + "time" +) + +const ticketDomain = "Qortal-MASQUE-Ticket-v1\x00" + +type TicketDescriptor struct { + Epoch int64 `json:"epoch"` + ExpiresAt int64 `json:"expiresAt"` + PublicKey string `json:"publicKey"` + KeyID string `json:"keyId"` + Policy string `json:"policy"` + RelayPin string `json:"relayPin"` +} +type ticketState struct { + client blindrsa.Client + states []blindrsa.State + messages [][]byte + descriptor TicketDescriptor + created time.Time +} + +var ticketPending = struct { + sync.Mutex + entries map[string]ticketState +}{entries: map[string]ticketState{}} + +func PrepareTickets(d TicketDescriptor) (map[string]any, error) { + ticketPending.Lock() + defer ticketPending.Unlock() + for k, s := range ticketPending.entries { + if time.Since(s.created) > time.Minute { + delete(ticketPending.entries, k) + } + } + if len(ticketPending.entries) >= 16 { + return nil, errors.New("RELAY_BUSY") + } + der, err := base64.StdEncoding.DecodeString(d.PublicKey) + if err != nil || len(der) > 1024 { + return nil, errors.New("RELAY_PROOF_INVALID") + } + pk, err := x509.ParsePKCS1PublicKey(der) + if err != nil || pk.N.BitLen() != 2048 || pk.E != 65537 { + return nil, errors.New("RELAY_PROOF_INVALID") + } + h := sha256.New() + h.Write([]byte(ticketDomain + d.Policy + d.RelayPin + strconv.FormatInt(d.Epoch, 10))) + h.Write(der) + if d.KeyID != hex.EncodeToString(h.Sum(nil)) || d.Epoch != time.Now().Unix()/3600 || d.ExpiresAt != (d.Epoch*3600+43200)*1000 { + return nil, errors.New("RELAY_PROOF_INVALID") + } + client, err := blindrsa.NewClient(blindrsa.SHA384PSSDeterministic, pk) + if err != nil { + return nil, err + } + state := ticketState{client: client, descriptor: d, created: time.Now()} + var blinded []string + for i := 0; i < 3; i++ { + nonce := make([]byte, 32) + if _, err = rand.Read(nonce); err != nil { + return nil, err + } + msg := append([]byte(ticketDomain+d.KeyID), nonce...) + prepared, err := client.Prepare(rand.Reader, msg) + if err != nil { + return nil, err + } + b, s, err := client.Blind(rand.Reader, prepared) + if err != nil { + return nil, err + } + state.messages = append(state.messages, prepared) + state.states = append(state.states, s) + blinded = append(blinded, base64.StdEncoding.EncodeToString(b)) + } + id := make([]byte, 24) + if _, err = rand.Read(id); err != nil { + return nil, err + } + handle := hex.EncodeToString(id) + ticketPending.entries[handle] = state + return map[string]any{"handle": handle, "blinded": blinded}, nil +} +func FinalizeTickets(handle string, signatures []string) (map[string]any, error) { + ticketPending.Lock() + s, ok := ticketPending.entries[handle] + delete(ticketPending.entries, handle) + ticketPending.Unlock() + if !ok || time.Since(s.created) > time.Minute || len(signatures) != len(s.states) { + return nil, errors.New("RELAY_PROOF_INVALID") + } + tickets := make([]string, 0, len(signatures)) + for i, b := range signatures { + raw, err := base64.StdEncoding.DecodeString(b) + if err != nil || len(raw) != 256 { + return nil, errors.New("RELAY_PROOF_INVALID") + } + sig, err := s.client.Finalize(s.states[i], raw) + if err != nil { + return nil, errors.New("RELAY_PROOF_INVALID") + } + token, _ := json.Marshal(map[string]any{"epoch": s.descriptor.Epoch, "keyId": s.descriptor.KeyID, "message": base64.StdEncoding.EncodeToString(s.messages[i]), "signature": base64.StdEncoding.EncodeToString(sig)}) + tickets = append(tickets, string(token)) + } + return map[string]any{"tickets": tickets, "expiresAt": s.descriptor.ExpiresAt}, nil +} +func clearTicketStates() { + ticketPending.Lock() + ticketPending.entries = map[string]ticketState{} + ticketPending.Unlock() +} diff --git a/electron/native/private-transport/internal/protocol/relay.go b/electron/native/private-transport/internal/protocol/relay.go new file mode 100644 index 00000000..72458e35 --- /dev/null +++ b/electron/native/private-transport/internal/protocol/relay.go @@ -0,0 +1,67 @@ +package protocol + +import ( + "context" + "errors" + masqueclient "qortal.org/qortal-hub/private-transport/internal/masque" + "strings" +) + +func relayErrorCode(err error) string { + for _, code := range []string{"RELAY_ACCESS_DENIED", "RELAY_PROOF_INVALID", "RELAY_AUTH_REQUIRED", "RELAY_MEMBERSHIP_UNAVAILABLE", "RELAY_TARGET_DENIED", "RELAY_FULL", "RELAY_AUTH_RATE_LIMITED", "RELAY_AUTH_UNAVAILABLE", "RELAY_CERTIFICATE_INVALID", "RELAY_PROTOCOL_UNSUPPORTED", "RELAY_CONNECTION_CLOSED", "RELAY_POOL_FULL", "RELAY_CONNECT_FAILED"} { + if strings.Contains(err.Error(), code) { + return code + } + } + return "" +} +func (s *Server) relayOperation(ctx context.Context, req Request) Response { + var p struct { + RelayAddress string `json:"relayAddress"` + RelayServerName string `json:"relayServerName"` + RelayCertSHA256 string `json:"relayCertSha256"` + Handle string `json:"handle"` + Proof string `json:"proof"` + Renew bool `json:"renew"` + LegacyRelay bool `json:"legacyRelay"` + Descriptor masqueclient.TicketDescriptor `json:"descriptor"` + Signatures []string `json:"signatures"` + } + if decodeParams(req.Params, &p) != nil { + return failure(req.RequestID, "INVALID_PARAMS", "invalid relay request") + } + var result masqueclient.PreparedRelay + var err error + if req.Operation == "prepareRelayTickets" || req.Operation == "finalizeRelayTickets" { + var value map[string]any + if req.Operation == "prepareRelayTickets" { + value, err = masqueclient.PrepareTickets(p.Descriptor) + } else { + value, err = masqueclient.FinalizeTickets(p.Handle, p.Signatures) + } + if err != nil { + return failure(req.RequestID, "RELAY_PROOF_INVALID", "invalid relay ticket") + } + return success(req.RequestID, value) + } + switch req.Operation { + case "prepareRelay": + result, err = masqueclient.BeginRelay(ctx, masqueclient.Config{RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, RelayCertSHA256: p.RelayCertSHA256, LegacyRelay: p.LegacyRelay}) + case "authorizeRelay": + result, err = masqueclient.AuthorizeRelay(ctx, p.Handle, p.Proof, p.Renew) + case "closeRelay": + masqueclient.CloseRelay(p.Handle) + case "clearRelays": + masqueclient.CloseAllRelays() + default: + err = errors.New("invalid relay operation") + } + if err != nil { + code := relayErrorCode(err) + if code == "" { + code = "RELAY_CONNECT_FAILED" + } + return failure(req.RequestID, code, code) + } + return success(req.RequestID, result) +} diff --git a/electron/native/private-transport/internal/protocol/server.go b/electron/native/private-transport/internal/protocol/server.go index 34287da0..a6b27665 100644 --- a/electron/native/private-transport/internal/protocol/server.go +++ b/electron/native/private-transport/internal/protocol/server.go @@ -22,7 +22,7 @@ import ( const ( Version = 2 - SidecarVersion = "0.5.0" + SidecarVersion = "0.7.0" MaxControlMessageBytes = 64 * 1024 MaxBinaryMessageBytes = innerquic.MaxReliablePayloadBytes maxRememberedRequestIDs = 4096 @@ -81,6 +81,10 @@ func NewServer() *Server { } func (s *Server) Serve(ctx context.Context, input io.Reader, output io.Writer) error { + ctx, cancel := context.WithCancel(ctx) + var preparations sync.WaitGroup + slots := make(chan struct{}, 4) + defer func() { cancel(); preparations.Wait(); masqueclient.CloseAllRelays() }() reader := bufio.NewReaderSize(input, 4096) s.emit = func(event Event, binary []byte) { s.write(output, event, binary) } defer s.Close() @@ -110,6 +114,21 @@ func (s *Server) Serve(ctx context.Context, input io.Reader, output io.Writer) e s.write(output, failure(req.RequestID, "MALFORMED_BINARY_FRAME", "truncated binary frame"), nil) return nil } + if req.Operation == "prepareRelay" || req.Operation == "authorizeRelay" { + select { + case slots <- struct{}{}: + preparations.Add(1) + go func(req Request) { + defer preparations.Done() + defer func() { <-slots }() + response, _ := s.handleRequest(ctx, req, nil) + s.write(output, response, nil) + }(req) + default: + s.write(output, failure(req.RequestID, "RELAY_BUSY", "relay preparation capacity reached"), nil) + } + continue + } response, shutdown := s.handleRequest(ctx, req, binary) s.write(output, response, nil) if shutdown { @@ -182,6 +201,8 @@ func (s *Server) handleRequest(ctx context.Context, req Request, binary []byte) return failure(req.RequestID, "DUPLICATE_REQUEST_ID", "requestId was already used"), false } switch req.Operation { + case "prepareRelay", "authorizeRelay", "closeRelay", "clearRelays", "prepareRelayTickets", "finalizeRelayTickets": + return s.relayOperation(ctx, req), false case "health": return success(req.RequestID, map[string]interface{}{"service": "qortal-private-transport", "sidecarVersion": SidecarVersion, "protocolVersion": Version, "innerAlpn": innerquic.ALPN, "moqAlpn": moqclient.ALPN}), false case "openMasqueTunnel": @@ -220,6 +241,7 @@ func (s *Server) handleRequest(ctx context.Context, req Request, binary []byte) } type moqOpenParams struct { + PreparedRelay string `json:"preparedRelay"` RelayAddress string `json:"relayAddress"` RelayServerName string `json:"relayServerName"` RelayCertSHA256 string `json:"relayCertSha256"` @@ -251,7 +273,8 @@ func (s *Server) openMoqSession(ctx context.Context, req Request) Response { } session, err := moqclient.Open(ctx, moqclient.Config{ Relay: masqueclient.Config{ - RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, + PreparedRelay: p.PreparedRelay, + RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, RelayCertSHA256: p.RelayCertSHA256, TargetAddress: p.BackendAddress, Timeout: duration(p.TimeoutMS), }, @@ -274,6 +297,9 @@ func (s *Server) openMoqSession(ctx context.Context, req Request) Response { } func moqErrorCode(err error) string { + if code := relayErrorCode(err); code != "" { + return code + } text := err.Error() if strings.Contains(text, "MASQUE_TUNNEL_FAILED") { return "MASQUE_TUNNEL_FAILED" @@ -425,6 +451,7 @@ func (s *Server) getMoqSession(id string) *moqclient.Session { } type privateOpenParams struct { + PreparedRelay string `json:"preparedRelay"` RelayAddress string `json:"relayAddress"` RelayServerName string `json:"relayServerName"` RelayCertSHA256 string `json:"relayCertSha256"` @@ -448,7 +475,7 @@ func (s *Server) openPrivateSession(ctx context.Context, req Request) Response { if err != nil { return failure(req.RequestID, "INTERNAL_ERROR", "failed to allocate session ID") } - session, err := innerquic.Open(ctx, innerquic.Config{Relay: masqueclient.Config{RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, RelayCertSHA256: p.RelayCertSHA256, TargetAddress: p.BackendAddress, Timeout: duration(p.TimeoutMS)}, BackendServerName: p.BackendServerName, BackendCertSHA256: p.BackendCertSHA256, LogicalSessionID: p.LogicalSessionID, AttachToken: p.AttachToken, Nonce: p.Nonce, Purpose: p.Purpose, OwnerBindingHash: p.OwnerBindingHash, Timeout: duration(p.TimeoutMS)}, func(e innerquic.Event) { s.emitInner(sessionID, e) }) + session, err := innerquic.Open(ctx, innerquic.Config{Relay: masqueclient.Config{PreparedRelay: p.PreparedRelay, RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, RelayCertSHA256: p.RelayCertSHA256, TargetAddress: p.BackendAddress, Timeout: duration(p.TimeoutMS)}, BackendServerName: p.BackendServerName, BackendCertSHA256: p.BackendCertSHA256, LogicalSessionID: p.LogicalSessionID, AttachToken: p.AttachToken, Nonce: p.Nonce, Purpose: p.Purpose, OwnerBindingHash: p.OwnerBindingHash, Timeout: duration(p.TimeoutMS)}, func(e innerquic.Event) { s.emitInner(sessionID, e) }) if err != nil { return failure(req.RequestID, innerErrorCode(err), "private session establishment failed") } @@ -459,6 +486,9 @@ func (s *Server) openPrivateSession(ctx context.Context, req Request) Response { return success(req.RequestID, map[string]interface{}{"sessionId": sessionID, "innerQuicConnectionId": innerID, "logicalSessionId": p.LogicalSessionID, "transportGeneration": 1}) } func innerErrorCode(err error) string { + if code := relayErrorCode(err); code != "" { + return code + } text := err.Error() for _, code := range []string{"ATTACH_TOKEN_REJECTED", "DATAGRAM_UNSUPPORTED", "SESSION_ATTACH_FAILED", "MASQUE_TUNNEL_FAILED"} { if strings.Contains(text, code) { @@ -548,6 +578,7 @@ func (s *Server) getSession(id string) *innerquic.Session { // Step 2 tunnel operations remain for regression tests. func (s *Server) openTunnel(ctx context.Context, req Request) Response { var p struct { + PreparedRelay string `json:"preparedRelay"` RelayAddress string `json:"relayAddress"` RelayServerName string `json:"relayServerName"` RelayCertSHA256 string `json:"relayCertSha256"` @@ -557,7 +588,7 @@ func (s *Server) openTunnel(ctx context.Context, req Request) Response { if decodeParams(req.Params, &p) != nil { return failure(req.RequestID, "INVALID_PARAMS", "invalid params") } - t, err := masqueclient.Open(ctx, masqueclient.Config{RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, RelayCertSHA256: p.RelayCertSHA256, TargetAddress: p.TargetAddress, Timeout: duration(p.TimeoutMS)}) + t, err := masqueclient.Open(ctx, masqueclient.Config{PreparedRelay: p.PreparedRelay, RelayAddress: p.RelayAddress, RelayServerName: p.RelayServerName, RelayCertSHA256: p.RelayCertSHA256, TargetAddress: p.TargetAddress, Timeout: duration(p.TimeoutMS)}) if err != nil { return failure(req.RequestID, "MASQUE_OPEN_FAILED", "failed to open authenticated MASQUE tunnel") } @@ -648,6 +679,8 @@ func (s *Server) Close() { } } func (s *Server) rememberRequestID(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() if _, ok := s.seen[id]; ok { return true } diff --git a/electron/resources/masque_discovery_codec.py b/electron/resources/masque_discovery_codec.py new file mode 100644 index 00000000..43e4e043 --- /dev/null +++ b/electron/resources/masque_discovery_codec.py @@ -0,0 +1,84 @@ +"""Bounded, signed MASQUE discovery wire format, shared with Hub.""" +import ipaddress +import struct +import time +import RNS + +DOMAIN = b"Qortal-MASQUE-Discovery-v2\x00" +MAX_PAYLOAD = 316 + +def encode(identity, host, port, name, pin, expiry, mode, groups, ticket_key=None): + address = ipaddress.ip_address(host) + groups = sorted(groups) + if mode not in ("public", "groups") or len(groups) > 16 or len(set(groups)) != len(groups): + raise ValueError("invalid relay access policy") + if (mode == "groups") != bool(groups) or any(type(g) is not int or not 0 < g < 2**31 for g in groups): + raise ValueError("invalid relay group IDs") + label = name.encode("ascii") + if not 1 <= len(label) <= 128 or any(ch not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-" for ch in name): + raise ValueError("invalid relay name") + if not 0 < port <= 65535 or len(bytes.fromhex(pin)) != 32: + raise ValueError("invalid relay endpoint") + packed_groups = bytearray() + for group in groups: + while group >= 128: + packed_groups.append((group & 127) | 128) + group >>= 7 + packed_groups.append(group) + version = 3 if ticket_key is not None else 2 + header = bytes([3, (len(label)-1) | (128 if address.version == 6 else 0), len(groups)]) if version == 3 else bytes([2, address.version, len(label), len(groups)]) + data = header + address.packed + data += struct.pack(">HI", port, expiry) + bytes.fromhex(pin) + label + bytes(packed_groups) + if version == 3: + if len(bytes.fromhex(ticket_key)) != 32: raise ValueError("invalid ticket key") + data += identity.get_public_key() + bytes.fromhex(ticket_key) + else: + data += identity.get_public_key()[32:] + domain = b"Qortal-MASQUE-Discovery-v3\x00" if version == 3 else DOMAIN + result = data + identity.sign(domain + data) + if len(result) > MAX_PAYLOAD: + raise ValueError("relay announcement exceeds Reticulum packet budget; shorten server name or group list") + return result + +def decode(raw): + if not 108 <= len(raw) <= MAX_PAYLOAD or raw[0] not in (2, 3) or (raw[0] == 2 and raw[1] not in (4, 6)): + raise ValueError("invalid relay advertisement") + version = raw[0] + if version == 3: + family, name_len, count = (6 if raw[1] & 128 else 4), (raw[1] & 127)+1, raw[2] + else: + _, family, name_len, count = raw[:4] + if not 1 <= name_len <= 128 or count > 16: + raise ValueError("invalid relay advertisement") + tail = 160 if version == 3 else 96 + identity = raw[-160:-96] if version == 3 else None + key = identity[32:] if identity else raw[-96:-64] + domain = b"Qortal-MASQUE-Discovery-v3\x00" if version == 3 else DOMAIN + RNS.Cryptography.Ed25519PublicKey.from_public_bytes(key).verify(raw[-64:], domain + raw[:-64]) + offset = 3 if version == 3 else 4 + length = 4 if family == 4 else 16 + host = str(ipaddress.ip_address(raw[offset:offset + length])); offset += length + port, expiry = struct.unpack(">HI", raw[offset:offset + 6]); offset += 6 + pin = raw[offset:offset + 32].hex(); offset += 32 + name = raw[offset:offset + name_len].decode("ascii"); offset += name_len + groups = [] + for _ in range(count): + value = 0 + for shift in range(0, 35, 7): + if offset >= len(raw) - tail: raise ValueError("truncated group ID") + byte = raw[offset]; offset += 1; value |= (byte & 127) << shift + if byte < 128: + if shift and byte == 0: raise ValueError("non-canonical group ID") + break + else: raise ValueError("invalid group ID") + if not 0 < value < 2**31: raise ValueError("invalid group ID") + groups.append(value) + if offset != len(raw) - tail or groups != sorted(set(groups)): + raise ValueError("invalid group list") + if expiry <= int(time.time()) or expiry > int(time.time()) + 3600: + raise ValueError("expired relay advertisement") + return {"v": version, "h": host, "p": port, "s": name, "c": pin, "x": expiry, + "ticketIdentity": identity.hex() if identity else None, + "ticketKeyId": raw[-96:-64].hex() if identity else None, + "relayIdentity": key.hex(), "accessMode": "groups" if groups else "public", + "allowedGroupIds": groups} diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index 58190787..85c968fd 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -9382,8 +9382,15 @@ def received_payload(self, app_data): raw = bytes(app_data or b"") if not raw or len(raw) > 512: return - value = json.loads(raw.decode("utf-8")) - if not isinstance(value, dict) or int(value.get("v") or 0) != 1: + if raw[0] in (2, 3): + from masque_discovery_codec import decode + value = decode(raw) + else: + value = json.loads(raw.decode("utf-8")) + # V2 is accepted only through the signature-verifying codec. + if not isinstance(value, dict) or value.get("v") != 1: + return + if not isinstance(value, dict) or int(value.get("v") or 0) not in (1, 2, 3): return if value.get("q") is True: return @@ -9417,7 +9424,19 @@ def received_payload(self, app_data): if len(_community_masque_event_times) >= 60: return seen_at = _community_masque_seen_endpoints.get(endpoint_key, 0.0) - if received_at - seen_at < 30.0: + previous = _community_masque_recent_endpoints.get(endpoint_key) + if previous and previous.get("protocolVersion", 1) >= 2: + if value.get("v", 1) < previous.get("protocolVersion") or value.get("relayIdentity") != previous.get("relayIdentity"): + return + if value.get("v") == previous.get("protocolVersion") and expires_at * 1000 <= previous.get("expiresAt", 0): + return + policy_changed = previous and ( + previous.get("protocolVersion") != value.get("v") or + previous.get("ticketKeyId") != value.get("ticketKeyId") or + previous.get("accessMode") != value.get("accessMode", "public") or + previous.get("allowedGroupIds") != value.get("allowedGroupIds", []) + ) + if received_at - seen_at < 30.0 and not policy_changed: return endpoint = { "host": host, @@ -9425,6 +9444,12 @@ def received_payload(self, app_data): "serverName": server_name, "certSha256": cert_sha256, "expiresAt": expires_at * 1000, + "protocolVersion": value.get("v", 1), + "relayIdentity": value.get("relayIdentity"), + "ticketIdentity": value.get("ticketIdentity"), + "ticketKeyId": value.get("ticketKeyId"), + "accessMode": value.get("accessMode", "public"), + "allowedGroupIds": value.get("allowedGroupIds", []), } _community_masque_seen_endpoints[endpoint_key] = received_at _community_masque_recent_endpoints[endpoint_key] = endpoint @@ -27644,6 +27669,52 @@ def request_failed(_value=None) -> None: _qapp_rns_error(req_id, "RNS_REQUEST_TIMEOUT") +_relay_ticket_slots = threading.BoundedSemaphore(4) + +def handle_relay_ticket_request(req_id, payload): + # Dedicated bounded worker: never block QApp/media or control schedulers. + if not _relay_ticket_slots.acquire(blocking=False): + emit_resp(req_id, False, error="RELAY_BUSY") + return + def run(): + link = None + try: + key = bytes.fromhex(str(payload.get("identity") or "")) + path = payload.get("path") + data = json.dumps(payload.get("data"), separators=(",", ":")).encode() + if len(key) != 64 or path not in ("/catalog", "/challenge", "/issue") or len(data) > 8192: + raise ValueError("RELAY_PROOF_INVALID") + identity = RNS.Identity(create_keys=False) + identity.load_public_key(key) + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, APP_NAMESPACE, COMMUNITY_MASQUE_ASPECT, COMMUNITY_MASQUE_VERSION) + deadline = time.monotonic() + 15 + if not RNS.Transport.has_path(destination.hash): + RNS.Transport.request_path(destination.hash) + while not RNS.Transport.has_path(destination.hash) and time.monotonic() < deadline: + time.sleep(0.05) + established = threading.Event() + link = RNS.Link(destination, established_callback=lambda _: established.set()) + if not established.wait(max(0, deadline-time.monotonic())): + raise ValueError("RELAY_AUTH_UNAVAILABLE") + done = threading.Event() + result = [] + def response(receipt): + result.append(receipt.get_response()) + done.set() + link.request(path, data=data, response_callback=response, failed_callback=lambda _: done.set(), timeout=10, max_response_size=8192) + if not done.wait(11) or not result or not isinstance(result[0], dict) or len(json.dumps(result[0])) > 8192: + raise ValueError("RELAY_AUTH_UNAVAILABLE") + emit_resp(req_id, True, payload=result[0]) + except Exception as exc: + code = str(exc) + emit_resp(req_id, False, error=code if code.startswith("RELAY_") else "RELAY_AUTH_UNAVAILABLE") + finally: + if link is not None: + try: link.teardown() + except Exception: pass + _relay_ticket_slots.release() + threading.Thread(target=run, daemon=True, name="relay-ticket").start() + def handle_command(message: Dict[str, Any]) -> None: req_id = str(message.get("id") or "") action = message.get("action") @@ -27661,6 +27732,8 @@ def handle_command(message: Dict[str, Any]) -> None: if action == "start": handle_start(req_id, payload) + elif action == "relay_ticket_request": + handle_relay_ticket_request(req_id, payload) elif action == "publish_presence": handle_publish_presence(req_id, payload) elif action == "clear_presence_cache": diff --git a/electron/resources/presence_bridge_test.py b/electron/resources/presence_bridge_test.py index a675903f..09bf08e8 100644 --- a/electron/resources/presence_bridge_test.py +++ b/electron/resources/presence_bridge_test.py @@ -9791,5 +9791,51 @@ def test_call_pinned_inbound_peer_uses_reserved_capacity(self): ) +class MasqueSignedDiscoveryTest(unittest.TestCase): + def setUp(self): + self.bridge = load_bridge() + self.handler = self.bridge.CommunityMasqueRelayAnnounceHandler() + + def test_unsigned_json_cannot_claim_v2(self): + value = {"v": 2, "h": "8.8.8.8", "p": 47322, "s": "relay.test", "c": "ab" * 32, + "x": int(time.time()) + 600, "relayIdentity": "cd" * 32, + "accessMode": "groups", "allowedGroupIds": [1144]} + self.handler.received_payload(json.dumps(value).encode()) + self.assertEqual(self.bridge._community_masque_recent_endpoints, {}) + + def test_v3_preserves_service_identity_and_key_and_rejects_downgrade(self): + from masque_discovery_codec import encode + identity = RNS.Identity() + expiry = int(time.time()) + 600 + def packet(key=None): + return encode(identity, "8.8.8.8", 47322, "relay.test", "ab"*32, expiry, "groups", [1144], key) + with mock.patch.object(self.bridge, "emit_event"): + self.handler.received_payload(packet("cd"*32)) + value=list(self.bridge._community_masque_recent_endpoints.values())[0] + self.assertEqual(value["protocolVersion"],3) + self.assertEqual(value["ticketIdentity"],identity.get_public_key().hex()) + self.assertEqual(value["ticketKeyId"],"cd"*32) + self.handler.received_payload(packet()) + self.assertEqual(list(self.bridge._community_masque_recent_endpoints.values())[0]["protocolVersion"],3) + # A new key commitment must not be lost to endpoint debouncing. + expiry += 1 + self.handler.received_payload(packet("ef"*32)) + self.assertEqual(list(self.bridge._community_masque_recent_endpoints.values())[0]["ticketKeyId"],"ef"*32) + + def test_signed_direct_response_is_verified_and_not_downgraded(self): + from masque_discovery_codec import encode + identity = RNS.Identity() + expiry = int(time.time()) + 600 + packet = encode(identity, "8.8.8.8", 47322, "relay.test", "ab" * 32, expiry, "groups", [1144]) + with mock.patch.object(self.bridge, "emit_event"): + self.handler.received_packet(packet, None) + values = list(self.bridge._community_masque_recent_endpoints.values()) + self.assertEqual(len(values), 1) + self.assertEqual(values[0]["allowedGroupIds"], [1144]) + self.handler.received_payload(json.dumps({"v": 1, "h": "8.8.8.8", "p": 47322, + "s": "relay.test", "c": "ab" * 32, "x": expiry + 1}).encode()) + self.assertEqual(list(self.bridge._community_masque_recent_endpoints.values())[0]["protocolVersion"], 2) + + if __name__ == "__main__": unittest.main() diff --git a/electron/scripts/build-rnsd-frozen.py b/electron/scripts/build-rnsd-frozen.py index 54ca1594..e44df331 100644 --- a/electron/scripts/build-rnsd-frozen.py +++ b/electron/scripts/build-rnsd-frozen.py @@ -278,6 +278,8 @@ def freeze_target( "qortalland_proximity", "--hidden-import", "qortal_python_diagnostics", + "--hidden-import", + "masque_discovery_codec", "--paths", str(electron_root / "resources"), "--runtime-hook", @@ -319,6 +321,7 @@ def copy_runtime_sources(electron_root: Path, output_dir: Path) -> None: if not source_sitecustomize.is_file(): sys.exit(f"Missing Python diagnostics startup hook: {source_sitecustomize}") shutil.copy2(source_bridge, output_dir / "presence_bridge.py") + shutil.copy2(electron_root / "resources" / "masque_discovery_codec.py", output_dir / "masque_discovery_codec.py") shutil.copy2(source_games, output_dir / "qortalland_games.py") shutil.copy2(source_proximity, output_dir / "qortalland_proximity.py") shutil.copy2(source_diagnostics, output_dir / "qortal_python_diagnostics.py") diff --git a/electron/scripts/linux-docker-entry.sh b/electron/scripts/linux-docker-entry.sh index e7f2089b..f4606836 100755 --- a/electron/scripts/linux-docker-entry.sh +++ b/electron/scripts/linux-docker-entry.sh @@ -4,6 +4,36 @@ set -euo pipefail BUILD_ARCH="${QORTAL_LINUX_DOCKER_ARCH:-x64}" BUILD_PROFILE="${QORTAL_LINUX_DOCKER_PROFILE:-full}" +WORKSPACE_OWNER_UID="$(stat -c %u /workspace)" +WORKSPACE_OWNER_GID="$(stat -c %g /workspace)" + +restore_host_ownership() { + if [[ ! "${WORKSPACE_OWNER_UID}" =~ ^[0-9]+$ || ! "${WORKSPACE_OWNER_GID}" =~ ^[0-9]+$ ]]; then + echo "Skipping ownership restoration: workspace owner is invalid." >&2 + return + fi + + local generated_paths=( + /workspace/dist + /workspace/electron/build + /workspace/electron/dist + /workspace/electron/resources/__pycache__ + /workspace/electron/resources/private-transport + /workspace/electron/resources/reticulum + ) + local existing_paths=() + local generated_path + for generated_path in "${generated_paths[@]}"; do + [[ -e "${generated_path}" ]] && existing_paths+=("${generated_path}") + done + if (( ${#existing_paths[@]} > 0 )); then + chown -R "${WORKSPACE_OWNER_UID}:${WORKSPACE_OWNER_GID}" "${existing_paths[@]}" + fi +} + +# The repository is a host bind mount while this container runs as root. +# Always return generated files to the invoking user, including after failures. +trap restore_host_ownership EXIT case "${BUILD_ARCH}:${BUILD_PROFILE}" in x64:full) diff --git a/electron/src/masque-relay-discovery.test.ts b/electron/src/masque-relay-discovery.test.ts index afb34b36..1ff961f7 100644 --- a/electron/src/masque-relay-discovery.test.ts +++ b/electron/src/masque-relay-discovery.test.ts @@ -18,6 +18,11 @@ const relay = { }; describe('community MASQUE relay discovery', () => { + it('validates v3 service identities and ticket key commitments', () => { + const value={...relay,protocolVersion:3,relayIdentity:'cd'.repeat(32),ticketIdentity:'ef'.repeat(32)+'cd'.repeat(32),ticketKeyId:'12'.repeat(32),accessMode:'groups',allowedGroupIds:[1144]}; + expect(validateCommunityMasqueRelay(value,{now:()=>1_000_000})).toEqual(value); + for(const invalid of [{...value,ticketKeyId:'bad'},{...value,ticketIdentity:'ab'.repeat(64)},{...value,allowedGroupIds:[]}])expect(validateCommunityMasqueRelay(invalid,{now:()=>1_000_000})).toBeNull(); + }); it('validates a pinned literal endpoint with a bounded lease', () => { expect( validateCommunityMasqueRelay(relay, { now: () => 1_000_000 }) diff --git a/electron/src/masque-relay-discovery.ts b/electron/src/masque-relay-discovery.ts index 55941da9..e838f405 100644 --- a/electron/src/masque-relay-discovery.ts +++ b/electron/src/masque-relay-discovery.ts @@ -8,6 +8,12 @@ const MAX_LEASE_AHEAD_MS = 60 * 60_000; const MIN_REMAINING_LEASE_MS = 5_000; export type CommunityMasqueRelay = { + protocolVersion?: number; + relayIdentity?: string; + ticketIdentity?: string; + ticketKeyId?: string; + accessMode?: 'public' | 'groups'; + allowedGroupIds?: number[]; host: string; port: number; serverName: string; @@ -55,6 +61,47 @@ export function validateCommunityMasqueRelay( ) { return null; } + if (candidate.protocolVersion === 2 || candidate.protocolVersion === 3) { + if ( + candidate.protocolVersion === 3 && + (!/^[a-f0-9]{128}$/.test(candidate.ticketIdentity ?? '') || + !/^[a-f0-9]{64}$/.test(candidate.ticketKeyId ?? '') || + candidate.ticketIdentity?.slice(64) !== candidate.relayIdentity) + ) + return null; + if ( + !/^[a-f0-9]{64}$/.test(candidate.relayIdentity ?? '') || + !['public', 'groups'].includes(candidate.accessMode ?? '') || + !Array.isArray(candidate.allowedGroupIds) || + candidate.allowedGroupIds.length > 16 || + candidate.allowedGroupIds.some( + (id) => !Number.isInteger(id) || id <= 0 || id > 2147483647 + ) || + new Set(candidate.allowedGroupIds).size !== + candidate.allowedGroupIds.length || + (candidate.accessMode === 'groups') !== + candidate.allowedGroupIds.length > 0 + ) + return null; + return { + host, + port, + serverName, + certSha256, + expiresAt, + protocolVersion: candidate.protocolVersion, + ticketIdentity: candidate.ticketIdentity, + ticketKeyId: candidate.ticketKeyId, + relayIdentity: candidate.relayIdentity, + accessMode: candidate.accessMode, + allowedGroupIds: candidate.allowedGroupIds, + }; + } + if ( + candidate.protocolVersion !== undefined && + candidate.protocolVersion !== 1 + ) + return null; return { host, port, serverName, certSha256, expiresAt }; } @@ -149,6 +196,12 @@ export async function discoverCommunityMasqueRelay( const host = net.isIP(selected.host) === 6 ? `[${selected.host}]` : selected.host; return { + protocolVersion: selected.protocolVersion, + ticketIdentity: selected.ticketIdentity, + ticketKeyId: selected.ticketKeyId, + relayIdentity: selected.relayIdentity, + accessMode: selected.accessMode, + allowedGroupIds: selected.allowedGroupIds, relayAddress: `${host}:${selected.port}`, relayServerName: selected.serverName, relayCertSha256: selected.certSha256, diff --git a/electron/src/moq-masque-transport.test.ts b/electron/src/moq-masque-transport.test.ts index f280f8c3..68bcfb12 100644 --- a/electron/src/moq-masque-transport.test.ts +++ b/electron/src/moq-masque-transport.test.ts @@ -71,6 +71,46 @@ const context = { }; describe('generic trusted MOQT transport', () => { + it.each([ + ['RELAY_ACCESS_DENIED', true], + ['RELAY_MEMBERSHIP_UNAVAILABLE', true], + ['ATTACH_TOKEN_REJECTED', false], + ['MOQ_ATTACH_FAILED', false], + ['BACKEND_IDENTITY_MISMATCH', false], + ] as const)( + 'distinguishes %s from backend authentication rejection', + async (code, retry) => { + const { sidecar, transport } = setup(); + sidecar.openMoqSession.mockRejectedValueOnce( + new PrivateTransportSidecarError(code) + ); + if (retry) await transport.open(context); + else + await expect(transport.open(context)).rejects.toMatchObject({ code }); + expect(sidecar.openMoqSession).toHaveBeenCalledTimes(retry ? 2 : 1); + await transport.close(); + } + ); + it('refreshes credentials that almost expired while relay discovery ran', async () => { + const { sidecar, provider, transport } = setup(); + vi.mocked(provider.getBootstrap) + .mockResolvedValueOnce({ + ...descriptor, + expiresAt: Date.now() + 1_000, + attachToken: 'old', + }) + .mockResolvedValueOnce({ + ...descriptor, + expiresAt: Date.now() + 30_000, + attachToken: 'fresh', + }); + await transport.open(context); + expect(provider.getBootstrap).toHaveBeenCalledTimes(2); + expect(sidecar.openMoqSession).toHaveBeenCalledWith( + expect.objectContaining({ attachToken: 'fresh' }) + ); + await transport.close(); + }); it('uses an authenticated realtime bootstrap and transports opaque objects', async () => { const { sidecar, provider, transport } = setup(); await transport.open(context); diff --git a/electron/src/moq-masque-transport.ts b/electron/src/moq-masque-transport.ts index 12653ea2..8249921a 100644 --- a/electron/src/moq-masque-transport.ts +++ b/electron/src/moq-masque-transport.ts @@ -93,12 +93,30 @@ export class MoqMasqueTransport { throw new PrivateTransportSidecarError('INVALID_MOQ_CONFIG'); } this.context = context; - await this.connect(context); + for (let attempt = 0; ; attempt++) { + try { + await this.connect(context); + break; + } catch (error) { + if ( + attempt >= 2 || + !this.lastAttemptedRelayAddress || + !isRecoverableMoqError(error) || + this.closed + ) + throw error; + if (this.lastAttemptedRelayAddress) + this.failedRelayAddresses.set( + this.lastAttemptedRelayAddress, + Date.now() + ); + } + } } private async connect(context: MoqOpenContext): Promise { this.lastAttemptedRelayAddress = null; - const relay = await this.resolveRelay(); + const relayPromise = this.resolveRelay(); const bootstrapContext: PrivateTransportContext = { channelId: 'trusted-moq-transport', rnsConnectionId: context.rnsConnectionId, @@ -106,8 +124,12 @@ export class MoqMasqueTransport { generation: 1, owner: context.owner, }; - const bootstrap = - await this.bootstrapProvider.getBootstrap(bootstrapContext); + let [relay, bootstrap] = await Promise.all([ + relayPromise, + this.bootstrapProvider.getBootstrap(bootstrapContext), + ]); + if (bootstrap.expiresAt < Date.now() + 5_000) + bootstrap = await this.bootstrapProvider.getBootstrap(bootstrapContext); if ( bootstrap.applicationProtocol !== 'moqt-18' || bootstrap.supportedFeatures.moqt !== true @@ -117,6 +139,7 @@ export class MoqMasqueTransport { this.lastAttemptedRelayAddress = relay.relayAddress; const openAt = (relayAddress: string) => this.sidecar.openMoqSession({ + preparedRelay: relay.preparedRelay, relayAddress, relayServerName: relay.relayServerName, relayCertSha256: relay.relayCertSha256, @@ -394,6 +417,13 @@ function errorCode(error: unknown): string { function isRecoverableMoqCode(code: string | undefined): boolean { return ( + code === 'RELAY_CONNECT_FAILED' || + code === 'RELAY_CONNECTION_CLOSED' || + code === 'RELAY_TARGET_DENIED' || + code === 'RELAY_FULL' || + code === 'RELAY_AUTH_REQUIRED' || + code === 'RELAY_ACCESS_DENIED' || + code === 'RELAY_MEMBERSHIP_UNAVAILABLE' || code === 'MOQ_TRANSPORT_CLOSED' || code === 'MOQ_QUIC_FAILED' || code === 'MOQ_SESSION_CLOSED' || diff --git a/electron/src/private-channel-step3.test.ts b/electron/src/private-channel-step3.test.ts index 42cdf4f3..fc07e406 100644 --- a/electron/src/private-channel-step3.test.ts +++ b/electron/src/private-channel-step3.test.ts @@ -93,7 +93,7 @@ class Fixture { integration('Step 3 inner QUIC through MASQUE', () => { beforeAll(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qortal-step3-')); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hub-step3-test-')); sidecarBinary = path.join(tempDir, 'sidecar'); fixtureBinary = path.join(tempDir, 'fixture'); const root = path.join( diff --git a/electron/src/private-channel-step4.test.ts b/electron/src/private-channel-step4.test.ts index 59178657..856911df 100644 --- a/electron/src/private-channel-step4.test.ts +++ b/electron/src/private-channel-step4.test.ts @@ -152,7 +152,7 @@ integration('Step 4 real backend bootstrap and attachment', () => { beforeAll(() => { temporaryDirectory = fs.mkdtempSync( - path.join(os.tmpdir(), 'qortal-step4-') + path.join(os.tmpdir(), 'hub-step4-test-') ); sidecarBinary = path.join(temporaryDirectory, 'private-transport-sidecar'); relayBinary = path.join(temporaryDirectory, 'private-transport-relay'); diff --git a/electron/src/private-transport-runtime.ts b/electron/src/private-transport-runtime.ts index a8dcc317..e85d3e5b 100644 --- a/electron/src/private-transport-runtime.ts +++ b/electron/src/private-transport-runtime.ts @@ -8,13 +8,14 @@ import { QuicMasqueTransport } from './quic-masque-transport'; import { ReticulumPrivateChannelBootstrapProvider } from './private-channel-bootstrap'; import type { QAppReticulumManager } from './qapp-reticulum-manager'; import type { ReticulumBridge } from './reticulum-bridge'; -import { discoverCommunityMasqueRelay } from './masque-relay-discovery'; +import { RelayAccessCoordinator } from './relay-access-coordinator'; import { MoqMasqueTransport, type MoqTransportEvent, } from './moq-masque-transport'; let prototypeSidecar: PrivateTransportSidecar | null = null; +let relayCoordinator: RelayAccessCoordinator | null = null; let sidecarShutdownPromise: Promise | null = null; /** @@ -33,6 +34,7 @@ export function getPrivateTransportFactory( reticulumManager ); prototypeSidecar ??= new PrivateTransportSidecar(); + relayCoordinator ??= new RelayAccessCoordinator(prototypeSidecar); return (emit) => new QuicMasqueTransport( emit, @@ -41,11 +43,11 @@ export function getPrivateTransportFactory( (async (excludedRelayAddresses) => { const reticulumBridge = reticulumBridgeProvider(); if (!reticulumBridge) throw new Error('MASQUE_RELAY_UNAVAILABLE'); - return discoverCommunityMasqueRelay(reticulumBridge, { - allowLoopback: - environment.QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY === '1', - excludeRelayAddresses: excludedRelayAddresses, - }); + return relayCoordinator!.select( + reticulumBridge, + excludedRelayAddresses, + environment.QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY === '1' + ); }), bootstrapProvider ); @@ -66,6 +68,7 @@ export function createMoqTransport( ? readTrustedRelayConfig(environment) : null; prototypeSidecar ??= new PrivateTransportSidecar(); + relayCoordinator ??= new RelayAccessCoordinator(prototypeSidecar); return new MoqMasqueTransport( emit, prototypeSidecar, @@ -73,11 +76,11 @@ export function createMoqTransport( (async (excludedRelayAddresses) => { const reticulumBridge = reticulumBridgeProvider(); if (!reticulumBridge) throw new Error('MASQUE_RELAY_UNAVAILABLE'); - return discoverCommunityMasqueRelay(reticulumBridge, { - allowLoopback: - environment.QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY === '1', - excludeRelayAddresses: excludedRelayAddresses, - }); + return relayCoordinator!.select( + reticulumBridge, + excludedRelayAddresses, + environment.QORTAL_PRIVATE_TRANSPORT_ALLOW_LOCAL_RELAY === '1' + ); }), new ReticulumPrivateChannelBootstrapProvider(reticulumManager) ); @@ -101,6 +104,8 @@ export function readTrustedRelayConfig(environment: NodeJS.ProcessEnv) { export async function shutdownPrivateTransportSidecar(): Promise { if (sidecarShutdownPromise) return sidecarShutdownPromise; const sidecar = prototypeSidecar; + relayCoordinator?.dispose(); + relayCoordinator = null; prototypeSidecar = null; if (!sidecar) return; sidecarShutdownPromise = sidecar.shutdown().finally(() => { diff --git a/electron/src/private-transport-sidecar.test.ts b/electron/src/private-transport-sidecar.test.ts index c0032bc9..aea40983 100644 --- a/electron/src/private-transport-sidecar.test.ts +++ b/electron/src/private-transport-sidecar.test.ts @@ -79,7 +79,7 @@ const describeIntegration = goAvailable ? describe.sequential : describe.skip; describeIntegration('private transport sidecar integration', () => { beforeAll(() => { temporaryDirectory = fs.mkdtempSync( - path.join(os.tmpdir(), 'qortal-private-transport-') + path.join(os.tmpdir(), 'hub-private-transport-test-') ); sidecarBinary = path.join(temporaryDirectory, 'qortal-private-transport'); fixtureBinary = path.join( @@ -125,7 +125,7 @@ describeIntegration('private transport sidecar integration', () => { await sidecar.start(); expect(await sidecar.health()).toEqual({ service: 'qortal-private-transport', - sidecarVersion: '0.5.0', + sidecarVersion: '0.7.0', protocolVersion: 2, innerAlpn: 'qortal-private/1', moqAlpn: 'moqt-18', @@ -146,6 +146,25 @@ describeIntegration('private transport sidecar integration', () => { await fixture.close(); }, 20_000); + it('prepares and reuses a relay without forwarding any backend traffic', async () => { + const fixture = new FixtureProcess(); + const config = await fixture.startup(); + const sidecar = new PrivateTransportSidecar({ command: sidecarBinary }); + try { + const result = await sidecar.prepareRelay(config); + expect(result.ready).toBe(true); + expect(await sidecar.authorizeRelay(result.handle)).toEqual(result); + expect((await fixture.command('stats')).echoCount).toBe(0); + await sidecar.closeRelay(result.handle); + await expect(sidecar.authorizeRelay(result.handle)).rejects.toMatchObject( + { code: 'RELAY_CONNECTION_CLOSED' } + ); + } finally { + await sidecar.shutdown(); + await fixture.close(); + } + }, 20_000); + it('fails closed on a certificate pin mismatch', async () => { const fixture = new FixtureProcess(); const config = await fixture.startup(); @@ -197,7 +216,7 @@ describeIntegration('private transport sidecar integration', () => { let count = 0; readline.createInterface({ input: process.stdin }).on('line', (line) => { const request = JSON.parse(line); - if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.5.0',protocolVersion:2,innerAlpn:'qortal-private/1',moqAlpn:'moqt-18'}}) + '\\n'); + if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.7.0',protocolVersion:2,innerAlpn:'qortal-private/1',moqAlpn:'moqt-18'}}) + '\\n'); else process.stdout.write('{not-json\\n'); }); `; diff --git a/electron/src/private-transport-sidecar.ts b/electron/src/private-transport-sidecar.ts index d6ce05c1..674c5f7b 100644 --- a/electron/src/private-transport-sidecar.ts +++ b/electron/src/private-transport-sidecar.ts @@ -5,8 +5,14 @@ import { EventEmitter } from 'events'; import path from 'path'; export const PRIVATE_TRANSPORT_PROTOCOL_VERSION = 2; -export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.5.0'; +export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.7.0'; export const MAX_MOQ_OBJECT_BYTES = 1024; +export type PreparedRelay = { + handle: string; + ready: boolean; + challenge?: Record; + expiresAt?: number; +}; const MOQ_NAME = /^[A-Za-z0-9._-]{1,128}$/; function validMoqNamespace(namespace: readonly string[]): boolean { @@ -38,6 +44,7 @@ export type MasqueTestConfig = Readonly<{ }>; export type PrivateSessionConfig = Readonly<{ + preparedRelay?: string; relayAddress: string; relayServerName: string; relayCertSha256: string; @@ -53,6 +60,7 @@ export type PrivateSessionConfig = Readonly<{ }>; export type MoqSessionConfig = Readonly<{ + preparedRelay?: string; relayAddress: string; relayServerName: string; relayCertSha256: string; @@ -88,6 +96,12 @@ export type PrivateTransportSidecarEvent = { }; type SidecarOperation = + | 'prepareRelay' + | 'authorizeRelay' + | 'closeRelay' + | 'clearRelays' + | 'prepareRelayTickets' + | 'finalizeRelayTickets' | 'health' | 'openMasqueTunnel' | 'sendDatagram' @@ -214,6 +228,75 @@ export class PrivateTransportSidecar extends EventEmitter { }; } + async prepareRelayTickets(descriptor: Record) { + await this.start(); + return (await this.request('prepareRelayTickets', { descriptor })) as { + handle: string; + blinded: string[]; + }; + } + async finalizeRelayTickets(handle: string, signatures: string[]) { + return (await this.request('finalizeRelayTickets', { + handle, + signatures, + })) as { tickets: string[]; expiresAt: number }; + } + async prepareRelay(config: { + relayAddress: string; + relayServerName: string; + relayCertSha256: string; + legacyRelay?: boolean; + }) { + await this.start(); + return this.validatePreparedRelay( + await this.request( + 'prepareRelay', + { + relayAddress: config.relayAddress, + relayServerName: config.relayServerName, + relayCertSha256: config.relayCertSha256, + legacyRelay: config.legacyRelay === true, + }, + 6_000 + ) + ); + } + async authorizeRelay(handle: string, proof = '', renew = false) { + return this.validatePreparedRelay( + await this.request( + 'authorizeRelay', + { handle, proof, renew: renew || !!proof }, + 9_000 + ) + ); + } + private validatePreparedRelay(value: unknown): PreparedRelay { + const r = value as PreparedRelay; + if ( + !r || + typeof r.handle !== 'string' || + !/^[a-f0-9]{48}$/.test(r.handle) || + typeof r.ready !== 'boolean' || + (r.expiresAt !== undefined && + (!Number.isSafeInteger(r.expiresAt) || r.expiresAt <= Date.now())) || + (r.challenge !== undefined && + (!r.challenge || + typeof r.challenge !== 'object' || + Array.isArray(r.challenge) || + JSON.stringify(r.challenge).length > 1024)) || + (!r.ready && !r.challenge) || + (r.ready && r.challenge) + ) + throw new PrivateTransportSidecarError('MALFORMED_RESPONSE'); + return r; + } + async closeRelay(handle: string) { + if (this.child) await this.request('closeRelay', { handle }); + } + async clearRelays() { + if (this.child) await this.request('clearRelays', {}); + } + async openMasqueTunnel(config: MasqueTestConfig): Promise { await this.start(); const result = (await this.request('openMasqueTunnel', config)) as { diff --git a/electron/src/quic-masque-transport.ts b/electron/src/quic-masque-transport.ts index 009fb835..ff173698 100644 --- a/electron/src/quic-masque-transport.ts +++ b/electron/src/quic-masque-transport.ts @@ -13,6 +13,13 @@ import { } from './private-transport-sidecar'; export type TrustedRelayConfig = Readonly<{ + protocolVersion?: number; + relayIdentity?: string; + ticketIdentity?: string; + ticketKeyId?: string; + accessMode?: 'public' | 'groups'; + allowedGroupIds?: number[]; + preparedRelay?: string; relayAddress: string; relayServerName: string; relayCertSha256: string; @@ -59,7 +66,25 @@ export class QuicMasqueTransport implements PrivateTransport { if (this.closed) throw new PrivateChannelError('TRANSPORT_CLOSED'); this.context = context; try { - await this.connect(context); + for (let attempt = 0; ; attempt++) { + try { + await this.connect(context); + break; + } catch (error) { + if ( + attempt >= 2 || + !this.lastAttemptedRelayAddress || + !isRecoverableTransportError(error) || + this.closed + ) + throw error; + if (this.lastAttemptedRelayAddress) + this.failedRelayAddresses.set( + this.lastAttemptedRelayAddress, + Date.now() + ); + } + } } catch (error) { throw translateSidecarError(error); } @@ -169,10 +194,17 @@ export class QuicMasqueTransport implements PrivateTransport { private async connect(context: PrivateTransportContext): Promise { this.lastAttemptedRelayAddress = null; - const relay = await this.resolveRelay(); - const bootstrap = await this.bootstrapProvider.getBootstrap(context); + let [relay, bootstrap] = await Promise.all([ + this.resolveRelay(), + this.bootstrapProvider.getBootstrap(context), + ]); + // Discovery/authentication can outlast a short-lived backend credential. + // Refresh before sending it; a real backend rejection remains terminal. + if (bootstrap.expiresAt < Date.now() + 5_000) + bootstrap = await this.bootstrapProvider.getBootstrap(context); const openAt = (relayAddress: string) => this.sidecar.openPrivateSession({ + preparedRelay: relay.preparedRelay, relayAddress, relayServerName: relay.relayServerName, relayCertSha256: relay.relayCertSha256, @@ -289,6 +321,13 @@ function errorCode(error: unknown): string { function isRecoverableCode(code: string | undefined): boolean { return ( + code === 'RELAY_CONNECT_FAILED' || + code === 'RELAY_CONNECTION_CLOSED' || + code === 'RELAY_TARGET_DENIED' || + code === 'RELAY_FULL' || + code === 'RELAY_AUTH_REQUIRED' || + code === 'RELAY_ACCESS_DENIED' || + code === 'RELAY_MEMBERSHIP_UNAVAILABLE' || code === 'INNER_QUIC_FAILED' || code === 'TRANSPORT_CLOSED' || code === 'MASQUE_TUNNEL_FAILED' || @@ -339,6 +378,15 @@ function translateSidecarError(error: unknown): PrivateChannelError { ? error.message : 'TRANSPORT_ERROR'; const allowed = new Set([ + 'RELAY_NO_ELIGIBLE_RELAY', + 'RELAY_ACCESS_DENIED', + 'RELAY_PROOF_INVALID', + 'RELAY_MEMBERSHIP_UNAVAILABLE', + 'RELAY_TARGET_DENIED', + 'RELAY_FULL', + 'RELAY_AUTH_REQUIRED', + 'RELAY_CONNECTION_CLOSED', + 'RELAY_CONNECT_FAILED', 'BACKEND_IDENTITY_MISMATCH', 'MASQUE_TUNNEL_FAILED', 'MASQUE_RELAY_UNAVAILABLE', diff --git a/electron/src/relay-access-coordinator.test.ts b/electron/src/relay-access-coordinator.test.ts new file mode 100644 index 00000000..f69fcff6 --- /dev/null +++ b/electron/src/relay-access-coordinator.test.ts @@ -0,0 +1,263 @@ +import { EventEmitter } from 'events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + RelayAccessCoordinator, + configureRelaySigner, + setRelayAccount, + setRelayGroups, +} from './relay-access-coordinator'; +import { PrivateTransportSidecarError } from './private-transport-sidecar'; +import { RelayTicketWallet } from './relay-ticket-wallet'; + +class Sidecar extends EventEmitter { + prepareRelay = vi.fn( + async (_config: unknown) => + ({ handle: 'ab'.repeat(24), ready: true }) as any + ); + authorizeRelay = vi.fn( + async (_handle: string, _proof?: string, _renew?: boolean) => + ({ handle: 'ab'.repeat(24), ready: true }) as any + ); + closeRelay = vi.fn(async (_handle: string) => {}); + clearRelays = vi.fn(async () => {}); +} +const advertisement = (host = '8.8.8.8', ids: number[] = []) => ({ + host, + port: 47322, + serverName: 'relay.test', + certSha256: 'ab'.repeat(32), + expiresAt: Date.now() + 600_000, + protocolVersion: 3, + ticketIdentity: 'ef'.repeat(32) + 'cd'.repeat(32), + ticketKeyId: '12'.repeat(32), + relayIdentity: 'cd'.repeat(32), + accessMode: ids.length ? 'groups' : 'public', + allowedGroupIds: ids, +}); +const bridgeFor = (...relays: unknown[]) => + ({ getCommunityMasqueRelays: vi.fn(async () => relays) }) as never; +let coordinator: RelayAccessCoordinator; +let sidecar: Sidecar; +beforeEach(() => { + vi.spyOn(RelayTicketWallet.prototype, 'ensure').mockResolvedValue(undefined); + vi.spyOn(RelayTicketWallet.prototype, 'take').mockReturnValue( + '{"epoch":1,"keyId":"key","message":"anonymous","signature":"blind"}' + ); + setRelayAccount('account-A', true); + configureRelaySigner(async () => ({ + authorAddress: 'account-A', + authorPublicKey: 'key', + signature: 'signature', + })); + sidecar = new Sidecar(); + coordinator = new RelayAccessCoordinator(sidecar as never, () => 0); +}); +afterEach(() => { + coordinator.dispose(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe('shared relay selection and authorization', () => { + it.each([{ ids: [] }, { ids: [9] }])( + 'does not contact restricted relays with fresh nonmatching groups %j', + async ({ ids }) => { + setRelayGroups(ids); + await expect( + coordinator.select(bridgeFor(advertisement('8.8.8.8', [42]))) + ).rejects.toMatchObject({ code: 'RELAY_NO_ELIGIBLE_RELAY' }); + expect(RelayTicketWallet.prototype.ensure).not.toHaveBeenCalled(); + expect(sidecar.prepareRelay).not.toHaveBeenCalled(); + expect(sidecar.authorizeRelay).not.toHaveBeenCalled(); + } + ); + it('still tries a restricted relay when membership is unknown', async () => { + await coordinator.select(bridgeFor(advertisement('8.8.8.8', [42]))); + expect(RelayTicketWallet.prototype.ensure).toHaveBeenCalledTimes(1); + expect(sidecar.prepareRelay).toHaveBeenCalledTimes(1); + }); + it('does not exclude a relay using stale nonmembership information', async () => { + vi.useFakeTimers(); + setRelayGroups([]); + await vi.advanceTimersByTimeAsync(30_000); + await coordinator.select(bridgeFor(advertisement('8.8.8.8', [42]))); + expect(sidecar.prepareRelay).toHaveBeenCalledTimes(1); + }); + it('uses a public alternative without contacting the ineligible relay', async () => { + setRelayGroups([]); + const selected = await coordinator.select( + bridgeFor(advertisement('8.8.8.8', [42]), advertisement('1.1.1.1')) + ); + expect(selected.relayAddress).toBe('1.1.1.1:47322'); + expect(sidecar.prepareRelay).toHaveBeenCalledTimes(1); + expect(RelayTicketWallet.prototype.ensure).not.toHaveBeenCalled(); + }); + it('treats a successful empty Core membership result as known nonmembership', async () => { + configureRelaySigner(vi.fn(), undefined, async () => []); + await expect( + coordinator.select(bridgeFor(advertisement('8.8.8.8', [42]))) + ).rejects.toMatchObject({ code: 'RELAY_NO_ELIGIBLE_RELAY' }); + expect(sidecar.prepareRelay).not.toHaveBeenCalled(); + }); + it('shares a concurrent preparation between applications', async () => { + const bridge = bridgeFor(advertisement()); + const [a, b] = await Promise.all([ + coordinator.select(bridge), + coordinator.select(bridge), + ]); + expect(a.preparedRelay).toBe(b.preparedRelay); + expect(sidecar.prepareRelay).toHaveBeenCalledTimes(1); + await coordinator.select(bridge); + expect(sidecar.authorizeRelay).toHaveBeenCalledTimes(1); + }); + it('moves immediately past a rejected relay', async () => { + sidecar.prepareRelay.mockRejectedValueOnce( + new PrivateTransportSidecarError('RELAY_ACCESS_DENIED') + ); + const result = await coordinator.select( + bridgeFor(advertisement(), advertisement('1.1.1.1')) + ); + expect(result.relayAddress).toBe('1.1.1.1:47322'); + expect(sidecar.prepareRelay).toHaveBeenCalledTimes(2); + }); + it('discovers a new relay automatically when its cached relay fails', async () => { + const bridge = new EventEmitter() as EventEmitter & { + getCommunityMasqueRelays: ReturnType; + }; + let fresh = false; + bridge.getCommunityMasqueRelays = vi.fn(async (announce: boolean) => { + if (announce) fresh = true; + return [advertisement(), ...(fresh ? [advertisement('1.1.1.1')] : [])]; + }); + sidecar.prepareRelay.mockRejectedValueOnce( + new PrivateTransportSidecarError('RELAY_ACCESS_DENIED') + ); + await expect(coordinator.select(bridge as never)).resolves.toMatchObject({ + relayAddress: '1.1.1.1:47322', + }); + expect(bridge.getCommunityMasqueRelays).toHaveBeenCalledWith(true); + }); + it('hedges a slow relay without waiting for its timeout', async () => { + vi.useFakeTimers(); + sidecar.prepareRelay.mockImplementationOnce(() => new Promise(() => {})); + const pending = coordinator.select( + bridgeFor(advertisement(), advertisement('1.1.1.1')) + ); + await vi.advanceTimersByTimeAsync(300); + await expect(pending).resolves.toMatchObject({ + relayAddress: '1.1.1.1:47322', + }); + expect(sidecar.prepareRelay).toHaveBeenCalledTimes(2); + }); + it('prefers a known matching group without treating the hint as authorization', async () => { + setRelayGroups([42]); + await coordinator.select( + bridgeFor(advertisement('8.8.8.8', [9]), advertisement('1.1.1.1', [42])) + ); + expect(sidecar.prepareRelay.mock.calls[0][0]).toMatchObject({ + relayAddress: '1.1.1.1:47322', + }); + }); + it('loads group hints independently when chat has not supplied memberships', async () => { + const readGroups = vi.fn(async () => [42]); + configureRelaySigner( + async () => ({ + authorAddress: 'account-A', + authorPublicKey: 'key', + signature: 'signature', + }), + undefined, + readGroups + ); + await coordinator.select( + bridgeFor(advertisement('8.8.8.8', [9]), advertisement('1.1.1.1', [42])) + ); + expect(readGroups).toHaveBeenCalledWith('account-A'); + expect(sidecar.prepareRelay.mock.calls[0][0]).toMatchObject({ + relayAddress: '1.1.1.1:47322', + }); + }); + it('rejects late results after an account change and releases their connection', async () => { + let finish!: (value: any) => void; + sidecar.prepareRelay.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }) + ); + const pending = coordinator.select(bridgeFor(advertisement())); + const checked = expect(pending).rejects.toThrow('RELAY_ACCOUNT_CHANGED'); + await vi.waitFor(() => + expect(sidecar.prepareRelay).toHaveBeenCalledTimes(1) + ); + setRelayAccount('account-B'); + finish({ handle: 'ab'.repeat(24), ready: true }); + await checked; + expect(sidecar.closeRelay).toHaveBeenCalledWith('ab'.repeat(24)); + }); + it('sends only an anonymous ticket over QUIC', async () => { + const challenge = { + type: 'masque-ticket-required-v1', + }; + sidecar.prepareRelay.mockResolvedValueOnce({ + handle: 'ab'.repeat(24), + ready: false, + challenge, + }); + await coordinator.select(bridgeFor(advertisement('8.8.8.8', [42]))); + expect(JSON.parse(sidecar.authorizeRelay.mock.calls[0][1]!)).toMatchObject({ + message: 'anonymous', + }); + expect(sidecar.authorizeRelay.mock.calls[0][1]).not.toContain('account-A'); + }); + it('never signs a mismatched policy', async () => { + const sign = vi.fn(); + configureRelaySigner(sign); + sidecar.prepareRelay.mockResolvedValueOnce({ + handle: 'ab'.repeat(24), + ready: false, + challenge: { type: 'masque-relay-auth-v1' }, + }); + await expect( + coordinator.select(bridgeFor(advertisement('8.8.8.8', [42]))) + ).rejects.toThrow('RELAY_PROTOCOL_UNSUPPORTED'); + expect(sign).not.toHaveBeenCalled(); + }); + it('removes its sidecar listener on disposal', () => { + coordinator.dispose(); + expect(sidecar.listenerCount('death')).toBe(0); + }); + it('renews an expiring grant and shares renewal with new applications', async () => { + vi.useFakeTimers(); + const handle = 'ab'.repeat(24); + sidecar.prepareRelay.mockResolvedValueOnce({ + handle, + ready: true, + expiresAt: Date.now() + 360_000, + }); + const bridge = bridgeFor(advertisement('8.8.8.8', [42])); + await coordinator.select(bridge); + sidecar.authorizeRelay.mockResolvedValueOnce({ + handle, + ready: true, + expiresAt: Date.now() + 43_200_000, + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(sidecar.authorizeRelay).toHaveBeenCalledWith(handle, '', true); + expect(sidecar.prepareRelay).toHaveBeenCalledTimes(1); + }); + it('does not keep renewing a connection after confirmed rejection', async () => { + vi.useFakeTimers(); + sidecar.prepareRelay.mockResolvedValueOnce({ + handle: 'ab'.repeat(24), + ready: true, + expiresAt: Date.now() + 60_000, + }); + await coordinator.select(bridgeFor(advertisement('8.8.8.8', [42]))); + sidecar.authorizeRelay.mockRejectedValue( + new PrivateTransportSidecarError('RELAY_ACCESS_DENIED') + ); + await vi.advanceTimersByTimeAsync(60_000); + expect(sidecar.authorizeRelay).toHaveBeenCalledTimes(1); + }); +}); diff --git a/electron/src/relay-access-coordinator.ts b/electron/src/relay-access-coordinator.ts new file mode 100644 index 00000000..6a4aad79 --- /dev/null +++ b/electron/src/relay-access-coordinator.ts @@ -0,0 +1,502 @@ +import { RelayTicketWallet } from './relay-ticket-wallet'; +import net from 'net'; +import type { ReticulumBridge } from './reticulum-bridge'; +import { + discoverCommunityMasqueRelay, + validateCommunityMasqueRelay, +} from './masque-relay-discovery'; +import { + PrivateTransportSidecar, + PrivateTransportSidecarError, + type PreparedRelay, +} from './private-transport-sidecar'; +import type { TrustedRelayConfig } from './quic-masque-transport'; + +type Signature = { + authorAddress: string; + authorPublicKey: string; + signature: string; +}; +type Signer = (fields: Record) => Promise; +let signer: Signer | undefined; +let accountReader: (() => Promise) | undefined; +let groupReader: ((address: string) => Promise) | undefined; +let groupRead: { epoch: number; promise: Promise } | undefined; +let accountRead: Promise | undefined; +let account = ''; +let generation = 0; +let groups: Set | null = null; +let groupsAt = 0; +const instances = new Set(); + +export function configureRelaySigner( + value: Signer, + readAccount?: () => Promise, + readGroups?: (address: string) => Promise +) { + signer = value; + accountReader = readAccount; + groupReader = readGroups; +} +export function setRelayAccount(address: string, force = false) { + if (address === account && !force) return; + const clearHints = !!account || !address; + account = address; + generation++; + if (clearHints) { + groups = null; + groupsAt = 0; + } + for (const coordinator of instances) coordinator.clear(); +} +export function setRelayGroups(ids: number[]) { + groups = new Set(ids.filter((id) => Number.isInteger(id) && id > 0)); + groupsAt = Date.now(); +} + +/** Main-process only. Announcements are selection hints; admission is enforced + * on the peer's actual TLS connection. Neither QApps nor discovery can sign. */ +export class RelayAccessCoordinator { + private tickets: RelayTicketWallet; + private bridge?: ReticulumBridge; + private pending = new Map>(); + private ready = new Map(); + private failures = new Map(); + private failedEndpoints = new Map(); + private renewals = new Map(); + private discovery: Promise | null = null; + private onDeath = () => this.clear(); + constructor( + private sidecar: PrivateTransportSidecar, + private random = Math.random + ) { + this.tickets = new RelayTicketWallet(sidecar); + instances.add(this); + sidecar.on('death', this.onDeath); + } + clear() { + this.tickets.clear(); + for (const timer of this.renewals.values()) clearTimeout(timer); + this.renewals.clear(); + this.pending.clear(); + this.ready.clear(); + this.failures.clear(); + this.failedEndpoints.clear(); + void this.sidecar.clearRelays().catch(() => undefined); + } + dispose() { + this.clear(); + this.sidecar.off('death', this.onDeath); + instances.delete(this); + } + async select( + bridge: ReticulumBridge, + excluded: ReadonlySet = new Set(), + allowLoopback = false + ): Promise { + this.bridge = bridge; + const deadline = Date.now() + 60_000; + try { + return await this.selectPass(bridge, excluded, allowLoopback, deadline); + } catch (error) { + const code = + error instanceof PrivateTransportSidecarError + ? error.code + : error instanceof Error + ? error.message + : ''; + if ( + ![ + 'RELAY_CONNECT_FAILED', + 'RELAY_ACCESS_DENIED', + 'RELAY_MEMBERSHIP_UNAVAILABLE', + 'RELAY_FULL', + 'RELAY_BUSY', + 'MASQUE_RELAY_UNAVAILABLE', + ].includes(code) || + Date.now() >= deadline + ) + throw error; + const rejected = new Set(excluded); + for (const [endpoint, until] of this.failedEndpoints) + if (until > Date.now()) rejected.add(endpoint); + // Cached relays can all be dead or inaccessible. Ask the network once + // for a fresh alternative instead of requiring a manual reconnect. + this.discovery ??= discoverCommunityMasqueRelay(bridge, { + allowLoopback, + excludeRelayAddresses: rejected, + timeoutMs: Math.min(5_000, deadline - Date.now()), + }).finally(() => { + this.discovery = null; + }); + try { + await this.discovery; + } catch { + throw error; + } + return this.selectPass(bridge, rejected, allowLoopback, deadline); + } + } + private async selectPass( + bridge: ReticulumBridge, + excluded: ReadonlySet, + allowLoopback: boolean, + deadlineAt: number + ): Promise { + if (accountReader) { + if (!accountRead) { + const before = generation; + accountRead = accountReader() + .then((address) => { + if (before !== generation) throw new Error('RELAY_ACCOUNT_CHANGED'); + setRelayAccount(address); + }) + .finally(() => { + accountRead = undefined; + }); + } + await accountRead; + } + const epoch = generation; + const collect = async () => + (await bridge.getCommunityMasqueRelays(false)) + .map((value) => validateCommunityMasqueRelay(value, { allowLoopback })) + .filter(Boolean) + .map( + (relay) => + ({ + relayAddress: `${net.isIP(relay!.host) === 6 ? `[${relay!.host}]` : relay!.host}:${relay!.port}`, + relayServerName: relay!.serverName, + relayCertSha256: relay!.certSha256, + localFallbackAddress: `127.0.0.1:${relay!.port}`, + protocolVersion: relay!.protocolVersion, + relayIdentity: relay!.relayIdentity, + ticketIdentity: relay!.ticketIdentity, + ticketKeyId: relay!.ticketKeyId, + accessMode: relay!.accessMode, + allowedGroupIds: relay!.allowedGroupIds, + }) satisfies TrustedRelayConfig + ); + let candidates = await collect(); + if (!candidates.some((r) => !excluded.has(r.relayAddress))) { + this.discovery ??= discoverCommunityMasqueRelay(bridge, { + allowLoopback, + excludeRelayAddresses: excluded, + timeoutMs: Math.max(1, Math.min(8_000, deadlineAt - Date.now())), + }).finally(() => { + this.discovery = null; + }); + await this.discovery; + candidates = await collect(); + } + if ( + candidates.length && + candidates.every((r) => r.accessMode === 'groups') && + (!groups || Date.now() - groupsAt >= 30_000) && + groupReader && + account + ) { + if (!groupRead || groupRead.epoch !== epoch) { + const operation = groupReader(account).catch(() => null); + groupRead = { epoch, promise: operation }; + void operation.finally(() => { + if (groupRead?.promise === operation) groupRead = undefined; + }); + } + const hints = await groupRead.promise; + if (epoch !== generation) throw new Error('RELAY_ACCOUNT_CHANGED'); + if (hints) setRelayGroups(hints); + } + const score = (relay: TrustedRelayConfig) => { + if (this.ready.has(this.key(relay))) return 0; + if (relay.accessMode !== 'groups') return 1; + if (groups && relay.allowedGroupIds?.some((id) => groups!.has(id))) + return 1; + return 2; + }; + candidates = candidates.filter( + (r) => + !excluded.has(r.relayAddress) && + (this.failures.get(this.key(r)) ?? 0) <= Date.now() + ); + const tieBreak = new Map( + candidates.map((r) => [this.key(r), this.random()]) + ); + candidates.sort( + (a, b) => + score(a) - score(b) || + tieBreak.get(this.key(a))! - tieBreak.get(this.key(b))! + ); + // Unknown/stale membership is lower priority, never permanent exclusion. + const eligible = candidates.filter( + (r) => + !( + r.accessMode === 'groups' && + groups && + Date.now() - groupsAt < 30_000 && + !r.allowedGroupIds?.some((id) => groups!.has(id)) + ) + ); + if (candidates.length && !eligible.length) + throw new PrivateTransportSidecarError('RELAY_NO_ELIGIBLE_RELAY'); + candidates = eligible; + if (!candidates.length) throw new Error('MASQUE_RELAY_UNAVAILABLE'); + let index = 0, + finished = false, + active = 0; + return new Promise((resolve, reject) => { + const deadline = setTimeout( + () => { + finished = true; + reject(new Error('MASQUE_RELAY_UNAVAILABLE')); + }, + Math.max(1, deadlineAt - Date.now()) + ); + let lastError: unknown = new Error('MASQUE_RELAY_UNAVAILABLE'); + const next = () => { + if (finished) return; + if (epoch !== generation) { + finished = true; + clearTimeout(deadline); + reject(new Error('RELAY_ACCOUNT_CHANGED')); + return; + } + if (index >= Math.min(6, candidates.length)) { + if (!active) { + finished = true; + clearTimeout(deadline); + reject(lastError); + } + return; + } + const candidate = candidates[index++]; + active++; + this.prepare(candidate, epoch) + .then((result) => { + if (finished) return; + if (epoch !== generation) { + finished = true; + clearTimeout(deadline); + reject(new Error('RELAY_ACCOUNT_CHANGED')); + return; + } + finished = true; + clearTimeout(deadline); + resolve(result); + }) + .catch((error) => { + lastError = error; + active--; + next(); + }); + }; + next(); + const hedge = setTimeout(() => { + if (!finished && active < 2) next(); + }, 300); + hedge.unref?.(); + }); + } + private key(r: TrustedRelayConfig) { + return `${generation}:${r.relayAddress}:${r.relayCertSha256}:${JSON.stringify(r.allowedGroupIds ?? [])}`; + } + private prepare( + relay: TrustedRelayConfig, + epoch: number + ): Promise { + const key = this.key(relay); + const pending = this.pending.get(key); + if (pending) return pending; + if (this.pending.size >= 32) + return Promise.reject(new PrivateTransportSidecarError('RELAY_BUSY')); + const operation = (async () => { + let prepared: PreparedRelay | undefined; + let connectedRelay = relay; + const prior = this.ready.get(key); + try { + if (prior?.preparedRelay) { + try { + prepared = await this.sidecar.authorizeRelay(prior.preparedRelay); + } catch { + this.ready.delete(key); + } + } + if (prepared && prior) connectedRelay = prior; + if (!prepared || prepared.challenge) + await this.ensureTickets(relay, epoch); + if (!prepared) { + try { + prepared = await this.sidecar.prepareRelay({ + ...relay, + legacyRelay: ![2, 3].includes(relay.protocolVersion ?? 1), + }); + } catch (error) { + // Same-host fallback requires possession of the advertised TLS key. + if ( + !(error instanceof PrivateTransportSidecarError) || + error.code !== 'RELAY_CONNECT_FAILED' || + !relay.localFallbackAddress || + relay.localFallbackAddress === relay.relayAddress + ) + throw error; + connectedRelay = { + ...relay, + relayAddress: relay.localFallbackAddress, + }; + prepared = await this.sidecar.prepareRelay({ + ...connectedRelay, + legacyRelay: ![2, 3].includes(relay.protocolVersion ?? 1), + }); + } + } + prepared = await this.complete(relay, prepared, epoch); + if (this.ready.size >= 512 && !this.ready.has(key)) { + const disposable = [...this.ready.keys()].find( + (k) => !this.renewals.has(k) + ); + if (disposable) this.ready.delete(disposable); + else throw new PrivateTransportSidecarError('RELAY_POOL_FULL'); + } + const ready = { ...connectedRelay, preparedRelay: prepared.handle }; + this.ready.set(key, ready); + this.scheduleRenewal(key, ready, prepared.expiresAt, epoch); + return ready; + } catch (error) { + if (prepared?.handle) + void this.sidecar.closeRelay(prepared.handle).catch(() => undefined); + this.ready.delete(key); + const code = + error instanceof PrivateTransportSidecarError ? error.code : ''; + if (this.failures.size >= 256) + this.failures.delete(this.failures.keys().next().value!); + this.failures.set( + key, + Date.now() + (code === 'RELAY_ACCESS_DENIED' ? 15_000 : 5_000) + ); + if (this.failedEndpoints.size >= 256) + this.failedEndpoints.delete( + this.failedEndpoints.keys().next().value! + ); + this.failedEndpoints.set(relay.relayAddress, Date.now() + 5_000); + throw error; + } + })().finally(() => { + if (this.pending.get(key) === operation) this.pending.delete(key); + }); + this.pending.set(key, operation); + return operation; + } + private async complete( + relay: TrustedRelayConfig, + result: PreparedRelay, + epoch: number + ) { + if (epoch !== generation) throw new Error('RELAY_ACCOUNT_CHANGED'); + if (result.challenge) { + if ( + relay.protocolVersion !== 3 || + relay.accessMode !== 'groups' || + JSON.stringify(result.challenge) !== + '{"type":"masque-ticket-required-v1"}' + ) + throw new PrivateTransportSidecarError('RELAY_PROTOCOL_UNSUPPORTED'); + result = await this.sidecar.authorizeRelay( + result.handle, + this.tickets.take(relay) + ); + } + if (!result.ready || epoch !== generation) + throw new Error('RELAY_AUTH_UNAVAILABLE'); + return result; + } + private async ensureTickets(relay: TrustedRelayConfig, epoch: number) { + if (relay.accessMode !== 'groups') return; + if (!this.bridge || !account || !signer) + throw new Error('RELAY_AUTH_UNAVAILABLE'); + // Refresh signed key commitment after hourly rotation, including renewals. + const candidates = await this.bridge.getCommunityMasqueRelays(false); + const latest = candidates + .map((v) => validateCommunityMasqueRelay(v, { allowLoopback: true })) + .find( + (v) => + v?.relayIdentity === relay.relayIdentity && + v?.certSha256 === relay.relayCertSha256 && + JSON.stringify(v?.allowedGroupIds) === + JSON.stringify(relay.allowedGroupIds) + ); + const current = latest + ? { + ...relay, + ticketIdentity: latest.ticketIdentity, + ticketKeyId: latest.ticketKeyId, + protocolVersion: latest.protocolVersion, + } + : relay; + await this.tickets.ensure( + this.bridge, + current, + account, + signer, + () => epoch === generation + ); + } + private scheduleRenewal( + key: string, + relay: TrustedRelayConfig, + expires: number | undefined, + epoch: number, + retryDelay?: number + ) { + const old = this.renewals.get(key); + if (old) clearTimeout(old); + if (!expires || !relay.preparedRelay) return; + const timer = setTimeout( + async () => { + this.renewals.delete(key); + if (epoch !== generation) return; + if (this.pending.has(key)) { + this.scheduleRenewal(key, relay, expires, epoch, 1_000); + return; + } + const renewal = (async () => { + await this.ensureTickets(relay, epoch); + let result = await this.sidecar.authorizeRelay( + relay.preparedRelay!, + '', + true + ); + result = await this.complete(relay, result, epoch); + this.scheduleRenewal(key, relay, result.expiresAt, epoch); + return relay; + })(); + this.pending.set(key, renewal); + try { + await renewal; + } catch (error) { + this.ready.delete(key); + if ( + error instanceof PrivateTransportSidecarError && + [ + 'RELAY_ACCESS_DENIED', + 'RELAY_PROOF_INVALID', + 'RELAY_CONNECTION_CLOSED', + ].includes(error.code) + ) { + this.failures.set(key, Date.now() + 15_000); + return; + } + // The relay's deadline remains authoritative if renewal is unavailable. + // Retry promptly while it remains valid; never extend cached permission. + if (epoch === generation && Date.now() + 5_000 < expires) { + this.scheduleRenewal(key, relay, expires, epoch, 5_000); + } + } finally { + if (this.pending.get(key) === renewal) this.pending.delete(key); + } + }, + retryDelay ?? Math.max(1_000, expires - Date.now() - 5 * 60_000) + ); + timer.unref?.(); + this.renewals.set(key, timer); + } +} diff --git a/electron/src/relay-ticket-wallet.test.ts b/electron/src/relay-ticket-wallet.test.ts new file mode 100644 index 00000000..b79e31f6 --- /dev/null +++ b/electron/src/relay-ticket-wallet.test.ts @@ -0,0 +1,141 @@ +import { createHash } from 'crypto'; +import { describe, it, expect, vi } from 'vitest'; +import { RelayTicketWallet, relayPolicy } from './relay-ticket-wallet'; + +function fixture() { + const relay = { + protocolVersion: 3 as const, + accessMode: 'groups' as const, + allowedGroupIds: [1144], + relayIdentity: 'cd'.repeat(32), + ticketIdentity: 'ef'.repeat(32) + 'cd'.repeat(32), + ticketKeyId: 'ab'.repeat(32), + relayCertSha256: '12'.repeat(32), + relayAddress: '8.8.8.8:47322', + relayServerName: 'relay', + }; + const descriptor = { + epoch: Math.floor(Date.now() / 3600000), + expiresAt: Date.now() + 43200000, + publicKey: 'key', + keyId: relay.ticketKeyId, + relayPin: relay.relayCertSha256, + policy: relayPolicy(relay), + }; + const blinded = ['a', 'b', 'c']; + const sidecar = { + prepareRelayTickets: vi.fn(async () => ({ + handle: 'ab'.repeat(24), + blinded, + })), + finalizeRelayTickets: vi.fn(async () => ({ + tickets: ['ticket1', 'ticket2', 'ticket3'], + expiresAt: descriptor.expiresAt, + })), + }; + const bridge = { + relayTicketRequest: vi.fn( + async (_id: string, path: string, _data: unknown) => { + if (path === '/catalog') return descriptor; + if (path === '/challenge') + return { + type: 'masque-ticket-issue-v1', + relayPin: relay.relayCertSha256, + policy: relayPolicy(relay), + binding: createHash('sha256') + .update(JSON.stringify({ key: descriptor.keyId, blinded })) + .digest('hex'), + nonce: '34'.repeat(32), + expiresAt: Date.now() + 60000, + }; + return { signatures: ['s1', 's2', 's3'] }; + } + ), + getCommunityMasqueRelays: vi.fn(async () => []), + }; + const sign = vi.fn(async () => ({ + authorAddress: 'account-A', + authorPublicKey: 'pub', + signature: 'sig', + })); + const wallet = new RelayTicketWallet(sidecar as never); + const ensure = () => + wallet.ensure(bridge as never, relay, 'account-A', sign, () => true); + return { wallet, sidecar, bridge, relay, descriptor, sign, ensure }; +} +describe('anonymous relay ticket wallet', () => { + it('coalesces issuance, keeps spares and spends each ticket once', async () => { + const f = fixture(); + await Promise.all([f.ensure(), f.ensure()]); + expect(f.sign).toHaveBeenCalledTimes(1); + expect(f.bridge.relayTicketRequest.mock.calls.map((c) => c[1])).toEqual([ + '/catalog', + '/challenge', + '/issue', + ]); + expect(f.wallet.take(f.relay)).toBe('ticket1'); + await f.ensure(); + expect(f.sign).toHaveBeenCalledTimes(1); + expect(f.wallet.take(f.relay)).toBe('ticket2'); + expect(f.wallet.take(f.relay)).toBe('ticket3'); + expect(() => f.wallet.take(f.relay)).toThrow(); + }); + it('does not sign a wrong relay or policy', async () => { + const f = fixture(); + f.descriptor.policy = '00'.repeat(32); + await expect(f.ensure()).rejects.toThrow('RELAY_PROOF_INVALID'); + expect(f.sign).not.toHaveBeenCalled(); + }); + it('sends account proof only through Reticulum', async () => { + const f = fixture(); + await f.ensure(); + const issued = f.bridge.relayTicketRequest.mock.calls.find( + (c) => c[1] === '/issue' + )![2] as any; + expect(issued.proof.authorAddress).toBe('account-A'); + expect( + JSON.stringify(f.sidecar.prepareRelayTickets.mock.calls) + ).not.toContain('account-A'); + expect( + JSON.stringify(f.sidecar.finalizeRelayTickets.mock.calls) + ).not.toContain('account-A'); + }); + it('discards all tickets on logout', async () => { + const f = fixture(); + await f.ensure(); + f.wallet.clear(); + expect(() => f.wallet.take(f.relay)).toThrow(); + await f.ensure(); + expect(f.sign).toHaveBeenCalledTimes(2); + }); + it('rejects an account change during issuance', async () => { + const f = fixture(); + f.sign.mockImplementationOnce(async () => { + f.wallet.clear(); + return { + authorAddress: 'account-A', + authorPublicKey: 'pub', + signature: 'sig', + }; + }); + await expect(f.ensure()).rejects.toThrow('RELAY_ACCOUNT_CHANGED'); + expect(f.sidecar.finalizeRelayTickets).not.toHaveBeenCalled(); + expect(() => f.wallet.take(f.relay)).toThrow(); + }); + it('fails closed on membership failure without caching credentials', async () => { + const f = fixture(); + f.bridge.relayTicketRequest.mockRejectedValueOnce( + new Error('RELAY_MEMBERSHIP_UNAVAILABLE') + ); + await expect(f.ensure()).rejects.toThrow('RELAY_MEMBERSHIP_UNAVAILABLE'); + expect(() => f.wallet.take(f.relay)).toThrow(); + await f.ensure(); + expect(f.sign).toHaveBeenCalledTimes(1); + }); + it('refuses old account-proof relays', async () => { + const f = fixture(); + f.relay.protocolVersion = 2 as never; + await expect(f.ensure()).rejects.toThrow('RELAY_PROTOCOL_UNSUPPORTED'); + expect(f.sign).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/src/relay-ticket-wallet.ts b/electron/src/relay-ticket-wallet.ts new file mode 100644 index 00000000..61c52820 --- /dev/null +++ b/electron/src/relay-ticket-wallet.ts @@ -0,0 +1,207 @@ +import { createHash } from 'crypto'; +import type { ReticulumBridge } from './reticulum-bridge'; +import { + PrivateTransportSidecar, + PrivateTransportSidecarError, +} from './private-transport-sidecar'; +import type { TrustedRelayConfig } from './quic-masque-transport'; +import { validateCommunityMasqueRelay } from './masque-relay-discovery'; + +export function relayPolicy(relay: TrustedRelayConfig) { + return createHash('sha256') + .update( + JSON.stringify({ + Mode: relay.accessMode, + Groups: relay.allowedGroupIds?.length + ? [...relay.allowedGroupIds].sort((a, b) => a - b) + : null, + }) + ) + .digest('hex'); +} +type Signature = { + authorAddress: string; + authorPublicKey: string; + signature: string; +}; +type Batch = { tickets: string[]; expiresAt: number }; +/** In-memory, account-scoped bearer credentials. Never exposed to a QApp. */ +export class RelayTicketWallet { + private batches = new Map(); + private pending = new Map>(); + private generation = 0; + constructor(private sidecar: PrivateTransportSidecar) {} + clear() { + this.generation++; + this.batches.clear(); + this.pending.clear(); + } + private key(r: TrustedRelayConfig) { + return `${r.relayIdentity}:${r.relayCertSha256}:${relayPolicy(r)}`; + } + async ensure( + bridge: ReticulumBridge, + relay: TrustedRelayConfig, + account: string, + sign: (p: Record) => Promise, + valid: () => boolean + ) { + const key = this.key(relay), + existing = this.batches.get(key); + if ( + existing?.tickets.length && + existing.expiresAt > Date.now() + 10 * 60_000 + ) + return; + if (this.pending.has(key)) return this.pending.get(key)!; + if ( + relay.protocolVersion !== 3 || + !relay.ticketIdentity || + !relay.ticketKeyId + ) + throw new PrivateTransportSidecarError('RELAY_PROTOCOL_UNSUPPORTED'); + if (this.pending.size >= 4) + throw new PrivateTransportSidecarError('RELAY_BUSY'); + const epoch = this.generation; + const check = () => { + if (epoch !== this.generation || !valid()) + throw new Error('RELAY_ACCOUNT_CHANGED'); + }; + const operation = (async () => { + for (let attempt = 0; attempt < 2; attempt++) { + const startedWindow = Math.floor(Date.now() / 3_600_000); + try { + const request = async ( + path: string, + data: Record + ) => { + check(); + try { + const result = await bridge.relayTicketRequest( + relay.ticketIdentity!, + path, + data + ); + check(); + return result; + } catch (error) { + check(); + throw new PrivateTransportSidecarError( + error instanceof Error && /^RELAY_[A-Z_]+$/.test(error.message) + ? error.message + : 'RELAY_AUTH_UNAVAILABLE' + ); + } + }; + const descriptor = await request('/catalog', {}); + let committedKey = relay.ticketKeyId; + if (descriptor.keyId !== committedKey) { + await bridge.getCommunityMasqueRelays(true); + for (let attempt = 0; attempt < 10; attempt++) { + check(); + const ads = await bridge.getCommunityMasqueRelays(false); + const current = ads + .map((v) => + validateCommunityMasqueRelay(v, { allowLoopback: true }) + ) + .find( + (v) => + v?.protocolVersion === 3 && + v.relayIdentity === relay.relayIdentity && + v.certSha256 === relay.relayCertSha256 && + JSON.stringify(v.allowedGroupIds) === + JSON.stringify(relay.allowedGroupIds) && + v.ticketKeyId === descriptor.keyId + ); + if (current) { + committedKey = current.ticketKeyId; + break; + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + } + if ( + Object.keys(descriptor).sort().join(',') !== + 'epoch,expiresAt,keyId,policy,publicKey,relayPin' || + descriptor.keyId !== committedKey || + descriptor.relayPin !== relay.relayCertSha256 || + descriptor.policy !== relayPolicy(relay) + ) + throw new PrivateTransportSidecarError('RELAY_PROOF_INVALID'); + const state = await this.sidecar.prepareRelayTickets(descriptor); + check(); + if ( + !/^[a-f0-9]{48}$/.test(state.handle) || + !Array.isArray(state.blinded) || + state.blinded.length !== 3 + ) + throw new Error('RELAY_PROOF_INVALID'); + const data = { descriptor, blinded: state.blinded }; + const c = await request('/challenge', data); + const binding = createHash('sha256') + .update( + JSON.stringify({ key: descriptor.keyId, blinded: state.blinded }) + ) + .digest('hex'); + if ( + Object.keys(c).sort().join(',') !== + 'binding,expiresAt,nonce,policy,relayPin,type' || + c.type !== 'masque-ticket-issue-v1' || + c.binding !== binding || + c.policy !== relayPolicy(relay) || + c.relayPin !== relay.relayCertSha256 || + !/^[a-f0-9]{64}$/.test(c.nonce) || + !Number.isSafeInteger(c.expiresAt) || + c.expiresAt <= Date.now() || + c.expiresAt > Date.now() + 65_000 + ) + throw new Error('RELAY_PROOF_INVALID'); + const signature = await sign(c); + check(); + if (!signature || signature.authorAddress !== account) + throw new Error('RELAY_ACCOUNT_CHANGED'); + const response = await request('/issue', { + ...data, + proof: { ...c, ...signature }, + }); + const finalized = await this.sidecar.finalizeRelayTickets( + state.handle, + response.signatures + ); + check(); + if ( + finalized.expiresAt !== descriptor.expiresAt || + !Array.isArray(finalized.tickets) || + finalized.tickets.length !== 3 + ) + throw new Error('RELAY_PROOF_INVALID'); + if (this.batches.size >= 32 && !this.batches.has(key)) + this.batches.delete(this.batches.keys().next().value!); + this.batches.set(key, finalized); + return; + } catch (error) { + check(); + // A request crossing an hourly key rotation may safely start again + // with new blind randomness; never replay the previous proof/token. + if ( + attempt === 0 && + Math.floor(Date.now() / 3_600_000) !== startedWindow + ) + continue; + throw error; + } + } + })().finally(() => { + if (this.pending.get(key) === operation) this.pending.delete(key); + }); + this.pending.set(key, operation); + return operation; + } + take(relay: TrustedRelayConfig): string { + const batch = this.batches.get(this.key(relay)); + if (!batch || batch.expiresAt <= Date.now() || !batch.tickets.length) + throw new PrivateTransportSidecarError('RELAY_AUTH_UNAVAILABLE'); + // Consume before sending. A lost response must never cause token replay. + return batch.tickets.shift()!; + } +} diff --git a/electron/src/reticulum-bridge.ts b/electron/src/reticulum-bridge.ts index 27f639df..82820849 100644 --- a/electron/src/reticulum-bridge.ts +++ b/electron/src/reticulum-bridge.ts @@ -202,6 +202,7 @@ type BridgeCmdFrame = { | 'configure_community_stun' | 'get_community_stun_endpoints' | 'get_community_masque_relays' + | 'relay_ticket_request' | 'configure_developer_log_filter' | 'stop' | 'send_call' @@ -3386,6 +3387,13 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { ); } + async relayTicketRequest(identity: string, path: string, data: Record): Promise> { + await this.start(); + const result = await this.sendCommand('relay_ticket_request', { identity, path, data }); + if (!result.ok || result.payload?.error) throw new Error(String(result.payload?.error || result.error || 'RELAY_AUTH_UNAVAILABLE')); + return result.payload ?? {}; + } + async getCommunityMasqueRelays(announce = false): Promise< Array<{ host: string; @@ -4052,7 +4060,7 @@ export class ReticulumBridge extends EventEmitter implements PresenceTransport { const frame: BridgeCmdFrame = { type: 'cmd', action, id, payload }; const wire = JSON.stringify(frame) + '\n'; const timeoutMs = - action === 'qapp_rns_connect' + action === 'relay_ticket_request' ? 28_000 : action === 'qapp_rns_connect' ? 45_000 : action === 'qapp_rns_request' ? Math.min( diff --git a/electron/src/setup.ts b/electron/src/setup.ts index 654c5b6c..a14d4bc5 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -155,6 +155,11 @@ import { shutdownPrivateTransportSidecar, } from './private-transport-runtime'; import { QAppMoqError, QAppMoqTransportManager } from './moq-transport-manager'; +import { + configureRelaySigner, + setRelayAccount, + setRelayGroups, +} from './relay-access-coordinator'; import { attachReticulumStatusBridgeEvents } from './reticulum-daemon'; import { startReticulumMeshCoordinator, @@ -2970,6 +2975,11 @@ function getQAppReticulumManager(): QAppReticulumManager { } function getPrivateChannelManager(): PrivateChannelManager { + configureRelaySigner( + signReticulumChatControlFields, + readRelayWalletAddress, + readRelayGroupHints + ); if (privateChannelManager) return privateChannelManager; privateChannelManager = new PrivateChannelManager( (owner, connectionId) => @@ -2986,6 +2996,11 @@ function getPrivateChannelManager(): PrivateChannelManager { } function getQAppMoqTransportManager(): QAppMoqTransportManager { + configureRelaySigner( + signReticulumChatControlFields, + readRelayWalletAddress, + readRelayGroupHints + ); if (qAppMoqTransportManager) return qAppMoqTransportManager; qAppMoqTransportManager = new QAppMoqTransportManager( (owner, connectionId) => @@ -3529,6 +3544,46 @@ function scheduleReticulumOverlayStateSyncRetry( reticulumOverlaySyncRetryTimer.unref?.(); } +async function readRelayGroupHints(address: string): Promise { + const main = myCapacitorApp.getMainWindow(); + if ( + !main || + main.isDestroyed() || + !isRendererMainFrameReady(main.webContents) + ) + return null; + const result = await main.webContents.executeJavaScript( + `(async () => { + const result = await window.sendMessage('getRelayGroupHints', {address:${JSON.stringify(address)}}, 3000); + return result?.groups ?? null; + })()`, + true + ); + return Array.isArray(result) && + result.length <= 4096 && + result.every((id) => Number.isInteger(id) && id > 0 && id <= 2147483647) + ? result + : null; +} + +async function readRelayWalletAddress(): Promise { + const main = myCapacitorApp.getMainWindow(); + if ( + !main || + main.isDestroyed() || + !isRendererMainFrameReady(main.webContents) + ) + return ''; + const result = await main.webContents.executeJavaScript( + `(async () => { + const result = await window.sendMessage('getWalletInfo', {}, 5000); + return result?.hasKeyPair && typeof result?.walletInfo?.address0 === 'string' ? result.walletInfo.address0 : ''; + })()`, + true + ); + return typeof result === 'string' ? result : ''; +} + async function signReticulumChatControlFields( fields: Record ): Promise<{ @@ -4382,6 +4437,11 @@ ipcMain.handle( return { success: false, error: 'Account session changed' }; } manager.setLocalGroupMemberships(Array.isArray(groupIds) ? groupIds : []); + setRelayGroups( + (Array.isArray(groupIds) ? groupIds : []).map((value) => + typeof value === 'number' ? value : Number(value.groupId) + ) + ); return { success: true }; } ); @@ -4450,6 +4510,7 @@ ipcMain.handle( ); ipcMain.handle('reticulumChat:clearLocalAccountState', async () => { + setRelayAccount('', true); reticulumLocalAccountLifecycleGeneration += 1; // These managers live for the lifetime of the main process too. Clear their // account routing at the same explicit logout boundary so a later login diff --git a/src/App.tsx b/src/App.tsx index e88ab787..d5e11f79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -141,6 +141,7 @@ import type { AuthUnlockTransitionSnapshot } from './types/authTransition'; import { openQWalletsTab } from './utils/openQWalletsTab'; import { clearLastAuthenticatedWalletAddress } from './utils/lastAuthenticatedWallet'; import { appLockedAtom, isIdleAtom } from './atoms/presence'; +import { reconcilePendingLocalForeignCoinSends } from './qortal/foreign-coin-send'; import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES, isAutoLockDue, @@ -477,6 +478,24 @@ function App() { } }, [extState, setIsAppLocked, setIsIdle]); + useEffect(() => { + if (extState !== 'authenticated' || !isMainWindow || isAppLocked) return; + const controller = new AbortController(); + const reconcile = () => { + void reconcilePendingLocalForeignCoinSends(controller.signal).catch( + () => undefined + ); + }; + reconcile(); + const intervalId = window.setInterval(reconcile, 60_000); + window.addEventListener('focus', reconcile); + return () => { + controller.abort(); + window.clearInterval(intervalId); + window.removeEventListener('focus', reconcile); + }; + }, [extState, isAppLocked, selectedNode?.apikey, selectedNode?.url]); + useEffect(() => { if ( extState !== 'authenticated' || diff --git a/src/background/background-cases.ts b/src/background/background-cases.ts index 437b64bb..da6b744e 100644 --- a/src/background/background-cases.ts +++ b/src/background/background-cases.ts @@ -2440,11 +2440,81 @@ export async function signPresenceMessageCase(request, event) { } } +export async function getRelayGroupHintsCase(request, event) { + let groups: number[] | null = null; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 2_000); + try { + const wallet = await getData('walletInfo'); + const address = request.payload?.address; + if ( + event.source !== window || + typeof address !== 'string' || + wallet?.address0 !== address + ) + throw new Error('Account changed'); + const endpoint = await createEndpoint( + `/groups/member/${encodeURIComponent(address)}` + ); + const response = await fetch(endpoint, { signal: controller.signal }); + if (!response.ok || !response.body) + throw new Error('Group hints unavailable'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let size = 0, + text = ''; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 262144) throw new Error('Group hints too large'); + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + } finally { + await reader.cancel().catch(() => undefined); + } + const rows = JSON.parse(text); + if ( + !Array.isArray(rows) || + rows.length > 4096 || + rows.some( + (row) => + !Number.isInteger(row?.groupId) || + row.groupId <= 0 || + row.groupId > 2147483647 + ) + ) + throw new Error('Invalid group hints'); + groups = [...new Set(rows.map((row) => row.groupId))]; + } catch { + /* Hints are optional; the relay still verifies actual membership. */ + } finally { + clearTimeout(timer); + } + event.source.postMessage( + { + requestId: request.requestId, + action: 'getRelayGroupHints', + payload: { groups }, + type: 'backgroundMessageResponse', + }, + event.origin + ); +} + export async function signReticulumChatEventCase(request, event) { let resKeyPair; let privateKeyBytes: Uint8Array | null = null; let messageBytes: Uint8Array | null = null; try { + if ( + request.payload?.type === 'masque-ticket-issue-v1' && + event.source !== window + ) { + throw new Error('Relay authorization is restricted to the Hub wallet'); + } assertAllowedReticulumSigningPayload(request.payload); resKeyPair = await getKeyPair(); privateKeyBytes = Base58.decode(resKeyPair.privateKey); diff --git a/src/background/background.ts b/src/background/background.ts index da231770..095b9eb5 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -69,6 +69,7 @@ import { getRewardSharePrivateKeyCase, signPresenceMessageCase, signReticulumChatEventCase, + getRelayGroupHintsCase, decryptBoxWithMyKeyCase, encryptSupportMessageCase, decryptSupportMessageCase, @@ -3100,6 +3101,9 @@ function setupMessageListener() { case 'signReticulumChatEvent': signReticulumChatEventCase(request, event); break; + case 'getRelayGroupHints': + getRelayGroupHintsCase(request, event); + break; case 'decryptBoxWithMyKey': decryptBoxWithMyKeyCase(request, event); break; diff --git a/src/background/reticulum-signing-policy.test.ts b/src/background/reticulum-signing-policy.test.ts index df758286..d0c5530b 100644 --- a/src/background/reticulum-signing-policy.test.ts +++ b/src/background/reticulum-signing-policy.test.ts @@ -12,6 +12,26 @@ function expectAllowed( } describe('Reticulum wallet signing policy', () => { + it('accepts only the exact fresh relay authorization domain', () => { + const challenge = { + type: 'masque-ticket-issue-v1', + binding: 'ab'.repeat(32), + nonce: 'cd'.repeat(32), + policy: 'ef'.repeat(32), + relayPin: '12'.repeat(32), + expiresAt: Date.now() + 10_000, + }; + expectAllowed(assertAllowedReticulumSigningPayload, challenge); + for (const invalid of [ + { ...challenge, extra: 'injected' }, + { ...challenge, expiresAt: 1 }, + { ...challenge, expiresAt: Date.now() + 120_000 }, + { ...challenge, type: 'masque-relay-auth-v1' }, + { ...challenge, binding: 'short' }, + ]) { + expect(() => assertAllowedReticulumSigningPayload(invalid)).toThrow(); + } + }); it('allows only an exact, short-lived Qortal Land proximity capability', () => { const now = Date.now(); const capability = { diff --git a/src/background/reticulum-signing-policy.ts b/src/background/reticulum-signing-policy.ts index 7c8ab1c8..ad2fed71 100644 --- a/src/background/reticulum-signing-policy.ts +++ b/src/background/reticulum-signing-policy.ts @@ -946,6 +946,27 @@ export function assertAllowedPresenceSigningPayload( export function assertAllowedReticulumSigningPayload( payload: unknown ): asserts payload is Record { + if ( + payload && + typeof payload === 'object' && + (payload as Record).type === 'masque-ticket-issue-v1' + ) { + const p = payload as Record; + if ( + Object.keys(p).sort().join(',') !== + 'binding,expiresAt,nonce,policy,relayPin,type' || + ['binding', 'nonce', 'policy', 'relayPin'].some( + (key) => + typeof p[key] !== 'string' || !/^[a-f0-9]{64}$/.test(p[key] as string) + ) || + !Number.isSafeInteger(p.expiresAt) || + Number(p.expiresAt) <= Date.now() || + Number(p.expiresAt) > Date.now() + 65_000 + ) { + throw new Error('Invalid relay authorization proof'); + } + return; + } assertPayload( payload, rchatSchemas, diff --git a/src/components/Group/Group.tsx b/src/components/Group/Group.tsx index 66822b41..3be4b4c5 100644 --- a/src/components/Group/Group.tsx +++ b/src/components/Group/Group.tsx @@ -3562,7 +3562,6 @@ export const Group = ({ setIsLoadingThreadKey(false); setFirstSecretKeyInCreation(false); setMountedLandGroupId(null); - setReticulumMountedGroupSections({}); setNotificationReticulumChannelId(''); setNotificationReticulumMessageId(''); setGroupSection('home'); diff --git a/src/i18n/locales/ar/question.json b/src/i18n/locales/ar/question.json index 8df1ceb9..07dfd06c 100644 --- a/src/i18n/locales/ar/question.json +++ b/src/i18n/locales/ar/question.json @@ -15,11 +15,13 @@ "download_file": "عايز تحمل الملف دا؟", "local_send": { "changed": "تغيرت المحفظة أو العقدة أو الأموال المتاحة. راجع الدفع مرة أخرى.", + "confirming": "لا تزال دفعتك السابقة في انتظار تأكيد الشبكة. المعاملة: {{txId}}. لم يتم إرسال دفعة جديدة.", "declined": "تم رفض الدفع.", "invalid": "تعذر إعداد هذا الدفع بأمان. تحقق من العنوان والمبلغ والرصيد والرسوم والاتصال بالعقدة.", "native_body": "المحفظة: {{wallet}}\n\nالمخرجات:\n{{outputs}}\n\nرسوم الشبكة: {{fee}}", "native_title": "تأكيد الدفع من المحفظة", "pending": "يجب التحقق من دفعة سابقة قبل إرسال دفعة أخرى. المعاملة: {{txId}}", + "previous": "تم إرسال دفعتك السابقة. المعاملة: {{txId}}. ابدأ دفعة جديدة إذا أردت الإرسال مرة أخرى.", "recovery": "لا تزال دفعة تمت الموافقة عليها سابقًا معلّقة. هل تريد إعادة بث المعاملة نفسها؟ لن يؤدي ذلك إلى إرسال الدفعة الجديدة التي طلبتها.", "unknown": "نتيجة البث غير معروفة. لا ترسل مرة أخرى. تحقق من المعاملة {{txId}} في سجل المحفظة.", "upgrade": "يتطلب توقيع معاملات المحفظة محليًا إصدارات محدثة من Qortal Core وQortal Hub." diff --git a/src/i18n/locales/de/question.json b/src/i18n/locales/de/question.json index 3729cbd0..7babb15e 100644 --- a/src/i18n/locales/de/question.json +++ b/src/i18n/locales/de/question.json @@ -15,11 +15,13 @@ "download_file": "Möchten Sie herunterladen:", "local_send": { "changed": "Wallet, Node oder verfügbare Mittel haben sich geändert. Prüfe die Zahlung erneut.", + "confirming": "Ihre vorherige Zahlung wartet noch auf die Netzwerkbestätigung. Transaktion: {{txId}}. Es wurde keine neue Zahlung gesendet.", "declined": "Zahlung abgelehnt.", "invalid": "Diese Zahlung kann nicht sicher vorbereitet werden. Prüfe Adresse, Betrag, Guthaben, Gebühr und Node-Verbindung.", "native_body": "Wallet: {{wallet}}\n\nAusgänge:\n{{outputs}}\n\nNetzwerkgebühr: {{fee}}", "native_title": "Wallet-Zahlung bestätigen", "pending": "Eine frühere Zahlung muss abgeglichen werden, bevor eine weitere gesendet werden kann. Transaktion: {{txId}}", + "previous": "Ihre vorherige Zahlung wurde gesendet. Transaktion: {{txId}}. Starten Sie eine neue Zahlung, wenn Sie erneut senden möchten.", "recovery": "Eine zuvor genehmigte Zahlung ist noch ausstehend. Soll dieselbe Transaktion erneut übertragen werden? Die neu angeforderte Zahlung wird dabei nicht gesendet.", "unknown": "Das Ergebnis der Übertragung ist unbekannt. Nicht erneut senden. Prüfe Transaktion {{txId}} im Wallet-Verlauf.", "upgrade": "Das lokale Signieren von Wallet-Transaktionen erfordert aktuelle Versionen von Qortal Core und Qortal Hub." diff --git a/src/i18n/locales/en/question.json b/src/i18n/locales/en/question.json index ce2e643c..1915412f 100644 --- a/src/i18n/locales/en/question.json +++ b/src/i18n/locales/en/question.json @@ -15,11 +15,13 @@ "download_file": "would you like to download:", "local_send": { "changed": "The wallet, node, or spendable funds changed. Please review the payment again.", + "confirming": "Your previous payment is still waiting for network confirmation. Transaction: {{txId}}. No new payment was sent.", "declined": "Payment declined.", "invalid": "Unable to prepare this payment safely. Check the address, amount, balance, fee, and node connection.", "native_body": "Wallet: {{wallet}}\n\nOutputs:\n{{outputs}}\n\nNetwork fee: {{fee}}", "native_title": "Confirm wallet payment", "pending": "A previous payment needs reconciliation before another can be sent. Transaction: {{txId}}", + "previous": "Your previous payment was sent. Transaction: {{txId}}. Start a new payment if you want to send again.", "recovery": "A previously approved payment is still pending. Retry broadcasting that same transaction? This will not send the new payment you requested.", "unknown": "The broadcast outcome is unknown. Do not send again. Check transaction {{txId}} in your wallet history.", "upgrade": "Local wallet signing requires updated versions of Qortal Core and Qortal Hub." diff --git a/src/i18n/locales/es/question.json b/src/i18n/locales/es/question.json index 9c842a35..4fffd63b 100644 --- a/src/i18n/locales/es/question.json +++ b/src/i18n/locales/es/question.json @@ -15,11 +15,13 @@ "download_file": "¿Desea descargar:", "local_send": { "changed": "La cartera, el nodo o los fondos disponibles han cambiado. Revisa el pago de nuevo.", + "confirming": "Su pago anterior aún espera la confirmación de la red. Transacción: {{txId}}. No se envió ningún pago nuevo.", "declined": "Pago rechazado.", "invalid": "No se puede preparar este pago de forma segura. Comprueba la dirección, el importe, el saldo, la comisión y la conexión al nodo.", "native_body": "Cartera: {{wallet}}\n\nSalidas:\n{{outputs}}\n\nComisión de red: {{fee}}", "native_title": "Confirmar pago de la cartera", "pending": "Es necesario comprobar un pago anterior antes de enviar otro. Transacción: {{txId}}", + "previous": "Su pago anterior fue enviado. Transacción: {{txId}}. Inicie un nuevo pago si desea enviar de nuevo.", "recovery": "Un pago autorizado anteriormente sigue pendiente. ¿Volver a transmitir esa misma transacción? Esto no enviará el nuevo pago solicitado.", "unknown": "Se desconoce el resultado de la transmisión. No vuelvas a enviar el pago. Comprueba la transacción {{txId}} en el historial de la cartera.", "upgrade": "La firma local de transacciones de la cartera requiere versiones actualizadas de Qortal Core y Qortal Hub." diff --git a/src/i18n/locales/et/question.json b/src/i18n/locales/et/question.json index c13fe30a..0eaa287a 100644 --- a/src/i18n/locales/et/question.json +++ b/src/i18n/locales/et/question.json @@ -15,11 +15,13 @@ "download_file": "kas sa soovid allalaadida:", "local_send": { "changed": "Rahakott, sõlm või saadaolevad vahendid on muutunud. Vaata makse uuesti üle.", + "confirming": "Teie eelmine makse ootab endiselt võrgu kinnitust. Tehing: {{txId}}. Uut makset ei saadetud.", "declined": "Makse tagasi lükatud.", "invalid": "Makset ei saa turvaliselt ette valmistada. Kontrolli aadressi, summat, saldot, tasu ja ühendust sõlmega.", "native_body": "Rahakott: {{wallet}}\n\nVäljundid:\n{{outputs}}\n\nVõrgutasu: {{fee}}", "native_title": "Kinnita rahakoti makse", "pending": "Enne uue makse saatmist tuleb eelmine makse üle kontrollida. Tehing: {{txId}}", + "previous": "Teie eelmine makse saadeti. Tehing: {{txId}}. Kui soovite uuesti saata, alustage uut makset.", "recovery": "Varem kinnitatud makse on endiselt ootel. Kas edastada sama tehing uuesti võrku? See ei saada sinu taotletud uut makset.", "unknown": "Edastamise tulemus pole teada. Ära saada uuesti. Kontrolli tehingut {{txId}} rahakoti ajaloos.", "upgrade": "Rahakoti tehingute kohalik allkirjastamine nõuab Qortal Core’i ja Qortal Hubi uuendatud versioone." diff --git a/src/i18n/locales/fi/question.json b/src/i18n/locales/fi/question.json index 94eeef77..39a3232a 100644 --- a/src/i18n/locales/fi/question.json +++ b/src/i18n/locales/fi/question.json @@ -15,11 +15,13 @@ "download_file": "haluatko ladata:", "local_send": { "changed": "Lompakko, solmu tai käytettävissä olevat varat ovat muuttuneet. Tarkista maksu uudelleen.", + "confirming": "Edellinen maksusi odottaa vielä verkon vahvistusta. Tapahtuma: {{txId}}. Uutta maksua ei lähetetty.", "declined": "Maksu hylätty.", "invalid": "Maksua ei voi valmistella turvallisesti. Tarkista osoite, summa, saldo, siirtomaksu ja yhteys solmuun.", "native_body": "Lompakko: {{wallet}}\n\nUlostulot:\n{{outputs}}\n\nVerkkomaksu: {{fee}}", "native_title": "Vahvista lompakon maksu", "pending": "Aiempi maksu on tarkistettava ennen uuden lähettämistä. Tapahtuma: {{txId}}", + "previous": "Edellinen maksusi lähetettiin. Tapahtuma: {{txId}}. Aloita uusi maksu, jos haluat lähettää uudelleen.", "recovery": "Aiemmin hyväksytty maksu on yhä odottamassa. Lähetetäänkö sama tapahtuma uudelleen verkkoon? Tämä ei lähetä pyytämääsi uutta maksua.", "unknown": "Lähetyksen tulos on tuntematon. Älä lähetä uudelleen. Tarkista tapahtuma {{txId}} lompakon historiasta.", "upgrade": "Lompakon tapahtumien paikallinen allekirjoittaminen edellyttää päivitettyjä Qortal Core- ja Qortal Hub -versioita." diff --git a/src/i18n/locales/fr/question.json b/src/i18n/locales/fr/question.json index 8b4559e8..eb5546ca 100644 --- a/src/i18n/locales/fr/question.json +++ b/src/i18n/locales/fr/question.json @@ -15,11 +15,13 @@ "download_file": "Souhaitez-vous télécharger :", "local_send": { "changed": "Le portefeuille, le nœud ou les fonds disponibles ont changé. Vérifiez à nouveau le paiement.", + "confirming": "Votre paiement précédent attend toujours la confirmation du réseau. Transaction : {{txId}}. Aucun nouveau paiement n’a été envoyé.", "declined": "Paiement refusé.", "invalid": "Impossible de préparer ce paiement en toute sécurité. Vérifiez l’adresse, le montant, le solde, les frais et la connexion au nœud.", "native_body": "Portefeuille : {{wallet}}\n\nSorties :\n{{outputs}}\n\nFrais de réseau : {{fee}}", "native_title": "Confirmer le paiement du portefeuille", "pending": "Un paiement précédent doit être vérifié avant tout nouvel envoi. Transaction : {{txId}}", + "previous": "Votre paiement précédent a été envoyé. Transaction : {{txId}}. Commencez un nouveau paiement si vous souhaitez envoyer à nouveau.", "recovery": "Un paiement précédemment autorisé est toujours en attente. Voulez-vous rediffuser cette même transaction ? Cela n’enverra pas le nouveau paiement demandé.", "unknown": "Le résultat de la diffusion est inconnu. Ne renvoyez pas le paiement. Vérifiez la transaction {{txId}} dans l’historique du portefeuille.", "upgrade": "La signature locale des transactions du portefeuille nécessite des versions à jour de Qortal Core et Qortal Hub." diff --git a/src/i18n/locales/it/question.json b/src/i18n/locales/it/question.json index 4cfe8ded..bfccff0e 100644 --- a/src/i18n/locales/it/question.json +++ b/src/i18n/locales/it/question.json @@ -15,11 +15,13 @@ "download_file": "vuoi scaricare:", "local_send": { "changed": "Il portafoglio, il nodo o i fondi disponibili sono cambiati. Controlla nuovamente il pagamento.", + "confirming": "Il pagamento precedente è ancora in attesa della conferma della rete. Transazione: {{txId}}. Non è stato inviato alcun nuovo pagamento.", "declined": "Pagamento rifiutato.", "invalid": "Impossibile preparare questo pagamento in sicurezza. Controlla indirizzo, importo, saldo, commissione e connessione al nodo.", "native_body": "Portafoglio: {{wallet}}\n\nUscite:\n{{outputs}}\n\nCommissione di rete: {{fee}}", "native_title": "Conferma il pagamento dal portafoglio", "pending": "Un pagamento precedente deve essere verificato prima di inviarne un altro. Transazione: {{txId}}", + "previous": "Il pagamento precedente è stato inviato. Transazione: {{txId}}. Avvia un nuovo pagamento se desideri inviare di nuovo.", "recovery": "Un pagamento autorizzato in precedenza è ancora in sospeso. Vuoi ritrasmettere la stessa transazione? Il nuovo pagamento richiesto non verrà inviato.", "unknown": "L’esito della trasmissione è sconosciuto. Non inviare di nuovo. Controlla la transazione {{txId}} nella cronologia del portafoglio.", "upgrade": "La firma locale delle transazioni del portafoglio richiede versioni aggiornate di Qortal Core e Qortal Hub." diff --git a/src/i18n/locales/ja/question.json b/src/i18n/locales/ja/question.json index 31189f21..21ea71e7 100644 --- a/src/i18n/locales/ja/question.json +++ b/src/i18n/locales/ja/question.json @@ -15,11 +15,13 @@ "download_file": "次のファイルをダウンロードしますか:", "local_send": { "changed": "ウォレット、ノード、または利用可能な資金が変更されました。支払いを再確認してください。", + "confirming": "前回の支払いはまだネットワークの確認を待っています。トランザクション:{{txId}}。新しい支払いは送信されていません。", "declined": "支払いが拒否されました。", "invalid": "この支払いを安全に準備できません。アドレス、金額、残高、手数料、ノードへの接続を確認してください。", "native_body": "ウォレット:{{wallet}}\n\n出力:\n{{outputs}}\n\nネットワーク手数料:{{fee}}", "native_title": "ウォレットの支払いを確認", "pending": "次の支払いを送信する前に、以前の支払いを確認する必要があります。トランザクション:{{txId}}", + "previous": "前回の支払いは送信されました。トランザクション:{{txId}}。もう一度送信する場合は、新しい支払いを開始してください。", "recovery": "以前承認した支払いがまだ保留中です。同じトランザクションを再送信しますか?新しく依頼した支払いは送信されません。", "unknown": "ブロードキャストの結果が不明です。再送信しないでください。ウォレットの履歴でトランザクション{{txId}}を確認してください。", "upgrade": "ウォレットのトランザクションをローカルで署名するには、Qortal CoreとQortal Hubを更新する必要があります。" diff --git a/src/i18n/locales/pt/question.json b/src/i18n/locales/pt/question.json index ff99a5e2..fc34c6a5 100644 --- a/src/i18n/locales/pt/question.json +++ b/src/i18n/locales/pt/question.json @@ -15,11 +15,13 @@ "download_file": "você gostaria de baixar:", "local_send": { "changed": "A carteira, o nó ou os fundos disponíveis mudaram. Revise o pagamento novamente.", + "confirming": "O seu pagamento anterior ainda aguarda confirmação da rede. Transação: {{txId}}. Nenhum novo pagamento foi enviado.", "declined": "Pagamento recusado.", "invalid": "Não foi possível preparar este pagamento com segurança. Verifique o endereço, o valor, o saldo, a taxa e a conexão com o nó.", "native_body": "Carteira: {{wallet}}\n\nSaídas:\n{{outputs}}\n\nTaxa de rede: {{fee}}", "native_title": "Confirmar pagamento da carteira", "pending": "É necessário verificar um pagamento anterior antes de enviar outro. Transação: {{txId}}", + "previous": "O seu pagamento anterior foi enviado. Transação: {{txId}}. Inicie um novo pagamento se quiser enviar novamente.", "recovery": "Um pagamento autorizado anteriormente ainda está pendente. Retransmitir essa mesma transação? Isto não enviará o novo pagamento solicitado.", "unknown": "O resultado da transmissão é desconhecido. Não envie novamente. Verifique a transação {{txId}} no histórico da carteira.", "upgrade": "A assinatura local de transações da carteira requer versões atualizadas do Qortal Core e do Qortal Hub." diff --git a/src/i18n/locales/ru/question.json b/src/i18n/locales/ru/question.json index 09533eb7..39f1bfaf 100644 --- a/src/i18n/locales/ru/question.json +++ b/src/i18n/locales/ru/question.json @@ -15,11 +15,13 @@ "download_file": "Вы хотите загрузить:", "local_send": { "changed": "Кошелёк, узел или доступные средства изменились. Проверьте платёж ещё раз.", + "confirming": "Ваш предыдущий платёж всё ещё ожидает подтверждения сетью. Транзакция: {{txId}}. Новый платёж не был отправлен.", "declined": "Платёж отклонён.", "invalid": "Не удалось безопасно подготовить платёж. Проверьте адрес, сумму, баланс, комиссию и подключение к узлу.", "native_body": "Кошелёк: {{wallet}}\n\nВыходы:\n{{outputs}}\n\nКомиссия сети: {{fee}}", "native_title": "Подтвердить платёж из кошелька", "pending": "Перед отправкой нового платежа необходимо проверить предыдущий. Транзакция: {{txId}}", + "previous": "Ваш предыдущий платеж был отправлен. Транзакция: {{txId}}. Начните новый платеж, если хотите отправить средства снова.", "recovery": "Ранее одобренный платёж всё ещё ожидает обработки. Повторно отправить ту же транзакцию в сеть? Новый запрошенный платёж отправлен не будет.", "unknown": "Результат отправки неизвестен. Не отправляйте повторно. Проверьте транзакцию {{txId}} в истории кошелька.", "upgrade": "Для локального подписания транзакций кошелька требуются обновлённые версии Qortal Core и Qortal Hub." diff --git a/src/i18n/locales/zh/question.json b/src/i18n/locales/zh/question.json index e99e58d0..eb397b15 100644 --- a/src/i18n/locales/zh/question.json +++ b/src/i18n/locales/zh/question.json @@ -15,11 +15,13 @@ "download_file": "您想下载以下内容:", "local_send": { "changed": "钱包、节点或可用资金已发生变化。请重新检查付款。", + "confirming": "您之前的付款仍在等待网络确认。交易:{{txId}}。未发送新的付款。", "declined": "付款已拒绝。", "invalid": "无法安全地准备此付款。请检查地址、金额、余额、手续费和节点连接。", "native_body": "钱包:{{wallet}}\n\n输出:\n{{outputs}}\n\n网络手续费:{{fee}}", "native_title": "确认钱包付款", "pending": "发送下一笔付款前,需要核实之前的付款。交易:{{txId}}", + "previous": "您之前的付款已发送。交易:{{txId}}。如果您想再次发送,请发起新的付款。", "recovery": "之前批准的付款仍在等待处理。是否重新广播同一笔交易?这不会发送您新请求的付款。", "unknown": "广播结果未知。请勿再次发送。请在钱包历史记录中检查交易{{txId}}。", "upgrade": "在本地签署钱包交易需要更新至新版Qortal Core和Qortal Hub。" diff --git a/src/lib/foreign-wallet/send.test.ts b/src/lib/foreign-wallet/send.test.ts index bdaf084b..45483d46 100644 --- a/src/lib/foreign-wallet/send.test.ts +++ b/src/lib/foreign-wallet/send.test.ts @@ -13,7 +13,12 @@ import { walletPublicKey, } from './foreign-wallets'; import { buildForeignWalletSignedTransaction } from './foreign-wallet-transaction'; -import { sendForeignCoin, atomicAmount, type PendingSend } from './send'; +import { + sendForeignCoin, + reconcilePendingForeignCoin, + atomicAmount, + type PendingSend, +} from './send'; import { getForeignWalletMainnetChainId } from './foreign-wallet-spend-context'; import PhraseWallet from '../../utils/generateWallet/phrase-wallet'; @@ -86,6 +91,21 @@ function fixture(coin: (typeof foreignCoins)[number]) { return { xprv, original, leaf, input, context }; } +function transactionStatus( + coin: (typeof foreignCoins)[number], + txId: string, + status: 'UNKNOWN' | 'MEMPOOL' | 'CONFIRMED' +) { + return { + version: 1, + currencyCode: coin, + activeNetwork: 'MAIN', + chainId: getForeignWalletMainnetChainId(coin), + txId, + status, + }; +} + describe('local foreign wallet signing', () => { for (const coin of foreignCoins) { it(`${coin}: preserves existing Hub derivation and signs valid input scripts`, async () => { @@ -188,7 +208,7 @@ describe('local foreign wallet signing', () => { ).rejects.toThrow('disk failure'); expect(broadcasts).toBe(0); }); - it('retains a reservation after acknowledgement until read servers observe the spend', async () => { + it('retains a reservation while the exact transaction is in the mempool', async () => { const { xprv, leaf, context } = fixture('LTC'); let pending: PendingSend = null; const deps = { @@ -198,11 +218,11 @@ describe('local foreign wallet signing', () => { writePending: async (value: PendingSend) => { pending = value; }, - post: async (path: string) => + post: async (path: string, body: any) => path.endsWith('/send/broadcast') ? pending.txId - : path.endsWith('wallettransactions') - ? [{ txHash: pending.txId }] + : path.endsWith('transaction-status') + ? transactionStatus('LTC', body.txId, 'MEMPOOL') : context, }; const req = { @@ -214,10 +234,52 @@ describe('local foreign wallet signing', () => { const txId = await sendForeignCoin(req, deps); expect(pending.txId).toBe(txId); await expect(sendForeignCoin(req, deps)).rejects.toMatchObject({ - code: 'pending', + code: 'confirming', + txId, }); expect(pending.txId).toBe(txId); }); + it('clears a confirmed old payment and asks normally about the current one', async () => { + const { xprv, leaf, context } = fixture('LTC'); + let pending: PendingSend = null; + let status: 'UNKNOWN' | 'CONFIRMED' = 'UNKNOWN'; + const approve = vi + .fn<() => Promise>() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const post = vi.fn(async (path: string, body: any) => { + if (path.endsWith('/send/broadcast')) return pending.txId; + if (path.endsWith('transaction-status')) + return transactionStatus('LTC', body.txId, status); + return context; + }); + const deps = { + approve, + stillValid: async () => true, + readPending: async () => pending, + writePending: async (value: PendingSend) => { + pending = value; + }, + post, + }; + const req = { + coin: 'LTC' as const, + xprv, + amount: '1', + recipient: leaf.address, + }; + const previousTxId = await sendForeignCoin(req, deps); + status = 'CONFIRMED'; + + await expect(sendForeignCoin(req, deps)).rejects.toMatchObject({ + code: 'declined', + }); + expect(pending).toBeNull(); + expect(approve).toHaveBeenCalledTimes(2); + expect( + post.mock.calls.filter(([path]) => path.endsWith('/send/broadcast')) + ).toHaveLength(1); + }); it('rejects forged amounts and previous transactions', () => { const { xprv, leaf, input } = fixture('LTC'); for (const changed of [ @@ -263,7 +325,8 @@ describe('local foreign wallet signing', () => { post: async (path: string, body: unknown) => { bodies.push(JSON.stringify(body)); if (path.endsWith('spend-context')) return context; - if (path.endsWith('wallettransactions')) return []; + if (path.endsWith('transaction-status')) + return transactionStatus('LTC', pending.txId, 'UNKNOWN'); broadcasts++; throw new Error('timeout'); }, @@ -319,7 +382,8 @@ it('recovers only the identical persisted bytes and never reports an old payment }, post: async (path: string, body: any) => { if (path.endsWith('spend-context')) return context; - if (path.endsWith('wallettransactions')) return []; + if (path.endsWith('transaction-status')) + return transactionStatus('LTC', pending.txId, 'UNKNOWN'); sent.push(body); if (!allowBroadcast) throw new Error('lost connection'); return pending.txId; @@ -353,7 +417,9 @@ it('rejects corrupt recovery bytes and respects expired trade funding deadlines' outputs: [{ address: leaf.address, value: 999990000n }], }); const approveRecovery = vi.fn(async () => true); - const post = vi.fn(async (_path: string) => []); + const post = vi.fn(async (_path: string, body: any) => + transactionStatus('LTC', body.txId, 'UNKNOWN') + ); for (const entry of [ { txId: 'a'.repeat(64), rawTransactionHex: signed.rawTransactionHex }, { @@ -381,9 +447,95 @@ it('rejects corrupt recovery bytes and respects expired trade funding deadlines' } expect(approveRecovery).not.toHaveBeenCalled(); expect( - post.mock.calls.every(([path]) => path.endsWith('wallettransactions')) + post.mock.calls.every(([path]) => path.endsWith('transaction-status')) ).toBe(true); }); + +it('background reconciliation clears only a confirmed exact transaction', async () => { + const { xprv } = fixture('LTC'); + const xpub = walletPublicKey(xprv, 'LTC'); + const txId = 'a'.repeat(64); + let pending: PendingSend = { + txId, + outpoints: [`${'b'.repeat(64)}:0`], + }; + const writePending = vi.fn(async (value: PendingSend) => { + pending = value; + }); + const state = await reconcilePendingForeignCoin('LTC', xpub, { + stillValid: async () => true, + readPending: async () => pending, + writePending, + post: async (_path, body: any) => + transactionStatus('LTC', body.txId, 'CONFIRMED'), + }); + expect(state).toBe('confirmed'); + expect(pending).toBeNull(); + expect(writePending).toHaveBeenCalledWith(null); +}); + +it.each(['UNKNOWN', 'MEMPOOL'] as const)( + 'background reconciliation keeps a %s transaction reserved', + async (status) => { + const { xprv } = fixture('LTC'); + const xpub = walletPublicKey(xprv, 'LTC'); + const pending: PendingSend = { + txId: 'a'.repeat(64), + outpoints: [`${'b'.repeat(64)}:0`], + }; + const writePending = vi.fn(); + expect( + await reconcilePendingForeignCoin('LTC', xpub, { + stillValid: async () => true, + readPending: async () => pending, + writePending, + post: async (_path, body: any) => + transactionStatus('LTC', body.txId, status), + }) + ).toBe(status.toLowerCase()); + expect(writePending).not.toHaveBeenCalled(); + } +); + +it('does not clear a confirmed reservation after the wallet or Core changes', async () => { + const { xprv } = fixture('LTC'); + const xpub = walletPublicKey(xprv, 'LTC'); + const pending: PendingSend = { + txId: 'a'.repeat(64), + outpoints: [`${'b'.repeat(64)}:0`], + }; + const writePending = vi.fn(); + expect( + await reconcilePendingForeignCoin('LTC', xpub, { + stillValid: async () => false, + readPending: async () => pending, + writePending, + post: async (_path, body: any) => + transactionStatus('LTC', body.txId, 'CONFIRMED'), + }) + ).toBe('unknown'); + expect(writePending).not.toHaveBeenCalled(); +}); + +it('rejects a transaction-status response for a different transaction', async () => { + const { xprv } = fixture('LTC'); + const xpub = walletPublicKey(xprv, 'LTC'); + const pending: PendingSend = { + txId: 'a'.repeat(64), + outpoints: [`${'b'.repeat(64)}:0`], + }; + const writePending = vi.fn(); + await expect( + reconcilePendingForeignCoin('LTC', xpub, { + stillValid: async () => true, + readPending: async () => pending, + writePending, + post: async () => transactionStatus('LTC', 'c'.repeat(64), 'CONFIRMED'), + }) + ).rejects.toMatchObject({ code: 'invalid' }); + expect(writePending).not.toHaveBeenCalled(); +}); + it('plans with only a public key when using the isolated signer', async () => { const { xprv, leaf, context } = fixture('LTC'); let pending: PendingSend = null; diff --git a/src/lib/foreign-wallet/send.ts b/src/lib/foreign-wallet/send.ts index 2f5e4793..346244f2 100644 --- a/src/lib/foreign-wallet/send.ts +++ b/src/lib/foreign-wallet/send.ts @@ -33,6 +33,7 @@ export class ForeignSendError extends Error { | 'upgrade' | 'changed' | 'pending' + | 'confirming' | 'unknown' | 'declined', public txId?: string @@ -58,7 +59,107 @@ export type SendDependencies = { readPending: () => Promise; writePending: (entry: PendingSend | null) => Promise; }; +type ReconciliationDependencies = Pick< + SendDependencies, + 'post' | 'stillValid' | 'readPending' | 'writePending' +>; +export type ForeignTransactionState = + | 'none' + | 'unknown' + | 'mempool' + | 'confirmed' + | 'busy'; const busy = new Set(); + +async function withWalletLock( + lock: string, + unavailable: () => T | Promise, + action: () => Promise +): Promise { + const run = async () => { + if (busy.has(lock)) return unavailable(); + busy.add(lock); + try { + return await action(); + } finally { + busy.delete(lock); + } + }; + if (typeof navigator !== 'undefined' && navigator.locks) + return navigator.locks.request(lock, { ifAvailable: true }, (held) => + held ? run() : unavailable() + ); + return run(); +} + +function normalizeTransactionStatus( + value: unknown, + coin: ForeignWalletCoin, + expectedChainId: string, + txId: string +): 'UNKNOWN' | 'MEMPOOL' | 'CONFIRMED' { + const response = value as Record; + if ( + !response || + response.version !== 1 || + response.currencyCode !== coin || + response.activeNetwork !== 'MAIN' || + response.chainId !== expectedChainId || + response.txId !== txId || + !['UNKNOWN', 'MEMPOOL', 'CONFIRMED'].includes(response.status as string) + ) + throw new ForeignSendError('invalid'); + return response.status as 'UNKNOWN' | 'MEMPOOL' | 'CONFIRMED'; +} + +async function transactionStatus( + coin: ForeignWalletCoin, + expectedChainId: string, + txId: string, + post: SendDependencies['post'] +) { + return normalizeTransactionStatus( + await post( + `/crosschain/${coin.toLowerCase()}/wallet/public/transaction-status`, + { + expectedChainId, + txId, + } + ), + coin, + expectedChainId, + txId + ); +} + +export async function reconcilePendingForeignCoin( + coin: ForeignWalletCoin, + xpub: string, + deps: ReconciliationDependencies +): Promise { + walletFingerprint(xpub, coin); + const expectedChainId = getForeignWalletMainnetChainId(coin); + return withWalletLock( + `${coin}:${xpub}`, + () => 'busy', + async () => { + const pending = await deps.readPending(); + if (!pending) return 'none'; + const status = await transactionStatus( + coin, + expectedChainId, + pending.txId, + deps.post + ); + if (status === 'CONFIRMED') { + if (!(await deps.stillValid())) return 'unknown'; + await deps.writePending(null); + return 'confirmed'; + } + return status === 'MEMPOOL' ? 'mempool' : 'unknown'; + } + ); +} export function atomicAmount(raw: unknown): bigint { // Existing q-apps pass numbers; accept their decimal spelling without multiplying floats. const text = @@ -109,34 +210,30 @@ export async function sendForeignCoin( const expectedChainId = getForeignWalletMainnetChainId(coin); const lock = `${coin}:${xpub}`; // Reject overlapping sends across tabs as well as within this process. - const run = async () => { - if (busy.has(lock)) throw new ForeignSendError('pending'); - busy.add(lock); - try { - return await execute(); - } finally { - busy.delete(lock); - } - }; - if (typeof navigator !== 'undefined' && navigator.locks) - return navigator.locks.request(lock, { ifAvailable: true }, (held) => { - if (!held) throw new ForeignSendError('pending'); - return run(); - }); - return run(); + return withWalletLock( + lock, + () => { + throw new ForeignSendError('pending'); + }, + execute + ); async function execute() { - const pending = await deps.readPending(); + let pending = await deps.readPending(); if (pending) { - // A timeout is not evidence of failure. Only the exact txid in wallet history - // clears a reservation; never automatically construct another payment. - const history = await deps.post( - `/crosschain/${coin.toLowerCase()}/wallettransactions`, - xpub + const status = await transactionStatus( + coin, + expectedChainId, + pending.txId, + deps.post ); - if (!Array.isArray(history)) - throw new ForeignSendError('pending', pending.txId); - if (!history.some((tx) => tx?.txHash === pending.txId)) { + if (status === 'CONFIRMED') { + if (!(await deps.stillValid())) throw new ForeignSendError('changed'); + await deps.writePending(null); + pending = null; + } else if (status === 'MEMPOOL') { + throw new ForeignSendError('confirming', pending.txId); + } else { if ( pending.rawTransactionHex && deps.approveRecovery && @@ -185,15 +282,6 @@ export async function sendForeignCoin( return context; }; const context = await read(); - if (pending) { - if ( - context.utxos.some((input) => - pending.outpoints.includes(`${input.txHash}:${input.txPos}`) - ) - ) - throw new ForeignSendError('pending', pending.txId); - await deps.writePending(null); - } if (request.sendMax && request.amount !== undefined) throw new ForeignSendError('invalid'); diff --git a/src/lib/foreign-wallet/trade-plan.test.ts b/src/lib/foreign-wallet/trade-plan.test.ts index 4a730ece..752cc051 100644 --- a/src/lib/foreign-wallet/trade-plan.test.ts +++ b/src/lib/foreign-wallet/trade-plan.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { base58check } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ripemd160 } from '@noble/hashes/ripemd160'; import { hexToBytes, bytesToHex } from '@noble/hashes/utils'; import { foreignCoins } from './foreign-wallets'; import { validateLocalTradePlan } from './trade-plan'; +import Base58 from '../../encryption/Base58'; describe('trade funding validation', () => { for (const coin of foreignCoins) @@ -14,9 +15,7 @@ describe('trade funding validation', () => { receivingAddress: 'QORT', expectedForeignAmount: '1', tradeTimeout: 60, - creatorForeignPKH: btoa( - String.fromCharCode(...new Uint8Array(20).fill(3)) - ), + creatorForeignPKH: Base58.encode(new Uint8Array(20).fill(3)), }; const lockTime = Math.floor(Date.now() / 1000) + 3600; const time = new Uint8Array(4); @@ -57,10 +56,40 @@ describe('trade funding validation', () => { expect(() => validateLocalTradePlan( [plan], - [{ ...offer, creatorForeignPKH: btoa('x'.repeat(20)) }], + [{ ...offer, creatorForeignPKH: 'not-valid-base58-0' }], coin, 'QORT' ) ).toThrow(); }); + + it('accepts the Base58 seller key hash returned by the live Core API', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1788958554000); + const offer = { + qortalAtAddress: 'AQyL6gfcJ6vbmN7thDii3h3di5Mq7u1uZ4', + expectedForeignAmount: '0.04350000', + tradeTimeout: 120, + creatorForeignPKH: '3TuTyu7byeYxS4Ly4pJGepauRZet', + }; + const plan = { + atAddress: offer.qortalAtAddress, + receivingAddress: 'QORT', + address: '31kxF481xPMfR5H6bdMib5XUu8mYC9e6JH', + amount: '4352730', + fundingReserve: '2730', + lockTime: 1788965754, + refundPublicKeyHash: 'c04caa54b7a5251dcb0597da62e586101048d6b9', + hashOfSecret: 'a1d75bb7ed909f700bb25be919e4b6cba31f07e1', + redeemScript: + '7dada97614c04caa54b7a5251dcb0597da62e586101048d6b9876375047a73a16ab16714b0da988370168d62ea7968d7f0a38ff8a10098f588a914a1d75bb7ed909f700bb25be919e4b6cba31f07e18768', + }; + + try { + expect(validateLocalTradePlan([plan], [offer], 'LTC', 'QORT')).toEqual([ + { address: plan.address, value: 4352730n }, + ]); + } finally { + now.mockRestore(); + } + }); }); diff --git a/src/lib/foreign-wallet/trade-plan.ts b/src/lib/foreign-wallet/trade-plan.ts index 51f5edf0..7a9a7db0 100644 --- a/src/lib/foreign-wallet/trade-plan.ts +++ b/src/lib/foreign-wallet/trade-plan.ts @@ -4,6 +4,7 @@ import { validateForeignWalletRecipient } from './foreign-wallet-transaction'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { ripemd160 } from '@noble/hashes/ripemd160'; import { sha256 } from '@noble/hashes/sha256'; +import Base58 from '../../encryption/Base58'; // Pure validation before any signing: bind outputs to the selected ATs and HTLC terms. export function validateLocalTradePlan( plans: any, @@ -31,14 +32,10 @@ export function validateLocalTradePlan( !/^[a-f0-9]{40}$/.test(plan.hashOfSecret) ) throw new Error('Invalid trade terms'); - // Core's trade API serializes byte[] as base64. Check the actual seller key hash. + // Qortal's API adapter serializes byte[] values as Base58 strings. const seller = typeof offer.creatorForeignPKH === 'string' - ? bytesToHex( - Uint8Array.from(atob(offer.creatorForeignPKH), (c) => - c.charCodeAt(0) - ) - ) + ? bytesToHex(Base58.decode(offer.creatorForeignPKH)) : bytesToHex(Uint8Array.from(offer.creatorForeignPKH || [])); if (!/^[a-f0-9]{40}$/.test(seller)) throw new Error('Invalid seller'); const lock = new Uint8Array(4); diff --git a/src/qortal/foreign-coin-send.test.ts b/src/qortal/foreign-coin-send.test.ts index 3d45a2d1..1f314e37 100644 --- a/src/qortal/foreign-coin-send.test.ts +++ b/src/qortal/foreign-coin-send.test.ts @@ -1,5 +1,7 @@ import { beforeEach, afterEach, it, expect, vi } from 'vitest'; import { HDKey } from '@scure/bip32'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex } from '@noble/hashes/utils'; vi.mock('../background/background', () => ({ getKeyPair: vi.fn(), getSaveWallet: vi.fn(), @@ -15,8 +17,11 @@ import { getSaveWallet, createEndpoint, } from '../background/background'; -import { sendForeignCoin } from '../lib/foreign-wallet/send'; -import { sendLocalForeignCoin } from './foreign-coin-send'; +import { ForeignSendError, sendForeignCoin } from '../lib/foreign-wallet/send'; +import { + reconcilePendingLocalForeignCoinSends, + sendLocalForeignCoin, +} from './foreign-coin-send'; beforeEach(() => { const key = HDKey.fromMasterSeed(new Uint8Array(32).fill(5)); @@ -104,6 +109,55 @@ it('does not post after the selected wallet changes', async () => { expect(fetch).not.toHaveBeenCalled(); }); +it('reports when an earlier payment is still awaiting confirmation', async () => { + vi.mocked(sendForeignCoin).mockRejectedValue( + new ForeignSendError('confirming', 'a'.repeat(64)) + ); + await expect(sendLocalForeignCoin({ coin: 'LTC' }, vi.fn())).rejects.toThrow( + 'question:local_send.confirming' + ); +}); + +it('automatically clears a confirmed journal entry without exposing wallet keys', async () => { + const keys = await getKeyPair(); + const xpub = HDKey.fromExtendedKey(keys.ltcPrivateKey).publicExtendedKey; + const fingerprint = bytesToHex(sha256(new TextEncoder().encode(xpub))); + const storageKey = `foreign-send-v1:LTC:${fingerprint}`; + const txId = 'a'.repeat(64); + localStorage.setItem( + storageKey, + JSON.stringify({ txId, outpoints: [`${'b'.repeat(64)}:0`] }) + ); + vi.stubGlobal( + 'fetch', + vi.fn(async (_url, init) => { + const body = JSON.parse(init.body as string); + return Response.json({ + version: 1, + currencyCode: 'LTC', + activeNetwork: 'MAIN', + chainId: 'bip122:12a765e31ffd4059bada1e25190f6e98', + txId: body.txId, + status: 'CONFIRMED', + }); + }) + ); + + await reconcilePendingLocalForeignCoinSends(); + + expect(localStorage.getItem(storageKey)).toBeNull(); + expect(fetch).toHaveBeenCalledOnce(); + const requestBody = (vi.mocked(fetch).mock.calls[0][1] as RequestInit).body; + expect(requestBody).toBe( + JSON.stringify({ + expectedChainId: 'bip122:12a765e31ffd4059bada1e25190f6e98', + txId, + }) + ); + expect(requestBody).not.toContain(keys.ltcPrivateKey); + expect(requestBody).not.toContain(xpub); +}); + it('routes desktop signing through native IPC without passing a private key', async () => { const root = HDKey.fromMasterSeed(new Uint8Array(32).fill(5)); vi.mocked(getKeyPair).mockResolvedValue({ diff --git a/src/qortal/foreign-coin-send.ts b/src/qortal/foreign-coin-send.ts index e61475cd..4ac541ac 100644 --- a/src/qortal/foreign-coin-send.ts +++ b/src/qortal/foreign-coin-send.ts @@ -13,10 +13,12 @@ import { bytesToHex } from '@noble/hashes/utils'; import { decimalAmount, ForeignSendError, + reconcilePendingForeignCoin, sendForeignCoin, type PendingSend, } from '../lib/foreign-wallet/send'; import { + foreignCoins, walletPublicKey, type ForeignWalletCoin, } from '../lib/foreign-wallet/foreign-wallets'; @@ -27,6 +29,142 @@ export class LocalWalletError extends Error { } } +function parsePendingSend(raw: string | null | undefined): PendingSend | null { + if (raw === null || raw === undefined) return null; + let value: Record; + try { + value = JSON.parse(raw); + } catch { + throw new ForeignSendError('pending'); + } + if ( + !value || + !/^[a-f0-9]{64}$/.test(value.txId as string) || + !Array.isArray(value.outpoints) || + value.outpoints.length < 1 || + value.outpoints.length > 1000 || + value.outpoints.some( + (p: unknown) => + typeof p !== 'string' || !/^[a-f0-9]{64}:[0-9]{1,10}$/.test(p) + ) || + (value.rawTransactionHex !== undefined && + (typeof value.rawTransactionHex !== 'string' || + value.rawTransactionHex.length > 400000 || + !/^(?:[a-f0-9]{2})+$/.test(value.rawTransactionHex))) || + (value.broadcastBefore !== undefined && + (!Number.isSafeInteger(value.broadcastBefore) || + (value.broadcastBefore as number) <= 0)) + ) + throw new ForeignSendError('pending'); + return value as PendingSend; +} + +async function fetchWalletEndpoint( + path: string, + body: unknown, + stillValid: () => Promise, + timeoutMs: number, + signal?: AbortSignal +) { + if (!(await stillValid())) throw new ForeignSendError('changed'); + const controller = new AbortController(); + const abort = () => controller.abort(); + signal?.addEventListener('abort', abort, { once: true }); + const timeout = setTimeout(abort, timeoutMs); + try { + if (signal?.aborted) controller.abort(); + const response = await fetch(await createEndpoint(path), { + method: 'POST', + headers: { + 'Content-Type': + typeof body === 'string' ? 'text/plain' : 'application/json', + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + signal: controller.signal, + }); + if (response.status === 404 || response.status === 405) + throw new ForeignSendError('upgrade'); + if (!response.ok) throw new ForeignSendError('invalid'); + const text = await readBoundedWalletResponse(response, 20 * 1024 * 1024); + try { + return JSON.parse(text); + } catch { + return text; + } + } finally { + clearTimeout(timeout); + signal?.removeEventListener('abort', abort); + } +} + +/** Reconciles saved sends without signing, broadcasting, or asking permission. */ +export async function reconcilePendingLocalForeignCoinSends( + signal?: AbortSignal +) { + if ( + signal?.aborted || + (window.appStorage && + (!window.foreignWalletJournal || !window.foreignWalletSigner)) + ) + return; + const keys = await getKeyPair(); + const wallet = await getSaveWallet(); + await Promise.allSettled( + foreignCoins.map(async (coin) => { + if (signal?.aborted) return; + const xprv = keys[`${coin.toLowerCase()}PrivateKey`]; + const xpub = window.foreignWalletSigner + ? await window.foreignWalletSigner.publicKey(coin) + : walletPublicKey(xprv, coin); + if ( + window.foreignWalletSigner && + keys[`${coin.toLowerCase()}PublicKey`] !== xpub + ) + return; + const fingerprint = bytesToHex(sha256(new TextEncoder().encode(xpub))); + const storageKey = `foreign-send-v1:${coin}:${fingerprint}`; + let pendingTxId: string; + const statusPath = `/crosschain/${coin.toLowerCase()}/wallet/public/transaction-status`; + const endpoint = await createEndpoint(statusPath); + const stillValid = async () => { + try { + return ( + !signal?.aborted && + (await getSaveWallet()).address0 === wallet.address0 && + (window.foreignWalletSigner + ? (await window.foreignWalletSigner.publicKey(coin)) === xpub && + (await getKeyPair())[`${coin.toLowerCase()}PublicKey`] === xpub + : (await getKeyPair())[`${coin.toLowerCase()}PrivateKey`] === + xprv) && + (await createEndpoint(statusPath)) === endpoint + ); + } catch { + return false; + } + }; + await reconcilePendingForeignCoin(coin, xpub, { + stillValid, + readPending: async () => { + const raw = window.foreignWalletJournal + ? await window.foreignWalletJournal.get(storageKey) + : localStorage.getItem(storageKey); + const pending = parsePendingSend(raw); + pendingTxId = pending?.txId; + return pending; + }, + writePending: async (entry) => { + if (entry) throw new ForeignSendError('invalid'); + if (window.foreignWalletJournal) + await window.foreignWalletJournal.delete(storageKey, pendingTxId); + else localStorage.removeItem(storageKey); + }, + post: (path, body) => + fetchWalletEndpoint(path, body, stillValid, 20_000, signal), + }); + }) + ); +} + /** The app's private key is used only by the local signer, never by post(). */ export async function sendLocalForeignCoin( data: Record, @@ -130,29 +268,9 @@ export async function sendLocalForeignCoin( const raw = window.foreignWalletJournal ? await window.foreignWalletJournal.get(storageKey) : localStorage.getItem(storageKey); - if (raw === null || raw === undefined) return null; - const value = JSON.parse(raw); - if ( - !value || - !/^[a-f0-9]{64}$/.test(value.txId) || - !Array.isArray(value.outpoints) || - value.outpoints.length < 1 || - value.outpoints.length > 1000 || - value.outpoints.some( - (p: unknown) => - typeof p !== 'string' || !/^[a-f0-9]{64}:[0-9]{1,10}$/.test(p) - ) || - (value.rawTransactionHex !== undefined && - (typeof value.rawTransactionHex !== 'string' || - value.rawTransactionHex.length > 400000 || - !/^(?:[a-f0-9]{2})+$/.test(value.rawTransactionHex))) || - (value.broadcastBefore !== undefined && - (!Number.isSafeInteger(value.broadcastBefore) || - value.broadcastBefore <= 0)) - ) - throw new ForeignSendError('pending'); - pendingTxId = value.txId; - return value as PendingSend; + const pending = parsePendingSend(raw); + pendingTxId = pending?.txId; + return pending; }, writePending: async (entry) => { if (window.foreignWalletJournal) { @@ -168,34 +286,7 @@ export async function sendLocalForeignCoin( else localStorage.removeItem(storageKey); }, post: async (path, body) => { - if (!(await stillValid())) throw new ForeignSendError('changed'); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 90000); - try { - const response = await fetch(await createEndpoint(path), { - method: 'POST', - headers: { - 'Content-Type': - typeof body === 'string' ? 'text/plain' : 'application/json', - }, - body: typeof body === 'string' ? body : JSON.stringify(body), - signal: controller.signal, - }); - if (response.status === 404 || response.status === 405) - throw new ForeignSendError('upgrade'); - if (!response.ok) throw new ForeignSendError('invalid'); - const text = await readBoundedWalletResponse( - response, - 20 * 1024 * 1024 - ); - try { - return JSON.parse(text); - } catch { - return text; - } - } finally { - clearTimeout(timeout); - } + return fetchWalletEndpoint(path, body, stillValid, 90_000); }, approve: async (plan) => ( From 40f8910e9e41f0f8508fc5a2ad4a072d58f1c9ca Mon Sep 17 00:00:00 2001 From: PhilReact Date: Thu, 10 Sep 2026 20:26:18 +0300 Subject: [PATCH 25/32] Honor custom BTC-family fees below Core recommendations --- docs/local-wallet-signing.md | 14 +++-- src/lib/foreign-wallet/send.test.ts | 87 +++++++++++++++++++++++++++++ src/lib/foreign-wallet/send.ts | 8 +-- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/docs/local-wallet-signing.md b/docs/local-wallet-signing.md index e3f39000..f8af5411 100644 --- a/docs/local-wallet-signing.md +++ b/docs/local-wallet-signing.md @@ -99,8 +99,10 @@ sequenceDiagram ``` Hub refreshes the spend context after approval. If the wallet, selected node, -fee recommendation, inputs, outputs or trade intent changed during approval, Hub -stops instead of signing stale information. +inputs, outputs or trade intent changed during approval, Hub stops instead of +signing stale information. A changed fee recommendation also stops a payment +that relies on that recommendation, but it does not replace a fee explicitly +supplied by the caller. ## Q-App compatibility @@ -151,10 +153,10 @@ rate for each supported network, expressed per 1,000 bytes. The public spend context exposes this as an integer atomic-unit rate per byte, rounded upward. Hub uses that rate when the request omits `fee`. If the request supplies a custom -rate, Hub currently requires it to be at least Core's configured rate and no more -than ten times that rate. Independent per-coin ceilings limit the rate and total -fee. Hub calculates the fee from the actual transaction plan and displays the -planned fee in its normal permission prompt. +positive rate, Hub honors it as the previous Core send flow did. Independent +per-coin ceilings still limit the rate and total fee. Hub calculates the fee +from the actual transaction plan and displays the planned fee in its normal +permission prompt. The value is a configured recommendation, not a live mempool estimate. A future fee-estimation change can improve that recommendation without changing where the diff --git a/src/lib/foreign-wallet/send.test.ts b/src/lib/foreign-wallet/send.test.ts index 45483d46..1e27aacb 100644 --- a/src/lib/foreign-wallet/send.test.ts +++ b/src/lib/foreign-wallet/send.test.ts @@ -310,6 +310,93 @@ describe('local foreign wallet signing', () => { for (const raw of [0, -1, NaN, Infinity, '1e3', '0.000000001', 0.000000001]) expect(() => atomicAmount(raw)).toThrow(); }); + it('honors an explicit fee below the Core recommendation', async () => { + const { xprv, leaf, context } = fixture('LTC'); + const approve = vi.fn(async () => false); + + await expect( + sendForeignCoin( + { + coin: 'LTC', + xprv, + amount: '0.07', + recipient: leaf.address, + fee: '0.00000009', + }, + { + post: async () => context, + stillValid: async () => true, + approve, + readPending: async () => null, + writePending: vi.fn(), + } + ) + ).rejects.toMatchObject({ code: 'declined' }); + expect(approve).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 7000000n, + feePerByte: 9n, + }) + ); + }); + it('keeps an explicit fee when the Core recommendation changes', async () => { + const { xprv, leaf, context } = fixture('LTC'); + let pending: PendingSend = null; + let reads = 0; + const post = vi.fn(async (path: string) => { + if (path.endsWith('/send/broadcast')) return pending.txId; + reads++; + return reads === 1 + ? context + : { ...context, recommendedFeePerByte: '11' }; + }); + + const txId = await sendForeignCoin( + { + coin: 'LTC', + xprv, + amount: '0.07', + recipient: leaf.address, + fee: '0.00000009', + }, + { + post, + stillValid: async () => true, + approve: async () => true, + readPending: async () => null, + writePending: async (value) => { + pending = value; + }, + } + ); + + expect(txId).toBe(pending.txId); + expect(post).toHaveBeenCalledTimes(3); + }); + it('still rejects an explicit fee above the local safety ceiling', async () => { + const { xprv, leaf, context } = fixture('LTC'); + const approve = vi.fn(async () => true); + + await expect( + sendForeignCoin( + { + coin: 'LTC', + xprv, + amount: '0.07', + recipient: leaf.address, + fee: '0.00020001', + }, + { + post: async () => context, + stillValid: async () => true, + approve, + readPending: async () => null, + writePending: vi.fn(), + } + ) + ).rejects.toThrow('Wallet fee policy exceeded'); + expect(approve).not.toHaveBeenCalled(); + }); it('retains ambiguous broadcasts and never sends the key to Core', async () => { const { xprv, leaf, context } = fixture('LTC'); let pending: PendingSend = null; diff --git a/src/lib/foreign-wallet/send.ts b/src/lib/foreign-wallet/send.ts index 346244f2..e087ed2d 100644 --- a/src/lib/foreign-wallet/send.ts +++ b/src/lib/foreign-wallet/send.ts @@ -289,11 +289,6 @@ export async function sendForeignCoin( request.fee === undefined ? context.recommendedFeePerByte : atomicAmount(request.fee); - if ( - fee < context.recommendedFeePerByte || - fee > context.recommendedFeePerByte * 10n - ) - throw new ForeignSendError('invalid'); const cache = createForeignWalletPreviousTransactionCache(); const planFor = (state: typeof context) => planForeignWalletSpend({ @@ -317,7 +312,8 @@ export async function sendForeignCoin( if (!(await deps.stillValid())) throw new ForeignSendError('changed'); const after = await read(); if ( - context.recommendedFeePerByte !== after.recommendedFeePerByte || + (request.fee === undefined && + context.recommendedFeePerByte !== after.recommendedFeePerByte) || context.minimumNonDustOutput !== after.minimumNonDustOutput || snapshot(planFor(after)) !== snapshot(plan) ) From f553e868b11c4c4ff8c45b3663dfb4b1fd260255 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Fri, 11 Sep 2026 19:10:24 +0300 Subject: [PATCH 26/32] allow with permission downloads from qapps --- docs/qapp-streaming-save.md | 77 +++++++ electron/src/preload.ts | 1 + electron/src/qapp-file-save.test.ts | 154 +++++++++++++ electron/src/qapp-file-save.ts | 252 ++++++++++++++++++++++ electron/src/setup.ts | 62 ++++++ src/hooks/useQortalMessageListener.tsx | 56 +++-- src/i18n/locales/ar/question.json | 7 + src/i18n/locales/de/question.json | 7 + src/i18n/locales/en/question.json | 7 + src/i18n/locales/es/question.json | 7 + src/i18n/locales/et/question.json | 7 + src/i18n/locales/fi/question.json | 7 + src/i18n/locales/fr/question.json | 7 + src/i18n/locales/it/question.json | 7 + src/i18n/locales/ja/question.json | 7 + src/i18n/locales/pt/question.json | 7 + src/i18n/locales/ru/question.json | 7 + src/i18n/locales/zh/question.json | 7 + src/qortal/qapp-file-save-request.test.ts | 46 ++++ src/qortal/qapp-file-save-request.ts | 57 +++++ src/types/window.d.ts | 1 + 21 files changed, 772 insertions(+), 18 deletions(-) create mode 100644 docs/qapp-streaming-save.md create mode 100644 electron/src/qapp-file-save.test.ts create mode 100644 electron/src/qapp-file-save.ts create mode 100644 src/qortal/qapp-file-save-request.test.ts create mode 100644 src/qortal/qapp-file-save-request.ts diff --git a/docs/qapp-streaming-save.md b/docs/qapp-streaming-save.md new file mode 100644 index 00000000..36511245 --- /dev/null +++ b/docs/qapp-streaming-save.md @@ -0,0 +1,77 @@ +# Streaming file saves (desktop) + +This generic QApp API saves bytes progressively without a whole-file Blob. +It does not download URLs, decrypt data, inspect file formats, or verify an app's +claims. The app remains responsible for its protocol and integrity checks. +Legacy `SAVE_FILE` remains unchanged. An updated desktop Hub is required. + +## Permission and API + +Call `FILE_SAVE_OPEN` from a deliberate Download/Save action. Hub presents a +native permission prompt showing the trusted app identity, **suggested filename** +and **declared size**. It explicitly says these details come from the app and +are not a guarantee of content or safety. Approval opens the native save dialog. +Approval is per save, never a session-wide permission. Hub never auto-opens files. + +```js +const { saveId } = await qortalRequestWithTimeout({ + action: 'FILE_SAVE_OPEN', filename: 'example.zip', size: expectedBytes +}, 330000); +try { + let offset = 0; + for await (const data of verifiedChunks) { // Uint8Array, max 256 KiB + await qortalRequestWithTimeout({ + action: 'FILE_SAVE_WRITE', saveId, offset, data + }, 60000); + offset += data.byteLength; + } + await verifyCompleteFile(); + await qortalRequestWithTimeout({ action: 'FILE_SAVE_FINISH', saveId }, 60000); +} catch (error) { + await qortalRequest({ action: 'FILE_SAVE_ABORT', saveId }); + throw error; +} +``` + +WRITE returns cumulative `bytesWritten`; FINISH returns `{ saved: true }`. +Do not pipeline writes: await each acknowledgment. FINISH requires the exact +declared length. Hub cannot know whether a malicious app actually verified it. +The native path is never returned to the QApp. The iframe cannot supply its owner, +permission wording, or an output path. Main-process IPC is restricted to Hub's +main frame; saves are additionally bound to the QApp identity and tab. + +## Limits and cleanup + +- One pending/active save per tab/app, eight across Hub; maximum 8 GiB per save. + Applications can impose a smaller limit (the file-sharing app uses 3 GiB). +- One in-flight write per save; chunks up to 256 KiB; strictly increasing offsets. +- Five-minute idle expiry and 24-hour absolute lifetime. Pending dialogs are + invalidated too. A stale approval cannot create an output after logout/cleanup. +- Tab reload/closure, logout, shell navigation/crash, cancellation and expiry + abort unfinished saves. On ordinary shutdown cleanup is best effort. +- A randomly named `.qortal-save-.part` beside the chosen destination holds + plaintext with owner-only POSIX permissions. It is not the final filename. + FINISH fsyncs and atomically renames it, replacing a file only at the destination + selected through the native overwrite-confirming dialog. There is no automatic + launch or antivirus/content-safety guarantee. Windows relies on folder ACLs. +- Private local journals record partial paths for cleanup on the next Hub launch + following a crash. Failed deletions retain the journal for a later attempt. + Journals contain local paths, not file contents, and never reach a backend. + Cleanup is deletion, not guaranteed secure erasure of disk sectors. + +Keep the original destination unchanged on abort or integrity failure. If an +application loses the final acknowledgment at the commit boundary, the file +may already have been saved; cancellation cannot roll back a completed save. + +Error codes include `SAVE_CANCELLED`, `SAVE_BUSY`, `SAVE_NOT_FOUND`, +`SAVE_INVALID_REQUEST`, `SAVE_INVALID_CHUNK`, `SAVE_INCOMPLETE`, `SAVE_IO_ERROR`, +`SAVE_PERMISSION_DENIED`, and `SAVE_UNAVAILABLE`. OS errors are redacted. + +## Manual smoke test + +After building/syncing and restarting desktop Hub, open a file-sharing link. +Approve the prompt and choose a path. Check a small file's contents, then a large +file; memory must not grow with the entire file size. Cancel midway, close the +tab, and log out during separate downloads: no final file should be created. +Repeat with an existing output file and cancel: its contents must stay unchanged. +Test native save/overwrite dialogs separately on Linux, macOS and Windows. diff --git a/electron/src/preload.ts b/electron/src/preload.ts index acaf37e3..2e08b4d5 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -736,6 +736,7 @@ try { ipcRenderer.on('qappReticulum:event', listener); return () => ipcRenderer.removeListener('qappReticulum:event', listener); }, + qappFileSave: (owner, request) => ipcRenderer.invoke('qappFileSave:request', owner, request), privateChannelOpen: (owner, rnsConnectionId, purpose) => invokePrivateChannel( 'privateChannel:open', diff --git a/electron/src/qapp-file-save.test.ts b/electron/src/qapp-file-save.test.ts new file mode 100644 index 00000000..933dfc76 --- /dev/null +++ b/electron/src/qapp-file-save.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { QAppFileSaves, SAVE_CHUNK_LIMIT } from './qapp-file-save'; + +describe('permission-scoped streaming saves', () => { + let dir: string, destination: string, manager: QAppFileSaves, now: number; + beforeEach(async () => { + dir = await fs.mkdtemp(join(tmpdir(), 'qapp-save-test-')); + destination = join(dir, 'chosen.txt'); + now = 0; + manager = new QAppFileSaves(join(dir, 'journal'), () => now); + }); + afterEach(async () => { + await manager.cleanup(); + await fs.rm(dir, { recursive: true, force: true }); + }); + const bytes = new Uint8Array([1, 2, 3]); + const open = () => + manager.open('tab-a', 'suggested.txt', 3, async () => destination); + + it('does not replace the approved destination until exact-length finish', async () => { + await fs.writeFile(destination, 'original'); + const { saveId } = await open(); + expect(await fs.readFile(destination, 'utf8')).toBe('original'); + await expect(manager.finish('tab-a', saveId)).rejects.toThrow( + 'SAVE_INCOMPLETE' + ); + await manager.write('tab-a', saveId, 0, bytes); + expect(await fs.readFile(destination, 'utf8')).toBe('original'); + expect(await manager.finish('tab-a', saveId)).toEqual({ saved: true }); + expect(new Uint8Array(await fs.readFile(destination))).toEqual(bytes); + expect(await fs.readdir(join(dir, 'journal'))).toEqual([]); + }); + it('rejects cross-tab writes/finish and ignores cross-tab abort', async () => { + const { saveId } = await open(); + await expect(manager.write('tab-b', saveId, 0, bytes)).rejects.toThrow( + 'SAVE_NOT_FOUND' + ); + await expect(manager.finish('tab-b', saveId)).rejects.toThrow( + 'SAVE_NOT_FOUND' + ); + await manager.abort('tab-b', saveId); + await manager.write('tab-a', saveId, 0, bytes); + await manager.finish('tab-a', saveId); + }); + it('bounds chunks and enforces size, offset and one in-flight write', async () => { + const { saveId } = await open(); + await expect(manager.write('tab-a', saveId, 1, bytes)).rejects.toThrow( + 'SAVE_INVALID_CHUNK' + ); + await expect( + manager.write('tab-a', saveId, 0, new Uint8Array(SAVE_CHUNK_LIMIT + 1)) + ).rejects.toThrow('SAVE_INVALID_CHUNK'); + await expect( + manager.write('tab-a', saveId, 0, new Uint8Array(4)) + ).rejects.toThrow('SAVE_INVALID_CHUNK'); + const write = manager.write('tab-a', saveId, 0, bytes); + await expect(manager.write('tab-a', saveId, 0, bytes)).rejects.toThrow( + 'SAVE_BUSY' + ); + await write; + }); + it('cancels an active save without touching an existing destination', async () => { + await fs.writeFile(destination, 'original'); + const { saveId } = await open(); + await manager.write('tab-a', saveId, 0, bytes); + await manager.cleanup('tab-a'); + expect(await fs.readFile(destination, 'utf8')).toBe('original'); + expect((await fs.readdir(dir)).filter((n) => n.endsWith('.part'))).toEqual( + [] + ); + await expect(manager.finish('tab-a', saveId)).rejects.toThrow( + 'SAVE_NOT_FOUND' + ); + }); + it('invalidates pending approval on logout and creates no file', async () => { + let choose!: (path: string) => void; + const opening = manager.open( + 'tab-a', + 'name.txt', + 3, + () => + new Promise((resolve) => { + choose = resolve; + }) + ); + while (!choose) await new Promise((resolve) => setTimeout(resolve, 1)); + await manager.cleanup(); + choose(destination); + await expect(opening).rejects.toThrow('SAVE_CANCELLED'); + expect((await fs.readdir(dir)).filter((n) => n.endsWith('.part'))).toEqual( + [] + ); + }); + + it('cancels during an in-flight write and cleans up after that write settles', async () => { + await fs.writeFile(destination, 'original'); + const { saveId } = await open(); + const writing = manager.write('tab-a', saveId, 0, bytes); + await manager.abort('tab-a', saveId); + await expect(writing).rejects.toThrow('SAVE_CANCELLED'); + expect(await fs.readFile(destination, 'utf8')).toBe('original'); + expect((await fs.readdir(dir)).filter((n) => n.endsWith('.part'))).toEqual( + [] + ); + }); + it('expires idle saves and limits unapproved concurrent prompts', async () => { + const { saveId } = await open(); + await expect(open()).rejects.toThrow('SAVE_BUSY'); + now = 5 * 60_000 + 1; + await manager.expire(); + await expect(manager.write('tab-a', saveId, 0, bytes)).rejects.toThrow( + 'SAVE_NOT_FOUND' + ); + }); + it('rejects paths, bidi filenames and unreasonable sizes before prompting', async () => { + for (const filename of ['../secret', 'a\\b', 'a\u202etxt']) + await expect( + manager.open('tab-a', filename, 3, async () => destination) + ).rejects.toThrow('SAVE_INVALID_REQUEST'); + await expect( + manager.open('tab-a', 'ok', 9 * 1024 ** 3, async () => destination) + ).rejects.toThrow('SAVE_INVALID_REQUEST'); + }); + it('cleans journalled crash remnants without deleting the completed file', async () => { + await fs.mkdir(join(dir, 'journal')); + const id = '12345678-1234-1234-1234-123456789abc'; + const part = join(dir, `.qortal-save-${id}.part`); + await fs.writeFile(part, 'incomplete'); + await fs.writeFile(destination, 'complete'); + await fs.writeFile( + join(dir, 'journal', `${id}.json`), + JSON.stringify({ part }) + ); + await manager.initialize(); + await expect(fs.stat(part)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await fs.readFile(destination, 'utf8')).toBe('complete'); + }); + it('handles empty files and user cancellation', async () => { + await expect( + manager.open('tab-a', 'empty', 0, async () => undefined) + ).rejects.toThrow('SAVE_CANCELLED'); + const { saveId } = await manager.open( + 'tab-a', + 'empty', + 0, + async () => destination + ); + await manager.finish('tab-a', saveId); + expect((await fs.stat(destination)).size).toBe(0); + }); +}); diff --git a/electron/src/qapp-file-save.ts b/electron/src/qapp-file-save.ts new file mode 100644 index 00000000..3dc0c662 --- /dev/null +++ b/electron/src/qapp-file-save.ts @@ -0,0 +1,252 @@ +import { randomUUID } from 'crypto'; +import { promises as fs } from 'fs'; +import type { FileHandle } from 'fs/promises'; +import { basename, dirname, isAbsolute, join } from 'path'; + +export const SAVE_CHUNK_LIMIT = 256 * 1024; +const MAX_SIZE = 8 * 1024 ** 3; +const IDLE_MS = 5 * 60_000; +type Save = { + id: string; + owner: string; + size: number; + offset: number; + created: number; + touched: number; + cancelled: boolean; + busy: boolean; + file?: FileHandle; + part?: string; + destination?: string; +}; +export class SaveError extends Error {} +function fail(code: string): never { + throw new SaveError(code); +} + +/** Paths never cross the renderer boundary. Only the native dialog chooses them. */ +export class QAppFileSaves { + private saves = new Map(); + private recovery?: Promise; + constructor( + private journal: string, + private now = Date.now + ) {} + initialize() { + return (this.recovery ??= this.recover()); + } + + private async recover() { + await fs.mkdir(this.journal, { recursive: true, mode: 0o700 }); + for (const name of await fs.readdir(this.journal)) { + if (!/^[a-f0-9-]{36}\.json$/.test(name)) continue; + const record = join(this.journal, name); + try { + const part = JSON.parse(await fs.readFile(record, 'utf8')).part; + if ( + typeof part !== 'string' || + !isAbsolute(part) || + basename(part) !== `.qortal-save-${name.slice(0, -5)}.part` + ) + continue; + await fs.unlink(part).catch((e) => { + if (e.code !== 'ENOENT') throw e; + }); + await fs.unlink(record); + } catch { + /* Keep failed records for a later recovery attempt. */ + } + } + } + + async open( + owner: string, + filename: unknown, + size: unknown, + choose: ( + filename: string, + size: number, + checkLive: () => void + ) => Promise + ) { + if ( + typeof filename !== 'string' || + !filename || + filename.length > 200 || + /[\\/\u202a-\u202e\u2066-\u2069]/.test(filename) || + [...filename].some( + (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 + ) || + filename === '.' || + filename === '..' || + !Number.isSafeInteger(size) || + (size as number) < 0 || + (size as number) > MAX_SIZE + ) + fail('SAVE_INVALID_REQUEST'); + if ( + this.saves.size >= 8 || + [...this.saves.values()].some((s) => s.owner === owner) + ) + fail('SAVE_BUSY'); + const save: Save = { + id: randomUUID(), + owner, + size: size as number, + offset: 0, + created: this.now(), + touched: this.now(), + busy: true, + cancelled: false, + }; + this.saves.set(save.id, save); + try { + await this.initialize(); + this.live(save); + const destination = await choose(filename, save.size, () => + this.live(save) + ); + this.live(save); + if (!destination) fail('SAVE_CANCELLED'); + if (!isAbsolute(destination)) fail('SAVE_INVALID_REQUEST'); + // Do not allow two saves to race replacement of the same destination. + if ( + [...this.saves.values()].some( + (s) => s !== save && s.destination === destination + ) + ) + fail('SAVE_BUSY'); + save.destination = destination; + save.part = join(dirname(destination), `.qortal-save-${save.id}.part`); + const record = await fs.open( + join(this.journal, `${save.id}.json`), + 'wx', + 0o600 + ); + try { + await record.writeFile(JSON.stringify({ part: save.part })); + await record.sync(); + } finally { + await record.close(); + } + save.file = await fs.open(save.part, 'wx', 0o600); + this.live(save); + save.touched = this.now(); + save.busy = false; + return { saveId: save.id, maxChunkBytes: SAVE_CHUNK_LIMIT }; + } catch (error) { + await this.dispose(save); + throw error; + } + } + + private live(save: Save) { + if ( + save.cancelled || + this.now() - save.touched > IDLE_MS || + this.now() - save.created > 24 * 60 * 60_000 + ) + fail('SAVE_CANCELLED'); + } + private get(owner: string, id: unknown) { + const save = typeof id === 'string' ? this.saves.get(id) : undefined; + if (!save || save.owner !== owner) fail('SAVE_NOT_FOUND'); + this.live(save); + if (save.busy) fail('SAVE_BUSY'); + return save; + } + async write(owner: string, id: unknown, offset: unknown, data: unknown) { + const save = this.get(owner, id); + if ( + !(data instanceof Uint8Array) || + data.byteLength < 1 || + data.byteLength > SAVE_CHUNK_LIMIT || + offset !== save.offset || + save.offset + data.byteLength > save.size + ) + fail('SAVE_INVALID_CHUNK'); + save.busy = true; + try { + let written = 0; + while (written < data.byteLength) { + this.live(save); + const result = await save.file.write( + data, + written, + data.byteLength - written, + save.offset + written + ); + if (!result.bytesWritten) fail('SAVE_IO_ERROR'); + written += result.bytesWritten; + } + this.live(save); + save.offset += written; + save.touched = this.now(); + return { bytesWritten: save.offset }; + } catch (error) { + await this.dispose(save); + throw error; + } finally { + save.busy = false; + } + } + async finish(owner: string, id: unknown) { + const save = this.get(owner, id); + if (save.offset !== save.size) fail('SAVE_INCOMPLETE'); + save.busy = true; + try { + await save.file.sync(); + await save.file.close(); + save.file = undefined; + this.live(save); + // Atomic replacement only after the app explicitly finishes verification. + await fs.rename(save.part, save.destination); + return { saved: true }; + } finally { + await this.dispose(save); + } + } + async abort(owner: string, id: unknown) { + const save = typeof id === 'string' ? this.saves.get(id) : undefined; + if (!save || save.owner !== owner) return { aborted: true }; + save.cancelled = true; + // In-flight operations own cleanup; don't close a descriptor beneath a write. + if (!save.busy) await this.dispose(save); + return { aborted: true }; + } + async cleanup(owner?: string) { + await Promise.all( + [...this.saves.values()] + .filter((s) => !owner || s.owner === owner) + .map((s) => this.abort(s.owner, s.id)) + ); + } + async expire() { + await Promise.all( + [...this.saves.values()] + .filter( + (s) => + this.now() - s.touched > IDLE_MS || + this.now() - s.created > 24 * 60 * 60_000 + ) + .map((s) => this.abort(s.owner, s.id)) + ); + } + private async dispose(save: Save) { + save.cancelled = true; + try { + await save.file?.close(); + save.file = undefined; + if (save.part) + await fs.unlink(save.part).catch((e) => { + if (e.code !== 'ENOENT') throw e; + }); + await fs.unlink(join(this.journal, `${save.id}.json`)).catch((e) => { + if (e.code !== 'ENOENT') throw e; + }); + } catch { + /* Journal retains the exact partial path for next launch cleanup. */ + } + this.saves.delete(save.id); + } +} diff --git a/electron/src/setup.ts b/electron/src/setup.ts index a14d4bc5..95c6173c 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -5,6 +5,7 @@ import { signForeignWalletPayment, } from './foreign-wallet-signer'; import { createForeignWalletJournal } from './foreign-wallet-journal'; +import { QAppFileSaves, SaveError } from './qapp-file-save'; import type { CapacitorElectronConfig } from '@capacitor-community/electron'; import { CapElectronEventEmitter, @@ -3026,6 +3027,66 @@ function validateQAppReticulumIpcSender( } } +let qappFileSaves: QAppFileSaves; +function fileSaves() { + return (qappFileSaves ??= new QAppFileSaves(join(app.getPath('userData'), 'file-save-journal'))); +} +void app.whenReady().then(() => fileSaves().initialize()).catch(() => undefined); +function saveOwner(owner: any) { + if (!owner || !['tabId', 'name', 'service'].every(key => + typeof owner[key] === 'string' && owner[key].length > 0 && owner[key].length <= 256)) + throw new SaveError('SAVE_INVALID_REQUEST'); + return JSON.stringify([owner.tabId, owner.name, owner.service]); +} +ipcMain.handle('qappFileSave:request', async (event, owner, request) => { + if (!isMainShellSender(event.sender) || event.senderFrame !== event.sender.mainFrame) + return { error: 'SAVE_PERMISSION_DENIED' }; + try { + const key = saveOwner(owner); + const manager = fileSaves(); + switch (request?.action) { + case 'FILE_SAVE_OPEN': + return await manager.open(key, request.filename, request.size, async (filename, _size, checkLive) => { + const labels = request.labels; + if (!labels || !['title', 'detail', 'allow', 'cancel'].every(k => + typeof labels[k] === 'string' && labels[k].length <= 2048)) + throw new SaveError('SAVE_INVALID_REQUEST'); + const win = myCapacitorApp.getMainWindow(); + const permission = await dialog.showMessageBox(win, { + type: 'question', message: labels.title, detail: labels.detail, + buttons: [labels.cancel, labels.allow], defaultId: 0, cancelId: 0, + noLink: true, + }); + if (permission.response !== 1) return undefined; + checkLive(); + const result = await dialog.showSaveDialog(win, { + defaultPath: filename, title: labels.title, + properties: ['showOverwriteConfirmation', 'createDirectory'], + }); + return result.canceled ? undefined : result.filePath; + }); + case 'FILE_SAVE_WRITE': return await manager.write(key, request.saveId, request.offset, request.data); + case 'FILE_SAVE_FINISH': return await manager.finish(key, request.saveId); + case 'FILE_SAVE_ABORT': return await manager.abort(key, request.saveId); + case 'FILE_SAVE_CLEANUP': await manager.cleanup(key); return { aborted: true }; + default: throw new SaveError('SAVE_INVALID_REQUEST'); + } + } catch (error) { + // Never expose OS errors containing filesystem paths to a QApp. + return { error: error instanceof SaveError ? error.message : 'SAVE_IO_ERROR' }; + } +}); +setInterval(() => { void qappFileSaves?.expire(); }, 30_000).unref(); +app.on('before-quit', () => { void qappFileSaves?.cleanup(); }); +app.on('web-contents-created', (_event, contents) => { + const cleanup = () => { if (isMainShellSender(contents)) void qappFileSaves?.cleanup(); }; + contents.on('render-process-gone', cleanup); + contents.on('destroyed', cleanup); + contents.on('did-start-navigation', (_e, _url, inPlace, mainFrame) => { + if (mainFrame && !inPlace) cleanup(); + }); +}); + ipcMain.handle( 'qappReticulum:request', async (event, owner: QAppReticulumOwner, options) => { @@ -4510,6 +4571,7 @@ ipcMain.handle( ); ipcMain.handle('reticulumChat:clearLocalAccountState', async () => { + await qappFileSaves?.cleanup(); setRelayAccount('', true); reticulumLocalAccountLifecycleGeneration += 1; // These managers live for the lifetime of the main process too. Clear their diff --git a/src/hooks/useQortalMessageListener.tsx b/src/hooks/useQortalMessageListener.tsx index 48c14157..3edafda1 100644 --- a/src/hooks/useQortalMessageListener.tsx +++ b/src/hooks/useQortalMessageListener.tsx @@ -31,6 +31,10 @@ import { } from '../qortal/qapp-moq-request'; import { normalizeQappIdentityContext } from '../qortal/qapp-identity'; import { dispatchQAppScreenCaptureRequest } from '../qortal/qapp-screen-capture-request'; +import { + dispatchQAppFileSaveRequest, + isQAppFileSaveAction, +} from '../qortal/qapp-file-save-request'; import { serializeQortalRequestError } from '../qortal/qortal-request-errors'; export const saveFileInChunks = async ( @@ -193,6 +197,10 @@ export function openIndexedDB() { } export const listOfAllQortalRequests = [ + 'FILE_SAVE_OPEN', + 'FILE_SAVE_WRITE', + 'FILE_SAVE_FINISH', + 'FILE_SAVE_ABORT', 'SCREEN_CAPTURE_SELECT', 'RNS_CLOSE', 'RNS_CONNECT', @@ -315,6 +323,10 @@ export const listOfAllQortalRequests = [ ]; export const UIQortalRequests = [ + 'FILE_SAVE_OPEN', + 'FILE_SAVE_WRITE', + 'FILE_SAVE_FINISH', + 'FILE_SAVE_ABORT', 'SCREEN_CAPTURE_SELECT', 'RNS_CLOSE', 'RNS_CONNECT', @@ -805,25 +817,30 @@ export const useQortalMessageListener = ( isFromExtension: message.isExtension, tabId, }; - const requestPromise = isQAppReticulumAction(message?.action) - ? dispatchQAppReticulumRequest(message.payload, requestContext) - : isQAppPrivateChannelAction(message?.action) - ? dispatchQAppPrivateChannelRequest(message.payload, requestContext) - : isQAppMoqAction(message?.action) - ? dispatchQAppMoqRequest(message.payload, requestContext) - : message?.action === 'SCREEN_CAPTURE_SELECT' - ? dispatchQAppScreenCaptureRequest(message.payload) - : window.sendMessage( - message.action, + const requestPromise = isQAppFileSaveAction(message?.action) + ? dispatchQAppFileSaveRequest(message.payload, requestContext) + : isQAppReticulumAction(message?.action) + ? dispatchQAppReticulumRequest(message.payload, requestContext) + : isQAppPrivateChannelAction(message?.action) + ? dispatchQAppPrivateChannelRequest( message.payload, - timeout, - message.isExtension, - { - name: appName, - service: appService, - tabId, - } - ); + requestContext + ) + : isQAppMoqAction(message?.action) + ? dispatchQAppMoqRequest(message.payload, requestContext) + : message?.action === 'SCREEN_CAPTURE_SELECT' + ? dispatchQAppScreenCaptureRequest(message.payload) + : window.sendMessage( + message.action, + message.payload, + timeout, + message.isExtension, + { + name: appName, + service: appService, + tabId, + } + ); // Store the promise for deduplication if (isDeduplicable) { @@ -1119,6 +1136,9 @@ export const useQortalMessageListener = ( ); }); const cleanupOwner = () => { + void api + .qappFileSave?.(owner, { action: 'FILE_SAVE_CLEANUP' }) + .catch(() => undefined); // This single main-process operation owns cleanup across Reticulum, // private channels, and MOQT. It is deliberately best-effort because a // tab reload/close can race an Electron main-process restart. diff --git a/src/i18n/locales/ar/question.json b/src/i18n/locales/ar/question.json index 07dfd06c..c24edfe8 100644 --- a/src/i18n/locales/ar/question.json +++ b/src/i18n/locales/ar/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} ساتوشي لكل كيلوبايت", "server_host": "السيرفر: {{ host }}", "server_type": "النوع: {{ type }}", + "stream_save": { + "choose": "اختيار الموقع", + "detail": "التطبيق: {{app}}\nاسم الملف المقترح: {{filename}}\nالحجم المُعلَن: {{size}} بايت\n\nهذه التفاصيل مقدمة من التطبيق. لا يستطيع Hub التحقق من المحتوى أو سلامة الملف. ينطبق هذا الإذن على عملية الحفظ هذه فقط.", + "failed": "تعذر حفظ الملف. يُرجى المحاولة مرة أخرى.", + "title": "حفظ ملف على جهازك", + "unavailable": "يتطلب حفظ الملفات تدريجيًا إصدارًا محدثًا من Qortal Hub لسطح المكتب." + }, "to_group": "ل: جروب {{ group_id }}", "to_recipient": "ل: {{ recipient }}", "total_locking_fee": "إجمالي عمولة القفل:", diff --git a/src/i18n/locales/de/question.json b/src/i18n/locales/de/question.json index 7babb15e..9dadfa17 100644 --- a/src/i18n/locales/de/question.json +++ b/src/i18n/locales/de/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} Sats pro KB", "server_host": "Host: {{ host }}", "server_type": "Typ: {{ type }}", + "stream_save": { + "choose": "Speicherort wählen", + "detail": "App: {{app}}\nVorgeschlagener Dateiname: {{filename}}\nAngegebene Größe: {{size}} Bytes\n\nDiese Angaben stammen von der App. Hub kann weder den Inhalt noch die Sicherheit der Datei prüfen. Diese Erlaubnis gilt nur für diesen Speichervorgang.", + "failed": "Die Datei konnte nicht gespeichert werden. Bitte versuche es erneut.", + "title": "Datei auf deinem Gerät speichern", + "unavailable": "Zum schrittweisen Speichern von Dateien wird eine aktuelle Desktop-Version von Qortal Hub benötigt." + }, "to_group": "an: Gruppe {{ group_id }}", "to_recipient": "an: {{ recipient }}", "total_locking_fee": "Gesamte Sperrgebühr:", diff --git a/src/i18n/locales/en/question.json b/src/i18n/locales/en/question.json index 1915412f..e922e04c 100644 --- a/src/i18n/locales/en/question.json +++ b/src/i18n/locales/en/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} sats per KB", "server_host": "host: {{ host }}", "server_type": "type: {{ type }}", + "stream_save": { + "choose": "Choose location", + "detail": "App: {{app}}\nSuggested filename: {{filename}}\nDeclared size: {{size}} bytes\n\nThese details are supplied by the app. Hub cannot verify the contents or whether the file is safe. This permission applies only to this save.", + "failed": "The file could not be saved. Please try again.", + "title": "Save a file to your device", + "unavailable": "Streaming file saves require an updated desktop version of Qortal Hub." + }, "to_group": "to: group {{ group_id }}", "to_recipient": "to: {{ recipient }}", "total_locking_fee": "total Locking Fee:", diff --git a/src/i18n/locales/es/question.json b/src/i18n/locales/es/question.json index 4fffd63b..3c96aef6 100644 --- a/src/i18n/locales/es/question.json +++ b/src/i18n/locales/es/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} sats por KB", "server_host": "host: {{ host }}", "server_type": "tipo: {{ type }}", + "stream_save": { + "choose": "Elegir ubicación", + "detail": "App: {{app}}\nNombre sugerido: {{filename}}\nTamaño declarado: {{size}} bytes\n\nLa app proporciona estos datos. Hub no puede verificar el contenido ni si el archivo es seguro. Este permiso solo se aplica a este guardado.", + "failed": "No se pudo guardar el archivo. Inténtalo de nuevo.", + "title": "Guardar un archivo en tu dispositivo", + "unavailable": "El guardado progresivo de archivos requiere una versión de escritorio actualizada de Qortal Hub." + }, "to_group": "a: grupo {{ group_id }}", "to_recipient": "a: {{ recipient }}", "total_locking_fee": "cuota total de bloqueo:", diff --git a/src/i18n/locales/et/question.json b/src/i18n/locales/et/question.json index 0eaa287a..5778a0af 100644 --- a/src/i18n/locales/et/question.json +++ b/src/i18n/locales/et/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} satsi per KB", "server_host": "host: {{ host }}", "server_type": "tüüp: {{ type }}", + "stream_save": { + "choose": "Vali asukoht", + "detail": "Rakendus: {{app}}\nSoovitatud failinimi: {{filename}}\nMärgitud suurus: {{size}} baiti\n\nNeed andmed pärinevad rakendusest. Hub ei saa kontrollida sisu ega faili ohutust. See luba kehtib ainult selle salvestamise kohta.", + "failed": "Faili ei saanud salvestada. Palun proovi uuesti.", + "title": "Faili salvestamine seadmesse", + "unavailable": "Failide järkjärguline salvestamine nõuab Qortal Hubi uuendatud töölauaversiooni." + }, "to_group": "vastuvõtja: rühm {{ group_id }}", "to_recipient": "vastuvõtja: {{ recipient }}", "total_locking_fee": "lukustustasu kokku:", diff --git a/src/i18n/locales/fi/question.json b/src/i18n/locales/fi/question.json index 39a3232a..3f586492 100644 --- a/src/i18n/locales/fi/question.json +++ b/src/i18n/locales/fi/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} sats per kt", "server_host": "isäntä: {{ host }}", "server_type": "tyyppi: {{ type }}", + "stream_save": { + "choose": "Valitse sijainti", + "detail": "Sovellus: {{app}}\nEhdotettu tiedostonimi: {{filename}}\nIlmoitettu koko: {{size}} tavua\n\nSovellus antaa nämä tiedot. Hub ei voi tarkistaa sisältöä tai tiedoston turvallisuutta. Tämä lupa koskee vain tätä tallennusta.", + "failed": "Tiedostoa ei voitu tallentaa. Yritä uudelleen.", + "title": "Tallenna tiedosto laitteellesi", + "unavailable": "Tiedostojen tallentaminen osissa edellyttää Qortal Hubin päivitettyä työpöytäversiota." + }, "to_group": "vastaanottaja: ryhmä {{ group_id }}", "to_recipient": "vastaanottaja: {{ recipient }}", "total_locking_fee": "lukitusmaksu yhteensä:", diff --git a/src/i18n/locales/fr/question.json b/src/i18n/locales/fr/question.json index eb5546ca..c6e18755 100644 --- a/src/i18n/locales/fr/question.json +++ b/src/i18n/locales/fr/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} sats par KB", "server_host": "hôte : {{ host }}", "server_type": "type : {{ type }}", + "stream_save": { + "choose": "Choisir un emplacement", + "detail": "App : {{app}}\nNom de fichier suggéré : {{filename}}\nTaille déclarée : {{size}} octets\n\nCes informations proviennent de l’app. Hub ne peut vérifier ni le contenu ni la sécurité du fichier. Cette autorisation ne vaut que pour cet enregistrement.", + "failed": "Le fichier n’a pas pu être enregistré. Veuillez réessayer.", + "title": "Enregistrer un fichier sur votre appareil", + "unavailable": "L’enregistrement progressif des fichiers nécessite une version de bureau à jour de Qortal Hub." + }, "to_group": "à : groupe {{ group_id }}", "to_recipient": "à : {{ recipient }}", "total_locking_fee": "frais de verrouillage totaux :", diff --git a/src/i18n/locales/it/question.json b/src/i18n/locales/it/question.json index bfccff0e..5baf2d71 100644 --- a/src/i18n/locales/it/question.json +++ b/src/i18n/locales/it/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} sats per KB", "server_host": "host: {{ host }}", "server_type": "tipo: {{ type }}", + "stream_save": { + "choose": "Scegli posizione", + "detail": "App: {{app}}\nNome file suggerito: {{filename}}\nDimensione dichiarata: {{size}} byte\n\nQuesti dati sono forniti dall’app. Hub non può verificare il contenuto o la sicurezza del file. Questa autorizzazione vale solo per questo salvataggio.", + "failed": "Impossibile salvare il file. Riprova.", + "title": "Salva un file sul tuo dispositivo", + "unavailable": "Il salvataggio progressivo dei file richiede una versione desktop aggiornata di Qortal Hub." + }, "to_group": "a: gruppo {{ group_id }}", "to_recipient": "a: {{ recipient }}", "total_locking_fee": "commissione totale di blocco:", diff --git a/src/i18n/locales/ja/question.json b/src/i18n/locales/ja/question.json index 21ea71e7..b5b4ba00 100644 --- a/src/i18n/locales/ja/question.json +++ b/src/i18n/locales/ja/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} sats/KB", "server_host": "ホスト:{{ host }}", "server_type": "タイプ:{{ type }}", + "stream_save": { + "choose": "保存先を選択", + "detail": "アプリ:{{app}}\n提案されたファイル名:{{filename}}\n申告サイズ:{{size}} バイト\n\nこれらの情報はアプリから提供されています。Hubは内容やファイルの安全性を確認できません。この許可は今回の保存にのみ適用されます。", + "failed": "ファイルを保存できませんでした。もう一度お試しください。", + "title": "デバイスにファイルを保存", + "unavailable": "ファイルの逐次保存には、最新のデスクトップ版Qortal Hubが必要です。" + }, "to_group": "宛先:グループ {{ group_id }}", "to_recipient": "宛先:{{ recipient }}", "total_locking_fee": "ロック手数料合計:", diff --git a/src/i18n/locales/pt/question.json b/src/i18n/locales/pt/question.json index fc34c6a5..8e29e1b3 100644 --- a/src/i18n/locales/pt/question.json +++ b/src/i18n/locales/pt/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} sats por KB", "server_host": "servidor: {{ host }}", "server_type": "tipo: {{ type }}", + "stream_save": { + "choose": "Escolher localização", + "detail": "App: {{app}}\nNome de ficheiro sugerido: {{filename}}\nTamanho declarado: {{size}} bytes\n\nEstes dados são fornecidos pela app. O Hub não pode verificar o conteúdo nem se o ficheiro é seguro. Esta permissão aplica-se apenas a esta gravação.", + "failed": "Não foi possível guardar o ficheiro. Tente novamente.", + "title": "Guardar um ficheiro no seu dispositivo", + "unavailable": "A gravação progressiva de ficheiros requer uma versão de computador atualizada do Qortal Hub." + }, "to_group": "para: grupo {{ group_id }}", "to_recipient": "para: {{ recipient }}", "total_locking_fee": "taxa de bloqueio total:", diff --git a/src/i18n/locales/ru/question.json b/src/i18n/locales/ru/question.json index 39f1bfaf..fcd5d994 100644 --- a/src/i18n/locales/ru/question.json +++ b/src/i18n/locales/ru/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} сатоши за KB", "server_host": "хост: {{ host }}", "server_type": "тип: {{ type }}", + "stream_save": { + "choose": "Выбрать место", + "detail": "Приложение: {{app}}\nПредлагаемое имя файла: {{filename}}\nЗаявленный размер: {{size}} байт\n\nЭти сведения предоставлены приложением. Hub не может проверить содержимое или безопасность файла. Разрешение действует только для этого сохранения.", + "failed": "Не удалось сохранить файл. Повторите попытку.", + "title": "Сохранить файл на устройство", + "unavailable": "Для потокового сохранения файлов нужна обновлённая настольная версия Qortal Hub." + }, "to_group": "в: группа {{ group_id }}", "to_recipient": "кому: {{ recipient }}", "total_locking_fee": "общая комиссия за блокировку:", diff --git a/src/i18n/locales/zh/question.json b/src/i18n/locales/zh/question.json index eb397b15..4eaa584f 100644 --- a/src/i18n/locales/zh/question.json +++ b/src/i18n/locales/zh/question.json @@ -219,6 +219,13 @@ "sats_per_kb": "{{ amount }} sats / KB", "server_host": "主机:{{ host }}", "server_type": "类型:{{ type }}", + "stream_save": { + "choose": "选择位置", + "detail": "应用:{{app}}\n建议的文件名:{{filename}}\n声明的大小:{{size}} 字节\n\n这些信息由应用提供。Hub 无法验证内容或文件是否安全。此权限仅适用于本次保存。", + "failed": "无法保存文件,请重试。", + "title": "将文件保存到设备", + "unavailable": "流式保存文件需要更新后的 Qortal Hub 桌面版。" + }, "to_group": "发送至群组:{{ group_id }}", "to_recipient": "发送至:{{ recipient }}", "total_locking_fee": "总锁仓费用:", diff --git a/src/qortal/qapp-file-save-request.test.ts b/src/qortal/qapp-file-save-request.test.ts new file mode 100644 index 00000000..ee043ace --- /dev/null +++ b/src/qortal/qapp-file-save-request.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + dispatchQAppFileSaveRequest, + isQAppFileSaveAction, +} from './qapp-file-save-request'; + +describe('stream save request boundary', () => { + const context = { tabId: 7, appName: 'Example', appService: 'APP' }; + const api = vi.fn(); + beforeEach(() => { + api.mockReset(); + window.electronAPI = { qappFileSave: api } as any; + }); + it('uses the trusted tab/app and replaces all iframe permission labels', async () => { + api.mockResolvedValue({ saveId: 'random', maxChunkBytes: 262144 }); + await dispatchQAppFileSaveRequest( + { + action: 'FILE_SAVE_OPEN', + filename: 'file.zip', + size: 3, + owner: { tabId: 'other' }, + labels: { title: 'Verified safe file' }, + path: '/sensitive', + }, + context + ); + const [owner, request] = api.mock.calls[0]; + expect(owner).toEqual({ tabId: '7', name: 'example', service: 'APP' }); + expect(request).not.toHaveProperty('path'); + expect(request).not.toHaveProperty('owner'); + expect(request.labels.title).not.toBe('Verified safe file'); + }); + it('preserves redacted machine-readable errors', async () => { + api.mockResolvedValue({ error: 'SAVE_CANCELLED' }); + await expect( + dispatchQAppFileSaveRequest( + { action: 'FILE_SAVE_ABORT', saveId: 'id' }, + context + ) + ).rejects.toMatchObject({ code: 'SAVE_CANCELLED' }); + }); + it('does not expose internal lifecycle cleanup as a QApp action', () => { + expect(isQAppFileSaveAction('FILE_SAVE_CLEANUP')).toBe(false); + expect(isQAppFileSaveAction('FILE_SAVE_OPEN')).toBe(true); + }); +}); diff --git a/src/qortal/qapp-file-save-request.ts b/src/qortal/qapp-file-save-request.ts new file mode 100644 index 00000000..3ac0ac80 --- /dev/null +++ b/src/qortal/qapp-file-save-request.ts @@ -0,0 +1,57 @@ +import i18n from '../i18n/i18n'; +import { normalizeQappIdentityContext } from './qapp-identity'; +import { codedQortalRequestError } from './qortal-request-errors'; + +export const isQAppFileSaveAction = (action: unknown) => + [ + 'FILE_SAVE_OPEN', + 'FILE_SAVE_WRITE', + 'FILE_SAVE_FINISH', + 'FILE_SAVE_ABORT', + ].includes(action as string); + +export async function dispatchQAppFileSaveRequest(message, context) { + const api = window.electronAPI?.qappFileSave; + if (!api) + throw codedQortalRequestError( + 'SAVE_UNAVAILABLE', + i18n.t('question:stream_save.unavailable') + ); + const identity = normalizeQappIdentityContext({ + name: context.appName, + service: context.appService, + }); + const owner = { tabId: String(context.tabId), ...identity }; + // Never accept ownership or permission labels supplied by the iframe. + const request = { + action: message.action, + filename: message.filename, + size: message.size, + saveId: message.saveId, + offset: message.offset, + data: message.data, + ...(message.action === 'FILE_SAVE_OPEN' + ? { + labels: { + title: i18n.t('question:stream_save.title'), + detail: i18n.t('question:stream_save.detail', { + app: identity.name, + filename: String(message.filename).slice(0, 200), + size: Number(message.size).toLocaleString(), + }), + allow: i18n.t('question:stream_save.choose'), + cancel: i18n.t('core:action.cancel', { + postProcess: 'capitalizeFirstChar', + }), + }, + } + : {}), + }; + const result = await api(owner, request); + if (result?.error) + throw codedQortalRequestError( + result.error, + i18n.t('question:stream_save.failed') + ); + return result; +} diff --git a/src/types/window.d.ts b/src/types/window.d.ts index a877bbba..53ce7e4a 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -384,6 +384,7 @@ declare global { ) => Promise; qappReticulumCleanupOwner?: (owner: any) => Promise; onQAppReticulumEvent?: (callback: (payload: any) => void) => () => void; + qappFileSave?: (owner: any, request: any) => Promise; privateChannelOpen?: ( owner: any, rnsConnectionId: unknown, From 14a6b89d63b2567777309ff889c53f9e2b359104 Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sat, 12 Sep 2026 14:53:15 +0300 Subject: [PATCH 27/32] add prioritized MoQ delivery and independent QUIC streams --- docs/qapp-moq-transport.md | 128 +++++++ docs/reticulum.md | 10 + .../internal/innerquic/session.go | 43 ++- .../internal/innerquic/streams.go | 69 ++++ .../internal/innerquic/streams_test.go | 101 ++++++ .../internal/innerquic/writer.go | 58 ++++ .../internal/innerquic/writer_test.go | 82 +++++ .../internal/moqclient/session.go | 66 +++- .../moqclient/session_integration_test.go | 2 +- .../internal/protocol/reliable_dispatch.go | 61 ++++ .../protocol/reliable_dispatch_test.go | 75 +++++ .../internal/protocol/server.go | 112 +++++-- .../internal/protocol/server_test.go | 40 +++ .../moqtransport/QORTAL-PATCHES.md | 12 + .../third_party/moqtransport/delivery.go | 313 ++++++++++++++++++ .../third_party/moqtransport/delivery_test.go | 170 ++++++++++ .../incoming_subscribe_request.go | 39 +++ .../moqtransport/quicmoq/connection.go | 5 + .../third_party/moqtransport/session.go | 4 +- electron/resources/presence_bridge.py | 138 +++++--- electron/resources/presence_bridge_test.py | 80 ++++- electron/src/moq-masque-transport.test.ts | 20 ++ electron/src/moq-masque-transport.ts | 6 +- electron/src/moq-transport-manager.test.ts | 37 ++- electron/src/moq-transport-manager.ts | 29 +- electron/src/preload.ts | 12 +- electron/src/private-channel-bootstrap.ts | 2 + electron/src/private-channel-manager.test.ts | 48 +++ electron/src/private-channel-manager.ts | 40 ++- electron/src/private-channel-step3.test.ts | 10 + .../src/private-transport-sidecar.test.ts | 4 +- electron/src/private-transport-sidecar.ts | 12 +- electron/src/quic-masque-transport.test.ts | 32 +- electron/src/quic-masque-transport.ts | 15 +- electron/src/setup.ts | 12 +- .../qapp-private-channel-request.test.ts | 3 +- src/qortal/qapp-private-channel-request.ts | 3 +- src/types/window.d.ts | 3 +- 38 files changed, 1787 insertions(+), 109 deletions(-) create mode 100644 electron/native/private-transport/internal/innerquic/streams.go create mode 100644 electron/native/private-transport/internal/innerquic/streams_test.go create mode 100644 electron/native/private-transport/internal/innerquic/writer.go create mode 100644 electron/native/private-transport/internal/innerquic/writer_test.go create mode 100644 electron/native/private-transport/internal/protocol/reliable_dispatch.go create mode 100644 electron/native/private-transport/internal/protocol/reliable_dispatch_test.go create mode 100644 electron/native/private-transport/third_party/moqtransport/delivery.go create mode 100644 electron/native/private-transport/third_party/moqtransport/delivery_test.go diff --git a/docs/qapp-moq-transport.md b/docs/qapp-moq-transport.md index 28b177b5..991e1f13 100644 --- a/docs/qapp-moq-transport.md +++ b/docs/qapp-moq-transport.md @@ -43,3 +43,131 @@ real QUIC-through-MASQUE connection with a verified relay egress address. Deployment requires updated native Hub code and sidecar together; strict sidecar version checking rejects stale binaries. Existing single-track apps remain supported. Backends must implement routing for their own declared track names. + +The separate private reliable channel bounds each send (including waiting for +another send) to five seconds, below Hub's seven-second sidecar request timeout. +QUIC write deadlines interrupt flow-control stalls. Failed partial frames cancel +the stream and report a recoverable transport closure; replacement connections +have independent writers. Backend authorization rejections remain rejections. +The private-channel attach exchange also observes its connection deadline. +These limits do not change MoQ datagram delivery or media buffering. + +Private bootstrap rejection `transport_already_attached` is surfaced as +`TRANSPORT_ALREADY_ATTACHED`, not a generic transport error. It is not retried +against another relay: the authenticated backend session already owns a private +transport. Apps should share their existing channel between consumers and route +responses by message ID, releasing it only when the last consumer is finished. + +## Independent private reliable streams (sidecar 0.8.0) + +`PRIVATE_CHANNEL_SEND` accepts optional `streamOptions` on reliable messages: + +```js +{ streamKey: 'opaque-stream-label', endStream: false } +``` + +Keys are 1–96 ASCII letters, digits, dots, underscores or hyphens. They are +scoped to the owner-bound private channel; Hub does not interpret their names +or payloads. Each key opens a distinct bidirectional QUIC stream under the +existing attached connection. Omitting the option preserves the legacy stream. +Datagrams cannot specify stream options. `endStream: true` writes a final frame +and FIN; the receive half remains open for final replies (at most 30 seconds). +Applications should use unique keys for finite tasks and finish them in cleanup. + +At most 32 keyed streams are live per connection. Each has an independent +five-second send deadline and a 192 KiB Hub pending-send budget, within existing +channel/owner budgets. A failed keyed write reports `RELIABLE_STREAM_FAILED` +and resets only that stream; connection failures still use relay recovery. +Streams share connection congestion control and the MASQUE path, not independent +bandwidth. A stream key does not imply a priority class or bypass authorization. + +The sidecar IPC reader dispatches reliable sends through per-session/per-key +queues, not inline. Each key preserves FIFO order while unrelated keys run +independently. Admission is capped at 64 active keys, 16 queued jobs per key and +4 MiB overall. Jobs waiting over one second fail without sending, keeping the +queue plus five-second native write inside Hub's seven-second request deadline. +Idle key workers are removed. This avoids moving stream head-of-line blocking +into the IPC layer. + +The backend advertises `reliableStreams: true` in its ATTACHED frame. Missing +support reports `RELIABLE_STREAMS_UNSUPPORTED`; it never silently multiplexes +keyed traffic onto the old stream. Deploy the backend first, then updated Hub +and sidecar together, then the QApp. The relay needs no change. + +## Paced, deadline-aware MoQ delivery (sidecar 0.9.0) + +Applications can supply generic local delivery policy on an object batch: + +```js +{ + trackName: 'opaque-track', + objects: [encryptedObject], + delivery: { priority: 0, maxQueueAgeMillis: 120 } +} +``` + +Priority is 0 (latency-sensitive), 1 (live), or 2 (background). +Queue lifetime is an integer from 10 to 2000 milliseconds. Legacy publications +default to priority 1 and 200 milliseconds. These values neither grant access +nor require Hub to interpret the payload. Backend applications assign their +own outbound policies; an untrusted publisher cannot choose recipient priority. + +Each MoQ connection has one scheduler, not one independent rate limiter per +track. It provides: + +- Weighted 8:4:1 service between priority classes; round-robin within a class. + Idle classes lend their unused service. FIFO order is preserved per track. +- A 64 KiB per-track and 256 KiB total queue cap, with 32 KiB reserved capacity + for each other class. There are at most 128 queued tracks. Limits include + encoded MoQ bytes. Changing class while a track has pending objects is rejected. +- Expiry before QUIC admission. Overload refuses new objects rather than + silently replacing arbitrary fragments of an existing object sequence. +- A byte-credit pacer, initially 1 MB/s, with at most four datagrams / 4 KiB + in one burst and no accumulated idle credit beyond that burst allowance. +- Conservative rate reduction on sustained packet loss / RTT inflation or a + blocked QUIC datagram send, with gradual recovery under demand. A stable high + RTT alone does not trigger reduction. This complements, never bypasses, + QUIC congestion control; it is not an independent bandwidth guarantee. + +Hub uses bounded, per-session/per-track IPC workers. A batch completes after +scheduled QUIC admission (not remote acknowledgement). Different publications +remain independent while one waits for pacing. Native IPC queue time counts +toward the delivery deadline. Both admission and worker queues are bounded. +A batch may partly transmit; it must never be blindly replayed. + +`MOQ_QUEUE_LIMIT` and `MOQ_OBJECT_EXPIRED` are expected pressure outcomes, not +reasons to reconnect or switch relay. Connection failures retain existing +recovery. Session close cancels queued work and releases blocked QUIC sends. + +Metrics expose `deliveryQueuedBytes`, `deliveryQueueDelayMillis`, +`deliveryPacingBytesPerSecond`, `deliverySent`, `deliveryExpired`, +`deliveryRejected`, and `deliverySendErrors`. Sent means handed to QUIC, +not received by the peer. Applications retain receiver feedback and use these +additional measurements for producer adaptation. + +### Boundaries and rollout + +The scheduler can expire data while it owns it; quic-go's public SendDatagram +API cannot retract an object already admitted to its internal queue. All tracks +still share congestion control, the outer MASQUE tunnel and physical bandwidth. +This is not global QoS across independent QUIC connections or applications. +Capture/encoding CPU starvation and receiver playout remain application concerns. + +No media keys, codecs, screen detection, or call policy were added to Hub. +No relay protocol change is needed. Deploy the backend media service, then Hub +with its matching 0.9.0 sidecar, then the QApp. An old backend remains wire +compatible but does not provide scheduled downlink forwarding. + +Tests cover priority/fairness, per-track order, reservations and memory caps, +expiry, pacing, slow-recipient isolation, shutdown, pressure without reconnect, +policy validation, and scheduled forwarding over real QUIC through MASQUE. + +### Sidecar 0.9.1 validation fix + +Queue dispatch and execution share complete parameter types for reliable sends +and MoQ publications. Both retain strict unknown-field rejection. Routing-only +parameter structs incorrectly rejected messageId/endStream and batched/delivery +before requests reached their handlers. Regression tests exercise the actual +line-plus-binary Serve path, including legacy requests and malformed fields. +Rebuild/restart Hub with the matching sidecar; no backend or relay update is +required for this validation correction. diff --git a/docs/reticulum.md b/docs/reticulum.md index 18f2e6fc..cac98f84 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -104,6 +104,16 @@ Q-App backend traffic is isolated from Hub control traffic and from its own real Each class is deterministically sharded by Q-App owner and backend destination. Once an RPC link is established, waiting for its response is callback-driven; it does not occupy a scheduler worker. The Electron manager additionally caps pending RPCs and logical connections both per Q-App owner and globally so one Q-App—or many malfunctioning owners—cannot exhaust shared bridge capacity. +Q-App stream sends use raw partial writes, not Python `BufferedWriter`: the +Reticulum raw writer can return zero while its channel window is full, which +can make buffered `flush()` spin indefinitely. Sends have a ten-second deadline +and run in a bounded pool (eight workers, 64 pending jobs), isolated from the +Q-App scheduler. A failed or stalled write detaches its physical Link and +schedules the existing reconnect flow. Every new stream gets a new write lock; +late writes and reader failures remain tied to the old Link. Queue pressure +alone does not reset a healthy Link. Failed sends return an error and release +their pending acknowledgement reservation instead of reporting success. + ### Mesh Coordinator — `reticulum-mesh.ts` Handles hub-to-hub mesh networking, separate from the TLS P2P layer (`p2p-network.ts`). diff --git a/electron/native/private-transport/internal/innerquic/session.go b/electron/native/private-transport/internal/innerquic/session.go index 813beba0..dee3764c 100644 --- a/electron/native/private-transport/internal/innerquic/session.go +++ b/electron/native/private-transport/internal/innerquic/session.go @@ -62,7 +62,10 @@ type Session struct { conn *quic.Conn stream *quic.Stream onEvent func(Event) - writeMu sync.Mutex + reliableWriter *boundedFrameWriter + streamsMu sync.Mutex + streams map[string]*reliableLane + reliableStreams bool closed atomic.Bool appSent atomic.Uint64 appReceived atomic.Uint64 @@ -80,6 +83,7 @@ type attachMetadata struct { OwnerBindingHash string `json:"ownerBindingHash"` } type attachedMetadata struct { + ReliableStreams bool `json:"reliableStreams"` OK bool `json:"ok"` LogicalSessionID string `json:"logicalSessionId"` TransportGeneration int `json:"transportGeneration"` @@ -147,6 +151,14 @@ func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error _ = tunnel.Close() return nil, fmt.Errorf("SESSION_ATTACH_FAILED: %w", err) } + // Dial's context does not bound subsequent stream reads or writes. + deadline, _ := ctx.Deadline() + _ = stream.SetDeadline(deadline) + stopAttach := context.AfterFunc(ctx, func() { + stream.CancelRead(1) + stream.CancelWrite(1) + }) + defer stopAttach() metadata, err := Metadata(attachMetadata{ProtocolVersion: ProtocolVersion, LogicalSessionID: cfg.LogicalSessionID, AttachToken: cfg.AttachToken, Nonce: cfg.Nonce, Purpose: cfg.Purpose, OwnerBindingHash: cfg.OwnerBindingHash}) if err != nil { _ = conn.CloseWithError(1, "attach failed") @@ -178,7 +190,14 @@ func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error _ = tunnel.Close() return nil, errors.New("DATAGRAM_UNSUPPORTED") } - s := &Session{tunnel: tunnel, conn: conn, stream: stream, onEvent: onEvent} + if !stopAttach() || ctx.Err() != nil { + _ = conn.CloseWithError(1, "attach timed out") + return fail(errors.New("SESSION_ATTACH_FAILED")) + } + _ = stream.SetDeadline(time.Time{}) + s := &Session{tunnel: tunnel, conn: conn, stream: stream, onEvent: onEvent, + reliableWriter: newBoundedFrameWriter(stream), reliableStreams: attached.ReliableStreams, + streams: make(map[string]*reliableLane)} go s.readReliable() go s.readDatagrams() go s.watchConnection() @@ -193,9 +212,7 @@ func (s *Session) SendReliable(messageID string, data []byte) error { return errors.New("FRAME_TOO_LARGE") } metadata, _ := Metadata(messageMetadata{MessageID: messageID}) - s.writeMu.Lock() - err := WriteFrame(s.stream, Frame{Type: FrameReliable, Metadata: metadata, Payload: data}) - s.writeMu.Unlock() + err := s.reliableWriter.write(Frame{Type: FrameReliable, Metadata: metadata, Payload: data}, 5*time.Second) if err == nil { s.appSent.Add(uint64(len(data))) } else { @@ -237,10 +254,14 @@ func (s *Session) Close() error { } func (s *Session) readReliable() { + s.readReliableStream(s.stream, true) +} + +func (s *Session) readReliableStream(stream *quic.Stream, primary bool) { for { - frame, err := ReadFrame(s.stream) + frame, err := ReadFrame(stream) if err != nil { - if !s.closed.Load() { + if primary && !s.closed.Load() { s.streamErrors.Add(1) s.emit(Event{Kind: "error", Code: "STREAM_ERROR"}) } @@ -248,13 +269,17 @@ func (s *Session) readReliable() { } if frame.Type != FrameReliable { s.streamErrors.Add(1) - s.emit(Event{Kind: "error", Code: "PROTOCOL_MISMATCH"}) + if primary { + s.emit(Event{Kind: "error", Code: "PROTOCOL_MISMATCH"}) + } return } var metadata messageMetadata if json.Unmarshal(frame.Metadata, &metadata) != nil || metadata.MessageID == "" { s.streamErrors.Add(1) - s.emit(Event{Kind: "error", Code: "PROTOCOL_MISMATCH"}) + if primary { + s.emit(Event{Kind: "error", Code: "PROTOCOL_MISMATCH"}) + } return } s.appReceived.Add(uint64(len(frame.Payload))) diff --git a/electron/native/private-transport/internal/innerquic/streams.go b/electron/native/private-transport/internal/innerquic/streams.go new file mode 100644 index 00000000..65d28a3f --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/streams.go @@ -0,0 +1,69 @@ +package innerquic + +import ( + "errors" + "regexp" + "time" + + "github.com/quic-go/quic-go" +) + +var streamKeyPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,96}$`) + +type reliableLane struct { + stream *quic.Stream + writer *boundedFrameWriter +} + +// Keys are opaque application routing labels, scoped to this attached session. +// End sends the final frame and FIN; the reply side remains alive until EOF. +func (s *Session) SendReliableStream(key, messageID string, data []byte, end bool) error { + if s.closed.Load() || s.conn.Context().Err() != nil { + return errors.New("TRANSPORT_CLOSED") + } + if !s.reliableStreams { + return errors.New("RELIABLE_STREAMS_UNSUPPORTED") + } + if !streamKeyPattern.MatchString(key) || len(messageID) == 0 || len(messageID) > 128 || len(data) > MaxReliablePayloadBytes { + return errors.New("invalid reliable stream message") + } + s.streamsMu.Lock() + lane := s.streams[key] + if lane == nil { + if len(s.streams) >= 32 { + s.streamsMu.Unlock() + return errors.New("STREAM_LIMIT_REACHED") + } + stream, err := s.conn.OpenStream() + if err != nil { + s.streamsMu.Unlock() + return errors.New("STREAM_LIMIT_REACHED") + } + lane = &reliableLane{stream: stream, writer: newBoundedFrameWriter(stream)} + s.streams[key] = lane + go func() { + s.readReliableStream(stream, false) + stream.CancelRead(1) + stream.CancelWrite(1) + s.streamsMu.Lock() + if s.streams[key] == lane { + delete(s.streams, key) + } + s.streamsMu.Unlock() + }() + } + s.streamsMu.Unlock() + metadata, _ := Metadata(messageMetadata{MessageID: messageID}) + if err := lane.writer.write(Frame{Type: FrameReliable, Metadata: metadata, Payload: data}, 5*time.Second); err != nil { + lane.stream.CancelRead(1) + lane.stream.CancelWrite(1) + return errors.New("RELIABLE_STREAM_FAILED") + } + s.appSent.Add(uint64(len(data))) + if end { + _ = lane.stream.Close() + // Bound half-closed lanes even if the backend never finishes its replies. + _ = lane.stream.SetReadDeadline(time.Now().Add(30 * time.Second)) + } + return nil +} diff --git a/electron/native/private-transport/internal/innerquic/streams_test.go b/electron/native/private-transport/internal/innerquic/streams_test.go new file mode 100644 index 00000000..101516f8 --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/streams_test.go @@ -0,0 +1,101 @@ +package innerquic + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "github.com/quic-go/quic-go" + "math/big" + "testing" + "time" +) + +func TestControlStreamProgressesWhileBulkIsFlowControlled(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + listener, err := quic.ListenAddr("127.0.0.1:0", &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, NextProtos: []string{ALPN}}, &quic.Config{InitialStreamReceiveWindow: 1024, MaxStreamReceiveWindow: 1024, InitialConnectionReceiveWindow: 1024 * 1024, MaxConnectionReceiveWindow: 1024 * 1024}) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := quic.DialAddr(ctx, listener.Addr().String(), &tls.Config{InsecureSkipVerify: true, NextProtos: []string{ALPN}}, &quic.Config{}) + if err != nil { + t.Fatal(err) + } + defer client.CloseWithError(0, "test complete") + server, err := listener.Accept(ctx) + if err != nil { + t.Fatal(err) + } + defer server.CloseWithError(0, "test complete") + events := make(chan Event, 4) + session := &Session{conn: client, reliableStreams: true, streams: map[string]*reliableLane{}, onEvent: func(e Event) { events <- e }} + bulkResult := make(chan error, 1) + go func() { + bulkResult <- session.SendReliableStream("bulk", "bulk-message", make([]byte, MaxReliablePayloadBytes), false) + }() + bulk, err := server.AcceptStream(ctx) + if err != nil { + t.Fatal(err) + } + // Deliberately don't read bulk: its 1 KiB receive window cannot fit the frame. + if err := session.SendReliableStream("control", "control-message", []byte("ping"), true); err != nil { + t.Fatal(err) + } + control, err := server.AcceptStream(ctx) + if err != nil { + t.Fatal(err) + } + if control.StreamID() == bulk.StreamID() { + t.Fatal("traffic shared a stream") + } + frame, err := ReadFrame(control) + if err != nil { + t.Fatal(err) + } + if string(frame.Payload) != "ping" { + t.Fatal("wrong control payload") + } + if err := WriteFrame(control, frame); err != nil { + t.Fatal(err) + } + _ = control.Close() + select { + case event := <-events: + if event.MessageID != "control-message" { + t.Fatalf("wrong event: %+v", event) + } + case <-ctx.Done(): + t.Fatal("control response blocked behind bulk") + } + select { + case <-bulkResult: + t.Fatal("bulk unexpectedly completed without being read") + default: + } + bulk.CancelRead(1) + select { + case err := <-bulkResult: + if err == nil { + t.Fatal("cancelled bulk write succeeded") + } + case <-ctx.Done(): + t.Fatal("cancelled write did not terminate") + } + // Stream failure must not close the authenticated QUIC connection. + if err := session.SendReliableStream("diagnostic", "after-reset", []byte("ok"), false); err != nil { + t.Fatal(err) + } +} diff --git a/electron/native/private-transport/internal/innerquic/writer.go b/electron/native/private-transport/internal/innerquic/writer.go new file mode 100644 index 00000000..d6fd50ac --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/writer.go @@ -0,0 +1,58 @@ +package innerquic + +import ( + "context" + "errors" + "io" + "time" + + "github.com/quic-go/quic-go" +) + +// ErrReliableWriteFailed means this physical stream must not be reused. A +// partial frame cannot safely be followed by another frame on the same stream. +var ErrReliableWriteFailed = errors.New("reliable stream write failed") + +type deadlineWriteStream interface { + io.Writer + SetWriteDeadline(time.Time) error + CancelWrite(quic.StreamErrorCode) +} + +type boundedFrameWriter struct { + stream deadlineWriteStream + gate chan struct{} + failed bool // protected by gate +} + +func newBoundedFrameWriter(stream deadlineWriteStream) *boundedFrameWriter { + return &boundedFrameWriter{stream: stream, gate: make(chan struct{}, 1)} +} + +func (w *boundedFrameWriter) write(frame Frame, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + select { + case w.gate <- struct{}{}: + defer func() { <-w.gate }() + case <-ctx.Done(): + return errors.Join(ErrReliableWriteFailed, ctx.Err()) + } + if w.failed { + return ErrReliableWriteFailed + } + if err := ctx.Err(); err != nil { + return errors.Join(ErrReliableWriteFailed, err) + } + deadline, _ := ctx.Deadline() + err := w.stream.SetWriteDeadline(deadline) + if err == nil { + err = WriteFrame(w.stream, frame) + } + if err != nil { + w.failed = true + w.stream.CancelWrite(1) + return errors.Join(ErrReliableWriteFailed, err) + } + return nil +} diff --git a/electron/native/private-transport/internal/innerquic/writer_test.go b/electron/native/private-transport/internal/innerquic/writer_test.go new file mode 100644 index 00000000..c7fcb749 --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/writer_test.go @@ -0,0 +1,82 @@ +package innerquic + +import ( + "bytes" + "errors" + "os" + "testing" + "time" + + "github.com/quic-go/quic-go" +) + +type testWriteStream struct { + bytes.Buffer + deadline time.Time + blocked bool + cancelled bool +} + +func (s *testWriteStream) SetWriteDeadline(d time.Time) error { s.deadline = d; return nil } +func (s *testWriteStream) CancelWrite(quic.StreamErrorCode) { s.cancelled = true } +func (s *testWriteStream) Write(p []byte) (int, error) { + if s.blocked { + time.Sleep(time.Until(s.deadline)) + return 0, os.ErrDeadlineExceeded + } + // Exercise framing across short writes, too. + if len(p) > 3 { + p = p[:3] + } + return s.Buffer.Write(p) +} + +func TestReliableWriteDeadlineCancelsAndPoisonsStream(t *testing.T) { + s := &testWriteStream{blocked: true} + w := newBoundedFrameWriter(s) + start := time.Now() + err := w.write(Frame{Type: FrameReliable}, 30*time.Millisecond) + if !errors.Is(err, ErrReliableWriteFailed) || !s.cancelled || time.Since(start) > time.Second { + t.Fatalf("write not bounded/cancelled: %v", err) + } + s.blocked = false + if err := w.write(Frame{Type: FrameReliable}, time.Second); !errors.Is(err, ErrReliableWriteFailed) || s.Len() != 0 { + t.Fatal("reused a failed stream") + } + // A replacement owns its own gate and failure state. + replacement := newBoundedFrameWriter(&testWriteStream{}) + if err := replacement.write(Frame{Type: FrameReliable}, time.Second); err != nil { + t.Fatal(err) + } +} + +func TestReliableWriteGateHasDeadline(t *testing.T) { + s := &testWriteStream{} + w := newBoundedFrameWriter(s) + w.gate <- struct{}{} + start := time.Now() + err := w.write(Frame{Type: FrameReliable}, 30*time.Millisecond) + if !errors.Is(err, ErrReliableWriteFailed) || time.Since(start) > time.Second { + t.Fatalf("lock wait not bounded: %v", err) + } + if s.cancelled || !s.deadline.IsZero() { + t.Fatal("waiting writer touched active writer's stream") + } + <-w.gate +} + +func TestReliableWriteKeepsFrameBoundaries(t *testing.T) { + s := &testWriteStream{} + w := newBoundedFrameWriter(s) + for i := 0; i < 2; i++ { + if err := w.write(Frame{Type: FrameReliable, Payload: []byte("hello")}, time.Second); err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + f, err := ReadFrame(&s.Buffer) + if err != nil || string(f.Payload) != "hello" { + t.Fatalf("invalid frame: %v", err) + } + } +} diff --git a/electron/native/private-transport/internal/moqclient/session.go b/electron/native/private-transport/internal/moqclient/session.go index 412aefdd..3cd150c0 100644 --- a/electron/native/private-transport/internal/moqclient/session.go +++ b/electron/native/private-transport/internal/moqclient/session.go @@ -66,6 +66,7 @@ type Event struct { } type Metrics struct { + moqtransport.DeliveryMetrics InnerRTTMillis int64 `json:"innerRttMillis"` ObjectsSent uint64 `json:"objectsSent"` ObjectsRead uint64 `json:"objectsReceived"` @@ -325,11 +326,55 @@ func (s *Session) PublishObject(payload []byte) error { } func (s *Session) PublishTrackObject(track string, payload []byte) error { + return s.PublishTrackObjectWithPolicy(track, payload, moqtransport.DeliveryPolicy{Priority: 1, MaxQueueAgeMillis: 200}) +} + +func (s *Session) PublishTrackObjectWithPolicy(track string, payload []byte, policy moqtransport.DeliveryPolicy) error { + return s.PublishTrackBatch(track, [][]byte{payload}, policy) +} + +func (s *Session) PublishTrackBatch(track string, objects [][]byte, policy moqtransport.DeliveryPolicy) error { + if track == "" { + track = s.defaultTrack + } + if len(objects) < 1 || len(objects) > 8 || !policy.Valid() { + return errors.New("INVALID_MOQ_CONFIG") + } + for _, payload := range objects { + if len(payload) < 1 || len(payload) > MaxObjectBytes { + return errors.New("MOQ_OBJECT_TOO_LARGE") + } + } + var pending []<-chan error + for _, payload := range objects { + completion, err := s.queueTrackObject(track, payload, policy) + if err != nil { + return err + } + pending = append(pending, completion) + } + for index, completion := range pending { + select { + case err := <-completion: + if err != nil { + s.objectErrors.Add(1) + return err + } + s.objectsSent.Add(1) + s.bytesSent.Add(uint64(len(objects[index]))) + case <-s.moq.Context().Done(): + return errors.New("MOQ_SESSION_CLOSED") + } + } + return nil +} + +func (s *Session) queueTrackObject(track string, payload []byte, policy moqtransport.DeliveryPolicy) (<-chan error, error) { if s.closed.Load() { - return errors.New("MOQ_SESSION_CLOSED") + return nil, errors.New("MOQ_SESSION_CLOSED") } if len(payload) == 0 || len(payload) > MaxObjectBytes { - return errors.New("MOQ_OBJECT_TOO_LARGE") + return nil, errors.New("MOQ_OBJECT_TOO_LARGE") } objectID := s.nextObjectID.Add(1) - 1 publication := s.publication @@ -337,20 +382,18 @@ func (s *Session) PublishTrackObject(track string, payload []byte) error { publication = s.publications[track] } if publication == nil { - return errors.New("INVALID_MOQ_CONFIG") + return nil, errors.New("INVALID_MOQ_CONFIG") } - err := publication.SendDatagram(moqtransport.Object{ + completion, err := publication.ScheduleDatagramResult(moqtransport.Object{ GroupID: 0, ObjectID: objectID, ForwardingPreference: moqtransport.ObjectForwardingPreferenceDatagram, Payload: append([]byte(nil), payload...), - }) + }, policy) if err != nil { s.objectErrors.Add(1) - return fmt.Errorf("MOQ_SEND_FAILED: %w", err) + return nil, fmt.Errorf("MOQ_SEND_FAILED: %w", err) } - s.objectsSent.Add(1) - s.bytesSent.Add(uint64(len(payload))) - return nil + return completion, nil } func (s *Session) readSubscription(subscriptionID string, entry subscription) { @@ -382,8 +425,9 @@ func (s *Session) readSubscription(subscriptionID string, entry subscription) { func (s *Session) Metrics() Metrics { stats := s.conn.ConnectionStats() return Metrics{ - InnerRTTMillis: stats.SmoothedRTT.Milliseconds(), - ObjectsSent: s.objectsSent.Load(), ObjectsRead: s.objectsRead.Load(), + DeliveryMetrics: s.moq.DeliveryMetrics(), + InnerRTTMillis: stats.SmoothedRTT.Milliseconds(), + ObjectsSent: s.objectsSent.Load(), ObjectsRead: s.objectsRead.Load(), BytesSent: s.bytesSent.Load(), BytesRead: s.bytesRead.Load(), ObjectErrors: s.objectErrors.Load(), } diff --git a/electron/native/private-transport/internal/moqclient/session_integration_test.go b/electron/native/private-transport/internal/moqclient/session_integration_test.go index eba2fa8d..3d1a2c97 100644 --- a/electron/native/private-transport/internal/moqclient/session_integration_test.go +++ b/electron/native/private-transport/internal/moqclient/session_integration_test.go @@ -145,7 +145,7 @@ func TestSessionTransportsOpaqueMOQTObjectOnlyThroughMasque(t *testing.T) { return } downstream.Accept(uint64(index + 10)) - if sendErr := downstream.SendDatagram(*object); sendErr != nil { + if sendErr := downstream.SendScheduledDatagram(*object, moqtransport.DeliveryPolicy{Priority: index % 3, MaxQueueAgeMillis: 200}); sendErr != nil { backendErrors <- sendErr return } diff --git a/electron/native/private-transport/internal/protocol/reliable_dispatch.go b/electron/native/private-transport/internal/protocol/reliable_dispatch.go new file mode 100644 index 00000000..c1872f52 --- /dev/null +++ b/electron/native/private-transport/internal/protocol/reliable_dispatch.go @@ -0,0 +1,61 @@ +package protocol + +import ( + "sync" + "time" +) + +type reliableJob struct { + bytes int + enqueued time.Time + run func(bool) +} + +// One worker per active opaque stream key preserves ordering without serializing +// unrelated streams in the IPC reader. Idle workers disappear immediately. +type reliableDispatcher struct { + mu sync.Mutex + queues map[string][]reliableJob + bytes int + workers sync.WaitGroup +} + +func (d *reliableDispatcher) submit(key string, size int, run func(bool)) bool { + d.mu.Lock() + defer d.mu.Unlock() + if d.queues == nil { + d.queues = make(map[string][]reliableJob) + } + queue, exists := d.queues[key] + if (!exists && len(d.queues) >= 64) || len(queue) >= 16 || d.bytes+size > 4*1024*1024 { + return false + } + d.bytes += size + d.queues[key] = append(queue, reliableJob{size, time.Now(), run}) + if exists { + return true + } + d.workers.Add(1) + go func() { + defer d.workers.Done() + for { + d.mu.Lock() + queue := d.queues[key] + if len(queue) == 0 { + delete(d.queues, key) + d.mu.Unlock() + return + } + job := queue[0] + d.queues[key] = queue[1:] + d.mu.Unlock() + // Native writes get five seconds; leave room inside the seven-second IPC + // deadline. Expired queued work must not send after its caller times out. + job.run(time.Since(job.enqueued) <= time.Second) + d.mu.Lock() + d.bytes -= job.bytes + d.mu.Unlock() + } + }() + return true +} diff --git a/electron/native/private-transport/internal/protocol/reliable_dispatch_test.go b/electron/native/private-transport/internal/protocol/reliable_dispatch_test.go new file mode 100644 index 00000000..6b9f6b74 --- /dev/null +++ b/electron/native/private-transport/internal/protocol/reliable_dispatch_test.go @@ -0,0 +1,75 @@ +package protocol + +import ( + "sync" + "testing" + "time" +) + +func TestReliableDispatcherIsolatesBlockedStreamsAndKeepsOrder(t *testing.T) { + var d reliableDispatcher + blocked := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(blocked) }) } + defer func() { release(); d.workers.Wait() }() + entered := make(chan struct{}) + control := make(chan struct{}) + ordered := make(chan int, 2) + if !d.submit("bulk", 64, func(bool) { close(entered); <-blocked }) { + t.Fatal("rejected bulk") + } + <-entered + d.submit("bulk", 64, func(bool) { ordered <- 1 }) + d.submit("bulk", 64, func(bool) { ordered <- 2 }) + d.submit("control", 64, func(bool) { close(control) }) + select { + case <-control: + case <-time.After(time.Second): + t.Fatal("control blocked behind bulk") + } + select { + case <-ordered: + t.Fatal("same stream reordered") + default: + } + release() + for _, expected := range []int{1, 2} { + select { + case value := <-ordered: + if value != expected { + t.Fatal("out of order") + } + case <-time.After(time.Second): + t.Fatal("queue did not drain") + } + } +} + +func TestReliableDispatcherBoundsCapacity(t *testing.T) { + var d reliableDispatcher + blocked := make(chan struct{}) + defer func() { close(blocked); d.workers.Wait() }() + if !d.submit("bulk", 4*1024*1024, func(bool) { <-blocked }) { + t.Fatal("unexpected rejection") + } + if d.submit("other", 1, func(bool) {}) { + t.Fatal("unbounded queued bytes") + } +} + +func TestReliableDispatcherDoesNotRunExpiredQueuedWrites(t *testing.T) { + var d reliableDispatcher + blocked, entered := make(chan struct{}), make(chan struct{}) + result := make(chan bool, 1) + d.submit("bulk", 1, func(bool) { close(entered); <-blocked }) + <-entered + d.submit("bulk", 1, func(ready bool) { result <- ready }) + d.mu.Lock() + d.queues["bulk"][0].enqueued = time.Now().Add(-2 * time.Second) + d.mu.Unlock() + close(blocked) + d.workers.Wait() + if <-result { + t.Fatal("expired queued write was allowed to send") + } +} diff --git a/electron/native/private-transport/internal/protocol/server.go b/electron/native/private-transport/internal/protocol/server.go index a6b27665..4b58e32c 100644 --- a/electron/native/private-transport/internal/protocol/server.go +++ b/electron/native/private-transport/internal/protocol/server.go @@ -10,6 +10,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "github.com/mengelbart/moqtransport" "io" "strings" "sync" @@ -22,13 +23,30 @@ import ( const ( Version = 2 - SidecarVersion = "0.7.0" + SidecarVersion = "0.9.1" MaxControlMessageBytes = 64 * 1024 MaxBinaryMessageBytes = innerquic.MaxReliablePayloadBytes maxRememberedRequestIDs = 4096 ) +// Dispatch and execution must decode the same complete schema. The decoder +// rejects unknown fields, so a routing-only subset rejects valid requests. +type privateSendParams struct { + SessionID string `json:"sessionId"` + MessageID string `json:"messageId"` + StreamKey string `json:"streamKey"` + EndStream bool `json:"endStream"` +} + +type moqPublishParams struct { + MoqSessionID string `json:"moqSessionId"` + TrackName string `json:"trackName"` + Batched bool `json:"batched"` + Delivery *moqtransport.DeliveryPolicy `json:"delivery"` +} + type Request struct { + receivedAt time.Time Version int `json:"version"` RequestID string `json:"requestId"` Operation string `json:"operation"` @@ -87,6 +105,10 @@ func (s *Server) Serve(ctx context.Context, input io.Reader, output io.Writer) e defer func() { cancel(); preparations.Wait(); masqueclient.CloseAllRelays() }() reader := bufio.NewReaderSize(input, 4096) s.emit = func(event Event, binary []byte) { s.write(output, event, binary) } + var reliableSends reliableDispatcher + var moqSends reliableDispatcher + defer moqSends.workers.Wait() + defer reliableSends.workers.Wait() defer s.Close() for { line, err := readControlLine(reader) @@ -114,6 +136,43 @@ func (s *Server) Serve(ctx context.Context, input io.Reader, output io.Writer) e s.write(output, failure(req.RequestID, "MALFORMED_BINARY_FRAME", "truncated binary frame"), nil) return nil } + req.receivedAt = time.Now() + if req.Operation == "publishMoqObject" { + var p moqPublishParams + if decodeParams(req.Params, &p) != nil || len(p.MoqSessionID) > 128 || len(p.TrackName) > 128 { + s.write(output, failure(req.RequestID, "INVALID_PARAMS", "invalid publication params"), nil) + continue + } + if !moqSends.submit(p.MoqSessionID+"\x00"+p.TrackName, len(binary), func(ready bool) { + if !ready { + s.write(output, failure(req.RequestID, "MOQ_OBJECT_EXPIRED", "publication queue timed out"), nil) + return + } + response, _ := s.handleRequest(ctx, req, binary) + s.write(output, response, nil) + }) { + s.write(output, failure(req.RequestID, "MOQ_QUEUE_LIMIT", "publication capacity reached"), nil) + } + continue + } + if req.Operation == "sendPrivateReliable" { + var p privateSendParams + if decodeParams(req.Params, &p) != nil || len(p.SessionID) > 128 || len(p.StreamKey) > 96 { + s.write(output, failure(req.RequestID, "INVALID_PARAMS", "invalid stream params"), nil) + continue + } + if !reliableSends.submit(p.SessionID+"\x00"+p.StreamKey, len(binary), func(ready bool) { + if !ready { + s.write(output, failure(req.RequestID, "RELIABLE_STREAM_FAILED", "stream queue timed out"), nil) + return + } + response, _ := s.handleRequest(ctx, req, binary) + s.write(output, response, nil) + }) { + s.write(output, failure(req.RequestID, "STREAM_LIMIT_REACHED", "stream send capacity reached"), nil) + } + continue + } if req.Operation == "prepareRelay" || req.Operation == "authorizeRelay" { select { case slots <- struct{}{}: @@ -308,6 +367,7 @@ func moqErrorCode(err error) string { return "BACKEND_IDENTITY_MISMATCH" } for _, code := range []string{ + "MOQ_QUEUE_LIMIT", "MOQ_OBJECT_EXPIRED", "INVALID_MOQ_CONFIG", "MOQ_QUIC_FAILED", "MOQ_SESSION_FAILED", "MOQ_ATTACH_FAILED", "DATAGRAM_UNSUPPORTED", "INVALID_MOQ_SUBSCRIPTION", "MOQ_SUBSCRIBE_FAILED", "MOQ_SESSION_CLOSED", "MOQ_SUBSCRIPTION_LIMIT", @@ -356,11 +416,7 @@ func (s *Server) subscribeMoqTrack(req Request) Response { } func (s *Server) publishMoqObject(req Request, binary []byte) Response { - var p struct { - MoqSessionID string `json:"moqSessionId"` - TrackName string `json:"trackName"` - Batched bool `json:"batched"` - } + var p moqPublishParams if decodeParams(req.Params, &p) != nil { return failure(req.RequestID, "INVALID_PARAMS", "invalid params") } @@ -376,16 +432,21 @@ func (s *Server) publishMoqObject(req Request, binary []byte) Response { return failure(req.RequestID, "INVALID_PARAMS", "invalid object batch") } } - for _, object := range objects { - var err error - if p.TrackName == "" { - err = session.PublishObject(object) - } else { - err = session.PublishTrackObject(p.TrackName, object) - } - if err != nil { - return failure(req.RequestID, moqErrorCode(err), "MOQT object publish failed") - } + policy := moqtransport.DeliveryPolicy{Priority: 1, MaxQueueAgeMillis: 200} + if p.Delivery != nil { + policy = *p.Delivery + } + if !policy.Valid() { + return failure(req.RequestID, "INVALID_MOQ_CONFIG", "invalid delivery policy") + } + if !req.receivedAt.IsZero() { + policy.MaxQueueAgeMillis -= int(time.Since(req.receivedAt).Milliseconds()) + } + if policy.MaxQueueAgeMillis < 10 { + return failure(req.RequestID, "MOQ_OBJECT_EXPIRED", "publication deadline exceeded") + } + if err := session.PublishTrackBatch(p.TrackName, objects, policy); err != nil { + return failure(req.RequestID, moqErrorCode(err), "MOQT object publish failed") } return success(req.RequestID, map[string]interface{}{ "accepted": true, "bytesSent": len(binary), @@ -510,10 +571,7 @@ func (s *Server) emitInner(sessionID string, e innerquic.Event) { s.emit(Event{Version: Version, Type: "event", Event: e.Kind, SessionID: sessionID, MessageID: e.MessageID, Code: e.Code, BinaryLength: len(e.Data)}, e.Data) } func (s *Server) sendPrivate(req Request, binary []byte, reliable bool) Response { - var p struct { - SessionID string `json:"sessionId"` - MessageID string `json:"messageId"` - } + var p privateSendParams if decodeParams(req.Params, &p) != nil { return failure(req.RequestID, "INVALID_PARAMS", "invalid params") } @@ -526,12 +584,24 @@ func (s *Server) sendPrivate(req Request, binary []byte, reliable bool) Response } var err error if reliable { - err = session.SendReliable(p.MessageID, binary) + if p.StreamKey != "" { + err = session.SendReliableStream(p.StreamKey, p.MessageID, binary, p.EndStream) + } else { + err = session.SendReliable(p.MessageID, binary) + } } else { err = session.SendDatagram(p.MessageID, binary) } if err != nil { code := "TRANSPORT_SEND_FAILED" + for _, streamCode := range []string{"STREAM_LIMIT_REACHED", "RELIABLE_STREAM_FAILED", "RELIABLE_STREAMS_UNSUPPORTED", "TRANSPORT_CLOSED"} { + if err.Error() == streamCode { + code = streamCode + } + } + if errors.Is(err, innerquic.ErrReliableWriteFailed) { + code = "TRANSPORT_CLOSED" + } if strings.Contains(err.Error(), "large") || strings.Contains(err.Error(), "invalid") { code = "FRAME_TOO_LARGE" } diff --git a/electron/native/private-transport/internal/protocol/server_test.go b/electron/native/private-transport/internal/protocol/server_test.go index 2ab67a30..35091f03 100644 --- a/electron/native/private-transport/internal/protocol/server_test.go +++ b/electron/native/private-transport/internal/protocol/server_test.go @@ -9,6 +9,46 @@ import ( "testing" ) +func TestServeDispatchUsesCompleteSendSchemas(t *testing.T) { + tests := []struct{ name, operation, params, want string }{ + {"legacy reliable", "sendPrivateReliable", `{"sessionId":"missing","messageId":"request-1"}`, "TRANSPORT_CLOSED"}, + {"keyed reliable", "sendPrivateReliable", `{"sessionId":"missing","messageId":"request-1","streamKey":"file-control","endStream":false}`, "TRANSPORT_CLOSED"}, + {"final reliable", "sendPrivateReliable", `{"sessionId":"missing","messageId":"request-1","streamKey":"upload-1","endStream":true}`, "TRANSPORT_CLOSED"}, + {"legacy moq", "publishMoqObject", `{"moqSessionId":"missing"}`, "MOQ_SESSION_CLOSED"}, + {"batch moq", "publishMoqObject", `{"moqSessionId":"missing","trackName":"opaque","batched":true}`, "MOQ_SESSION_CLOSED"}, + {"scheduled moq", "publishMoqObject", `{"moqSessionId":"missing","trackName":"opaque","batched":true,"delivery":{"priority":0,"maxQueueAgeMillis":120}}`, "MOQ_SESSION_CLOSED"}, + {"unknown reliable field", "sendPrivateReliable", `{"sessionId":"missing","messageId":"request-1","typo":true}`, "INVALID_PARAMS"}, + {"unknown moq field", "publishMoqObject", `{"moqSessionId":"missing","trackName":"opaque","typo":true}`, "INVALID_PARAMS"}, + {"unknown delivery field", "publishMoqObject", `{"moqSessionId":"missing","delivery":{"priority":0,"maxQueueAgeMillis":120,"typo":true}}`, "INVALID_PARAMS"}, + {"wrong reliable type", "sendPrivateReliable", `{"sessionId":"missing","messageId":"request-1","endStream":"true"}`, "INVALID_PARAMS"}, + {"wrong moq type", "publishMoqObject", `{"moqSessionId":"missing","batched":"true"}`, "INVALID_PARAMS"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Use the actual line+binary IPC reader, not Handle(), which bypasses the + // dispatcher. Missing sessions prove valid requests reached execution while + // keeping this test independent of network availability and credentials. + req := Request{Version: Version, RequestID: "test", Operation: tt.operation, Params: json.RawMessage(tt.params), BinaryLength: 3} + line, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + input := append(append(line, '\n'), 0, 1, 42) + var output bytes.Buffer + if err := NewServer().Serve(context.Background(), bytes.NewReader(input), &output); err != nil { + t.Fatal(err) + } + var response Response + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &response); err != nil { + t.Fatal(err, string(output.Bytes())) + } + if response.Error == nil || response.Error.Code != tt.want { + t.Fatalf("got %#v; want %s", response, tt.want) + } + }) + } +} + func TestMalformedAndUnsupportedRequestsFailSafely(t *testing.T) { tests := []struct { name string diff --git a/electron/native/private-transport/third_party/moqtransport/QORTAL-PATCHES.md b/electron/native/private-transport/third_party/moqtransport/QORTAL-PATCHES.md index 5ce3038a..3af75797 100644 --- a/electron/native/private-transport/third_party/moqtransport/QORTAL-PATCHES.md +++ b/electron/native/private-transport/third_party/moqtransport/QORTAL-PATCHES.md @@ -21,3 +21,15 @@ native sidecar when a remote peer disconnects during authorization. Run the parent module tests and this module's tests when updating the pin. This patch does not complete the library's other unfinished APIs or establish interoperability with another implementation. + +Generic delivery scheduling: delivery.go adds local deadline/priority policy, +bounded per-session/per-track queues, reserved class capacity, weighted fair +service, pacing and pressure metrics. ScheduleDatagram is nonblocking; +SendScheduledDatagram / ScheduleDatagramResult provide producer backpressure. +The QUIC adapter supplies public connection stats, without modifying quic-go. +Scheduled publisher priority uses the existing draft-18 wire field; payloads +are opaque and no application track names appear in this library. + +Keep this patch byte-identical in Hub and the backend's vendored module. +Run delivery_test.go (including the race detector) and the parent real-MASQUE +integration tests. No claim of cross-implementation MoQ compatibility is added. diff --git a/electron/native/private-transport/third_party/moqtransport/delivery.go b/electron/native/private-transport/third_party/moqtransport/delivery.go new file mode 100644 index 00000000..99dc4c86 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/delivery.go @@ -0,0 +1,313 @@ +package moqtransport + +import ( + "context" + "errors" + "sync" + "time" +) + +// DeliveryPolicy is local scheduling policy, not authorization or payload metadata. +// Priority 0 is latency-sensitive, 1 is live, 2 is background. Lower classes +// receive a reserved share even when a higher class is continuously busy. +type DeliveryPolicy struct { + Priority int `json:"priority"` + MaxQueueAgeMillis int `json:"maxQueueAgeMillis"` +} + +func (p DeliveryPolicy) Valid() bool { + return p.Priority >= 0 && p.Priority <= 2 && p.MaxQueueAgeMillis >= 10 && p.MaxQueueAgeMillis <= 2000 +} + +var ErrDeliveryQueueFull = errors.New("MOQ_QUEUE_LIMIT") +var ErrDeliveryPolicy = errors.New("INVALID_MOQ_CONFIG") +var ErrDeliveryExpired = errors.New("MOQ_OBJECT_EXPIRED") + +type DeliveryMetrics struct { + QueuedBytes int `json:"deliveryQueuedBytes"` + Sent uint64 `json:"deliverySent"` + Expired uint64 `json:"deliveryExpired"` + Rejected uint64 `json:"deliveryRejected"` + SendErrors uint64 `json:"deliverySendErrors"` + QueueDelayMillis int64 `json:"deliveryQueueDelayMillis"` + PacingBytesPerSecond int64 `json:"deliveryPacingBytesPerSecond"` +} +type DeliveryNetworkStats struct { + RTT, MinRTT time.Duration + PacketsSent, PacketsLost uint64 +} +type deliveryNetwork interface{ DeliveryNetworkStats() DeliveryNetworkStats } +type deliveryItem struct { + result chan error + data []byte + queued, deadline time.Time +} +type deliveryLane struct { + key uint64 + priority int + items []deliveryItem + bytes int +} +type deliveryScheduler struct { + mu sync.Mutex + ctx context.Context + send func([]byte) error + network func() DeliveryNetworkStats + lanes []*deliveryLane + cursor [3]int + slot int + metrics DeliveryMetrics + wake chan struct{} + rate float64 + tokens float64 + lastTick, lastSample time.Time + previous DeliveryNetworkStats + bad int + lastQueueSample time.Time +} + +const deliveryMaxTrackBytes = 64 * 1024 +const deliveryMaxSessionBytes = 256 * 1024 + +// Weighted fair service, with round-robin within each class. +var deliverySlots = [...]int{0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 2} + +func newDeliveryScheduler(ctx context.Context, send func([]byte) error, network func() DeliveryNetworkStats) *deliveryScheduler { + return &deliveryScheduler{ctx: ctx, send: send, network: network, wake: make(chan struct{}, 1), + rate: 1_000_000, lastTick: time.Now(), lastSample: time.Now()} +} +func (d *deliveryScheduler) enqueue(key uint64, data []byte, policy DeliveryPolicy, result ...chan error) error { + if !policy.Valid() { + return ErrDeliveryPolicy + } + d.mu.Lock() + defer d.mu.Unlock() + if err := d.ctx.Err(); err != nil { + return err + } + now := time.Now() + d.expire(now) + var lane *deliveryLane + for _, l := range d.lanes { + if l.key == key { + lane = l + break + } + } + if lane == nil { + if len(d.lanes) >= 128 { + d.metrics.Rejected++ + return ErrDeliveryQueueFull + } + lane = &deliveryLane{key: key, priority: policy.Priority} + d.lanes = append(d.lanes, lane) + } + // Do not silently discard dependency fragments to make room for later ones. + if len(lane.items) > 0 && lane.priority != policy.Priority { + return ErrDeliveryPolicy + } + // Refuse overload immediately so the producer can reduce its rate. + var classBytes [3]int + for _, l := range d.lanes { + classBytes[l.priority] += l.bytes + } + reserve := 0 + for class, bytes := range classBytes { + if class != policy.Priority { + reserve += max(0, 32*1024-bytes) + } + } + if lane.bytes+len(data) > deliveryMaxTrackBytes || d.metrics.QueuedBytes+len(data) > deliveryMaxSessionBytes-reserve { + d.metrics.Rejected++ + return ErrDeliveryQueueFull + } + lane.priority = policy.Priority + var completion chan error + if len(result) > 0 { + completion = result[0] + } + lane.items = append(lane.items, deliveryItem{completion, append([]byte(nil), data...), now, now.Add(time.Duration(policy.MaxQueueAgeMillis) * time.Millisecond)}) + lane.bytes += len(data) + d.metrics.QueuedBytes += len(data) + select { + case d.wake <- struct{}{}: + default: + } + return nil +} +func (d *deliveryScheduler) expire(now time.Time) { + for i := 0; i < len(d.lanes); { + l := d.lanes[i] + kept := l.items[:0] + for _, item := range l.items { + if !now.Before(item.deadline) { + l.bytes -= len(item.data) + d.metrics.QueuedBytes -= len(item.data) + d.metrics.Expired++ + if item.result != nil { + item.result <- ErrDeliveryExpired + } + } else { + kept = append(kept, item) + } + } + clear(l.items[len(kept):]) + l.items = kept + if len(l.items) == 0 { + d.lanes = append(d.lanes[:i], d.lanes[i+1:]...) + continue + } + i++ + } +} + +// Caller holds mu. Each turn transmits at most one object from one track. +func (d *deliveryScheduler) take(now time.Time) (deliveryItem, bool) { + d.expire(now) + if len(d.lanes) == 0 { + return deliveryItem{}, false + } + for n := 0; n < len(deliverySlots); n++ { + class := deliverySlots[d.slot] + d.slot = (d.slot + 1) % len(deliverySlots) + for i := 0; i < len(d.lanes); i++ { + index := (d.cursor[class] + i) % len(d.lanes) + l := d.lanes[index] + if l.priority != class || len(l.items) == 0 { + continue + } + item := l.items[0] + if float64(len(item.data)) > d.tokens { + continue + } + d.cursor[class] = (index + 1) % len(d.lanes) + l.items[0] = deliveryItem{} + l.items = l.items[1:] + l.bytes -= len(item.data) + d.metrics.QueuedBytes -= len(item.data) + d.tokens -= float64(len(item.data)) + d.metrics.QueueDelayMillis = now.Sub(item.queued).Milliseconds() + d.lastQueueSample = now + return item, true + } + } + return deliveryItem{}, false +} +func (d *deliveryScheduler) sample(now time.Time) { + if now.Sub(d.lastSample) < time.Second { + return + } + if d.network != nil { + s := d.network() + sent := uint64(0) + lost := uint64(0) + if s.PacketsSent >= d.previous.PacketsSent { + sent = s.PacketsSent - d.previous.PacketsSent + } + if s.PacketsLost >= d.previous.PacketsLost { + lost = s.PacketsLost - d.previous.PacketsLost + } + congested := sent >= 20 && (float64(lost)/float64(sent) > 0.03 || (s.MinRTT > 0 && s.RTT-s.MinRTT > max(20*time.Millisecond, s.MinRTT/4))) + if congested { + d.bad++ + } else { + d.bad = 0 + } + if d.bad >= 2 { + d.rate = max(32_000, d.rate*0.85) + } else if !congested && d.metrics.QueuedBytes > 0 { + d.rate = min(20_000_000, d.rate*1.05) + } + d.previous = s + } + d.lastSample = now +} +func (d *deliveryScheduler) run() { + timer := time.NewTimer(time.Hour) + timer.Stop() + defer timer.Stop() + defer func() { d.mu.Lock(); d.lanes = nil; d.metrics.QueuedBytes = 0; d.mu.Unlock() }() + for { + d.mu.Lock() + pending := d.metrics.QueuedBytes > 0 + d.mu.Unlock() + var tick <-chan time.Time + if pending { + timer.Reset(2 * time.Millisecond) + tick = timer.C + } + select { + case <-d.ctx.Done(): + return + case <-d.wake: + case <-tick: + } + timer.Stop() + now := time.Now() + d.mu.Lock() + d.sample(now) + // No idle credit or unbounded catch-up bursts after an event-loop stall. + d.tokens = min(4096, d.tokens+now.Sub(d.lastTick).Seconds()*d.rate) + d.lastTick = now + d.mu.Unlock() + for burst := 0; burst < 4; burst++ { + d.mu.Lock() + item, ok := d.take(time.Now()) + d.mu.Unlock() + if !ok { + break + } + if d.ctx.Err() != nil { + return + } + start := time.Now() + err := d.send(item.data) + if item.result != nil { + item.result <- err + } + elapsed := time.Since(start) + d.mu.Lock() + if err != nil { + d.metrics.SendErrors++ + } else { + d.metrics.Sent++ + } + // SendDatagram can block at the QUIC queue. Back off instead of filling it + // again at the original producer rate. QUIC remains the congestion authority. + if elapsed > 5*time.Millisecond { + d.rate = max(32_000, min(d.rate, float64(len(item.data))/elapsed.Seconds()*0.9)) + d.tokens = 0 + } + d.mu.Unlock() + } + } +} +func (d *deliveryScheduler) snapshot() DeliveryMetrics { + d.mu.Lock() + defer d.mu.Unlock() + m := d.metrics + m.PacingBytesPerSecond = int64(d.rate) + // Report current queue age too, including periods when sending is stalled. + for _, l := range d.lanes { + if len(l.items) > 0 { + m.QueueDelayMillis = max(m.QueueDelayMillis, time.Since(l.items[0].queued).Milliseconds()) + } + } + if m.QueuedBytes == 0 && time.Since(d.lastQueueSample) > time.Second { + m.QueueDelayMillis = 0 + } + return m +} +func (s *Session) scheduler() *deliveryScheduler { + s.deliveryOnce.Do(func() { + var network func() DeliveryNetworkStats + if c, ok := s.conn.(deliveryNetwork); ok { + network = c.DeliveryNetworkStats + } + s.delivery = newDeliveryScheduler(s.ctx, s.conn.SendDatagram, network) + // Context/connection closure terminates both the worker and a blocked send. + _ = s.goTracked(s.delivery.run) + }) + return s.delivery +} +func (s *Session) DeliveryMetrics() DeliveryMetrics { return s.scheduler().snapshot() } diff --git a/electron/native/private-transport/third_party/moqtransport/delivery_test.go b/electron/native/private-transport/third_party/moqtransport/delivery_test.go new file mode 100644 index 00000000..a96dc218 --- /dev/null +++ b/electron/native/private-transport/third_party/moqtransport/delivery_test.go @@ -0,0 +1,170 @@ +package moqtransport + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestDeliveryFairPriorityAndOrder(t *testing.T) { + d := newDeliveryScheduler(context.Background(), nil, nil) + // Multiple opaque tracks, not application-specific labels. + for i := 0; i < 40; i++ { + for key := uint64(1); key <= 4; key++ { + p := DeliveryPolicy{Priority: 0, MaxQueueAgeMillis: 1000} + if key == 3 { + p.Priority = 1 + } + if key == 4 { + p.Priority = 2 + } + if err := d.enqueue(key, []byte{byte(key), byte(i)}, p); err != nil { + t.Fatal(err) + } + } + } + d.tokens = 100000 + count := map[byte]int{} + for i := 0; i < 26; i++ { + item, ok := d.take(time.Now()) + if !ok { + t.Fatal("missing item") + } + if int(item.data[1]) != count[item.data[0]] { + t.Fatal("per-track order changed") + } + count[item.data[0]]++ + } + if count[1] != 8 || count[2] != 8 || count[3] != 8 || count[4] != 2 { + t.Fatalf("unfair service: %v", count) + } +} +func TestDeliveryBoundsExpiryAndReuse(t *testing.T) { + d := newDeliveryScheduler(context.Background(), nil, nil) + p := DeliveryPolicy{Priority: 1, MaxQueueAgeMillis: 10} + done := make(chan error, 1) + if err := d.enqueue(1, make([]byte, deliveryMaxTrackBytes), p, done); err != nil { + t.Fatal(err) + } + if !errors.Is(d.enqueue(1, []byte{1}, p), ErrDeliveryQueueFull) { + t.Fatal("track limit bypassed") + } + // Another track retains independent capacity. + if err := d.enqueue(2, []byte{2}, p); err != nil { + t.Fatal(err) + } + d.expire(time.Now().Add(time.Second)) + if !errors.Is(<-done, ErrDeliveryExpired) { + t.Fatal("expiry not reported") + } + if d.metrics.QueuedBytes != 0 || len(d.lanes) != 0 || d.metrics.Expired != 2 { + t.Fatal(d.metrics) + } + for i := 0; i < 300; i++ { + if err := d.enqueue(uint64(i), []byte{1}, p); err != nil { + t.Fatal(err) + } + d.expire(time.Now().Add(time.Second)) + } + if err := d.enqueue(1, []byte{1}, DeliveryPolicy{Priority: -1, MaxQueueAgeMillis: 10}); !errors.Is(err, ErrDeliveryPolicy) { + t.Fatal(err) + } +} +func TestDeliverySessionCap(t *testing.T) { + d := newDeliveryScheduler(context.Background(), nil, nil) + p := DeliveryPolicy{Priority: 1, MaxQueueAgeMillis: 1000} + for i := 0; i < 3; i++ { + if err := d.enqueue(uint64(i), make([]byte, deliveryMaxTrackBytes), p); err != nil { + t.Fatal(err) + } + } + if !errors.Is(d.enqueue(5, []byte{1}, p), ErrDeliveryQueueFull) { + t.Fatal("session limit bypassed") + } + if err := d.enqueue(6, make([]byte, 32*1024), DeliveryPolicy{Priority: 0, MaxQueueAgeMillis: 1000}); err != nil { + t.Fatal("urgent reserve consumed", err) + } + if err := d.enqueue(7, make([]byte, 32*1024), DeliveryPolicy{Priority: 2, MaxQueueAgeMillis: 1000}); err != nil { + t.Fatal("background reserve consumed", err) + } +} +func TestDeliveryPacingDoesNotAccumulateIdleBurst(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + sent := make(chan time.Time, 32) + d := newDeliveryScheduler(ctx, func([]byte) error { sent <- time.Now(); return nil }, nil) + d.rate = 10000 + for i := 0; i < 8; i++ { + if err := d.enqueue(1, make([]byte, 1000), DeliveryPolicy{Priority: 1, MaxQueueAgeMillis: 2000}); err != nil { + t.Fatal(err) + } + } + go d.run() + start := time.Now() + select { + case <-sent: + if time.Since(start) < 60*time.Millisecond { + t.Fatal("unpaced burst") + } + case <-time.After(time.Second): + t.Fatal("no progress") + } + cancel() +} +func TestDeliveryCongestionAndRecovery(t *testing.T) { + var stats DeliveryNetworkStats + d := newDeliveryScheduler(context.Background(), nil, func() DeliveryNetworkStats { return stats }) + now := time.Now() + for i := 1; i <= 3; i++ { + stats = DeliveryNetworkStats{RTT: 100 * time.Millisecond, MinRTT: 30 * time.Millisecond, PacketsSent: uint64(i * 100), PacketsLost: uint64(i * 10)} + d.sample(now.Add(time.Duration(i) * time.Second)) + } + if d.rate >= 1_000_000 { + t.Fatal("no congestion response") + } + low := d.rate + d.metrics.QueuedBytes = 1000 + stats = DeliveryNetworkStats{RTT: 30 * time.Millisecond, MinRTT: 30 * time.Millisecond, PacketsSent: 400, PacketsLost: 30} + d.sample(now.Add(4 * time.Second)) + if d.rate <= low || d.rate > low*1.051 { + t.Fatal("unsafe recovery", d.rate) + } +} +func TestDeliverySlowRecipientIsolationAndClose(t *testing.T) { + slowCtx, stop := context.WithCancel(context.Background()) + entered := make(chan struct{}) + slow := newDeliveryScheduler(slowCtx, func([]byte) error { close(entered); <-slowCtx.Done(); return slowCtx.Err() }, nil) + fastCtx, closeFast := context.WithCancel(context.Background()) + defer closeFast() + fast := newDeliveryScheduler(fastCtx, func([]byte) error { return nil }, nil) + slowDone := make(chan error, 1) + fastDone := make(chan error, 1) + p := DeliveryPolicy{Priority: 0, MaxQueueAgeMillis: 100} + _ = slow.enqueue(1, []byte{1}, p, slowDone) + _ = fast.enqueue(1, []byte{1}, p, fastDone) + go slow.run() + go fast.run() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("slow not started") + } + select { + case err := <-fastDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("slow recipient blocked fast one") + } + stop() + select { + case <-slowDone: + case <-time.After(time.Second): + t.Fatal("close did not release send") + } + if !errors.Is(slow.enqueue(1, []byte{1}, p), context.Canceled) { + t.Fatal("accepted after close") + } +} diff --git a/electron/native/private-transport/third_party/moqtransport/incoming_subscribe_request.go b/electron/native/private-transport/third_party/moqtransport/incoming_subscribe_request.go index 56ad1964..332f7fb1 100644 --- a/electron/native/private-transport/third_party/moqtransport/incoming_subscribe_request.go +++ b/electron/native/private-transport/third_party/moqtransport/incoming_subscribe_request.go @@ -82,6 +82,38 @@ func (r *IncomingSubscribeRequest) Reject(code RequestErrorCode, reason string) } func (r *IncomingSubscribeRequest) SendDatagram(o Object) error { + return r.sendDatagram(o, nil) +} + +// ScheduleDatagram admits an unreliable object; success is not a delivery ACK. +func (r *IncomingSubscribeRequest) ScheduleDatagram(o Object, policy DeliveryPolicy) error { + return r.sendDatagram(o, &policy) +} + +// SendScheduledDatagram adds producer backpressure without blocking other tracks. +// The result acknowledges QUIC admission, not reception by the peer. +func (r *IncomingSubscribeRequest) SendScheduledDatagram(o Object, policy DeliveryPolicy) error { + result, err := r.ScheduleDatagramResult(o, policy) + if err != nil { + return err + } + select { + case err := <-result: + return err + case <-r.session.ctx.Done(): + return r.session.ctx.Err() + } +} + +func (r *IncomingSubscribeRequest) ScheduleDatagramResult(o Object, policy DeliveryPolicy) (<-chan error, error) { + result := make(chan error, 1) + if err := r.sendDatagram(o, &policy, result); err != nil { + return nil, err + } + return result, nil +} + +func (r *IncomingSubscribeRequest) sendDatagram(o Object, policy *DeliveryPolicy, result ...chan error) error { // A datagram carries one complete object. Do not emulate unreliable // delivery with a stream or retry a rejected send. if err := r.session.ctx.Err(); err != nil { @@ -98,6 +130,13 @@ func (r *IncomingSubscribeRequest) SendDatagram(o Object) error { ObjectPayload: o.Payload, } message.SetZeroObjectID(o.ObjectID == 0) + if policy != nil { + if !policy.Valid() { + return ErrDeliveryPolicy + } + message.PublisherPriority = uint8(policy.Priority * 64) + return r.session.scheduler().enqueue(r.trackAlias, message.AppendDatagram(nil), *policy, result...) + } return r.session.conn.SendDatagram(message.AppendDatagram(nil)) } diff --git a/electron/native/private-transport/third_party/moqtransport/quicmoq/connection.go b/electron/native/private-transport/third_party/moqtransport/quicmoq/connection.go index bc2d4e6b..63e12546 100644 --- a/electron/native/private-transport/third_party/moqtransport/quicmoq/connection.go +++ b/electron/native/private-transport/third_party/moqtransport/quicmoq/connection.go @@ -88,6 +88,11 @@ func (c *connection) SendDatagram(b []byte) error { return c.connection.SendDatagram(b) } +func (c *connection) DeliveryNetworkStats() moqtransport.DeliveryNetworkStats { + s := c.connection.ConnectionStats() + return moqtransport.DeliveryNetworkStats{RTT: s.SmoothedRTT, MinRTT: s.MinRTT, PacketsSent: s.PacketsSent, PacketsLost: s.PacketsLost} +} + func (c *connection) ReceiveDatagram(ctx context.Context) ([]byte, error) { return c.connection.ReceiveDatagram(ctx) } diff --git a/electron/native/private-transport/third_party/moqtransport/session.go b/electron/native/private-transport/third_party/moqtransport/session.go index faf3491d..bc487be4 100644 --- a/electron/native/private-transport/third_party/moqtransport/session.go +++ b/electron/native/private-transport/third_party/moqtransport/session.go @@ -38,7 +38,9 @@ func WithHandler(handler Handler) Option { // A Session is an endpoint of a MoQ Session session. type Session struct { - logger *slog.Logger + deliveryOnce sync.Once + delivery *deliveryScheduler + logger *slog.Logger ctx context.Context cancelCtx context.CancelCauseFunc diff --git a/electron/resources/presence_bridge.py b/electron/resources/presence_bridge.py index 85c968fd..162b175a 100644 --- a/electron/resources/presence_bridge.py +++ b/electron/resources/presence_bridge.py @@ -39,6 +39,7 @@ pass import RNS +from RNS.Buffer import RawChannelWriter from RNS.vendor import umsgpack _BRIDGE_RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -1170,6 +1171,9 @@ def stop(self, timeout_seconds: float = 5.0) -> None: _qapp_close_coordinator_slots = threading.BoundedSemaphore( _QAPP_CLOSE_WRITE_MAX_PENDING ) +_qapp_stream_write_dispatcher = _BoundedKeyedSendDispatcher( + "qapp-stream", 8, 64, 0.25 +) _rns_overlay_callback_pending_keys: set[str] = set() _rns_packet_dispatcher_missing_logged = False _latest_land_state_lock = threading.Lock() @@ -2241,6 +2245,7 @@ def _start_packet_send_dispatchers() -> None: _control_packet_send_dispatcher.start() _realtime_packet_send_dispatcher.start() _qapp_close_write_dispatcher.start() + _qapp_stream_write_dispatcher.start() log( "[presence_bridge] target=reticulum-packet-send-dispatcher started " f"control_workers={_CONTROL_PACKET_SEND_WORKERS} " @@ -2253,6 +2258,7 @@ def _stop_packet_send_dispatchers() -> None: _control_packet_send_dispatcher.stop() _realtime_packet_send_dispatcher.stop() _qapp_close_write_dispatcher.stop() + _qapp_stream_write_dispatcher.stop() def _stop_scheduler_workers() -> None: @@ -27008,6 +27014,7 @@ def handle_send_group_audio_link_heartbeat(req_id: str, payload: Dict[str, Any]) _QAPP_RNS_MAX_QUEUE_BYTES = int(os.environ.get("QORTAL_QAPP_RNS_MAX_QUEUE_BYTES", 2 * 1024 * 1024)) _QAPP_RNS_IDLE_SECONDS = float(os.environ.get("QORTAL_QAPP_RNS_IDLE_SECONDS", 300)) _QAPP_RNS_ACK_TIMEOUT_SECONDS = float(os.environ.get("QORTAL_QAPP_RNS_ACK_TIMEOUT_SECONDS", 120)) +_QAPP_RNS_WRITE_TIMEOUT_SECONDS = 10.0 _QAPP_RNS_CLOSE_WRITE_TIMEOUT_SECONDS = max( 0.1, float(os.environ.get("QORTAL_QAPP_RNS_CLOSE_WRITE_TIMEOUT_SECONDS", 10.0)), @@ -27029,25 +27036,74 @@ def _qapp_rns_frame(frame_type: int, message_id: int, payload: bytes = b"") -> b return _QAPP_RNS_HEADER.pack(_QAPP_RNS_VERSION, frame_type, message_id, len(payload)) + payload -def _qapp_rns_write(entry: Dict[str, Any], frame: bytes) -> bool: - writer = entry.get("writer") - if writer is None or entry.get("established") is not True: - return False - with entry["write_lock"]: - offset = 0 - deadline = time.monotonic() + 10.0 - while offset < len(frame): - if entry.get("writer") is not writer or time.monotonic() >= deadline: +def _qapp_rns_write(entry: Dict[str, Any], frame: bytes, *, expected_link: Any = None) -> bool: + # Capture the physical stream before dispatch. A delayed old write must + # never acquire the writer (or lock) of a replacement Link. + with _state_lock: + writer = entry.get("writer") + link = entry.get("link") + if expected_link is not None and link is not expected_link: + return False + generation = entry.get("generation") + write_lock = entry.get("write_lock") + if writer is None or entry.get("established") is not True: + return False + cancelled = threading.Event() + + def current() -> bool: + return ( + not cancelled.is_set() + and entry.get("writer") is writer + and entry.get("generation") == generation + and entry.get("established") is True + ) + + def write() -> bool: + deadline = time.monotonic() + _QAPP_RNS_WRITE_TIMEOUT_SECONDS + if not write_lock.acquire(timeout=_QAPP_RNS_WRITE_TIMEOUT_SECONDS): + return False + try: + offset = 0 + while offset < len(frame): + if not current() or time.monotonic() >= deadline: + return False + written = int(writer.write(frame[offset:]) or 0) + if written <= 0: + cancelled.wait(0.01) + continue + offset += written + if not current(): return False - written = writer.write(frame[offset:]) - written = int(written or 0) - if written <= 0: - time.sleep(0.01) - continue - offset += written - writer.flush() - entry["last_used"] = time.time() - return True + # RawChannelWriter has no buffered bytes: flush is a no-op. The + # outer deadline also bounds unexpected blocking in RNS itself. + writer.flush() + return current() + finally: + write_lock.release() + + status, result, error = _qapp_stream_write_dispatcher.submit_and_wait( + f"{id(entry)}:{id(writer)}:{generation}", + _QAPP_RNS_WRITE_TIMEOUT_SECONDS, + write, + ) + cancelled.set() + if status == "completed" and result is True and not error: + entry["last_used"] = time.time() + return True + log( + "[presence_bridge] target=qapp-rns stream_write_failed " + f"destination={entry.get('destination')} reason={status}" + ) + # No bytes were attempted for a rejected queued job. Do not reset a + # healthy connection merely because the bounded pool is busy. + if status in ("completed", "exception", "send_timeout"): + with _state_lock: + if entry.get("writer") is writer and entry.get("generation") == generation: + if link is not None and entry.get("link") is link: + _qapp_rns_closed(entry["managerKey"], generation, link) + if link is not None: + _teardown_reticulum_link_bounded(link, "target=qapp-rns stream_write_failed") + return False def _qapp_rns_teardown_failed_close( @@ -27084,7 +27140,7 @@ def coordinate() -> None: status, result, error = _qapp_close_write_dispatcher.submit_and_wait( manager_key, _QAPP_RNS_CLOSE_WRITE_TIMEOUT_SECONDS, - lambda: _qapp_rns_write(entry, frame), + lambda: _qapp_rns_write(entry, frame, expected_link=link), ) if status == "completed" and result is True and not error: return @@ -27160,17 +27216,12 @@ def keepalive() -> None: message_id = int(entry["next_message_id"]) entry["next_message_id"] = (message_id + 1) & 0xffffffffffffffff payload = json.dumps({"type": "PING"}, separators=(",", ":")).encode("utf-8") - if not _qapp_rns_write( + _qapp_rns_write( entry, _qapp_rns_frame(_QAPP_RNS_CONTROL, message_id, payload), - ): - link = entry.get("link") - if link is not None: - try: - link.teardown() - except Exception: - pass - return + ) + # A busy pool can reject a keepalive before any bytes are sent. + # Retry at the next interval if the Link is still established. _qapp_rns_schedule_keepalive(entry) timer = threading.Timer(_QAPP_RNS_KEEPALIVE_SECONDS, keepalive) @@ -27244,6 +27295,7 @@ def _qapp_rns_handle_frame(entry: Dict[str, Any], frame_type: int, message_id: i def _qapp_rns_reader(entry: Dict[str, Any], generation: int) -> None: reader = entry.get("reader") + link = entry.get("link") buffered = bytearray() try: while entry.get("generation") == generation and entry.get("established") is True: @@ -27257,6 +27309,8 @@ def _qapp_rns_reader(entry: Dict[str, Any], generation: int) -> None: if len(buffered) > _QAPP_RNS_MAX_FRAME * 2: raise ValueError("receive buffer exceeded") while len(buffered) >= _QAPP_RNS_HEADER.size: + if entry.get("generation") != generation or entry.get("reader") is not reader: + return version, frame_type, message_id, payload_len = _QAPP_RNS_HEADER.unpack_from(buffered) if ( version != _QAPP_RNS_VERSION @@ -27272,12 +27326,8 @@ def _qapp_rns_reader(entry: Dict[str, Any], generation: int) -> None: _qapp_rns_handle_frame(entry, frame_type, message_id, payload) except Exception as exc: log(f"[presence_bridge] target=qapp-rns protocol_error destination={entry.get('destination')} error={type(exc).__name__}") - link = entry.get("link") if link is not None: - try: - link.teardown() - except Exception: - pass + _teardown_reticulum_link_bounded(link, "target=qapp-rns reader_failed") def _qapp_rns_established(manager_key: str, generation: int, link) -> None: @@ -27293,16 +27343,22 @@ def _qapp_rns_established(manager_key: str, generation: int, link) -> None: entry["establishing"] = False entry["reconnect_attempt"] = 0 channel = link.get_channel() - entry["writer"] = RNS.Buffer.create_writer(_QAPP_RNS_STREAM_ID, channel) + # BufferedWriter.flush spins indefinitely when RawChannelWriter.write + # returns zero for a full RNS channel window. Drive raw partial writes + # ourselves so congestion and failures remain bounded. + entry["writer"] = RawChannelWriter(_QAPP_RNS_STREAM_ID, channel) + entry["write_lock"] = threading.Lock() entry["reader"] = RNS.Buffer.create_reader(_QAPP_RNS_STREAM_ID, channel) entry["established_event"].set() unacked = [value["frame"] for _, value in sorted(entry["unacked"].items())] threading.Thread(target=_qapp_rns_reader, args=(entry, generation), daemon=True, name="qapp-rns-reader").start() _qapp_rns_schedule_keepalive(entry) for frame in unacked: - if not _qapp_rns_write(entry, frame): - break - _qapp_rns_emit_state(entry, "CONNECTED") + if not _qapp_rns_write(entry, frame, expected_link=link): + return + with _state_lock: + if entry.get("link") is link and entry.get("generation") == generation and entry.get("established") is True: + _qapp_rns_emit_state(entry, "CONNECTED") def _qapp_rns_schedule_reconnect(entry: Dict[str, Any]) -> None: @@ -27534,7 +27590,13 @@ def expire_unacked() -> None: entry["unacked"][message_id] = {"frame": frame, "created": time.time(), "timer": timer} entry["queued_bytes"] += len(frame) timer.start() - _qapp_rns_write(entry, frame) + if not _qapp_rns_write(entry, frame): + with _state_lock: + removed = entry["unacked"].pop(message_id, None) + if removed is not None: + removed["timer"].cancel() + entry["queued_bytes"] = max(0, entry["queued_bytes"] - len(frame)) + return _qapp_rns_error(req_id, "RNS_SEND_QUEUE_FULL") emit_resp(req_id, True, payload={"messageId": str(message_id)}) diff --git a/electron/resources/presence_bridge_test.py b/electron/resources/presence_bridge_test.py index 09bf08e8..58d76910 100644 --- a/electron/resources/presence_bridge_test.py +++ b/electron/resources/presence_bridge_test.py @@ -111,6 +111,69 @@ def flush(self): self.assertTrue(writer.flushed) self.assertEqual(self.bridge._QAPP_RNS_STREAM_ID, 7) + def test_full_channel_window_expires_without_blocking_later_writes(self): + entry = { + "writer": mock.Mock(write=mock.Mock(return_value=0)), + "established": True, "write_lock": threading.Lock(), + } + with mock.patch.object(self.bridge, "_QAPP_RNS_WRITE_TIMEOUT_SECONDS", 0.05): + started = time.monotonic() + self.assertFalse(self.bridge._qapp_rns_write(entry, b"auth")) + self.assertLess(time.monotonic() - started, 0.5) + entry["writer"] = mock.Mock(write=mock.Mock(side_effect=lambda data: len(data))) + self.assertTrue(self.bridge._qapp_rns_write(entry, b"next")) + + def test_blocked_stream_is_detached_and_replacement_uses_its_own_lock(self): + started = threading.Event() + release = threading.Event() + completed = threading.Event() + def blocked(_data): + started.set() + release.wait(2) + completed.set() + return 1 + old_link = mock.Mock() + entry = self.bridge._qapp_rns_entry("blocked-stream-test", "00" * 16) + entry.update(writer=mock.Mock(write=blocked), established=True, + link=old_link, generation=1, connections={"logical"}) + try: + with mock.patch.object(self.bridge, "_QAPP_RNS_WRITE_TIMEOUT_SECONDS", 0.05), mock.patch.object( + self.bridge, "_qapp_rns_schedule_reconnect" + ) as reconnect, mock.patch.object(self.bridge, "emit_event"): + before = time.monotonic() + self.assertFalse(self.bridge._qapp_rns_write(entry, b"old")) + self.assertTrue(started.is_set()) + self.assertLess(time.monotonic() - before, 0.5) + self.assertIsNone(entry["link"]) + self.assertFalse(entry["established"]) + reconnect.assert_called_once_with(entry) + new_link = mock.Mock() + new_writer = mock.Mock(write=mock.Mock(side_effect=lambda data: len(data))) + entry.update(writer=new_writer, write_lock=threading.Lock(), + link=new_link, generation=2, established=True) + self.assertTrue(self.bridge._qapp_rns_write(entry, b"new")) + release.set() + self.assertTrue(completed.wait(0.5)) + new_writer.write.assert_called_once_with(b"new") + new_link.teardown.assert_not_called() + old_link.teardown.assert_called_once() + finally: + release.set() + self.bridge._qapp_rns_entries.pop("blocked-stream-test", None) + + def test_waiting_for_stream_lock_has_a_deadline(self): + lock = threading.Lock() + lock.acquire() + entry = {"writer": mock.Mock(), "established": True, "write_lock": lock} + try: + with mock.patch.object(self.bridge, "_QAPP_RNS_WRITE_TIMEOUT_SECONDS", 0.05): + before = time.monotonic() + self.assertFalse(self.bridge._qapp_rns_write(entry, b"auth")) + self.assertLess(time.monotonic() - before, 0.5) + entry["writer"].write.assert_not_called() + finally: + lock.release() + def test_active_realtime_connection_sends_internal_keepalive(self): timers = [] writes = [] @@ -262,6 +325,7 @@ def flush(self): "link": mock.Mock(), } self.bridge._qapp_rns_entries[entry["managerKey"]] = entry + original_link = entry["link"] try: teardown_called = threading.Event() with mock.patch.object(self.bridge, "emit_resp"), mock.patch.object( @@ -278,7 +342,9 @@ def flush(self): self.assertEqual(entry["connections"], set()) self.assertTrue(teardown_called.wait(0.5)) - teardown.assert_called_once_with(entry, entry["link"], "exception") + teardown.assert_called_once_with(entry, original_link, "completed") + original_link.teardown.assert_called_once() + self.assertFalse(entry["established"]) finally: self.bridge._qapp_rns_entries.pop(entry["managerKey"], None) @@ -286,7 +352,7 @@ def test_close_releases_lifecycle_lane_when_control_write_stalls(self): write_started = threading.Event() release_write = threading.Event() - def stalled_write(_entry, _frame): + def stalled_write(_entry, _frame, **_kwargs): write_started.set() release_write.wait(1.0) return True @@ -361,6 +427,16 @@ def test_failed_close_does_not_teardown_a_replacement_link(self): teardown.assert_not_called() + def test_delayed_close_cannot_write_to_replacement_stream(self): + old_link = mock.Mock() + new_link = mock.Mock() + writer = mock.Mock() + entry = {"writer": writer, "link": new_link, "established": True, + "generation": 2, "write_lock": threading.Lock()} + self.assertFalse(self.bridge._qapp_rns_write(entry, b"old-close", expected_link=old_link)) + writer.write.assert_not_called() + new_link.teardown.assert_not_called() + def test_rpc_response_callback_extracts_request_receipt_response(self): emitted = [] diff --git a/electron/src/moq-masque-transport.test.ts b/electron/src/moq-masque-transport.test.ts index 68bcfb12..f0f91b5c 100644 --- a/electron/src/moq-masque-transport.test.ts +++ b/electron/src/moq-masque-transport.test.ts @@ -71,6 +71,26 @@ const context = { }; describe('generic trusted MOQT transport', () => { + it.each(['MOQ_QUEUE_LIMIT', 'MOQ_OBJECT_EXPIRED'])( + 'does not reconnect for expected delivery pressure: %s', + async (code) => { + const { transport, sidecar } = setup(); + await transport.open(context); + sidecar.publishMoqObject.mockRejectedValueOnce( + new PrivateTransportSidecarError(code) + ); + await expect( + transport.publish( + new Uint8Array([1]), + 'realtime-data', + [new Uint8Array([1])], + { priority: 0, maxQueueAgeMillis: 120 } + ) + ).rejects.toMatchObject({ code }); + expect(sidecar.openMoqSession).toHaveBeenCalledTimes(1); + await transport.close(); + } + ); it.each([ ['RELAY_ACCESS_DENIED', true], ['RELAY_MEMBERSHIP_UNAVAILABLE', true], diff --git a/electron/src/moq-masque-transport.ts b/electron/src/moq-masque-transport.ts index 8249921a..d0f280c9 100644 --- a/electron/src/moq-masque-transport.ts +++ b/electron/src/moq-masque-transport.ts @@ -233,7 +233,8 @@ export class MoqMasqueTransport { async publish( payload: Uint8Array, trackName?: string, - batch?: readonly Uint8Array[] + batch?: readonly Uint8Array[], + delivery?: { priority: number; maxQueueAgeMillis: number } ): Promise { if (payload.byteLength < 1 || payload.byteLength > MAX_MOQ_OBJECT_BYTES) { throw new PrivateTransportSidecarError('MOQ_OBJECT_TOO_LARGE'); @@ -245,7 +246,8 @@ export class MoqMasqueTransport { sessionId, payload, trackName, - batch + batch, + delivery ); else await this.sidecar.publishMoqObject(sessionId, payload); } catch (error) { diff --git a/electron/src/moq-transport-manager.test.ts b/electron/src/moq-transport-manager.test.ts index 1a65ad6f..3fa0f225 100644 --- a/electron/src/moq-transport-manager.test.ts +++ b/electron/src/moq-transport-manager.test.ts @@ -42,6 +42,40 @@ async function open(manager: QAppMoqTransportManager) { } describe('Q-App generic MOQT manager', () => { + it('validates opaque delivery policy before admission and forwards it unchanged', async () => { + const { manager, transports } = setup(); + const opened = await manager.open(owner, 'rns-1', ['opaque'], ['live']); + const objects = [new Uint8Array([1])]; + for (const delivery of [ + null, + {}, + { priority: -1, maxQueueAgeMillis: 100 }, + { priority: 3, maxQueueAgeMillis: 100 }, + { priority: 0, maxQueueAgeMillis: Infinity }, + { priority: 0, maxQueueAgeMillis: 2001 }, + ]) { + await expect( + manager.publish(owner, opened.sessionId, { + trackName: 'live', + objects, + delivery, + }) + ).rejects.toMatchObject({ code: 'INVALID_MOQ_CONFIG' }); + } + expect(transports[0].publish).not.toHaveBeenCalled(); + const delivery = { priority: 0, maxQueueAgeMillis: 120 }; + await manager.publish(owner, opened.sessionId, { + trackName: 'live', + objects, + delivery, + }); + expect(transports[0].publish).toHaveBeenCalledWith( + objects[0], + 'live', + objects, + delivery + ); + }); it('isolates bounded publication batches by track and rejects undeclared tracks', async () => { const { manager, transports } = setup(); const opened = await manager.open( @@ -58,7 +92,8 @@ describe('Q-App generic MOQT manager', () => { expect(transports[0].publish).toHaveBeenCalledWith( objects[0], 'bulk', - objects + objects, + undefined ); await expect( manager.publish(owner, opened.sessionId, { diff --git a/electron/src/moq-transport-manager.ts b/electron/src/moq-transport-manager.ts index 3b9b01b4..914c3122 100644 --- a/electron/src/moq-transport-manager.ts +++ b/electron/src/moq-transport-manager.ts @@ -29,6 +29,11 @@ export class QAppMoqError extends Error { } } +export type MoqDeliveryPolicy = Readonly<{ + priority: number; + maxQueueAgeMillis: number; +}>; + export interface ManagedMoqTransport { open(context: MoqOpenContext): Promise; subscribe( @@ -39,7 +44,8 @@ export interface ManagedMoqTransport { publish( payload: Uint8Array, trackName?: string, - batch?: readonly Uint8Array[] + batch?: readonly Uint8Array[], + delivery?: MoqDeliveryPolicy ): Promise; metrics(): Promise>; close(): Promise; @@ -210,7 +216,11 @@ export class QAppMoqTransportManager extends EventEmitter { payloadValue && typeof payloadValue === 'object' && 'objects' in payloadValue - ? (payloadValue as { objects: unknown; trackName: unknown }) + ? (payloadValue as { + objects: unknown; + trackName: unknown; + delivery?: unknown; + }) : null; const track = batchRequest ? requireName(batchRequest.trackName, 'INVALID_MOQ_CONFIG') @@ -227,6 +237,19 @@ export class QAppMoqTransportManager extends EventEmitter { const objects = batchRequest ? (batchRequest.objects as unknown[]).map(requirePayload) : [requirePayload(payloadValue)]; + const delivery = batchRequest?.delivery as MoqDeliveryPolicy | undefined; + if ( + delivery !== undefined && + (!delivery || + typeof delivery !== 'object' || + !Number.isInteger(delivery.priority) || + delivery.priority < 0 || + delivery.priority > 2 || + !Number.isInteger(delivery.maxQueueAgeMillis) || + delivery.maxQueueAgeMillis < 10 || + delivery.maxQueueAgeMillis > 2000) + ) + throw new QAppMoqError('INVALID_MOQ_CONFIG'); const bytes = objects.reduce((sum, payload) => sum + payload.byteLength, 0); if ( session.queuedBytes + bytes > QAPP_MOQ_LIMITS.maxQueuedBytesPerSession || @@ -242,7 +265,7 @@ export class QAppMoqTransportManager extends EventEmitter { ); try { if (batchRequest) - await session.transport.publish(objects[0], track, objects); + await session.transport.publish(objects[0], track, objects, delivery); else await session.transport.publish(objects[0]); return { sessionId: session.sessionId, diff --git a/electron/src/preload.ts b/electron/src/preload.ts index 2e08b4d5..f012d4ef 100644 --- a/electron/src/preload.ts +++ b/electron/src/preload.ts @@ -744,14 +744,22 @@ try { rnsConnectionId, purpose ), - privateChannelSend: (owner, channelId, lane, messageId, data) => + privateChannelSend: ( + owner, + channelId, + lane, + messageId, + data, + streamOptions + ) => invokePrivateChannel( 'privateChannel:send', owner, channelId, lane, messageId, - data + data, + streamOptions ), privateChannelStatus: (owner, channelId) => invokePrivateChannel('privateChannel:status', owner, channelId), diff --git a/electron/src/private-channel-bootstrap.ts b/electron/src/private-channel-bootstrap.ts index e446fcc0..afb31d35 100644 --- a/electron/src/private-channel-bootstrap.ts +++ b/electron/src/private-channel-bootstrap.ts @@ -90,6 +90,8 @@ export function validatePrivateBootstrapDescriptor( } const d = value as Record; if (d.error && typeof d.error === 'object') { + if ((d.error as { code?: unknown }).code === 'transport_already_attached') + throw new PrivateChannelError('TRANSPORT_ALREADY_ATTACHED'); throw new PrivateChannelError('BOOTSTRAP_FAILED'); } if (d.version !== PRIVATE_BOOTSTRAP_VERSION) { diff --git a/electron/src/private-channel-manager.test.ts b/electron/src/private-channel-manager.test.ts index 177c2d06..43ffd953 100644 --- a/electron/src/private-channel-manager.test.ts +++ b/electron/src/private-channel-manager.test.ts @@ -52,6 +52,54 @@ class BlockingTransport implements PrivateTransport { } describe('PrivateChannelManager', () => { + it('bounds one stream without consuming the control stream queue and validates options', async () => { + const transport = new BlockingTransport(); + const manager = new PrivateChannelManager(ownership, () => transport); + const channel = await manager.open(alice, aliceRns, 'file-transfer'); + await expect( + manager.send( + alice, + channel.channelId, + 'reliable', + 'invalid', + {}, + { streamKey: '../bad' } + ) + ).rejects.toMatchObject({ code: 'INVALID_STREAM_OPTIONS' }); + const sends = [0, 1, 2].map((i) => + manager.send( + alice, + channel.channelId, + 'reliable', + `bulk-${i}`, + new Uint8Array(64 * 1024), + { streamKey: 'bulk' } + ) + ); + await expect( + manager.send( + alice, + channel.channelId, + 'reliable', + 'overflow', + new Uint8Array(1), + { streamKey: 'bulk' } + ) + ).rejects.toMatchObject({ code: 'QUEUE_LIMIT_REACHED' }); + const control = manager.send( + alice, + channel.channelId, + 'reliable', + 'control', + { ping: true }, + { streamKey: 'control' } + ); + expect(transport.sendReliable).toHaveBeenLastCalledWith( + expect.objectContaining({ streamKey: 'control' }) + ); + transport.releases.forEach((release) => release()); + await Promise.all([...sends, control]); + }); it('opens an owner-scoped mock channel with an opaque unique handle', async () => { const manager = new PrivateChannelManager(ownership); const first = await manager.open(alice, aliceRns, 'game'); diff --git a/electron/src/private-channel-manager.ts b/electron/src/private-channel-manager.ts index fffd253d..6f669a92 100644 --- a/electron/src/private-channel-manager.ts +++ b/electron/src/private-channel-manager.ts @@ -20,6 +20,7 @@ export const PRIVATE_CHANNEL_LIMITS = Object.freeze({ }); export const PRIVATE_CHANNEL_FEATURES = Object.freeze({ + reliableStreams: true, reliableMessages: true, datagrams: true, maxMessageBytes: PRIVATE_CHANNEL_LIMITS.maxMessageBytes, @@ -35,6 +36,8 @@ export class PrivateChannelError extends Error { } export type PrivateTransportMessage = { + streamKey?: string; + endStream?: boolean; lane: PrivateChannelLane; messageId: string; data: unknown; @@ -187,6 +190,7 @@ function validateJsonValue( } export class PrivateChannelManager extends EventEmitter { + private readonly queuedBytesByStream = new Map(); private readonly channels = new Map(); private readonly channelsByOwner = new Map>(); private readonly queuedBytesByOwner = new Map(); @@ -289,7 +293,8 @@ export class PrivateChannelManager extends EventEmitter { channelIdValue: unknown, laneValue: unknown, messageIdValue: unknown, - data: unknown + data: unknown, + streamOptions?: unknown ): Promise<{ channelId: string; messageId: string; accepted: true }> { const channelId = this.validateChannelId(channelIdValue); const channel = this.requireOwned(owner, channelId, true); @@ -320,10 +325,32 @@ export class PrivateChannelManager extends EventEmitter { throw new PrivateChannelError('QUEUE_LIMIT_REACHED'); } + const lane = laneValue as PrivateChannelLane; + let streamKey: string | undefined; + let endStream: boolean | undefined; + if (streamOptions !== undefined) { + const o = streamOptions as { streamKey?: unknown; endStream?: unknown }; + if ( + !o || + typeof o !== 'object' || + Array.isArray(o) || + lane !== 'reliable' || + typeof o.streamKey !== 'string' || + !/^[A-Za-z0-9._-]{1,96}$/.test(o.streamKey) || + (o.endStream !== undefined && typeof o.endStream !== 'boolean') + ) + throw new PrivateChannelError('INVALID_STREAM_OPTIONS'); + streamKey = o.streamKey; + endStream = o.endStream === true; + } + const message = { lane, messageId, data, streamKey, endStream }; + const queueKey = `${channelId}\0${streamKey ?? ''}`; + const streamBytes = this.queuedBytesByStream.get(queueKey) ?? 0; + if (streamKey && streamBytes + size > 192 * 1024) + throw new PrivateChannelError('QUEUE_LIMIT_REACHED'); + this.queuedBytesByStream.set(queueKey, streamBytes + size); channel.queuedBytes += size; this.queuedBytesByOwner.set(channel.ownerKey, ownerQueued + size); - const lane = laneValue as PrivateChannelLane; - const message = { lane, messageId, data }; try { if (lane === 'reliable') await channel.transport.sendReliable(message); else await channel.transport.sendDatagram(message); @@ -332,6 +359,13 @@ export class PrivateChannelManager extends EventEmitter { if (error instanceof PrivateChannelError) throw error; throw new PrivateChannelError('CHANNEL_SEND_FAILED'); } finally { + const remainingStream = Math.max( + 0, + (this.queuedBytesByStream.get(queueKey) ?? 0) - size + ); + if (remainingStream) + this.queuedBytesByStream.set(queueKey, remainingStream); + else this.queuedBytesByStream.delete(queueKey); channel.queuedBytes = Math.max(0, channel.queuedBytes - size); const remaining = Math.max( 0, diff --git a/electron/src/private-channel-step3.test.ts b/electron/src/private-channel-step3.test.ts index fc07e406..0015f1ab 100644 --- a/electron/src/private-channel-step3.test.ts +++ b/electron/src/private-channel-step3.test.ts @@ -555,6 +555,16 @@ describe('private bootstrap validation', () => { expect.objectContaining({ code: 'BOOTSTRAP_EXPIRED' }) ); }); + it('reports a duplicate attachment explicitly but sanitizes unknown backend errors', () => { + expect(() => + validate({ error: { code: 'transport_already_attached' } }) + ).toThrowError( + expect.objectContaining({ code: 'TRANSPORT_ALREADY_ATTACHED' }) + ); + expect(() => + validate({ error: { code: 'untrusted backend text' } }) + ).toThrowError(expect.objectContaining({ code: 'BOOTSTRAP_FAILED' })); + }); it('rejects wrong RNS/owner binding and reuse', () => { expect(() => validate({ ...base, backendRnsDestination: 'f'.repeat(32) }) diff --git a/electron/src/private-transport-sidecar.test.ts b/electron/src/private-transport-sidecar.test.ts index aea40983..d19ddc11 100644 --- a/electron/src/private-transport-sidecar.test.ts +++ b/electron/src/private-transport-sidecar.test.ts @@ -125,7 +125,7 @@ describeIntegration('private transport sidecar integration', () => { await sidecar.start(); expect(await sidecar.health()).toEqual({ service: 'qortal-private-transport', - sidecarVersion: '0.7.0', + sidecarVersion: '0.9.1', protocolVersion: 2, innerAlpn: 'qortal-private/1', moqAlpn: 'moqt-18', @@ -216,7 +216,7 @@ describeIntegration('private transport sidecar integration', () => { let count = 0; readline.createInterface({ input: process.stdin }).on('line', (line) => { const request = JSON.parse(line); - if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.7.0',protocolVersion:2,innerAlpn:'qortal-private/1',moqAlpn:'moqt-18'}}) + '\\n'); + if (count++ === 0) process.stdout.write(JSON.stringify({version:2,type:'response',requestId:request.requestId,ok:true,result:{service:'qortal-private-transport',sidecarVersion:'0.9.1',protocolVersion:2,innerAlpn:'qortal-private/1',moqAlpn:'moqt-18'}}) + '\\n'); else process.stdout.write('{not-json\\n'); }); `; diff --git a/electron/src/private-transport-sidecar.ts b/electron/src/private-transport-sidecar.ts index 674c5f7b..6d2a6dfa 100644 --- a/electron/src/private-transport-sidecar.ts +++ b/electron/src/private-transport-sidecar.ts @@ -5,7 +5,7 @@ import { EventEmitter } from 'events'; import path from 'path'; export const PRIVATE_TRANSPORT_PROTOCOL_VERSION = 2; -export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.7.0'; +export const PRIVATE_TRANSPORT_SIDECAR_VERSION = '0.9.1'; export const MAX_MOQ_OBJECT_BYTES = 1024; export type PreparedRelay = { handle: string; @@ -361,11 +361,13 @@ export class PrivateTransportSidecar extends EventEmitter { async sendPrivateReliable( sessionId: string, messageId: string, - data: Buffer + data: Buffer, + streamKey?: string, + endStream?: boolean ): Promise { await this.request( 'sendPrivateReliable', - { sessionId, messageId }, + { sessionId, messageId, streamKey, endStream }, undefined, data ); @@ -455,7 +457,8 @@ export class PrivateTransportSidecar extends EventEmitter { moqSessionId: string, payload: Uint8Array, trackName?: string, - batch?: readonly Uint8Array[] + batch?: readonly Uint8Array[], + delivery?: { priority: number; maxQueueAgeMillis: number } ): Promise { if (payload.byteLength < 1 || payload.byteLength > MAX_MOQ_OBJECT_BYTES) { throw new PrivateTransportSidecarError('MOQ_OBJECT_TOO_LARGE'); @@ -484,6 +487,7 @@ export class PrivateTransportSidecar extends EventEmitter { moqSessionId, ...(trackName ? { trackName } : {}), ...(batch ? { batched: true } : {}), + ...(delivery ? { delivery } : {}), }, undefined, binary diff --git a/electron/src/quic-masque-transport.test.ts b/electron/src/quic-masque-transport.test.ts index 91d0fc9c..ea31f1ce 100644 --- a/electron/src/quic-masque-transport.test.ts +++ b/electron/src/quic-masque-transport.test.ts @@ -6,6 +6,7 @@ import { type PrivateSessionConfig, } from './private-transport-sidecar'; import { QuicMasqueTransport } from './quic-masque-transport'; +import { PrivateChannelError } from './private-channel-manager'; class SidecarStub extends EventEmitter { attempts: PrivateSessionConfig[] = []; @@ -32,6 +33,33 @@ class RecoveringSidecarStub extends EventEmitter { } describe('QUIC MASQUE transport', () => { + it('preserves duplicate-connection rejection without retrying relays', async () => { + const sidecar = new RecoveringSidecarStub(); + const getBootstrap = vi.fn(async () => { + throw new PrivateChannelError('TRANSPORT_ALREADY_ATTACHED'); + }); + const relay = vi.fn(async () => ({ + relayAddress: '8.8.8.8:47322', + relayServerName: 'relay.test', + relayCertSha256: 'ab'.repeat(32), + })); + const transport = new QuicMasqueTransport( + vi.fn(), + sidecar as never, + relay, + { getBootstrap } + ); + await expect( + transport.open({ + owner: { tabId: 'tab', name: 'app', service: 'APP' }, + rnsConnectionId: 'rns', + purpose: 'file-transfer', + }) + ).rejects.toMatchObject({ code: 'TRANSPORT_ALREADY_ATTACHED' }); + expect(getBootstrap).toHaveBeenCalledTimes(1); + expect(sidecar.attempts).toHaveLength(0); + await transport.close(); + }); it('falls back to the pinned same-host relay when public NAT hairpinning fails', async () => { const sidecar = new SidecarStub(); const bootstrapProvider: PrivateChannelBootstrapProvider = { @@ -132,7 +160,9 @@ describe('QUIC MASQUE transport', () => { expect(sidecar.sendPrivateReliable).toHaveBeenCalledWith( 'native-2', 'after-recovery', - expect.any(Buffer) + expect.any(Buffer), + undefined, + undefined ); expect(events).not.toHaveBeenCalled(); }); diff --git a/electron/src/quic-masque-transport.ts b/electron/src/quic-masque-transport.ts index ff173698..204ef948 100644 --- a/electron/src/quic-masque-transport.ts +++ b/electron/src/quic-masque-transport.ts @@ -100,7 +100,9 @@ export class QuicMasqueTransport implements PrivateTransport { await this.sidecar.sendPrivateReliable( sessionId, message.messageId, - encoded + encoded, + message.streamKey, + message.endStream ); } catch (error) { if (isRecoverableTransportError(error)) { @@ -109,7 +111,9 @@ export class QuicMasqueTransport implements PrivateTransport { await this.sidecar.sendPrivateReliable( replacementSessionId, message.messageId, - encoded + encoded, + message.streamKey, + message.endStream ); return; } catch (recoveryError) { @@ -372,12 +376,17 @@ function decodeApplicationData(value: Buffer): unknown { function translateSidecarError(error: unknown): PrivateChannelError { const code = - error instanceof PrivateTransportSidecarError + error instanceof PrivateTransportSidecarError || + error instanceof PrivateChannelError ? error.code : error instanceof Error && error.message === 'MASQUE_RELAY_UNAVAILABLE' ? error.message : 'TRANSPORT_ERROR'; const allowed = new Set([ + 'STREAM_LIMIT_REACHED', + 'RELIABLE_STREAM_FAILED', + 'RELIABLE_STREAMS_UNSUPPORTED', + 'TRANSPORT_ALREADY_ATTACHED', 'RELAY_NO_ELIGIBLE_RELAY', 'RELAY_ACCESS_DENIED', 'RELAY_PROOF_INVALID', diff --git a/electron/src/setup.ts b/electron/src/setup.ts index 95c6173c..43622411 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -3276,11 +3276,19 @@ ipcMain.handle( channelId: unknown, lane: unknown, messageId: unknown, - data: unknown + data: unknown, + streamOptions?: unknown ) => { validatePrivateChannelIpcSender(event); return privateChannelIpcResult(() => - getPrivateChannelManager().send(owner, channelId, lane, messageId, data) + getPrivateChannelManager().send( + owner, + channelId, + lane, + messageId, + data, + streamOptions + ) ); } ); diff --git a/src/qortal/qapp-private-channel-request.test.ts b/src/qortal/qapp-private-channel-request.test.ts index c8883275..ad5d5dd6 100644 --- a/src/qortal/qapp-private-channel-request.test.ts +++ b/src/qortal/qapp-private-channel-request.test.ts @@ -94,7 +94,8 @@ describe('Q-App private channel request dispatch', () => { 'private-1', 'reliable', 'message-1', - data + data, + undefined ); expect(window.electronAPI.privateChannelStatus).toHaveBeenCalledWith( owner, diff --git a/src/qortal/qapp-private-channel-request.ts b/src/qortal/qapp-private-channel-request.ts index eb6e2a94..fe070c73 100644 --- a/src/qortal/qapp-private-channel-request.ts +++ b/src/qortal/qapp-private-channel-request.ts @@ -95,7 +95,8 @@ export async function dispatchQAppPrivateChannelRequest( message.channelId, message.lane, message.messageId, - message.data + message.data, + message.streamOptions ); case 'PRIVATE_CHANNEL_STATUS': if (!api.privateChannelStatus) { diff --git a/src/types/window.d.ts b/src/types/window.d.ts index 53ce7e4a..6cc2e421 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -395,7 +395,8 @@ declare global { channelId: unknown, lane: unknown, messageId: unknown, - data: unknown + data: unknown, + streamOptions?: unknown ) => Promise; privateChannelStatus?: (owner: any, channelId: unknown) => Promise; privateChannelClose?: (owner: any, channelId: unknown) => Promise; From 18c56417ce95d941a6d4025986ad3d6597417a7e Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sat, 12 Sep 2026 21:21:53 +0300 Subject: [PATCH 28/32] Improve private transport throughput with bounded QUIC/MASQUE buffering --- docs/private-upload-investigation.md | 135 + docs/qapp-moq-transport.md | 83 + .../main.go | 37 +- .../main.go | 12 + electron/native/private-transport/go.mod | 4 + .../internal/innerquic/framing.go | 2 +- .../internal/innerquic/session.go | 64 +- .../internal/innerquic/streams.go | 13 +- .../internal/innerquic/streams_test.go | 12 +- .../internal/masque/client.go | 63 +- .../internal/masque/client_test.go | 36 +- .../private-transport/internal/masque/pool.go | 4 +- .../internal/moqclient/session.go | 26 +- .../internal/protocol/reliable_dispatch.go | 17 +- .../protocol/reliable_dispatch_test.go | 93 +- .../internal/protocol/server.go | 20 +- .../internal/protocol/server_test.go | 2 +- .../third_party/masque-go/.github/FUNDING.yml | 13 + .../masque-go/.github/dependabot.yml | 6 + .../.github/workflows/go-generate.sh | 20 + .../masque-go/.github/workflows/lint.yml | 46 + .../masque-go/.github/workflows/unit.yml | 49 + .../third_party/masque-go/.gitignore | 3 + .../third_party/masque-go/LICENSE | 7 + .../third_party/masque-go/QORTAL_PATCH.md | 10 + .../third_party/masque-go/README.md | 17 + .../third_party/masque-go/client.go | 126 + .../third_party/masque-go/cmd/client/main.go | 95 + .../third_party/masque-go/cmd/proxy/main.go | 71 + .../third_party/masque-go/codecov.yml | 10 + .../third_party/masque-go/conn.go | 208 + .../third_party/masque-go/conn_test.go | 191 + .../third_party/masque-go/connect-udp_test.go | 335 ++ .../third_party/masque-go/datagram_buffer.go | 19 + .../third_party/masque-go/go.mod | 20 + .../third_party/masque-go/go.sum | 26 + .../third_party/masque-go/proxy.go | 254 ++ .../third_party/masque-go/proxy_request.go | 111 + .../masque-go/proxy_request_test.go | 113 + .../third_party/masque-go/proxy_test.go | 169 + .../third_party/masque-go/request.go | 63 + .../third_party/masque-go/test_helper_test.go | 154 + .../third_party/masque-go/transport.go | 94 + .../third_party/masque-go/transport_test.go | 35 + .../quic-go/.clusterfuzzlite/Dockerfile | 5 + .../quic-go/.clusterfuzzlite/build.sh | 38 + .../quic-go/.clusterfuzzlite/project.yaml | 1 + .../third_party/quic-go/.githooks/README.md | 8 + .../third_party/quic-go/.githooks/pre-commit | 34 + .../third_party/quic-go/.github/FUNDING.yml | 13 + .../quic-go/.github/dependabot.yml | 6 + .../workflows/build-interop-docker.yml | 51 + .../workflows/clusterfuzz-coverage.yml | 108 + .../workflows/clusterfuzz-lite-batch.yml | 90 + .../.github/workflows/clusterfuzz-lite-pr.yml | 54 + .../workflows/clusterfuzz-lite-prune.yml | 32 + .../quic-go/.github/workflows/codspeed.yml | 30 + .../.github/workflows/cross-compile.sh | 33 + .../.github/workflows/cross-compile.yml | 50 + .../quic-go/.github/workflows/go-generate.sh | 18 + .../quic-go/.github/workflows/govulncheck.yml | 19 + .../quic-go/.github/workflows/integration.yml | 89 + .../quic-go/.github/workflows/lint.yml | 113 + .../quic-go/.github/workflows/unit.yml | 74 + .../third_party/quic-go/.gitignore | 20 + .../third_party/quic-go/.golangci.yml | 99 + .../third_party/quic-go/FIPS140.md | 37 + .../third_party/quic-go/FUZZING.md | 59 + .../third_party/quic-go/LICENSE | 21 + .../third_party/quic-go/QORTAL_PATCH.md | 52 + .../third_party/quic-go/README.md | 80 + .../third_party/quic-go/SECURITY.md | 14 + .../third_party/quic-go/assets/LICENSE.md | 27 + .../third_party/quic-go/assets/logo.svg | 330 ++ .../quic-go/assets/quic-go-logo.png | Bin 0 -> 105123 bytes .../third_party/quic-go/buffer_pool.go | 92 + .../third_party/quic-go/buffer_pool_test.go | 44 + .../third_party/quic-go/client.go | 109 + .../third_party/quic-go/client_test.go | 105 + .../third_party/quic-go/closed_conn.go | 58 + .../third_party/quic-go/closed_conn_test.go | 34 + .../third_party/quic-go/codecov.yml | 23 + .../third_party/quic-go/config.go | 129 + .../third_party/quic-go/config_test.go | 199 + .../third_party/quic-go/conn_id_generator.go | 212 + .../quic-go/conn_id_generator_test.go | 350 ++ .../third_party/quic-go/conn_id_manager.go | 331 ++ .../quic-go/conn_id_manager_test.go | 435 ++ .../third_party/quic-go/conn_wrapped_test.go | 73 + .../third_party/quic-go/connection.go | 3169 +++++++++++++++ .../third_party/quic-go/connection_logging.go | 315 ++ .../quic-go/connection_logging_test.go | 143 + .../third_party/quic-go/connection_test.go | 3509 +++++++++++++++++ .../third_party/quic-go/crypto_stream.go | 249 ++ .../quic-go/crypto_stream_manager.go | 73 + .../quic-go/crypto_stream_manager_test.go | 87 + .../third_party/quic-go/crypto_stream_test.go | 288 ++ .../third_party/quic-go/datagram_buffer.go | 31 + .../third_party/quic-go/datagram_queue.go | 138 + .../quic-go/datagram_queue_test.go | 180 + .../third_party/quic-go/errors.go | 106 + .../third_party/quic-go/errors_test.go | 37 + .../quic-go/example/client/main.go | 81 + .../third_party/quic-go/example/echo/echo.go | 113 + .../third_party/quic-go/example/main.go | 182 + .../quic-go/flow_controller_base.go | 84 + .../quic-go/flow_controller_connection.go | 178 + .../flow_controller_connection_test.go | 71 + .../quic-go/flow_controller_stream.go | 196 + .../quic-go/flow_controller_stream_test.go | 319 ++ .../flow_controller_test_helpers_test.go | 39 + .../third_party/quic-go/frame_sorter.go | 274 ++ .../third_party/quic-go/frame_sorter_test.go | 1657 ++++++++ .../third_party/quic-go/framer.go | 484 +++ .../third_party/quic-go/framer_test.go | 745 ++++ .../third_party/quic-go/go.mod | 27 + .../third_party/quic-go/go.sum | 84 + .../third_party/quic-go/http3/README.md | 9 + .../third_party/quic-go/http3/body.go | 137 + .../third_party/quic-go/http3/body_test.go | 140 + .../third_party/quic-go/http3/capsule.go | 148 + .../third_party/quic-go/http3/capsule_test.go | 163 + .../third_party/quic-go/http3/client.go | 500 +++ .../third_party/quic-go/http3/client_test.go | 897 +++++ .../third_party/quic-go/http3/conn.go | 330 ++ .../third_party/quic-go/http3/conn_test.go | 483 +++ .../quic-go/http3/datagram_buffer.go | 23 + .../third_party/quic-go/http3/error.go | 64 + .../third_party/quic-go/http3/error_codes.go | 84 + .../quic-go/http3/error_codes_test.go | 37 + .../third_party/quic-go/http3/error_test.go | 87 + .../third_party/quic-go/http3/frames.go | 371 ++ .../third_party/quic-go/http3/frames_test.go | 518 +++ .../third_party/quic-go/http3/gzip_reader.go | 39 + .../third_party/quic-go/http3/headers.go | 472 +++ .../third_party/quic-go/http3/headers_test.go | 909 +++++ .../quic-go/http3/http3_helper_test.go | 353 ++ .../quic-go/http3/internal/testdata/ca.pem | 17 + .../quic-go/http3/internal/testdata/cert.go | 56 + .../quic-go/http3/internal/testdata/cert.pem | 18 + .../http3/internal/testdata/cert_test.go | 28 + .../http3/internal/testdata/generate_key.sh | 24 + .../quic-go/http3/internal/testdata/priv.key | 28 + .../third_party/quic-go/http3/ip_addr.go | 48 + .../quic-go/http3/mock_clientconn_test.go | 157 + .../http3/mock_datagram_stream_test.go | 610 +++ .../quic-go/http3/mock_quic_listener_test.go | 158 + .../third_party/quic-go/http3/mockgen.go | 11 + .../third_party/quic-go/http3/priority.go | 45 + .../quic-go/http3/priority_test.go | 93 + .../third_party/quic-go/http3/qlog.go | 56 + .../third_party/quic-go/http3/qlog/event.go | 138 + .../quic-go/http3/qlog/event_test.go | 98 + .../third_party/quic-go/http3/qlog/frame.go | 241 ++ .../quic-go/http3/qlog/frame_test.go | 208 + .../quic-go/http3/qlog/qlog_dir.go | 15 + .../quic-go/http3/qlog/qlog_dir_test.go | 41 + .../quic-go/http3/request_writer.go | 324 ++ .../quic-go/http3/request_writer_test.go | 175 + .../quic-go/http3/response_writer.go | 373 ++ .../quic-go/http3/response_writer_test.go | 258 ++ .../third_party/quic-go/http3/server.go | 780 ++++ .../third_party/quic-go/http3/server_conn.go | 316 ++ .../third_party/quic-go/http3/server_test.go | 802 ++++ .../quic-go/http3/settings_0rtt.go | 56 + .../quic-go/http3/settings_0rtt_test.go | 62 + .../quic-go/http3/state_tracking_stream.go | 193 + .../http3/state_tracking_stream_test.go | 319 ++ .../third_party/quic-go/http3/stream.go | 442 +++ .../third_party/quic-go/http3/stream_test.go | 266 ++ .../third_party/quic-go/http3/trace.go | 105 + .../third_party/quic-go/http3/transport.go | 539 +++ .../quic-go/http3/transport_test.go | 572 +++ .../integrationtests/self/benchmark_test.go | 151 + .../integrationtests/self/cancelation_test.go | 598 +++ .../integrationtests/self/close_test.go | 230 ++ .../integrationtests/self/conn_id_test.go | 151 + .../self/connection_migration_test.go | 144 + .../integrationtests/self/datagram_test.go | 339 ++ .../integrationtests/self/deadline_test.go | 235 ++ .../integrationtests/self/drop_test.go | 117 + .../integrationtests/self/early_data_test.go | 72 + .../self/handshake_context_test.go | 289 ++ .../self/handshake_drop_test.go | 411 ++ .../self/handshake_rtt_test.go | 207 + .../integrationtests/self/handshake_test.go | 820 ++++ .../self/http3_stream_priority_test.go | 92 + .../integrationtests/self/http_0rtt_test.go | 158 + .../self/http_datagram_test.go | 323 ++ .../self/http_hotswap_test.go | 111 + .../integrationtests/self/http_qlog_test.go | 83 + .../self/http_raw_conn_test.go | 171 + .../self/http_shutdown_test.go | 520 +++ .../integrationtests/self/http_test.go | 1359 +++++++ .../integrationtests/self/http_trace_test.go | 137 + .../integrationtests/self/key_update_test.go | 93 + .../integrationtests/self/mitm_test.go | 428 ++ .../quic-go/integrationtests/self/mtu_test.go | 197 + .../integrationtests/self/multiplex_test.go | 346 ++ .../self/nat_rebinding_test.go | 126 + .../self/packetization_test.go | 273 ++ .../integrationtests/self/qlog_dir_test.go | 75 + .../integrationtests/self/qlog_test.go | 138 + .../integrationtests/self/resumption_test.go | 149 + .../quic-go/integrationtests/self/rtt_test.go | 183 + .../integrationtests/self/self_go124_test.go | 9 + .../integrationtests/self/self_go125_test.go | 9 + .../self/self_suite_linux_test.go | 20 + .../self/self_suite_others_test.go | 7 + .../integrationtests/self/self_test.go | 352 ++ .../self/simnet_helper_test.go | 105 + .../self/stateless_reset_test.go | 127 + .../self/stream_priority_test.go | 155 + .../integrationtests/self/stream_test.go | 375 ++ .../integrationtests/self/timeout_test.go | 504 +++ .../integrationtests/self/zero_rtt_test.go | 1154 ++++++ .../quic-go/integrationtests/tools/crypto.go | 127 + .../integrationtests/tools/crypto_test.go | 99 + .../integrationtests/tools/israce/norace.go | 6 + .../integrationtests/tools/israce/race.go | 6 + .../integrationtests/tools/proxy/proxy.go | 374 ++ .../tools/proxy/proxy_test.go | 503 +++ .../quic-go/integrationtests/tools/qlog.go | 55 + .../versionnegotiation/handshake_test.go | 192 + .../versionnegotiation/rtt_test.go | 58 + .../versionnegotiation/test_helper_test.go | 111 + .../third_party/quic-go/interface.go | 222 ++ .../internal/ackhandler/ack_eliciting.go | 33 + .../internal/ackhandler/ack_eliciting_test.go | 74 + .../quic-go/internal/ackhandler/ecn.go | 340 ++ .../quic-go/internal/ackhandler/ecn_test.go | 353 ++ .../quic-go/internal/ackhandler/frame.go | 21 + .../quic-go/internal/ackhandler/interfaces.go | 39 + .../ackhandler/lost_packet_tracker.go | 73 + .../ackhandler/lost_packet_tracker_test.go | 75 + .../ackhandler/mock_ecn_handler_test.go | 189 + .../quic-go/internal/ackhandler/mockgen.go | 6 + .../quic-go/internal/ackhandler/packet.go | 60 + .../ackhandler/packet_number_generator.go | 84 + .../packet_number_generator_test.go | 92 + .../ackhandler/received_packet_handler.go | 119 + .../received_packet_handler_test.go | 144 + .../ackhandler/received_packet_history.go | 159 + .../received_packet_history_test.go | 304 ++ .../ackhandler/received_packet_tracker.go | 228 ++ .../received_packet_tracker_test.go | 188 + .../quic-go/internal/ackhandler/send_mode.go | 46 + .../internal/ackhandler/send_mode_test.go | 18 + .../ackhandler/sent_packet_handler.go | 1143 ++++++ .../ackhandler/sent_packet_handler_test.go | 1772 +++++++++ .../ackhandler/sent_packet_history.go | 274 ++ .../ackhandler/sent_packet_history_test.go | 343 ++ .../quic-go/internal/congestion/bandwidth.go | 22 + .../internal/congestion/bandwidth_test.go | 12 + .../quic-go/internal/congestion/clock.go | 20 + .../quic-go/internal/congestion/cubic.go | 214 + .../internal/congestion/cubic_sender.go | 330 ++ .../internal/congestion/cubic_sender_test.go | 594 +++ .../quic-go/internal/congestion/cubic_test.go | 205 + .../internal/congestion/hybrid_slow_start.go | 113 + .../congestion/hybrid_slow_start_test.go | 68 + .../quic-go/internal/congestion/interface.go | 27 + .../quic-go/internal/congestion/pacer.go | 110 + .../quic-go/internal/congestion/pacer_test.go | 154 + .../quic-go/internal/datagrambuffer/buffer.go | 163 + .../internal/datagrambuffer/buffer_test.go | 133 + .../quic-go/internal/handshake/aead.go | 91 + .../quic-go/internal/handshake/aead_test.go | 108 + .../internal/handshake/cipher_suite.go | 114 + .../handshake/cipher_suite_fips140.go | 48 + .../internal/handshake/crypto_setup.go | 722 ++++ .../internal/handshake/crypto_setup_test.go | 566 +++ .../quic-go/internal/handshake/fake_conn.go | 21 + .../internal/handshake/handshake_fuzz_test.go | 395 ++ .../handshake/handshake_helpers_test.go | 41 + .../internal/handshake/header_protector.go | 134 + .../quic-go/internal/handshake/hkdf.go | 26 + .../quic-go/internal/handshake/hkdf_test.go | 76 + .../internal/handshake/initial_aead.go | 81 + .../internal/handshake/initial_aead_test.go | 317 ++ .../quic-go/internal/handshake/interface.go | 147 + .../quic-go/internal/handshake/retry.go | 68 + .../quic-go/internal/handshake/retry_test.go | 56 + .../internal/handshake/session_ticket.go | 55 + .../internal/handshake/session_ticket_test.go | 46 + .../internal/handshake/tls_config_go126.go | 54 + .../handshake/tls_config_go126_test.go | 99 + .../internal/handshake/tls_config_go127.go | 24 + .../internal/handshake/token_generator.go | 126 + .../handshake/token_generator_test.go | 140 + .../internal/handshake/token_protector.go | 78 + .../handshake/token_protector_test.go | 70 + .../internal/handshake/updatable_aead.go | 372 ++ .../internal/handshake/updatable_aead_test.go | 736 ++++ .../mocks/ackhandler/sent_packet_handler.go | 640 +++ .../quic-go/internal/mocks/congestion.go | 486 +++ .../quic-go/internal/mocks/crypto_setup.go | 730 ++++ .../internal/mocks/long_header_opener.go | 154 + .../quic-go/internal/mocks/mockgen.go | 10 + .../internal/mocks/short_header_opener.go | 155 + .../internal/mocks/short_header_sealer.go | 191 + .../quic-go/internal/monotime/time.go | 90 + .../quic-go/internal/monotime/time_test.go | 78 + .../internal/protocol/connection_id.go | 116 + .../internal/protocol/connection_id_test.go | 92 + .../internal/protocol/encryption_level.go | 65 + .../protocol/encryption_level_test.go | 40 + .../quic-go/internal/protocol/key_phase.go | 36 + .../internal/protocol/key_phase_test.go | 26 + .../internal/protocol/packet_number.go | 57 + .../internal/protocol/packet_number_test.go | 47 + .../quic-go/internal/protocol/params.go | 163 + .../quic-go/internal/protocol/params_test.go | 13 + .../quic-go/internal/protocol/perspective.go | 26 + .../internal/protocol/perspective_test.go | 18 + .../quic-go/internal/protocol/protocol.go | 156 + .../internal/protocol/protocol_test.go | 39 + .../quic-go/internal/protocol/stream.go | 102 + .../quic-go/internal/protocol/stream_test.go | 66 + .../quic-go/internal/protocol/version.go | 115 + .../quic-go/internal/protocol/version_test.go | 151 + .../quic-go/internal/qerr/error_codes.go | 87 + .../quic-go/internal/qerr/errorcodes_test.go | 47 + .../quic-go/internal/qerr/errors.go | 134 + .../quic-go/internal/qerr/errors_test.go | 176 + .../quic-go/internal/qtls/cipher_suite.go | 52 + .../internal/qtls/cipher_suite_test.go | 54 + .../quic-go/internal/testdata/ca.pem | 17 + .../quic-go/internal/testdata/cert.go | 56 + .../quic-go/internal/testdata/cert.pem | 18 + .../quic-go/internal/testdata/cert_test.go | 28 + .../quic-go/internal/testdata/generate_key.sh | 24 + .../quic-go/internal/testdata/priv.key | 28 + .../internal/utils/buffered_write_closer.go | 26 + .../utils/buffered_write_closer_test.go | 25 + .../quic-go/internal/utils/connstats.go | 14 + .../internal/utils/linkedlist/README.md | 6 + .../internal/utils/linkedlist/linkedlist.go | 264 ++ .../third_party/quic-go/internal/utils/log.go | 131 + .../quic-go/internal/utils/log_test.go | 146 + .../quic-go/internal/utils/minheap/minheap.go | 78 + .../internal/utils/minheap/minheap_test.go | 37 + .../quic-go/internal/utils/rand.go | 29 + .../quic-go/internal/utils/rand_test.go | 32 + .../internal/utils/ringbuffer/ringbuffer.go | 92 + .../utils/ringbuffer/ringbuffer_test.go | 78 + .../quic-go/internal/utils/rtt_stats.go | 159 + .../quic-go/internal/utils/rtt_stats_test.go | 146 + .../quic-go/internal/wire/ack_frame.go | 298 ++ .../quic-go/internal/wire/ack_frame_test.go | 612 +++ .../internal/wire/ack_frequency_frame.go | 65 + .../internal/wire/ack_frequency_frame_test.go | 71 + .../quic-go/internal/wire/ack_range.go | 14 + .../quic-go/internal/wire/ack_range_test.go | 12 + .../internal/wire/connection_close_frame.go | 75 + .../wire/connection_close_frame_test.go | 137 + .../quic-go/internal/wire/crypto_frame.go | 97 + .../internal/wire/crypto_frame_test.go | 123 + .../internal/wire/data_blocked_frame.go | 29 + .../internal/wire/data_blocked_frame_test.go | 40 + .../quic-go/internal/wire/datagram_frame.go | 85 + .../internal/wire/datagram_frame_test.go | 126 + .../quic-go/internal/wire/extended_header.go | 164 + .../internal/wire/extended_header_test.go | 269 ++ .../quic-go/internal/wire/frame.go | 33 + .../quic-go/internal/wire/frame_parser.go | 192 + .../internal/wire/frame_parser_test.go | 1083 +++++ .../quic-go/internal/wire/frame_test.go | 42 + .../quic-go/internal/wire/frame_type.go | 81 + .../quic-go/internal/wire/frame_type_test.go | 29 + .../internal/wire/handshake_done_frame.go | 17 + .../wire/handshake_done_frame_test.go | 16 + .../quic-go/internal/wire/header.go | 302 ++ .../quic-go/internal/wire/header_test.go | 793 ++++ .../internal/wire/immediate_ack_frame.go | 18 + .../internal/wire/immediate_ack_frame_test.go | 22 + .../third_party/quic-go/internal/wire/log.go | 74 + .../quic-go/internal/wire/log_test.go | 188 + .../quic-go/internal/wire/max_data_frame.go | 33 + .../internal/wire/max_data_frame_test.go | 39 + .../internal/wire/max_stream_data_frame.go | 43 + .../wire/max_stream_data_frame_test.go | 46 + .../internal/wire/max_streams_frame.go | 50 + .../internal/wire/max_streams_frame_test.go | 117 + .../internal/wire/new_connection_id_frame.go | 80 + .../wire/new_connection_id_frame_test.go | 88 + .../quic-go/internal/wire/new_token_frame.go | 43 + .../internal/wire/new_token_frame_test.go | 51 + .../internal/wire/path_challenge_frame.go | 32 + .../wire/path_challenge_frame_test.go | 37 + .../internal/wire/path_response_frame.go | 32 + .../internal/wire/path_response_frame_test.go | 37 + .../quic-go/internal/wire/ping_frame.go | 17 + .../quic-go/internal/wire/ping_frame_test.go | 17 + .../third_party/quic-go/internal/wire/pool.go | 33 + .../quic-go/internal/wire/pool_test.go | 24 + .../internal/wire/reset_stream_frame.go | 79 + .../internal/wire/reset_stream_frame_test.go | 104 + .../wire/retire_connection_id_frame.go | 30 + .../wire/retire_connection_id_frame_test.go | 39 + .../quic-go/internal/wire/short_header.go | 62 + .../internal/wire/short_header_test.go | 106 + .../internal/wire/stop_sending_frame.go | 45 + .../internal/wire/stop_sending_frame_test.go | 47 + .../wire/stream_data_blocked_frame.go | 42 + .../wire/stream_data_blocked_frame_test.go | 46 + .../quic-go/internal/wire/stream_frame.go | 191 + .../internal/wire/stream_frame_test.go | 379 ++ .../internal/wire/streams_blocked_frame.go | 50 + .../wire/streams_blocked_frame_test.go | 117 + .../internal/wire/test_helpers_test.go | 32 + .../internal/wire/transport_parameter_test.go | 1172 ++++++ .../internal/wire/transport_parameters.go | 595 +++ .../internal/wire/version_negotiation.go | 53 + .../internal/wire/version_negotiation_test.go | 103 + .../third_party/quic-go/interop/Dockerfile | 38 + .../quic-go/interop/client/main.go | 210 + .../quic-go/interop/http09/client.go | 159 + .../quic-go/interop/http09/http_test.go | 77 + .../quic-go/interop/http09/server.go | 121 + .../quic-go/interop/run_endpoint.sh | 19 + .../quic-go/interop/server/main.go | 105 + .../quic-go/interop/utils/logging.go | 58 + .../quic-go/metrics/dashboards/README.md | 24 + .../metrics/dashboards/datasources.yml | 13 + .../metrics/dashboards/docker-compose.yml | 25 + .../quic-go/metrics/dashboards/prometheus.yml | 9 + .../quic-go/metrics/dashboards/quic-go.json | 926 +++++ .../quic-go/mock_ack_frame_source_test.go | 81 + .../quic-go/mock_conn_runner_test.go | 224 ++ .../quic-go/mock_frame_source_test.go | 121 + .../third_party/quic-go/mock_packer_test.go | 395 ++ .../quic-go/mock_packet_handler_test.go | 149 + .../quic-go/mock_packetconn_test.go | 311 ++ .../third_party/quic-go/mock_raw_conn_test.go | 273 ++ .../quic-go/mock_sealing_manager_test.go | 197 + .../quic-go/mock_send_conn_test.go | 306 ++ .../third_party/quic-go/mock_sender_test.go | 264 ++ .../mock_stream_control_frame_getter_test.go | 82 + .../quic-go/mock_stream_frame_getter_test.go | 162 + .../quic-go/mock_stream_sender_test.go | 293 ++ .../third_party/quic-go/mock_unpacker_test.go | 124 + .../third_party/quic-go/mockgen.go | 44 + .../third_party/quic-go/mtu_discoverer.go | 241 ++ .../quic-go/mtu_discoverer_test.go | 242 ++ .../third_party/quic-go/oss-fuzz.sh | 52 + .../third_party/quic-go/packet_packer.go | 1009 +++++ .../third_party/quic-go/packet_packer_test.go | 1077 +++++ .../third_party/quic-go/packet_unpacker.go | 222 ++ .../quic-go/packet_unpacker_test.go | 373 ++ .../third_party/quic-go/path_manager.go | 206 + .../quic-go/path_manager_outgoing.go | 311 ++ .../quic-go/path_manager_outgoing_test.go | 330 ++ .../third_party/quic-go/path_manager_test.go | 356 ++ .../quic-go/qlog/benchmark_test.go | 86 + .../third_party/quic-go/qlog/event.go | 874 ++++ .../third_party/quic-go/qlog/event_test.go | 913 +++++ .../third_party/quic-go/qlog/frame.go | 481 +++ .../third_party/quic-go/qlog/frame_test.go | 421 ++ .../quic-go/qlog/json_helper_test.go | 42 + .../third_party/quic-go/qlog/packet_header.go | 96 + .../quic-go/qlog/packet_header_test.go | 134 + .../third_party/quic-go/qlog/qlog_dir.go | 61 + .../third_party/quic-go/qlog/qlog_dir_test.go | 70 + .../third_party/quic-go/qlog/types.go | 305 ++ .../third_party/quic-go/qlog/types_test.go | 20 + .../quic-go/qlogwriter/jsontext/encoder.go | 324 ++ .../qlogwriter/jsontext/encoder_test.go | 405 ++ .../third_party/quic-go/qlogwriter/trace.go | 124 + .../quic-go/qlogwriter/trace_test.go | 115 + .../third_party/quic-go/qlogwriter/writer.go | 229 ++ .../quic-go/qlogwriter/writer_test.go | 116 + .../third_party/quic-go/quic_linux_test.go | 12 + .../third_party/quic-go/quic_test.go | 87 + .../third_party/quic-go/quicvarint/io.go | 98 + .../third_party/quic-go/quicvarint/io_test.go | 162 + .../third_party/quic-go/quicvarint/varint.go | 180 + .../quic-go/quicvarint/varint_test.go | 350 ++ .../third_party/quic-go/receive_stream.go | 578 +++ .../quic-go/receive_stream_test.go | 1140 ++++++ .../quic-go/retransmission_queue.go | 158 + .../quic-go/retransmission_queue_test.go | 131 + .../third_party/quic-go/send_conn.go | 124 + .../third_party/quic-go/send_conn_test.go | 135 + .../third_party/quic-go/send_queue.go | 112 + .../third_party/quic-go/send_queue_test.go | 207 + .../third_party/quic-go/send_stream.go | 983 +++++ .../third_party/quic-go/send_stream_test.go | 1930 +++++++++ .../third_party/quic-go/server.go | 1120 ++++++ .../third_party/quic-go/server_test.go | 1387 +++++++ .../third_party/quic-go/sni.go | 136 + .../third_party/quic-go/sni_test.go | 301 ++ .../third_party/quic-go/stateless_reset.go | 42 + .../quic-go/stateless_reset_test.go | 42 + .../third_party/quic-go/stream.go | 262 ++ .../third_party/quic-go/stream_test.go | 109 + .../third_party/quic-go/streams_map.go | 356 ++ .../quic-go/streams_map_incoming.go | 209 + .../quic-go/streams_map_incoming_test.go | 364 ++ .../quic-go/streams_map_outgoing.go | 253 ++ .../quic-go/streams_map_outgoing_test.go | 621 +++ .../third_party/quic-go/streams_map_test.go | 682 ++++ .../third_party/quic-go/sys_conn.go | 144 + .../third_party/quic-go/sys_conn_buffers.go | 67 + .../quic-go/sys_conn_buffers_inspect.go | 31 + .../quic-go/sys_conn_buffers_inspect_stub.go | 6 + .../quic-go/sys_conn_buffers_nonopenbsd.go | 5 + .../quic-go/sys_conn_buffers_openbsd.go | 8 + .../quic-go/sys_conn_buffers_write.go | 69 + .../third_party/quic-go/sys_conn_df.go | 22 + .../third_party/quic-go/sys_conn_df_darwin.go | 90 + .../quic-go/sys_conn_df_darwin_test.go | 101 + .../third_party/quic-go/sys_conn_df_linux.go | 42 + .../quic-go/sys_conn_df_windows.go | 52 + .../quic-go/sys_conn_helper_darwin.go | 38 + .../quic-go/sys_conn_helper_freebsd.go | 33 + .../quic-go/sys_conn_helper_linux.go | 154 + .../quic-go/sys_conn_helper_linux_test.go | 79 + .../quic-go/sys_conn_helper_nonlinux.go | 10 + .../quic-go/sys_conn_helper_nonlinux_test.go | 10 + .../third_party/quic-go/sys_conn_no_oob.go | 18 + .../third_party/quic-go/sys_conn_oob.go | 319 ++ .../third_party/quic-go/sys_conn_oob_test.go | 342 ++ .../third_party/quic-go/sys_conn_test.go | 32 + .../third_party/quic-go/sys_conn_windows.go | 42 + .../quic-go/sys_conn_windows_test.go | 30 + .../testutils/events/event_recorder.go | 94 + .../testutils/events/event_recorder_test.go | 101 + .../third_party/quic-go/testutils/frames.go | 26 + .../quic-go/testutils/simnet/README.md | 16 + .../quic-go/testutils/simnet/queue.go | 134 + .../quic-go/testutils/simnet/queue_test.go | 83 + .../quic-go/testutils/simnet/router.go | 149 + .../quic-go/testutils/simnet/simconn.go | 250 ++ .../quic-go/testutils/simnet/simconn_test.go | 187 + .../quic-go/testutils/simnet/simlink.go | 145 + .../quic-go/testutils/simnet/simlink_test.go | 153 + .../quic-go/testutils/simnet/simnet.go | 71 + .../testutils/simnet/simnet_synctest_test.go | 59 + .../quic-go/testutils/testutils.go | 108 + .../third_party/quic-go/token_store.go | 116 + .../third_party/quic-go/token_store_test.go | 79 + .../third_party/quic-go/transport.go | 850 ++++ .../third_party/quic-go/transport_test.go | 739 ++++ electron/src/private-channel-manager.test.ts | 45 +- electron/src/private-channel-manager.ts | 57 +- electron/src/private-channel-step3.test.ts | 186 +- electron/src/private-channel-step4.test.ts | 312 +- .../src/private-transport-sidecar.test.ts | 4 +- electron/src/private-transport-sidecar.ts | 4 +- electron/src/quic-masque-transport.test.ts | 9 + electron/src/quic-masque-transport.ts | 3 +- 552 files changed, 106404 insertions(+), 131 deletions(-) create mode 100644 docs/private-upload-investigation.md create mode 100644 electron/native/private-transport/third_party/masque-go/.github/FUNDING.yml create mode 100644 electron/native/private-transport/third_party/masque-go/.github/dependabot.yml create mode 100644 electron/native/private-transport/third_party/masque-go/.github/workflows/go-generate.sh create mode 100644 electron/native/private-transport/third_party/masque-go/.github/workflows/lint.yml create mode 100644 electron/native/private-transport/third_party/masque-go/.github/workflows/unit.yml create mode 100644 electron/native/private-transport/third_party/masque-go/.gitignore create mode 100644 electron/native/private-transport/third_party/masque-go/LICENSE create mode 100644 electron/native/private-transport/third_party/masque-go/QORTAL_PATCH.md create mode 100644 electron/native/private-transport/third_party/masque-go/README.md create mode 100644 electron/native/private-transport/third_party/masque-go/client.go create mode 100644 electron/native/private-transport/third_party/masque-go/cmd/client/main.go create mode 100644 electron/native/private-transport/third_party/masque-go/cmd/proxy/main.go create mode 100644 electron/native/private-transport/third_party/masque-go/codecov.yml create mode 100644 electron/native/private-transport/third_party/masque-go/conn.go create mode 100644 electron/native/private-transport/third_party/masque-go/conn_test.go create mode 100644 electron/native/private-transport/third_party/masque-go/connect-udp_test.go create mode 100644 electron/native/private-transport/third_party/masque-go/datagram_buffer.go create mode 100644 electron/native/private-transport/third_party/masque-go/go.mod create mode 100644 electron/native/private-transport/third_party/masque-go/go.sum create mode 100644 electron/native/private-transport/third_party/masque-go/proxy.go create mode 100644 electron/native/private-transport/third_party/masque-go/proxy_request.go create mode 100644 electron/native/private-transport/third_party/masque-go/proxy_request_test.go create mode 100644 electron/native/private-transport/third_party/masque-go/proxy_test.go create mode 100644 electron/native/private-transport/third_party/masque-go/request.go create mode 100644 electron/native/private-transport/third_party/masque-go/test_helper_test.go create mode 100644 electron/native/private-transport/third_party/masque-go/transport.go create mode 100644 electron/native/private-transport/third_party/masque-go/transport_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/.clusterfuzzlite/Dockerfile create mode 100644 electron/native/private-transport/third_party/quic-go/.clusterfuzzlite/build.sh create mode 100644 electron/native/private-transport/third_party/quic-go/.clusterfuzzlite/project.yaml create mode 100644 electron/native/private-transport/third_party/quic-go/.githooks/README.md create mode 100644 electron/native/private-transport/third_party/quic-go/.githooks/pre-commit create mode 100644 electron/native/private-transport/third_party/quic-go/.github/FUNDING.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/dependabot.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/build-interop-docker.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/clusterfuzz-coverage.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/clusterfuzz-lite-batch.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/clusterfuzz-lite-pr.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/clusterfuzz-lite-prune.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/codspeed.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/cross-compile.sh create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/cross-compile.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/go-generate.sh create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/govulncheck.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/integration.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/lint.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.github/workflows/unit.yml create mode 100644 electron/native/private-transport/third_party/quic-go/.gitignore create mode 100644 electron/native/private-transport/third_party/quic-go/.golangci.yml create mode 100644 electron/native/private-transport/third_party/quic-go/FIPS140.md create mode 100644 electron/native/private-transport/third_party/quic-go/FUZZING.md create mode 100644 electron/native/private-transport/third_party/quic-go/LICENSE create mode 100644 electron/native/private-transport/third_party/quic-go/QORTAL_PATCH.md create mode 100644 electron/native/private-transport/third_party/quic-go/README.md create mode 100644 electron/native/private-transport/third_party/quic-go/SECURITY.md create mode 100644 electron/native/private-transport/third_party/quic-go/assets/LICENSE.md create mode 100644 electron/native/private-transport/third_party/quic-go/assets/logo.svg create mode 100644 electron/native/private-transport/third_party/quic-go/assets/quic-go-logo.png create mode 100644 electron/native/private-transport/third_party/quic-go/buffer_pool.go create mode 100644 electron/native/private-transport/third_party/quic-go/buffer_pool_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/client.go create mode 100644 electron/native/private-transport/third_party/quic-go/client_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/closed_conn.go create mode 100644 electron/native/private-transport/third_party/quic-go/closed_conn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/codecov.yml create mode 100644 electron/native/private-transport/third_party/quic-go/config.go create mode 100644 electron/native/private-transport/third_party/quic-go/config_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/conn_id_generator.go create mode 100644 electron/native/private-transport/third_party/quic-go/conn_id_generator_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/conn_id_manager.go create mode 100644 electron/native/private-transport/third_party/quic-go/conn_id_manager_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/conn_wrapped_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/connection.go create mode 100644 electron/native/private-transport/third_party/quic-go/connection_logging.go create mode 100644 electron/native/private-transport/third_party/quic-go/connection_logging_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/connection_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/crypto_stream.go create mode 100644 electron/native/private-transport/third_party/quic-go/crypto_stream_manager.go create mode 100644 electron/native/private-transport/third_party/quic-go/crypto_stream_manager_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/crypto_stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/datagram_buffer.go create mode 100644 electron/native/private-transport/third_party/quic-go/datagram_queue.go create mode 100644 electron/native/private-transport/third_party/quic-go/datagram_queue_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/errors.go create mode 100644 electron/native/private-transport/third_party/quic-go/errors_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/example/client/main.go create mode 100644 electron/native/private-transport/third_party/quic-go/example/echo/echo.go create mode 100644 electron/native/private-transport/third_party/quic-go/example/main.go create mode 100644 electron/native/private-transport/third_party/quic-go/flow_controller_base.go create mode 100644 electron/native/private-transport/third_party/quic-go/flow_controller_connection.go create mode 100644 electron/native/private-transport/third_party/quic-go/flow_controller_connection_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/flow_controller_stream.go create mode 100644 electron/native/private-transport/third_party/quic-go/flow_controller_stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/flow_controller_test_helpers_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/frame_sorter.go create mode 100644 electron/native/private-transport/third_party/quic-go/frame_sorter_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/framer.go create mode 100644 electron/native/private-transport/third_party/quic-go/framer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/go.mod create mode 100644 electron/native/private-transport/third_party/quic-go/go.sum create mode 100644 electron/native/private-transport/third_party/quic-go/http3/README.md create mode 100644 electron/native/private-transport/third_party/quic-go/http3/body.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/body_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/capsule.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/capsule_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/client.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/client_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/conn.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/conn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/datagram_buffer.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/error.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/error_codes.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/error_codes_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/error_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/frames.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/frames_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/gzip_reader.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/headers.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/headers_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/http3_helper_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/internal/testdata/ca.pem create mode 100644 electron/native/private-transport/third_party/quic-go/http3/internal/testdata/cert.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/internal/testdata/cert.pem create mode 100644 electron/native/private-transport/third_party/quic-go/http3/internal/testdata/cert_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/internal/testdata/generate_key.sh create mode 100644 electron/native/private-transport/third_party/quic-go/http3/internal/testdata/priv.key create mode 100644 electron/native/private-transport/third_party/quic-go/http3/ip_addr.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/mock_clientconn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/mock_datagram_stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/mock_quic_listener_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/mockgen.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/priority.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/priority_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/qlog.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/qlog/event.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/qlog/event_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/qlog/frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/qlog/frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/qlog/qlog_dir.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/qlog/qlog_dir_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/request_writer.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/request_writer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/response_writer.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/response_writer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/server.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/server_conn.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/server_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/settings_0rtt.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/settings_0rtt_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/state_tracking_stream.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/state_tracking_stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/stream.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/trace.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/transport.go create mode 100644 electron/native/private-transport/third_party/quic-go/http3/transport_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/benchmark_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/cancelation_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/close_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/conn_id_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/connection_migration_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/datagram_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/deadline_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/drop_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/early_data_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/handshake_context_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/handshake_drop_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/handshake_rtt_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/handshake_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http3_stream_priority_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http_0rtt_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http_datagram_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http_hotswap_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http_qlog_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http_raw_conn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http_shutdown_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/http_trace_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/key_update_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/mitm_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/mtu_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/multiplex_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/nat_rebinding_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/packetization_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/qlog_dir_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/qlog_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/resumption_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/rtt_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/self_go124_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/self_go125_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/self_suite_linux_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/self_suite_others_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/self_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/simnet_helper_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/stateless_reset_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/stream_priority_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/timeout_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/self/zero_rtt_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/tools/crypto.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/tools/crypto_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/tools/israce/norace.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/tools/israce/race.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/tools/proxy/proxy.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/tools/proxy/proxy_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/tools/qlog.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/versionnegotiation/handshake_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/versionnegotiation/rtt_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/integrationtests/versionnegotiation/test_helper_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/interface.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/ack_eliciting.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/ack_eliciting_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/ecn.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/ecn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/interfaces.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/lost_packet_tracker.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/lost_packet_tracker_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/mock_ecn_handler_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/mockgen.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/packet.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/packet_number_generator.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/packet_number_generator_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/received_packet_handler.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/received_packet_handler_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/received_packet_history.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/received_packet_history_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/received_packet_tracker.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/received_packet_tracker_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/send_mode.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/send_mode_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/sent_packet_handler.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/sent_packet_handler_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/sent_packet_history.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/ackhandler/sent_packet_history_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/bandwidth.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/bandwidth_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/clock.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/cubic.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/cubic_sender.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/cubic_sender_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/cubic_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/hybrid_slow_start.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/hybrid_slow_start_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/interface.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/pacer.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/congestion/pacer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/datagrambuffer/buffer.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/datagrambuffer/buffer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/aead.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/aead_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/cipher_suite.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/cipher_suite_fips140.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/crypto_setup.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/crypto_setup_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/fake_conn.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/handshake_fuzz_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/handshake_helpers_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/header_protector.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/hkdf.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/hkdf_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/initial_aead.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/initial_aead_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/interface.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/retry.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/retry_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/session_ticket.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/session_ticket_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/tls_config_go126.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/tls_config_go126_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/tls_config_go127.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/token_generator.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/token_generator_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/token_protector.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/token_protector_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/updatable_aead.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/handshake/updatable_aead_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/mocks/ackhandler/sent_packet_handler.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/mocks/congestion.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/mocks/crypto_setup.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/mocks/long_header_opener.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/mocks/mockgen.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/mocks/short_header_opener.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/mocks/short_header_sealer.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/monotime/time.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/monotime/time_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/connection_id.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/connection_id_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/encryption_level.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/encryption_level_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/key_phase.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/key_phase_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/packet_number.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/packet_number_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/params.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/params_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/perspective.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/perspective_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/protocol.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/protocol_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/stream.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/version.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/protocol/version_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/qerr/error_codes.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/qerr/errorcodes_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/qerr/errors.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/qerr/errors_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/qtls/cipher_suite.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/qtls/cipher_suite_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/testdata/ca.pem create mode 100644 electron/native/private-transport/third_party/quic-go/internal/testdata/cert.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/testdata/cert.pem create mode 100644 electron/native/private-transport/third_party/quic-go/internal/testdata/cert_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/testdata/generate_key.sh create mode 100644 electron/native/private-transport/third_party/quic-go/internal/testdata/priv.key create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/buffered_write_closer.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/buffered_write_closer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/connstats.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/linkedlist/README.md create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/linkedlist/linkedlist.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/log.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/log_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/minheap/minheap.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/minheap/minheap_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/rand.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/rand_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/ringbuffer/ringbuffer.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/ringbuffer/ringbuffer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/rtt_stats.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/utils/rtt_stats_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/ack_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/ack_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/ack_frequency_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/ack_frequency_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/ack_range.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/ack_range_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/connection_close_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/connection_close_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/crypto_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/crypto_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/data_blocked_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/data_blocked_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/datagram_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/datagram_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/extended_header.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/extended_header_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/frame_parser.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/frame_parser_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/frame_type.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/frame_type_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/handshake_done_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/handshake_done_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/header.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/header_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/immediate_ack_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/immediate_ack_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/log.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/log_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/max_data_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/max_data_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/max_stream_data_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/max_stream_data_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/max_streams_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/max_streams_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/new_connection_id_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/new_connection_id_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/new_token_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/new_token_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/path_challenge_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/path_challenge_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/path_response_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/path_response_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/ping_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/ping_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/pool.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/pool_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/reset_stream_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/reset_stream_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/retire_connection_id_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/retire_connection_id_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/short_header.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/short_header_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/stop_sending_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/stop_sending_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/stream_data_blocked_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/stream_data_blocked_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/stream_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/stream_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/streams_blocked_frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/streams_blocked_frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/test_helpers_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/transport_parameter_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/transport_parameters.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/version_negotiation.go create mode 100644 electron/native/private-transport/third_party/quic-go/internal/wire/version_negotiation_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/interop/Dockerfile create mode 100644 electron/native/private-transport/third_party/quic-go/interop/client/main.go create mode 100644 electron/native/private-transport/third_party/quic-go/interop/http09/client.go create mode 100644 electron/native/private-transport/third_party/quic-go/interop/http09/http_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/interop/http09/server.go create mode 100644 electron/native/private-transport/third_party/quic-go/interop/run_endpoint.sh create mode 100644 electron/native/private-transport/third_party/quic-go/interop/server/main.go create mode 100644 electron/native/private-transport/third_party/quic-go/interop/utils/logging.go create mode 100644 electron/native/private-transport/third_party/quic-go/metrics/dashboards/README.md create mode 100644 electron/native/private-transport/third_party/quic-go/metrics/dashboards/datasources.yml create mode 100644 electron/native/private-transport/third_party/quic-go/metrics/dashboards/docker-compose.yml create mode 100644 electron/native/private-transport/third_party/quic-go/metrics/dashboards/prometheus.yml create mode 100644 electron/native/private-transport/third_party/quic-go/metrics/dashboards/quic-go.json create mode 100644 electron/native/private-transport/third_party/quic-go/mock_ack_frame_source_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_conn_runner_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_frame_source_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_packer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_packet_handler_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_packetconn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_raw_conn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_sealing_manager_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_send_conn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_sender_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_stream_control_frame_getter_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_stream_frame_getter_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_stream_sender_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mock_unpacker_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/mockgen.go create mode 100644 electron/native/private-transport/third_party/quic-go/mtu_discoverer.go create mode 100644 electron/native/private-transport/third_party/quic-go/mtu_discoverer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/oss-fuzz.sh create mode 100644 electron/native/private-transport/third_party/quic-go/packet_packer.go create mode 100644 electron/native/private-transport/third_party/quic-go/packet_packer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/packet_unpacker.go create mode 100644 electron/native/private-transport/third_party/quic-go/packet_unpacker_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/path_manager.go create mode 100644 electron/native/private-transport/third_party/quic-go/path_manager_outgoing.go create mode 100644 electron/native/private-transport/third_party/quic-go/path_manager_outgoing_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/path_manager_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/benchmark_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/event.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/event_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/frame.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/frame_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/json_helper_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/packet_header.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/packet_header_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/qlog_dir.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/qlog_dir_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/types.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlog/types_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlogwriter/jsontext/encoder.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlogwriter/jsontext/encoder_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlogwriter/trace.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlogwriter/trace_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlogwriter/writer.go create mode 100644 electron/native/private-transport/third_party/quic-go/qlogwriter/writer_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/quic_linux_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/quic_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/quicvarint/io.go create mode 100644 electron/native/private-transport/third_party/quic-go/quicvarint/io_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/quicvarint/varint.go create mode 100644 electron/native/private-transport/third_party/quic-go/quicvarint/varint_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/receive_stream.go create mode 100644 electron/native/private-transport/third_party/quic-go/receive_stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/retransmission_queue.go create mode 100644 electron/native/private-transport/third_party/quic-go/retransmission_queue_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/send_conn.go create mode 100644 electron/native/private-transport/third_party/quic-go/send_conn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/send_queue.go create mode 100644 electron/native/private-transport/third_party/quic-go/send_queue_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/send_stream.go create mode 100644 electron/native/private-transport/third_party/quic-go/send_stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/server.go create mode 100644 electron/native/private-transport/third_party/quic-go/server_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/sni.go create mode 100644 electron/native/private-transport/third_party/quic-go/sni_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/stateless_reset.go create mode 100644 electron/native/private-transport/third_party/quic-go/stateless_reset_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/stream.go create mode 100644 electron/native/private-transport/third_party/quic-go/stream_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/streams_map.go create mode 100644 electron/native/private-transport/third_party/quic-go/streams_map_incoming.go create mode 100644 electron/native/private-transport/third_party/quic-go/streams_map_incoming_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/streams_map_outgoing.go create mode 100644 electron/native/private-transport/third_party/quic-go/streams_map_outgoing_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/streams_map_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_buffers.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_buffers_inspect.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_buffers_inspect_stub.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_buffers_nonopenbsd.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_buffers_openbsd.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_buffers_write.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_df.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_df_darwin.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_df_darwin_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_df_linux.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_df_windows.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_helper_darwin.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_helper_freebsd.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_helper_linux.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_helper_linux_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_helper_nonlinux.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_helper_nonlinux_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_no_oob.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_oob.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_oob_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_windows.go create mode 100644 electron/native/private-transport/third_party/quic-go/sys_conn_windows_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/events/event_recorder.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/events/event_recorder_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/frames.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/README.md create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/queue.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/queue_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/router.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/simconn.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/simconn_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/simlink.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/simlink_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/simnet.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/simnet/simnet_synctest_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/testutils/testutils.go create mode 100644 electron/native/private-transport/third_party/quic-go/token_store.go create mode 100644 electron/native/private-transport/third_party/quic-go/token_store_test.go create mode 100644 electron/native/private-transport/third_party/quic-go/transport.go create mode 100644 electron/native/private-transport/third_party/quic-go/transport_test.go diff --git a/docs/private-upload-investigation.md b/docs/private-upload-investigation.md new file mode 100644 index 00000000..f3ca47c1 --- /dev/null +++ b/docs/private-upload-investigation.md @@ -0,0 +1,135 @@ +# Private upload throughput investigation — 2026-09-12 + +## Confirmed in the local transport fixture + +quic-go's HTTP/3 `stateTrackingStream.enqueueDatagram` silently drops received +datagrams once its per-stream queue contains 32 packets. The outer QUIC layer +can already have acknowledged these packets, so outer packet-loss statistics +do not expose this discard. Inner QUIC must recover the missing tunnel packets. + +A test-only Go workspace and overlay instrumented that exact discard branch. +The module cache, running Hub, and VPS binaries were not modified. + +The opt-in `QORTAL_BULK_BENCH=1` test in +`electron/src/private-channel-step3.test.ts` sends 64 opaque 524,886-byte messages +over one keyed reliable stream with two concurrent native submissions. The +fixture returns small acknowledgements. A local UDP proxy adds 60 ms in each +direction. It does not intentionally drop packets; its explicit drop counter +does not measure OS socket drops or packet reordering. + +| HTTP/3 receive queue | Seconds for 33,592,704 bytes | MB/s (decimal) | Instrumented relay queue drops | Inner reported loss | Outer reported loss | +| --- | ---: | ---: | ---: | ---: | ---: | +| 32, run 1 | 6.297 | 5.33 | 25 | 25 | 0 | +| 32, run 2 | 9.803 | 3.43 | 194 | 194 | 0 | +| 1024, run 1 | 2.416 | 13.91 | 0 | 191 | 13 | +| 1024, run 2 | 3.664 | 9.17 | 0 | 972 | 458 | + +The larger queue is an experiment, not a proposed production default. It removes +this discard point but does not remove all loss or reproduce a real network +perfectly. The exact match between the instrumented discard count and inner loss +in both stock-queue runs establishes a concrete loss mechanism in this fixture. + +## Production applicability and limits + +The VPS relay checkout at inspection was `8cbdaf3`, declaring quic-go v0.60.0 +and masque-go v0.4.0. v0.60.0 has the same 32-packet discard branch as the Hub +fixture's v0.62.0. This identifies a relevant production risk, not a direct +measurement of the running VPS relay's discard count. + +Earlier real-upload diagnostics showed inner loss with no outer loss and little +time spent in encryption or backend disk synchronization. Those observations are +consistent with this mechanism, but do not prove it accounts for all slow upload +time. The fixture uses a Go receiver, not the production Python backend, and +does not include QApp encryption, browser work, or storage. + +## Follow-up + +- Add bounded, observable tunnel receive buffering, with per-tunnel and aggregate + memory limits; do not replace the cap with unbounded queues. +- Check receive draining, burst handling and loss at the other queue boundaries. +- Measure throughput and media latency together before choosing production bounds. +- Validate on the actual relay/backend path before claiming a production speed fix. + +## Implemented validation + +The final implementation uses bounded rings at both DATAGRAM receive layers, +not the experimental constant-only change. Large limits are enabled after +admission, shared accounting is capped at 32 MiB, and packets older than 50 ms +are discarded when processed. Upstream raw packet safety limits remain intact. + +With small datagrams every 20 ms on the same inner connection: + +| Transfer | Simulated RTT | Elapsed | MB/s | Small datagrams received | Small datagram p95 RTT | +| --- | ---: | ---: | ---: | ---: | ---: | +| 33,592,704 bytes | 120 ms | 2.174 s | 15.45 | 104 / 104 | 181 ms | +| 134,370,816 bytes | 120 ms | 6.128 s | 21.93 | 294 / 294 | 170 ms | + +Both runs reported zero full/budget/expiry drops at the new receive queues. +The longer run's new raw-packet counter reported 58 drops at QUIC's existing +256-packet unprocessed-packet cap. Inner loss was 57 packets and outer loss 18; +these numbers measure different points and must not be subtracted to infer +another exact loss count. The raw cap remains unchanged for flood protection. +This local fixture is not evidence that the VPS has those exact raw drops. + +Validation passed: queue bounds/order/ownership/expiry/close/global-budget tests +under the race detector; upstream QUIC and HTTP/3 race tests; Hub native race +tests; 42 Electron transport tests; relay race tests; TypeScript checking; the +relay Docker build stage; real Hub-sidecar/relay membership, renewal, replay and +logout integration using isolated loopback Reticulum and generated test accounts. + +The VPS services were not modified by this implementation. Real deployment and +a live-path measurement are still required to establish the user-visible speed. + +## Production Python receiver reproduction + +After deploying the relay and restarting Hub 0.10.1, a real upload still delivered +about 84 MB in 40 s. Encryption consumed only 0.227 s of measured cumulative time; +file reads 3.334 s. Native sends averaged 458 ms and acknowledgements 593 ms per +roughly 512 KiB batch. These are overlapping request times, not additive durations. +No receive-pressure reports appeared from the updated relay. The backend socket +had `rb212992` and 3869 cumulative kernel drops (not attributable to one upload). + +The Step 4 opt-in benchmark now exercises the real Python QUIC server, parser, +signed session authentication, bootstrap and attachment through the patched relay +and sidecar. It sends the same 64 x 524886-byte reliable messages with two native +sends in flight and 120 ms simulated RTT. A benchmark-only binary handler returns +a small acknowledgement; storage, file encryption and browser work are excluded. + +| Receiver variant | Effective Linux socket receive buffer | Seconds | MB/s | Kernel drops / inner loss | +| --- | ---: | ---: | ---: | ---: | +| Production asyncio, default socket | 212992 bytes | 12.591 | 2.67 | 772 / 772 | +| Production asyncio, 4 MiB SO_RCVBUF request | 8388608 bytes | 5.993 | 5.61 | 0 / 0 | +| Test-only uvloop, same buffer | 8388608 bytes | 4.162 | 8.07 | 0 / 0 | +| Test-only uvloop + coalesced transmit scheduling | 8388608 bytes | 3.392 | 9.90 | 238 / 238 | + +Linux reports doubled SO_RCVBUF accounting. Changing this setting is not itself +a complete fix: inner RTT rose to 336 ms in the larger-buffer asyncio run and +235 ms with uvloop, versus roughly 120 ms at the unchanged outer connection. +The coalescing experiment still overflowed. These are diagnostic experiments, +not approved production settings or a claim that buffering alone resolves this. + +Without profiling, the larger-buffer asyncio receiver consumed 4.98 CPU seconds +for its 5.99-second transfer. A separate cProfile run (not a throughput result) +recorded about 28900 UDP callbacks and transmit checks. Major costs were QUIC +packet receive/send preparation, repeated header parsing, event-loop dispatch, +and framing. AEAD decryption was about 0.16 s self-time in that profiled run. + +This reproduces the low-throughput symptom with the actual Python transport +without any storage involvement. The socket drop count exactly matching inner +loss establishes a second loss point. It also establishes substantial per-packet +processing cost even when socket drops are eliminated. The remaining work is +the backend receive path, including a bounded buffer and efficient processing / +scheduling, with mixed-traffic latency validation—not another relay-only tweak. + +Reproduce from Hub's root with Go and uv available: + +``` +QORTAL_PYTHON_BENCH=1 npx vitest run electron/src/private-channel-step4.test.ts -t 'benchmarks real Python' +``` + +Diagnostic-only environment switches: `QORTAL_STEP4_PROFILE=1`, +`QORTAL_STEP4_UVLOOP=1` (uses `uv run --with uvloop`, no production dependency +change), and `QORTAL_STEP4_COALESCE=1`. These affect only the isolated fixture. + +Upstream implementation: +https://github.com/quic-go/quic-go/blob/v0.60.0/http3/state_tracking_stream.go diff --git a/docs/qapp-moq-transport.md b/docs/qapp-moq-transport.md index 991e1f13..0a4f98d2 100644 --- a/docs/qapp-moq-transport.md +++ b/docs/qapp-moq-transport.md @@ -1,5 +1,42 @@ # Generic multi-track Q-App transport +## Bounded tunnel receive buffering (sidecar 0.10.1) + +Hub and the standalone relay use documented local quic-go v0.62.0 / masque-go +v0.5.0 replacements for bounded receive burst absorption. Both the QUIC DATAGRAM +queue and the HTTP/3 tunnel queue use owned-payload FIFO rings, packet/byte caps, +lazy 50 ms expiry, and one shared 32 MiB live-allocation budget per process. +Ring storage is included; this is not a process RSS limit. Queue growth cannot +bypass the shared budget. Large empty rings and closed queues release storage. + +The larger profile (1024 packets / 1 MiB charged packet data plus bounded ring +slots) is enabled only after successful tunnel admission. Defaults before +admission retain the upstream 32/128 packet caps. No QApp chooses memory limits. +The relay still validates access and targets before opening or enabling a tunnel. + +Native session metrics distinguish HTTP/3 `tunnelReceiveDroppedFull`, +`tunnelReceiveDroppedBudget`, `tunnelReceiveDroppedExpired` from corresponding +`outerReceiveDropped*` QUIC receive-queue counters. They also expose tunnel queued +bytes and maximum observed residence time. Outer counters include shared relay +connection traffic. Expired residence times can exceed 50 ms; stale entries are +never delivered. Relay pressure logs are numeric aggregates, rate-limited to one +report per 30 seconds, including raw QUIC receive-queue overflow. + +This does not alter congestion control, packet encryption, stream priorities, +capture, codecs, jitter policy or call logic. Encrypted packets remain opaque. +FIFO buffering cannot guarantee priority or bandwidth across connections. + +Build/restart Hub with its matching 0.10.1 sidecar and rebuild the relay to gain +both directions' improvements. No QApp or backend wire-protocol change is needed. +Docker and native builds must include `third_party/`; Go 1.26+ is required. +See the vendored `QORTAL_PATCH.md` files for maintenance and bounds tests. + +The opt-in `QORTAL_BULK_BENCH=1` Step 3 test sends 32 MiB through real MASQUE with +0/120 ms simulated RTT while sending small datagrams every 20 ms. It reports +throughput, receive drops and datagram p95 RTT; it requires at least 95% of the +small packets to arrive with p95 below 500 ms. This is a local transport regression +test, not a WAN speed guarantee or a full media-quality test. + Sidecar 0.5.0 extends the existing owner-bound `PRIVATE_DATA_CHANNEL` capability. No capture, codec, media encryption, jitter, call membership, or media-specific priority policy lives in Hub. @@ -171,3 +208,49 @@ before requests reached their handlers. Regression tests exercise the actual line-plus-binary Serve path, including legacy requests and malformed fields. Rebuild/restart Hub with the matching sidecar; no backend or relay update is required for this validation correction. + +### Bounded binary reliable messages (sidecar 0.10.0) + +Authenticated private channels expose `features.maxBinaryMessageBytes` (1 MiB +minus the encoding byte). ArrayBuffer/typed-array messages larger than 64 KiB +require a keyed reliable stream. JSON, primary-stream messages, and datagrams +retain their existing limits. Hub treats the binary payload as opaque; applications +own framing, encryption, durable acknowledgements, and idempotent retries. + +Large-message send admission uses separate budgets: 2 MiB per stream, 4 MiB per +channel, 8 MiB per owner, and 32 MiB globally. Capacity is released when native +submission completes or fails, not when the application's durable reply arrives. +Small-message admission is independent. These are bounded queues, not a guarantee +of bandwidth isolation: streams still share connection congestion control. + +The backend must advertise `maxReliablePayloadBytes` in its authenticated attach +response. Missing/invalid advertisements retain the old 64 KiB ceiling; larger +sends fail locally with `BULK_TRANSPORT_UNSUPPORTED`. Applications must negotiate +their own binary protocol before using it. Rebuild/restart Hub with sidecar +0.10.0 to enable the larger limit. No relay change is required. + +### Private transport diagnostics + +Native `sessionMetrics` also reports `innerPacketsSent/Lost`, +`innerWireBytesSent/Lost`, and their `outer` counterparts, plus `outerRttMillis`. +These are QUIC loss-detection estimates, not proof of physical packet loss: +reordering can cause packets to be declared lost. Wire-byte totals include +retransmissions and are distinct from the existing application-byte totals. +Outer counters cover the pooled relay connection and can include other tunnels; +do not add the same outer totals across sessions. Compare snapshots of a single +connection during an isolated test. Loss totals may decrease when loss detection +is corrected, so do not assume every delta is nonnegative. + +`tunnelWrites`, `tunnelWriteMicros`, and `tunnelWriteErrors` measure submission +to the outer datagram transport for this tunnel. Time is cumulative across +completed writes, not end-to-end delivery time. Existing `datagramsDropped` is +an application-datagram submission error count, not a network-loss counter. +These measurements contain no account IDs, endpoints, payloads, or keys. They +do not change queue limits, congestion algorithms, or retry behavior. + +Reliable native sends share a six-second queue-and-write budget under the +seven-second IPC timeout; individual writes remain capped at five seconds. +Queue expiry before any write returns `RELIABLE_SEND_NOT_STARTED`, which an +application can safely back off and retry. Actual write failures still reset +the affected stream and must not be treated as unsent work. MoQ retains its +existing expiry policy; queue memory/admission limits are unchanged. diff --git a/electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go b/electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go index 1a54b0c9..02e6e9c8 100644 --- a/electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go +++ b/electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go @@ -35,6 +35,8 @@ const ( ownerBindingHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ) +type benchmarkConnectionKey struct{} + type fixture struct { mu sync.Mutex backendUDP *net.UDPConn @@ -73,6 +75,7 @@ func main() { f.mu.Lock() v := map[string]interface{}{"backendSource": f.backendSource, "relayEgress": f.relayEgress, "reliableCount": f.reliableCount, "datagramCount": f.datagramCount, "tokenUsed": f.tokenUsed} f.mu.Unlock() + v["receiveBuffers"] = quic.GlobalDatagramReceiveBufferStats() _ = enc.Encode(v) case "stopRelay": f.stopRelay() @@ -126,6 +129,11 @@ func startFixture() (*fixture, map[string]interface{}, error) { template := uritemplate.MustNew("https://" + relayAddress + "/.well-known/masque/udp/{target_host}/{target_port}/") mux := http.NewServeMux() mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, r *http.Request) { + // The opt-in delay benchmark forwards through a local UDP proxy. + if os.Getenv("QORTAL_STEP3_TEST_ACK_ONLY") == "1" { + r.Host = relayAddress + r.URL.Host = relayAddress + } req, e := masque.ParseProxyRequest(r, template) if e != nil || req.Target != f.backendAddress { w.WriteHeader(http.StatusBadRequest) @@ -144,9 +152,15 @@ func startFixture() (*fixture, map[string]interface{}, error) { f.mu.Lock() f.relayEgress = egress.LocalAddr().String() f.mu.Unlock() + w.(http3.HTTPStreamer).HTTPStream().EnableDatagramReceiveBuffer() + if c, ok := r.Context().Value(benchmarkConnectionKey{}).(*quic.Conn); ok { + c.EnableDatagramReceiveBuffer() + } _ = f.proxy.ProxyConnectedSocket(w, req, egress) }) - f.relayServer = &http3.Server{TLSConfig: &tls.Config{Certificates: []tls.Certificate{relayCert}, NextProtos: []string{http3.NextProtoH3}}, QUICConfig: &quic.Config{EnableDatagrams: true}, EnableDatagrams: true, Handler: mux} + f.relayServer = &http3.Server{ConnContext: func(ctx context.Context, c *quic.Conn) context.Context { + return context.WithValue(ctx, benchmarkConnectionKey{}, c) + }, TLSConfig: &tls.Config{Certificates: []tls.Certificate{relayCert}, NextProtos: []string{http3.NextProtoH3}}, QUICConfig: &quic.Config{EnableDatagrams: true}, EnableDatagrams: true, Handler: mux} go func() { _ = f.relayServer.Serve(relayUDP) }() relayPin := sha256.Sum256(relayDER) backendPin := sha256.Sum256(backendDER) @@ -192,6 +206,10 @@ func (f *fixture) handleBackend(conn *quic.Conn) { } f.mu.Unlock() response := map[string]interface{}{"ok": valid, "logicalSessionId": logicalSessionID, "transportGeneration": 1, "reliable": true, "datagrams": true} + if os.Getenv("QORTAL_STEP3_TEST_BULK") == "1" { + response["reliableStreams"] = true + response["maxReliablePayloadBytes"] = innerquic.MaxReliablePayloadBytes + } if !valid { response["code"] = "ATTACH_TOKEN_REJECTED" } @@ -203,6 +221,20 @@ func (f *fixture) handleBackend(conn *quic.Conn) { return } go f.echoDatagrams(conn) + go func() { + for { + keyed, err := conn.AcceptStream(context.Background()) + if err != nil { + return + } + go f.echoReliable(keyed) + } + }() + f.echoReliable(stream) +} + +func (f *fixture) echoReliable(stream *quic.Stream) { + defer stream.Close() for { message, err := innerquic.ReadFrame(stream) if err != nil { @@ -214,6 +246,9 @@ func (f *fixture) handleBackend(conn *quic.Conn) { f.mu.Lock() f.reliableCount++ f.mu.Unlock() + if os.Getenv("QORTAL_STEP3_TEST_ACK_ONLY") == "1" { + message.Payload = []byte("ack") + } if innerquic.WriteFrame(stream, message) != nil { return } diff --git a/electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go b/electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go index 9171f1dc..09aee86e 100644 --- a/electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go +++ b/electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -33,6 +34,8 @@ type relay struct { lastEgress string } +type connectionKey struct{} + func main() { target := os.Getenv("QORTAL_STEP4_BACKEND_ADDRESS") parsedTarget, err := net.ResolveUDPAddr("udp", target) @@ -51,6 +54,10 @@ func main() { template := uritemplate.MustNew("https://" + udp.LocalAddr().String() + "/.well-known/masque/udp/{target_host}/{target_port}/") mux := http.NewServeMux() mux.HandleFunc("/.well-known/masque/udp/", func(w http.ResponseWriter, request *http.Request) { + if os.Getenv("QORTAL_STEP4_BULK_BENCH") == "1" { + request.Host = udp.LocalAddr().String() + request.URL.Host = request.Host + } proxyRequest, parseErr := masque.ParseProxyRequest(request, template) if parseErr != nil || proxyRequest.Target != r.target { w.WriteHeader(http.StatusBadRequest) @@ -64,9 +71,14 @@ func main() { r.mu.Lock() r.lastEgress = egress.LocalAddr().String() r.mu.Unlock() + w.(http3.HTTPStreamer).HTTPStream().EnableDatagramReceiveBuffer() + request.Context().Value(connectionKey{}).(*quic.Conn).EnableDatagramReceiveBuffer() _ = r.proxy.ProxyConnectedSocket(w, proxyRequest, egress) }) r.server = &http3.Server{ + ConnContext: func(ctx context.Context, c *quic.Conn) context.Context { + return context.WithValue(ctx, connectionKey{}, c) + }, TLSConfig: &tls.Config{Certificates: []tls.Certificate{certificate}, NextProtos: []string{http3.NextProtoH3}}, QUICConfig: &quic.Config{EnableDatagrams: true}, EnableDatagrams: true, diff --git a/electron/native/private-transport/go.mod b/electron/native/private-transport/go.mod index aa2fcc8f..87c752e3 100644 --- a/electron/native/private-transport/go.mod +++ b/electron/native/private-transport/go.mod @@ -4,6 +4,10 @@ go 1.26.0 replace github.com/mengelbart/moqtransport => ./third_party/moqtransport +replace github.com/quic-go/quic-go => ./third_party/quic-go + +replace github.com/quic-go/masque-go => ./third_party/masque-go + require ( github.com/cloudflare/circl v1.6.5 github.com/mengelbart/moqtransport v0.5.1-0.20260831154657-9eaf40a4dedd diff --git a/electron/native/private-transport/internal/innerquic/framing.go b/electron/native/private-transport/internal/innerquic/framing.go index 1f0437a3..a8f6106f 100644 --- a/electron/native/private-transport/internal/innerquic/framing.go +++ b/electron/native/private-transport/internal/innerquic/framing.go @@ -10,7 +10,7 @@ import ( const ( ALPN = "qortal-private/1" ProtocolVersion = 1 - MaxReliablePayloadBytes = 64 * 1024 + MaxReliablePayloadBytes = 1024 * 1024 MaxMetadataBytes = 4 * 1024 MaxDatagramPayloadBytes = 1024 InnerPacketSize = 1200 diff --git a/electron/native/private-transport/internal/innerquic/session.go b/electron/native/private-transport/internal/innerquic/session.go index dee3764c..5ef879fb 100644 --- a/electron/native/private-transport/internal/innerquic/session.go +++ b/electron/native/private-transport/internal/innerquic/session.go @@ -49,12 +49,32 @@ type Event struct { Data []byte } type Metrics struct { - InnerRTTMillis int64 `json:"innerRttMillis"` - BytesSent uint64 `json:"bytesSent"` - BytesReceived uint64 `json:"bytesReceived"` - DatagramsDropped uint64 `json:"datagramsDropped"` - StreamErrors uint64 `json:"streamErrors"` - ConnectionErrors uint64 `json:"connectionErrors"` + TunnelReceiveQueuedBytes int64 `json:"tunnelReceiveQueuedBytes"` + TunnelReceiveDroppedFull uint64 `json:"tunnelReceiveDroppedFull"` + TunnelReceiveDroppedBudget uint64 `json:"tunnelReceiveDroppedBudget"` + TunnelReceiveDroppedExpired uint64 `json:"tunnelReceiveDroppedExpired"` + TunnelReceiveMaxResidenceMicros uint64 `json:"tunnelReceiveMaxResidenceMicros"` + OuterReceiveDroppedFull uint64 `json:"outerReceiveDroppedFull"` + OuterReceiveDroppedBudget uint64 `json:"outerReceiveDroppedBudget"` + OuterReceiveDroppedExpired uint64 `json:"outerReceiveDroppedExpired"` + InnerPacketsSent uint64 `json:"innerPacketsSent"` + InnerPacketsLost uint64 `json:"innerPacketsLost"` + InnerWireBytesSent uint64 `json:"innerWireBytesSent"` + InnerWireBytesLost uint64 `json:"innerWireBytesLost"` + OuterRTTMillis int64 `json:"outerRttMillis"` + OuterPacketsSent uint64 `json:"outerPacketsSent"` + OuterPacketsLost uint64 `json:"outerPacketsLost"` + OuterWireBytesSent uint64 `json:"outerWireBytesSent"` + OuterWireBytesLost uint64 `json:"outerWireBytesLost"` + TunnelWrites uint64 `json:"tunnelWrites"` + TunnelWriteMicros uint64 `json:"tunnelWriteMicros"` + TunnelWriteErrors uint64 `json:"tunnelWriteErrors"` + InnerRTTMillis int64 `json:"innerRttMillis"` + BytesSent uint64 `json:"bytesSent"` + BytesReceived uint64 `json:"bytesReceived"` + DatagramsDropped uint64 `json:"datagramsDropped"` + StreamErrors uint64 `json:"streamErrors"` + ConnectionErrors uint64 `json:"connectionErrors"` } type Session struct { @@ -66,6 +86,7 @@ type Session struct { streamsMu sync.Mutex streams map[string]*reliableLane reliableStreams bool + maxReliableBytes int closed atomic.Bool appSent atomic.Uint64 appReceived atomic.Uint64 @@ -83,6 +104,7 @@ type attachMetadata struct { OwnerBindingHash string `json:"ownerBindingHash"` } type attachedMetadata struct { + MaxReliableBytes int `json:"maxReliablePayloadBytes"` ReliableStreams bool `json:"reliableStreams"` OK bool `json:"ok"` LogicalSessionID string `json:"logicalSessionId"` @@ -195,9 +217,13 @@ func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error return fail(errors.New("SESSION_ATTACH_FAILED")) } _ = stream.SetDeadline(time.Time{}) + limit := attached.MaxReliableBytes + if limit < 64*1024 || limit > MaxReliablePayloadBytes { + limit = 64 * 1024 + } s := &Session{tunnel: tunnel, conn: conn, stream: stream, onEvent: onEvent, reliableWriter: newBoundedFrameWriter(stream), reliableStreams: attached.ReliableStreams, - streams: make(map[string]*reliableLane)} + streams: make(map[string]*reliableLane), maxReliableBytes: limit} go s.readReliable() go s.readDatagrams() go s.watchConnection() @@ -205,14 +231,18 @@ func Open(ctx context.Context, cfg Config, onEvent func(Event)) (*Session, error } func (s *Session) SendReliable(messageID string, data []byte) error { + return s.SendReliableWithTimeout(messageID, data, 5*time.Second) +} + +func (s *Session) SendReliableWithTimeout(messageID string, data []byte, timeout time.Duration) error { if s.closed.Load() { return errors.New("TRANSPORT_CLOSED") } - if len(messageID) == 0 || len(messageID) > 128 || len(data) > MaxReliablePayloadBytes { + if len(messageID) == 0 || len(messageID) > 128 || len(data) > 64*1024 { return errors.New("FRAME_TOO_LARGE") } metadata, _ := Metadata(messageMetadata{MessageID: messageID}) - err := s.reliableWriter.write(Frame{Type: FrameReliable, Metadata: metadata, Payload: data}, 5*time.Second) + err := s.reliableWriter.write(Frame{Type: FrameReliable, Metadata: metadata, Payload: data}, timeout) if err == nil { s.appSent.Add(uint64(len(data))) } else { @@ -239,7 +269,21 @@ func (s *Session) SendDatagram(messageID string, data []byte) error { func (s *Session) Metrics() Metrics { stats := s.conn.ConnectionStats() - return Metrics{InnerRTTMillis: stats.SmoothedRTT.Milliseconds(), BytesSent: s.appSent.Load(), BytesReceived: s.appReceived.Load(), DatagramsDropped: s.datagramsDropped.Load(), StreamErrors: s.streamErrors.Load(), ConnectionErrors: s.connectionErrors.Load()} + m := Metrics{InnerRTTMillis: stats.SmoothedRTT.Milliseconds(), BytesSent: s.appSent.Load(), BytesReceived: s.appReceived.Load(), DatagramsDropped: s.datagramsDropped.Load(), StreamErrors: s.streamErrors.Load(), ConnectionErrors: s.connectionErrors.Load(), + InnerPacketsSent: stats.PacketsSent, InnerPacketsLost: stats.PacketsLost, + InnerWireBytesSent: stats.BytesSent, InnerWireBytesLost: stats.BytesLost} + if s.tunnel != nil { + h, q := s.tunnel.ReceiveBufferMetrics() + m.TunnelReceiveQueuedBytes, m.TunnelReceiveDroppedFull, m.TunnelReceiveDroppedBudget, m.TunnelReceiveDroppedExpired = h.QueuedBytes, h.DroppedFull, h.DroppedBudget, h.DroppedExpired + m.TunnelReceiveMaxResidenceMicros = h.MaxResidenceMicros + m.OuterReceiveDroppedFull, m.OuterReceiveDroppedBudget, m.OuterReceiveDroppedExpired = q.DroppedFull, q.DroppedBudget, q.DroppedExpired + outer, writes, micros, errors := s.tunnel.TransportMetrics() + m.OuterRTTMillis = outer.SmoothedRTT.Milliseconds() + m.OuterPacketsSent, m.OuterPacketsLost = outer.PacketsSent, outer.PacketsLost + m.OuterWireBytesSent, m.OuterWireBytesLost = outer.BytesSent, outer.BytesLost + m.TunnelWrites, m.TunnelWriteMicros, m.TunnelWriteErrors = writes, micros, errors + } + return m } func (s *Session) Close() error { diff --git a/electron/native/private-transport/internal/innerquic/streams.go b/electron/native/private-transport/internal/innerquic/streams.go index 65d28a3f..c39ee803 100644 --- a/electron/native/private-transport/internal/innerquic/streams.go +++ b/electron/native/private-transport/internal/innerquic/streams.go @@ -18,12 +18,23 @@ type reliableLane struct { // Keys are opaque application routing labels, scoped to this attached session. // End sends the final frame and FIN; the reply side remains alive until EOF. func (s *Session) SendReliableStream(key, messageID string, data []byte, end bool) error { + return s.SendReliableStreamWithTimeout(key, messageID, data, end, 5*time.Second) +} + +func (s *Session) SendReliableStreamWithTimeout(key, messageID string, data []byte, end bool, timeout time.Duration) error { if s.closed.Load() || s.conn.Context().Err() != nil { return errors.New("TRANSPORT_CLOSED") } if !s.reliableStreams { return errors.New("RELIABLE_STREAMS_UNSUPPORTED") } + limit := s.maxReliableBytes + if limit == 0 { + limit = 64 * 1024 + } + if len(data) > limit { + return errors.New("BULK_TRANSPORT_UNSUPPORTED") + } if !streamKeyPattern.MatchString(key) || len(messageID) == 0 || len(messageID) > 128 || len(data) > MaxReliablePayloadBytes { return errors.New("invalid reliable stream message") } @@ -54,7 +65,7 @@ func (s *Session) SendReliableStream(key, messageID string, data []byte, end boo } s.streamsMu.Unlock() metadata, _ := Metadata(messageMetadata{MessageID: messageID}) - if err := lane.writer.write(Frame{Type: FrameReliable, Metadata: metadata, Payload: data}, 5*time.Second); err != nil { + if err := lane.writer.write(Frame{Type: FrameReliable, Metadata: metadata, Payload: data}, timeout); err != nil { lane.stream.CancelRead(1) lane.stream.CancelWrite(1) return errors.New("RELIABLE_STREAM_FAILED") diff --git a/electron/native/private-transport/internal/innerquic/streams_test.go b/electron/native/private-transport/internal/innerquic/streams_test.go index 101516f8..720aba30 100644 --- a/electron/native/private-transport/internal/innerquic/streams_test.go +++ b/electron/native/private-transport/internal/innerquic/streams_test.go @@ -41,7 +41,17 @@ func TestControlStreamProgressesWhileBulkIsFlowControlled(t *testing.T) { } defer server.CloseWithError(0, "test complete") events := make(chan Event, 4) - session := &Session{conn: client, reliableStreams: true, streams: map[string]*reliableLane{}, onEvent: func(e Event) { events <- e }} + session := &Session{conn: client, reliableStreams: true, maxReliableBytes: MaxReliablePayloadBytes, streams: map[string]*reliableLane{}, onEvent: func(e Event) { events <- e }} + // An older attached peer does not opt into larger frames. Reject locally, + // without creating a stream or disturbing its connection. + session.maxReliableBytes = 0 + if err := session.SendReliableStream("unsupported", "large", make([]byte, 65537), false); err == nil || err.Error() != "BULK_TRANSPORT_UNSUPPORTED" { + t.Fatalf("expected old-peer capability rejection, got %v", err) + } + if len(session.streams) != 0 { + t.Fatal("unsupported send created a stream") + } + session.maxReliableBytes = MaxReliablePayloadBytes bulkResult := make(chan error, 1) go func() { bulkResult <- session.SendReliableStream("bulk", "bulk-message", make([]byte, MaxReliablePayloadBytes), false) diff --git a/electron/native/private-transport/internal/masque/client.go b/electron/native/private-transport/internal/masque/client.go index 6becd755..1a8310d9 100644 --- a/electron/native/private-transport/internal/masque/client.go +++ b/electron/native/private-transport/internal/masque/client.go @@ -12,6 +12,7 @@ import ( "net/http" "net/netip" "sync" + "sync/atomic" "time" masque "github.com/quic-go/masque-go" @@ -46,13 +47,58 @@ type Config struct { } type Tunnel struct { - conn net.PacketConn - onClose func() - closeOnce sync.Once + conn net.PacketConn + outer *quic.Conn + writes atomic.Uint64 + writeNanos atomic.Uint64 + writeErrors atomic.Uint64 + onClose func() + closeOnce sync.Once } // PacketConn is the only packet path exposed to the inner QUIC transport. -func (t *Tunnel) PacketConn() net.PacketConn { return t.conn } +func (t *Tunnel) PacketConn() net.PacketConn { + return &measuredPacketConn{PacketConn: t.conn, tunnel: t} +} + +type measuredPacketConn struct { + net.PacketConn + tunnel *Tunnel +} + +func (c *measuredPacketConn) WriteTo(data []byte, addr net.Addr) (int, error) { + start := time.Now() + n, err := c.PacketConn.WriteTo(data, addr) + c.tunnel.writes.Add(1) + c.tunnel.writeNanos.Add(uint64(time.Since(start))) + if err != nil { + c.tunnel.writeErrors.Add(1) + } + return n, err +} + +// Outer counters cover the pooled relay connection, potentially shared by +// multiple tunnels. Write timing covers only this tunnel's packet submission. +func (t *Tunnel) TransportMetrics() (quic.ConnectionStats, uint64, uint64, uint64) { + var stats quic.ConnectionStats + if t.outer != nil { + stats = t.outer.ConnectionStats() + } + return stats, t.writes.Load(), t.writeNanos.Load() / uint64(time.Microsecond), t.writeErrors.Load() +} + +func (t *Tunnel) ReceiveBufferMetrics() (quic.DatagramReceiveBufferStats, quic.DatagramReceiveBufferStats) { + var httpStats, quicStats quic.DatagramReceiveBufferStats + if c, ok := t.conn.(interface { + DatagramReceiveBufferStats() quic.DatagramReceiveBufferStats + }); ok { + httpStats = c.DatagramReceiveBufferStats() + } + if t.outer != nil { + quicStats = t.outer.DatagramReceiveBufferStats() + } + return httpStats, quicStats +} // RemoteAddr is the logical backend endpoint represented by CONNECT-UDP. func (t *Tunnel) RemoteAddr() net.Addr { @@ -123,6 +169,7 @@ func Open(ctx context.Context, cfg Config) (*Tunnel, error) { if err != nil { return nil, fmt.Errorf("create CONNECT-UDP request: %w", err) } + var outer *quic.Conn transport := masque.Transport{ TLSClientConfig: tlsConfig, QUICConfig: tunnelQUICConfig(), @@ -132,7 +179,9 @@ func Open(ctx context.Context, cfg Config) (*Tunnel, error) { if addr != relayAddress { return nil, fmt.Errorf("unexpected relay address %q", addr) } - return quic.DialAddr(dialCtx, relayAddress, tlsConf, quicConf) + var err error + outer, err = quic.DialAddr(dialCtx, relayAddress, tlsConf, quicConf) + return outer, err }, } conn, response, err := transport.Dial(req) @@ -142,7 +191,9 @@ func Open(ctx context.Context, cfg Config) (*Tunnel, error) { } return nil, fmt.Errorf("open CONNECT-UDP tunnel: %w", err) } - return &Tunnel{conn: conn}, nil + conn.EnableDatagramReceiveBuffer() + outer.EnableDatagramReceiveBuffer() + return &Tunnel{conn: conn, outer: outer}, nil } func parseLiteralAddrPort(name, value string) (netip.AddrPort, error) { diff --git a/electron/native/private-transport/internal/masque/client_test.go b/electron/native/private-transport/internal/masque/client_test.go index 913ffbd6..bf864847 100644 --- a/electron/native/private-transport/internal/masque/client_test.go +++ b/electron/native/private-transport/internal/masque/client_test.go @@ -1,6 +1,40 @@ package masqueclient -import "testing" +import ( + "net" + "testing" + "time" +) + +func TestPacketMeasurementPreservesDeliveryAndErrors(t *testing.T) { + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + tunnel := &Tunnel{conn: conn} + measured := tunnel.PacketConn() + if err := measured.SetDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if n, err := measured.WriteTo([]byte("opaque"), conn.LocalAddr()); n != 6 || err != nil { + t.Fatalf("write changed: %d %v", n, err) + } + buffer := make([]byte, 10) + if n, _, err := measured.ReadFrom(buffer); err != nil || string(buffer[:n]) != "opaque" { + t.Fatalf("read changed: %v", err) + } + if err := measured.Close(); err != nil { + t.Fatal(err) + } + if _, err := measured.WriteTo([]byte("opaque"), conn.LocalAddr()); err == nil { + t.Fatal("closed socket write succeeded") + } + _, writes, _, failures := tunnel.TransportMetrics() + if writes != 2 || failures != 1 { + t.Fatalf("wrong counters: %d %d", writes, failures) + } +} func TestTunnelQUICConfigKeepsIdleCallTunnelAlive(t *testing.T) { config := tunnelQUICConfig() diff --git a/electron/native/private-transport/internal/masque/pool.go b/electron/native/private-transport/internal/masque/pool.go index 47b3bc59..7165f7cc 100644 --- a/electron/native/private-transport/internal/masque/pool.go +++ b/electron/native/private-transport/internal/masque/pool.go @@ -240,5 +240,7 @@ func openPrepared(ctx context.Context, cfg Config) (*Tunnel, error) { } return nil, relayConnectionError(err) } - return &Tunnel{conn: conn, onClose: release}, nil + conn.EnableDatagramReceiveBuffer() + p.conn.EnableDatagramReceiveBuffer() + return &Tunnel{conn: conn, outer: p.conn, onClose: release}, nil } diff --git a/electron/native/private-transport/internal/moqclient/session.go b/electron/native/private-transport/internal/moqclient/session.go index 3cd150c0..37b41fe4 100644 --- a/electron/native/private-transport/internal/moqclient/session.go +++ b/electron/native/private-transport/internal/moqclient/session.go @@ -67,12 +67,18 @@ type Event struct { type Metrics struct { moqtransport.DeliveryMetrics - InnerRTTMillis int64 `json:"innerRttMillis"` - ObjectsSent uint64 `json:"objectsSent"` - ObjectsRead uint64 `json:"objectsReceived"` - BytesSent uint64 `json:"bytesSent"` - BytesRead uint64 `json:"bytesReceived"` - ObjectErrors uint64 `json:"objectErrors"` + TunnelReceiveDroppedFull uint64 `json:"tunnelReceiveDroppedFull"` + TunnelReceiveDroppedBudget uint64 `json:"tunnelReceiveDroppedBudget"` + TunnelReceiveDroppedExpired uint64 `json:"tunnelReceiveDroppedExpired"` + OuterReceiveDroppedFull uint64 `json:"outerReceiveDroppedFull"` + OuterReceiveDroppedBudget uint64 `json:"outerReceiveDroppedBudget"` + OuterReceiveDroppedExpired uint64 `json:"outerReceiveDroppedExpired"` + InnerRTTMillis int64 `json:"innerRttMillis"` + ObjectsSent uint64 `json:"objectsSent"` + ObjectsRead uint64 `json:"objectsReceived"` + BytesSent uint64 `json:"bytesSent"` + BytesRead uint64 `json:"bytesReceived"` + ObjectErrors uint64 `json:"objectErrors"` } type publicationHandler struct { @@ -424,13 +430,19 @@ func (s *Session) readSubscription(subscriptionID string, entry subscription) { func (s *Session) Metrics() Metrics { stats := s.conn.ConnectionStats() - return Metrics{ + m := Metrics{ DeliveryMetrics: s.moq.DeliveryMetrics(), InnerRTTMillis: stats.SmoothedRTT.Milliseconds(), ObjectsSent: s.objectsSent.Load(), ObjectsRead: s.objectsRead.Load(), BytesSent: s.bytesSent.Load(), BytesRead: s.bytesRead.Load(), ObjectErrors: s.objectErrors.Load(), } + if s.tunnel != nil { + h, q := s.tunnel.ReceiveBufferMetrics() + m.TunnelReceiveDroppedFull, m.TunnelReceiveDroppedBudget, m.TunnelReceiveDroppedExpired = h.DroppedFull, h.DroppedBudget, h.DroppedExpired + m.OuterReceiveDroppedFull, m.OuterReceiveDroppedBudget, m.OuterReceiveDroppedExpired = q.DroppedFull, q.DroppedBudget, q.DroppedExpired + } + return m } func (s *Session) Close() error { diff --git a/electron/native/private-transport/internal/protocol/reliable_dispatch.go b/electron/native/private-transport/internal/protocol/reliable_dispatch.go index c1872f52..2bf8b382 100644 --- a/electron/native/private-transport/internal/protocol/reliable_dispatch.go +++ b/electron/native/private-transport/internal/protocol/reliable_dispatch.go @@ -14,10 +14,11 @@ type reliableJob struct { // One worker per active opaque stream key preserves ordering without serializing // unrelated streams in the IPC reader. Idle workers disappear immediately. type reliableDispatcher struct { - mu sync.Mutex - queues map[string][]reliableJob - bytes int - workers sync.WaitGroup + maxQueueAge time.Duration + mu sync.Mutex + queues map[string][]reliableJob + bytes int + workers sync.WaitGroup } func (d *reliableDispatcher) submit(key string, size int, run func(bool)) bool { @@ -49,9 +50,11 @@ func (d *reliableDispatcher) submit(key string, size int, run func(bool)) bool { job := queue[0] d.queues[key] = queue[1:] d.mu.Unlock() - // Native writes get five seconds; leave room inside the seven-second IPC - // deadline. Expired queued work must not send after its caller times out. - job.run(time.Since(job.enqueued) <= time.Second) + age := d.maxQueueAge + if age == 0 { + age = time.Second + } + job.run(time.Since(job.enqueued) <= age) d.mu.Lock() d.bytes -= job.bytes d.mu.Unlock() diff --git a/electron/native/private-transport/internal/protocol/reliable_dispatch_test.go b/electron/native/private-transport/internal/protocol/reliable_dispatch_test.go index 6b9f6b74..1c009194 100644 --- a/electron/native/private-transport/internal/protocol/reliable_dispatch_test.go +++ b/electron/native/private-transport/internal/protocol/reliable_dispatch_test.go @@ -1,75 +1,38 @@ package protocol import ( - "sync" "testing" "time" ) -func TestReliableDispatcherIsolatesBlockedStreamsAndKeepsOrder(t *testing.T) { - var d reliableDispatcher - blocked := make(chan struct{}) - var once sync.Once - release := func() { once.Do(func() { close(blocked) }) } - defer func() { release(); d.workers.Wait() }() - entered := make(chan struct{}) - control := make(chan struct{}) - ordered := make(chan int, 2) - if !d.submit("bulk", 64, func(bool) { close(entered); <-blocked }) { - t.Fatal("rejected bulk") - } - <-entered - d.submit("bulk", 64, func(bool) { ordered <- 1 }) - d.submit("bulk", 64, func(bool) { ordered <- 2 }) - d.submit("control", 64, func(bool) { close(control) }) - select { - case <-control: - case <-time.After(time.Second): - t.Fatal("control blocked behind bulk") - } - select { - case <-ordered: - t.Fatal("same stream reordered") - default: - } - release() - for _, expected := range []int{1, 2} { - select { - case value := <-ordered: - if value != expected { - t.Fatal("out of order") - } - case <-time.After(time.Second): - t.Fatal("queue did not drain") +func TestReliableQueueSharesLongerBudgetWithoutSendingExpiredWork(t *testing.T) { + for _, tc := range []struct { + age time.Duration + ready bool + }{ + {2 * time.Second, true}, {7 * time.Second, false}, + } { + d := reliableDispatcher{maxQueueAge: 6 * time.Second} + started, release := make(chan struct{}), make(chan struct{}) + d.submit("bulk", 10, func(bool) { close(started); <-release }) + <-started + result := make(chan bool, 1) + d.submit("bulk", 10, func(ready bool) { result <- ready }) + d.mu.Lock() + d.queues["bulk"][0].enqueued = time.Now().Add(-tc.age) + d.mu.Unlock() + control := make(chan bool, 1) + d.submit("control", 1, func(ready bool) { control <- ready }) + if !<-control { + t.Fatal("control blocked") + } + close(release) + if got := <-result; got != tc.ready { + t.Fatalf("age=%v ready=%v", tc.age, got) + } + d.workers.Wait() + if d.bytes != 0 || len(d.queues) != 0 { + t.Fatal("queue leaked reservations") } - } -} - -func TestReliableDispatcherBoundsCapacity(t *testing.T) { - var d reliableDispatcher - blocked := make(chan struct{}) - defer func() { close(blocked); d.workers.Wait() }() - if !d.submit("bulk", 4*1024*1024, func(bool) { <-blocked }) { - t.Fatal("unexpected rejection") - } - if d.submit("other", 1, func(bool) {}) { - t.Fatal("unbounded queued bytes") - } -} - -func TestReliableDispatcherDoesNotRunExpiredQueuedWrites(t *testing.T) { - var d reliableDispatcher - blocked, entered := make(chan struct{}), make(chan struct{}) - result := make(chan bool, 1) - d.submit("bulk", 1, func(bool) { close(entered); <-blocked }) - <-entered - d.submit("bulk", 1, func(ready bool) { result <- ready }) - d.mu.Lock() - d.queues["bulk"][0].enqueued = time.Now().Add(-2 * time.Second) - d.mu.Unlock() - close(blocked) - d.workers.Wait() - if <-result { - t.Fatal("expired queued write was allowed to send") } } diff --git a/electron/native/private-transport/internal/protocol/server.go b/electron/native/private-transport/internal/protocol/server.go index 4b58e32c..ee705967 100644 --- a/electron/native/private-transport/internal/protocol/server.go +++ b/electron/native/private-transport/internal/protocol/server.go @@ -23,7 +23,7 @@ import ( const ( Version = 2 - SidecarVersion = "0.9.1" + SidecarVersion = "0.10.1" MaxControlMessageBytes = 64 * 1024 MaxBinaryMessageBytes = innerquic.MaxReliablePayloadBytes maxRememberedRequestIDs = 4096 @@ -105,7 +105,7 @@ func (s *Server) Serve(ctx context.Context, input io.Reader, output io.Writer) e defer func() { cancel(); preparations.Wait(); masqueclient.CloseAllRelays() }() reader := bufio.NewReaderSize(input, 4096) s.emit = func(event Event, binary []byte) { s.write(output, event, binary) } - var reliableSends reliableDispatcher + reliableSends := reliableDispatcher{maxQueueAge: 6 * time.Second} var moqSends reliableDispatcher defer moqSends.workers.Wait() defer reliableSends.workers.Wait() @@ -163,7 +163,7 @@ func (s *Server) Serve(ctx context.Context, input io.Reader, output io.Writer) e } if !reliableSends.submit(p.SessionID+"\x00"+p.StreamKey, len(binary), func(ready bool) { if !ready { - s.write(output, failure(req.RequestID, "RELIABLE_STREAM_FAILED", "stream queue timed out"), nil) + s.write(output, failure(req.RequestID, "RELIABLE_SEND_NOT_STARTED", "send expired before writing"), nil) return } response, _ := s.handleRequest(ctx, req, binary) @@ -584,10 +584,20 @@ func (s *Server) sendPrivate(req Request, binary []byte, reliable bool) Response } var err error if reliable { + // Queueing and writing share one budget, below the seven-second IPC + // timeout. Never restart a full write timeout after a long queue wait. + budget := 5 * time.Second + if !req.receivedAt.IsZero() { + remaining := time.Until(req.receivedAt.Add(6 * time.Second)) + if remaining <= 0 { + return failure(req.RequestID, "RELIABLE_SEND_NOT_STARTED", "send expired before writing") + } + budget = min(budget, remaining) + } if p.StreamKey != "" { - err = session.SendReliableStream(p.StreamKey, p.MessageID, binary, p.EndStream) + err = session.SendReliableStreamWithTimeout(p.StreamKey, p.MessageID, binary, p.EndStream, budget) } else { - err = session.SendReliable(p.MessageID, binary) + err = session.SendReliableWithTimeout(p.MessageID, binary, budget) } } else { err = session.SendDatagram(p.MessageID, binary) diff --git a/electron/native/private-transport/internal/protocol/server_test.go b/electron/native/private-transport/internal/protocol/server_test.go index 35091f03..2676f0a5 100644 --- a/electron/native/private-transport/internal/protocol/server_test.go +++ b/electron/native/private-transport/internal/protocol/server_test.go @@ -157,7 +157,7 @@ func TestOversizedControlMessageFailsWithoutPanic(t *testing.T) { func TestMalformedAndOversizedBinaryFramesFailClosed(t *testing.T) { for _, input := range []string{ `{"version":2,"requestId":"truncated","operation":"sendPrivateReliable","params":{},"binaryLength":5}` + "\nxx", - `{"version":2,"requestId":"oversized","operation":"sendPrivateReliable","params":{},"binaryLength":65537}` + "\n", + `{"version":2,"requestId":"oversized","operation":"sendPrivateReliable","params":{},"binaryLength":1048577}` + "\n", } { var output bytes.Buffer if err := NewServer().Serve(context.Background(), strings.NewReader(input), &output); err != nil { diff --git a/electron/native/private-transport/third_party/masque-go/.github/FUNDING.yml b/electron/native/private-transport/third_party/masque-go/.github/FUNDING.yml new file mode 100644 index 00000000..7de30a1d --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: [marten-seemann] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/electron/native/private-transport/third_party/masque-go/.github/dependabot.yml b/electron/native/private-transport/third_party/masque-go/.github/dependabot.yml new file mode 100644 index 00000000..5ace4600 --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/electron/native/private-transport/third_party/masque-go/.github/workflows/go-generate.sh b/electron/native/private-transport/third_party/masque-go/.github/workflows/go-generate.sh new file mode 100644 index 00000000..b51c8bef --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/.github/workflows/go-generate.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -e + +DIR=$(pwd) +TMP=$(mktemp -d) +cd "$TMP" +cp -r "$DIR" orig +cp -r "$DIR" generated + +cd generated +# delete all go-generated files generated (that adhere to the comment convention) +grep --include \*.go -lrIZ "^// Code generated .* DO NOT EDIT\.$" . | xargs -r --null rm + +# generate everything +go generate ./... +cd .. + +# don't compare fuzzing corpora +diff --exclude=corpus --exclude=.git -ruN orig generated diff --git a/electron/native/private-transport/third_party/masque-go/.github/workflows/lint.yml b/electron/native/private-transport/third_party/masque-go/.github/workflows/lint.yml new file mode 100644 index 00000000..5f38f907 --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/.github/workflows/lint.yml @@ -0,0 +1,46 @@ +on: [push, pull_request] + +jobs: + check: + runs-on: ubuntu-latest + name: Lint + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: "1.27.x" + - name: Check for //go:build ignore in .go files + run: | + IGNORED_FILES=$(grep -rl '//go:build ignore' . --include='*.go') || true + if [ -n "$IGNORED_FILES" ]; then + echo "::error::Found ignored Go files: $IGNORED_FILES" + exit 1 + fi + - name: Check that go.mod is tidied + if: success() || failure() # run this step even if the previous one failed + run: go mod tidy -diff + - name: Run code generators + if: success() || failure() # run this step even if the previous one failed + run: .github/workflows/go-generate.sh + - name: gofmt + if: success() || failure() # run this step even if the previous one failed + run: | + out=$(gofmt -s -l .) + if [[ -n "$out" ]]; then + echo $out | awk '{print "::error file=" $0 ",line=0,col=0::File is not gofmt-ed."}' + exit 1 + fi + - name: go vet + if: success() || failure() # run this step even if the previous one failed + run: go vet ./... + - name: Install staticcheck + if: success() || failure() # run this step even if the previous one failed + run: go install honnef.co/go/tools/cmd/staticcheck@v0.8.1 + - name: Run go fix + if: success() || failure() # run this step even if the previous one failed + run: go fix -diff ./... + - name: staticcheck + if: success() || failure() # run this step even if the previous one failed + run: | + set -o pipefail + staticcheck ./... | sed -e 's@\(.*\)\.go@./\1.go@g' diff --git a/electron/native/private-transport/third_party/masque-go/.github/workflows/unit.yml b/electron/native/private-transport/third_party/masque-go/.github/workflows/unit.yml new file mode 100644 index 00000000..ff6baf44 --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/.github/workflows/unit.yml @@ -0,0 +1,49 @@ +on: [push, pull_request] + +jobs: + unit: + strategy: + fail-fast: false + matrix: + os: [ "ubuntu", "windows", "macos" ] + go: [ "1.26.x", "1.27.x" ] + runs-on: ${{ fromJSON(vars[format('UNIT_RUNNER_{0}', matrix.os)] || format('"{0}-latest"', matrix.os)) }} + name: Unit tests (${{ matrix.os}}, Go ${{ matrix.go }}) + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: ${{ matrix.go }} + - run: go version + - name: Install go-junit-report + run: go install github.com/jstemmer/go-junit-report/v2@v2.1.0 + - name: Run tests + env: + TIMESCALE_FACTOR: 10 + run: go test -v -shuffle on -cover -coverprofile coverage.txt ./... 2>&1 | go-junit-report -set-exit-code -iocopy -out report.xml + - name: Run tests with race detector + env: + TIMESCALE_FACTOR: 10 + run: go test -race -v -shuffle on ./... + - name: Upload coverage to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + env: + OS: ${{ matrix.os }} + GO: ${{ matrix.go }} + with: + files: coverage.txt + env_vars: OS,GO + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload test report to Codecov + if: ${{ !cancelled() }} + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + env: + OS: ${{ matrix.os }} + GO: ${{ matrix.go }} + with: + report_type: test_results + name: Unit tests + files: report.xml + env_vars: OS,GO + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/electron/native/private-transport/third_party/masque-go/.gitignore b/electron/native/private-transport/third_party/masque-go/.gitignore new file mode 100644 index 00000000..48062770 --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/.gitignore @@ -0,0 +1,3 @@ +coverage.txt +*.qlog +*.sqlog diff --git a/electron/native/private-transport/third_party/masque-go/LICENSE b/electron/native/private-transport/third_party/masque-go/LICENSE new file mode 100644 index 00000000..e5a7b51c --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/LICENSE @@ -0,0 +1,7 @@ +Copyright 2024 Marten Seemann + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/electron/native/private-transport/third_party/masque-go/QORTAL_PATCH.md b/electron/native/private-transport/third_party/masque-go/QORTAL_PATCH.md new file mode 100644 index 00000000..41690477 --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/QORTAL_PATCH.md @@ -0,0 +1,10 @@ +# Datagram buffer accessors + +Base: upstream masque-go v0.5.0, with its MIT license and tests retained. +Only `datagram_buffer.go` is added. It exposes the admitted tunnel's HTTP/3 +receive-buffer opt-in and numeric snapshot through `Conn`. + +It requires the paired quic-go module replacement. There are no changes to +CONNECT-UDP framing, certificates, ticket authorization, forwarding or encryption. +Keep this copy identical between Hub and the relay. On upgrade, rebase this one +file and run the upstream tests and real tunnel integration tests. diff --git a/electron/native/private-transport/third_party/masque-go/README.md b/electron/native/private-transport/third_party/masque-go/README.md new file mode 100644 index 00000000..2e82d8db --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/README.md @@ -0,0 +1,17 @@ +# masque-go + +[![Documentation](https://img.shields.io/badge/docs-quic--go.net-red?style=flat)](https://quic-go.net/docs/connect-udp) +[![PkgGoDev](https://pkg.go.dev/badge/github.com/quic-go/masque-go)](https://pkg.go.dev/github.com/quic-go/masque-go) +[![Code Coverage](https://img.shields.io/codecov/c/github/quic-go/masque-go/master.svg?style=flat-square)](https://codecov.io/gh/quic-go/masque-go/) + +masque-go is an implementation of the CONNECT-UDP protocol [RFC 9298](https://datatracker.ietf.org/doc/html/rfc9298), based on [quic-go](https://github.com/quic-go/quic-go). It provides both a client and a proxy implementation. + +Detailed documentation can be found on [quic-go.net](https://quic-go.net/docs/connect-udp/). + +## Release Policy + +masque-go always aims to support the latest two Go releases. + +## Contributing + +We are always happy to welcome new contributors! If you have any questions, please feel free to reach out by opening an issue or leaving a comment. diff --git a/electron/native/private-transport/third_party/masque-go/client.go b/electron/native/private-transport/third_party/masque-go/client.go new file mode 100644 index 00000000..2560b18b --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/client.go @@ -0,0 +1,126 @@ +package masque + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + + "github.com/dunglas/httpsfv" +) + +// A ClientConn represents a connection to a single proxy server. +// Multiple proxied connections can be established over a single ClientConn. +type ClientConn struct { + conn *quic.Conn + clientConn *http3.ClientConn +} + +// Dial dials a proxied connection to a target server over the proxy connection. +func (c *ClientConn) Dial(req *Request) (*Conn, *http.Response, error) { + return c.dial(req, nil) +} + +func (c *ClientConn) dial(req *Request, closeConn func() error) (*Conn, *http.Response, error) { + httpReq := req.req + if httpReq.URL == nil { + return nil, nil, errors.New("masque: request URL is nil") + } + if httpReq.Host == "" && httpReq.URL.Host == "" { + return nil, nil, errors.New("masque: request needs a host") + } + + select { + case <-httpReq.Context().Done(): + return nil, nil, context.Cause(httpReq.Context()) + case <-c.clientConn.Context().Done(): + return nil, nil, context.Cause(c.clientConn.Context()) + case <-c.clientConn.ReceivedSettings(): + } + settings := c.clientConn.Settings() + if !settings.EnableExtendedConnect { + return nil, nil, errors.New("masque: server didn't enable Extended CONNECT") + } + if !settings.EnableDatagrams { + return nil, nil, errors.New("masque: server didn't enable Datagrams") + } + + rstr, err := c.clientConn.OpenRequestStream(httpReq.Context()) + if err != nil { + return nil, nil, fmt.Errorf("masque: failed to open request stream: %w", err) + } + + var keepStream bool + defer func() { + if !keepStream { + rstr.CancelRead(quic.StreamErrorCode(http3.ErrCodeNoError)) + rstr.CancelWrite(quic.StreamErrorCode(http3.ErrCodeNoError)) + } + }() + if err := rstr.SendRequestHeader(httpReq); err != nil { + return nil, nil, fmt.Errorf("masque: failed to send request: %w", err) + } + // TODO: optimistically return the connection + rsp, err := rstr.ReadResponse() + if err != nil { + return nil, nil, fmt.Errorf("masque: failed to read response: %w", err) + } + if rsp.StatusCode < 200 || rsp.StatusCode > 299 { + return nil, rsp, fmt.Errorf("masque: server responded with %d", rsp.StatusCode) + } + + var raddr net.Addr + if udpAddr := nextHopAddr(rsp); udpAddr != nil { + raddr = udpAddr + } else { + raddr = net.Addr(masqueAddr{req.target}) + } + + keepStream = true + return newProxiedConn(rstr, masqueAddr{c.conn.LocalAddr().String()}, raddr, closeConn), rsp, nil +} + +// Extract the Proxy-Status next-hop value as a UDPAddr. +func nextHopAddr(rsp *http.Response) *net.UDPAddr { + proxyStatusVals := rsp.Header.Values("Proxy-Status") + if len(proxyStatusVals) == 0 { + return nil + } + proxyStatus, err := httpsfv.UnmarshalItem(proxyStatusVals) + if err != nil { + log.Printf("bad Proxy-Status: %v", err) + return nil + } + nextHop, ok := proxyStatus.Params.Get("next-hop") + if !ok { + return nil + } + nextHopStr, ok := nextHop.(string) + if !ok { + log.Printf("non-string nextHop value") + return nil + } + if nextHopStr == "" { + return nil + } + host, port, err := net.SplitHostPort(nextHopStr) + if err != nil { + log.Printf("bad next-hop value: %v", err) + return nil + } + ip := net.ParseIP(host) + if ip == nil { + return nil + } + portNum, err := net.LookupPort("udp", port) + if err != nil { + log.Printf("bad port: %v", err) + return nil + } + return &net.UDPAddr{IP: ip, Port: portNum} +} diff --git a/electron/native/private-transport/third_party/masque-go/cmd/client/main.go b/electron/native/private-transport/third_party/masque-go/cmd/client/main.go new file mode 100644 index 00000000..3637ef5d --- /dev/null +++ b/electron/native/private-transport/third_party/masque-go/cmd/client/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "crypto/tls" + "flag" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "strconv" + + "github.com/quic-go/masque-go" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/yosida95/uritemplate/v3" +) + +func main() { + var proxyURITemplate string + flag.StringVar(&proxyURITemplate, "t", "", "URI template") + flag.Parse() + if proxyURITemplate == "" { + flag.Usage() + os.Exit(1) + } + urls := flag.Args() + if len(urls) != 1 { + log.Fatal("usage: client -t