Skip to content

Peer check on macOS: the code signature of the browser - #23

Merged
marcocastignoli merged 3 commits into
mainfrom
macos-peer-check
Oct 20, 2026
Merged

marcocastignoli merged 3 commits into
mainfrom
macos-peer-check

Conversation

@marcocastignoli

Copy link
Copy Markdown
Member

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

  • Who connected. getpeereid gives the user and the group, LOCAL_PEERPID the 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.
  • The browser. The relay's parent must have a Developer ID signature from an approved vendor: Chrome, Brave, Edge, Vivaldi, or Arc, each pinned by bundle identifier and Team ID. The check uses the Security framework (SecCodeCopyGuestWithAttributes, SecCodeCheckValidity) through the security-framework crate, 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.
  • The name in the window. A macOS browser shows by its signature, for example "Google Chrome (com.google.Chrome, team EQHXZ8M8AV)". The path can point into the code-sign clone that Chromium keeps in a temporary folder.
  • The reason for a refusal. Not an approved browser; changed on disk while it runs (restart the browser to finish its update); or the signature could not be checked.

Linux and macOS share one check over a small System trait. Only the system calls differ per system, and the sequence is tested on a fake.

Also in this pull request

  • Linux. Brave, Edge, and Vivaldi join the allowlist. A browser binary that a package update replaced (/proc/<pid>/exe ends in (deleted)) is checked at its installed path, so the link keeps working until the browser restarts.
  • Host manifests. The app writes one for every Chromium browser it knows, also beta channels and Chromium on macOS. A browser that the check refuses then gets the reason, not "The Onay app is not installed".
  • Link. A connection is removed on every way out: on macOS, setting the read timeout fails once the relay has closed. A peer that is gone before the check is not listed as refused. A second instance binds the socket before it writes manifests, so it no longer points the browsers at its own relay.
  • CI. One Rust job on ubuntu-latest and macos-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.
  • Docs. PLAN.md and the READMEs say that the peer check keeps out stray programs, but a program of the same user can pass it: it can start the relay and then exec a real browser. Against such a program, the boundary is the pairing approval.

Not in this pull request

Tests

  • New unit tests: the shared check on a fake (an approved browser passes; another user, another binary, no parent, and a refused browser fail), the (deleted) path, the refusal messages, and a peer that is gone before the check.
  • On macOS: each vendor requirement against the browsers in /Applications (Chrome and Edge on the GitHub runner), launchd against an Apple requirement, and the system calls on the test process.
  • Local run on macOS: cargo clippy --all-targets -- -D warnings and cargo test --workspace pass, as do pnpm format:check, pnpm --filter app build, and pnpm -r lint. The requirement also passes for the running Chrome and Brave.
  • The Linux code was not compiled locally. The Linux leg of CI is its first build.

Manual checks on a Mac:

  • Pair with Chrome: the window shows "Google Chrome (com.google.Chrome, team EQHXZ8M8AV)".
  • Connect from Chromium: the extension and the window show that the app refused it, with the reason.

🤖 Generated with Claude Code

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 manuelwedler left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/src-tauri/src/peer.rs
&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] = &[

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread app/src-tauri/src/host_manifest.rs Outdated
/// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in adac110. Moved to the macOS list, naming Chromium.

Comment thread .github/workflows/ci.yml Outdated
- 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'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in adac110. The build step has an id now; main saves when the build passed and the run wasn't cancelled.

Comment thread app/src-tauri/src/link.rs
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in adac110. listen skips BrokenPipe; the test checks that the gone peer gives that kind.

Comment thread app/src-tauri/src/peer.rs
fn refusal(name: &str, err: &Error) -> String {
match err.code() {
REQUIREMENT_FAILED | UNSIGNED => format!("{name} is not an approved browser"),
CHANGED_ON_DISK => format!(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

marcocastignoli and others added 2 commits October 19, 2026 15:25
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 manuelwedler left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@marcocastignoli
marcocastignoli merged commit 45130c6 into main Oct 20, 2026
6 checks passed
@marcocastignoli
marcocastignoli deleted the macos-peer-check branch October 20, 2026 07:27
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.

2 participants