Utly Remote remote.utly.dev

Security review before 1.0 (checkpoint S3)

Held 2026-09-20 against main at the end of M6, by the developer with the threat model open. An outside reader has not been through this yet; that is listed under open items, not hidden.

Scope

Every place where trust is decided, read line by line with the question “what does an attacker who controls the other end, the broker, or the network get here”:

AreaCode
Identity keys, registration signature, fingerprint signature, canonical fingerprintscore/src/identity/keys.rs, broker/src/ws.rs
Pinning and first-contact trustcore/src/identity/pins.rs, app/src-tauri/src/session.rs (trust, verify_host, on_request)
Verification wordscore/src/identity/sas.rs
DTLS certificate, SDP fingerprint checkscore/src/transport/certificate.rs, core/src/transport/mod.rs, session.rs (answer_matches, the Answered arm)
Unattended OPAQUE login and lockoutcore/src/auth/*, session.rs (Auth handling), reviewed once already at S2
Account OPAQUE, TOTP, recovery codes, device tokens, approvalbroker/src/account_ws.rs, broker/src/accounts.rs, broker/src/totp.rs, core/src/account/password.rs, app/src-tauri/src/account.rs
Address-book keys and blobscore/src/book/crypto.rs, broker/src/blobs.rs, app/src-tauri/src/contacts.rs
Update verification and roll-backapp/src-tauri/src/update.rs, scripts/publish.ps1
Grantssession.rs (Live, Gated, paste_sink), core/src/files/xfer.rs, core/src/audio/wasapi.rs
Abuse channelbroker/src/abuse.rs, app/src-tauri/src/safety.rs

Findings

1. Medium, fixed. The SDP fingerprint check read the first a=fingerprint: line only. A broker that relayed a description with the attested fingerprint on the first line and a different one on a later media section could have had the later section’s DTLS transport verified against the wrong certificate. transport::sdp_attests now requires at least one fingerprint line, and every fingerprint line to be sha-256 and equal to the attested value; both the host’s answer check and the viewer’s offer check use it. Regression test in core/src/transport/mod.rs.

2. Medium, fixed. Update artifacts were signed but the manifest was not, so the version number was not bound to the artifact: a compromised web host could offer an old signed build under a higher version. publish.ps1 now signs the manifest with the same key and the client verifies <channel>.json.sig before reading the manifest.

3. Low, fixed earlier (WP-7.4). The signal client dropped the whole connection on any broker rejection after registration, with a thirty-second backoff, which made the controller’s rejection handling unreachable and let a single refused relay message knock a host offline briefly. Rejections are delivered as messages now; only a rejection followed by a close still backs off.

4. Informational, accepted. Sign-up says whether an email already has an account. Sign-in does not (dummy OPAQUE record), which is where enumeration would have mattered.

5. Informational, accepted. Recovery codes carry about 49 bits and are stored as unsalted SHA-256; a leaked database lets an attacker brute-force them offline, but a recovery code is only the second factor and the password cannot be recovered from the OPAQUE record, so the leak alone signs nobody in. Stated on the privacy page.

6. Informational, accepted. The TOTP secret is stored in the clear on the broker, as any TOTP verifier must; the mitigation is the same as above.

7. Informational, accepted. The verification words carry 44 bits (four BIP-39 words), which is above the 20 bits ZRTP considers enough against a man in the middle who must match both screens.

No high findings.

Dependencies

cargo deny check runs in scripts/check.ps1 on every change and covers the RustSec advisory database (the same source cargo audit reads), licences and duplicate versions; it is clean on this commit. unmaintained = "workspace" keeps unmaintained-crate warnings from failing the build for transitive crates the workspace does not name.

Fuzzing

fuzz/ is a cargo-fuzz project with four targets over the decoders that face the other side:

targetwhat it exercisesrun on 2026-09-20
messagesevery signal, account, control, input, cursor and files message: parse, re-serialize, parse again, must round-trip2.7 M runs, 7 min, seeded corpus
chunkthe binary file-chunk frame26.7 M runs, 3 min
bookopening address-book blobs with a fixed key, and sealing arbitrary entries1.0 M runs, 4 min
opaquethe unattended login’s three server- and client-side deserializers129 k runs, 5 min

No crashes. scripts/fuzz.ps1 -minutes N runs all four with the pinned nightly and the MSVC address-sanitizer runtime on the path; the 24-hour run the plan asks for is scripts\fuzz.ps1 -minutes 360 on a spare machine, one target at a time, and belongs to the beta period.

Permissions matrix

session::tests::every_grant_that_is_off_stops_its_traffic_before_it_reaches_windows builds the host-side gates with a grant off and asserts: input events are dropped before any Windows injector exists; a remote clipboard paste changes nothing; a restart request goes nowhere; the file store answers not allowed. With the grant on, the same calls reach the injector, the restart channel and the store. The files path has its own refusal test in core/src/files/xfer.rs; audio is gated in the capture thread by the same flag (wasapi.rs), which a unit test cannot drive without a device and which the VM matrix runs exercised.

Open items