Threat model, v1
Written at M1 for the trust layer as built: device identities, the broker, the signaling relay and the session transport. It is revised at each milestone that adds an actor (the Windows service at M3, accounts at M6). The design rule it implements is the plan’s: the broker is part of the threat model, and trusting its operator is barely required.
Assets
- Control of the host machine. Whoever holds a session holds the keyboard and mouse. Everything else protects this.
- Screen content and input in transit.
- The identity secret on each device: the Ed25519 key that a nine-digit id names.
- Who talks to whom — session metadata the broker necessarily sees.
- The unattended-access password (WP-3.5): never stored or sent. The host keeps an OPAQUE server registration (ristretto255, 3DH, Argon2id on the viewer’s side) DPAPI-sealed next to the identity; a viewer proves knowledge of the password with three messages relayed by the broker before any session exists, and the broker sees only OPAQUE bytes that reveal nothing about it. A wrong password fails on the viewer’s side; five failures from one id lock that id out for thirty minutes and the host is told. Reviewed line by line 2026-09-18 (S2), with three consequences built in: the OPAQUE transcript is bound to both DTLS fingerprints (
auth::binding, OPAQUE’scontext), so a broker that relays one viewer’s login under another client’s id cannot get that client accepted — the legitimate viewer’s finish fails first; a login left pending when its request times out, is withdrawn or is declined counts as a failure, since the viewer learns pass/fail from the response before the host does; and twenty failures in thirty minutes from any mix of ids stop the host offering the password at all for thirty minutes (requests still show the accept card), which is what bounds an attacker who registers a fresh id per guess. - The address book once it syncs (WP-7.2): which ids a person has named, and what they called them. The broker holds it as ciphertext under keys it cannot relate to any id.
Actors
| Actor | Sees | Can do |
|---|---|---|
| Broker operator (Utly, or anyone running the open-source broker) | ids, public keys, presence, session requests and answers, the SDP blobs it relays, source addresses | relay, drop, delay or swap any signaling message; mint TURN credentials; take the database |
| TURN relay | ciphertext packets and both endpoints’ addresses | drop or delay traffic |
| Network attacker between any two parties | everything on the wire | inject, replay, redirect |
| A stranger with the app | the id space, if they can enumerate it | send session requests; guess ids |
| A prior contact whose key changed | their old pin on your device | present a new key under the same id |
| Someone with your unlocked machine | your identity file, pins, logs | impersonate you from that machine |
Mechanisms as built
Identity. core::identity::Identity — an Ed25519 key generated on first run, sealed with DPAPI (CryptProtectData, user scope, no UI) at the app’s data directory. The public key is what the broker binds to an id, forever: identities.public_key is unique and the binding is never rewritten. A device that loses the file gets a new id; there is no recovery path by design, because a recovery path is an impersonation path.
Registration. The broker sends a 32-byte nonce; the client answers with its public key and an Ed25519 signature over "utly-remote signal register v1" || nonce. The context string keeps the signature from being replayed as anything else, and the nonce keeps a captured registration from being replayed at all.
Session keys. Each session is a DTLS-SRTP handshake between the peers. The broker relays only the SDP; it never sees session keys. Each peer signs its own DTLS certificate fingerprint ("utly-remote dtls fingerprint v1" || fingerprint) with its identity key and sends the signature beside its offer or answer. The receiver verifies it against the public key the broker delivered with the session request or answer, and against its pin store, before completing the handshake; the signaling client (WP-2.2) performs that check. A broker that swaps an SDP therefore cannot complete a session: the swapped fingerprint carries no valid signature from the claimed identity.
Pinning. core::identity::Pins records the public key seen for each id on first contact. A different key for a known id is refused outright on the viewer side and flagged in caution on the host’s accept card. Settings › Security lists the pinned peers and lets the user forget one, which returns that id to first-time trust — the only way back after a legitimate reinstall on the other side, and a deliberate, per-peer action on the local machine. A stranger’s first contact is marked as such in the accept dialog.
Short authentication string. Four words from the BIP-39 English list, indexed by SHA-256 over both canonical fingerprints in sorted order (core::identity::sas). Both sides can only display the same words if they hold the same pair of fingerprints, so reading them over the phone defeats a relay that terminated DTLS on both sides — the one attack pinning alone cannot catch on first contact.
Broker hygiene. Session requests are the only id lookup, and unknown and offline ids answer identically (Unreachable) so the reply leaks nothing beyond “not reachable now”. Each source address gets ten lookups a minute. Offers, answers and candidates relay only inside a session the callee accepted in the last ten minutes, so an online id cannot be bombarded with descriptions. TURN credentials are minted per accepted session with a ten-minute expiry, so leaked credentials are useless soon after.
The host service. With the service installed, hosting runs in an agent as LocalSystem in the console session; the app a user runs is only its front. The two meet on a named pipe whose ACL admits SYSTEM, Administrators and interactively signed-in users, rejects remote clients and takes the first instance so nothing can squat the name ahead of the agent; the app in turn checks the server process’s owner and refuses a pipe served by anything but SYSTEM or its own account. Over the pipe travel exactly the commands and events the UI already had — the unattended password crosses it once, zeroized in memory on both sides, and the OPAQUE record, the identity and the pins stay with the agent under %ProgramData%\Utly Remote, sealed with machine-scope DPAPI and readable by SYSTEM and Administrators only. Any interactive user can therefore accept a request or set the unattended password on a shared machine, which is the same trust the console itself grants; per-user separation is an account feature (M6).
The session log. Every session start and end, grant change, declined request, failed unattended login, lockout, first pin, changed key and forgotten pin is appended to sessions.log beside the identity, each line carrying the SHA-256 of the line before it. The Session log board verifies the chain on every read and marks the first entry whose seal does not match. The chain is unkeyed: it catches edits and deletions by anyone who is not prepared to rewrite everything after them, and the file’s ACL (SYSTEM and Administrators with the service; the user alone in portable mode) keeps others from writing at all. An administrator can rewrite the chain; that is the same administrator who can replace the binary, so the log is evidence against tampering below that line, not above it.
Audio. Sound leaves the host only while the audio grant is on: the capture thread drops samples the moment it is withdrawn, so nothing is encoded, let alone sent. The loopback hears whatever the host’s default output plays, which is the whole point and also the reason the grant is a switch on the accept card rather than a default.
Files. The host answers file requests only while the files grant is on, and only for the signed-in console user: when the agent runs as SYSTEM it impersonates that user’s token around every open, listing, rename and delete, so Windows’ own ACLs decide what the viewer can reach, and on top of that the path policy refuses Windows, Program Files, ProgramData, the recycle bin and other users’ profiles outright — checked on the requested path and again on its canonical form so a junction cannot lead out. Nothing is served at the logon screen, because there is no user to impersonate. Every finished, failed or cancelled transfer is written to the session log with the host path and size. Withdrawing the grant stops a transfer at its next chunk. The host only ever pushes a file to the viewer when the host’s own user picks it in the native dialog; the viewer stores what it is offered under its own Downloads, never at a path the host names, and refuses to overwrite.
Views. A second window on the viewer opens a second WebRTC connection to the host, but its offer and answer travel only inside the main session’s encrypted control channel, so it inherits that session’s authentication and grants and can never outlive it: ending the session closes every view on both sides.
The legacy shell. utly-remote-win7.exe swaps the webview for native dialogs and nothing else: identity, registration, session keys, pinning, the SAS words, the session log, the service and the grants are the same code paths, driven through the same Backend commands and events. The accept board shows the same id, trust note and words, and a session still exists only after that board’s Accept (or a proven unattended password with ask-on-screen off); the strip while connected carries the same live grants and End. Installing the host service from the ID window relaunches the exe elevated for the same --install-service step the installer runs.
Accounts. Optional, and nothing before this paragraph changes for a device that never signs in. An account is an email, an OPAQUE registration record and a TOTP secret on the broker (broker/src/accounts.rs), reached over a second socket, /v1/account, from the user’s own app process rather than from the agent, so it belongs to the Windows user and not to the machine. The password goes through the same OPAQUE suite as unattended access, with the broker as the server and the email as the credential identifier: the broker keeps one persistent server setup, never sees the password, and answers a sign-in for an unknown email with a dummy record so the exchange cannot tell an attacker whether the email exists (the guess fails on the client either way). Sign-up enrols an authenticator before anything is written: the registration and a fresh 20-byte secret wait in the socket’s memory until a code matches, then the account, eight single-use recovery codes (stored as SHA-256) and the first device are committed together. A new device signs in with the password and is then held as pending with a token but no rights until one second factor arrives: approval from a device already signed in, which is pushed the request with the newcomer’s name and address; a TOTP code, of which each 30-second step is accepted once; or a recovery code. Pending devices expire after ten minutes. Devices hold 32-byte random tokens, stored hashed on the broker and DPAPI-sealed on the client (account.bin, user scope); every signed-in device sees the list and can revoke any other, and a revoked or denied device is told over its socket and cut off at once, its token dead for any later resume. Five wrong codes lock the account’s second factor for fifteen minutes, the password stages are limited to ten a minute per address, and five wrong enrolment codes throw the sign-up away. There is no password reset, because the broker holds nothing that could perform one; a lost password with no signed-in device and no recovery code is a lost account, and the page says so before sign-up. The Windows 7 shell has no account page; accounts do nothing a host needs.
The address book. A signed-in device syncs the names it has given machines through the account socket, and nothing else: last-connected times stay local. The key comes from OPAQUE’s export key, which the client derives from the password at registration and login and the broker never learns; HKDF-SHA256 splits it into a sealing key and a tagging key, both kept DPAPI-sealed beside the device token and deleted with it. Each entry is one blob: the id, the name (or none, for a removed entry) and a Lamport clock with the writing device, serialized and sealed with XChaCha20-Poly1305 under a fresh 24-byte nonce. The blob’s key on the broker is a 16-byte HMAC of the id under the tagging key, so the broker sees how many machines are named and when entries change, never which ids; the same tag is the ciphertext’s associated data and is checked against the id inside on opening, so a blob cannot be re-filed under another id. Versions on the broker advance one at a time: a device that writes from a stale version gets the current blob back, merges it and writes again, and every stored change is pushed to the account’s other signed-in devices. Merging is last-writer-wins by the clock, the writing device breaking ties, and a removal is a tombstone that outranks older names. A broker that replays an old blob can only roll an entry back to a state the same account once wrote; the client ignores versions below the one it knows. Signing out drops the key and forgets the sync state; the local book stays, because it is the user’s own file.
Updates. The app fetches a channel manifest from utly.dev over TLS, downloads the artifact it names into its temp directory and verifies a minisign signature (ed25519 over BLAKE2b) against the public key compiled into the client before anything runs; TLS is transport, the signature is the trust. The private key is the release PC’s today and moves offline before launch (docs/signing.md). A manifest can therefore only offer what the key holder signed, and a compromised web host can at worst withhold updates or serve an older signed build to clients that are on a newer version already, which they ignore because the manifest version must be higher than their own. Installed copies hand the signed installer to Windows, which asks for elevation as any per-machine install does; the installer stops and removes the host service before it writes and installs it again afterwards. The portable exe swaps itself and keeps the previous file; a build that has not reported itself healthy within a minute of its first start is moved aside and the previous one restored on the next launch, and automatic checks then stand down until the user asks again, so a bad build cannot loop.
Abuse. The product’s worst case is not a cryptographic break but a person on the phone talking someone into installing it. Before the first unattended password is set, the app shows the warning board once: plain language that no bank, office or company ever asks for remote-access software, and two buttons. Someone told me to install this opens the hang-up card and starts a ten-minute cool-down during which unattended access cannot be switched on — enforced in the app process behind the UI, not just in the page, and kept in safety.json so a restart does not shorten it — after which the warning is shown again; I’m setting this up for myself records the acknowledgement. The Windows 7 shell asks the same question as a system dialog. Attended sessions are untouched: the warning is friction against the one grant a scammer needs. Reports go over the signal socket (Report {id, reason}), so every report comes from a registered id and an address and is budgeted like lookups; the broker keeps them and a person reads them with utly-remote-broker reports. Blocking is a human decision (block <id> <reason>): a blocked id is refused at registration and, if already online, on every session request in either direction, within a minute of the change. A blocked scammer can mint a fresh id, which is what the per-address budgets and the next report are for.
Consent. A session exists only after the host’s client answers Accept; the host UI (WP-2.6) shows the requester’s id, first-contact status and the SAS words before that answer. The keyboard-and-mouse grant is chosen on the same card and can be withdrawn at any moment during the session: the host’s own injector drops input while the grant is off, so a viewer’s compliance is never relied on, and the viewer is told (Grant{input}) so its UI says so.
What each actor gets
| Actor | Outcome under v1 |
|---|---|
| Broker operator | Learns who connects to whom and when, and can deny service. Cannot read sessions, cannot impersonate a peer, cannot silently insert itself. Taking the database yields public keys and ids, and for accounts the emails, OPAQUE records that are useless without the password, TOTP secrets, hashed recovery codes, hashed device tokens and address-book ciphertext with the count and timing of its entries: enough to sign in as a device only by also taking that device’s sealed token, never enough to learn a password or read a book. |
| TURN relay | Learns endpoint addresses and traffic volume. Sees ciphertext. |
| Network attacker | Sees the broker connection’s TLS and the peers’ DTLS. Cannot forge registrations or fingerprints without an identity key. |
| Stranger | Can send requests to ids they already know, at ten guesses a minute per address; every request lands in a consent dialog. Once reported and blocked, cannot register that id or reach anyone from it. |
| Phone scammer | Must talk the victim past the warning board before unattended access exists, and past the consent dialog for every attended session; a victim who says they were told to install gets ten minutes and the hang-up card instead of a password field. |
| Changed key | Blocked at the pin check until the user re-pins through the warning. |
| Unlocked machine | Full impersonation from that machine. DPAPI ties the secret to the Windows account; it does not defend against the account itself. TPM binding arrives at 1.0. The account token is sealed the same way, so the machine is also signed in as that device until another device revokes it. |
| Password thief | Gets as far as pending: the account’s other devices see a named, addressed request they can deny, and the thief still needs the authenticator or a recovery code. With no other device signed in, only the codes stand between them and the account. |
Not covered by v1
- The broker’s TLS is terminated by Caddy in front of it (WP-2.5); until then the socket is
ws://and only fit for the LAN. - Unattended access, accounts and the address book are covered above; what they do not defend against is an attacker who already runs code as the host’s Windows user, who can unseal the records like the identity.
- A password change would need every entry re-sealed under the new export key; there is no password change yet, so the book key is stable for the life of the account.
- Account email is not verified and there is no email delivery yet, so an email is only a name and nothing can be reset through it.
- Abuse of consented sessions by a social engineer: a person who talks the host into clicking Accept. The first-run warning and the always-visible session indicator (WP-2.6, WP-3.x) are the mitigations.
- Denial of service against the broker beyond the per-address lookup budget.