Summary
grans sync is broken on current macOS Granola builds and cannot be fixed by improving our decryption code. Granola now stores its data-encryption key (DEK) in the macOS data-protection keychain, gated on Granola's own code signature and team ID. No third-party process can read it, with or without a user prompt.
The fix is for grans to stop scavenging Granola's local storage on macOS and instead hold its own credentials.
This is the follow-up to #86 / PR #88, which implemented the Windows os_crypt scheme and assumed macOS matched it.
Reproduction
$ grans sync
[grans] Starting full sync from Granola API...
Error: Failed to read encrypted Granola token store in /Users/<user>/Library/Application Support/Granola
Caused by:
0: Failed to read the 'Granola Safe Storage' password from the macOS Keychain. Ensure Granola is installed and allow access when prompted, or pass --token.
1: The specified item could not be found in the keychain.
Granola desktop 7.441.6, macOS.
What we found
1. Three distinct defects, only one of them trivial
Assumption in src/api/token_store.rs |
Reality on macOS |
Keychain account is Granola |
Account is Granola Key (service Granola Safe Storage) |
storage.dek exists |
File does not exist |
Local State has an os_crypt master key |
Contains only {"uninstall_metrics":{...}} |
supabase.json.enc is v10-prefixed |
No prefix: [12-byte nonce][ciphertext][16-byte GCM tag] |
Fixing the account name alone changes nothing, because there is no storage.dek to unseal.
2. The DEK is entitlement-gated
From /Applications/Granola.app/Contents/Resources/app.asar and the bundled native/keychain.node:
- On packaged macOS builds Granola calls
getOrCreateDek() in its native module.
- The DEK is a random 32 bytes stored in the data-protection keychain:
- service
com.granola.app.dek
- access group
QZ7DHHLN25.granola
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
kSecUseDataProtectionKeychain
- On upgrade it migrates any legacy
storage.dek via safeStorage.decryptStringAsync → importDek(buf) → then unlinkSyncs the file. That is why it is missing.
A direct SecItemCopyMatching probe confirms this empirically:
| Query |
Result |
service com.granola.app.dek + access group QZ7DHHLN25.granola |
errSecMissingEntitlement |
| same, without access group |
errSecItemNotFound |
Access requires an entitlement bound to Granola's code signature. This is not something a prompt, an admin password, or better Rust can work around.
Non-darwin and unpackaged builds still use the old storage.dek + safeStorage path, so the Windows implementation remains correct and should be preserved.
3. Every other local route is closed
granola.db — SQLCipher; random header, sqlite3 reports "file is not a database".
cache-v6.json.enc, stored-accounts.json.enc, user-preferences.json.enc — same DEK.
- Electron
Local Storage/leveldb — only a feature-flag string, no JWT.
- No Granola process listening on any local TCP port.
- Leftover plaintext
supabase.json (May 7) and stored-accounts.json (Jun 12) are stale; every live file was rewritten encrypted on Jul 25.
We deliberately did not try the stale refresh token. WorkOS rotates refresh tokens and treats replay of a consumed one as compromise, revoking the whole session family — it would likely log the user out of Granola desktop.
4. There is no Granola web app
Worth recording, since it is an easy wrong turn:
https://notes.granola.ai/ → 308 → marketing site.
https://app.granola.ai/, https://app.granola.so/ → do not resolve.
- The desktop client only parses
notes.granola.ai for share links: /d/<id>, /f/<id>, /t/<slug>.
So there is no browser session to copy a token from.
The path forward
grans should own its own credentials. Granola's desktop login is standard PKCE and is fully replicable.
Login (verified live)
GET https://api.granola.ai/v1/auth
?dev=false
&code_challenge=<base64url(SHA-256(verifier)), padding stripped>
&platform=macOS
&version=7.441.6
&sign_in_click_id=<uuid>
&intent=download
&provider=google # or microsoft
→ 302 to https://api.workos.com/user_management/authorize?client_id=client_01JZJ0XBDAT8PHJWQY09Y0VD61&...
Notes:
- Omitting
version returns 302 to https://granola.ai/upgrade-app. The client version is mandatory and will need periodic bumping.
- PKCE verifier is 43 chars over
[A-Za-z0-9-._~], challenge is S256.
Callback
Browser lands on granola://login-complete?code=<code>&..., validated against:
{ code, isDev?, sso?, handoff?, signInClickId?, inAppCalendarLinking?, calendarLinkingFlow? }
Exchange
POST https://api.granola.ai/v1/workos-auth-complete
{ code, codeVerifier, platform, isDev, sso, ssoCode, signInClickId, ... }
In the bundle this is k(null, 'workos-auth-complete', ...). The null first argument means the call is unauthenticated, which is what makes bootstrap possible. It returns the workos_tokens object including refresh_token.
Refresh (indefinite)
async function Li(e, t, n) { // e = access token, t = refresh token
return Pi(e, `refresh-access-token`, {
input: { refresh_token: t }, retries: 3, signal: n.signal,
additionalHeaders: n.timeZone ? { "X-Granola-Time-Zone": n.timeZone } : undefined
});
}
POST https://api.granola.ai/v1/refresh-access-token
Authorization: Bearer <access token>
{ "refresh_token": "..." }
Response schema is {access_token, expires_in, refresh_token, token_type, obtained_at?, session_id?, sign_in_method?} — it returns a rotated refresh_token which must be persisted on every refresh, or the chain breaks.
This keeps the full internal API surface grans already depends on: v2/get-documents, v1/get-document-transcript, v1/get-document-panels, people, calendars, templates, recipes.
Open problem: intercepting the callback
macOS routes granola:// to Granola.app, so grans cannot receive the code directly.
We tried the obvious fix — redirect=http://127.0.0.1:<port>/callback on /v1/auth — and it returns 403. The parameter exists but is server-side gated to Granola's own web shell, so the standard loopback CLI pattern is unavailable.
Candidate approaches, in descending order of preference:
- Manual paste.
grans prints the auth URL; user completes login and pastes the granola://login-complete?... URL back. Reliable and reversible, but awkward — browsers show an app-open prompt rather than the target URL.
granola-dev:// scheme. The URL parser accepts granola-dev: as well as granola:, and Info.plist registers only granola. grans could claim granola-dev:// with no conflict. Unverified: dev=true may switch to a staging backend holding none of the user's data. Must be confirmed before relying on this.
- Temporarily reassign the
granola:// handler via LSSetDefaultHandlerForURLScheme, then restore. Rejected — an interrupted run would leave the user unable to log into Granola at all.
There is no device/polling flow to sidestep this: login-check and auth-handoff-complete appear only in the endpoint table and are never called by the client, and retrieve-device-session requires an existing access token.
Decision required before implementation
Performing this login means grans presents Granola's platform and version against Granola's own WorkOS client ID — i.e. it authenticates as the first-party desktop client. It is the user's own account and own data, and grans already calls the internal API, but minting new sessions is a step beyond reading a token the app had already stored. This should be an explicit decision, not an implicit one.
Alternative: the sanctioned public API
Granola ships an official public API (https://public-api.granola.ai, grn_ keys, created in Settings → Connectors → API keys; the revoke-public-api-key endpoint confirms the feature).
- Pros: sanctioned, stable, workspace keys never expire.
- Cons: only
/v1/notes, /v1/notes/{id}?include=transcript, /v1/folders. No panels, people, calendars, templates or recipes. Requires a Business/Enterprise plan.
A poor fit for grans today, but worth revisiting as an optional backend.
Proposed implementation
src/api/credentials.rs — new credential store modelled on the existing src/sync/config.rs Dropbox pattern: TOML at data_dir()/auth.toml, atomic temp-file write + rename, 0600 on unix. Fields: refresh_token, access_token, expires_at, session_id.
src/api/auth.rs — refresh_access_token(...); persist the rotated refresh token before returning so a crash cannot strand the chain.
grans auth login / status / logout — PKCE generation, browser launch, callback capture, code exchange. login also accepts a refresh token on stdin so it never lands in shell history. status prints only expiry and presence, never token material.
resolve_token resolution order — --token → GRANS_TOKEN env var → stored credentials (refresh if expired) → Granola's local store. The last step is unchanged, so Windows and older/unpackaged macOS builds keep working. Note the crate is edition 2024, so env::set_var is unsafe; keep the env read at the CLI boundary and pass an Option<String> down to keep tests free of environment mutation.
src/api/token_store.rs — keep the Granola Key / Granola account fallback and the storage.dek-existence check that avoids a pointless Keychain prompt. Add a typed marker error so read_token_json can downcast_ref and surface actionable guidance directly instead of burying it under Caused by:.
- Docs —
README.md (~123-136) currently claims the macOS Keychain prompt works; correct it. Update CLAUDE.md:61. Note that scripts/granola-token.py is Windows-only.
Risks
grans creates its own session, visible in Granola's session list and revocable there.
- Hardcoded client
version will eventually trip the upgrade-app redirect.
- Refresh-token rotation must be persisted atomically on every refresh.
- Do not replay the stale refresh token from the old plaintext
supabase.json.
Summary
grans syncis broken on current macOS Granola builds and cannot be fixed by improving our decryption code. Granola now stores its data-encryption key (DEK) in the macOS data-protection keychain, gated on Granola's own code signature and team ID. No third-party process can read it, with or without a user prompt.The fix is for
gransto stop scavenging Granola's local storage on macOS and instead hold its own credentials.This is the follow-up to #86 / PR #88, which implemented the Windows
os_cryptscheme and assumed macOS matched it.Reproduction
Granola desktop 7.441.6, macOS.
What we found
1. Three distinct defects, only one of them trivial
src/api/token_store.rsGranolaGranola Key(serviceGranola Safe Storage)storage.dekexistsLocal Statehas anos_cryptmaster key{"uninstall_metrics":{...}}supabase.json.encisv10-prefixed[12-byte nonce][ciphertext][16-byte GCM tag]Fixing the account name alone changes nothing, because there is no
storage.dekto unseal.2. The DEK is entitlement-gated
From
/Applications/Granola.app/Contents/Resources/app.asarand the bundlednative/keychain.node:getOrCreateDek()in its native module.com.granola.app.dekQZ7DHHLN25.granolakSecAttrAccessibleAfterFirstUnlockThisDeviceOnlykSecUseDataProtectionKeychainstorage.dekviasafeStorage.decryptStringAsync→importDek(buf)→ thenunlinkSyncs the file. That is why it is missing.A direct
SecItemCopyMatchingprobe confirms this empirically:com.granola.app.dek+ access groupQZ7DHHLN25.granolaerrSecMissingEntitlementerrSecItemNotFoundAccess requires an entitlement bound to Granola's code signature. This is not something a prompt, an admin password, or better Rust can work around.
Non-darwin and unpackaged builds still use the old
storage.dek+safeStoragepath, so the Windows implementation remains correct and should be preserved.3. Every other local route is closed
granola.db— SQLCipher; random header,sqlite3reports "file is not a database".cache-v6.json.enc,stored-accounts.json.enc,user-preferences.json.enc— same DEK.Local Storage/leveldb— only a feature-flag string, no JWT.supabase.json(May 7) andstored-accounts.json(Jun 12) are stale; every live file was rewritten encrypted on Jul 25.We deliberately did not try the stale refresh token. WorkOS rotates refresh tokens and treats replay of a consumed one as compromise, revoking the whole session family — it would likely log the user out of Granola desktop.
4. There is no Granola web app
Worth recording, since it is an easy wrong turn:
https://notes.granola.ai/→ 308 → marketing site.https://app.granola.ai/,https://app.granola.so/→ do not resolve.notes.granola.aifor share links:/d/<id>,/f/<id>,/t/<slug>.So there is no browser session to copy a token from.
The path forward
gransshould own its own credentials. Granola's desktop login is standard PKCE and is fully replicable.Login (verified live)
→
302tohttps://api.workos.com/user_management/authorize?client_id=client_01JZJ0XBDAT8PHJWQY09Y0VD61&...Notes:
versionreturns302tohttps://granola.ai/upgrade-app. The client version is mandatory and will need periodic bumping.[A-Za-z0-9-._~], challenge is S256.Callback
Browser lands on
granola://login-complete?code=<code>&..., validated against:Exchange
In the bundle this is
k(null, 'workos-auth-complete', ...). Thenullfirst argument means the call is unauthenticated, which is what makes bootstrap possible. It returns theworkos_tokensobject includingrefresh_token.Refresh (indefinite)
Response schema is
{access_token, expires_in, refresh_token, token_type, obtained_at?, session_id?, sign_in_method?}— it returns a rotatedrefresh_tokenwhich must be persisted on every refresh, or the chain breaks.This keeps the full internal API surface
gransalready depends on:v2/get-documents,v1/get-document-transcript,v1/get-document-panels, people, calendars, templates, recipes.Open problem: intercepting the callback
macOS routes
granola://to Granola.app, sogranscannot receive the code directly.We tried the obvious fix —
redirect=http://127.0.0.1:<port>/callbackon/v1/auth— and it returns403. The parameter exists but is server-side gated to Granola's own web shell, so the standard loopback CLI pattern is unavailable.Candidate approaches, in descending order of preference:
gransprints the auth URL; user completes login and pastes thegranola://login-complete?...URL back. Reliable and reversible, but awkward — browsers show an app-open prompt rather than the target URL.granola-dev://scheme. The URL parser acceptsgranola-dev:as well asgranola:, andInfo.plistregisters onlygranola.granscould claimgranola-dev://with no conflict. Unverified:dev=truemay switch to a staging backend holding none of the user's data. Must be confirmed before relying on this.granola://handler viaLSSetDefaultHandlerForURLScheme, then restore. Rejected — an interrupted run would leave the user unable to log into Granola at all.There is no device/polling flow to sidestep this:
login-checkandauth-handoff-completeappear only in the endpoint table and are never called by the client, andretrieve-device-sessionrequires an existing access token.Decision required before implementation
Performing this login means
granspresents Granola'splatformandversionagainst Granola's own WorkOS client ID — i.e. it authenticates as the first-party desktop client. It is the user's own account and own data, andgransalready calls the internal API, but minting new sessions is a step beyond reading a token the app had already stored. This should be an explicit decision, not an implicit one.Alternative: the sanctioned public API
Granola ships an official public API (
https://public-api.granola.ai,grn_keys, created in Settings → Connectors → API keys; therevoke-public-api-keyendpoint confirms the feature)./v1/notes,/v1/notes/{id}?include=transcript,/v1/folders. No panels, people, calendars, templates or recipes. Requires a Business/Enterprise plan.A poor fit for
granstoday, but worth revisiting as an optional backend.Proposed implementation
src/api/credentials.rs— new credential store modelled on the existingsrc/sync/config.rsDropbox pattern: TOML atdata_dir()/auth.toml, atomic temp-file write + rename,0600on unix. Fields:refresh_token,access_token,expires_at,session_id.src/api/auth.rs—refresh_access_token(...); persist the rotated refresh token before returning so a crash cannot strand the chain.grans auth login/status/logout— PKCE generation, browser launch, callback capture, code exchange.loginalso accepts a refresh token on stdin so it never lands in shell history.statusprints only expiry and presence, never token material.resolve_tokenresolution order —--token→GRANS_TOKENenv var → stored credentials (refresh if expired) → Granola's local store. The last step is unchanged, so Windows and older/unpackaged macOS builds keep working. Note the crate is edition 2024, soenv::set_varisunsafe; keep the env read at the CLI boundary and pass anOption<String>down to keep tests free of environment mutation.src/api/token_store.rs— keep theGranola Key/Granolaaccount fallback and thestorage.dek-existence check that avoids a pointless Keychain prompt. Add a typed marker error soread_token_jsoncandowncast_refand surface actionable guidance directly instead of burying it underCaused by:.README.md(~123-136) currently claims the macOS Keychain prompt works; correct it. UpdateCLAUDE.md:61. Note thatscripts/granola-token.pyis Windows-only.Risks
granscreates its own session, visible in Granola's session list and revocable there.versionwill eventually trip theupgrade-appredirect.supabase.json.