Skip to content

feat(desktop): set up the approver key in the app, behind Touch ID - #1500

Closed
abrichr wants to merge 6 commits into
fix/signed-human-approvalfrom
feat/desktop-keychain-approver
Closed

abrichr wants to merge 6 commits into
fix/signed-human-approvalfrom
feat/desktop-keychain-approver

Conversation

@abrichr

@abrichr abrichr commented Oct 24, 2026

Copy link
Copy Markdown
Member

Follows #1486 and is stacked on its branch. Retarget to main once #1486 merges.

Problem

After #1486, Run it counts an approval only when a registered approver key signs it. Desktop users had to set that key up at a terminal (jit keygen, then jit register --role approver) before "Approve and save" worked.

Change

On a Mac with a Secure Enclave (Apple silicon or a T2 chip), the key is set up in the app.

Setup is one screen and one prompt. Until the key exists, the approval screen shows Set up your approver key in place of Approve. One click and one Touch ID or login-password prompt do the following:

  1. Make a Secure Enclave key (P-256) whose access control asks for user presence on every use.
  2. Make the Ed25519 approver key and seal it to the Secure Enclave key (ECIES with AES-GCM).
  3. Store the sealed key and the Secure Enclave's handle in the login Keychain.
  4. Open the sealed key once, to prove the round trip and that a person is present.
  5. Register the public key under approvers with Flow's register_principal, the same function jit register calls.

Every answer prompts. Desktop runs item-approval approve|decline --approver-signer stdio --approver-public-key HEX. This is a new Flow option:

  1. Flow writes the exact bytes to sign on stdout.
  2. Desktop checks that they are the answer the person chose: the same request, digest and decision, decided_by human, route desktop_run_it, and canonical JSON.
  3. Desktop prompts, signs, and writes the signature back.
  4. Flow checks the signature against the registered key before it uses it.

Key handling. Flow never receives the key. Desktop holds the unsealed key only for one signature and zeroes the buffers. The key is never written to disk unsealed and never logged. The audit log records a 16-character fingerprint only.

Fail closed. Desktop sends no answer, and the screen says why, in each of these cases:

  • no key;
  • a cancelled or failed prompt;
  • a Keychain or Secure Enclave error;
  • bytes that aren't the person's answer;
  • a Flow that's too old.

Rotation and removal. Settings > Approver key does both:

  • Replace key registers the new key before it retires the old one. If the replacement fails, the old key keeps working.
  • Remove key unregisters the key, then deletes it from the Keychain.

Flow's new authority.remove_principal removes a registered key only while it's still the registered one.

Windows, Linux, and Macs without a Secure Enclave keep the terminal path from #1486, and the screen and Settings show those steps. Each would need its own presence-backed key:

  • Windows: Windows Hello (KeyCredentialManager, a TPM key that asks for the PIN or face on each use).
  • Linux: a Secret Service item plus a separate presence check, because Secret Service has none.

Why the Secure Enclave, not a Keychain access-control flag

The Keychain's kSecAttrAccessControl (.userPresence) returns errSecMissingEntitlement (-34018) for a process without a keychain-access-groups entitlement. That covers both the legacy and the data-protection Keychain, and the engine is a Python sidecar. A non-permanent Secure Enclave key with the same access control needs no entitlement. The Secure Enclave enforces the prompt itself: a caller that can't show a prompt gets LAErrorNotInteractive (-1004).

PyObjC can't wrap a Secure Enclave key reference (its CF type lookup raises during the call), so MacEnclave calls Security.framework through ctypes. This adds no dependencies.

Tests

  • Flow (test_human_approval_stdio_signer.py, real subprocess):

    • Desktop-style signing over the pipe is accepted.
    • Each of these records nothing: a cancelled prompt, another key's signature, an unregistered key (never asked to sign), a malformed or unknown response.
    • A removed key no longer signs, and removing an old key leaves its replacement.
  • Desktop (tests/test_engine/test_approver_keychain.py, fake Secure Enclave and in-memory keyring, 27 tests):

    • Setup makes exactly one prompt. Only sealed material is stored.
    • Each of these stores and registers nothing: a cancelled setup, a failed Keychain save, a failed registration.
    • Every signature prompts. No key, cancel, or error means no signature.
    • A key stored without presence is refused.
    • Rotation (same name and new name), a failed rotation, and removal.
    • Bridge: Flow gets only --approver-signer stdio and no passphrase. Desktop refuses to sign a different request, decision, decider or route without prompting.
    • Dispatcher: the new commands work. They're served while a run waits and aren't on the agent socket.
  • Real Mac (tests/test_approver_keychain_real.py, opt-in, no prompt shown):

    • Full setup, sign, rotate and remove against the real Secure Enclave and login Keychain, with the presence flag off.
    • A presence-flag key refuses a non-interactive unseal.
    • A sealed key opens only with its own Secure Enclave key.

    Run it with:

    cd packages/desktop
    OPENADAPT_REAL_KEYCHAIN_TESTS=1 uv run pytest tests/test_approver_keychain_real.py -v

    Result: 3 passed on an M2 Max. Hosted macOS runners are VMs with no Secure Enclave, so the test skips there.

  • End to end, local: real Secure Enclave and Keychain, Desktop's FlowBridge, and this branch's Flow CLI. Flow stored the answer with attestation desktop-approver / desktop_key.

  • Suites:

    • Desktop: 1,304 passed, 48 skipped. Ruff clean.
    • Frontend: 383 passed, tsc clean. New tests cover the setup-in-place-of-Approve flow.
    • Flow: approval suites 81 passed. Ruff, ruff format and mypy clean (561 files).

Not checked: a live Touch ID prompt from the packaged app. Automated tests can't touch the sensor.

Before this works in a released Desktop

🤖 Generated with Claude Code

abrichr and others added 3 commits October 24, 2026 12:33
`item-approval approve|decline --approver-signer stdio --approver-public-key HEX`
lets the process that holds a registered approver key sign the answer
without handing the key to Flow. Flow looks the public key up under
`approvers`, writes the exact bytes to sign as one JSON line on stdout and
reads one line back: a signature, checked against the registered key before
use, or a refusal. Anything else is a refusal, and nothing is recorded.

Desktop uses this to sign with a key kept behind Touch ID. Also adds
`authority.remove_principal`, which removes a registered key only while it is
still the one registered, for key rotation and removal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a Mac with a Secure Enclave, Run it no longer needs terminal setup. The
approval screen shows "Set up your approver key" until the key exists: one
click and one Touch ID (or login password) prompt.

- The Ed25519 approver key is sealed to a Secure Enclave key whose access
  control asks for user presence on every use, and kept in the login
  Keychain. It is never written to disk unsealed, never logged and never
  given to Flow.
- Setup registers the public key under `approvers` with Flow's own
  `register_principal`, the function `jit register` calls.
- Each answer runs Flow with `--approver-signer stdio`. Desktop checks that
  the bytes Flow asks it to sign are the answer the person chose, prompts,
  signs and drops the key.
- No key, a cancelled prompt or a Keychain error means no answer.
- Settings > Approver key replaces the key (new one registered before the
  old one is retired) or removes it.
- Windows, Linux and Macs without a Secure Enclave keep the terminal setup.

The Keychain's own access-control flags need a keychain-access-groups
entitlement the Python engine doesn't have (errSecMissingEntitlement), so the
key is sealed to the Secure Enclave instead, through Security.framework over
ctypes. Real-hardware tests are opt-in (OPENADAPT_REAL_KEYCHAIN_TESTS=1) and
never show a prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abrichr added a commit that referenced this pull request Oct 24, 2026
Each pull request is made of move commits, proven by AST equality, and
seam commits read in full. Coordination table refreshed against the open
pull requests (#1500, #1501 and #1504 added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abrichr and others added 3 commits October 24, 2026 20:17
errSecInteractionNotAllowed (-25308) while the screen is locked made the
availability check remember False, so Desktop showed the terminal setup
until restart. The check is no longer remembered then, and the refusal says
to unlock the Mac instead of blaming Touch ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Touch ID Macs

From the security review of #1500:

- setup() re-registered a stored but unregistered key without a prompt, so
  anything that can write the Keychain item could plant its own public key.
  It now requires the key to open behind a presence prompt (a fresh
  signature over a setup challenge) first, and refuses an item that admits
  it has no presence check.
- On a Mac that can keep the key behind Touch ID but has none set up, Run it
  fell back to a terminal-made file key, which asks for no presence. It now
  refuses and points to setup in OpenAdapt; the file key counts only where
  the app can't keep the key. Settings says so.
- Desktop 0.24.0 with its changelog (after #1486's 0.23.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
abrichr added a commit that referenced this pull request Oct 25, 2026
From the security review of #1519:

- Answering stops re-checks every card after the signature returns and
  before anything changes, so a card that changed or expired while the
  Touch ID prompt was up changes nothing.
- Queue answers on a Mac that can keep the key behind Touch ID never fall
  back to a terminal-made key (as Run it, #1500).
- Removed the unreachable re-arm branch and a method pasted into the
  portal's StopQueueBackend Protocol.
- 0.24.0 changelog entries for the stop queue and re-arm.

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

abrichr commented Oct 25, 2026

Copy link
Copy Markdown
Member Author

Folded into #1519 so a single PR carries Desktop 0.24.0. #1519 contains every commit from this branch (through 21fa218), including the security-review fixes. Review and merge there.

@abrichr abrichr closed this Oct 25, 2026
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