Skip to content

macOS: Granola DEK is entitlement-gated; grans must own its own credentials #89

Description

@dmwyatt

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.decryptStringAsyncimportDek(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:

  1. 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.
  2. 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.
  3. 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

  1. 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.
  2. src/api/auth.rsrefresh_access_token(...); persist the rotated refresh token before returning so a crash cannot strand the chain.
  3. 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.
  4. resolve_token resolution order--tokenGRANS_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.
  5. 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:.
  6. DocsREADME.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    prio: p0Critical, drop everythingsize: lLarge, multiple daysstatus: triageNeeds review and categorizationtype: bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions