diff --git a/CAPABILITIES.md b/CAPABILITIES.md
index e541d413f..8dbd86732 100644
--- a/CAPABILITIES.md
+++ b/CAPABILITIES.md
@@ -139,6 +139,7 @@ improvising or retrying when something is off.
| Admit a record check only after a fault suite written to a store copy shows it catches every fault it claims, and sign its coverage into the receipt | On main | Synthetic (in-memory REST store, SQLite copy) | `openadapt_flow/fault_gate.py`, `scaffold-verifier --fault-gate`, `verify-run` `admission:` |
| Check that the saved record matches its source document, not only the parameters: intake keeps each value's line, a second reading must agree before a guarded save, and a round trip compares the read-back with the document | On main | Synthetic (MockMed, text referral) | `openadapt_flow/provenance.py`, `intake`, `verify-run --provenance`, guarded save |
| Guarded save: a safety layer any agent wraps around one write (check the record, hold for approval, read back) | On main ([#1258](https://github.com/OpenAdaptAI/OpenAdapt/pull/1258)) | Synthetic | `GUARDED_SAVE.md` |
+| A person's approval in Desktop is signed with a key kept behind Touch ID on a Mac: one-prompt setup in the app, a prompt for every answer, and Flow never sees the key | On main ([#1500](https://github.com/OpenAdaptAI/OpenAdapt/pull/1500)) | Unit tests; opt-in test on a real Secure Enclave | `packages/desktop/openadapt_desktop/approver_keychain.py`, [APPROVAL_MODES.md](packages/flow/docs/APPROVAL_MODES.md#the-approver-key) |
| Draft a record check from the demonstration's write steps (`scaffold-verifier`) | Released | Synthetic | `openadapt-flow scaffold-verifier` (a person fills in the draft) |
| Build a working record check from the reads the app made while the person viewed the saved record, and test it against injected bad writes before anyone reviews it | On main ([#1284](https://github.com/OpenAdaptAI/OpenAdapt/pull/1284)) | Synthetic (MockMed, OpenEMR fixture) | `openadapt-flow scaffold-verifier` |
| Check a save on desktop, RDP or Citrix by looking the record up in a separate read-only session | On main ([#1280](https://github.com/OpenAdaptAI/OpenAdapt/pull/1280)) | Synthetic (WinForms, RDP and Citrix stand-ins) | `runtime/effects/` |
diff --git a/llms-full.txt b/llms-full.txt
index 02ba1a00d..b2510803c 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -536,6 +536,7 @@ improvising or retrying when something is off.
| Admit a record check only after a fault suite written to a store copy shows it catches every fault it claims, and sign its coverage into the receipt | On main | Synthetic (in-memory REST store, SQLite copy) | `openadapt_flow/fault_gate.py`, `scaffold-verifier --fault-gate`, `verify-run` `admission:` |
| Check that the saved record matches its source document, not only the parameters: intake keeps each value's line, a second reading must agree before a guarded save, and a round trip compares the read-back with the document | On main | Synthetic (MockMed, text referral) | `openadapt_flow/provenance.py`, `intake`, `verify-run --provenance`, guarded save |
| Guarded save: a safety layer any agent wraps around one write (check the record, hold for approval, read back) | On main ([#1258](https://github.com/OpenAdaptAI/OpenAdapt/pull/1258)) | Synthetic | `GUARDED_SAVE.md` |
+| A person's approval in Desktop is signed with a key kept behind Touch ID on a Mac: one-prompt setup in the app, a prompt for every answer, and Flow never sees the key | On main ([#1500](https://github.com/OpenAdaptAI/OpenAdapt/pull/1500)) | Unit tests; opt-in test on a real Secure Enclave | `packages/desktop/openadapt_desktop/approver_keychain.py`, [APPROVAL_MODES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/APPROVAL_MODES.md#the-approver-key) |
| Draft a record check from the demonstration's write steps (`scaffold-verifier`) | Released | Synthetic | `openadapt-flow scaffold-verifier` (a person fills in the draft) |
| Build a working record check from the reads the app made while the person viewed the saved record, and test it against injected bad writes before anyone reviews it | On main ([#1284](https://github.com/OpenAdaptAI/OpenAdapt/pull/1284)) | Synthetic (MockMed, OpenEMR fixture) | `openadapt-flow scaffold-verifier` |
| Check a save on desktop, RDP or Citrix by looking the record up in a separate read-only session | On main ([#1280](https://github.com/OpenAdaptAI/OpenAdapt/pull/1280)) | Synthetic (WinForms, RDP and Citrix stand-ins) | `runtime/effects/` |
diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md
index 749a195ee..535eae9cd 100644
--- a/packages/desktop/CHANGELOG.md
+++ b/packages/desktop/CHANGELOG.md
@@ -1,6 +1,20 @@
# CHANGELOG
+## v0.24.0 (2026-10-24)
+
+### Features
+
+- **desktop**: set up the approver key in the app, behind Touch ID
+
+### Bug Fixes
+
+- **desktop**: a locked Mac isn't a Mac without a Secure Enclave
+- **desktop**: re-register a stored approver key only after it opens behind Touch ID
+- **desktop**: a Mac that can keep the key behind Touch ID never falls back to a terminal-made key
+
+
+
## v0.23.0 (2026-10-24)
### Bug Fixes
diff --git a/packages/desktop/docs/OPERATOR_SCREENS.md b/packages/desktop/docs/OPERATOR_SCREENS.md
index 50367db1d..1ade92572 100644
--- a/packages/desktop/docs/OPERATOR_SCREENS.md
+++ b/packages/desktop/docs/OPERATOR_SCREENS.md
@@ -130,7 +130,16 @@ How it works:
reaches Flow only through the child process's environment. Flow re-checks
the answer, re-reads the form and runs the right-record check again before
it saves.
-- Set the key up once per computer, at a terminal. With
+- On a Mac with a Secure Enclave (Apple silicon or a T2 chip), the
+ approval screen shows **Set up your approver key** in place of Approve
+ until the key exists. One click and one Touch ID (or login password)
+ prompt make the key, seal it to the Secure Enclave, keep it in the login
+ Keychain and register its public key under `approvers` with Flow's own
+ `register_principal`. Each answer prompts again, and Flow gets only the
+ signature (`--approver-signer stdio`). Settings > Approver key replaces
+ the key (the new one is registered before the old one is retired) or
+ removes it. See `openadapt_desktop/approver_keychain.py`.
+- Elsewhere, set the key up once per computer, at a terminal. With
`OPENADAPT_JIT_KEY_PASSPHRASE` set, run `openadapt-flow jit keygen --out
~/.openadapt/approver/desktop-approver.pem`, then a person registers the
public key it prints: `openadapt-flow jit register --role approver
diff --git a/packages/desktop/openadapt_desktop/__init__.py b/packages/desktop/openadapt_desktop/__init__.py
index 0f8185fea..f8eab681b 100644
--- a/packages/desktop/openadapt_desktop/__init__.py
+++ b/packages/desktop/openadapt_desktop/__init__.py
@@ -21,4 +21,4 @@
+-- backends/ Storage backend plugins
"""
-__version__ = "0.23.0"
+__version__ = "0.24.0"
diff --git a/packages/desktop/openadapt_desktop/approver_key.py b/packages/desktop/openadapt_desktop/approver_key.py
index 5dc547fea..ac6d58d86 100644
--- a/packages/desktop/openadapt_desktop/approver_key.py
+++ b/packages/desktop/openadapt_desktop/approver_key.py
@@ -167,7 +167,12 @@ def approver_key_environment(
def setup_instructions(route: Route = "desktop") -> str:
- """The one-time terminal steps that make this computer's answers count."""
+ """The one-time terminal steps that make this computer's answers count.
+
+ For computers where the app can't keep the key behind a presence check
+ (Windows, Linux, a Mac without a Secure Enclave). On a Mac that can, the
+ key is set up in OpenAdapt and a file key isn't used.
+ """
path = approver_key_path(route)
name = _ROUTES[route][2]
diff --git a/packages/desktop/openadapt_desktop/approver_keychain.py b/packages/desktop/openadapt_desktop/approver_keychain.py
new file mode 100644
index 000000000..3310b4e76
--- /dev/null
+++ b/packages/desktop/openadapt_desktop/approver_keychain.py
@@ -0,0 +1,784 @@
+"""This Mac's approver key, behind Touch ID.
+
+Flow counts an answer from Run it as a person's only when an Ed25519 key
+registered under ``approvers`` signs it
+(:mod:`openadapt_desktop.approver_key`). On a Mac, Desktop keeps that key
+like this instead of in a file:
+
+* A Secure Enclave key (P-256) with a user-presence access control: every use
+ needs Touch ID or the login password, and the Secure Enclave enforces it, so
+ a program running as this user can't use the key without the person seeing
+ the prompt. The key never leaves the Secure Enclave; Desktop keeps only the
+ Secure Enclave's encrypted handle to it.
+* The Ed25519 key, sealed to that Secure Enclave key (ECIES, AES-GCM). It
+ opens only on this Mac, and only after the prompt.
+* Both, with the public key, as one item in the login Keychain.
+
+Setup (:meth:`ApproverKeychain.setup`) is one screen and one prompt: Desktop
+makes both keys, opens the sealed key once to prove the round trip and that a
+person is there, and registers the public key under ``approvers`` with Flow's
+own :func:`openadapt_flow.jit.authority.register_principal`, the function
+``openadapt-flow jit register`` calls. Each answer
+(:meth:`ApproverKeychain.sign`) prompts again, opens the key, signs one
+message and drops it. The key is never written to disk unsealed, never logged
+and never handed to Flow: Flow asks for the signature over a pipe
+(``--approver-signer stdio``).
+
+Everything fails closed (:class:`ApproverKeychainError`): no key, a cancelled
+prompt, a Mac without a Secure Enclave or a Keychain error means no signature
+and no answer.
+
+The Keychain's own access-control flags would do the same job without the
+Secure Enclave, but macOS honours them only for a signed app with a
+keychain-access-groups entitlement (``errSecMissingEntitlement`` otherwise),
+and the engine runs as a Python sidecar. The Secure Enclave needs no
+entitlement.
+
+Windows and Linux keep the terminal setup (``openadapt-desktop approver-key
+setup``). The same design would need Windows Hello (``KeyCredentialManager``,
+a TPM-backed key that asks for the PIN or face on each use) and, on Linux, a
+Secret Service item plus a presence check, which Secret Service alone doesn't
+give.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import secrets
+import sys
+import threading
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Callable, Literal, Protocol
+
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+# Look the keyring helpers up on the module at call time, as approver_key
+# does, so the test suite's in-memory keyring is used.
+from openadapt_desktop.auth import store as _store
+
+#: The login Keychain item (service ``ai.openadapt.desktop``) for Run it.
+ACCOUNT = "desktop-approver-enclave-v1"
+DEFAULT_PRINCIPAL = "desktop-approver"
+#: Flow signs a person's answer under this domain
+#: (``openadapt_flow.approval_mode.human_attestation.ANSWER_DOMAIN``).
+ANSWER_DOMAIN = b"openadapt-flow.approval.human-answer/v1\x00"
+#: What setup signs to prove the key opens behind a presence prompt.
+SETUP_CHALLENGE = b"openadapt-desktop.approver-setup/v1\x00"
+_SCHEMA = 1
+
+Code = Literal[
+ "approver_key_missing",
+ "approver_presence_refused",
+ "approver_key_unusable",
+ "approver_keychain_unsupported",
+ "approver_payload_refused",
+]
+
+
+class ApproverKeychainError(RuntimeError):
+ """No signature: ``code`` says why, the message says what to do."""
+
+ def __init__(self, code: Code, message: str) -> None:
+ super().__init__(message)
+ self.code: Code = code
+
+
+class Enclave(Protocol):
+ """The Secure Enclave operations Desktop needs (faked in tests)."""
+
+ def available(self) -> bool: ...
+
+ def create(self, *, require_presence: bool) -> tuple[bytes, bytes]:
+ """A new key: (its encrypted handle, its public key in X9.63)."""
+ ...
+
+ def seal(self, public_key: bytes, plaintext: bytes) -> bytes: ...
+
+ def open(
+ self, handle: bytes, sealed: bytes, *, reason: str, interactive: bool = True
+ ) -> bytearray:
+ """Unseal, asking for presence. Raises :class:`ApproverKeychainError`."""
+ ...
+
+
+@dataclass(frozen=True)
+class StoredKey:
+ """The Keychain item: nothing in it opens without the Secure Enclave."""
+
+ public_key: str
+ principal: str
+ handle: bytes
+ sealed: bytes
+ presence: bool
+ created_at: str
+
+ def to_json(self) -> str:
+ return json.dumps(
+ {
+ "schema": _SCHEMA,
+ "public_key": self.public_key,
+ "principal": self.principal,
+ "enclave_key": base64.b64encode(self.handle).decode("ascii"),
+ "sealed_key": base64.b64encode(self.sealed).decode("ascii"),
+ "presence": self.presence,
+ "created_at": self.created_at,
+ },
+ sort_keys=True,
+ )
+
+ @classmethod
+ def from_json(cls, text: str) -> "StoredKey":
+ data = json.loads(text)
+ if not isinstance(data, dict) or data.get("schema") != _SCHEMA:
+ raise ValueError("unknown approver key format")
+ return cls(
+ public_key=str(data["public_key"]),
+ principal=str(data["principal"]),
+ handle=base64.b64decode(data["enclave_key"], validate=True),
+ sealed=base64.b64decode(data["sealed_key"], validate=True),
+ presence=bool(data["presence"]),
+ created_at=str(data["created_at"]),
+ )
+
+
+Registrar = Callable[[str, str], None]
+Unregistrar = Callable[[str, str], bool]
+Lookup = Callable[[str], "str | None"]
+
+
+def _flow_authority():
+ try:
+ from openadapt_flow.jit import authority, lifecycle
+ except ImportError as exc:
+ raise ApproverKeychainError(
+ "approver_key_unusable",
+ "OpenAdapt Flow isn't installed with Desktop, so the approver key can't be registered.",
+ ) from exc
+ if not hasattr(authority, "remove_principal"):
+ raise ApproverKeychainError(
+ "approver_key_unusable",
+ "This Desktop's OpenAdapt Flow is too old to register an approver key. Update Desktop.",
+ )
+ return authority, lifecycle.default_key_dir()
+
+
+def register_with_flow(principal: str, public_key: str) -> None:
+ """``jit register --role approver``, in-process (same function)."""
+
+ authority, key_dir = _flow_authority()
+ try:
+ authority.register_principal(
+ key_dir, role="approver", principal_id=principal, public_key_hex=public_key
+ )
+ except (authority.AuthorityError, OSError, ValueError) as exc:
+ raise ApproverKeychainError(
+ "approver_key_unusable", f"Flow didn't register the approver key: {exc}"
+ ) from exc
+
+
+def unregister_with_flow(principal: str, public_key: str) -> bool:
+ authority, key_dir = _flow_authority()
+ try:
+ return bool(
+ authority.remove_principal(
+ key_dir,
+ role="approver",
+ principal_id=principal,
+ public_key_hex=public_key,
+ )
+ )
+ except (authority.AuthorityError, OSError, ValueError) as exc:
+ raise ApproverKeychainError(
+ "approver_key_unusable", f"Flow didn't remove the approver key: {exc}"
+ ) from exc
+
+
+def registered_name(public_key: str) -> str | None:
+ """The approver name Flow has for this key, or ``None``."""
+
+ try:
+ authority, key_dir = _flow_authority()
+ found = authority.PrincipalRegistry.load(key_dir).lookup(public_key)
+ except (ApproverKeychainError, OSError, ValueError):
+ return None
+ if found is None or found[0] != "approver":
+ return None
+ return str(found[1])
+
+
+def _public_hex(key: Ed25519PrivateKey) -> str:
+ return (
+ key.public_key()
+ .public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ .hex()
+ )
+
+
+def _wipe(buffer: bytearray) -> None:
+ for index in range(len(buffer)):
+ buffer[index] = 0
+
+
+class ApproverKeychain:
+ """Set up, use, rotate and remove this Mac's approver key.
+
+ ``require_presence=False`` exists only for the real-Keychain test, which
+ can't touch the sensor. A key stored that way can't be used by an instance
+ that requires presence, which is every instance Desktop makes.
+ """
+
+ def __init__(
+ self,
+ enclave: Enclave | None = None,
+ *,
+ account: str = ACCOUNT,
+ require_presence: bool = True,
+ register: Registrar = register_with_flow,
+ unregister: Unregistrar = unregister_with_flow,
+ lookup: Lookup = registered_name,
+ ) -> None:
+ self._enclave = enclave if enclave is not None else default_enclave()
+ self._account = account
+ self._presence = require_presence
+ self._register = register
+ self._unregister = unregister
+ self._lookup = lookup
+ self._lock = threading.Lock()
+
+ # -- state ------------------------------------------------------------------
+
+ def supported(self) -> bool:
+ return self._enclave.available()
+
+ def stored(self) -> StoredKey | None:
+ raw = _store._kr_get(_store._keyring(), self._account)
+ if not raw:
+ return None
+ try:
+ return StoredKey.from_json(raw)
+ except (ValueError, KeyError, TypeError):
+ return None
+
+ def status(self) -> dict:
+ """What the setup screen shows. Never includes the key itself."""
+
+ supported = self.supported()
+ stored = self.stored() if supported else None
+ registered = stored is not None and self._lookup(stored.public_key) == stored.principal
+ return {
+ "supported": supported,
+ "set_up": stored is not None and registered,
+ "stored": stored is not None,
+ "registered": registered,
+ "principal": stored.principal if stored else None,
+ "fingerprint": stored.public_key[:16] if stored else None,
+ }
+
+ # -- setup, rotation, removal -------------------------------------------------
+
+ def setup(self, principal: str = DEFAULT_PRINCIPAL, *, replace: bool = False) -> StoredKey:
+ """Make, prove and register the key. One presence prompt.
+
+ Returns the existing key when one is set up, unless ``replace`` (key
+ rotation): then the new key is registered first and the old one is
+ retired, so Run it is never left without a working key.
+ """
+
+ with self._lock:
+ if not self.supported():
+ raise ApproverKeychainError(
+ "approver_keychain_unsupported",
+ "This Mac has no Secure Enclave, so Desktop can't keep the "
+ "approver key behind Touch ID. Set it up at a terminal "
+ "instead.",
+ )
+ old = self.stored()
+ if old is not None and not replace:
+ if self._lookup(old.public_key) != old.principal:
+ # Stored but not registered (an interrupted setup, or a
+ # removed registration). Register it again only after the
+ # key opens behind a presence prompt: a Keychain item
+ # alone proves nothing, since anything that can write it
+ # could have put its own public key there.
+ if self._presence and not old.presence:
+ raise ApproverKeychainError(
+ "approver_key_unusable",
+ "The stored approver key doesn't ask for Touch ID. "
+ "Set up a new one.",
+ )
+ self._sign_with(
+ old,
+ SETUP_CHALLENGE + old.public_key.encode() + secrets.token_bytes(16),
+ reason="confirm your approver key",
+ )
+ self._register(old.principal, old.public_key)
+ return old
+ handle, enclave_public = self._enclave.create(require_presence=self._presence)
+ key = Ed25519PrivateKey.generate()
+ public = _public_hex(key)
+ seed = bytearray(
+ key.private_bytes(
+ serialization.Encoding.Raw,
+ serialization.PrivateFormat.Raw,
+ serialization.NoEncryption(),
+ )
+ )
+ del key
+ try:
+ sealed = self._enclave.seal(enclave_public, bytes(seed))
+ finally:
+ _wipe(seed)
+ new = StoredKey(
+ public_key=public,
+ principal=principal,
+ handle=handle,
+ sealed=sealed,
+ presence=self._presence,
+ created_at=datetime.now(timezone.utc).isoformat(),
+ )
+ # The one prompt: proves the key opens, and that a person is here.
+ self._sign_with(
+ new,
+ SETUP_CHALLENGE + public.encode(),
+ reason="set up your approver key",
+ )
+ if not _store._kr_set(_store._keyring(), self._account, new.to_json()):
+ raise ApproverKeychainError(
+ "approver_key_unusable",
+ "The login Keychain didn't save the approver key. Nothing was set up.",
+ )
+ try:
+ self._register(principal, public)
+ except Exception:
+ if old is not None:
+ _store._kr_set(_store._keyring(), self._account, old.to_json())
+ else:
+ _store._kr_delete(_store._keyring(), self._account)
+ raise
+ if old is not None and (old.principal != principal or old.public_key != public):
+ # A different name keeps the old registration unless retired.
+ self._unregister(old.principal, old.public_key)
+ return new
+
+ def remove(self) -> bool:
+ """Retire the key: unregister it, then delete it from the Keychain."""
+
+ with self._lock:
+ old = self.stored()
+ if old is None:
+ return False
+ self._unregister(old.principal, old.public_key)
+ _store._kr_delete(_store._keyring(), self._account)
+ return True
+
+ # -- signing ------------------------------------------------------------------
+
+ def sign(self, message: bytes, *, reason: str) -> tuple[str, str]:
+ """Sign one message after the presence prompt.
+
+ Returns ``(public_key_hex, "ed25519:")``. Raises
+ :class:`ApproverKeychainError`.
+ """
+
+ with self._lock:
+ stored = self.stored()
+ if stored is None:
+ raise ApproverKeychainError(
+ "approver_key_missing",
+ "Set up your approver key to approve saves on this Mac.",
+ )
+ if self._presence and not stored.presence:
+ raise ApproverKeychainError(
+ "approver_key_unusable",
+ "The stored approver key doesn't ask for Touch ID. Set up a new one.",
+ )
+ return stored.public_key, self._sign_with(stored, message, reason=reason)
+
+ def _sign_with(self, stored: StoredKey, message: bytes, *, reason: str) -> str:
+ seed = self._enclave.open(stored.handle, stored.sealed, reason=reason)
+ try:
+ if len(seed) != 32:
+ raise ApproverKeychainError(
+ "approver_key_unusable", "The approver key didn't open."
+ )
+ key = Ed25519PrivateKey.from_private_bytes(seed)
+ finally:
+ _wipe(seed)
+ try:
+ if _public_hex(key) != stored.public_key:
+ raise ApproverKeychainError(
+ "approver_key_unusable",
+ "The approver key in the Keychain doesn't match its public key.",
+ )
+ return "ed25519:" + key.sign(message).hex()
+ finally:
+ del key
+
+
+_DEFAULT: list[ApproverKeychain] = []
+
+
+def default_keychain() -> ApproverKeychain:
+ """The engine's one approver keychain (one lock for every caller)."""
+
+ if not _DEFAULT:
+ _DEFAULT.append(ApproverKeychain())
+ return _DEFAULT[0]
+
+
+# -- the Secure Enclave, through Security.framework --------------------------------
+
+
+def default_enclave() -> Enclave:
+ if sys.platform == "darwin":
+ return MacEnclave()
+ return NoEnclave()
+
+
+class NoEnclave:
+ """Windows and Linux: no in-app key (see the module docstring)."""
+
+ def available(self) -> bool:
+ return False
+
+ def create(self, *, require_presence: bool) -> tuple[bytes, bytes]:
+ raise ApproverKeychainError(
+ "approver_keychain_unsupported",
+ "In-app approver keys are on macOS only. Set it up at a terminal.",
+ )
+
+ def seal(self, public_key: bytes, plaintext: bytes) -> bytes:
+ raise ApproverKeychainError("approver_keychain_unsupported", "not on this OS")
+
+ def open(
+ self, handle: bytes, sealed: bytes, *, reason: str, interactive: bool = True
+ ) -> bytearray:
+ raise ApproverKeychainError("approver_keychain_unsupported", "not on this OS")
+
+
+# LocalAuthentication and Security error codes that mean the person didn't
+# confirm: cancelled, failed, locked out, no UI allowed, or no passcode.
+_PRESENCE_REFUSED = frozenset({-1, -2, -4, -5, -6, -7, -8, -9, -1004, -128})
+#: errSecInteractionNotAllowed: the Mac is locked (or no prompt can show).
+_INTERACTION_NOT_ALLOWED = -25308
+
+
+class MacEnclave:
+ """Security.framework through ctypes.
+
+ PyObjC can't wrap a Secure Enclave key reference (its CF type lookup
+ raises inside the call), so the key calls go through ctypes and handle
+ Core Foundation objects as pointers. Every object this creates is
+ released; the unsealed key's buffer is zeroed before release.
+ """
+
+ _SEC = "/System/Library/Frameworks/Security.framework/Security"
+ _CF = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"
+ _OBJC = "/usr/lib/libobjc.A.dylib"
+ _LA = "/System/Library/Frameworks/LocalAuthentication.framework/LocalAuthentication"
+ _PRIVATE_KEY_USAGE = 1 << 30
+ _USER_PRESENCE = 1 << 0
+
+ def __init__(self) -> None:
+ self._lib = None
+ self._available: bool | None = None
+
+ # ctypes setup is lazy so importing this module costs nothing elsewhere.
+ def _l(self):
+ if self._lib is None:
+ self._lib = _MacLib(self._SEC, self._CF, self._OBJC, self._LA)
+ return self._lib
+
+ def available(self) -> bool:
+ if self._available is None:
+ try:
+ handle, _public = self.create(require_presence=False)
+ self._available = bool(handle)
+ except ApproverKeychainError as exc:
+ if exc.code == "approver_presence_refused":
+ # The Mac is locked: the Secure Enclave is there but won't
+ # make a key now. Don't remember that; ask again later.
+ return True
+ self._available = False
+ except Exception: # no Secure Enclave, or Security.framework differs
+ self._available = False
+ return self._available
+
+ def create(self, *, require_presence: bool) -> tuple[bytes, bytes]:
+ lib = self._l()
+ flags = self._PRIVATE_KEY_USAGE | (self._USER_PRESENCE if require_presence else 0)
+ with lib.scope() as keep:
+ access = keep(
+ lib.call(
+ "SecAccessControlCreateWithFlags",
+ None,
+ lib.const("kSecAttrAccessibleWhenUnlockedThisDeviceOnly"),
+ flags,
+ )
+ )
+ private = keep(
+ lib.dictionary(
+ [
+ (lib.const("kSecAttrIsPermanent"), lib.false),
+ (lib.const("kSecAttrAccessControl"), access),
+ ]
+ )
+ )
+ attrs = keep(
+ lib.dictionary(lib.key_attrs(keep) + [(lib.const("kSecPrivateKeyAttrs"), private)])
+ )
+ key = keep(lib.call("SecKeyCreateRandomKey", attrs))
+ info = keep(lib.raw("SecKeyCopyAttributes", key))
+ handle = lib.data_bytes(lib.dict_get(info, keep(lib.string("toid"))))
+ public_ref = keep(lib.raw("SecKeyCopyPublicKey", key))
+ public = lib.data_bytes(keep(lib.call("SecKeyCopyExternalRepresentation", public_ref)))
+ if not handle or not public:
+ raise ApproverKeychainError(
+ "approver_key_unusable", "The Secure Enclave didn't make a key."
+ )
+ return handle, public
+
+ def seal(self, public_key: bytes, plaintext: bytes) -> bytes:
+ lib = self._l()
+ with lib.scope() as keep:
+ attrs = keep(
+ lib.dictionary(
+ [
+ (
+ lib.const("kSecAttrKeyType"),
+ lib.const("kSecAttrKeyTypeECSECPrimeRandom"),
+ ),
+ (lib.const("kSecAttrKeyClass"), lib.const("kSecAttrKeyClassPublic")),
+ (lib.const("kSecAttrKeySizeInBits"), keep(lib.number(256))),
+ ]
+ )
+ )
+ public = keep(lib.call("SecKeyCreateWithData", keep(lib.data(public_key)), attrs))
+ sealed = keep(
+ lib.call(
+ "SecKeyCreateEncryptedData",
+ public,
+ lib.algorithm,
+ keep(lib.data(plaintext)),
+ )
+ )
+ return lib.data_bytes(sealed)
+
+ def open(
+ self, handle: bytes, sealed: bytes, *, reason: str, interactive: bool = True
+ ) -> bytearray:
+ lib = self._l()
+ with lib.scope() as keep:
+ context = lib.la_context(reason, interactive)
+ keep(context)
+ private = keep(lib.dictionary([(lib.const("kSecAttrIsPermanent"), lib.false)]))
+ attrs = keep(
+ lib.dictionary(
+ lib.key_attrs(keep)
+ + [
+ (keep(lib.string("toid")), keep(lib.data(handle))),
+ (lib.const("kSecUseAuthenticationContext"), context),
+ (lib.const("kSecPrivateKeyAttrs"), private),
+ ]
+ )
+ )
+ key = keep(lib.call("SecKeyCreateRandomKey", attrs))
+ plain = lib.call(
+ "SecKeyCreateDecryptedData", key, lib.algorithm, keep(lib.data(sealed))
+ )
+ try:
+ return bytearray(lib.data_bytes(plain))
+ finally:
+ lib.wipe_data(plain)
+ lib.release(plain)
+
+
+class _MacLib:
+ """The few Core Foundation, Security and Objective-C calls MacEnclave makes."""
+
+ def __init__(self, sec_path: str, cf_path: str, objc_path: str, la_path: str) -> None:
+ import ctypes
+
+ self.ctypes = ctypes
+ vp = ctypes.c_void_p
+ self.sec = ctypes.CDLL(sec_path)
+ self.cf = ctypes.CDLL(cf_path)
+ self.objc = ctypes.CDLL(objc_path)
+ ctypes.CDLL(la_path) # registers LAContext with the runtime
+ err = ctypes.POINTER(vp)
+ self._sigs = {
+ "SecAccessControlCreateWithFlags": (self.sec, [vp, vp, ctypes.c_ulong, err]),
+ "SecKeyCreateRandomKey": (self.sec, [vp, err]),
+ "SecKeyCopyExternalRepresentation": (self.sec, [vp, err]),
+ "SecKeyCreateWithData": (self.sec, [vp, vp, err]),
+ "SecKeyCreateEncryptedData": (self.sec, [vp, vp, vp, err]),
+ "SecKeyCreateDecryptedData": (self.sec, [vp, vp, vp, err]),
+ "SecKeyCopyAttributes": (self.sec, [vp]),
+ "SecKeyCopyPublicKey": (self.sec, [vp]),
+ }
+ for name, (lib, args) in self._sigs.items():
+ fn = getattr(lib, name)
+ fn.restype, fn.argtypes = vp, args
+ for name, res, args in (
+ ("CFRelease", None, [vp]),
+ ("CFDataCreate", vp, [vp, ctypes.c_char_p, ctypes.c_long]),
+ ("CFDataGetLength", ctypes.c_long, [vp]),
+ ("CFDataGetBytePtr", vp, [vp]),
+ ("CFDictionaryCreateMutable", vp, [vp, ctypes.c_long, vp, vp]),
+ ("CFDictionarySetValue", None, [vp, vp, vp]),
+ ("CFDictionaryGetValue", vp, [vp, vp]),
+ ("CFNumberCreate", vp, [vp, ctypes.c_long, vp]),
+ ("CFStringCreateWithCString", vp, [vp, ctypes.c_char_p, ctypes.c_uint32]),
+ ("CFErrorGetCode", ctypes.c_long, [vp]),
+ ):
+ fn = getattr(self.cf, name)
+ fn.restype, fn.argtypes = res, args
+ self.objc.objc_getClass.restype = vp
+ self.objc.objc_getClass.argtypes = [ctypes.c_char_p]
+ self.objc.sel_registerName.restype = vp
+ self.objc.sel_registerName.argtypes = [ctypes.c_char_p]
+ address = ctypes.cast(self.objc.objc_msgSend, vp).value
+ self._send = ctypes.CFUNCTYPE(vp, vp, vp)(address)
+ self._send_bool = ctypes.CFUNCTYPE(None, vp, vp, ctypes.c_bool)(address)
+ self._send_ptr = ctypes.CFUNCTYPE(None, vp, vp, vp)(address)
+ self._keys = ctypes.addressof(
+ ctypes.c_char.in_dll(self.cf, "kCFTypeDictionaryKeyCallBacks")
+ )
+ self._values = ctypes.addressof(
+ ctypes.c_char.in_dll(self.cf, "kCFTypeDictionaryValueCallBacks")
+ )
+ self.false = vp.in_dll(self.cf, "kCFBooleanFalse").value
+ self.algorithm = self.const(
+ "kSecKeyAlgorithmECIESEncryptionCofactorVariableIVX963SHA256AESGCM"
+ )
+
+ def const(self, name: str):
+ return self.ctypes.c_void_p.in_dll(self.sec, name).value
+
+ def release(self, ref) -> None:
+ if ref:
+ self.cf.CFRelease(ref)
+
+ def scope(self):
+ lib = self
+
+ class _Scope:
+ def __enter__(self):
+ self.refs: list = []
+
+ def keep(ref):
+ self.refs.append(ref)
+ return ref
+
+ return keep
+
+ def __exit__(self, *_exc):
+ for ref in reversed(self.refs):
+ lib.release(ref)
+
+ return _Scope()
+
+ def call(self, name: str, *args):
+ """A Security call that reports a CFError; raises on failure."""
+
+ error = self.ctypes.c_void_p()
+ result = getattr(self.sec, name)(*args, self.ctypes.byref(error))
+ if not result:
+ code = self.cf.CFErrorGetCode(error) if error.value else 0
+ self.release(error.value)
+ if code == _INTERACTION_NOT_ALLOWED:
+ raise ApproverKeychainError(
+ "approver_presence_refused",
+ "This Mac is locked or can't show the Touch ID prompt right "
+ "now. Unlock it and try again. Nothing changed.",
+ )
+ if code in _PRESENCE_REFUSED:
+ raise ApproverKeychainError(
+ "approver_presence_refused",
+ "Touch ID or your password wasn't confirmed, so nothing was approved.",
+ )
+ raise ApproverKeychainError(
+ "approver_key_unusable",
+ f"The Secure Enclave refused ({name}, error {code}).",
+ )
+ return result
+
+ def raw(self, name: str, *args):
+ result = getattr(self.sec, name)(*args)
+ if not result:
+ raise ApproverKeychainError(
+ "approver_key_unusable", f"The Secure Enclave refused ({name})."
+ )
+ return result
+
+ def key_attrs(self, keep) -> list:
+ return [
+ (self.const("kSecAttrKeyType"), self.const("kSecAttrKeyTypeECSECPrimeRandom")),
+ (self.const("kSecAttrKeySizeInBits"), keep(self.number(256))),
+ (self.const("kSecAttrTokenID"), self.const("kSecAttrTokenIDSecureEnclave")),
+ ]
+
+ def dictionary(self, pairs: list):
+ ref = self.cf.CFDictionaryCreateMutable(None, 0, self._keys, self._values)
+ for key, value in pairs:
+ self.cf.CFDictionarySetValue(ref, key, value)
+ return ref
+
+ def dict_get(self, ref, key):
+ return self.cf.CFDictionaryGetValue(ref, key)
+
+ def number(self, value: int):
+ number = self.ctypes.c_int32(value)
+ return self.cf.CFNumberCreate(None, 3, self.ctypes.byref(number))
+
+ def string(self, text: str):
+ return self.cf.CFStringCreateWithCString(None, text.encode("utf-8"), 0x08000100)
+
+ def data(self, value: bytes):
+ return self.cf.CFDataCreate(None, value, len(value))
+
+ def data_bytes(self, ref) -> bytes:
+ if not ref:
+ return b""
+ return self.ctypes.string_at(self.cf.CFDataGetBytePtr(ref), self.cf.CFDataGetLength(ref))
+
+ def wipe_data(self, ref) -> None:
+ if ref:
+ self.ctypes.memset(self.cf.CFDataGetBytePtr(ref), 0, self.cf.CFDataGetLength(ref))
+
+ def la_context(self, reason: str, interactive: bool):
+ """An LAContext with the prompt's reason; +1, released by the scope."""
+
+ sel = self.objc.sel_registerName
+ cls = self.objc.objc_getClass(b"LAContext")
+ context = self._send(self._send(cls, sel(b"alloc")), sel(b"init"))
+ if not context:
+ raise ApproverKeychainError(
+ "approver_key_unusable", "LocalAuthentication isn't available."
+ )
+ self._send_bool(context, sel(b"setInteractionNotAllowed:"), not interactive)
+ text = self.string(reason)
+ self._send_ptr(context, sel(b"setLocalizedReason:"), text)
+ self.release(text)
+ return context
+
+
+__all__ = [
+ "ACCOUNT",
+ "ANSWER_DOMAIN",
+ "ApproverKeychain",
+ "ApproverKeychainError",
+ "DEFAULT_PRINCIPAL",
+ "Enclave",
+ "MacEnclave",
+ "NoEnclave",
+ "StoredKey",
+ "default_enclave",
+ "default_keychain",
+ "register_with_flow",
+ "registered_name",
+ "unregister_with_flow",
+]
diff --git a/packages/desktop/openadapt_desktop/dispatch.py b/packages/desktop/openadapt_desktop/dispatch.py
index 08c7cfa09..cccf42d21 100644
--- a/packages/desktop/openadapt_desktop/dispatch.py
+++ b/packages/desktop/openadapt_desktop/dispatch.py
@@ -66,7 +66,15 @@
#: Flow refusals that mean this computer's approver key isn't set up or
#: registered. Desktop shows Flow's message, which says what to do.
_APPROVER_KEY_SETUP_CODES = frozenset(
- {"human_answer_unsigned", "human_approver_unregistered", "approver_key_unusable"}
+ {
+ "human_answer_unsigned",
+ "human_approver_unregistered",
+ "approver_key_unusable",
+ "approver_key_missing",
+ "approver_presence_refused",
+ "approver_keychain_unsupported",
+ "approver_payload_refused",
+ }
)
_TEACH_CORRECTION_SCHEMA = "openadapt.desktop-teach-correction/v1"
_TEACH_CORRECTION_META_KEY = "teach_correction"
@@ -306,6 +314,12 @@ def _register(self) -> None:
# two answer a waiting run while it runs.
"get_item_approval": self.get_item_approval,
"answer_item_approval": self.answer_item_approval,
+ # The approver key that signs those answers, behind Touch ID on a
+ # Mac. Only the window and the tray's own wire reach these; the
+ # agent socket doesn't.
+ "get_approver_key": self.get_approver_key,
+ "set_up_approver_key": self.set_up_approver_key,
+ "remove_approver_key": self.remove_approver_key,
# "Only ask a person about problems": read the switch, turn it off.
# Nothing here turns it on; that happens in OpenAdapt Cloud.
"get_approval_mode": self.get_approval_mode,
@@ -4349,10 +4363,15 @@ def answer_item_approval(self, **params: Any) -> dict:
if not answer.get("ok"):
code = str(answer.get("code") or "")
error = "Flow refused the answer, so nothing was approved."
+ refused: dict[str, Any] = {"ok": False, "code": code}
if code in _APPROVER_KEY_SETUP_CODES:
- # The person needs to set up or register the approver key.
+ # The person needs to set up or register the approver key, or
+ # didn't confirm Touch ID. Flow's message says which.
error = str(answer.get("message") or error)
- return {"ok": False, "error": error, "code": code}
+ setup = answer.get("setup") or self._approver_key_setup_route(code)
+ if setup:
+ refused["setup"] = setup
+ return {**refused, "error": error}
if answer.get("resume_required"):
# The run stopped waiting before the answer arrived. Desktop
# doesn't resume it: run the item again.
@@ -4368,6 +4387,79 @@ def answer_item_approval(self, **params: Any) -> dict:
"decision": "approved" if decision == "approve" else "declined",
}
+ def _approver_key_setup_route(self, code: str) -> str | None:
+ """Where the person sets the key up after this refusal, if anywhere."""
+
+ if code not in {"human_answer_unsigned", "human_approver_unregistered"}:
+ return None
+ try:
+ supported = self.services.flow_bridge.approver_keychain.supported()
+ except Exception:
+ logger.exception("Could not check the approver keychain")
+ supported = False
+ return "in_app" if supported else "terminal"
+
+ def get_approver_key(self, **_params: Any) -> dict:
+ """Whether this Mac's approver key is set up. Never the key itself."""
+
+ from openadapt_desktop.approver_key import setup_instructions
+
+ try:
+ status = self.services.flow_bridge.approver_keychain.status()
+ except Exception:
+ logger.exception("Could not read the approver key")
+ return {"ok": False, "error": "Desktop couldn't read the approver key."}
+ if not status["supported"]:
+ status["instructions"] = setup_instructions()
+ return {"ok": True, **status}
+
+ def set_up_approver_key(self, **params: Any) -> dict:
+ """Make the approver key behind Touch ID and register it (one prompt).
+
+ ``replace=True`` rotates it: the new key is registered before the old
+ one is retired.
+ """
+
+ from openadapt_desktop.approver_keychain import ApproverKeychainError
+
+ keychain = self.services.flow_bridge.approver_keychain
+ try:
+ stored = keychain.setup(replace=bool(params.get("replace")))
+ except ApproverKeychainError as exc:
+ return {"ok": False, "code": exc.code, "error": str(exc)}
+ except Exception:
+ logger.exception("Could not set up the approver key")
+ return {
+ "ok": False,
+ "code": "approver_key_unusable",
+ "error": "Desktop couldn't set up the approver key. Nothing changed.",
+ }
+ self.services.audit.log(
+ "approver_key_setup",
+ route="desktop",
+ store="keychain",
+ replaced=bool(params.get("replace")),
+ fingerprint=stored.public_key[:16],
+ )
+ return {"ok": True, **keychain.status()}
+
+ def remove_approver_key(self, **_params: Any) -> dict:
+ """Unregister this Mac's approver key and delete it from the Keychain."""
+
+ from openadapt_desktop.approver_keychain import ApproverKeychainError
+
+ keychain = self.services.flow_bridge.approver_keychain
+ try:
+ removed = keychain.remove()
+ except ApproverKeychainError as exc:
+ return {"ok": False, "code": exc.code, "error": str(exc)}
+ except Exception:
+ logger.exception("Could not remove the approver key")
+ return {"ok": False, "error": "Desktop couldn't remove the approver key."}
+ if removed:
+ self.services.audit.log("approver_key_removed", route="desktop")
+ return {"ok": True, "removed": removed, **keychain.status()}
+
def run_item(self, **params: Any) -> dict:
"""Run a task once on one item's input values and record the outcome.
diff --git a/packages/desktop/openadapt_desktop/flow_bridge.py b/packages/desktop/openadapt_desktop/flow_bridge.py
index ae5932816..b4ef4c137 100644
--- a/packages/desktop/openadapt_desktop/flow_bridge.py
+++ b/packages/desktop/openadapt_desktop/flow_bridge.py
@@ -151,6 +151,47 @@
DECLINE_REASONS = frozenset({"wrong_values", "wrong_record", "screen_problem"})
_REQUEST_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_REQUEST_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
+#: Flow's ``--approver-signer stdio`` lines
+#: (``openadapt_flow.approval_mode.human_attestation``).
+_SIGN_REQUEST_SCHEMA = "openadapt-flow.approver-sign-request/v1"
+_SIGN_RESPONSE_SCHEMA = "openadapt-flow.approver-sign-response/v1"
+
+
+def _sign_refusal(code: str, message: str) -> dict:
+ return {"schema": _SIGN_RESPONSE_SCHEMA, "refused": code, "message": message}
+
+
+def _is_this_answer(
+ message: bytes, *, decision: str, request_id: str, request_sha256: str
+) -> bool:
+ """Whether ``message`` is exactly a person's answer to this request.
+
+ Flow signs the answer domain, then the answer as canonical JSON. Desktop
+ reads it back before Touch ID, so the prompt can only ever approve the
+ answer the person chose.
+ """
+
+ from openadapt_desktop.approver_keychain import ANSWER_DOMAIN
+
+ if not message.startswith(ANSWER_DOMAIN):
+ return False
+ body = message[len(ANSWER_DOMAIN) :]
+ try:
+ payload = json.loads(body)
+ except ValueError:
+ return False
+ if not isinstance(payload, dict):
+ return False
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
+ return (
+ canonical == body
+ and payload.get("request_id") == request_id
+ and payload.get("request_sha256") == request_sha256
+ and payload.get("decision") == decision
+ and payload.get("decided_by") == "human"
+ and payload.get("route") == ITEM_APPROVAL_ROUTE
+ )
+
def _contract_counts(value: object) -> dict[str, int] | None:
@@ -561,16 +602,28 @@ def __init__(
flow_bin: str = FLOW_BIN,
runner=subprocess.run,
popen=subprocess.Popen,
+ approver_keychain=None,
) -> None:
self.flow_bin = flow_bin
self._runner = runner
self._popen = popen
+ self._approver_keychain = approver_keychain
self._run_auth_support: bool | None = None
self._attached_browser_support: bool | None = None
self._push_json_support: bool | None = None
self._approval_gate_support: bool | None = None
self._local_drill_support: bool | None = None
+ @property
+ def approver_keychain(self):
+ """This Mac's in-app approver key (:mod:`.approver_keychain`)."""
+
+ if self._approver_keychain is None:
+ from openadapt_desktop.approver_keychain import default_keychain
+
+ self._approver_keychain = default_keychain()
+ return self._approver_keychain
+
@staticmethod
def hosted_runner_contract() -> ModuleType:
"""Load Flow's exact hosted-runner contract or fail before networking.
@@ -1081,10 +1134,18 @@ def item_approval_answer(
"""Answer one waiting request as the person at this computer.
Flow counts the answer as a person's only when Desktop's approver key
- signs it (:mod:`openadapt_desktop.approver_key`), so Desktop passes
- ``--approver-key`` and puts the key's passphrase in the Flow child's
- environment, never on its command line. Without a key, nothing is sent
- and the answer is ``{"ok": False, "code": "human_answer_unsigned"}``.
+ signs it. On a Mac with the key set up in the app
+ (:mod:`openadapt_desktop.approver_keychain`), Flow asks Desktop for the
+ signature over a pipe and Desktop signs after Touch ID; Flow never sees
+ the key. Otherwise Desktop passes the terminal-made key
+ (:mod:`openadapt_desktop.approver_key`) with ``--approver-key`` and
+ puts its passphrase in the Flow child's environment, never on its
+ command line; only where the app can't keep the key (Windows, Linux,
+ a Mac without a Secure Enclave). Without a key, nothing is sent: the
+ answer is
+ ``{"ok": False, "code": "approver_key_missing", "setup": "in_app"}``
+ where the app can set one up, else ``"human_answer_unsigned"`` with
+ the terminal steps.
Desktop has no person-level sign-in (its Cloud credential belongs to
the organization), so the answer carries no Cloud name
(``--unauthenticated``); the signature names the registered approver.
@@ -1124,11 +1185,34 @@ def item_approval_answer(
setup_instructions,
)
+ keychain = self.approver_keychain
+ in_app = keychain.supported()
+ if in_app and keychain.stored() is not None:
+ return self._answer_signed_in_app(
+ args,
+ keychain,
+ decision=decision,
+ request_id=request_id,
+ request_sha256=request_sha256,
+ )
+ if in_app:
+ # A Mac that can keep the key behind Touch ID never falls back to
+ # a terminal-made file key, which asks for no presence.
+ return {
+ "ok": False,
+ "code": "approver_key_missing",
+ "setup": "in_app",
+ "message": (
+ "Set up your approver key in OpenAdapt to approve saves on "
+ "this Mac. Nothing was approved."
+ ),
+ }
key = approver_key_environment()
if key is None:
return {
"ok": False,
"code": "human_answer_unsigned",
+ "setup": "terminal",
"message": (
"This computer has no approver key, so nothing was approved. "
+ setup_instructions()
@@ -1137,8 +1221,12 @@ def item_approval_answer(
key_path, key_env = key
args += ["--approver-key", str(key_path)]
result = self._run(args, timeout=60, env_overrides=key_env)
+ return self._read_answer(result.stdout, result.returncode)
+
+ def _read_answer(self, stdout: str, returncode: int) -> dict:
+ lines = [line for line in (stdout or "").splitlines() if line.strip()]
try:
- answer = json.loads(result.stdout)
+ answer = json.loads(lines[-1]) if lines else None
except json.JSONDecodeError:
answer = None
if not isinstance(answer, dict) or not isinstance(answer.get("ok"), bool):
@@ -1147,7 +1235,7 @@ def item_approval_answer(
"code": "unreadable",
"message": "Flow's answer couldn't be read, so nothing was approved.",
}
- if answer["ok"] and result.returncode != 0:
+ if answer["ok"] and returncode != 0:
return {
"ok": False,
"code": "inconsistent",
@@ -1155,6 +1243,124 @@ def item_approval_answer(
}
return answer
+ def _answer_signed_in_app(
+ self,
+ args: list[str],
+ keychain,
+ *,
+ decision: str,
+ request_id: str,
+ request_sha256: str,
+ timeout: float = 180,
+ ) -> dict:
+ """Run the answer with Flow asking Desktop to sign over a pipe.
+
+ Flow writes one sign request; Desktop checks that the bytes are this
+ answer (the request, the decision, a person at Run it), asks for
+ Touch ID, signs and writes one response. Any check or prompt that
+ fails is sent to Flow as a refusal, so Flow records nothing.
+ """
+
+ import threading
+
+ from openadapt_desktop.approver_keychain import ApproverKeychainError
+
+ stored = keychain.stored()
+ if stored is None:
+ return {
+ "ok": False,
+ "code": "approver_key_missing",
+ "setup": "in_app",
+ "message": "Set up your approver key to approve saves on this Mac.",
+ }
+ prefix = _flow_command(self.flow_bin)
+ if prefix is None:
+ raise FlowNotAvailableError(
+ f"openadapt-flow isn't available: {flow_problem(self.flow_bin)}"
+ )
+ cmd = [
+ *prefix,
+ *args,
+ "--approver-signer",
+ "stdio",
+ "--approver-public-key",
+ stored.public_key,
+ ]
+ logger.debug("flow: {cmd}", cmd=_safe_command_for_log(cmd))
+ proc = self._popen(
+ cmd,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ env=_subprocess_env(),
+ )
+ out: list[str] = []
+
+ def respond(line: str) -> dict:
+ try:
+ sign_request = json.loads(line)
+ message = bytes.fromhex(str(sign_request["message_hex"]))
+ except (ValueError, KeyError, TypeError):
+ return _sign_refusal(
+ "approver_payload_refused", "Flow's sign request couldn't be read."
+ )
+ if sign_request.get("public_key") != stored.public_key or not (
+ _is_this_answer(
+ message,
+ decision=decision,
+ request_id=request_id,
+ request_sha256=request_sha256,
+ )
+ ):
+ return _sign_refusal(
+ "approver_payload_refused",
+ "Flow asked Desktop to sign something other than your "
+ "answer, so nothing was signed.",
+ )
+ reason = (
+ "approve this save"
+ if decision == "approve"
+ else "record that this item shouldn't save"
+ )
+ try:
+ _public, signature = keychain.sign(message, reason=reason)
+ except ApproverKeychainError as exc:
+ return _sign_refusal(exc.code, str(exc))
+ return {"schema": _SIGN_RESPONSE_SCHEMA, "signature": signature}
+
+ def talk() -> None:
+ assert proc.stdout is not None and proc.stdin is not None
+ first = proc.stdout.readline()
+ try:
+ is_request = json.loads(first).get("schema") == _SIGN_REQUEST_SCHEMA
+ except (ValueError, AttributeError):
+ is_request = False
+ if is_request:
+ proc.stdin.write(json.dumps(respond(first)) + "\n")
+ proc.stdin.flush()
+ else:
+ out.append(first)
+ out.append(proc.stdout.read())
+ proc.stdin.close()
+
+ worker = threading.Thread(target=talk, name="approver-sign", daemon=True)
+ worker.start()
+ worker.join(timeout)
+ if worker.is_alive():
+ proc.kill()
+ return {
+ "ok": False,
+ "code": "timeout",
+ "message": "The approval took too long, so nothing was approved.",
+ }
+ try:
+ returncode = proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ returncode = -9
+ return self._read_answer("".join(out), returncode)
+
def supports_approval_gate(self) -> bool:
"""Probe (once) whether Flow can stop before each save for a person.
diff --git a/packages/desktop/openadapt_desktop/ipc.py b/packages/desktop/openadapt_desktop/ipc.py
index c4631fced..6f7388a8c 100644
--- a/packages/desktop/openadapt_desktop/ipc.py
+++ b/packages/desktop/openadapt_desktop/ipc.py
@@ -30,10 +30,19 @@
from openadapt_desktop.dispatch import EngineDispatcher, EngineServices
#: Commands served while another command runs. A run can stop before a save
-#: and wait for a person; these two read and answer that wait, so they never
-#: queue behind the run and never take the dispatch lock. They touch only the
-#: dispatcher's approval watches and Flow's approval files.
-CONCURRENT_COMMANDS = frozenset({"get_item_approval", "answer_item_approval"})
+#: and wait for a person; these read and answer that wait, and set up the
+#: approver key the answer needs, so they never queue behind the run and never
+#: take the dispatch lock. They touch only the dispatcher's approval watches,
+#: Flow's approval files and the approver key (which has its own lock).
+CONCURRENT_COMMANDS = frozenset(
+ {
+ "get_item_approval",
+ "answer_item_approval",
+ "get_approver_key",
+ "set_up_approver_key",
+ "remove_approver_key",
+ }
+)
#: Sent by the Tauri shell when the app quits. Handled on the reader thread,
#: ahead of queued commands, because the shell kills the engine shortly after.
diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json
index 2437b934a..0339b74b6 100644
--- a/packages/desktop/package-lock.json
+++ b/packages/desktop/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "openadapt-desktop",
- "version": "0.23.0",
+ "version": "0.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openadapt-desktop",
- "version": "0.23.0",
+ "version": "0.24.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 41f49e4e8..4d01f9de3 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "openadapt-desktop",
- "version": "0.23.0",
+ "version": "0.24.0",
"description": "Installed companion for OpenAdapt authoring, teaching, and local pairing",
"private": true,
"type": "module",
diff --git a/packages/desktop/pyproject.toml b/packages/desktop/pyproject.toml
index 01b901e62..d14ced2aa 100644
--- a/packages/desktop/pyproject.toml
+++ b/packages/desktop/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "openadapt-desktop"
-version = "0.23.0"
+version = "0.24.0"
description = "The OpenAdapt desktop app and local engine: record a data-entry task, run it on a list, and check each saved record"
readme = "README.md"
requires-python = ">=3.11"
diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock
index cd42b1a36..32a8327f5 100644
--- a/packages/desktop/src-tauri/Cargo.lock
+++ b/packages/desktop/src-tauri/Cargo.lock
@@ -2502,7 +2502,7 @@ dependencies = [
[[package]]
name = "openadapt-desktop"
-version = "0.23.0"
+version = "0.24.0"
dependencies = [
"hex",
"reqwest",
diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml
index 54f9d4a73..8c7680a00 100644
--- a/packages/desktop/src-tauri/Cargo.toml
+++ b/packages/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "openadapt-desktop"
-version = "0.23.0"
+version = "0.24.0"
description = "Installed companion for OpenAdapt authoring, teaching, and local pairing"
authors = ["OpenAdapt AI"]
license = "MIT"
diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json
index 32cb9de7b..f4b491d58 100644
--- a/packages/desktop/src-tauri/tauri.conf.json
+++ b/packages/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicoverbruggen/tauri-v2-schema/main/schema.json",
"productName": "OpenAdapt Desktop",
- "version": "0.23.0",
+ "version": "0.24.0",
"identifier": "ai.openadapt.desktop",
"build": {
"frontendDist": "../dist",
diff --git a/packages/desktop/src/lib/engine.ts b/packages/desktop/src/lib/engine.ts
index 3b262849f..685508c28 100644
--- a/packages/desktop/src/lib/engine.ts
+++ b/packages/desktop/src/lib/engine.ts
@@ -49,6 +49,11 @@ export const CMD = {
// and answer it (they're served while the run is still going).
GET_ITEM_APPROVAL: "get_item_approval",
ANSWER_ITEM_APPROVAL: "answer_item_approval",
+ // The approver key that signs those answers (Touch ID on a Mac). Served
+ // while a run waits, so a person can set it up from the approval screen.
+ GET_APPROVER_KEY: "get_approver_key",
+ SET_UP_APPROVER_KEY: "set_up_approver_key",
+ REMOVE_APPROVER_KEY: "remove_approver_key",
// "Only ask a person about problems": read the switch and turn it off.
// Turning it on happens in OpenAdapt Cloud, never here.
GET_APPROVAL_MODE: "get_approval_mode",
diff --git a/packages/desktop/src/lib/types.ts b/packages/desktop/src/lib/types.ts
index 1a9e4f5e8..ec48c44f5 100644
--- a/packages/desktop/src/lib/types.ts
+++ b/packages/desktop/src/lib/types.ts
@@ -1214,4 +1214,23 @@ export interface ItemApprovalAnswer {
decision?: "approved" | "declined";
error?: string;
code?: string;
+ /** Where the approver key is set up after this refusal. */
+ setup?: "in_app" | "terminal";
+}
+
+/** This computer's approver key. Never the key itself. */
+export interface ApproverKeyStatus {
+ ok: boolean;
+ error?: string;
+ code?: string;
+ /** The app can keep the key behind Touch ID (a Mac with a Secure Enclave). */
+ supported?: boolean;
+ /** Stored and registered: approvals from this computer can count. */
+ set_up?: boolean;
+ stored?: boolean;
+ registered?: boolean;
+ principal?: string | null;
+ fingerprint?: string | null;
+ /** The terminal steps, where the app can't set the key up. */
+ instructions?: string;
}
diff --git a/packages/desktop/src/screens/Settings.tsx b/packages/desktop/src/screens/Settings.tsx
index adc848451..76d6d2b54 100644
--- a/packages/desktop/src/screens/Settings.tsx
+++ b/packages/desktop/src/screens/Settings.tsx
@@ -23,6 +23,7 @@ import {
Pill,
SegControl,
} from "../ui/primitives";
+import { ApproverKeyCard } from "../ui/ApproverKey";
import { OsWarning } from "../ui/OsWarning";
import { ShadowModeCard } from "../ui/ShadowMode";
import {
@@ -250,6 +251,8 @@ export function Settings({
+
+
Promise;
+} {
+ const [status, setStatus] = useState(null);
+ const refresh = useCallback(async () => {
+ const next = await engineTry(CMD.GET_APPROVER_KEY, {}, null);
+ setStatus(next && next.ok ? next : null);
+ }, []);
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+ return { status, refresh };
+}
+
+async function setUp(replace: boolean): Promise {
+ try {
+ return await engineInvoke(CMD.SET_UP_APPROVER_KEY, { replace });
+ } catch (failure) {
+ return {
+ ok: false,
+ error: `OpenAdapt couldn't set up the approver key. Nothing changed. (${String(failure)})`,
+ };
+ }
+}
+
+/** The one-screen setup shown in place of Approve until the key exists. */
+export function ApproverKeySetup({ onReady }: { onReady: () => void | Promise }) {
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+
+ async function start() {
+ setBusy(true);
+ setError("");
+ const result = await setUp(false);
+ setBusy(false);
+ if (!result.ok || !result.set_up) {
+ setError(result.error || "OpenAdapt couldn't set up the approver key. Nothing changed.");
+ return;
+ }
+ await onReady();
+ }
+
+ return (
+
+
Set up your approver key
+
+ On this Mac, each approval asks for your Touch ID or login password, so
+ only a person can approve a save. Set it up once.
+