- 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.
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.
Encodings
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.
- Node socket: GET /ws/remote/node
- Device socket: GET /ws/remote/device?channel=<id>
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.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 is0x00000000 ‖ 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.
1
M1, device → node
pair is true only when the device is pairing with a secret.2
M2, node → device
The node makes an ephemeral ECDH key and sends
e_N and computes:{"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.3
M3, device → node (under hk_d2n, AAD 'panofx-remote-v1 m3')
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.4
M4, node → device (under hk_n2d, AAD 'panofx-remote-v1 m4')
{"t":"denied","reason":"not_paired"|"pairing_expired"|"bad_proof"|"revoked"|"bad_signature"}.5
Traffic keys
"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.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 asb64u. 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.
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 withpanofx-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.
