Skip to content

fix: the UI kept reporting successes that never happened - #1

Merged
Johnserf-Seed merged 5 commits into
mainfrom
fix/audit-and-hardening
Jul 15, 2026
Merged

Johnserf-Seed merged 5 commits into
mainfrom
fix/audit-and-hardening

Conversation

@Johnserf-Seed

Copy link
Copy Markdown
Owner

An audit of every falsifiable claim in the app — the code, the settings, the docs — turned up almost no crashes. Nearly every real defect was the same shape:

something reported a success that had not happened, or asked for a confirmation that could not be given.

And the tests were pinning the lies. loopback_pairing_mutually_trusts…, untrusted_text_…_is_dropped_but_acked, and "migrates trust to the new key when the codes match" each named and guarded the broken behaviour. A green suite meant nothing.


The security ones

Pairing granted trust inside the handshake, before any human compared the SAS — and only the host could see that SAS. The joiner got it back from join_by_code and threw it away, while the host's screen said "compare this with the other device". A code only one side can read is not a check; it is a formality that ends in a yes.

Both ends now show the same SAS, and trust is recorded only once a person confirms it — through the same set_trusted path the trust circle uses, so a paired device is trusted and auto-accepting, exactly like one dragged into the ring, and the two notions of trust can no longer drift apart. trust::grant() is deleted outright: leaving a backend-side "trust without asking" helper lying around is leaving a door for someone to wire it back up.

The fingerprint-changed alert offered to "re-verify and restore trust" by dialling the suspect key and showing the SAS that handshake produced — while the other device displayed nothing at all. There was no second number to compare, so pressing they match migrated trust onto the very key the alert existed to make you suspicious of. That flow is gone; the alert now reports, and offers the two honest exits (delete the old record, or pair with the new key like the new device it is).

It also fired on a name match — and the default device name is the machine's hostname, so two un-renamed installs raised an impersonation alarm at each other.

You could file yourself as a peer by dialling your own address, and a self-entry is uniquely un-removable: discovery drops its own announces, so it can never be expired away. Refused on every path, filtered from the device list, and evicted from the trust store at startup.

Things that could not be deleted

forget_device — "delete" used to clear the trust row while the peer sat untouched in the manual address table, back on the very next list, with nothing anywhere able to take it out.

Quiet failures

  • A checksum mismatch deleted the whole batch, verified siblings included, while the message spoke of one file.
  • Quick text from an untrusted sender was dropped and ACKed, so send_text — whose contract is "resolves only once the peer confirms it received it" — resolved successfully for a message thrown away on arrival.
  • Transfer failures were silent; only "the peer declined" toasted, while success got a toast and an OS notification.
  • Batch-forward swallowed every failure and reported "forwarded N items".
  • The backend's pause is bounded — it un-parks itself after 50s and emits transfer_resumed. Nothing listened, so the row read "Paused" while the progress bar crept along underneath it.
  • TransferDetail fell back to the backend's raw English diagnostic, local absolute paths and all.

Controls that did nothing

  • "Open the folder when done" — persisted, commanded, exported in the diagnostics bundle, and acted on by nothing. It defaults ON.
  • "History retention" and "Effective network" were wired to nothing. Retention is now real; the SSID dropdown is removed (its only honest implementation is a Windows-only WLAN call that would be silently dead elsewhere — a new lie for an old one).
  • Conflict policy "ask" never asked under auto-accept — which, under the default settings, is the only path.

Browser share

A live share is files being served over HTTP on your LAN. Closing the panel doesn't stop it (deliberately — a link you handed someone shouldn't die because you closed the panel you copied it from), but reopening minted a new share instead of adopting the live one, so the only Stop button in the app could never reach the old one again. A forgotten share went on serving: invisible, and unstoppable.

Also: "LAN only" was a claim, not a rule (now enforced per request, in middleware); and a single-file share skipped the branded landing page — in the most common case there is.

Cross-platform

keyring was compiled with windows-native and nothing else, so on macOS and Linux it fell back to keyring's in-memory mock store: a brand-new device identity on every launch, silently, invalidating every fingerprint every peer had pinned.

The repo's own identity_is_stable_across_loads proves that in one second. It had simply never been run anywhere but Windows — which is why CI now runs the backend suite on Windows, macOS and Linux, with a real dbus + gnome-keyring on the Linux job so the identity tests can't "pass" against nothing.

Plus: bundle.targets was hard-coded to nsis (no macOS/Linux artifact at all, and lanbeam:// could never be registered on macOS); Cargo metadata was still authors = ["you"].

Also

  • Data and logs move out of %LOCALAPPDATA%\app.lanbeam.desktop\ into a product-named folder — with a real migration, so the trust store comes along.
  • A <select> is a native widget and macOS means it: WKWebView draws an Aqua popup and ignores the border/background/radius, so every dropdown looked lifted from another program. Chromium honours them — which is exactly why it was invisible from the machine this was built on.
  • Interface scale (80–150%), applied to the webview and to the window's minimum size: a zoom shrinks the CSS viewport, so a floor that ignored it would let you scale the UI off the edge of its own window.
  • Status dots go through one place — colour is the decision, fill is presence. "Away" used to paint over "trusted", so a sleeping laptop you trust looked identical to a stranger, on the one page whose job is showing trust.

Verification

Rust 266 tests (250 unit + 13 integration + the new ones) · clippy 0 · fmt clean
Frontend 376 tests / 29 files · tsc clean · biome clean · build passes

Notes for review

  • The code is one commit. store.ts, commands.rs, transfer.rs, lib.rs and both i18n files are touched by nearly every fix; splitting them into "atomic" commits would take hunk-level surgery and produce intermediate trees that don't compile and were never green. Inventing a history that never existed is worse than one honest commit.
  • tauri dev must be restarted — the Rust side changed. On first launch the app migrates settings.json / trusted.json to the new folder and logs what it moved, and evicts any self-record from the trust store.
  • Deliberately not done: the updater (blocked on a release server + signing keys) and code signing / notarisation.

Replace the Tauri/Vite scaffold artwork with the actual LanBeam identity:
every app icon size, the .icns/.ico bundles, the Windows Store logos, and the
favicon.

Also adds a macOS menu-bar TEMPLATE icon, rasterised from the brand kit's tray
mark. macOS template icons are alpha-only — the system paints them black on a
light menu bar and white on a dark one — so the per-arc opacity of the mark is
baked into the alpha channel rather than into a colour.

BrandMark renders the logo as an inline SVG rather than an <img>: a real
element can take the theme's colours, and `pointer-events: none` keeps it from
eating the window's drag region.

The design source folder stays out of the repo; the assets actually derived
from it and shipped are versioned under assets/brand.
An audit of every falsifiable claim in the code turned up almost no crashes.
Nearly every real defect was the same shape: something reported a success that
had not happened, or asked for a confirmation that could not be given.

And the tests pinned the lies. `loopback_pairing_mutually_trusts...`,
`untrusted_text_..._is_dropped_but_acked`, and "migrates trust to the new key
when the codes match" each NAMED and guarded the broken behaviour. A green
suite meant nothing.

--- Security model ---

Pairing granted trust inside the handshake, before any human compared the SAS
-- and only the HOST could see that SAS: the joiner got it back from
`join_by_code` and threw it away. A code only one side can read is not a check;
it is a formality that ends in a yes. Both ends now show the same SAS, and
trust is recorded only once a person confirms it, through the same
`set_trusted` path the trust circle uses. So a paired device is trusted AND
auto-accepting, exactly like one dragged into the ring, and the two notions of
trust can no longer drift apart. `trust::grant()` is deleted outright: leaving
a backend-side "trust without asking" helper lying around is leaving a door for
someone to wire it back up.

The fingerprint-changed alert offered to "re-verify and restore trust" by
dialling the suspect key and showing the SAS that handshake produced -- while
the other device displayed nothing at all. There was no second number to
compare, so pressing "they match" migrated trust onto the very key the alert
existed to make you suspicious of. That flow is gone. The alert reports, and
offers the two honest exits: delete the old record, or pair with the new key
like the new device it is (pairing is the only flow that puts the same code on
both screens).

It also fired on a NAME match -- and the default device name is the machine's
hostname, so two un-renamed installs raised an impersonation alarm at each
other. A name now only counts when it is unambiguous AND worn by a device you
do not already know.

You could file YOURSELF as a peer by dialling your own address. A self-entry is
uniquely un-removable: discovery drops its own announces, so it can never be
expired away. Refused on every path, filtered out of the device list, and
evicted from the trust store at startup.

--- Things that could not be deleted ---

`forget_device`: deleting a device now drops its trust row AND the
manually-added address that kept it in the list. `remove_trusted` was all the
UI had, so "delete" cleared the trust row while the peer sat untouched in the
manual address table -- back on the very next list, with nothing anywhere able
to take it out.

--- Quiet failures ---

- A checksum mismatch deleted the WHOLE batch, verified siblings included,
  while the message spoke of one file. A file that passed its digest is
  finished; a sibling's bad hash says nothing about it.
- Quick text from an untrusted sender was dropped and ACKed anyway, so
  `send_text` -- whose contract is "resolves only once the peer confirms it
  received it" -- resolved successfully for a message thrown away on arrival.
  The ack can now say no (additive `delivered`/`reason`, wire-compatible).
- Transfer failures were silent. Only "the peer declined" toasted, while
  success got a toast AND an OS notification.
- Batch-forward swallowed every failure in a bare `.catch(() => {})` and then
  reported "forwarded N items".
- The backend's pause is bounded: it un-parks itself after 50s and emits
  `transfer_resumed`. Nothing listened, so the row read "Paused" while the
  progress bar crept along underneath it.
- TransferDetail fell back to the backend's raw English diagnostic --
  "protocol: peer closed connection", and local absolute paths.

--- Controls that did nothing ---

- "Open the folder when done" was persisted, commanded, and exported in the
  diagnostics bundle -- and nothing anywhere acted on it. It defaults ON.
- "History retention" and "Effective network" were wired to nothing. Retention
  is now real. The SSID dropdown is removed: its only honest implementation is
  a Windows-only WLAN call that would be silently dead on macOS and Linux -- a
  new lie in place of an old one.
- Conflict policy "ask" never asked under auto-accept -- which, under the
  default settings, is the ONLY path. Auto-accept answers "do I want anything
  from this device", not "what happens to my existing file".

--- Browser share ---

A live share is files being served over HTTP on your LAN. Closing the panel
does not stop it, deliberately -- a link you handed someone should not die
because you closed the panel you copied it from -- but reopening the panel
minted a NEW share instead of adopting the live one, so the only Stop button in
the app could never reach the old one again. A forgotten share went on serving,
invisible and unstoppable. It is now always shown in the sidebar, adopted on
reopen, and closing says what it did.

"LAN only" was a claim, not a rule: the server binds 0.0.0.0 and has to (a DHCP
renewal would otherwise kill every live share). The boundary is now enforced
per request, in middleware, before any handler runs.

A single-file share 302'd straight to the download, skipping the branded
landing page in the most common case there is -- the page whose entire job is
to tell the recipient this is LanBeam, against a browser saying "not secure".

--- Backend work with no way in ---

`list_partials`: a half-written file is saved under its FINAL name -- 1.2 GB of
a 4 GB holiday.mp4, which looks perfectly ordinary in the folder and plays for
thirty seconds. The backend has tracked these all along (it is what makes
resume work) and could always delete them; nothing ever asked it what it was
holding. Settings lists them and can clear them.

`self_test_secure_channel` was pure scaffolding. Removed.

--- Where files live ---

Data and logs move out of Tauri's bundle-id folder
(%LOCALAPPDATA%\app.lanbeam.desktop\logs) into a product-named one, on each
platform's own conventions. The identifier itself stays -- macOS needs a
reverse-DNS CFBundleIdentifier, and the installer and `lanbeam://` registration
key off it. It is just the wrong thing to name a folder a person has to open.

With a real migration: the trust store comes along. Renaming a folder and
quietly abandoning the user's trust circle to make a path prettier is exactly
the class of thing this whole change set is about.

--- Polish ---

A <select> is a native widget, and macOS means it: WKWebView draws an Aqua
popup button and ignores the border, background, radius and height, so every
dropdown in the app looked lifted from another program. Chromium honours them,
which is precisely why the mismatch is invisible from the machine this was
built on. `appearance: none` everywhere, with our own chevron (a <select> is a
replaced element and cannot carry a ::after) and a per-theme colour, since a
data-URI SVG cannot reach a CSS variable. Keyboard focus gets a ring back --
`outline: none` had left the app with no focus indicator at all.

Interface scale (80-150%), applied to the webview AND to the window's minimum
size: a zoom shrinks the CSS viewport, so a floor that ignored it would let the
user scale the interface straight off the edge of its own window. Ctrl +/-/0
drive the app's own setting; the webview's native zoom stays off.

Status dots go through one place. Colour is the decision (fingerprint changed /
trusted / not), fill is presence (here / away). "Away" used to paint over
"trusted", so a sleeping laptop you trust rendered identically to a stranger --
on the one page whose entire job is showing trust -- and a fingerprint-change
alert, the loudest thing this app can say, came out as a faded grey dot.
The keyring crate was compiled with `windows-native` and nothing else. keyring 3
picks its backend at COMPILE time and falls back to an in-memory MOCK store when
no feature matches the target -- so on macOS and Linux `Identity::load_or_create`
minted a brand-new X25519 key on every launch, silently, and every fingerprint
every peer had pinned went stale on restart. No error, no log, no degraded-mode
event. Adds the Apple and secret-service backends.

The repo's own `identity_is_stable_across_loads` proves that in one second. It
had simply never been run anywhere but Windows -- which is why CI now runs the
backend suite on Windows, macOS AND Linux, with a real dbus + gnome-keyring on
the Linux job so the identity tests cannot "pass" against nothing. The matrix is
not box-ticking; it exists because a bug of exactly that shape was invisible from
the machine this was built on.

Also:

- bundle targets were hard-coded to `nsis`, so macOS and Linux produced no
  artifact at all -- and `lanbeam://` could never be registered on macOS, whose
  only registration path is an Info.plist that only the macOS bundler writes.
- Cargo metadata was still the scaffold's: `authors = ["you"]`, `description =
  "A Tauri App"`. Those become the publisher and description of the installer.
- `productName` was lowercase, so the .app, the menu-bar name and the Start-menu
  shortcut all read "lanbeam".
- macOS gets an NSLocalNetworkUsageDescription. Discovery is IPv4 multicast and
  broadcast, which macOS gates behind the local-network permission -- without the
  key the system has nothing to tell the user when it asks, and a denial looks
  from inside the app like LanBeam is simply broken: no devices, ever, with
  nothing in the UI to say why.
- Linux deb dependencies: the `tray-icon` feature pulls in libappindicator, and
  without it the app starts with no tray at all.
A pass over every falsifiable claim in the READMEs and the roadmap, checked
against the code rather than against memory. Five statements were the wrong way
round, and five things the app now does were not mentioned at all.

Wrong:
- "Pairing pins fingerprints on both sides" -- the handshake grants no trust;
  both screens show the same SAS and a person confirms it.
- "a fingerprint-changed alert that auto-revokes trust" -- nothing is revoked
  automatically. The new key was never trusted; it is a different device.
- "The browser-share server binds the LAN" -- it binds 0.0.0.0, and LAN-only is
  enforced per request in middleware.
- "emits a single NSIS installer" -- every target for the host platform.
- "M4-M9 (all shipped)" -- M9's updater is still pending.

Missing: deleting a device (and that LanBeam refuses to file itself as a peer);
that quick text to an untrusted receiver now fails honestly rather than
reporting delivery; that the share's download cap is PER FILE; that closing the
share panel does not stop the share; three-platform CI; the interface scale.

Roadmap test counts were stale (148 unit -> 250 + 13 integration + 365
frontend), and the SSID item is marked dropped rather than pending, with the
reason.
`tauri::image::Image::from_bytes` is gated behind the `image-png` feature, and
we only enabled `tray-icon`. The call is in tray.rs's macOS menu-bar TEMPLATE
icon path, inside a `cfg(target_os = "macos")` block -- so a Windows build never
even type-checked it, and neither did anyone. The macOS job did, immediately.
This is exactly the class of bug the matrix exists to make loud.

Two bugs in the workflow itself, mine:

- `libappindicator3-dev` and `libayatana-appindicator3-dev` CONFLICT; apt refuses
  to install both. Ayatana is the one Tauri 2 wants on a current Ubuntu.
  libxdo-dev and libssl-dev were missing from the list entirely.
- biome is not vendored -- package.json fetches it through `npx --yes` -- so
  `pnpm exec biome` found nothing. CI now runs the project's own script, which
  also means CI and a developer's machine cannot end up on different versions.

Verified against the tauri 2.11.5 source rather than by another CI round-trip:
`from_bytes` is `cfg(any(feature = "image-ico", feature = "image-png"))`,
`icon_as_template` carries no platform gate, and the `config_dir`/`home_dir`/
`local_data_dir` that paths.rs uses on macOS and Linux are ungated on desktop.
@Johnserf-Seed
Johnserf-Seed merged commit cd34cdc into main Jul 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant