cookiesync-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cookiesync/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sync your browser cookies across machines."""
2
+
3
+ from __future__ import annotations
cookiesync/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from cookiesync.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
cookiesync/cli.py ADDED
@@ -0,0 +1,339 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import replace
5
+ from typing import TYPE_CHECKING
6
+
7
+ import anyio
8
+ import click
9
+ from loguru import logger
10
+
11
+ from cookiesync import helper, paths, state
12
+ from cookiesync.cookie import OutputFormat, StorageState, render
13
+ from cookiesync.cookie.browsers import REGISTRY
14
+ from cookiesync.daemon import rpc
15
+ from cookiesync.daemon.rpc import RpcError
16
+ from cookiesync.daemon.wire import cookie_from_wire
17
+ from cookiesync.helper import HelperState
18
+ from cookiesync.registry import RegistryError, reposync_registry, reposync_self
19
+ from cookiesync.state import BrowserEndpoint, BrowserId, SshTarget, parse_duration
20
+
21
+ if TYPE_CHECKING:
22
+ from cookiesync.daemon.wire import Response
23
+
24
+
25
+ @click.group()
26
+ @click.version_option(package_name="cookiesync")
27
+ def main() -> None:
28
+ """Sync your browser cookies across machines."""
29
+
30
+
31
+ async def daemon_call(method: str, params: dict | None = None) -> dict | list | None:
32
+ """Call ``method`` on the resident daemon, raising a clean :class:`click.ClickException` on failure."""
33
+ try:
34
+ response = await rpc.call(method, params or {})
35
+ except RpcError as exc:
36
+ raise click.ClickException(f"{exc}; is the daemon running? (cookiesync install)") from exc
37
+ return response_result(response)
38
+
39
+
40
+ def response_result(response: Response) -> dict | list | None:
41
+ if not response.ok:
42
+ raise click.ClickException(response.error or "daemon error")
43
+ return response.result
44
+
45
+
46
+ @main.group()
47
+ def browser() -> None:
48
+ """Track the browser profiles cookiesync syncs across hosts."""
49
+
50
+
51
+ @browser.command("add")
52
+ @click.argument("host")
53
+ @click.argument("browser_name")
54
+ @click.option("--profile", default="Default", help="Profile directory name.")
55
+ def browser_add(host: str, browser_name: str, profile: str) -> None:
56
+ """Track a browser profile on HOST for syncing."""
57
+ anyio.run(add_endpoint, SshTarget(host), browser_name, profile)
58
+
59
+
60
+ @browser.command("ls")
61
+ @click.option("--json", "as_json", is_flag=True, help="Emit the endpoints as JSON.")
62
+ def browser_ls(as_json: bool) -> None:
63
+ """List the tracked browser endpoints."""
64
+ anyio.run(list_endpoints, as_json)
65
+
66
+
67
+ @browser.command("rm")
68
+ @click.argument("host")
69
+ @click.argument("browser_name")
70
+ @click.option("--profile", default="Default", help="Profile directory name.")
71
+ def browser_rm(host: str, browser_name: str, profile: str) -> None:
72
+ """Stop tracking a browser profile on HOST."""
73
+ anyio.run(remove_endpoint, SshTarget(host), browser_name, profile)
74
+
75
+
76
+ async def add_endpoint(host: SshTarget, browser_name: str, profile: str) -> None:
77
+ if browser_name not in REGISTRY:
78
+ raise click.ClickException(f"unknown browser {browser_name!r}; choose from {', '.join(sorted(REGISTRY))}")
79
+ try:
80
+ self_target, hosts = await reposync_registry()
81
+ except RegistryError as exc:
82
+ raise click.ClickException(str(exc)) from exc
83
+ if host != self_target and host not in hosts:
84
+ raise click.ClickException(f"unknown host {host!r}; choose from {', '.join((self_target, *hosts))}")
85
+ endpoint = BrowserEndpoint(host, BrowserId(browser_name), profile)
86
+ await state.update(
87
+ lambda s: replace(
88
+ s,
89
+ self_target=self_target,
90
+ browsers=(*(e for e in s.browsers if e.id != endpoint.id), endpoint),
91
+ )
92
+ )
93
+ logger.debug("tracked {}", endpoint.id)
94
+ click.echo(f"Tracking {endpoint.id}")
95
+
96
+
97
+ async def list_endpoints(as_json: bool) -> None:
98
+ browsers = (await state.load()).browsers
99
+ if as_json:
100
+ click.echo(json.dumps([endpoint.to_json() for endpoint in browsers], indent=2))
101
+ return
102
+ click.echo("\n".join(endpoint.id for endpoint in browsers) if browsers else "No tracked browsers.")
103
+
104
+
105
+ async def remove_endpoint(host: SshTarget, browser_name: str, profile: str) -> None:
106
+ target = BrowserEndpoint(host, BrowserId(browser_name), profile).id
107
+ await state.update(lambda s: replace(s, browsers=tuple(e for e in s.browsers if e.id != target)))
108
+ logger.debug("untracked {}", target)
109
+ click.echo(f"Untracked {target}")
110
+
111
+
112
+ @main.command()
113
+ def watch() -> None:
114
+ """Run the resident sync daemon: watch local stores and serve the RPC socket."""
115
+ anyio.run(run_watch)
116
+
117
+
118
+ async def run_watch() -> None:
119
+ from cookiesync.daemon import Daemon
120
+
121
+ logger.debug("starting cookiesync daemon")
122
+ await (await Daemon.build()).watch()
123
+
124
+
125
+ @main.command()
126
+ @click.option("--tick-only", is_flag=True, help="Install only the periodic reconcile tick, not the watch daemon.")
127
+ def install(tick_only: bool) -> None:
128
+ """Fetch the signed key helper, then install the cookiesync LaunchAgents (watch daemon and reconcile tick)."""
129
+ anyio.run(run_install, tick_only)
130
+
131
+
132
+ async def run_install(tick_only: bool) -> None:
133
+ from cookiesync.service import LaunchctlLauncher, install
134
+
135
+ await ensure_helper()
136
+ await install(LaunchctlLauncher(), tick_only=tick_only)
137
+ click.echo("Installed cookiesync agents." if not tick_only else "Installed the cookiesync reconcile tick.")
138
+
139
+
140
+ async def ensure_helper() -> None:
141
+ match await helper.helper_state():
142
+ case HelperState.OK:
143
+ click.echo(f"Key helper present and Developer-ID-signed: {paths.helper_app_path()}")
144
+ case _:
145
+ click.echo(
146
+ "Installing the signed key helper via Homebrew (brew install yasyf/tap/cookiesync-keyhelper)…", err=True
147
+ )
148
+ try:
149
+ app = await helper.install_helper()
150
+ except helper.HelperInstallError as exc:
151
+ raise click.ClickException(str(exc)) from exc
152
+ click.echo(f"Installed and verified key helper: {app}")
153
+
154
+
155
+ @main.command()
156
+ def doctor() -> None:
157
+ """Check that the signed Secure-Enclave key helper is installed and Developer-ID-signed."""
158
+ anyio.run(run_doctor)
159
+
160
+
161
+ async def run_doctor() -> None:
162
+ match await helper.helper_state():
163
+ case HelperState.OK if await helper.supports_contract():
164
+ click.echo(f"key helper OK: {paths.helper_app_path()} (Developer ID signed, key-helper contract supported)")
165
+ case HelperState.OK:
166
+ raise click.ClickException(
167
+ f"key helper at {paths.helper_app_path()} is installed but does not support the required "
168
+ "key-helper contract (likely a stale cask); reinstall the key helper: cookiesync install"
169
+ )
170
+ case HelperState.UNSIGNED:
171
+ raise click.ClickException(
172
+ f"key helper at {paths.helper_app_path()} is not Developer-ID-signed; "
173
+ "reinstall the notarized .app via 'cookiesync install'"
174
+ )
175
+ case HelperState.MISSING:
176
+ raise click.ClickException(
177
+ f"key helper not installed at {paths.helper_app_path()}; run 'cookiesync install' to fetch it"
178
+ )
179
+
180
+
181
+ @main.command()
182
+ def uninstall() -> None:
183
+ """Remove the cookiesync LaunchAgents."""
184
+ anyio.run(run_uninstall)
185
+
186
+
187
+ async def run_uninstall() -> None:
188
+ from cookiesync.service import LaunchctlLauncher, uninstall
189
+
190
+ await uninstall(LaunchctlLauncher())
191
+ click.echo("Uninstalled cookiesync agents.")
192
+
193
+
194
+ @main.command()
195
+ def reconcile() -> None:
196
+ """Ask the daemon to run a full reconcile pass over every tracked browser group."""
197
+ anyio.run(run_reconcile)
198
+
199
+
200
+ async def run_reconcile() -> None:
201
+ result = await daemon_call("reconcile")
202
+ click.echo(json.dumps(result, indent=2))
203
+
204
+
205
+ @main.command("sync")
206
+ @click.option("--browser", "browser_name", required=True, help="The browser group to converge.")
207
+ def sync_cmd(browser_name: str) -> None:
208
+ """Ask the daemon to converge one browser group across this host and its peers."""
209
+ anyio.run(run_sync, browser_name)
210
+
211
+
212
+ async def run_sync(browser_name: str) -> None:
213
+ result = await daemon_call("sync", {"browser": browser_name})
214
+ click.echo(json.dumps(result, indent=2))
215
+
216
+
217
+ @main.command()
218
+ @click.option("--browser", "browser_name", default="chrome", show_default=True, help="The browser to authenticate.")
219
+ @click.option("--profile", default="Default", show_default=True, help="The profile to authenticate.")
220
+ @click.option("--ttl", default=None, help="Override the cache TTL (Go-style duration, e.g. 15m).")
221
+ def auth(browser_name: str, profile: str, ttl: str | None) -> None:
222
+ """Release the Safe Storage key behind one Touch ID tap and cache it for a short window."""
223
+ anyio.run(run_auth, browser_name, profile, ttl)
224
+
225
+
226
+ async def run_auth(browser_name: str, profile: str, ttl: str | None) -> None:
227
+ if ttl is not None:
228
+ await state.update(lambda s: replace(s, settings=replace(s.settings, auth_ttl=parse_duration(ttl))))
229
+ result = await daemon_call("prime_auth", {"browser": browser_name, "profile": profile})
230
+ click.echo(f"Authenticated {result['endpoint']}.")
231
+
232
+
233
+ @main.command()
234
+ @click.argument("url")
235
+ @click.option(
236
+ "--browser", "browser_name", default="chrome", show_default=True, help="The browser to read cookies from."
237
+ )
238
+ @click.option("--profile", default="Default", show_default=True, help="The profile to read cookies from.")
239
+ @click.option(
240
+ "--format",
241
+ "fmt",
242
+ type=click.Choice([f.value for f in OutputFormat]),
243
+ default=OutputFormat.PLAYWRIGHT.value,
244
+ show_default=True,
245
+ help="The output wire format.",
246
+ )
247
+ def cookies(url: str, browser_name: str, profile: str, fmt: str) -> None:
248
+ """Stream URL's cookies in the chosen format, decrypting with the daemon's cached key."""
249
+ anyio.run(run_cookies, url, browser_name, profile, fmt)
250
+
251
+
252
+ async def run_cookies(url: str, browser_name: str, profile: str, fmt: str) -> None:
253
+ result = await daemon_call("get_cookies", {"url": url, "browser": browser_name, "profile": profile})
254
+ state_obj = StorageState(tuple(cookie_from_wire(c) for c in result["cookies"]))
255
+ for line in render(state_obj, OutputFormat(fmt)):
256
+ click.echo(line)
257
+
258
+
259
+ @main.group()
260
+ def rpc_group() -> None:
261
+ """Low-level RPC client: drive the resident daemon over its unix socket."""
262
+
263
+
264
+ main.add_command(rpc_group, name="rpc")
265
+
266
+
267
+ @rpc_group.command("extract")
268
+ @click.option("--browser", "browser_name", required=True)
269
+ @click.option("--profile", default="Default")
270
+ @click.option("--origin", default=None)
271
+ def rpc_extract(browser_name: str, profile: str, origin: str | None) -> None:
272
+ """Return this host's decrypted cookies for a browser as wire records (used by peers over ssh)."""
273
+ anyio.run(run_rpc_passthrough, "extract", {"browser": browser_name, "profile": profile, "origin": origin})
274
+
275
+
276
+ @rpc_group.command("apply")
277
+ @click.option("--browser", "browser_name", required=True)
278
+ @click.option("--profile", default="Default")
279
+ @click.option("--origin", default=None)
280
+ def rpc_apply(browser_name: str, profile: str, origin: str | None) -> None:
281
+ """Ingest a merged wire cookie array from stdin into this host's store (used by peers over ssh)."""
282
+ cookies_in = json.loads(click.get_text_stream("stdin").read())
283
+ anyio.run(
284
+ run_rpc_passthrough,
285
+ "apply",
286
+ {"browser": browser_name, "profile": profile, "origin": origin, "cookies": cookies_in},
287
+ )
288
+
289
+
290
+ @rpc_group.command("sync")
291
+ @click.option("--browser", "browser_name", required=True)
292
+ @click.option("--origin", default=None)
293
+ def rpc_sync(browser_name: str, origin: str | None) -> None:
294
+ """Ask the daemon to converge one browser group, tagged with the notifying peer's origin."""
295
+ anyio.run(run_rpc_passthrough, "sync", {"browser": browser_name, "origin": origin})
296
+
297
+
298
+ @rpc_group.command("reconcile")
299
+ def rpc_reconcile() -> None:
300
+ """Ask the daemon to run a full reconcile pass."""
301
+ anyio.run(run_rpc_passthrough, "reconcile", {})
302
+
303
+
304
+ @rpc_group.command("whoami")
305
+ def rpc_whoami() -> None:
306
+ """Report this host's console session state."""
307
+ anyio.run(run_rpc_passthrough, "whoami", {})
308
+
309
+
310
+ @rpc_group.command("request_consent")
311
+ @click.option("--browser", "browser_name", required=True)
312
+ @click.option("--profile", default="Default")
313
+ @click.option("--nonce", required=True)
314
+ @click.option("--endpoint", required=True)
315
+ def rpc_request_consent(browser_name: str, profile: str, nonce: str, endpoint: str) -> None:
316
+ """Show the Touch ID prompt for BROWSER here and echo the requester's nonce + endpoint."""
317
+ anyio.run(
318
+ run_rpc_passthrough,
319
+ "request_consent",
320
+ {"browser": browser_name, "profile": profile, "nonce": nonce, "endpoint": endpoint},
321
+ )
322
+
323
+
324
+ async def run_rpc_passthrough(method: str, params: dict) -> None:
325
+ click.echo(json.dumps(await daemon_call(method, params)))
326
+
327
+
328
+ @main.command(name="self")
329
+ def self_cmd() -> None:
330
+ """Print this host's own SSH target, as reposync reports it."""
331
+ anyio.run(run_self)
332
+
333
+
334
+ async def run_self() -> None:
335
+ try:
336
+ target = await reposync_self()
337
+ except RegistryError as exc:
338
+ raise click.ClickException(str(exc)) from exc
339
+ click.echo(target)
@@ -0,0 +1,20 @@
1
+ """The cookiesync cookie engine: extract, merge, and apply browser cookies across machines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cookiesync.cookie.backend import CookieBackend, LocalBackend
6
+ from cookiesync.cookie.browsers import REGISTRY, Browser
7
+ from cookiesync.cookie.consent import Consent, ConsentError, TouchIDConsent
8
+ from cookiesync.cookie.crypto import DecryptError, decrypt_value, derive_key, encrypt_value
9
+ from cookiesync.cookie.merge import merge
10
+ from cookiesync.cookie.models import (
11
+ AesKey,
12
+ Cookie,
13
+ EncryptedRow,
14
+ Host,
15
+ HostKey,
16
+ SafeStorageKey,
17
+ StorageState,
18
+ )
19
+ from cookiesync.cookie.pipeline import apply, extract
20
+ from cookiesync.cookie.serialize import OutputFormat, render
@@ -0,0 +1,100 @@
1
+ """The cookie-store seam: where rows are read from, the key obtained, and rows written to.
2
+
3
+ ``CookieBackend`` is the boundary the pipeline talks to, so the same ``extract``/``apply``
4
+ flow runs against the local machine today and an ssh-backed remote tomorrow. ``LocalBackend``
5
+ wires the seam to this machine's stores: it auto-selects the profile with the most applicable
6
+ cookies (raising ``AmbiguousProfile`` when two profiles tie within 50%), filters rows to those
7
+ the browser would send to the host, obtains the Safe Storage key through the consent gate, and
8
+ upserts re-encrypted rows back into the live store.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from typing import TYPE_CHECKING, Protocol
15
+
16
+ from cookiesync.cookie.domains import cookie_applies
17
+ from cookiesync.cookie.stores import (
18
+ count_applicable,
19
+ list_profile_dirs,
20
+ profile_info,
21
+ read_rows,
22
+ write_rows,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Sequence
27
+
28
+ from cookiesync.cookie.browsers import Browser
29
+ from cookiesync.cookie.consent import Consent
30
+ from cookiesync.cookie.models import AesKey, Cookie, EncryptedRow, Host
31
+
32
+ AMBIGUITY_RATIO = 0.5
33
+
34
+
35
+ class AmbiguousProfile(Exception):
36
+ """Two or more browser profiles match the host within the ambiguity ratio; pass one explicitly."""
37
+
38
+
39
+ class NoMatchingProfile(Exception):
40
+ """No browser profile holds any cookie applicable to the host."""
41
+
42
+
43
+ class CookieBackend(Protocol):
44
+ """Reads encrypted rows, obtains the decryption key, and writes rows for a browser.
45
+
46
+ The pipeline holds only this seam, so the local store and a future ssh-backed remote
47
+ are interchangeable. ``read_rows`` returns rows already filtered to the host and picks
48
+ the profile when one is not given.
49
+ """
50
+
51
+ async def read_rows(
52
+ self, browser: Browser, host: Host, *, profile: str | None = None
53
+ ) -> tuple[EncryptedRow, ...]: ...
54
+
55
+ async def obtain_key(self, browser: Browser, *, reason: str) -> AesKey: ...
56
+
57
+ async def write_rows(self, browser: Browser, rows: Sequence[Cookie], key: AesKey) -> int: ...
58
+
59
+
60
+ async def select_profile(browser: Browser, host: Host) -> str:
61
+ scored = sorted(
62
+ (
63
+ (c, d)
64
+ for d in await list_profile_dirs(browser)
65
+ if (c := await count_applicable(browser, profile=d, host=host))
66
+ ),
67
+ reverse=True,
68
+ )
69
+ match scored:
70
+ case []:
71
+ raise NoMatchingProfile(f"no {browser.display} profile has cookies for {host}")
72
+ case [(top, _), (runner, _), *_] if runner >= AMBIGUITY_RATIO * top:
73
+ info = await profile_info(browser)
74
+ cands = "; ".join(f"{d} ({info.get(d, {}).get('email', '?')}: {c})" for c, d in scored)
75
+ raise AmbiguousProfile(
76
+ f"multiple {browser.display} profiles match {host} — pass an explicit profile. Candidates: {cands}"
77
+ )
78
+ case [(_, winner), *_]:
79
+ return winner
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class LocalBackend:
84
+ """The local machine's cookie stores behind the consent gate.
85
+
86
+ Example:
87
+ >>> await LocalBackend(TouchIDConsent()).read_rows(REGISTRY[BrowserName("chrome")], Host("x.com"))
88
+ """
89
+
90
+ consent: Consent
91
+
92
+ async def read_rows(self, browser: Browser, host: Host, *, profile: str | None = None) -> tuple[EncryptedRow, ...]:
93
+ chosen = profile or await select_profile(browser, host)
94
+ return tuple(row for row in await read_rows(browser, chosen) if cookie_applies(row.host_key, host))
95
+
96
+ async def obtain_key(self, browser: Browser, *, reason: str) -> AesKey:
97
+ return await self.consent.obtain_key(browser, reason=reason)
98
+
99
+ async def write_rows(self, browser: Browser, rows: Sequence[Cookie], key: AesKey) -> int:
100
+ return await write_rows(browser, "Default", rows, key)
@@ -0,0 +1,57 @@
1
+ """The browser registry: where each supported browser keeps its cookie store and Safe Storage key.
2
+
3
+ A ``Browser`` resolves a profile's on-disk paths (cookie DB, Local State) and names
4
+ the Keychain service holding its Safe Storage password.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import NewType
12
+
13
+ BrowserName = NewType("BrowserName", str)
14
+
15
+ APPLICATION_SUPPORT = Path.home() / "Library" / "Application Support"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Browser:
20
+ """A Chromium-family browser and its on-disk layout.
21
+
22
+ Example:
23
+ >>> REGISTRY["chrome"].cookies_db("Default")
24
+ """
25
+
26
+ name: BrowserName
27
+ display: str
28
+ data_root: Path
29
+ keychain_service: str
30
+
31
+ def profile_dir(self, profile: str) -> Path:
32
+ """The directory holding one profile's state under this browser's data root."""
33
+ return self.data_root / profile
34
+
35
+ def cookies_db(self, profile: str) -> Path:
36
+ """The SQLite cookie store for one profile."""
37
+ return self.profile_dir(profile) / "Cookies"
38
+
39
+ def local_state(self) -> Path:
40
+ """The ``Local State`` JSON file at this browser's data root."""
41
+ return self.data_root / "Local State"
42
+
43
+
44
+ REGISTRY: dict[BrowserName, Browser] = {
45
+ BrowserName("chrome"): Browser(
46
+ name=BrowserName("chrome"),
47
+ display="Chrome",
48
+ data_root=APPLICATION_SUPPORT / "Google" / "Chrome",
49
+ keychain_service="Chrome Safe Storage",
50
+ ),
51
+ BrowserName("arc"): Browser(
52
+ name=BrowserName("arc"),
53
+ display="Arc",
54
+ data_root=APPLICATION_SUPPORT / "Arc" / "User Data",
55
+ keychain_service="Arc Safe Storage",
56
+ ),
57
+ }
@@ -0,0 +1,128 @@
1
+ """Secure-Enclave-bound consent: obtain the Safe Storage AES key behind one Touch ID tap.
2
+
3
+ The legacy approach was consent theater — a cosmetic ``LAContext`` gate in front of a
4
+ sticky "Always Allow" ``/usr/bin/security`` read that goes silent after the first run.
5
+ ``TouchIDConsent`` instead stores the Safe Storage password in a data-protection
6
+ keychain item bound to biometry-or-passcode, so every retrieval forces a genuine
7
+ biometric (or device-passcode) evaluation through the keychain. On a host with neither
8
+ biometrics nor a passcode (headless, no Touch ID), it falls back to the device-unlock
9
+ ``security`` read.
10
+
11
+ The biometric vault and re-store run inside the installed, Developer-ID-signed
12
+ ``cookiesync-keyhelper.app`` (``vault-status`` / ``vault-retrieve`` / ``vault-enroll``).
13
+ An ad-hoc helper is SIGKILLed at exec by AMFI, so a missing helper fails closed rather
14
+ than degrading to an unsigned build — see :func:`cookiesync.paths.require_helper`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from dataclasses import dataclass
21
+ from typing import TYPE_CHECKING, NamedTuple, Protocol
22
+
23
+ import anyio
24
+
25
+ from cookiesync import paths
26
+ from cookiesync.cookie.crypto import derive_key
27
+ from cookiesync.cookie.models import AesKey, SafeStorageKey
28
+
29
+ if TYPE_CHECKING:
30
+ from pathlib import Path
31
+
32
+ from cookiesync.cookie.browsers import Browser
33
+
34
+ SECURITY = "/usr/bin/security"
35
+ REASON_CAP = 160
36
+
37
+
38
+ class VaultStatus(NamedTuple):
39
+ returncode: int
40
+ has_passcode: bool
41
+ has_vault: bool
42
+
43
+
44
+ class ConsentError(Exception):
45
+ """The user explicitly declined the Touch ID / passcode prompt, or the vault read failed."""
46
+
47
+
48
+ def compose_reason(host: str, reason: str) -> str:
49
+ """Touch ID prompt text: domain first, the caller's reason as a 'to …' clause.
50
+
51
+ ``reason`` is collapsed to a single line and capped, since it surfaces verbatim in
52
+ a security dialog.
53
+ """
54
+ return f"access your {host} session to {' '.join(reason.split())[:REASON_CAP]}"
55
+
56
+
57
+ class Consent(Protocol):
58
+ """Obtains the Safe Storage AES key for a browser, gating on the user's consent."""
59
+
60
+ async def obtain_key(self, browser: Browser, *, reason: str) -> AesKey: ...
61
+
62
+ async def obtain_key_unprompted(self, browser: Browser) -> AesKey: ...
63
+
64
+
65
+ @dataclass(frozen=True, slots=True)
66
+ class TouchIDConsent:
67
+ """A Secure-Enclave-bound key vault: one biometric tap unlocks the cached key.
68
+
69
+ Example:
70
+ >>> await TouchIDConsent().obtain_key(REGISTRY[BrowserName("chrome")], reason="post a tweet")
71
+ """
72
+
73
+ async def obtain_key_unprompted(self, browser: Browser) -> AesKey:
74
+ """Release ``browser``'s key non-interactively, via a bare Keychain read — no Touch ID.
75
+
76
+ For the owning host *only*, and *only* after a verified routed approval from the
77
+ active-session peer has already gated the release: this performs the unlocked
78
+ ``security`` read with no user-presence prompt, so the user-presence check must
79
+ have happened over the routed-consent handshake first.
80
+ """
81
+ return derive_key(await self.read_safe_storage(browser.keychain_service))
82
+
83
+ async def obtain_key(self, browser: Browser, *, reason: str) -> AesKey:
84
+ helper = paths.require_helper()
85
+ vault = f"cookiesync.vault.{browser.name}"
86
+ env = os.environ | {"COOKIESYNC_TOUCHID_REASON": compose_reason(browser.display, reason)}
87
+
88
+ status = await anyio.run_process([str(helper), "vault-status", vault], check=False)
89
+ match VaultStatus(status.returncode, b"passcode=true" in status.stdout, b"vault=true" in status.stdout):
90
+ case VaultStatus(returncode=2, has_passcode=False):
91
+ return derive_key(await self.read_safe_storage(browser.keychain_service))
92
+ case VaultStatus(has_vault=True):
93
+ return derive_key(await self.retrieve(helper, vault, browser.keychain_service, env=env))
94
+ case _:
95
+ await self.enroll(helper, vault, browser.keychain_service)
96
+ return derive_key(await self.retrieve(helper, vault, browser.keychain_service, env=env))
97
+
98
+ async def retrieve(
99
+ self, helper: Path, vault: str, safe_storage_service: str, *, env: dict[str, str]
100
+ ) -> SafeStorageKey:
101
+ result = await anyio.run_process([str(helper), "vault-retrieve", vault], check=False, env=env)
102
+ match result.returncode:
103
+ case 0:
104
+ return SafeStorageKey(result.stdout.decode("utf-8"))
105
+ case 1:
106
+ raise ConsentError("Touch ID authentication was cancelled or denied")
107
+ case _:
108
+ # errSecItemNotFound / errSecAuthFailed: the biometryCurrentSet ACL
109
+ # invalidated (the fingerprint set changed). Re-enroll once, then retry.
110
+ await self.enroll(helper, vault, safe_storage_service)
111
+ second = await anyio.run_process([str(helper), "vault-retrieve", vault], check=False, env=env)
112
+ if second.returncode == 0:
113
+ return SafeStorageKey(second.stdout.decode("utf-8"))
114
+ raise ConsentError("Touch ID vault retrieval failed after re-enrollment")
115
+
116
+ async def enroll(self, helper: Path, vault: str, safe_storage_service: str) -> None:
117
+ result = await anyio.run_process([str(helper), "vault-enroll", vault, safe_storage_service], check=False)
118
+ if result.returncode != 0:
119
+ raise ConsentError(
120
+ f"could not enroll the Touch ID vault for {safe_storage_service!r} "
121
+ f"(exit {result.returncode}: {result.stderr.decode().strip() or 'no detail'})"
122
+ )
123
+
124
+ async def read_safe_storage(self, service: str) -> SafeStorageKey:
125
+ result = await anyio.run_process([SECURITY, "find-generic-password", "-w", "-s", service], check=False)
126
+ if result.returncode != 0:
127
+ raise ConsentError(f"could not read '{service}' from the Keychain (denied or missing)")
128
+ return SafeStorageKey(result.stdout.decode("utf-8").strip())