fix(enclave): persist SE key in a file, not the keychain (finally fixes Secure Mode)#185
Conversation
…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>
There was a problem hiding this comment.
DGaffney has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
🛠️ Provider build (macOS arm64) — menu-bar app + CLIBuilt from 🔌 Pre-wired to this PR's stack — the app defaults to ⬇️ cocore-mac-arm64-pr185-33bcc2a.tar.gz (GitHub artifact — requires being signed in to this repo. The zip contains the tar -xzf cocore-mac-arm64*.tar.gz && cd cocore-mac-arm64
./install.sh # installs cocore.app to /Applications + the CLI, launches the tray
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 |
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>
…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>
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:
serveread one SE key (1MPNTd…)agent pubkeyCLI (same signed binary — and what the Secure Mode wizard calls) deterministically read a different key (1feH…) from the same keychain querySo the key
servesigns with never matched the key the wizard requested MDM attestation for → the captured chain never binds → Secure Mode loopsdoes not bind the current signing keyforever. 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
dataRepresentationas a plain 0600 file under~/.cocore— exactly like the existing softwareidentity.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.Validation (before shipping, this time)
Local unsigned build against a clean temp HOME:
agent pubkeyprocesses → the IDENTICAL hardware SE key (ZwnYtB5/…)~/.cocore/dev-cocore-provider-enclave-identity-v1.blob(0600)identity.pem→ a real SE key, not the software fallbackThat'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'sagent pubkey, the MDA auto-path) reads the same file, they converge on one key. The MDA re-request then targets the keyserveactually 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, thentrustLevel: hardware-attested.🤖 Generated with Claude Code