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 +3 -0
- cookiesync/__main__.py +6 -0
- cookiesync/cli.py +339 -0
- cookiesync/cookie/__init__.py +20 -0
- cookiesync/cookie/backend.py +100 -0
- cookiesync/cookie/browsers.py +57 -0
- cookiesync/cookie/consent.py +128 -0
- cookiesync/cookie/crypto.py +85 -0
- cookiesync/cookie/domains.py +38 -0
- cookiesync/cookie/getcookie.py +113 -0
- cookiesync/cookie/merge.py +74 -0
- cookiesync/cookie/models.py +90 -0
- cookiesync/cookie/pipeline.py +101 -0
- cookiesync/cookie/serialize.py +132 -0
- cookiesync/cookie/stores.py +218 -0
- cookiesync/daemon/__init__.py +13 -0
- cookiesync/daemon/backend_ssh.py +70 -0
- cookiesync/daemon/cache.py +113 -0
- cookiesync/daemon/engine.py +195 -0
- cookiesync/daemon/rpc.py +153 -0
- cookiesync/daemon/server.py +378 -0
- cookiesync/daemon/session.py +117 -0
- cookiesync/daemon/sync.py +241 -0
- cookiesync/daemon/wire.py +90 -0
- cookiesync/helper.py +112 -0
- cookiesync/paths.py +87 -0
- cookiesync/py.typed +0 -0
- cookiesync/registry.py +79 -0
- cookiesync/service.py +214 -0
- cookiesync/state.py +173 -0
- cookiesync/transport.py +108 -0
- cookiesync_cli-0.1.0.dist-info/METADATA +120 -0
- cookiesync_cli-0.1.0.dist-info/RECORD +36 -0
- cookiesync_cli-0.1.0.dist-info/WHEEL +4 -0
- cookiesync_cli-0.1.0.dist-info/entry_points.txt +3 -0
- cookiesync_cli-0.1.0.dist-info/licenses/LICENSE +133 -0
cookiesync/__init__.py
ADDED
cookiesync/__main__.py
ADDED
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())
|