diff --git a/.gitignore b/.gitignore index 8e41ebb30..69cdadeb9 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 @@ -70,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/development.md b/docs/development.md index c8f14df6e..caff76da1 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/local-wallet-signing.md b/docs/local-wallet-signing.md new file mode 100644 index 000000000..d4d41fc05 --- /dev/null +++ b/docs/local-wallet-signing.md @@ -0,0 +1,551 @@ +# 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. +- Public nodes can serve BTC-family sends and QORT trade buys through the local + signer; creating sell offers and all ARRR trading still require a local node. + Hub does not route public-node purchases through a trading proxy. +- 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, +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 + +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 +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 +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. + +### 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 +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. 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 +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; +- 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; +- `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 + 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, 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; +- 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/docs/masque-relay-access.md b/docs/masque-relay-access.md new file mode 100644 index 000000000..61a5181b4 --- /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/docs/private-upload-investigation.md b/docs/private-upload-investigation.md new file mode 100644 index 000000000..f3ca47c1c --- /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 new file mode 100644 index 000000000..643b70885 --- /dev/null +++ b/docs/qapp-moq-transport.md @@ -0,0 +1,296 @@ +# Generic multi-track Q-App transport + +## Complete reliable objects (sidecar 0.11.0) + +`MOQ_OBJECT_PUBLISH` accepts one opaque reliable object using: + +```js +payload: { + trackName: 'live', + objects: [bytes], + delivery: { priority: 1, maxQueueAgeMillis: 1500, groupId: 7, objectId: 1 } +} +``` + +Both IDs must be nonnegative safe integers. The batch contains exactly one object, +at most 1 MiB. Omitting both IDs selects datagrams, still limited to 1 KiB per +object and eight objects per batch. Mixed/missing IDs are rejected, not downgraded. +The authenticated bootstrap must advertise `moqtReliableGroups: true`; otherwise +session opening fails with `UNSUPPORTED_MOQ_TRANSPORT`. + +Each complete object has its own reliable subgroup, with subgroup ID equal to +object ID. IDs strictly increase within a group. A newer group replaces older +dependencies on that publication; late objects from old groups are rejected. +Applications, not Hub, decide what groups mean and authenticate metadata. + +Native delivery sends real FIN and resets outstanding retransmissions on expiry +or group replacement. Write completion acknowledges QUIC admission, not remote +receipt. Bounds: 64 leases / 2 MiB per publication, 4 MiB leased per session; +incoming bound-track queues share 2 MiB plus 64 KiB reserved for small objects, +and unknown aliases share 2 MiB. Wire fields are capped before allocation. +Cancelled data streams are isolated; they do not close unrelated tracks. + +`reliableLeasedBytes` measures unexpired leases, not actual unacknowledged QUIC +bytes. `receiveQueuedBytes`, inner RTT/minimum RTT and packet sent/lost counters +support diagnosis. Datagram pacing is not a bandwidth estimate for streams. +Publisher-priority headers are not a guarantee of QUIC stream priority; bounded +admission and separate datagram delivery limit interference but cannot reserve +physical bandwidth. Capture, codecs and payload encryption remain Q-App-owned. + +Update the backend bootstrap and media service together with Hub and the QApp. +The relay needs no change. No fallback to fragmented delivery is provided. + +## 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. + +`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. + +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. + +### 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/docs/qapp-reticulum.md b/docs/qapp-reticulum.md new file mode 100644 index 000000000..adbc173e4 --- /dev/null +++ b/docs/qapp-reticulum.md @@ -0,0 +1,144 @@ +# 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 view. Desktop Q-Apps use separate Electron web contents and storage +partitions; browser and mobile builds retain the iframe bridge. States are +`CONNECTING`, `CONNECTED`, `RECONNECTING`, +`DISCONNECTED`, `CLOSING`, `CLOSED`, and `ERROR`. + +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 +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 `{"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 +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 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 +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. +## 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 across the Hub window. Hub does not encode, encrypt, or +interpret this app's screen frames; application media logic stays in the Q-App. diff --git a/docs/qapp-streaming-save.md b/docs/qapp-streaming-save.md new file mode 100644 index 000000000..365112457 --- /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/docs/reticulum.md b/docs/reticulum.md index a0b506989..8e82f9461 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -94,6 +94,47 @@ 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. + +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. + +### Q-App document lifecycle + +Desktop Q-Apps run in isolated guest web contents. The shell registers each +guest with Electron main using a per-guest attach token bound to a trusted tab, +service, and name. The guest page has no Node API. Its preload forwards the +existing Q-App request messages to the owning shell view and retains reply +ports. Each app's browser storage has a separate persistent partition. Main +blocks navigation to another app or origin while keeping the guest's owner. +Full navigation (including refresh) starts resource cleanup on +`did-start-navigation` before the new document can create connections. +Same-document navigation retains connections. Tab removal and shell +shutdown/crash also clean registered resources. Browser and mobile builds keep +the iframe bridge. + +Reticulum, private channels, MoQ, and file saves snapshot their old resources +at the same time; slow shutdown in one manager must not delay another manager's +snapshot until after the new page connects. Never trigger owner-wide cleanup +from the guest's `load` event: new-page scripts can already have connected. +Reticulum setup checks ownership again after native setup returns and rejects +connections cancelled in flight instead of returning a dead connected handle. + ### Mesh Coordinator — `reticulum-mesh.ts` Handles hub-to-hub mesh networking, separate from the TLS P2P layer (`p2p-network.ts`). diff --git a/electron/electron-builder.config.arm.json b/electron/electron-builder.config.arm.json index d166cdbfd..1e7a24db2 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" @@ -75,8 +80,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 2d2af7574..c89ae5bf2 100644 --- a/electron/electron-builder.config.json +++ b/electron/electron-builder.config.json @@ -6,10 +6,19 @@ "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" }, + { + "from": "resources/masque_discovery_codec.py", + "to": "reticulum/masque_discovery_codec.py" + }, { "from": "resources/qortal_python_diagnostics.py", "to": "reticulum/qortal_python_diagnostics.py" @@ -40,6 +49,7 @@ "filter": [ "**/*", "!presence_bridge.py", + "!masque_discovery_codec.py", "!qortal_python_diagnostics.py", "!sitecustomize.py", "!qortalland_games.py", @@ -88,8 +98,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 3d82ac6dc..53919d5a0 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" @@ -75,8 +80,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.mac.json b/electron/electron-builder.config.mac.json index 996eed233..af2eea7c8 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 170ce0a86..f12aecac8 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 000000000..6cb52d541 --- /dev/null +++ b/electron/native/private-transport/README.md @@ -0,0 +1,129 @@ +# 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.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 + +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. + +## 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.26+. The normal build uses local Go when available and +automatically falls back to `golang:1.26-bookworm` through Docker otherwise. + +``` +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. 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/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 000000000..02e6e9c82 --- /dev/null +++ b/electron/native/private-transport/cmd/qortal-private-transport-step3-fixture/main.go @@ -0,0 +1,324 @@ +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 benchmarkConnectionKey struct{} + +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() + v["receiveBuffers"] = quic.GlobalDatagramReceiveBufferStats() + _ = 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) { + // 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) + 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() + 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{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) + 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 os.Getenv("QORTAL_STEP3_TEST_BULK") == "1" { + response["reliableStreams"] = true + response["maxReliablePayloadBytes"] = innerquic.MaxReliablePayloadBytes + } + 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) + 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 { + return + } + if message.Type != innerquic.FrameReliable { + return + } + 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 + } + } +} +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 000000000..09aee86e3 --- /dev/null +++ b/electron/native/private-transport/cmd/qortal-private-transport-step4-relay/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "bufio" + "context" + "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 +} + +type connectionKey struct{} + +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) { + 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) + 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() + 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, + 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 000000000..c4152a35c --- /dev/null +++ b/electron/native/private-transport/cmd/qortal-private-transport-test-fixture/main.go @@ -0,0 +1,188 @@ +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) { + 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) + 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 000000000..1cfdafdfa --- /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 000000000..87c752e3f --- /dev/null +++ b/electron/native/private-transport/go.mod @@ -0,0 +1,26 @@ +module qortal.org/qortal-hub/private-transport + +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 + 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.54.0 // indirect + golang.org/x/net v0.56.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 new file mode 100644 index 000000000..ae16ad01c --- /dev/null +++ b/electron/native/private-transport/go.sum @@ -0,0 +1,30 @@ +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/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= +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.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.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.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/innerquic/framing.go b/electron/native/private-transport/internal/innerquic/framing.go new file mode 100644 index 000000000..a8f6106fe --- /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 = 1024 * 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 000000000..a41bd8bd6 --- /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 000000000..5ef879fb1 --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/session.go @@ -0,0 +1,359 @@ +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" +) + +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 + 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 { + 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 { + tunnel *masqueclient.Tunnel + conn *quic.Conn + stream *quic.Stream + onEvent func(Event) + reliableWriter *boundedFrameWriter + streamsMu sync.Mutex + streams map[string]*reliableLane + reliableStreams bool + maxReliableBytes int + 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 { + MaxReliableBytes int `json:"maxReliablePayloadBytes"` + ReliableStreams bool `json:"reliableStreams"` + 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, + privateQUICConfig(), + ) + 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) + } + // 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") + _ = 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") + } + if !stopAttach() || ctx.Err() != nil { + _ = conn.CloseWithError(1, "attach timed out") + 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), maxReliableBytes: limit} + go s.readReliable() + go s.readDatagrams() + go s.watchConnection() + return s, nil +} + +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) > 64*1024 { + return errors.New("FRAME_TOO_LARGE") + } + metadata, _ := Metadata(messageMetadata{MessageID: messageID}) + err := s.reliableWriter.write(Frame{Type: FrameReliable, Metadata: metadata, Payload: data}, timeout) + 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() + 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 { + 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() { + s.readReliableStream(s.stream, true) +} + +func (s *Session) readReliableStream(stream *quic.Stream, primary bool) { + for { + frame, err := ReadFrame(stream) + if err != nil { + if primary && !s.closed.Load() { + s.streamErrors.Add(1) + s.emit(Event{Kind: "error", Code: "STREAM_ERROR"}) + } + return + } + if frame.Type != FrameReliable { + s.streamErrors.Add(1) + 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) + if primary { + 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/innerquic/session_test.go b/electron/native/private-transport/internal/innerquic/session_test.go new file mode 100644 index 000000000..c029feda5 --- /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/innerquic/streams.go b/electron/native/private-transport/internal/innerquic/streams.go new file mode 100644 index 000000000..c39ee8030 --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/streams.go @@ -0,0 +1,80 @@ +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 { + 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") + } + 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}, timeout); 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 000000000..720aba305 --- /dev/null +++ b/electron/native/private-transport/internal/innerquic/streams_test.go @@ -0,0 +1,111 @@ +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, 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) + }() + 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 000000000..d6fd50ac7 --- /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 000000000..c7fcb7493 --- /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/masque/client.go b/electron/native/private-transport/internal/masque/client.go new file mode 100644 index 000000000..1a8310d9d --- /dev/null +++ b/electron/native/private-transport/internal/masque/client.go @@ -0,0 +1,286 @@ +package masqueclient + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "sync" + "sync/atomic" + "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 + +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 { + PreparedRelay string + LegacyRelay bool + RelayAddress string + RelayServerName string + RelayCertSHA256 string + TargetAddress string + Timeout time.Duration +} + +type Tunnel struct { + 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 &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 { + 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) { + if cfg.PreparedRelay != "" { + return openPrepared(ctx, cfg) + } + 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) + } + var outer *quic.Conn + transport := masque.Transport{ + TLSClientConfig: tlsConfig, + 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) { + if addr != relayAddress { + return nil, fmt.Errorf("unexpected relay address %q", addr) + } + var err error + outer, err = quic.DialAddr(dialCtx, relayAddress, tlsConf, quicConf) + return outer, err + }, + } + 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) + } + conn.EnableDatagramReceiveBuffer() + outer.EnableDatagramReceiveBuffer() + return &Tunnel{conn: conn, outer: outer}, 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 { + 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/client_test.go b/electron/native/private-transport/internal/masque/client_test.go new file mode 100644 index 000000000..bf864847f --- /dev/null +++ b/electron/native/private-transport/internal/masque/client_test.go @@ -0,0 +1,45 @@ +package masqueclient + +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() + 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/masque/pool.go b/electron/native/private-transport/internal/masque/pool.go new file mode 100644 index 000000000..7165f7cc8 --- /dev/null +++ b/electron/native/private-transport/internal/masque/pool.go @@ -0,0 +1,246 @@ +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) + } + conn.EnableDatagramReceiveBuffer() + p.conn.EnableDatagramReceiveBuffer() + return &Tunnel{conn: conn, outer: p.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 000000000..9bf16cfc1 --- /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/moqclient/session.go b/electron/native/private-transport/internal/moqclient/session.go new file mode 100644 index 000000000..74934cfc8 --- /dev/null +++ b/electron/native/private-transport/internal/moqclient/session.go @@ -0,0 +1,501 @@ +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 = 128 + 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 + BackendCertSHA256 string + LogicalSessionID string + AttachToken string + PublicationNamespace []string + PublicationTrack string + PublicationTracks []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 { + moqtransport.DeliveryMetrics + 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"` + InnerMinRTTMillis int64 `json:"innerMinRttMillis"` + InnerPacketsSent uint64 `json:"innerPacketsSent"` + InnerPacketsLost uint64 `json:"innerPacketsLost"` + 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 + tracks []string + publication chan *moqtransport.IncomingSubscribeRequest +} + +func (h *publicationHandler) HandleGoAway(string) {} + +func (h *publicationHandler) HandleSubscribe(request *moqtransport.IncomingSubscribeRequest) { + 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(alias) + 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 + publications map[string]*moqtransport.IncomingSubscribeRequest + defaultTrack string + 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) { + 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 { + 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), + mediaQUICConfig(), + ) + if err != nil { + return fail(fmt.Errorf("MOQ_QUIC_FAILED: %w", err)) + } + handler := &publicationHandler{ + namespace: stringsToNamespace(cfg.PublicationNamespace), + track: cfg.PublicationTrack, tracks: tracks, publication: make(chan *moqtransport.IncomingSubscribeRequest, len(tracks)), + } + 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)) + } + 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: publications[tracks[0]], + publications: publications, defaultTrack: tracks[0], onEvent: onEvent, subscriptions: make(map[string]subscription)}, nil +} + +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 { + return s.PublishTrackObject(s.defaultTrack, payload) +} + +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) PublishGroupObject(track string, groupID, objectID uint64, payload []byte, policy moqtransport.DeliveryPolicy) error { + if s.closed.Load() { + return errors.New("MOQ_SESSION_CLOSED") + } + if groupID > maxSafeJSONInteger || objectID > maxSafeJSONInteger || !policy.Valid() { + return errors.New("INVALID_MOQ_CONFIG") + } + if len(payload) == 0 || len(payload) > moqtransport.MaxReliableObjectBytes { + return errors.New("MOQ_OBJECT_TOO_LARGE") + } + publication := s.publications[track] + if publication == nil { + return errors.New("INVALID_MOQ_CONFIG") + } + err := publication.SendGroupObject(moqtransport.Object{GroupID: groupID, ObjectID: objectID, Payload: payload}, policy) + 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) queueTrackObject(track string, payload []byte, policy moqtransport.DeliveryPolicy) (<-chan error, error) { + if s.closed.Load() { + return nil, errors.New("MOQ_SESSION_CLOSED") + } + if len(payload) == 0 || len(payload) > MaxObjectBytes { + return nil, errors.New("MOQ_OBJECT_TOO_LARGE") + } + objectID := s.nextObjectID.Add(1) - 1 + publication := s.publication + if s.publications != nil { + publication = s.publications[track] + } + if publication == nil { + return nil, errors.New("INVALID_MOQ_CONFIG") + } + 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 nil, fmt.Errorf("MOQ_SEND_FAILED: %w", err) + } + return completion, 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 + } + limit := MaxObjectBytes + if object.ForwardingPreference == moqtransport.ObjectForwardingPreferenceSubgroup { + limit = moqtransport.MaxReliableObjectBytes + } + if len(object.Payload) == 0 || len(object.Payload) > limit || + 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() + m := Metrics{ + DeliveryMetrics: s.moq.DeliveryMetrics(), + InnerRTTMillis: stats.SmoothedRTT.Milliseconds(), + InnerMinRTTMillis: stats.MinRTT.Milliseconds(), + InnerPacketsSent: stats.PacketsSent, + InnerPacketsLost: stats.PacketsLost, + 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 { + 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 000000000..3d1a2c977 --- /dev/null +++ b/electron/native/private-transport/internal/moqclient/session_integration_test.go @@ -0,0 +1,235 @@ +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" + tracks := []string{publicationTrack, "auxiliary", "status"} + 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 + } + 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) + } + 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.SendScheduledDatagram(*object, moqtransport.DeliveryPolicy{Priority: index % 3, MaxQueueAgeMillis: 200}); 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, PublicationTracks: tracks, Timeout: 5 * time.Second, + }, func(event Event) { events <- event }) + if err != nil { + t.Fatal(err) + } + defer session.Close() + 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") + } + } + 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/moqclient/session_test.go b/electron/native/private-transport/internal/moqclient/session_test.go new file mode 100644 index 000000000..ad2f6fb11 --- /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/native/private-transport/internal/moqproof/compatibility_test.go b/electron/native/private-transport/internal/moqproof/compatibility_test.go new file mode 100644 index 000000000..d1c99c3ec --- /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/relay.go b/electron/native/private-transport/internal/protocol/relay.go new file mode 100644 index 000000000..72458e352 --- /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/reliable_dispatch.go b/electron/native/private-transport/internal/protocol/reliable_dispatch.go new file mode 100644 index 000000000..2bf8b3823 --- /dev/null +++ b/electron/native/private-transport/internal/protocol/reliable_dispatch.go @@ -0,0 +1,64 @@ +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 { + 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 { + 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() + 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() + } + }() + 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 000000000..1c0091942 --- /dev/null +++ b/electron/native/private-transport/internal/protocol/reliable_dispatch_test.go @@ -0,0 +1,38 @@ +package protocol + +import ( + "testing" + "time" +) + +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") + } + } +} 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 000000000..617c0318e --- /dev/null +++ b/electron/native/private-transport/internal/protocol/server.go @@ -0,0 +1,815 @@ +package protocol + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "github.com/mengelbart/moqtransport" + "io" + "strings" + "sync" + "time" + + "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.11.0" + 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"` + GroupID *uint64 `json:"groupId"` + ObjectID *uint64 `json:"objectId"` +} + +type Request struct { + receivedAt time.Time + 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"` + 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 + 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{}, + moqSessions: map[string]*moqclient.Session{}, seen: map[string]struct{}{}, + } +} + +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) } + reliableSends := reliableDispatcher{maxQueueAge: 6 * time.Second} + var moqSends reliableDispatcher + defer moqSends.workers.Wait() + defer reliableSends.workers.Wait() + 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 + } + 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_SEND_NOT_STARTED", "send expired before writing"), 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{}{}: + 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 { + 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 "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": + 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 "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: + return failure(req.RequestID, "UNKNOWN_OPERATION", "unsupported operation"), false + } +} + +type moqOpenParams struct { + PreparedRelay string `json:"preparedRelay"` + 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 { + var p moqOpenParams + 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") + } + session, err := moqclient.Open(ctx, moqclient.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, + PublicationNamespace: p.PublicationNamespace, PublicationTrack: tracks[0], PublicationTracks: tracks, + 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 { + if code := relayErrorCode(err); code != "" { + return code + } + 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{ + "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", + "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 moqPublishParams + 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") + } + 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") + } + } + 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") + } + var err error + if p.GroupID != nil || p.ObjectID != nil { + if p.GroupID == nil || p.ObjectID == nil || p.Batched { + return failure(req.RequestID, "INVALID_MOQ_CONFIG", "invalid reliable object") + } + err = session.PublishGroupObject(p.TrackName, *p.GroupID, *p.ObjectID, binary, policy) + } else { + err = session.PublishTrackBatch(p.TrackName, objects, policy) + } + 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"` + } + 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 { + PreparedRelay string `json:"preparedRelay"` + 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{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") + } + 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 { + 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) { + 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 privateSendParams + 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 { + // 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.SendReliableStreamWithTimeout(p.StreamKey, p.MessageID, binary, p.EndStream, budget) + } else { + err = session.SendReliableWithTimeout(p.MessageID, binary, budget) + } + } 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" + } + 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 { + PreparedRelay string `json:"preparedRelay"` + 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{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") + } + 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 + 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() + } + for _, x := range ts { + _ = x.Close() + } +} +func (s *Server) rememberRequestID(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + 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 000000000..2676f0a5f --- /dev/null +++ b/electron/native/private-transport/internal/protocol/server_test.go @@ -0,0 +1,174 @@ +package protocol + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "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 + 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 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) + } + 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 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"}`) + 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":1048577}` + "\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/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 000000000..7de30a1d7 --- /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 000000000..5ace4600a --- /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 000000000..b51c8beff --- /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 000000000..5f38f907a --- /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 000000000..ff6baf445 --- /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 000000000..48062770f --- /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 000000000..e5a7b51c6 --- /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 000000000..416904773 --- /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 000000000..2e82d8db4 --- /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 000000000..2560b18b4 --- /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 000000000..3637ef5d5 --- /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