> ## Documentation Index
> Fetch the complete documentation index at: https://docs.panofx.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Remote console

> The end-to-end encrypted channel that lets a paired browser drive a node running somewhere else.

A node's own interface answers only on the computer running it. The remote console lets a paired browser
drive that interface from anywhere:

* The node keeps an outbound WebSocket to the relay.
* A browser opens a second WebSocket to the relay.
* The two run an end-to-end encrypted session through it. The relay forwards bytes it cannot read or forge.

This is the contract between the node (`panofx-node/internal/remote`), the relay (`WsRemoteGateway`) and the
console page (`panofx-web/src/features/remote`). Test vectors are in `docs/remote-console-vectors.json`.
The operator's steps are under [Run on a server](/resolvers/headless).

## Encodings

| Name        | Meaning                                                                  |
| ----------- | ------------------------------------------------------------------------ |
| `b64u`      | base64url, no padding                                                    |
| point       | P-256 public key, uncompressed SEC1, 65 bytes                            |
| sig         | ECDSA P-256 with SHA-256; 64 bytes `r ‖ s` (WebCrypto's format, not DER) |
| `lp(x)`     | 2-byte big-endian length of `x`, then `x`                                |
| `id(pub)`   | lowercase base32, no padding, of `SHA-256(pub)`, first 26 characters     |
| fingerprint | first 8 characters of a channel id, shown as `abcd-efgh`                 |

**Node identity** is a long-lived P-256 key `N`; its channel id is `id(N_pub)`. **Device identity** is a
long-lived, non-extractable P-256 key `D` per browser; its device id is `id(D_pub)`.

## Relay protocol

All relay messages are JSON text frames; `data` fields carry opaque bytes as `b64u`. The relay keeps no
database: routing lives in memory, on a single replica.

<Tabs>
  <Tab title="Node socket: GET /ws/remote/node">
    | Direction    | Message                                                                                                                                      |
    | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
    | relay → node | `{"type":"challenge","nonce":"<64 hex>"}`                                                                                                    |
    | node → relay | `{"type":"hello","v":1,"pub":"<b64u point>","sig":"<b64u sig>","version":"…"}`, `sig` over `"panofx-remote-node\|" + nonce + "\|" + channel` |
    | relay → node | `{"type":"welcome","channel":"<id>","pingMs":20000}`                                                                                         |
    | relay → node | `{"type":"open","sid":"<id>"}` a device connected; `{"type":"close","sid":"<id>"}` it left                                                   |
    | both         | `{"type":"frame","sid":"<id>","data":"<b64u>"}`                                                                                              |
    | node → relay | `{"type":"close","sid":"<id>","reason":"<text>"}`: the relay closes that device socket with 4400                                             |
    | both         | `{"type":"ping"}` / `{"type":"pong"}`                                                                                                        |

    The hello must arrive within 10 s. A newer node connection for the same channel closes the older one
    with 4009; a node that receives 4009 waits 60 s. When a node disconnects, every device socket on its
    channel is closed with 4410 `node_gone`.
  </Tab>

  <Tab title="Device socket: GET /ws/remote/device?channel=<id>">
    | Direction      | Message                                                                                                                                             |
    | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
    | relay → device | `{"type":"ready"}` when the node is online, or `{"type":"offline"}`; the socket stays open up to 10 minutes and gets `ready` when the node connects |
    | both           | `{"type":"frame","data":"<b64u>"}`; frames sent before `ready` are dropped                                                                          |
    | relay → device | `{"type":"closed","reason":"<text>"}`, then close                                                                                                   |

    The relay checks the `Origin` header against `REMOTE_ALLOWED_ORIGINS`; an empty list allows every origin.
  </Tab>
</Tabs>

**Close codes.** 4001 hello timeout, 4002 bad message, 4003 bad hello, 4009 replaced, 4400 closed by the
node, 4403 origin not allowed, 4404 bad channel id, 4410 node gone, 4413 frame too large, 4429 too many
connections or messages.

**Limits.** 8 devices per channel, 20 device sockets per IP, 700,000 characters of `data`, 50 messages/s
per device (burst 200), 500/s per node (burst 2,000). A socket whose `bufferedAmount` stays over 4 MiB for
10 s is closed.

## Session handshake

M1 and M2 are UTF-8 JSON. M3, M4 and every later frame are AES-256-GCM ciphertext. The 12-byte nonce is
`0x00000000 ‖ counter` (u64 big-endian); each key has its own counter from 0, incremented once per frame.
The receiver accepts only the next counter, so a replayed, reordered or dropped frame ends the session, as
does any decryption failure or a counter reaching 2^32.

<Steps>
  <Step title="M1, device → node">
    ```json theme={null}
    {"t":"hello","v":1,"pair":false,"e":"<b64u e_D point>","n":"<b64u 32 random bytes>"}
    ```

    `pair` is true only when the device is pairing with a secret.
  </Step>

  <Step title="M2, node → device">
    The node makes an ephemeral ECDH key `e_N` and computes:

    ```
    th     = SHA-256( lp("panofx-remote-v1") ‖ lp(channel) ‖ lp(pairFlag) ‖ lp(e_D) ‖ lp(n) ‖ lp(e_N) ‖ lp(N_pub) )
    dh     = ECDH(e_N, e_D)
    hk_d2n = HKDF-SHA256(secret = dh, salt = th, info = "panofx-remote-v1 hs d2n", 32 bytes)
    hk_n2d = HKDF-SHA256(secret = dh, salt = th, info = "panofx-remote-v1 hs n2d", 32 bytes)
    sig_N  = Sign(N, "panofx-remote-v1/node" ‖ th)
    ```

    and sends `{"t":"auth","v":1,"e":"<b64u e_N>","pub":"<b64u N_pub>","sig":"<b64u sig_N>"}`, or refuses in
    the clear with `{"t":"refused","reason":"busy"|"version"}`. The device checks `id(pub) == channel`,
    recomputes `th`, verifies `sig_N`, and derives the same keys.
  </Step>

  <Step title="M3, device → node (under hk_d2n, AAD 'panofx-remote-v1 m3')">
    ```json theme={null}
    {"pub":"<b64u D_pub>","sig":"<b64u sig_D>","name":"<device name>","pair":"<b64u proof>"}
    ```

    `sig_D = Sign(D, "panofx-remote-v1/device" ‖ th)`. `pair` is present only when pairing:
    `HMAC-SHA256(key = SHA-256("panofx-remote-v1/pair-secret" ‖ secret), "panofx-remote-v1/pair" ‖ th ‖ D_pub)`.
    The node accepts when `sig_D` verifies and either `id(D_pub)` is in the device list or `pair` matches
    an unexpired pending pairing. A pairing is consumed atomically on success; a failed proof never consumes it.
  </Step>

  <Step title="M4, node → device (under hk_n2d, AAD 'panofx-remote-v1 m4')">
    ```json theme={null}
    {"t":"welcome","node":{"name":"…","address":"0x…","version":"…","network":"base-sepolia","fingerprint":"abcd-efgh"},"device":{"id":"…","name":"…"},"paired":true}
    ```

    or `{"t":"denied","reason":"not_paired"|"pairing_expired"|"bad_proof"|"revoked"|"bad_signature"}`.
  </Step>

  <Step title="Traffic keys">
    ```
    th2   = SHA-256( lp(th) ‖ lp(D_pub) ‖ lp(sig_D) ‖ lp(M4 ciphertext) )
    k_d2n = HKDF-SHA256(secret = dh, salt = th2, info = "panofx-remote-v1 app d2n", 32 bytes)
    k_n2d = HKDF-SHA256(secret = dh, salt = th2, info = "panofx-remote-v1 app n2d", 32 bytes)
    ```

    Application frames use these with AAD `"panofx-remote-v1 app"`. Ephemeral keys and `dh` are discarded.
    A session lasts at most 12 hours; the node then sends `bye` and the browser runs a new handshake.
  </Step>
</Steps>

## Application messages

Request, device → node: `{"t":"req","id":7,"method":"POST","path":"/api/settings","headers":{"x-panofx-passphrase":"…"},"body":"…"}`.
Paths are `/api/...` or a UI asset. A path containing `%`, `..`, `//` or a query-less `?` is refused. The
only header passed through is `x-panofx-passphrase`.

Response, node → device: `{"t":"res","id":7,"status":200,"type":"application/json","enc":"text"|"b64","body":"…","part":0,"last":true}`.
A body over 256,000 characters is split into parts. A whole response is at most 8 MiB. At most 4 requests
run at once per session; more get status 429.

Notice, node → device: `{"t":"bye","reason":"revoked"|"expired"|"remote_off"|"shutting_down"}`.

A device never retries a request that changes something after the socket drops; it reports the result as unknown.

## Pairing

A pairing secret is 32 random bytes as `b64u`. It lasts 10 minutes, or 15 minutes for the link printed at a
headless first boot. The link is `<console URL>/remote#pair=v1.<channel>.<secret>.<network id>`; the
console page removes the fragment at once, shows the fingerprint, and waits for a click.

`PANOFX_REMOTE_PAIR_SECRET` (at least 20 characters) is a pairing secret set in the platform's secret
store. It is valid only while no device is paired; the log then prints only the channel id and fingerprint.

## What a paired browser may do

Every remote session starts locked and is unlocked with the console passphrase.

| Area                                                                                                      | Remote rule                                                                                                   |
| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Status, activity, logs, policy view, metrics, alerts                                                      | Allowed                                                                                                       |
| Name, region, alert settings, corridor toggles; approve, register, go live, on/off, pause/resume, refresh | Allowed; journaled with the device name                                                                       |
| Price or limits                                                                                           | Allowed; a change over 10% from market or current values needs the passphrase again (428) and raises an alert |
| RPC URL, import policy                                                                                    | Allowed before go-live, passphrase again after; public https addresses only                                   |
| Set or change the console passphrase                                                                      | With the current passphrase; removing it is local only                                                        |
| Create or restore a wallet                                                                                | Only while the node has no wallet                                                                             |
| Show recovery phrase                                                                                      | Only to the device that created the wallet, until it is marked backed up                                      |
| Switch wallet, move to a phrase wallet, bring balances over, forget previous                              | Local only                                                                                                    |
| Show private key, download or copy backup                                                                 | Local only                                                                                                    |
| Pair another device, revoke a device                                                                      | Passphrase again; the alert names the device; revoking cascades to devices it paired                          |

A route with no rule is refused. A test keeps the route table complete.

## Threat model

**The relay, or anyone on the path, can** see which channels are online, when devices connect, message
sizes and timing, and device IPs; and drop, delay or reorder traffic, each of which ends a session. It
cannot read or change traffic, pose as a node (the channel id is the hash of the node's key and M2 is
signed) or pose as a device (M3 is signed and bound to the transcript).

**A phishing link** can pair a victim's browser with an attacker's node, whose interface might ask for a
recovery phrase. The fingerprint is shown on both sides, pairing needs an explicit click, and the console
page shows its own warning whenever the node's interface sends a phrase or key.

**A stolen or compromised paired browser** has no path to the wallet's secrets once setup is done, needs
the console passphrase to do anything, and raises an alert on large changes. Revoke it from the node, or
with `panofx-node remote revoke`.

**The console's web deployment and its npm dependencies are trusted.** The console runs on its own host,
uses its own layout with no wallet libraries, has a nonce-based CSP, and shows the node's interface in a
sandboxed, opaque-origin frame that cannot reach the device key. A later version will move the device
credential to a passkey.

**Addresses set remotely** must resolve to public addresses. DNS rebinding after the check is not covered.
