From 8021adf8957c13e78427e22b82d93e2edeaf9c85 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 24 Oct 2026 12:33:46 -0400 Subject: [PATCH 1/5] feat(flow): sign a person's answer over a pipe (--approver-signer stdio) `item-approval approve|decline --approver-signer stdio --approver-public-key HEX` lets the process that holds a registered approver key sign the answer without handing the key to Flow. Flow looks the public key up under `approvers`, writes the exact bytes to sign as one JSON line on stdout and reads one line back: a signature, checked against the registered key before use, or a refusal. Anything else is a refusal, and nothing is recorded. Desktop uses this to sign with a key kept behind Touch ID. Also adds `authority.remove_principal`, which removes a registered key only while it is still the one registered, for key rotation and removal. Co-Authored-By: Claude Opus 5.5 --- packages/flow/docs/APPROVAL_MODES.md | 18 +- packages/flow/openadapt_flow/__main__.py | 43 ++- .../approval_mode/human_attestation.py | 153 +++++++++- packages/flow/openadapt_flow/jit/authority.py | 39 ++- .../tests/test_human_approval_stdio_signer.py | 274 ++++++++++++++++++ 5 files changed, 506 insertions(+), 21 deletions(-) create mode 100644 packages/flow/tests/test_human_approval_stdio_signer.py 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 5cee4c658..d78105302 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -4552,6 +4552,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, @@ -4631,23 +4632,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: @@ -4679,7 +4695,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( @@ -9400,6 +9416,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() From 01bc5995d6a200524920c900868c73e924f53846 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 24 Oct 2026 12:33:46 -0400 Subject: [PATCH 2/5] feat(desktop): set up the approver key in the app, behind Touch ID On a Mac with a Secure Enclave, Run it no longer needs terminal setup. The approval screen shows "Set up your approver key" until the key exists: one click and one Touch ID (or login password) prompt. - The Ed25519 approver key is sealed to a Secure Enclave key whose access control asks for user presence on every use, and kept in the login Keychain. It is never written to disk unsealed, never logged and never given to Flow. - Setup registers the public key under `approvers` with Flow's own `register_principal`, the function `jit register` calls. - Each answer runs Flow with `--approver-signer stdio`. Desktop checks that the bytes Flow asks it to sign are the answer the person chose, prompts, signs and drops the key. - No key, a cancelled prompt or a Keychain error means no answer. - Settings > Approver key replaces the key (new one registered before the old one is retired) or removes it. - Windows, Linux and Macs without a Secure Enclave keep the terminal setup. The Keychain's own access-control flags need a keychain-access-groups entitlement the Python engine doesn't have (errSecMissingEntitlement), so the key is sealed to the Secure Enclave instead, through Security.framework over ctypes. Real-hardware tests are opt-in (OPENADAPT_REAL_KEYCHAIN_TESTS=1) and never show a prompt. Co-Authored-By: Claude Opus 5.5 --- packages/desktop/docs/OPERATOR_SCREENS.md | 11 +- .../openadapt_desktop/approver_keychain.py | 753 ++++++++++++++++++ .../desktop/openadapt_desktop/dispatch.py | 98 ++- .../desktop/openadapt_desktop/flow_bridge.py | 214 ++++- packages/desktop/openadapt_desktop/ipc.py | 17 +- packages/desktop/src/lib/engine.ts | 5 + packages/desktop/src/lib/types.ts | 19 + packages/desktop/src/screens/Settings.tsx | 3 + packages/desktop/src/styles/app.css | 10 + packages/desktop/src/ui/ApproverKey.tsx | 162 ++++ packages/desktop/src/ui/ItemApproval.test.tsx | 47 ++ packages/desktop/src/ui/ItemApproval.tsx | 21 +- packages/desktop/tests/conftest.py | 16 + .../tests/test_approver_keychain_real.py | 125 +++ .../test_engine/test_approver_keychain.py | 560 +++++++++++++ 15 files changed, 2046 insertions(+), 15 deletions(-) create mode 100644 packages/desktop/openadapt_desktop/approver_keychain.py create mode 100644 packages/desktop/src/ui/ApproverKey.tsx create mode 100644 packages/desktop/tests/test_approver_keychain_real.py create mode 100644 packages/desktop/tests/test_engine/test_approver_keychain.py 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/approver_keychain.py b/packages/desktop/openadapt_desktop/approver_keychain.py new file mode 100644 index 000000000..5452e77c2 --- /dev/null +++ b/packages/desktop/openadapt_desktop/approver_keychain.py @@ -0,0 +1,753 @@ +"""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 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" +_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. + 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, + b"openadapt-desktop.approver-setup/v1\x00" + 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, -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 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 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..c75849897 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,16 @@ 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. 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 +1183,32 @@ 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, + ) key = approver_key_environment() if key is None: + if in_app: + return { + "ok": False, + "code": "approver_key_missing", + "setup": "in_app", + "message": ( + "Set up your approver key to approve saves on this Mac. " + "Nothing was approved." + ), + } return { "ok": False, "code": "human_answer_unsigned", + "setup": "terminal", "message": ( "This computer has no approver key, so nothing was approved. " + setup_instructions() @@ -1137,8 +1217,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 +1231,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 +1239,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/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..c5af71e92 --- /dev/null +++ b/packages/desktop/tests/test_engine/test_approver_keychain.py @@ -0,0 +1,560 @@ +"""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(keychain, registry): + stored = keychain.setup() + registry.approvers.clear() + + assert keychain.status()["set_up"] is False + keychain.setup() + assert registry.approvers == {"desktop-approver": stored.public_key} + + +# -- 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 From 3e6e98bed3c96c2bb42e0d9c044f589da3fc4c1e Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 24 Oct 2026 12:34:36 -0400 Subject: [PATCH 3/5] docs: list the Touch ID approver key in CAPABILITIES Co-Authored-By: Claude Opus 5.5 --- CAPABILITIES.md | 1 + llms-full.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 6b511d68b..b9dc2af31 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/` | From 99e998100f710f035b3cb5d2e0d2a2d29ea5d465 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 24 Oct 2026 20:17:15 -0400 Subject: [PATCH 4/5] fix(desktop): a locked Mac isn't a Mac without a Secure Enclave errSecInteractionNotAllowed (-25308) while the screen is locked made the availability check remember False, so Desktop showed the terminal setup until restart. The check is no longer remembered then, and the refusal says to unlock the Mac instead of blaming Touch ID. Co-Authored-By: Claude Opus 5.5 --- .../openadapt_desktop/approver_keychain.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/desktop/openadapt_desktop/approver_keychain.py b/packages/desktop/openadapt_desktop/approver_keychain.py index 5452e77c2..a03fc4204 100644 --- a/packages/desktop/openadapt_desktop/approver_keychain.py +++ b/packages/desktop/openadapt_desktop/approver_keychain.py @@ -444,7 +444,9 @@ def open( # 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, -25308}) +_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: @@ -478,6 +480,12 @@ def available(self) -> bool: 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 @@ -664,6 +672,12 @@ def call(self, name: str, *args): 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", From 21fa218a70d51ad931f0eb821ef83dbab8b508b4 Mon Sep 17 00:00:00 2001 From: abrichr Date: Sat, 24 Oct 2026 21:26:00 -0400 Subject: [PATCH 5/5] fix(desktop): presence before re-registering; no file-key fallback on Touch ID Macs From the security review of #1500: - setup() re-registered a stored but unregistered key without a prompt, so anything that can write the Keychain item could plant its own public key. It now requires the key to open behind a presence prompt (a fresh signature over a setup challenge) first, and refuses an item that admits it has no presence check. - On a Mac that can keep the key behind Touch ID but has none set up, Run it fell back to a terminal-made file key, which asks for no presence. It now refuses and points to setup in OpenAdapt; the file key counts only where the app can't keep the key. Settings says so. - Desktop 0.24.0 with its changelog (after #1486's 0.23.0). Co-Authored-By: Claude Opus 5.5 --- packages/desktop/CHANGELOG.md | 14 +++++ .../desktop/openadapt_desktop/__init__.py | 2 +- .../desktop/openadapt_desktop/approver_key.py | 7 ++- .../openadapt_desktop/approver_keychain.py | 21 ++++++- .../desktop/openadapt_desktop/flow_bridge.py | 26 ++++---- packages/desktop/package-lock.json | 4 +- packages/desktop/package.json | 2 +- packages/desktop/pyproject.toml | 2 +- packages/desktop/src-tauri/Cargo.lock | 2 +- packages/desktop/src-tauri/Cargo.toml | 2 +- packages/desktop/src-tauri/tauri.conf.json | 2 +- packages/desktop/src/ui/ApproverKey.tsx | 4 +- .../test_engine/test_approver_keychain.py | 61 ++++++++++++++++++- packages/desktop/uv.lock | 2 +- 14 files changed, 125 insertions(+), 26 deletions(-) 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/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 index a03fc4204..3310b4e76 100644 --- a/packages/desktop/openadapt_desktop/approver_keychain.py +++ b/packages/desktop/openadapt_desktop/approver_keychain.py @@ -45,6 +45,7 @@ import base64 import json +import secrets import sys import threading from dataclasses import dataclass @@ -64,6 +65,8 @@ #: 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[ @@ -296,7 +299,21 @@ def setup(self, principal: str = DEFAULT_PRINCIPAL, *, replace: bool = False) -> 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. + # 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) @@ -325,7 +342,7 @@ def setup(self, principal: str = DEFAULT_PRINCIPAL, *, replace: bool = False) -> # The one prompt: proves the key opens, and that a person is here. self._sign_with( new, - b"openadapt-desktop.approver-setup/v1\x00" + public.encode(), + SETUP_CHALLENGE + public.encode(), reason="set up your approver key", ) if not _store._kr_set(_store._keyring(), self._account, new.to_json()): diff --git a/packages/desktop/openadapt_desktop/flow_bridge.py b/packages/desktop/openadapt_desktop/flow_bridge.py index c75849897..b4ef4c137 100644 --- a/packages/desktop/openadapt_desktop/flow_bridge.py +++ b/packages/desktop/openadapt_desktop/flow_bridge.py @@ -1140,7 +1140,9 @@ def item_approval_answer( 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. Without a key, nothing is sent: the answer is + 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. @@ -1193,18 +1195,20 @@ def item_approval_answer( 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: - if in_app: - return { - "ok": False, - "code": "approver_key_missing", - "setup": "in_app", - "message": ( - "Set up your approver key to approve saves on this Mac. " - "Nothing was approved." - ), - } return { "ok": False, "code": "human_answer_unsigned", 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/ui/ApproverKey.tsx b/packages/desktop/src/ui/ApproverKey.tsx index 64df2c9e4..10105f4ae 100644 --- a/packages/desktop/src/ui/ApproverKey.tsx +++ b/packages/desktop/src/ui/ApproverKey.tsx @@ -115,8 +115,8 @@ export function ApproverKeyCard() { !status.supported ? "Approvals from this computer need a key set up once at a terminal." : status.set_up - ? "Set up. Each approval asks for your Touch ID or login password." - : "Not set up. Approvals from this computer are refused until it is." + ? "Set up. Each approval asks for your Touch ID or login password. On this Mac, only this key counts; a key made at a terminal doesn't." + : "Not set up. Approvals from this computer are refused until it is. On this Mac, a key made at a terminal doesn't count." } /> {!status.supported && status.instructions && ( diff --git a/packages/desktop/tests/test_engine/test_approver_keychain.py b/packages/desktop/tests/test_engine/test_approver_keychain.py index c5af71e92..d165afe16 100644 --- a/packages/desktop/tests/test_engine/test_approver_keychain.py +++ b/packages/desktop/tests/test_engine/test_approver_keychain.py @@ -205,13 +205,48 @@ def test_a_mac_without_a_secure_enclave_is_sent_to_the_terminal(registry): assert keychain.status()["supported"] is False -def test_a_stored_but_unregistered_key_is_registered_again(keychain, registry): +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 ----------------------------------------------------------------------- @@ -558,3 +593,27 @@ def test_the_key_commands_answer_while_a_run_waits_and_stay_off_the_agent_socket 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" },