Skip to content

fix(enclave): persist SE key in a file, not the keychain (finally fixes Secure Mode)#185

Merged
DGaffney merged 1 commit into
mainfrom
fix/se-key-file-persistence
Jul 6, 2026
Merged

fix(enclave): persist SE key in a file, not the keychain (finally fixes Secure Mode)#185
DGaffney merged 1 commit into
mainfrom
fix/se-key-file-persistence

Conversation

@DGaffney

@DGaffney DGaffney commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

The real root cause (found by deep on-device debugging)

#184 moved the SE key blobs to the data-protection keychain — that stopped the gross cross-restart rotation but did not fully fix Secure Mode. Driving the live 0.9.47 app found why:

Both keychains (login and data-protection) read inconsistently across this app's process contexts. Measured on-device:

  • GUI-launched serve read one SE key (1MPNTd…)
  • a one-shot agent pubkey CLI (same signed binary — and what the Secure Mode wizard calls) deterministically read a different key (1feH…) from the same keychain query
  • deleting the login-keychain item didn't change the CLI's answer → it was reading data-protection, proving the split is structural to how keychain access-groups resolve per launch context, not a stale duplicate

So the key serve signs with never matched the key the wizard requested MDM attestation for → the captured chain never binds → Secure Mode loops does not bind the current signing key forever. This is why five prior attempts (per-machine identity, key-rotation continuity, data-protection keychain…) each helped but none finished it: they all still routed through the keychain, whose read is context-dependent.

Fix

Persist the SE key dataRepresentation as a plain 0600 file under ~/.cocore — exactly like the existing software identity.pem. The blob is a device-bound reference the SEP resolves; the private key never leaves the enclave and the blob is useless on any other machine, so a file is safe. A file is read byte-identically by every process regardless of launch context — the entire requirement.

  • Keychain readers kept as one-time migration sources (upgrading machines adopt their existing key before writing the file).
  • Writes are atomic add-if-absent with adopt-the-winner on a concurrent race — never clobber.
  • Applied to both signing and encryption keys.

Validation (before shipping, this time)

Local unsigned build against a clean temp HOME:

  • 6 separate agent pubkey processes → the IDENTICAL hardware SE key (ZwnYtB5/…)
  • blob persisted to ~/.cocore/dev-cocore-provider-enclave-identity-v1.blob (0600)
  • no identity.pem → a real SE key, not the software fallback

That's the cross-process determinism the keychain never gave us. FFI signatures unchanged; provider links, 185 lib tests pass.

Why this is the last one

Once every process (serve, the wizard's agent pubkey, the MDA auto-path) reads the same file, they converge on one key. The MDA re-request then targets the key serve actually signs with, the coordinator captures a chain that binds, and Secure Mode attests. On-device confirmation after release: agent pubkey == the serve's published key, across a restart, then trustLevel: hardware-attested.

🤖 Generated with Claude Code

…rministic across process contexts

#184 moved the SE key blobs to the data-protection keychain, which stopped the
gross cross-restart rotation but did NOT fully fix Secure Mode. Deep debugging on
a live 0.9.47 machine found the real root cause:

  BOTH keychains (login AND data-protection) read INCONSISTENTLY across this
  app's process contexts. Measured on-device: the GUI-launched `serve` read one
  SE key (`1MPNTd…`) while a one-shot `agent pubkey` CLI — the same signed binary,
  which backs the Secure Mode wizard — deterministically read a DIFFERENT key
  (`1feH…`) from the SAME keychain query. Deleting the login-keychain item didn't
  change the CLI's answer (it was reading data-protection), proving the split is
  structural to how keychain access-groups resolve per launch context, not a
  stale duplicate. So the key `serve` signs with never matched the key the wizard
  requested MDM attestation for → the captured chain never binds → Secure Mode
  loops "does not bind the current signing key" forever.

Fix: persist the SE key `dataRepresentation` (a device-bound reference the SEP
resolves — the private key never leaves the enclave, and the blob is useless on
any other machine) as a plain 0600 FILE under ~/.cocore, exactly like the
existing software identity.pem. A file is read byte-identically by every process
regardless of launch context, which is the entire requirement. Keychain readers
are kept as one-time migration sources so an upgrading machine adopts its
existing key before writing the file; writes are atomic add-if-absent with
adopt-the-winner on a concurrent race (never clobber). Applied to both the
signing and encryption keys.

Validated locally (unsigned build, temp HOME): 6 separate `agent pubkey`
processes returned the IDENTICAL hardware SE key, and the blob persisted to
~/.cocore/dev-cocore-provider-enclave-identity-v1.blob — the determinism the
keychain never gave us. FFI signatures unchanged; provider links + 185 lib tests
pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

DGaffney has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@railway-app railway-app Bot temporarily deployed to co/core / cocore-pr-185 July 6, 2026 20:08 Destroyed
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

🛠️ Provider build (macOS arm64) — menu-bar app + CLI

Built from 33bcc2a. The tarball bundles cocore.app (the menu-bar/tray app) plus the bare cocore CLI.

🔌 Pre-wired to this PR's stack — the app defaults to https://client-cocore-pr-185.up.railway.app + wss://advisor-cocore-pr-185.up.railway.app/v1/agent (overridable in Settings → Network).

⬇️ cocore-mac-arm64-pr185-33bcc2a.tar.gz

(GitHub artifact — requires being signed in to this repo. The zip contains the .tar.gz + its .sha256.)

tar -xzf cocore-mac-arm64*.tar.gz && cd cocore-mac-arm64
./install.sh   # installs cocore.app to /Applications + the CLI, launches the tray

⚠️ Ad-hoc signed (not notarized) — but install.sh clears the Gatekeeper quarantine for you, so it launches without the "unidentified developer" block. (If you drag the .app in by hand instead, run xattr -dr com.apple.quarantine /Applications/cocore.app yourself first.)

CLI only? It's wired to this PR's stack too:

./bin/cocore agent pair  --console https://client-cocore-pr-185.up.railway.app
./bin/cocore agent serve --advisor wss://advisor-cocore-pr-185.up.railway.app/v1/agent

@DGaffney DGaffney merged commit c1432a7 into main Jul 6, 2026
10 checks passed
@DGaffney DGaffney deleted the fix/se-key-file-persistence branch July 6, 2026 20:17
DGaffney added a commit that referenced this pull request Jul 6, 2026
SE key file-persistence fix (#185): the enclave key blob is now stored in a
0600 file under ~/.cocore (not the keychain), so every process reads the same
key regardless of launch context — fixes the serve-vs-wizard key split that
kept Secure Mode from binding its MDM attestation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
waab76 pushed a commit to waab76/cocore-py that referenced this pull request Jul 6, 2026
…zed identity + published pubkey)

The real root cause of Secure Mode never binding, found by driving the live
0.9.48 app: an SE key created/accessible in one process context is NOT reliably
usable in another. On the real machine the GUI-launched `serve` settled on key
`1MPNTd…` while the `agent pubkey` CLI (which the Secure Mode wizard runs to bind
MDM attestation) read a DIFFERENT usable key `1feH…` — from the SAME blob file.
So the wizard requested attestation for a key the serve never signs with, and the
captured chain (freshness = sha256(signing pubkey)) could never bind. Worse, the
serve itself flapped (`1feH…` then `1MPNTd…`) because 5 separate call sites each
did a fresh `load_or_create_identity()`, and that load is non-deterministic.

Shared storage (keychain in graze-social#184, a file in graze-social#185) can't fix a KEY that's
context-bound — every prior attempt still had two contexts each loading their own
key. This removes that assumption:

1. `shared_identity()` memoizes the identity per process (once_cell), so all in-
   process call sites return ONE key — the serve can no longer flap mid-run.
2. The serve is the SINGLE SE-key holder. It publishes its pubkey (plaintext) to
   `~/.cocore/signing-pubkey` at startup. `agent pubkey` READS that file instead
   of loading its own SE key, so the wizard always requests MDM attestation for
   the exact key the serve signs with. Falls back to loading only when no serve
   has published one (pre-setup).
3. Diagnostic logs (`pinned process signing identity`, `published serve signing
   pubkey`) make the serve's actual key visible on-device instead of inferred.

Validated locally: `agent pubkey` returns exactly the serve-published key when the
file is present, and a stable loaded key when absent. 185 lib tests pass, fmt +
clippy clean, links with --features secure_enclave.

On-device confirmation after release: the log shows the serve's pinned pubkey ==
`agent pubkey` == the attestation record's key, and Secure Mode binds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant