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. +

+ {error && ( +

+ + {error} +

+ )} +
+ +
+
+ ); +} + +/** Settings: see, replace or remove this computer's approver key. */ +export function ApproverKeyCard() { + const { status, refresh } = useApproverKey(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [confirmRemove, setConfirmRemove] = useState(false); + + if (!status) return null; + + async function replace() { + setBusy(true); + setError(""); + const result = await setUp(status?.set_up ?? false); + setBusy(false); + if (!result.ok) setError(result.error || "The approver key didn't change."); + await refresh(); + } + + async function remove() { + setBusy(true); + setError(""); + try { + const result = await engineInvoke(CMD.REMOVE_APPROVER_KEY, {}); + if (!result.ok) setError(result.error || "The approver key wasn't removed."); + } catch (failure) { + setError(`The approver key wasn't removed. (${String(failure)})`); + } + setBusy(false); + setConfirmRemove(false); + await refresh(); + } + + return ( + + + {!status.supported && status.instructions && ( +

{status.instructions}

+ )} + {status.supported && ( +
+ {status.set_up && status.fingerprint && ( + {status.fingerprint} + )} + + {status.stored && + (confirmRemove ? ( + <> + + + + ) : ( + + ))} +
+ )} + {confirmRemove && ( + + Approvals from this computer are refused until you set up a new key. + + )} + {error && ( + + {error} + + )} +
+ ); +} diff --git a/packages/desktop/src/ui/ItemApproval.test.tsx b/packages/desktop/src/ui/ItemApproval.test.tsx index e3b265bed..7c531bbcc 100644 --- a/packages/desktop/src/ui/ItemApproval.test.tsx +++ b/packages/desktop/src/ui/ItemApproval.test.tsx @@ -106,3 +106,50 @@ it("says plainly when the engine refused the answer", async () => { expect(await screen.findByText(/isn't waiting any more/)).toBeTruthy(); expect(answered).not.toHaveBeenCalled(); }); + +it("offers the one-step approver key setup instead of Approve on a Mac without a key", async () => { + const { engineTry } = await import("../lib/engine"); + vi.mocked(engineTry).mockResolvedValue({ ok: true, supported: true, set_up: false, stored: false }); + vi.mocked(engineInvoke).mockResolvedValue({ ok: true, supported: true, set_up: true, stored: true }); + render( {}} />); + + const setUp = await screen.findByRole("button", { name: "Set up with Touch ID" }); + expect(screen.queryByRole("button", { name: "Approve and save" })).toBeNull(); + expect(screen.getByText(/asks for your Touch ID or login password/)).toBeTruthy(); + + vi.mocked(engineTry).mockResolvedValue({ ok: true, supported: true, set_up: true, stored: true }); + fireEvent.click(setUp); + + await screen.findByRole("button", { name: "Approve and save" }); + expect(engineInvoke).toHaveBeenCalledWith(CMD.SET_UP_APPROVER_KEY, { replace: false }); +}); + +it("keeps Approve hidden when setup fails, and says why", async () => { + const { engineTry } = await import("../lib/engine"); + vi.mocked(engineTry).mockResolvedValue({ ok: true, supported: true, set_up: false }); + vi.mocked(engineInvoke).mockResolvedValue({ + ok: false, + code: "approver_presence_refused", + error: "Touch ID or your password wasn't confirmed, so nothing was approved.", + }); + render( {}} />); + + fireEvent.click(await screen.findByRole("button", { name: "Set up with Touch ID" })); + + expect((await screen.findByRole("alert")).textContent).toMatch(/wasn't confirmed/); + expect(screen.queryByRole("button", { name: "Approve and save" })).toBeNull(); +}); + +it("switches to setup when the engine says the key is missing", async () => { + vi.mocked(engineInvoke).mockResolvedValue({ + ok: false, + code: "approver_key_missing", + setup: "in_app", + error: "Set up your approver key to approve saves on this Mac.", + }); + render( {}} />); + + fireEvent.click(screen.getByRole("button", { name: "Approve and save" })); + + await screen.findByRole("button", { name: "Set up with Touch ID" }); +}); diff --git a/packages/desktop/src/ui/ItemApproval.tsx b/packages/desktop/src/ui/ItemApproval.tsx index a5d359038..5faf5cb8e 100644 --- a/packages/desktop/src/ui/ItemApproval.tsx +++ b/packages/desktop/src/ui/ItemApproval.tsx @@ -9,12 +9,15 @@ // record before it saves. // // Desktop has no person-level sign-in, so every approval is recorded without -// a name. The screen never offers Approve without the filled form to compare. +// a name; this computer's approver key signs it. On a Mac without that key +// yet, the screen offers its one-step setup instead of Approve. The screen +// never offers Approve without the filled form to compare. import { useCallback, useEffect, useRef, useState } from "react"; import { DECLINED } from "../lib/approvalMode"; import { CMD, EVT, engineInvoke, engineTry, onEngineEvent } from "../lib/engine"; import { humanizeInput, sentenceCase } from "../lib/outcomes"; import type { ItemApprovalAnswer, ItemApprovalState } from "../lib/types"; +import { ApproverKeySetup, useApproverKey } from "./ApproverKey"; import { Button } from "./primitives"; import { Icon } from "./simple"; @@ -90,6 +93,11 @@ export function ItemApprovalPanel({ const [reason, setReason] = useState(""); const [error, setError] = useState(""); const [sent, setSent] = useState<"approved" | "declined" | null>(null); + const { status: keyStatus, refresh: refreshKey } = useApproverKey(); + // The engine said the key is missing on a Mac that can set it up here. + const [needsKey, setNeedsKey] = useState(false); + const showSetup = + needsKey || Boolean(keyStatus?.supported && keyStatus.set_up === false); useEffect(() => { setBusy(false); @@ -117,6 +125,10 @@ export function ItemApprovalPanel({ ...(decision === "decline" ? { reason } : {}), }); if (!response.ok) { + if (response.setup === "in_app") { + setNeedsKey(true); + return; + } setError(response.error || "OpenAdapt couldn't send your answer. Nothing was saved."); return; } @@ -196,6 +208,13 @@ export function ItemApprovalPanel({ : "OpenAdapt won't save it."}

+ ) : showSetup ? ( + { + setNeedsKey(false); + await refreshKey(); + }} + /> ) : declining ? (
Why shouldn't it save? diff --git a/packages/desktop/tests/conftest.py b/packages/desktop/tests/conftest.py index d7e277cde..06a0a946c 100644 --- a/packages/desktop/tests/conftest.py +++ b/packages/desktop/tests/conftest.py @@ -40,6 +40,22 @@ def fake_native_recorder(monkeypatch): monkeypatch.setattr("openadapt_desktop.controller._load_capture_recorder", lambda: FakeRecorder) +@pytest.fixture(autouse=True) +def no_real_secure_enclave(monkeypatch): + """Keep tests off this Mac's Secure Enclave and the in-app approver key. + + The engine's default approver keychain would probe the Secure Enclave and + read the login Keychain. Tests get a computer without one (the terminal + path) unless they build an ``ApproverKeychain`` with a fake enclave. The + real-hardware test (tests/test_approver_keychain_real.py) makes its own. + """ + + from openadapt_desktop import approver_keychain + + monkeypatch.setattr(approver_keychain, "default_enclave", approver_keychain.NoEnclave) + monkeypatch.setattr(approver_keychain, "_DEFAULT", []) + + @pytest.fixture(autouse=True) def restore_umask(): """The engine entry points set a private umask; keep it from leaking between tests.""" diff --git a/packages/desktop/tests/test_approver_keychain_real.py b/packages/desktop/tests/test_approver_keychain_real.py new file mode 100644 index 000000000..a28ed5cb1 --- /dev/null +++ b/packages/desktop/tests/test_approver_keychain_real.py @@ -0,0 +1,125 @@ +"""The approver key on a real Mac: the Secure Enclave and the login Keychain. + +Opt-in, because it writes a throwaway item to the login Keychain and makes +Secure Enclave keys. It never shows a Touch ID prompt: + +* the full setup, sign, rotate and remove cycle uses a Secure Enclave key + without the presence flag (``require_presence=False``); +* the presence flag is checked by unsealing with a context that may not show + a prompt, which the Secure Enclave must refuse. + +Run it on a Mac with Touch ID or a T2 chip (Apple silicon or a T2 Intel Mac; +a macOS virtual machine, such as a hosted CI runner, has no Secure Enclave +and skips):: + + cd packages/desktop + OPENADAPT_REAL_KEYCHAIN_TESTS=1 uv run pytest tests/test_approver_keychain_real.py -v + +It removes its Keychain item when it finishes. To check by hand that a real +approval prompts, set up the key from Settings > Approver key in the app. +""" + +from __future__ import annotations + +import os +import secrets +import sys + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +pytestmark = [ + pytest.mark.skipif(sys.platform != "darwin", reason="macOS only"), + pytest.mark.skipif( + os.environ.get("OPENADAPT_REAL_KEYCHAIN_TESTS") != "1", + reason="set OPENADAPT_REAL_KEYCHAIN_TESTS=1 to use the real Keychain", + ), +] + + +@pytest.fixture +def enclave(): + from openadapt_desktop.approver_keychain import MacEnclave + + real = MacEnclave() + if not real.available(): + pytest.skip("this Mac has no Secure Enclave") + return real + + +@pytest.fixture +def account(): + import keyring + + from openadapt_desktop.auth.store import SERVICE_NAME + + name = f"approver-keychain-test-{secrets.token_hex(6)}" + yield name + try: + keyring.delete_password(SERVICE_NAME, name) + except Exception: + pass + + +def test_setup_sign_rotate_and_remove_with_the_real_keychain(enclave, account): + import keyring + + from openadapt_desktop.approver_keychain import ApproverKeychain, ApproverKeychainError + from openadapt_desktop.auth.store import SERVICE_NAME + + approvers: dict[str, str] = {} + + def unregister(principal: str, public: str) -> bool: + if approvers.get(principal) != public: + return False + del approvers[principal] + return True + + keychain = ApproverKeychain( + enclave, + account=account, + require_presence=False, + register=approvers.__setitem__, + unregister=unregister, + lookup=lambda public: next((p for p, v in approvers.items() if v == public), None), + ) + + first = keychain.setup() + assert keyring.get_password(SERVICE_NAME, account) # in the login Keychain + _public, signature = keychain.sign(b"real message", reason="test") + Ed25519PublicKey.from_public_bytes(bytes.fromhex(first.public_key)).verify( + bytes.fromhex(signature.removeprefix("ed25519:")), b"real message" + ) + + second = keychain.setup(replace=True) + assert second.public_key != first.public_key + assert approvers == {"desktop-approver": second.public_key} + + assert keychain.remove() is True + assert keyring.get_password(SERVICE_NAME, account) is None + with pytest.raises(ApproverKeychainError): + keychain.sign(b"real message", reason="test") + + +def test_the_secure_enclave_refuses_a_presence_key_without_a_prompt(enclave): + from openadapt_desktop.approver_keychain import ApproverKeychainError + + handle, public = enclave.create(require_presence=True) + sealed = enclave.seal(public, b"\x07" * 32) + + with pytest.raises(ApproverKeychainError) as raised: + enclave.open(handle, sealed, reason="test", interactive=False) + + assert raised.value.code == "approver_presence_refused" + + +def test_a_sealed_key_opens_only_with_its_own_enclave_key(enclave): + from openadapt_desktop.approver_keychain import ApproverKeychainError + + handle, public = enclave.create(require_presence=False) + other, _other_public = enclave.create(require_presence=False) + sealed = enclave.seal(public, b"\x07" * 32) + + assert bytes(enclave.open(handle, sealed, reason="test")) == b"\x07" * 32 + with pytest.raises(ApproverKeychainError): + enclave.open(other, sealed, reason="test") diff --git a/packages/desktop/tests/test_engine/test_approver_keychain.py b/packages/desktop/tests/test_engine/test_approver_keychain.py new file mode 100644 index 000000000..d165afe16 --- /dev/null +++ b/packages/desktop/tests/test_engine/test_approver_keychain.py @@ -0,0 +1,619 @@ +"""This Mac's approver key behind Touch ID, with a fake Secure Enclave. + +The fake keeps the unsealed key inside itself, as the Secure Enclave keeps +its own key, and answers each presence prompt the way the test says: confirm, +cancel or fail. The real Secure Enclave and Keychain are exercised by +tests/test_approver_keychain_real.py. +""" + +from __future__ import annotations + +import json +import secrets +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from openadapt_desktop.approver_keychain import ( + ACCOUNT, + ANSWER_DOMAIN, + ApproverKeychain, + ApproverKeychainError, +) +from openadapt_desktop.auth.store import SERVICE_NAME +from openadapt_desktop.flow_bridge import FlowBridge + +REQUEST_ID = "0" * 31 + "1" +REQUEST_SHA = "sha256:" + "a" * 64 + + +class FakeEnclave: + def __init__(self, *, available: bool = True) -> None: + self._available = available + self._keys: dict[bytes, bool] = {} # handle -> requires presence + self._sealed: dict[bytes, tuple[bytes, bytes]] = {} # sealed -> (handle, plain) + self.prompts: list[str] = [] + self.answer = "confirm" # or "cancel", "error" + + def available(self) -> bool: + return self._available + + def create(self, *, require_presence: bool) -> tuple[bytes, bytes]: + handle = secrets.token_bytes(16) + self._keys[handle] = require_presence + return handle, b"\x04public:" + handle + + def seal(self, public_key: bytes, plaintext: bytes) -> bytes: + handle = public_key.removeprefix(b"\x04public:") + sealed = secrets.token_bytes(24) + self._sealed[sealed] = (handle, bytes(plaintext)) + return sealed + + def open(self, handle, sealed, *, reason, interactive=True) -> bytearray: + if self._keys.get(handle): + self.prompts.append(reason) + if not interactive or self.answer == "cancel": + raise ApproverKeychainError( + "approver_presence_refused", + "Touch ID or your password wasn't confirmed, so nothing was approved.", + ) + if self.answer == "error": + raise ApproverKeychainError( + "approver_key_unusable", "The Secure Enclave refused (error -25300)." + ) + owner, plain = self._sealed[sealed] + if owner != handle: + raise ApproverKeychainError("approver_key_unusable", "wrong key") + return bytearray(plain) + + +class FakeRegistry: + """Flow's ``approvers`` role: register overwrites, remove only a match.""" + + def __init__(self) -> None: + self.approvers: dict[str, str] = {} + self.fail = False + + def register(self, principal: str, public: str) -> None: + if self.fail: + raise ApproverKeychainError("approver_key_unusable", "Flow is too old") + self.approvers[principal] = public + + def unregister(self, principal: str, public: str) -> bool: + if self.approvers.get(principal) != public: + return False + del self.approvers[principal] + return True + + def lookup(self, public: str) -> str | None: + return next((p for p, v in self.approvers.items() if v == public), None) + + +@pytest.fixture +def enclave() -> FakeEnclave: + return FakeEnclave() + + +@pytest.fixture +def registry() -> FakeRegistry: + return FakeRegistry() + + +@pytest.fixture +def keychain(enclave, registry) -> ApproverKeychain: + return ApproverKeychain( + enclave, + register=registry.register, + unregister=registry.unregister, + lookup=registry.lookup, + ) + + +def _verify(public_hex: str, message: bytes, signature: str) -> None: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_hex)).verify( + bytes.fromhex(signature.removeprefix("ed25519:")), message + ) + + +# -- setup ------------------------------------------------------------------------- + + +def test_setup_is_one_prompt_and_registers_the_public_key( + keychain, enclave, registry, fake_keyring +): + stored = keychain.setup() + + assert enclave.prompts == ["set up your approver key"] + assert registry.approvers == {"desktop-approver": stored.public_key} + assert keychain.status() == { + "supported": True, + "set_up": True, + "stored": True, + "registered": True, + "principal": "desktop-approver", + "fingerprint": stored.public_key[:16], + } + item = json.loads(fake_keyring.get_password(SERVICE_NAME, ACCOUNT)) + assert item["presence"] is True + # Only sealed material is stored: nothing in the item opens without the + # (fake) Secure Enclave. + assert set(item) == { + "schema", + "public_key", + "principal", + "enclave_key", + "sealed_key", + "presence", + "created_at", + } + (plain,) = [plain for _handle, plain in enclave._sealed.values()] + assert plain.hex() not in json.dumps(item) + + +def test_setup_again_returns_the_same_key_without_a_prompt(keychain, enclave): + first = keychain.setup() + enclave.prompts.clear() + + assert keychain.setup().public_key == first.public_key + assert enclave.prompts == [] + + +def test_a_cancelled_setup_prompt_stores_and_registers_nothing( + keychain, enclave, registry, fake_keyring +): + enclave.answer = "cancel" + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + + assert raised.value.code == "approver_presence_refused" + assert fake_keyring.get_password(SERVICE_NAME, ACCOUNT) is None + assert registry.approvers == {} + assert keychain.status()["set_up"] is False + + +def test_a_keychain_that_wont_save_registers_nothing(keychain, registry, fake_keyring): + def refuse(*_args): + raise RuntimeError("keychain locked") + + fake_keyring.set_password = refuse # type: ignore[method-assign] + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + + assert raised.value.code == "approver_key_unusable" + assert registry.approvers == {} + + +def test_a_failed_registration_leaves_no_key_behind(keychain, registry, fake_keyring): + registry.fail = True + + with pytest.raises(ApproverKeychainError): + keychain.setup() + + assert fake_keyring.get_password(SERVICE_NAME, ACCOUNT) is None + + +def test_a_mac_without_a_secure_enclave_is_sent_to_the_terminal(registry): + keychain = ApproverKeychain(FakeEnclave(available=False), register=registry.register) + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + + assert raised.value.code == "approver_keychain_unsupported" + assert keychain.status()["supported"] is False + + +def test_a_stored_but_unregistered_key_is_registered_again_after_a_prompt( + keychain, enclave, registry +): + stored = keychain.setup() + registry.approvers.clear() + enclave.prompts.clear() + + assert keychain.status()["set_up"] is False + keychain.setup() + assert registry.approvers == {"desktop-approver": stored.public_key} + assert enclave.prompts == ["confirm your approver key"] + + +def test_a_planted_keychain_item_is_never_registered_without_presence( + keychain, enclave, registry, fake_keyring +): + """Something that can write the Keychain item plants its own key.""" + + from openadapt_desktop.approver_keychain import StoredKey + + keychain.setup() + registry.approvers.clear() + planted = StoredKey.from_json(fake_keyring.get_password(SERVICE_NAME, ACCOUNT)) + # The planted item claims Touch ID but the person doesn't confirm. + enclave.answer = "cancel" + + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + assert raised.value.code == "approver_presence_refused" + assert registry.approvers == {} + + # An item that admits it has no presence check is refused outright. + fake_keyring.set_password( + SERVICE_NAME, + ACCOUNT, + StoredKey(**{**planted.__dict__, "presence": False}).to_json(), + ) + enclave.answer = "confirm" + with pytest.raises(ApproverKeychainError) as raised: + keychain.setup() + assert raised.value.code == "approver_key_unusable" + assert registry.approvers == {} + + +# -- signing ----------------------------------------------------------------------- + + +def test_each_signature_asks_for_presence(keychain, enclave): + stored = keychain.setup() + enclave.prompts.clear() + + for _ in range(2): + public, signature = keychain.sign(b"message", reason="approve this save") + assert public == stored.public_key + _verify(public, b"message", signature) + + assert enclave.prompts == ["approve this save", "approve this save"] + + +@pytest.mark.parametrize( + "answer, code", + [("cancel", "approver_presence_refused"), ("error", "approver_key_unusable")], +) +def test_no_presence_or_a_keychain_error_means_no_signature(keychain, enclave, answer, code): + keychain.setup() + enclave.answer = answer + + with pytest.raises(ApproverKeychainError) as raised: + keychain.sign(b"message", reason="approve this save") + + assert raised.value.code == code + + +def test_no_key_means_no_signature(keychain): + with pytest.raises(ApproverKeychainError) as raised: + keychain.sign(b"message", reason="approve this save") + assert raised.value.code == "approver_key_missing" + + +def test_a_key_stored_without_presence_isnt_used(enclave, registry, fake_keyring): + ApproverKeychain(enclave, require_presence=False, register=registry.register).setup() + + with pytest.raises(ApproverKeychainError) as raised: + ApproverKeychain(enclave, register=registry.register).sign(b"m", reason="r") + + assert raised.value.code == "approver_key_unusable" + + +# -- rotation and removal ---------------------------------------------------------- + + +def test_rotation_registers_the_new_key_and_retires_the_old(keychain, enclave, registry): + old = keychain.setup() + + new = keychain.setup(replace=True) + + assert new.public_key != old.public_key + assert registry.approvers == {"desktop-approver": new.public_key} + _public, signature = keychain.sign(b"m", reason="approve this save") + _verify(new.public_key, b"m", signature) + + +def test_rotation_to_a_new_name_unregisters_the_old_name(keychain, registry): + old = keychain.setup() + + new = keychain.setup("front-desk", replace=True) + + assert registry.approvers == {"front-desk": new.public_key} + assert old.public_key not in registry.approvers.values() + + +def test_a_failed_rotation_keeps_the_old_key_working(keychain, registry): + old = keychain.setup() + registry.fail = True + + with pytest.raises(ApproverKeychainError): + keychain.setup(replace=True) + + assert keychain.stored().public_key == old.public_key + assert registry.approvers == {"desktop-approver": old.public_key} + + +def test_removal_unregisters_and_deletes_the_key(keychain, registry, fake_keyring): + keychain.setup() + + assert keychain.remove() is True + + assert registry.approvers == {} + assert fake_keyring.get_password(SERVICE_NAME, ACCOUNT) is None + assert keychain.remove() is False + with pytest.raises(ApproverKeychainError) as raised: + keychain.sign(b"m", reason="r") + assert raised.value.code == "approver_key_missing" + + +# -- Run it: Flow asks Desktop to sign over a pipe --------------------------------- + + +class FakeFlow: + """``item-approval ... --approver-signer stdio`` as Flow runs it.""" + + def __init__(self, cmd, *, payload: dict, registry: FakeRegistry, **kwargs) -> None: + self.cmd = cmd + self.env = kwargs.get("env") or {} + self.public = cmd[cmd.index("--approver-public-key") + 1] + self.message = ( + ANSWER_DOMAIN + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ) + self.registry = registry + self.response: dict | None = None + flow = self + + class Out: + def readline(self): + return ( + json.dumps( + { + "schema": "openadapt-flow.approver-sign-request/v1", + "public_key": flow.public, + "message_hex": flow.message.hex(), + } + ) + + "\n" + ) + + def read(self): + return json.dumps(flow.result()) + "\n" + + class In: + def write(self, text): + flow.response = json.loads(text) + + def flush(self): + pass + + def close(self): + pass + + self.stdout, self.stdin = Out(), In() + + def result(self) -> dict: + response = self.response or {} + if "signature" not in response: + return { + "ok": False, + "code": response.get("refused", "approver_signature_unavailable"), + "message": response.get("message", ""), + } + _verify(self.public, self.message, response["signature"]) + self.returncode = 0 + return { + "ok": True, + "request_id": REQUEST_ID, + "decision": "approve", + "answer_sha256": "sha256:" + "c" * 64, + "resume_required": False, + } + + def wait(self, timeout=None): + return 0 if (self.response or {}).get("signature") else 2 + + def kill(self): + pass + + +def _payload(**overrides) -> dict: + return { + "request_id": REQUEST_ID, + "request_sha256": REQUEST_SHA, + "decision": "approve", + "decided_by": "human", + "route": "desktop_run_it", + "approver_kind": "unauthenticated", + "auth_method": "none", + "answered_at": "2026-10-24T12:00:00Z", + **overrides, + } + + +def _bridge(monkeypatch, keychain, registry, payload, launched): + monkeypatch.setattr( + "openadapt_desktop.flow_bridge._flow_command", lambda _bin: ["openadapt-flow"] + ) + + def popen(cmd, **kwargs): + flow = FakeFlow(cmd, payload=payload, registry=registry, **kwargs) + launched.append(flow) + return flow + + def no_run(*_args, **_kwargs): + raise AssertionError("the in-app key never takes the terminal path") + + return FlowBridge(runner=no_run, popen=popen, approver_keychain=keychain) + + +def test_run_it_signs_in_app_after_touch_id(monkeypatch, tmp_path, keychain, enclave, registry): + stored = keychain.setup() + enclave.prompts.clear() + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer["ok"] is True + (flow,) = launched + assert flow.cmd[flow.cmd.index("--approver-signer") + 1] == "stdio" + assert flow.public == stored.public_key + assert "--approver-key" not in flow.cmd + assert "OPENADAPT_JIT_KEY_PASSPHRASE" not in flow.env + assert enclave.prompts == ["approve this save"] + + +@pytest.mark.parametrize( + "payload", + [ + _payload(request_id="0" * 31 + "2"), + _payload(decision="decline"), + _payload(decided_by="automation"), + _payload(route="mcp"), + ], +) +def test_desktop_signs_only_the_answer_the_person_chose( + monkeypatch, tmp_path, keychain, enclave, registry, payload +): + keychain.setup() + enclave.prompts.clear() + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, payload, launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer == { + "ok": False, + "code": "approver_payload_refused", + "message": ( + "Flow asked Desktop to sign something other than your answer, so nothing was signed." + ), + } + assert enclave.prompts == [] # never asked the person to approve it + + +def test_a_cancelled_prompt_sends_flow_a_refusal( + monkeypatch, tmp_path, keychain, enclave, registry +): + keychain.setup() + enclave.answer = "cancel" + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer["ok"] is False + assert answer["code"] == "approver_presence_refused" + assert launched[0].response["refused"] == "approver_presence_refused" + + +def test_no_key_on_a_mac_that_can_set_one_up_sends_nothing( + monkeypatch, tmp_path, keychain, registry +): + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer["ok"] is False + assert answer["code"] == "approver_key_missing" + assert answer["setup"] == "in_app" + assert launched == [] + + +# -- the window's commands --------------------------------------------------------- + + +def _dispatcher(tmp_path: Path, keychain): + from openadapt_desktop.config import EngineConfig + from openadapt_desktop.dispatch import EngineDispatcher, EngineServices + + class Audit: + def __init__(self): + self.events: list = [] + + def log(self, event, **fields): + self.events.append((event, fields)) + + audit = Audit() + config = EngineConfig(data_dir=tmp_path / "data") + services = EngineServices( + config, flow_bridge=FlowBridge(approver_keychain=keychain), audit=audit + ) + return EngineDispatcher(config, services=services), audit + + +def test_the_window_sets_up_rotates_and_removes_the_key(tmp_path, keychain, registry): + dispatcher, audit = _dispatcher(tmp_path, keychain) + + assert dispatcher.dispatch("get_approver_key", {})["set_up"] is False + first = dispatcher.dispatch("set_up_approver_key", {}) + assert first["ok"] is True and first["set_up"] is True + rotated = dispatcher.dispatch("set_up_approver_key", {"replace": True}) + assert rotated["fingerprint"] != first["fingerprint"] + removed = dispatcher.dispatch("remove_approver_key", {}) + assert removed["removed"] is True and removed["set_up"] is False + assert registry.approvers == {} + assert [event for event, _ in audit.events] == [ + "approver_key_setup", + "approver_key_setup", + "approver_key_removed", + ] + # The audit names a fingerprint, never the key. + for _event, fields in audit.events: + assert all(len(str(value)) <= 16 for value in fields.values()) + + +def test_a_cancelled_setup_reaches_the_window(tmp_path, keychain, enclave): + enclave.answer = "cancel" + dispatcher, _audit = _dispatcher(tmp_path, keychain) + + result = dispatcher.dispatch("set_up_approver_key", {}) + + assert result["ok"] is False + assert result["code"] == "approver_presence_refused" + + +def test_another_computer_gets_the_terminal_steps(tmp_path, registry): + keychain = ApproverKeychain(FakeEnclave(available=False), register=registry.register) + dispatcher, _audit = _dispatcher(tmp_path, keychain) + + status = dispatcher.dispatch("get_approver_key", {}) + + assert status["supported"] is False + assert "openadapt-flow jit register --role approver" in status["instructions"] + + +def test_the_key_commands_answer_while_a_run_waits_and_stay_off_the_agent_socket(): + from openadapt_desktop.ipc import CONCURRENT_COMMANDS + from openadapt_desktop.socket_server import _SOCKET_COMMANDS + + keys = {"get_approver_key", "set_up_approver_key", "remove_approver_key"} + assert keys <= CONCURRENT_COMMANDS + assert not keys & _SOCKET_COMMANDS + + +def test_a_mac_that_can_use_touch_id_never_falls_back_to_a_file_key( + monkeypatch, tmp_path, keychain, registry +): + """A terminal-made key asks for no presence; on such a Mac it doesn't count.""" + + from openadapt_desktop import approver_key + + monkeypatch.setattr(approver_key, "KEY_DIR", tmp_path / "approver") + assert approver_key.setup_approver_key().created is True + assert approver_key.approver_key_environment() is not None + launched: list = [] + bridge = _bridge(monkeypatch, keychain, registry, _payload(), launched) + + answer = bridge.item_approval_answer( + tmp_path, decision="approve", request_id=REQUEST_ID, request_sha256=REQUEST_SHA + ) + + assert answer["ok"] is False + assert answer["code"] == "approver_key_missing" + assert answer["setup"] == "in_app" + assert "in OpenAdapt" in answer["message"] + assert launched == [] diff --git a/packages/desktop/uv.lock b/packages/desktop/uv.lock index 4fc00ed48..86cb30a56 100644 --- a/packages/desktop/uv.lock +++ b/packages/desktop/uv.lock @@ -1172,7 +1172,7 @@ wheels = [ [[package]] name = "openadapt-desktop" -version = "0.23.0" +version = "0.24.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, diff --git a/packages/flow/docs/APPROVAL_MODES.md b/packages/flow/docs/APPROVAL_MODES.md index a4e225295..c298db2f9 100644 --- a/packages/flow/docs/APPROVAL_MODES.md +++ b/packages/flow/docs/APPROVAL_MODES.md @@ -102,10 +102,20 @@ key registered under `approvers` in the key directory's `principals.json` signature on every answer, including an answer file written around the command. Each route signs with its own key: -- Desktop's Run it signs with the Desktop approver key. Desktop creates it - with `openadapt-desktop approver-key setup`, which keeps the encrypted key - in an owner-only file and its passphrase in the OS keychain, and prints the - public key. +- Desktop's Run it signs with the Desktop approver key. + - On a Mac, a person sets it up in the app, from the approval screen or + Settings, with one Touch ID or login-password prompt. The key is sealed + to a Secure Enclave key that asks for Touch ID or the password on every + use, and Desktop registers its public key under `approvers` itself. Flow + never sees the key: Desktop runs `item-approval approve|decline` with + `--approver-signer stdio --approver-public-key HEX`, Flow writes the + exact bytes to sign on stdout, and Desktop checks that they are the + person's answer, asks for Touch ID, and writes the signature back on + stdin. A cancelled prompt or any other failure is a refusal, and Flow + records nothing. + - On Windows and Linux, `openadapt-desktop approver-key setup` creates it, + keeps the encrypted key in an owner-only file and its passphrase in the OS + keychain, and prints the public key for a person to register. - The decision portal signs with the key at `OPENADAPT_PORTAL_APPROVER_KEY` after the person answered on their phone. Without it, the portal records no answer. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 59232ba6c..66c3c0705 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -4571,6 +4571,7 @@ def _cmd_item_approval(args: argparse.Namespace) -> int: """ import json + from openadapt_flow.approval_mode.human_attestation import AttestationRefused from openadapt_flow.approval_mode.item_approval import ( ItemApprovalRefused, ItemApprovalStore, @@ -4650,23 +4651,38 @@ def refuse(code: str, message: str) -> int: if delegate is None: from openadapt_flow.approval_mode.human_attestation import ( ROUTE_METHOD, - AttestationRefused, load_signer, + stdio_signer, ) method = ROUTE_METHOD.get(args.route) if method is None: return refuse("route_not_allowed", "a person answers from desktop_run_it") - if not getattr(args, "approver_key", None): + public_key = getattr(args, "approver_public_key", None) + external = getattr(args, "approver_signer", None) + if bool(public_key) != bool(external): + return refuse( + "approver_invalid", + "--approver-signer stdio and --approver-public-key go together", + ) + if public_key and getattr(args, "approver_key", None): + return refuse( + "approver_invalid", + "pass --approver-key or --approver-signer stdio, not both", + ) + if not public_key and not getattr(args, "approver_key", None): return refuse( "human_answer_unsigned", "a person's answer is signed with the Desktop approver key: pass " "--approver-key. --decided-by human on its own proves nothing", ) try: - signer = load_signer( - args.approver_key, store.key_directory(), method=method - ) + if public_key: + signer = stdio_signer(public_key, store.key_directory(), method=method) + else: + signer = load_signer( + args.approver_key, store.key_directory(), method=method + ) except AttestationRefused as exc: return refuse(exc.code, str(exc)) try: @@ -4698,7 +4714,7 @@ def refuse(code: str, message: str) -> int: signer=signer, ) store.write_answer(answer) - except ItemApprovalRefused as exc: + except (ItemApprovalRefused, AttestationRefused) as exc: return refuse(exc.code, str(exc)) resume_required = _answer_durable_item_pause(run_dir, request, answer) print( @@ -9428,6 +9444,21 @@ def build_parser() -> argparse.ArgumentParser: help="Desktop was signed out: record the answer without a name", ) _add_approver_key_flags(q) + q.add_argument( + "--approver-signer", + choices=["stdio"], + default=None, + help=( + "The process that started this one holds the approver key and " + "signs over stdin/stdout (Desktop's Keychain key). Flow writes " + "one sign request line and reads one response line" + ), + ) + q.add_argument( + "--approver-public-key", + default=None, + help="With --approver-signer: the registered approver key's public key (hex)", + ) if verb == "decline": q.add_argument( "--reason", diff --git a/packages/flow/openadapt_flow/approval_mode/human_attestation.py b/packages/flow/openadapt_flow/approval_mode/human_attestation.py index d9b017e51..871318057 100644 --- a/packages/flow/openadapt_flow/approval_mode/human_attestation.py +++ b/packages/flow/openadapt_flow/approval_mode/human_attestation.py @@ -9,6 +9,10 @@ (:class:`~openadapt_flow.jit.authority.PrincipalRegistry`). * Desktop's Run it signs with the Desktop approver key (``desktop_key``). + Desktop either hands Flow that key's encrypted file (``--approver-key``) or + keeps the key itself and signs over a pipe (``--approver-signer stdio``, + :func:`stdio_signer`): on macOS the key sits in the Keychain behind Touch + ID, and Flow never sees it. * The decision portal signs, after the person answered on their phone, with the portal's approver key (``portal_key``). * ``openadapt-flow approve`` signs a durable resume approval with the @@ -29,9 +33,12 @@ from __future__ import annotations +import json +import re +import sys from dataclasses import dataclass from pathlib import Path -from typing import Any, Literal, Mapping, Optional +from typing import IO, Any, Literal, Mapping, Optional, Protocol from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from pydantic import BaseModel, ConfigDict, Field @@ -47,6 +54,21 @@ ANSWER_DOMAIN = b"openadapt-flow.approval.human-answer/v1\x00" RESUME_DOMAIN = b"openadapt-flow.durable.resume-approval/v1\x00" _ID = r"^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$" +_HEX_KEY = re.compile(r"^[0-9a-f]{64}$") +#: The one line ``--approver-signer stdio`` writes to stdout, and the one line +#: it then reads from stdin (:class:`StdioSigning`). +SIGN_REQUEST_SCHEMA = "openadapt-flow.approver-sign-request/v1" +SIGN_RESPONSE_SCHEMA = "openadapt-flow.approver-sign-response/v1" +#: Why the process holding the key didn't sign. Anything else reads as +#: ``approver_signature_unavailable``. +SIGN_REFUSALS = frozenset( + { + "approver_presence_refused", + "approver_key_unusable", + "approver_key_missing", + "approver_payload_refused", + } +) #: ``desktop_key``: Desktop's Run it. ``portal_key``: the decision portal. #: ``person_key``: a person at a terminal (``openadapt-flow approve``). @@ -69,21 +91,32 @@ class HumanAttestation(BaseModel): signature: str = Field(min_length=1, max_length=256) +class _Signs(Protocol): + def sign(self, data: bytes, /) -> bytes: ... + + @dataclass(frozen=True) class ApproverSigner: - """An approver key ready to sign, and who the registry says it is.""" + """An approver key ready to sign, and who the registry says it is. + + ``signing`` is the key itself, or :class:`StdioSigning` when the process + that started this one holds it. + """ principal_id: str method: AttestationMethod - signing: Ed25519PrivateKey + signing: "Ed25519PrivateKey | _Signs" def attest(self, domain: bytes, payload: Any) -> HumanAttestation: - from openadapt_flow.jit.authority import _sign + from openadapt_flow.jit.authority import _canonical + # The same bytes and format as ``authority._sign``, for a key held + # here or by the process that started this one. + raw = self.signing.sign(domain + _canonical(payload)) return HumanAttestation( signer=self.principal_id, method=self.method, - signature=_sign(self.signing, domain, payload), + signature="ed25519:" + raw.hex(), ) @@ -128,6 +161,116 @@ def load_signer( return ApproverSigner(principal_id=found[1], method=method, signing=key) +class StdioSigning: + """Ask the process that started this one to sign one message. + + Writes one :data:`SIGN_REQUEST_SCHEMA` line to ``stdout`` with the exact + bytes to sign (the domain, then the canonical answer), and reads one + :data:`SIGN_RESPONSE_SCHEMA` line from ``stdin``: a signature, or a + refusal (a cancelled Touch ID prompt, a Keychain error). The signature is + checked against the registered public key before it is used. Anything + else is :class:`AttestationRefused`, so nothing unsigned is written. + """ + + def __init__( + self, + public_key_hex: str, + *, + stdin: Optional[IO[str]] = None, + stdout: Optional[IO[str]] = None, + ) -> None: + self._public = public_key_hex + self._stdin = stdin + self._stdout = stdout + + def sign(self, data: bytes, /) -> bytes: + from openadapt_flow.jit.authority import _public + + out = self._stdout or sys.stdout + out.write( + json.dumps( + { + "schema": SIGN_REQUEST_SCHEMA, + "public_key": self._public, + "message_hex": data.hex(), + }, + sort_keys=True, + ) + + "\n" + ) + out.flush() + line = (self._stdin or sys.stdin).readline(64 * 1024) + try: + response = json.loads(line) + except ValueError: + response = None + if not isinstance(response, dict) or response.get("schema") != ( + SIGN_RESPONSE_SCHEMA + ): + raise AttestationRefused( + "approver_signature_unavailable", + "the approver key's holder sent no signature, so nothing was recorded", + ) + signature = response.get("signature") + if not isinstance(signature, str): + code = str(response.get("refused") or "") + if code not in SIGN_REFUSALS: + code = "approver_signature_unavailable" + message = str(response.get("message") or "")[:500] + raise AttestationRefused( + code, message or "the approver key's holder didn't sign" + ) + try: + raw = bytes.fromhex(signature.removeprefix("ed25519:")) + _public(self._public).verify(raw, data) + except Exception as exc: + raise AttestationRefused( + "human_answer_signature_invalid", + "the signature the approver key's holder sent doesn't match " + "the registered key", + ) from exc + return raw + + +def stdio_signer( + public_key_hex: str, + key_dir: Path, + *, + method: AttestationMethod, + stdin: Optional[IO[str]] = None, + stdout: Optional[IO[str]] = None, +) -> ApproverSigner: + """A signer for a registered approver key another process holds. + + The principal is the name ``public_key_hex`` is registered under, as in + :func:`load_signer`. Raises :class:`AttestationRefused`. + """ + + from openadapt_flow.jit.authority import PrincipalRegistry + + public = str(public_key_hex or "").lower() + if not _HEX_KEY.fullmatch(public): + raise AttestationRefused( + "approver_key_unusable", "--approver-public-key is 64 hex characters" + ) + try: + registry = PrincipalRegistry.load(key_dir) + except (OSError, ValueError) as exc: + raise AttestationRefused("approver_key_unusable", str(exc)) from exc + found = registry.lookup(public) + if found is None or found[0] != "approver": + raise AttestationRefused( + "human_approver_unregistered", + "this key isn't registered as an approver; a person registers it " + "with jit register --role approver, or sets it up in Desktop", + ) + return ApproverSigner( + principal_id=found[1], + method=method, + signing=StdioSigning(public, stdin=stdin, stdout=stdout), + ) + + def refusal( attestation: Optional[HumanAttestation], *, diff --git a/packages/flow/openadapt_flow/jit/authority.py b/packages/flow/openadapt_flow/jit/authority.py index bbcdc4f29..5d294d874 100644 --- a/packages/flow/openadapt_flow/jit/authority.py +++ b/packages/flow/openadapt_flow/jit/authority.py @@ -296,18 +296,21 @@ def lookup(self, public_key_hex: str) -> Optional[tuple[Role, str]]: return None +_ROLE_FIELDS: dict[str, str] = { + "policy_signer": "policy_signers", + "admitter_key": "admitter_keys", + "agent": "agents", + "approver": "approvers", +} + + def register_principal( key_dir: Path, *, role: Role, principal_id: str, public_key_hex: str ) -> PrincipalRegistry: """Add a key to the registry. A person does this (``jit register``).""" registry = PrincipalRegistry.load(key_dir) - field_name = { - "policy_signer": "policy_signers", - "admitter_key": "admitter_keys", - "agent": "agents", - "approver": "approvers", - }.get(role) + field_name = _ROLE_FIELDS.get(role) if field_name is None: raise AuthorityError(f"unknown role {role!r}") data = registry.model_dump(by_alias=True) @@ -320,6 +323,30 @@ def register_principal( return updated +def remove_principal( + key_dir: Path, *, role: Role, principal_id: str, public_key_hex: str +) -> bool: + """Remove one registered key, only if it is still the one registered. + + Returns whether anything was removed. A principal that has since been + registered with a different key is left alone, so retiring an old key + can't take away its replacement. + """ + + field_name = _ROLE_FIELDS.get(role) + if field_name is None: + raise AuthorityError(f"unknown role {role!r}") + registry = PrincipalRegistry.load(key_dir) + entries = dict(getattr(registry, field_name)) + if entries.get(principal_id) != public_key_hex.lower(): + return False + del entries[principal_id] + data = registry.model_dump(by_alias=True) + data[field_name] = entries + PrincipalRegistry.model_validate(data).save(key_dir) + return True + + # -- standard repairs ------------------------------------------------------------ #: How many new menu steps a standard repair may add before a moved control. diff --git a/packages/flow/tests/test_human_approval_stdio_signer.py b/packages/flow/tests/test_human_approval_stdio_signer.py new file mode 100644 index 000000000..65d93365f --- /dev/null +++ b/packages/flow/tests/test_human_approval_stdio_signer.py @@ -0,0 +1,274 @@ +"""Desktop signs a person's answer over a pipe; Flow never sees the key. + +``item-approval approve|decline --approver-signer stdio --approver-public-key +HEX`` writes one sign request line and reads one response line. The process +that started Flow (Desktop, with the key in the macOS Keychain behind Touch +ID) checks what it signs and answers with a signature or a refusal. These +tests play that process. +""" + +from __future__ import annotations + +import io +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from openadapt_flow.approval_mode.human_attestation import ( + ANSWER_DOMAIN, + SIGN_REQUEST_SCHEMA, + SIGN_RESPONSE_SCHEMA, + AttestationRefused, + StdioSigning, + stdio_signer, +) +from openadapt_flow.approval_mode.item_approval import ItemApprovalStore +from openadapt_flow.approval_mode.model import ApprovalMode +from openadapt_flow.jit.authority import ( + public_hex, + register_principal, + remove_principal, +) +from openadapt_flow.jit.lifecycle import KEY_DIR_ENV + +FRAME = b"\x89PNG\r\n\x1a\nmade-up filled form" + + +def _request(store: ItemApprovalStore): + return store.open_request( + run_id="run-0001", + workflow_name="referral", + step_id="save", + step_index=3, + step_intent="Click 'Save'", + mode=ApprovalMode.EACH_ITEM, + bundle_content_digest="a" * 64, + runtime_inputs_digest="b" * 64, + frame_png=FRAME, + form_state=("dom", "sha256:" + "1" * 64), + ) + + +@pytest.fixture +def held_key(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """An approver key registered in the key directory but held by 'Desktop'.""" + + key_dir = tmp_path / "keys" + monkeypatch.setenv(KEY_DIR_ENV, str(key_dir)) + key = Ed25519PrivateKey.generate() + register_principal( + key_dir, + role="approver", + principal_id="front-desk", + public_key_hex=public_hex(key), + ) + return key_dir, key + + +def _response(**fields) -> str: + return json.dumps({"schema": SIGN_RESPONSE_SCHEMA, **fields}) + "\n" + + +def _args(store, request, public: str, verb: str = "approve") -> list[str]: + args = [ + "item-approval", + verb, + str(store.run_dir), + "--request-id", + request.request_id, + "--request-sha256", + request.digest(), + "--decided-by", + "human", + "--route", + "desktop_run_it", + "--unauthenticated", + "--approver-signer", + "stdio", + "--approver-public-key", + public, + ] + if verb == "decline": + args += ["--reason", "wrong_values"] + return args + + +def _run_cli(args: list[str], answer) -> tuple[int, list[dict]]: + """Run Flow as Desktop does: read its request, write ``answer(request)``.""" + + env = {**os.environ} + proc = subprocess.Popen( + [sys.executable, "-m", "openadapt_flow", *args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + assert proc.stdout is not None and proc.stdin is not None + lines: list[dict] = [] + first = json.loads(proc.stdout.readline()) + lines.append(first) + if first.get("schema") == SIGN_REQUEST_SCHEMA: + proc.stdin.write(answer(first)) + proc.stdin.flush() + rest = proc.stdout.read() + lines += [json.loads(line) for line in rest.splitlines() if line.strip()] + proc.stdin.close() + proc.wait(timeout=60) + return proc.returncode, lines + + +def test_desktop_signs_over_the_pipe_and_the_answer_counts(tmp_path, held_key): + key_dir, key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + seen: list[bytes] = [] + + def sign(sign_request: dict) -> str: + message = bytes.fromhex(sign_request["message_hex"]) + seen.append(message) + assert sign_request["public_key"] == public_hex(key) + return _response(signature="ed25519:" + key.sign(message).hex()) + + code, lines = _run_cli(_args(store, request, public_hex(key)), sign) + + assert code == 0, lines + assert lines[-1]["ok"] is True and lines[-1]["decision"] == "approve" + # What Desktop signed is the domain, then the answer it can read and check. + assert seen[0].startswith(ANSWER_DOMAIN) + payload = json.loads(seen[0][len(ANSWER_DOMAIN) :]) + assert payload["request_id"] == request.request_id + assert payload["decision"] == "approve" + answer = store.read_answer(request.request_id) + assert answer is not None and answer.attestation is not None + assert answer.attestation.signer == "front-desk" + assert answer.attestation.method == "desktop_key" + + +def test_a_cancelled_touch_id_prompt_records_nothing(tmp_path, held_key): + key_dir, key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + + code, lines = _run_cli( + _args(store, request, public_hex(key)), + lambda _req: _response( + refused="approver_presence_refused", message="Touch ID was cancelled." + ), + ) + + assert code == 2 + assert lines[-1] == { + "ok": False, + "code": "approver_presence_refused", + "message": "Touch ID was cancelled.", + } + assert store.read_answer(request.request_id) is None + + +def test_a_signature_by_another_key_is_refused(tmp_path, held_key): + key_dir, key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + other = Ed25519PrivateKey.generate() + + code, lines = _run_cli( + _args(store, request, public_hex(key)), + lambda req: _response( + signature="ed25519:" + other.sign(bytes.fromhex(req["message_hex"])).hex() + ), + ) + + assert code == 2 and lines[-1]["code"] == "human_answer_signature_invalid" + assert store.read_answer(request.request_id) is None + + +def test_an_unregistered_public_key_never_asks_for_a_signature(tmp_path, held_key): + key_dir, _key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + stranger = Ed25519PrivateKey.generate() + + code, lines = _run_cli( + _args(store, request, public_hex(stranger)), + lambda _req: pytest.fail("Flow asked an unregistered key to sign"), + ) + + assert code == 2 and lines[-1]["code"] == "human_approver_unregistered" + + +def test_a_removed_key_no_longer_signs(tmp_path, held_key): + key_dir, key = held_key + assert remove_principal( + key_dir, + role="approver", + principal_id="front-desk", + public_key_hex=public_hex(key), + ) + with pytest.raises(AttestationRefused) as raised: + stdio_signer(public_hex(key), key_dir, method="desktop_key") + assert raised.value.code == "human_approver_unregistered" + + +def test_removing_an_old_key_leaves_its_replacement(tmp_path, held_key): + key_dir, old = held_key + new = Ed25519PrivateKey.generate() + register_principal( + key_dir, + role="approver", + principal_id="front-desk", + public_key_hex=public_hex(new), + ) + + assert not remove_principal( + key_dir, + role="approver", + principal_id="front-desk", + public_key_hex=public_hex(old), + ) + signer = stdio_signer(public_hex(new), key_dir, method="desktop_key") + assert signer.principal_id == "front-desk" + + +def test_signer_flags_go_together(tmp_path, held_key, capsys): + from openadapt_flow.__main__ import main + + key_dir, key = held_key + store = ItemApprovalStore(tmp_path / "run", key_dir=key_dir) + request = _request(store) + args = _args(store, request, public_hex(key)) + without_signer = [a for a in args if a not in {"--approver-signer", "stdio"}] + + assert main(without_signer) == 2 + assert json.loads(capsys.readouterr().out)["code"] == "approver_invalid" + + +@pytest.mark.parametrize( + "line, code", + [ + ("", "approver_signature_unavailable"), + ("not json\n", "approver_signature_unavailable"), + (json.dumps({"schema": "other"}) + "\n", "approver_signature_unavailable"), + (_response(refused="made_up_code"), "approver_signature_unavailable"), + (_response(refused="approver_key_unusable"), "approver_key_unusable"), + (_response(signature="ed25519:zz"), "human_answer_signature_invalid"), + ], +) +def test_anything_but_a_valid_signature_is_a_refusal(line, code): + key = Ed25519PrivateKey.generate() + out = io.StringIO() + signing = StdioSigning(public_hex(key), stdin=io.StringIO(line), stdout=out) + + with pytest.raises(AttestationRefused) as raised: + signing.sign(b"message") + + assert raised.value.code == code + request = json.loads(out.getvalue()) + assert request["schema"] == SIGN_REQUEST_SCHEMA + assert request["message_hex"] == b"message".hex()