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