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”:
| Area | Code |
|---|---|
| Identity keys, registration signature, fingerprint signature, canonical fingerprints | core/src/identity/keys.rs, broker/src/ws.rs |
| Pinning and first-contact trust | core/src/identity/pins.rs, app/src-tauri/src/session.rs (trust, verify_host, on_request) |
| Verification words | core/src/identity/sas.rs |
| DTLS certificate, SDP fingerprint checks | core/src/transport/certificate.rs, core/src/transport/mod.rs, session.rs (answer_matches, the Answered arm) |
| Unattended OPAQUE login and lockout | core/src/auth/*, session.rs (Auth handling), reviewed once already at S2 |
| Account OPAQUE, TOTP, recovery codes, device tokens, approval | broker/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 blobs | core/src/book/crypto.rs, broker/src/blobs.rs, app/src-tauri/src/contacts.rs |
| Update verification and roll-back | app/src-tauri/src/update.rs, scripts/publish.ps1 |
| Grants | session.rs (Live, Gated, paste_sink), core/src/files/xfer.rs, core/src/audio/wasapi.rs |
| Abuse channel | broker/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:
| target | what it exercises | run on 2026-09-20 |
|---|---|---|
messages | every signal, account, control, input, cursor and files message: parse, re-serialize, parse again, must round-trip | 2.7 M runs, 7 min, seeded corpus |
chunk | the binary file-chunk frame | 26.7 M runs, 3 min |
book | opening address-book blobs with a fixed key, and sealing arbitrary entries | 1.0 M runs, 4 min |
opaque | the unattended login’s three server- and client-side deserializers | 129 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
- Outside reader. Nobody but the author has read the threat model or this review. Before launch, one outside reader of
docs/threat-model.md,docs/protocol.mdand this page; the security overview on the site is the invitation. - Signing keys. The update key and the code-signing certificate are development keys on the release PC.
docs/signing.mdsays how they move before launch. - TPM binding of the identity key is still the 1.0 item it was at M1; DPAPI is what ships.
- Installer in-place update with the service running has not been exercised on a machine with the service installed.
- Email delivery does not exist, so there is no password reset and no account deletion without asking; both are stated to users.