Session isolation, partitions and browser identity
Multi-partition session sandboxing
Every account runs in its own Chromium session partition. account-store.ts names partitions
persist:magimail_<accountId>; the default account uses persist:magimail_personal
(DEFAULT_PARTITION in apps/magimail/src/constants.ts).
graph TD
subgraph Storage["~/Library/Application Support/Magimail/Partitions/"]
P1["Partitions/magimail_work/"]
P2["Partitions/magimail_personal/"]
end
subgraph Sessions["Electron Chromium Sessions"]
S1["session.fromPartition('persist:magimail_work')"]
S2["session.fromPartition('persist:magimail_personal')"]
end
subgraph Views["WebContentsViews"]
W1["Work Inbox (Always /u/0/)"]
W2["Personal Inbox (Always /u/0/)"]
end
P1 <--> S1 <--> W1
P2 <--> S2 <--> W2
Within its own persist: partition, every account is always /u/0/. Sharing one session, as
ordinary Chrome does, makes Google index accounts /u/0/, /u/1/ and so on by login order; a
session expiring reorders them and breaks the mapping from tab to account. A partition also carries
its own authentication lifecycle, so logging out of one Google account leaves the others signed in.
Each app holds its own accounts
Magimail and Magical keep separate accounts files and separate partition prefixes,
persist:magimail_<accountId> and persist:magical_<accountId>. Signing into an account in one app
leaves the other signed out of it, so the user signs in twice.
Cookie encryption at rest
Session cookies on disk are encrypted using macOS Keychain. Packaged builds flip the
enableCookieEncryption Electron build fuse in electron-builder.json, which activates Chromium's
OSCrypt layer.
Chromium derives an encryption key from an entry in the user's login keychain named
<Product Name> Safe Storage (Magimail Safe Storage and Magical Safe Storage). Cookie values in
the SQLite Cookies database are stored encrypted in the encrypted_value column, leaving the
plaintext value column empty.
Chromium seals the cookies. The apps seal the JSON files they write themselves, under a key of their own; see Local cache files.
Browser identity and authentication
Google refuses sign-in from browsers it scores as unsafe, with "This browser or app may not be secure". What it scores is consistency, not any single value. A browser whose header, JavaScript and engine name three different products is a stronger signal of automation than any one of them would be alone.
The apps present one identity on every surface Google can observe: branded Chrome, at the version of
the Chromium the app ships. Sending a Safari User-Agent to the accounts hosts and Chrome everywhere
else does not work: the split lives only in the request headers while the page's JavaScript reports
Chrome, and feature detection, error-stack formats, window.chrome and Client Hints support all
identify Blink, which no header work settles.
flowchart TD
Chromium["process.versions.chrome<br/>(the engine actually running)"] --> Lists["chromeBrands() / chromeFullVersionBrands()<br/>browser-identity.ts"]
Chromium --> UA["CHROME_USER_AGENT<br/>constants.ts"]
UA --> Header["Request headers<br/>session.ts"]
Lists --> Header
UA --> Partition["ses.setUserAgent()<br/>→ navigator.userAgent in the page"]
Lists --> MainWorld["navigator.userAgentData<br/>preload.ts → executeInMainWorld"]
Header --> Google["Google sees one browser"]
Partition --> Google
MainWorld --> Google
Version derivation
Every version is read from process.versions.chrome, which Electron exposes in the main process and
in preloads alike. Nothing has to be updated when Electron is upgraded, and there is no second copy
to fall out of step with the first.
The one hand-written version is CHROMIUM_VERSION_FALLBACK. It is used only where there is no
Electron process, which means unit tests, so it never reaches Google.
packages/core/tests/browser-identity.test.ts runs the installed Electron binary with
ELECTRON_RUN_AS_NODE=1 and fails if the fallback has fallen behind it, because a pinned version
goes stale silently.
What is sent, and what is deliberately not
| Surface | Value | Source |
|---|---|---|
User-Agent header |
Chrome/<major>.0.0.0, platform frozen at 10_15_7 as Chrome's UA reduction requires |
CHROME_USER_AGENT |
navigator.userAgent |
identical: ses.setUserAgent() governs both |
CHROME_USER_AGENT |
Sec-CH-UA, Sec-CH-UA-Full-Version-List |
GREASE + Chromium + Google Chrome, on HTTPS only |
chromeBrands() |
navigator.userAgentData |
the same two lists, installed in the page's main world | chromeBrands() |
Sec-CH-UA-Mobile, -Platform |
?0, "macOS" |
fixed; true of every build |
Sec-CH-UA-Platform-Version |
not sent | None |
navigator.vendor, .webdriver, platformVersion, architecture |
not touched | Chromium's own, already correct |
Two values are left out on purpose:
Sec-CH-UA-Platform-Versionis not sent. It is high-entropy, so Chrome states it only when a server asks viaAccept-CH, and the main process has no honest value for it. Saying nothing cannot contradict the real value the page reports.navigator.vendorandnavigator.webdriverare not overridden. Chromium already reports"Google Inc."andfalse. Patching a property that is already right only creates a new opportunity to disagree with it.
Electron's Chromium sends no Sec-CH-UA headers at all while still exposing
navigator.userAgentData to pages, so the low-entropy hints have to be supplied. It also advertises
itself as bare Chromium with no Google Chrome brand, which contradicts a Chrome User-Agent; both
lists add that entry at the engine's own version.
Main-world identity patch
Account views run with contextIsolation: true. Anything a preload defines on navigator lands in
the isolated world, and the page never sees it. The identity patch therefore goes through
contextBridge.executeInMainWorld, which serialises the function and calls it in the page.
applyMainWorldIdentity consequently may not reference imports, module constants or any closure.
The brand lists are passed in as args, and a test asserts the function's source contains no such
reference.
Verifying a change
Check anything that touches this file against a real page: compare the request headers with the main world's values for the same load. Unit tests cover the brand lists and the version derivation, but they cannot see what a page receives, and the failure is the two sides disagreeing, which no single-sided assertion catches.
Why neither app holds an OAuth client
The apps use no OAuth:
- Magimail reads unread counts and snippets from the authenticated Atom feed
(
/mail/u/X/feed/atom) and triggers mutations through page actions. - Magical reads events from the authenticated web endpoint (
sync.prefetcheventrange) and dispatches RSVPs through response URLs.
So there is no OAuth client, no Google Cloud project, no consent screen and no API quota. Sign-in is the user's ordinary Google sign-in, happening in Google's own pages, and no token ever reaches the app. Nothing to leak, nothing to rotate, no quota to exhaust, and no verification to pass before shipping a feature.
It also reaches users an OAuth client would not. An administrator who blocks unconfigured third-party apps leaves the user of an OAuth client with a refusal rather than a consent screen, and nothing they can do about it without raising a ticket. Signing in through Google's own pages asks for no grant, so both apps inherit whatever that user can already do in a browser.
What having no contract costs
If embedded sign-in is blocked, or the Atom feed is retired, every install stops working at once and there is nobody to appeal to. The only mitigation is speed: the User-Agent and partition strategy stay documented where they can be changed quickly.
Adopting OAuth would move the risk rather than remove it. A verified client can have its verification reviewed again, its scopes reclassified, or its consent screen rejected on a branding change, and an administrator can still restrict embedded sign-in.
Naming
Describe the apps as "a Mac client for Gmail" and "a Mac client for Google Calendar". Descriptive use of the product name is fine. Use no Google logos or marks anywhere in the apps, the site or a store listing.
Local cache files
Every file an app writes and reads back on its own is encrypted under a key in the login keychain.
These files hold meeting titles, message subjects and snippets; as plaintext JSON, any process
running as the user could read them. widget-cache.json, accounts.json and focus-filter.json
stay plaintext, because each is read by the widget extension or the App Intents extension, a
separate sandboxed binary, so sealing them would mean both sides holding the same key. A keychain
item shared across bundles needs a keychain access group, and macOS kills a bundle claiming that
entitlement at exec unless it also carries a provisioning profile issued by Apple, so no packaged
build could reach the key and every file would fail closed. The key therefore sits in the login
keychain, which the Electron process reaches on its own, and the three extension-read files wait for
a provisioning profile. App Group entitlements are a different case and need no profile, which is
why the widget cache can live in the group container; see building-and-signing.md.
Sealed file format
packages/core/src/secret-file.ts reads and writes the format in the Electron process, and
apps/<app>/swift/Sources/<App>HelperLib/SecretFile.swift does the same in Swift. The layout is
pinned, because both sides open the same files:
0 "MAGBOX" 6 bytes, ASCII
6 0x01 format version
7 nonce 12 random bytes
19 ciphertext AES-256-GCM over the UTF-8 JSON
-16 tag 16 bytes
The file's own basename is the additional authenticated data, so one file's ciphertext cannot be dropped into another's place and still open. A file that does not start with the header is legacy plaintext JSON and is returned as it stands; every write is encrypted, so an existing install converts each file the first time it writes it. Reading a legacy file needs no key, so a build that cannot reach the keychain still starts.
The Swift half is one source file per app rather than a shared target, compiled into the helper, the
widget extension and the App Intents extension. The App Intents extension is built by a bare
swiftc invocation, which cannot consume a SwiftPM library without building and passing the module
by hand; BrandMark.swift is already shared across these targets by relative path.
Both test suites seal and open the same pinned vector, sealed under the name settings.json with a
key of the bytes 0x00 to 0x1f, so the two implementations cannot drift apart without a test
failing. CI runs swift test for both helpers.
Which files are sealed
| File | App | What it holds |
|---|---|---|
settings.json |
both | every preference the user has set |
event-cache.json |
Magical | three days of meetings, tens of kilobytes of titles |
alert-state.json |
Magical | which alerts have already fired |
atom-state/<accountId>.json |
Magimail | the message ids the poller has already announced |
theme-preference.json |
Magimail | the theme choice |
default-client-preference.json |
Magimail | whether the mailto prompt was dismissed for good |
widget-cache.json, accounts.json and focus-filter.json are plaintext; Local cache
files says why.
Key and where it lives
The key is 32 bytes in a generic password item in the login keychain, created on first use. Magimail
names its item com.alexhaslam.magimail.localcache and Magical com.alexhaslam.magical.localcache,
both under account v1. Two files describe the item for each app,
native/Sources/KeychainBridge.swift for Node and SecretFile.swift for Swift, and every field has
to match across them or each side creates a key of its own and neither opens the other's files.
Node cannot call SecItem, so the Electron process reaches the key through a
@_cdecl("localcache_key") bridge in each app's native addon. It crosses base64-encoded, because
the bridge returns a C string and a random key contains NUL bytes.
Every process fetches the key once and holds it for its life. The call crosses into securityd, a
settings write happens on every keystroke in the settings window, and WidgetKit gives a timeline
provider a few seconds in total. When two processes race to create the item, the one already on
disk wins: SecItemAdd reports errSecDuplicateItem and the loser re-reads rather than keeping the
key it just generated.
Unpackaged builds write plaintext
app.isPackaged decides, and each app's main.ts passes it to configureSecretFiles before
anything reads a file. An unpackaged build writes plaintext and asks for no key unless it meets a
sealed file. A dev run shares userData with the installed app, so this is also what stops it
writing factory-fresh plaintext defaults over the packaged build's settings.
When a file will not decrypt
A file that carries the MAGBOX header but does not decrypt is never read as plaintext, because a
file someone replaced would then open exactly as they intended. readSecretFile keeps the two cases
apart: it returns null when the file is absent, and throws SecretFileUnreadableError when the
file is present but yields nothing. What happens next depends on the file.
settings.jsonis neither read nor rewritten for the rest of the session. Both apps fall back to defaults and log on every save that the file is left alone. The unreadable file still holds every preference the user set, and replacing it with defaults is the one outcome nothing recovers from.- Every other file is discarded and rebuilt, each with a log line: the event cache refills on the
next poll, an empty alert record risks repeating one banner, the poller takes the next feed as its
baseline and announces nothing from it, the theme falls back to
system, and themailtoprompt returns once.