ADR-0008: Mobile access — passkey approval, held by a sovereign native "holder"¶
Status: Accepted Date: 2026-08-14 (accepted on 2026-08-15) Deciders: Thomas (PO) Note: renumbered from ADR-0007 on 2026-08-15 — number 0007 was taken by the "per-session rights" ADR (PR #108), a separate topic.
TL;DR — To reach your Google accounts from a phone — even when the Mac is off — without ever handing your access tokens to a third-party service, we split three roles: who asks for an action (the AI assistant, wherever it runs), who approves it (always the phone, through a fingerprint that signs the precise action using a passkey — the standard supported on macOS/Windows/Linux/Android/iOS), and who actually holds the access (a native "holder" component that seals the tokens in the device's hardware vault — Secure Enclave, StrongBox — with a non-exportable key). Without an always-on server, the phone itself becomes that holder, on demand (nothing runs in the background). The project is now redesigned as free software, which rules out any centralized control point: the only possible relay is blind (it only sees signatures, never a token) and self-hostable.
Context¶
Today the whole system is local to one Mac:
- The MCP server is stdio (
gateway/mcp_server.py): launched as a child process by the LLM client, on the same machine. No network listener. - Tokens live in a local encrypted vault (
GWSA_ROOT/.vault/<alias>/credentials.enc), served by a loopback broker127.0.0.1:4878. - Approval of a sensitive action (unlock, grant, add) goes through the web admin
127.0.0.1:4877— which has no application-level authentication: it trusts the macOS session. Being physically in front of the unlocked Mac is the authorization (ambient authority). - Under
strongauth, consent is signed (Touch ID → ECDSA P-256, anti-replay nonce, verifiable receipt) — see ADR-0005. The key lives in the Secure Enclave when available; the Swift helper otherwise falls back to the software Keychain, then to an exportableprivate.p256file — so the "enclave" guarantee is not universal today.
The new need (session of 2026-08-13): use these accounts from a mobile phone, in contexts where the agent runs somewhere other than the Mac (Claude Code remote, a Claude chat, cowork, Anthropic's cloud environment), with the Mac on or off, and without a personal device that is always on.
Three forces pull against each other:
- The project's DNA: 100% local, tokens never handed to a third party, audit and per-service lock (default-deny).
- Mobile reality: the phone has no "macOS session" to trust; ambient authority does not exist; a cloud agent must be assumed potentially hostile.
- No server: without a machine on 24/7, there is no "backend" that survives the Mac being switched off — except the phone, the only personal device always with you and on.
Decision¶
Adopt a three-role decoupled model, and rebuild the holding/approval component around cross-platform standards.
- Three roles.
- Requester — the LLM agent that asks for an action. Anywhere (local, remote, cloud). Never trusted by construction.
- Approver — the phone, always. It approves by signing the exact description of the action with a passkey (WebAuthn/FIDO2), guarded by biometrics (Face ID / Touch ID / Hello / Android biometrics).
-
Holder — the component that holds the tokens and executes the Google calls. It acts only when presented with a valid signed approval.
-
Approval becomes a passkey signature — a generalization of ADR-0005. The signer migrates from the Mac's Secure Enclave to the phone's passkey. A single standard API replaces the macOS-only Swift helper, and covers all 5 OSes.
-
WYSIWYS — "what you see is what you sign". The phone displays the precise action before signing ("Allow Drive · personal account · folder X · 2 h?"). The signed challenge binds the consent to that action (account, target, duration,
nonce,expires_at). A compromised cloud requester cannot get anything signed other than what the human sees. -
Maximum security at rest: native holder, tokens sealed in the hardware vault. Tokens are encrypted by a non-exportable key kept in the device's hardware vault (iOS/macOS Secure Enclave, Android StrongBox/Keystore, TPM), unsealed only after biometrics. We abandon the "browser" route (PWA): the PO's priority is security at rest, not speed of implementation.
-
No 24/7 server: the phone is the holder when the Mac is off — as an ephemeral on-demand holder. No daemon: open the app → biometrics → unseal → Google call → forget. The Mac remains a possible holder when it is on (the existing setup keeps serving).
-
Chosen technology: Tauri 2 (Rust core, embedded web UI) for the native holder/approver — covers all 5 OSes, small auditable binary, native access to hardware vaults, reduced attack surface. Flutter is the documented alternative (see Options).
-
Free software, therefore zero centralized control point. The only possible relay (to reach a holder behind a NAT from a cloud agent) is blind — it carries end-to-end encrypted envelopes, opaque to it, in both directions (requests/challenges and results); it never sees a token or any plaintext content — and self-hostable. Each user supplies their own OAuth client (already the case through GCP provisioning). No shared secret enters the repository.
-
Complementary decisions (settled on 2026-08-15 with the PO).
- Per-device OAuth enrollment: each device (Mac, phone) holds its own tokens in its own vault — no secret synchronization between devices.
- Gradual migration, no big bang: the existing Python-macOS holder is kept and continues to serve; the native holder is added alongside it. Any eventual unification of the desktop on Tauri 2 is deferred (not decided).
- License: ~~Apache-2.0~~ → MIT (corrected on 2026-09-04: the repository's actual license is MIT — see
LICENSEand the delivered backlog item 0016. The Apache-2.0 choice considered here was not retained at open-source delivery.) - Starting point = phase 1: put passkey approval on the current architecture (Mac holder on) before any native rebuild.
Options considered¶
Axis 1 — Where does the holder live (token holding + execution)?¶
Option 1A — Status quo: the Mac, sole holder¶
| Dimension | Assessment |
|---|---|
| "Mac off" | ❌ no access |
| Sovereignty | ✅✅ maximal (nothing changes) |
| Cost | 🟢 none |
Rejected: does not meet the "Mac on or off" requirement.
Option 1B — An always-on personal device (Pi, NAS, Mac mini)¶
| Dimension | Assessment |
|---|---|
| "Mac off" | ✅ the server holds |
| Sovereignty | ✅ (your own machine) |
| Cost | 🟠 a server to administer + inbound transport + remote biometrics |
Rejected: the PO does not have and does not want an always-on device.
Option 1C — A holder in the cloud (Anthropic or a third party)¶
| Dimension | Assessment |
|---|---|
| "Mac off" | ✅ |
| Sovereignty | ❌ Google tokens handed to a third party, in cleartext at time of use |
| Cost | 🟢 light |
Rejected: betrays the project's DNA. "No MCP at all" (a generic Google connector) is a degraded case of this one — it loses multi-account, policy, lock and audit, that is all the value.
Option 1D — The phone becomes the holder (chosen)¶
| Dimension | Assessment |
|---|---|
| "Mac off" | ✅ the phone is the always-on device |
| Sovereignty | ✅✅ nothing leaves the user's devices |
| Cost | 🔴 core rebuild (leaves Python + gws CLI + macOS Swift) |
Chosen: the only option that reconciles "Mac off", sovereignty and no server. The price — the native rebuild — is accepted.
Axis 2 — Technology of the native holder/approver¶
Option 2A — "Web-first" PWA (WebAuthn + PRF + direct Google calls)¶
| Dimension | Assessment |
|---|---|
| Reach | ✅ the 5 OSes through the browser, no store |
| Security at rest | 🟠 browser vault < native hardware vault; PRF extension still young |
| Cost | 🟢 the leanest |
Rejected given the PO's priority (maximum security at rest). Remains a candidate for a quick prototype if needed.
Option 2B — Tauri 2 (Rust) (chosen)¶
| Dimension | Assessment |
|---|---|
| Reach | ✅ desktop and mobile (Tauri 2) |
| Security at rest | ✅✅ native access to Secure Enclave / StrongBox / TPM; Rust core (memory safety) |
| Auditability (open source) | ✅✅ small binary, reduced surface (vs Electron) |
| Cost | 🟠 stack to set up; Tauri mobile ecosystem young but viable |
Chosen: best alignment of security at rest and open-source auditability.
Option 2C — Flutter (Dart)¶
| Dimension | Assessment |
|---|---|
| Reach | ✅ the 5 OSes |
| Security at rest | ✅ native vault plugins (biometrics, secure storage) |
| Mobile UI velocity | ✅✅ the best |
| Cost | 🟠 heavier runtime; security core in Dart |
Credible alternative: preferable if the priority shifts toward mobile UI richness rather than the auditability of a minimal Rust core.
Option 2D — Keep Python, port it to mobile (BeeWare/Kivy)¶
Rejected: the mobile Python ecosystem is immature for a security component with background tasks and a hardware vault.
Axis 3 — Cross-OS biometric approval¶
Passkeys / WebAuthn (chosen) versus rewriting a native biometric helper per OS. The FIDO2 standard gives, through one API, an enclave-held key + biometrics + signing of a challenge — across the 5 OSes. Writing 5 native helpers (like elicitation-sign.swift) would be the same work five times over. The ADR-0005 logic (canonical payload, nonce, receipt) is kept; only the signing mechanism changes.
Device-bound constraint (hardening). The approver passkey must be bound to the device (platform authenticator, not synced) and guarded by biometric verification — a synced passkey (iCloud / Google Password Manager) or a simple PIN guarantees neither "this precise phone" nor the enclave, and ends up on other devices. Enrollment requires user verification and, where possible, attestation; synced or purely software authenticators are refused for this role.
Threat model (new — the cloud agent changes the game)¶
ADR-0005 assumed a semi-honest local agent (able to bypass via bare gws, but physically nearby). As soon as the requester is cloud, it must be assumed remote and potentially hostile. Consequences, non-negotiable:
- The holder never trusts the requester: every sensitive action requires a fresh signed approval (anti-replay through
nonce+expires_at). - WYSIWYS is mandatory: the phone displays the action before the signature; we never sign something opaque.
- The relay (if any) is blind: it carries end-to-end encrypted envelopes in both directions (challenges/signatures and results), never decrypting any token or content. It is the only return channel when the holder is behind a NAT — the content read passes through it, but encrypted, unreadable to the relay.
- Least authority is kept: the approval covers one action (account, target, duration), not a blank check. The per-service default-deny policy and temporary Drive zones remain the reference.
Link with per-session rights (ADR-0007 / PR #108)¶
A parallel axis (PR #108, ADR-0007 "per-session rights") isolates the scope of each session (conversation): session_id + a right token carried in the call + fine granularity, itself reusing signed elicitation (ADR-0005). The two axes fit together:
- this ADR's requester is a "session" in the sense of ADR-0007;
- the approver (passkey, phase 1 / backlog item 0078) is the "remote consent" of ADR-0007's phases B/C — to be designed once;
- order: the session layer is laid first (ADR-0007 phase A, desktop); mobile consent (this axis) plugs into it afterward.
The two ADRs remain distinct (different questions: per-session isolation vs where / how to approve + hold) but must describe the same signed-consent mechanism (ADR-0005 foundation).
Consequences¶
Becomes possible - Reach your Google accounts from mobile, Mac on or off, without ceding tokens to a third party. - Approve from the phone through one standard API on the 5 OSes. - Hardware security at rest (non-exportable key, guarded by biometrics). - Distribute the project as free software without imposing central infrastructure.
Becomes harder / costlier - The mobile holder leaves Python: core rebuild (Rust/Tauri). This is the hard point. - Two holder implementations will coexist during the transition (existing Python-macOS + native) → risk of security-rule divergence, to be framed. - Multi-device OAuth: the same Google account held by the Mac and the phone forces a choice between per-device enrollment (each device has its own tokens in its vault — recommended) and encrypted synchronization between devices (more convenient, larger surface).
Settled on 2026-08-15 (see Decision §8): per-device enrollment; both holders coexist (gradual migration); license ~~Apache-2.0~~ → MIT (corrected on 2026-09-04, see §8).
To revisit later (child ADRs/backlog items) - Blind relay design (protocol, discovery, hosting) — and the "Mac on" case without a relay (direct LAN / WireGuard-style tunnel). → child ADR. - Multi-device enrollment: the concrete protocol (the "per-device" principle is settled in §8). → child ADR. - Desktop unification on Tauri 2 in the long run (removing Python): open — decision deferred. - Public threat model published + multi-OS CI (desktop + mobile builds; see GHA cost).
Next steps (action items)¶
- [x] Validate this ADR (direction + invariants) with the PO — Accepted on 2026-08-15 (see §8).
- [x] Open the backlog item — epic 0077 + phase 1 0078.
- [ ] Phase 1, low risk: put passkey approval on the current architecture (Mac = holder on, phone = approver) — immediate benefit, no rebuild.
- [ ] Native Tauri 2 holder prototype: seal a token in the hardware vault + one Google call, on a mobile OS.
- [ ] A child ADR on multi-device enrollment and another on the blind relay.
References¶
- ADR-0005 — signed elicitation (signing component reused).
- ADR-0007 "per-session rights" (PR #108) — complementary axis: per-session isolation; its remote consent (phases B/C) = phase 1 of this axis (backlog item 0078). Same signing foundation (ADR-0005).
- ADR-0003, ADR-0006 — content lanes, "default-deny" threat model.
- Current code:
gateway/mcp_server.py(stdio),gateway/broker_server.py(4878),gateway/elicitation.py,scripts/elicitation-sign.swift,admin/server.js(4877). - Standards: WebAuthn / FIDO2 (passkeys), hardware vaults (Secure Enclave, Android StrongBox/Keystore, TPM).