Utly Remote remote.utly.dev

Protocol

The messages Utly Remote sends, as they are on the wire, for anyone who wants to audit the program or write another client. This document describes version 1 of the signaling protocol and the session channels of release 0.2. It is licensed CC BY 4.0; the types it describes live in the proto crate of the repository, and the crate’s serialization tests are the authority where the two disagree.

Every text message is a JSON object with a "type" field in snake_case naming the variant; the other fields follow. Byte fields (keys, signatures, ciphertext) are lowercase hex strings. Nine-digit ids are strings.

1. Signaling

Transport. A WebSocket to wss://broker.utly.dev/v1/ws. The client sends a ping frame every 20 seconds and drops the connection if no pong arrives before the next; the broker closes a connection that stays silent for a minute.

Registration. The broker speaks first:

{"type":"challenge","nonce":<32 bytes>}

The client answers with its Ed25519 public key and a signature over "utly-remote signal register v1" || nonce:

{"type":"register","public_key":<32 bytes>,"signature":<64 bytes>,"version":1}

The broker replies {"type":"registered","id":"123456789"}, or {"type":"rejected","reason":"…"} followed by a close. The id is bound to the public key the first time it is seen and never reassigned. Reasons include unsupported protocol version, bad signature, this id is blocked for abuse. Registering the same key from a second connection replaces the first, which is told rejected with reason another copy of the app signed in with this identity and closed.

Asking for a session. The viewer sends

{"type":"session_request","to":"<host id>","attestation":{"fingerprint":"AB:CD:…","signature":<64 bytes>}}

where fingerprint is the SHA-256 fingerprint of the DTLS certificate the viewer will use, in the colon-separated form SDP uses, and signature is the viewer’s Ed25519 signature over "utly-remote dtls fingerprint v1" || fingerprint with the colons and case normalised. The broker delivers it to the host as

{"type":"session_request","from":"<viewer id>","public_key":<32 bytes>,"attestation":{…}}

adding the viewer’s registered public key so the host can verify the attestation and compare the key with the one it pinned. A request to an id that is offline, unknown, or blocked is answered to the viewer with session_answer carrying {"kind":"unreachable"} (offline and unknown look the same on purpose) or a rejected. Each source address may make ten lookups a minute.

The host’s side. The host answers session_seen with its own attestation as soon as it shows the accept card, so the viewer can display the four verification words, and then session_answer:

{"type":"session_answer","to":"<viewer id>","decision":{"kind":"accept","attestation":{…}}}
{"type":"session_answer","to":"<viewer id>","decision":{"kind":"decline"}}
{"type":"session_answer","to":"<viewer id>","decision":{"kind":"need_password"}}
{"type":"session_answer","to":"<viewer id>","decision":{"kind":"locked","seconds":1800}}

need_password tells the viewer to run the unattended login (§2). The broker forwards answers with from and public_key added, and records an acceptance for the pair for ten minutes, refreshed by traffic.

Descriptions. Inside an acceptance only, offer, answer (each {"to","sdp"}) and ice ({"to","candidate"}) are relayed as-is with from added; outside one the broker answers rejected with no accepted session with that peer. cancel ({"to"}) withdraws a request or a pending login and is always relayed.

Relay credentials. {"type":"turn_request","peer":"<id>"} inside an acceptance returns

{"type":"turn_credentials","urls":["turn:turn.utly.dev:3478?transport=udp",…],"username":"<expiry>:<id>","password":"<base64 hmac>","ttl_seconds":600}

in the coturn static-auth-secret scheme, valid ten minutes.

Abuse reports. {"type":"report","id":"<reported id>","reason":"<at most 500 characters>"} is answered {"type":"reported"}; reports share the lookup budget.

Rejections. {"type":"rejected","reason":"…"} may arrive in answer to any message. A rejection followed by the broker closing the socket means the registration itself was refused.

2. Unattended login

The host keeps an OPAQUE server registration of the unattended password (ristretto255 OPRF, 3DH key exchange, SHA-512, Argon2id as the client key-stretching function). A viewer that receives need_password runs one OPAQUE login through the broker:

{"type":"auth","to":"<id>","stage":"request","data":<CredentialRequest>}     viewer → host
{"type":"auth","to":"<id>","stage":"response","data":<CredentialResponse>}   host → viewer
{"type":"auth","to":"<id>","stage":"finish","data":<CredentialFinalization>} viewer → host

The credential identifier is the constant utly-remote unattended v1; the OPAQUE context is utly-remote unattended login v1|<viewer fingerprint>|<host fingerprint> with both fingerprints normalised, which binds the login to this pair of DTLS certificates. A wrong password fails on the viewer at the response stage before the host learns anything. After a successful finish the host either accepts outright or, with ask on screen set, shows the accept card marked as unlocked. Five failures from one id lock that id out for thirty minutes (locked); twenty failures in thirty minutes from any ids stop the host offering the password at all for thirty minutes.

3. Verification words

Both sides compute four words from the two DTLS fingerprints: sort the two normalised fingerprints, take SHA-256("utly-remote sas v1" || first || "\n" || second), read the first 64 bits big-endian, and index the BIP-39 English list with four consecutive 11-bit groups from the top. The words are shown on the accept card and on the viewer’s connecting screen.

4. The session

A session is one WebRTC peer connection, offered by the host after acceptance. DTLS-SRTP protects the media; every data channel is a DTLS-protected SCTP stream. Before completing the handshake each side checks that the remote DTLS fingerprint equals the one in the attestation it verified during signaling.

Media. One VP9 video track per shared screen, labelled screen-<index>, payload type 98, profile-id=0, with the playout-delay RTP header extension; one Opus track labelled sound for the host’s audio when granted.

Data channels, all opened by the host:

labelordercarries
controlordered, reliableControlMessage (§5)
inputordered, reliableInputEvent (§6), viewer → host
filesordered, reliableFilesMessage text and binary chunks (§8)
cursorunordered, reliableCursorMessage (§7), host → viewer
traceunordered, no retransmitsTraceReport per frame, host → viewer

Further screens on the viewer open views: additional peer connections whose offer and answer travel inside the main session’s control channel, so they inherit its authentication and end with it.

5. Control messages

messagefieldsmeaning
ping / pongsent, plus received and replied on the pong, microsecondslatency measurement
grantinput, clipboard, audio, files booleansthe host’s current permissions; sent on start and on every change
clipboardtextclipboard text in either direction while granted
monitorslist of {id,name,width,height,x,y}, watching idsthe host’s screens and which are streaming
watchidsthe viewer asks for these screens
capabilitiessas, restart booleanswhether the host can send Ctrl+Alt+Del and restart
sas, restart, restartingthe requests and the host’s notice that it is going down
open_viewview, screenthe viewer asks for a view of one screen
view_offer, view_answerview, sdpthe view’s own handshake, relayed inside the session
endeither side ends the session

6. Input

InputEvent on the input channel, honoured only while the input grant is on:

messagefields
mouse_movex, y in 0–65535 across the named screen, screen id
mouse_buttonbutton (left, middle, right, back, forward), down
wheeldx, dy
keycode (the DOM KeyboardEvent.code), down
textutf8 for characters that have no key code

7. Cursor

CursorMessage on the cursor channel: shape (width, height, hotspot_x, hotspot_y, bgra pixels as hex) when the pointer image changes, and position (x, y, visible, screen) as it moves, so the viewer draws the pointer itself instead of streaming it.

8. Files

Text messages on the files channel, honoured only while the files grant is on:

messagefieldsdirection
listpathviewer → host; an empty path lists the roots
entriespath, entries of {name,dir,size,modifiedMs,hidden}, morehost → viewer
refusedpath, reasonhost → viewer, for a path the policy refuses
getid, path, offsetviewer asks for a file (resumable at offset)
putid, path, size, offset, replacethe sender announces an upload
ackid, seqthe receiver acknowledges chunks
doneid, blake3the sender is finished; the hash covers the whole file
verifiedidthe receiver confirms the hash
cancel, failedid, and reason on failedeither side

File bytes travel as binary frames on the same channel: an 8-byte little-endian header of id (u32) and seq (u32) followed by up to 16384 bytes. The host refuses Windows, Program Files, ProgramData, the recycle bin and other users’ profiles, checked on the requested path and again on its canonical form, and serves nothing at the logon screen.

9. Accounts

Transport. A second WebSocket, wss://broker.utly.dev/v1/account, from the user’s app rather than from the host service. The client speaks first.

OPAQUE. The same cipher suite as §2. The credential identifier is the normalised email (trimmed, lower-cased). The OPAQUE identifiers are client = the email, server = broker.utly.dev; the login context is utly-remote account login v1. A sign-in for an unknown email is answered with a dummy record and fails on the client.

Sign-up. sign_up_start {email, request}sign_up_response {response}; sign_up_finish {upload, device}enrolment {email, uri, secret} with an otpauth:// URI and the base32 secret for an authenticator (SHA-1, 6 digits, 30 seconds); enrol {code}signed_up {token, recovery, session}. Nothing is stored until the code matches; five wrong codes end the attempt with signed_out.

Sign-in. sign_in_start {email, request}sign_in_response {response}; sign_in_finish {finalization, device}pending {token, email, approvers}: the device holds a token but no rights until one second factor arrives. prove {code} with an authenticator code (each 30-second step accepted once) or a recovery code (single use) → signed {session}; or a signed-in device sends approve {device, allow} and the pending device receives signed or signed_out. Pending devices expire after ten minutes.

Sessions. resume {token} on a new connection → signed {session}, pending {…} or signed_out {reason}. A session is {email, device, devices: [{id, name, ip, trusted, online, created_at, seen_at}]}. devices {devices} is pushed to every signed-in device when the list changes. revoke {device} removes a device, which receives signed_out and is closed; sign_out removes the caller’s own.

Address book. book_pull {since}book {blobs, serial} with every blob whose serial is above since; book_put {key, version, ciphertext} stores a blob when version is exactly one above the stored version (book back with the stored blob, and the same book pushed to the account’s other devices) and otherwise answers book_conflict {current}. Keys are 16 bytes, blobs at most 4096 bytes, 5000 per account. The key is HMAC-SHA256(tag key, id)[0..16]; the ciphertext is XChaCha20-Poly1305 over the JSON entry {id, name, clock, by} with a 24-byte nonce prefixed and the key as associated data; both keys come from HKDF-SHA256(OPAQUE export key) with the infos utly-remote address book seal v1 and utly-remote address book tag v1.

Limits. Password stages are limited to ten a minute per address; five wrong codes lock an account’s second factor for fifteen minutes.

10. Updates

GET https://remote.utly.dev/update/<stable|beta>.json returns {version, rollout, notes, installer: {url, signature}, portable: {url, signature}}, and <stable|beta>.json.sig beside it is a minisign signature over the manifest bytes, which the client verifies against the public key compiled into the program before it reads the manifest at all. Each signature is the base64 text of a minisign signature over that artifact, as tauri signer produces it, verified again after the download. rollout is a percentage against a per-machine bucket.