Repository navigation
Peer check on macOS: the code signature of the browser - #23
Conversation
On macOS the app now checks who is on the other end of the socket, as on Linux: the peer must be our relay, run by the same user, and the relay's parent must be an approved browser. On macOS that browser needs a Developer ID signature with the bundle identifier and Team ID of Chrome, Brave, Edge, Vivaldi, or Arc. The window names it by its signature, not by its path. Before, macOS accepted every connection and marked it as not verified. Both systems share one check over a small System trait, with tests on a fake. On Linux, Brave, Edge, and Vivaldi join the allowlist, and a browser binary that a package update replaced still passes. The app writes a host manifest for every Chromium browser it knows, so a browser that the check refuses gets the reason. The link removes a connection on every way out and does not list a peer that is gone before the check. A second instance stops before it rewrites the manifests. CI runs the Rust job on Linux and macOS, and only main saves the build cache. The docs say that the pairing approval, not the peer check, stops a program of the same user. Co-Authored-By: Claude Opus 5.5 <[email protected]>
manuelwedler
left a comment
There was a problem hiding this comment.
Review of the peer check on macOS. The Linux sequence is unchanged: the only differences there are the three new browser names, the (deleted) path, geteuid in place of the owner of /proc/self, and the wording of two error messages. Clippy and the tests pass on my Linux machine. I checked the Team IDs of Brave, Vivaldi, and Arc against third-party listings (appcatalog.cloud, desktopinsights.com), and they match. The two manual checks on a Mac in the description are still unchecked, so the end-to-end pairing on macOS is not verified yet.
| &raw mut len, | ||
| /// Chromium builds have an ad hoc signature without a Team ID. Any | ||
| /// program can make such a signature, so the app refuses them. | ||
| const BROWSERS: &[Browser] = &[ |
There was a problem hiding this comment.
The beta channels pass on Linux and fail on macOS. On Linux, Chrome Beta and Edge Beta have the binary names chrome and msedge, which are approved. On macOS, their bundle identifiers are com.google.Chrome.beta, com.google.Chrome.dev, com.google.Chrome.canary, com.microsoft.edgemac.Beta, com.microsoft.edgemac.Dev, and com.microsoft.edgemac.Canary. None of them is in this list, so the check refuses them with "not an approved browser", although host_manifest.rs writes a manifest for each of them. The description says that beta channels get a manifest, which reads as if they are supported.
Two ways out: add the channel identifiers to the requirement (identifier "com.google.Chrome" or identifier "com.google.Chrome.beta" or ..., the Team ID stays the same), or say in the README that macOS accepts only the stable channel.
There was a problem hiding this comment.
Fixed in adac110. Added Chrome Beta/Dev/Canary and Edge Beta/Dev/Canary as their own entries, so the window names the channel. The Chrome IDs match Chrome's designated requirement on my Mac; the Edge IDs are yours and still unconfirmed.
| /// Browser configuration directories, relative to the user's home. A | ||
| /// browser that has none is not installed and is skipped. | ||
| /// browser that has none is not installed and is skipped. The list also | ||
| /// has browsers that the peer check in `peer.rs` refuses, so that they get |
There was a problem hiding this comment.
Nit: this sentence sits above the Linux list, but every browser in the Linux list is approved. The refused one is Chromium on macOS. Move the sentence to the macOS list, or name the browser.
There was a problem hiding this comment.
Fixed in adac110. Moved to the macOS list, naming Chromium.
| - run: cargo build --locked --workspace | ||
| - run: cargo clippy --locked --workspace --all-targets -- -D warnings | ||
| - run: cargo test --locked --workspace | ||
| - if: github.ref == 'refs/heads/main' && steps.cache.outputs.cache-hit != 'true' |
There was a problem hiding this comment.
A custom if keeps the implicit success(). So a push to main with a failing test saves no cache, and the next run builds from scratch. The same happens when cancel-in-progress stops a run on main. If that is what you want, fine. If not, add !cancelled() and steps.<build>.outcome == 'success' for the build step only, so a red test run still saves the compiled dependencies.
There was a problem hiding this comment.
Fixed in adac110. The build step has an id now; main saves when the build passed and the run wasn't cancelled.
| let message = format!("The Onay app refused the connection: {reason}."); | ||
| let sent = send_clear(&mut writer, &ServerFrame::Error { message }); | ||
| // The window does not list a peer that is gone already, such | ||
| // as the probe of a second instance of the app. |
There was a problem hiding this comment.
Nit: the probe of a second instance now ends here with a broken pipe, and listen prints "link connection ended: Broken pipe" for it. Harmless, but it is a log line for a normal event. listen could skip the log for BrokenPipe.
There was a problem hiding this comment.
Fixed in adac110. listen skips BrokenPipe; the test checks that the gone peer gives that kind.
| fn refusal(name: &str, err: &Error) -> String { | ||
| match err.code() { | ||
| REQUIREMENT_FAILED | UNSIGNED => format!("{name} is not an approved browser"), | ||
| CHANGED_ON_DISK => format!( |
There was a problem hiding this comment.
Chromium hit this case in its own parent check and chose another answer. Chrome on macOS updates its bundle on disk while it runs, so SecCodeCheckValidity on the pid fails with errSecCSStaticCodeChanged until the user restarts Chrome. For a browser that updates every week or two and stays open for longer, this refusal is a regular event, not a corner case.
Chromium's web_app_shortcut_copier validates its parent Chrome with the same calls as this file. Commit a39af65b ("Only consider dynamic validity when validating parent process", https://chromium.googlesource.com/chromium/src/+/a39af65b6d233d5a141785fad82b0bffa0094651) switched it to a dynamic-only check: read the Info.plist of the running code with SecCodeCopySigningInformation(kSecCSDynamicInformation), then call SecCodeCopyGuestWithAttributes again with kSecGuestAttributeDynamicCode = true and kSecGuestAttributeDynamicCodeInfoPlist. The requirement is then checked against the signature in memory, the one the kernel validated at exec, not against the file on disk. security-framework-sys exports both attributes, and GuestAttributes::set_other takes them, so this fits the crate that is already in the tree.
I would do it in this pull request or in a follow-up issue, but not leave it as a message to the user. The kernel still guarantees that the running pages match the signature in memory, so the dynamic-only check loses nothing that the check here proves.
There was a problem hiding this comment.
I tried a39af65b's calls first. After renaming a new Info.plist, CodeResources and executable over a running app, the dynamic-only check fails with -67030, because SecCodeCopySigningInformation reads the new Info.plist from disk. Chrome changes Info.plist on every update, so this doesn't avoid the refusal. What keeps Chrome valid is CodeSignCloneManager (crbug 338582873, default on since 2024); without it the app now says to restart the browser. Leaving it as is unless we see the refusal with a real update.
The macOS check approves the beta, dev, and canary channels of Chrome and Edge, which carry the Team ID of the stable channel. Linux approves them already by their binary names. The comment about the manifest for a refused browser now sits on the macOS list and names Chromium. On main, CI saves the build cache once the build passed, also when clippy or a test fails. listen no longer logs a peer that left before the app could answer it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
RustSec published RUSTSEC-2026-0346 to 0349 against rustls 0.23.45, which comes in through reqwest 0.13 (alloy). 0.23.46 fixes all four. It is five days old, so this bump is an exception to the release-age delay, made for a security release. Only RUSTSEC-2026-0349 reaches the app: a network attacker can downgrade later connections to an RPC endpoint from post-quantum to classical key exchange. Co-Authored-By: Claude Opus 5.5 <[email protected]>
manuelwedler
left a comment
There was a problem hiding this comment.
The review fixes close four of the five comments. The Edge channel identifiers match the Homebrew casks. The refusal after an on-disk update stays by choice, and the reason holds: Chromium clones its bundle at launch by default, so Chrome itself does not hit it. Clippy and the tests pass on Linux, and CI is green on both systems.
Part of #20. The peer check on macOS: the app now refuses a connection that does not come from our relay, started by an approved browser, as it already does on Linux. Before this, macOS accepted every connection and marked it as not verified.
What it does
getpeereidgives the user and the group,LOCAL_PEERPIDthe process. The peer must be our relay, run by the same user. The macOS bundle has no setgid relay, so the relay is known by its path until the app is signed.SecCodeCopyGuestWithAttributes,SecCodeCheckValidity) through thesecurity-frameworkcrate, which was already in the tree through reqwest, and it does not use the network. Chromium has only an ad hoc signature, which any program can make, so the app refuses it on macOS.Linux and macOS share one check over a small
Systemtrait. Only the system calls differ per system, and the sequence is tested on a fake.Also in this pull request
/proc/<pid>/exeends in(deleted)) is checked at its installed path, so the link keeps working until the browser restarts.ubuntu-latestandmacos-latest. Pull requests restore the cache of main but do not save their own, and the builds skip incremental data and most debug info, to stay inside the 10 GB cache quota. The job names change to "Rust on ubuntu-latest (app, relay)" and "Rust on macos-latest (app, relay)", which matters for Repo: branch protection on main requiring the CI and Supply chain checks #16.Not in this pull request
/etc/onay/custom_allowed_browsers(bundle identifier and Team ID), from Peer verification on macOS: code signature of the browser #20.LOCAL_PEERTOKEN: an audit token would pin the relay to one process instance, but the browser check names the parent by its process ID anyway.Tests
(deleted)path, the refusal messages, and a peer that is gone before the check./Applications(Chrome and Edge on the GitHub runner), launchd against an Apple requirement, and the system calls on the test process.cargo clippy --all-targets -- -D warningsandcargo test --workspacepass, as dopnpm format:check,pnpm --filter app build, andpnpm -r lint. The requirement also passes for the running Chrome and Brave.Manual checks on a Mac:
🤖 Generated with Claude Code