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/daemon/rpc.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Unix-socket RPC plumbing: a one-shot client and a serialized-dispatch server.
|
|
2
|
+
|
|
3
|
+
The server listens on ``paths.sock_path()`` and speaks the newline-delimited JSON
|
|
4
|
+
protocol from ``wire`` — one request line in, one response line out, then close.
|
|
5
|
+
Dispatch is serialized behind an ``anyio.Lock`` so at most one handler runs at a
|
|
6
|
+
time and handlers never race the cookie store. Only a same-UID peer is served,
|
|
7
|
+
checked via ``LOCAL_PEERCRED`` on the accepted connection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import struct
|
|
15
|
+
import sys
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import anyio
|
|
19
|
+
from anyio.abc import SocketAttribute
|
|
20
|
+
from anyio.streams.buffered import BufferedByteReceiveStream
|
|
21
|
+
|
|
22
|
+
from cookiesync import paths
|
|
23
|
+
from cookiesync.daemon.wire import (
|
|
24
|
+
Request,
|
|
25
|
+
Response,
|
|
26
|
+
decode_request,
|
|
27
|
+
decode_response,
|
|
28
|
+
encode_request,
|
|
29
|
+
encode_response,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Awaitable, Callable
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from anyio.abc import SocketStream, TaskStatus
|
|
37
|
+
|
|
38
|
+
type Handler = Callable[[dict], Awaitable[dict | list | None]]
|
|
39
|
+
|
|
40
|
+
READ_TIMEOUT = 30.0
|
|
41
|
+
DISPATCH_TIMEOUT = 600.0
|
|
42
|
+
MAX_LINE = 16 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
SOL_LOCAL = 0
|
|
45
|
+
# macOS-only socket constant (value 0x001). This daemon runs only on macOS; the literal
|
|
46
|
+
# fallback just keeps the package importable on other platforms (docs build, portable engine).
|
|
47
|
+
LOCAL_PEERCRED = getattr(socket, "LOCAL_PEERCRED", 0x001)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RpcError(Exception):
|
|
51
|
+
"""The RPC transport failed to reach or read from the daemon."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def peer_uid(sock: socket.socket) -> int:
|
|
55
|
+
"""The UID of the process on the other end of ``sock``, via ``LOCAL_PEERCRED``.
|
|
56
|
+
|
|
57
|
+
Reads the ``xucred`` struct macOS returns and pulls ``cr_uid`` out of it.
|
|
58
|
+
"""
|
|
59
|
+
cred = sock.getsockopt(SOL_LOCAL, LOCAL_PEERCRED, struct.calcsize("II") + 256)
|
|
60
|
+
_version, uid = struct.unpack_from("II", cred, 0)
|
|
61
|
+
return uid
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Dispatcher:
|
|
65
|
+
"""Routes a method name to the async handler the integrator registered for it.
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
>>> dispatcher = Dispatcher()
|
|
69
|
+
>>> dispatcher.register("status", handle_status)
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self.handlers: dict[str, Handler] = {}
|
|
74
|
+
self.lock = anyio.Lock()
|
|
75
|
+
|
|
76
|
+
def register(self, method: str, handler: Handler) -> None:
|
|
77
|
+
"""Bind ``method`` to ``handler``; the daemon calls it with the request params."""
|
|
78
|
+
self.handlers[method] = handler
|
|
79
|
+
|
|
80
|
+
async def dispatch(self, req: Request) -> Response:
|
|
81
|
+
"""Run the handler for ``req`` under the serialization lock, with a hard timeout."""
|
|
82
|
+
if (handler := self.handlers.get(req.method)) is None:
|
|
83
|
+
return Response(ok=False, error=f"unknown method {req.method!r}")
|
|
84
|
+
async with self.lock:
|
|
85
|
+
response = Response(ok=False, error=f"method {req.method!r} timed out after {DISPATCH_TIMEOUT:.0f}s")
|
|
86
|
+
with anyio.move_on_after(DISPATCH_TIMEOUT):
|
|
87
|
+
try:
|
|
88
|
+
response = Response(ok=True, result=await handler(req.params))
|
|
89
|
+
except Exception as exc: # noqa: BLE001 — surface a handler failure as a typed error to the caller
|
|
90
|
+
response = Response(ok=False, error=str(exc))
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def call(method: str, params: dict | None = None, *, sock_path: Path | None = None) -> Response:
|
|
95
|
+
"""Send one ``Request`` to the daemon and return its ``Response``.
|
|
96
|
+
|
|
97
|
+
Connects to the daemon's unix socket, writes one request line, reads one
|
|
98
|
+
response line, and closes.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
RpcError: the daemon could not be reached or sent no response line.
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
>>> await call("status", {"endpoint": "host:chrome:Default"})
|
|
105
|
+
"""
|
|
106
|
+
path = sock_path or paths.sock_path()
|
|
107
|
+
try:
|
|
108
|
+
stream = await anyio.connect_unix(str(path))
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
raise RpcError(f"connect to daemon at {path}: {exc}") from exc
|
|
111
|
+
async with stream:
|
|
112
|
+
await stream.send(encode_request(Request(method=method, params=params or {})))
|
|
113
|
+
try:
|
|
114
|
+
line = await BufferedByteReceiveStream(stream).receive_until(b"\n", MAX_LINE)
|
|
115
|
+
except anyio.EndOfStream as exc:
|
|
116
|
+
raise RpcError(f"daemon at {path} closed without a response") from exc
|
|
117
|
+
return decode_response(line)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def serve(dispatcher: Dispatcher, *, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED) -> None:
|
|
121
|
+
"""Listen on the daemon socket and dispatch each connection's request.
|
|
122
|
+
|
|
123
|
+
Unlinks a stale socket before binding, then serves until the surrounding task
|
|
124
|
+
group is cancelled. Signals readiness via ``task_status`` once the listener is
|
|
125
|
+
bound, so a daemon can ``await tg.start(serve, dispatcher)``.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
>>> async with anyio.create_task_group() as tg:
|
|
129
|
+
... await tg.start(serve, dispatcher)
|
|
130
|
+
"""
|
|
131
|
+
path = paths.sock_path()
|
|
132
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
path.unlink(missing_ok=True)
|
|
134
|
+
listener = await anyio.create_unix_listener(str(path))
|
|
135
|
+
|
|
136
|
+
async def handle(stream: SocketStream) -> None:
|
|
137
|
+
async with stream:
|
|
138
|
+
# LOCAL_PEERCRED is macOS-only; this daemon runs only on macOS, where the
|
|
139
|
+
# same-uid check is enforced. Off-macOS (CI/import only) the 0700 socket dir
|
|
140
|
+
# is the boundary, so skip the unsupported getsockopt.
|
|
141
|
+
if sys.platform == "darwin" and (uid := peer_uid(stream.extra(SocketAttribute.raw_socket))) != os.getuid():
|
|
142
|
+
await stream.send(encode_response(Response(ok=False, error=f"peer uid {uid} is not {os.getuid()}")))
|
|
143
|
+
return
|
|
144
|
+
with anyio.move_on_after(READ_TIMEOUT) as scope:
|
|
145
|
+
line = await BufferedByteReceiveStream(stream).receive_until(b"\n", MAX_LINE)
|
|
146
|
+
if scope.cancelled_caught:
|
|
147
|
+
await stream.send(encode_response(Response(ok=False, error="request read timed out")))
|
|
148
|
+
return
|
|
149
|
+
await stream.send(encode_response(await dispatcher.dispatch(decode_request(line))))
|
|
150
|
+
|
|
151
|
+
async with listener:
|
|
152
|
+
task_status.started()
|
|
153
|
+
await listener.serve(handle)
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""The cookiesync daemon: wire the watch engine, the sync layer, and the unix-socket RPC into one process.
|
|
2
|
+
|
|
3
|
+
``Daemon.watch`` runs two things under one :class:`anyio.abc.TaskGroup` — the engine watching
|
|
4
|
+
this host's local endpoints (each settle notifies every peer to converge) and ``rpc.serve``
|
|
5
|
+
answering the bimodal RPC method set — so the first failure cancels the other. Every method
|
|
6
|
+
routes through :meth:`Daemon.dispatcher`.
|
|
7
|
+
|
|
8
|
+
The method set splits in two:
|
|
9
|
+
|
|
10
|
+
* **Peer methods** carry an ``origin`` and are how peers drive this host over ssh: ``sync``
|
|
11
|
+
converges one endpoint (suppressing the origin), ``reconcile`` runs a full pass, ``extract``
|
|
12
|
+
returns this host's decrypted cookies as wire records (priming auth if the key is cold),
|
|
13
|
+
``apply`` ingests a merged wire set (idempotent write plus the engine's anti-echo digest),
|
|
14
|
+
and ``whoami`` reports this host's session.
|
|
15
|
+
* **Local methods** are terminal and carry no origin — what the CLI on this box invokes:
|
|
16
|
+
``prime_auth`` obtains the Safe Storage key (locally behind Touch ID when a session is live,
|
|
17
|
+
else by routing the user-presence gate to the active peer and then releasing this host's
|
|
18
|
+
*own* key non-interactively) and caches it; ``get_cookies`` renders a url's cookies from the
|
|
19
|
+
cached key, failing closed when cold; ``auth_status`` reports cache warmth; ``request_consent``
|
|
20
|
+
shows the Touch-ID prompt for the named browser to the person at *this* machine and echoes the
|
|
21
|
+
requester's nonce + endpoint to bind the approval — the key never crosses hosts.
|
|
22
|
+
|
|
23
|
+
Every collaborator (consent gate, key cache, watch engine, session probe, ssh runner, cookie
|
|
24
|
+
sources, and the clock) is injected, so the whole dispatcher runs in unit tests against fakes
|
|
25
|
+
without a real macOS API, ssh, or cookie store.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import secrets
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from typing import TYPE_CHECKING
|
|
34
|
+
|
|
35
|
+
import anyio
|
|
36
|
+
|
|
37
|
+
from cookiesync import state as state_module
|
|
38
|
+
from cookiesync.cookie import LocalBackend, extract
|
|
39
|
+
from cookiesync.cookie.browsers import REGISTRY, BrowserName
|
|
40
|
+
from cookiesync.cookie.consent import ConsentError
|
|
41
|
+
from cookiesync.cookie.crypto import DecryptError, decrypt_value
|
|
42
|
+
from cookiesync.cookie.models import AesKey, Cookie
|
|
43
|
+
from cookiesync.cookie.stores import read_rows, write_rows
|
|
44
|
+
from cookiesync.daemon import rpc
|
|
45
|
+
from cookiesync.daemon.backend_ssh import SshBackend
|
|
46
|
+
from cookiesync.daemon.engine import Engine, logical_digest
|
|
47
|
+
from cookiesync.daemon.rpc import Dispatcher
|
|
48
|
+
from cookiesync.daemon.session import has_active_session, probe_session, session_summary
|
|
49
|
+
from cookiesync.daemon.sync import Extracted, NeedsAuth, converge, reconcile
|
|
50
|
+
from cookiesync.daemon.wire import cookie_from_wire, cookie_to_wire
|
|
51
|
+
from cookiesync.state import BrowserId, SshTarget
|
|
52
|
+
from cookiesync.transport import shell_quote, ssh
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
56
|
+
|
|
57
|
+
from cookiesync.cookie.browsers import Browser
|
|
58
|
+
from cookiesync.cookie.consent import Consent
|
|
59
|
+
from cookiesync.cookie.models import EncryptedRow
|
|
60
|
+
from cookiesync.daemon.cache import KeyCache
|
|
61
|
+
from cookiesync.daemon.session import SessionSnapshot
|
|
62
|
+
from cookiesync.daemon.sync import Source
|
|
63
|
+
from cookiesync.state import BrowserEndpoint, State
|
|
64
|
+
|
|
65
|
+
PEER_METHODS = ("sync", "reconcile", "extract", "apply", "whoami")
|
|
66
|
+
LOCAL_METHODS = ("prime_auth", "get_cookies", "auth_status", "request_consent")
|
|
67
|
+
|
|
68
|
+
CONSENT_REASON = "sync your cookies across your machines"
|
|
69
|
+
DEFAULT_PROFILE = "Default"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AuthRequired(Exception):
|
|
73
|
+
"""The local key cache is cold; the user must run ``cookiesync auth`` first."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def browser_for(browser_id: BrowserId) -> Browser:
|
|
77
|
+
"""The :class:`~cookiesync.cookie.browsers.Browser` a wire ``browser`` id names."""
|
|
78
|
+
return REGISTRY[BrowserName(browser_id)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def endpoint_id(host: SshTarget, browser: BrowserId, profile: str) -> str:
|
|
82
|
+
return f"{host}:{browser}:{profile}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class CachedKeySource:
|
|
87
|
+
"""The sync :class:`~cookiesync.daemon.sync.Source` for this host, decrypting with the cached key.
|
|
88
|
+
|
|
89
|
+
``extract`` reads every row of the browser profile off a private store copy and decrypts it
|
|
90
|
+
with the cached Safe Storage key — never the consent gate, so the merge pass never prompts;
|
|
91
|
+
a cold cache raises :class:`~cookiesync.daemon.sync.NeedsAuth`. ``apply`` re-encrypts the
|
|
92
|
+
merged set back into the live store with that same key.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
cache: KeyCache
|
|
96
|
+
self_target: SshTarget
|
|
97
|
+
|
|
98
|
+
async def key_for(self, browser: BrowserId, profile: str) -> AesKey:
|
|
99
|
+
if (key := await self.cache.get(endpoint_id(self.self_target, browser, profile))) is None:
|
|
100
|
+
raise NeedsAuth(f"no cached key for {endpoint_id(self.self_target, browser, profile)}; run cookiesync auth")
|
|
101
|
+
return AesKey(key)
|
|
102
|
+
|
|
103
|
+
async def extract(self, browser: BrowserId, profile: str) -> Extracted:
|
|
104
|
+
key = await self.key_for(browser, profile)
|
|
105
|
+
rows = await read_rows(browser_for(browser), profile)
|
|
106
|
+
return Extracted(tuple(c for row in rows if (c := decrypt_row(row, key)) is not None))
|
|
107
|
+
|
|
108
|
+
async def apply(self, browser: BrowserId, profile: str, cookies: Sequence[Cookie]) -> int:
|
|
109
|
+
return await write_rows(browser_for(browser), profile, cookies, await self.key_for(browser, profile))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decrypt_row(row: EncryptedRow, key: AesKey) -> Cookie | None:
|
|
113
|
+
try:
|
|
114
|
+
value = decrypt_value(row.encrypted_value, key, row.host_key)
|
|
115
|
+
except DecryptError:
|
|
116
|
+
return None
|
|
117
|
+
return Cookie(
|
|
118
|
+
host_key=row.host_key,
|
|
119
|
+
name=row.name,
|
|
120
|
+
value=value,
|
|
121
|
+
path=row.path,
|
|
122
|
+
expires_utc=row.expires_utc,
|
|
123
|
+
last_update_utc=row.last_update_utc,
|
|
124
|
+
creation_utc=row.creation_utc,
|
|
125
|
+
is_secure=row.is_secure,
|
|
126
|
+
is_httponly=row.is_httponly,
|
|
127
|
+
samesite=row.samesite,
|
|
128
|
+
source_scheme=row.source_scheme,
|
|
129
|
+
source_port=row.source_port,
|
|
130
|
+
top_frame_site_key=row.top_frame_site_key,
|
|
131
|
+
has_cross_site_ancestor=row.has_cross_site_ancestor,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(slots=True)
|
|
136
|
+
class Daemon:
|
|
137
|
+
"""The resident cookiesync daemon: the watch loop, the sync layer, and the RPC dispatcher.
|
|
138
|
+
|
|
139
|
+
Holds every collaborator behind an injected seam so :meth:`dispatcher` and :meth:`watch`
|
|
140
|
+
run in unit tests against fakes. In production it is built with the real consent gate,
|
|
141
|
+
key cache, watch engine, session probe, and ssh runner.
|
|
142
|
+
|
|
143
|
+
Example:
|
|
144
|
+
>>> daemon = await Daemon.build()
|
|
145
|
+
>>> await daemon.watch()
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
consent: Consent
|
|
149
|
+
cache: KeyCache
|
|
150
|
+
engine: Engine
|
|
151
|
+
probe: Callable[[], Awaitable[SessionSnapshot]] = probe_session
|
|
152
|
+
run_ssh: Callable[..., Awaitable[str]] = ssh
|
|
153
|
+
load_state: Callable[[], Awaitable[State]] = state_module.load
|
|
154
|
+
local_source_factory: Callable[[Daemon, SshTarget], Source] | None = None
|
|
155
|
+
source_for_factory: Callable[[Daemon, SshTarget], Source] | None = None
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
async def build(cls) -> Daemon:
|
|
159
|
+
"""Wire the production daemon: Touch-ID consent, the Enclave-backed cache, and a fresh engine."""
|
|
160
|
+
from cookiesync.cookie.consent import TouchIDConsent
|
|
161
|
+
from cookiesync.daemon.cache import KeyCache, SecureEnclaveWrapper
|
|
162
|
+
|
|
163
|
+
settings = (await state_module.load()).settings
|
|
164
|
+
daemon = cls(TouchIDConsent(), KeyCache(await SecureEnclaveWrapper.open()), Engine(settings, notify=_noop))
|
|
165
|
+
daemon.engine.notify = daemon.notify_peers
|
|
166
|
+
return daemon
|
|
167
|
+
|
|
168
|
+
def local_source(self, self_target: SshTarget) -> Source:
|
|
169
|
+
if self.local_source_factory is not None:
|
|
170
|
+
return self.local_source_factory(self, self_target)
|
|
171
|
+
return CachedKeySource(self.cache, self_target)
|
|
172
|
+
|
|
173
|
+
def source_for(self, self_target: SshTarget) -> Callable[[SshTarget], Source]:
|
|
174
|
+
if self.source_for_factory is not None:
|
|
175
|
+
return lambda peer: self.source_for_factory(self, peer)
|
|
176
|
+
return lambda peer: SshBackend(peer, origin=self_target)
|
|
177
|
+
|
|
178
|
+
async def watch(self) -> None:
|
|
179
|
+
"""Run the engine over this host's local endpoints and the RPC server until cancelled.
|
|
180
|
+
|
|
181
|
+
Under one task group the engine watches each local endpoint (a settle notifies every
|
|
182
|
+
peer to converge) and ``rpc.serve`` answers the bimodal RPC set; the first failure
|
|
183
|
+
cancels the rest.
|
|
184
|
+
"""
|
|
185
|
+
state = await self.load_state()
|
|
186
|
+
local_endpoints = tuple(e for e in state.browsers if e.host == state.self_target)
|
|
187
|
+
async with anyio.create_task_group() as tg:
|
|
188
|
+
await tg.start(rpc.serve, self.dispatcher())
|
|
189
|
+
tg.start_soon(self.engine.run, local_endpoints)
|
|
190
|
+
|
|
191
|
+
async def notify_peers(self, endpoint: BrowserEndpoint) -> None:
|
|
192
|
+
"""A local endpoint settled: ssh every peer to converge that browser, tagged with our origin."""
|
|
193
|
+
state = await self.load_state()
|
|
194
|
+
peers = {e.host for e in state.browsers if e.browser == endpoint.browser and e.host != state.self_target}
|
|
195
|
+
async with anyio.create_task_group() as tg:
|
|
196
|
+
for peer in peers:
|
|
197
|
+
tg.start_soon(self.notify_peer, peer, endpoint.browser, state.self_target)
|
|
198
|
+
|
|
199
|
+
async def notify_peer(self, peer: SshTarget, browser: BrowserId, self_target: SshTarget) -> None:
|
|
200
|
+
await self.run_ssh(
|
|
201
|
+
peer,
|
|
202
|
+
f"cookiesync rpc sync --browser {shell_quote(browser)} --origin {shell_quote(self_target)}",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def dispatcher(self) -> Dispatcher:
|
|
206
|
+
"""Build the :class:`~cookiesync.daemon.rpc.Dispatcher` with every peer and local method bound."""
|
|
207
|
+
dispatcher = Dispatcher()
|
|
208
|
+
dispatcher.register("sync", self.handle_sync)
|
|
209
|
+
dispatcher.register("reconcile", self.handle_reconcile)
|
|
210
|
+
dispatcher.register("extract", self.handle_extract)
|
|
211
|
+
dispatcher.register("apply", self.handle_apply)
|
|
212
|
+
dispatcher.register("whoami", self.handle_whoami)
|
|
213
|
+
dispatcher.register("prime_auth", self.handle_prime_auth)
|
|
214
|
+
dispatcher.register("get_cookies", self.handle_get_cookies)
|
|
215
|
+
dispatcher.register("auth_status", self.handle_auth_status)
|
|
216
|
+
dispatcher.register("request_consent", self.handle_request_consent)
|
|
217
|
+
return dispatcher
|
|
218
|
+
|
|
219
|
+
async def handle_sync(self, params: dict) -> dict:
|
|
220
|
+
state = await self.load_state()
|
|
221
|
+
browser = BrowserId(params["browser"])
|
|
222
|
+
origin = SshTarget(params["origin"]) if params.get("origin") else None
|
|
223
|
+
group = [e for e in state.browsers if e.browser == browser]
|
|
224
|
+
anchor = next((e for e in group if e.host == state.self_target), None)
|
|
225
|
+
if anchor is None:
|
|
226
|
+
return {"converged": False, "reason": "no local endpoint for this browser"}
|
|
227
|
+
merged = await converge(
|
|
228
|
+
anchor,
|
|
229
|
+
[e for e in group if e is not anchor],
|
|
230
|
+
origin=origin,
|
|
231
|
+
self_target=state.self_target,
|
|
232
|
+
cache=self.cache,
|
|
233
|
+
engine=self.engine,
|
|
234
|
+
local_source=self.local_source(state.self_target),
|
|
235
|
+
source_for=self.source_for(state.self_target),
|
|
236
|
+
)
|
|
237
|
+
return {"converged": True, "cookies": len(merged)}
|
|
238
|
+
|
|
239
|
+
async def handle_reconcile(self, params: dict) -> dict:
|
|
240
|
+
state = await self.load_state()
|
|
241
|
+
results = await reconcile(
|
|
242
|
+
state.browsers,
|
|
243
|
+
self_target=state.self_target,
|
|
244
|
+
registry={BrowserId(name): browser for name, browser in REGISTRY.items()},
|
|
245
|
+
cache=self.cache,
|
|
246
|
+
engine=self.engine,
|
|
247
|
+
local_source=self.local_source(state.self_target),
|
|
248
|
+
source_for=self.source_for(state.self_target),
|
|
249
|
+
)
|
|
250
|
+
return {"groups": {anchor_id: len(cookies) for anchor_id, cookies in results.items()}}
|
|
251
|
+
|
|
252
|
+
async def handle_extract(self, params: dict) -> dict:
|
|
253
|
+
state = await self.load_state()
|
|
254
|
+
browser = BrowserId(params["browser"])
|
|
255
|
+
profile = params.get("profile", DEFAULT_PROFILE)
|
|
256
|
+
if await self.cache.get(endpoint_id(state.self_target, browser, profile)) is None:
|
|
257
|
+
await self.prime_auth(browser, profile, state)
|
|
258
|
+
extracted = await self.local_source(state.self_target).extract(browser, profile)
|
|
259
|
+
return {"cookies": [cookie_to_wire(c) for c in extracted.cookies]}
|
|
260
|
+
|
|
261
|
+
async def handle_apply(self, params: dict) -> dict:
|
|
262
|
+
state = await self.load_state()
|
|
263
|
+
browser = BrowserId(params["browser"])
|
|
264
|
+
profile = params.get("profile", DEFAULT_PROFILE)
|
|
265
|
+
cookies = tuple(cookie_from_wire(c) for c in params["cookies"])
|
|
266
|
+
self.engine.record_applied(endpoint_id(state.self_target, browser, profile), logical_digest(cookies))
|
|
267
|
+
applied = await self.local_source(state.self_target).apply(browser, profile, cookies)
|
|
268
|
+
return {"applied": applied}
|
|
269
|
+
|
|
270
|
+
async def handle_whoami(self, params: dict) -> dict:
|
|
271
|
+
return await session_summary(probe=self.probe)
|
|
272
|
+
|
|
273
|
+
async def handle_prime_auth(self, params: dict) -> dict:
|
|
274
|
+
state = await self.load_state()
|
|
275
|
+
browser = BrowserId(params["browser"])
|
|
276
|
+
profile = params.get("profile", DEFAULT_PROFILE)
|
|
277
|
+
await self.prime_auth(browser, profile, state)
|
|
278
|
+
return {"primed": True, "endpoint": endpoint_id(state.self_target, browser, profile)}
|
|
279
|
+
|
|
280
|
+
async def prime_auth(self, browser: BrowserId, profile: str, state: State) -> AesKey:
|
|
281
|
+
"""Obtain the Safe Storage key and cache it under the endpoint's TTL.
|
|
282
|
+
|
|
283
|
+
A live local session releases the key behind one Touch-ID tap here. Otherwise the user
|
|
284
|
+
presence check is *routed* to the active peer via ``request_consent``: the peer shows
|
|
285
|
+
Touch ID for *this exact* browser, and only a reply whose ``nonce`` and ``endpoint``
|
|
286
|
+
echo back the ones this host sent counts as an approval. On a verified approval this
|
|
287
|
+
host releases its *own* key non-interactively — the key never leaves this box. Raises
|
|
288
|
+
:class:`AuthRequired` when no peer can approve or the reply fails to bind.
|
|
289
|
+
"""
|
|
290
|
+
if await has_active_session(probe=self.probe):
|
|
291
|
+
key = await self.consent.obtain_key(browser_for(browser), reason=CONSENT_REASON)
|
|
292
|
+
else:
|
|
293
|
+
key = await self.routed_release(browser, profile, state)
|
|
294
|
+
await self.cache.put(
|
|
295
|
+
endpoint_id(state.self_target, browser, profile), key, state.settings.auth_ttl.total_seconds()
|
|
296
|
+
)
|
|
297
|
+
return key
|
|
298
|
+
|
|
299
|
+
async def routed_release(self, browser: BrowserId, profile: str, state: State) -> AesKey:
|
|
300
|
+
"""Route the user-presence gate to the active peer, then release this host's own key.
|
|
301
|
+
|
|
302
|
+
The peer's ``request_consent`` reply must echo the exact ``nonce`` and ``endpoint``
|
|
303
|
+
this host sent; otherwise the approval is unbound and we fail closed. Only after that
|
|
304
|
+
verified approval do we read this host's own key non-interactively — it never crosses
|
|
305
|
+
the wire.
|
|
306
|
+
"""
|
|
307
|
+
peer = await self.active_peer(state)
|
|
308
|
+
nonce = secrets.token_urlsafe(32)
|
|
309
|
+
endpoint = endpoint_id(state.self_target, browser, profile)
|
|
310
|
+
resp = json.loads(
|
|
311
|
+
await self.run_ssh(
|
|
312
|
+
peer,
|
|
313
|
+
"cookiesync rpc request_consent"
|
|
314
|
+
f" --browser {shell_quote(browser)} --profile {shell_quote(profile)}"
|
|
315
|
+
f" --nonce {shell_quote(nonce)} --endpoint {shell_quote(endpoint)}",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
if not (resp.get("status") == "approved" and resp.get("nonce") == nonce and resp.get("endpoint") == endpoint):
|
|
319
|
+
raise AuthRequired(f"consent {resp.get('status') or 'unavailable'} from {peer}")
|
|
320
|
+
return await self.consent.obtain_key_unprompted(browser_for(browser))
|
|
321
|
+
|
|
322
|
+
async def active_peer(self, state: State) -> SshTarget:
|
|
323
|
+
for peer in {e.host for e in state.browsers if e.host != state.self_target}:
|
|
324
|
+
summary = json.loads(await self.run_ssh(peer, "cookiesync rpc whoami"))
|
|
325
|
+
if summary.get("on_console") and not summary.get("locked"):
|
|
326
|
+
return peer
|
|
327
|
+
raise AuthRequired("no peer has a live session to approve consent")
|
|
328
|
+
|
|
329
|
+
async def handle_get_cookies(self, params: dict) -> dict:
|
|
330
|
+
state = await self.load_state()
|
|
331
|
+
browser = BrowserId(params["browser"])
|
|
332
|
+
profile = params.get("profile", DEFAULT_PROFILE)
|
|
333
|
+
if (key := await self.cache.get(endpoint_id(state.self_target, browser, profile))) is None:
|
|
334
|
+
raise AuthRequired(
|
|
335
|
+
f"no cached key for {endpoint_id(state.self_target, browser, profile)}; run cookiesync auth"
|
|
336
|
+
)
|
|
337
|
+
result = await extract(
|
|
338
|
+
params["url"],
|
|
339
|
+
browser=browser_for(browser),
|
|
340
|
+
key=AesKey(key),
|
|
341
|
+
backend=LocalBackend(self.consent),
|
|
342
|
+
profile=profile,
|
|
343
|
+
fallback=False,
|
|
344
|
+
)
|
|
345
|
+
return {"cookies": [cookie_to_wire(c) for c in result.cookies]}
|
|
346
|
+
|
|
347
|
+
async def handle_auth_status(self, params: dict) -> dict:
|
|
348
|
+
state = await self.load_state()
|
|
349
|
+
browser = BrowserId(params["browser"])
|
|
350
|
+
profile = params.get("profile", DEFAULT_PROFILE)
|
|
351
|
+
endpoint = endpoint_id(state.self_target, browser, profile)
|
|
352
|
+
return {"endpoint": endpoint, "authenticated": await self.cache.get(endpoint) is not None}
|
|
353
|
+
|
|
354
|
+
async def handle_request_consent(self, params: dict) -> dict:
|
|
355
|
+
"""Show the Touch-ID prompt to the person at *this* machine for the requesting endpoint.
|
|
356
|
+
|
|
357
|
+
The prompt names the exact browser the requester asked to release, so the user sees
|
|
358
|
+
what they are approving. The reply echoes the requester's ``nonce`` and ``endpoint``,
|
|
359
|
+
binding the approval to that one request — no key crosses the wire.
|
|
360
|
+
|
|
361
|
+
Returns ``{"status": "approved", "nonce": ..., "endpoint": ...}`` on a live tap,
|
|
362
|
+
``{"status": "denied"}`` when the prompt was declined, or ``{"status": "unavailable"}``
|
|
363
|
+
when this host has no live session to prompt.
|
|
364
|
+
"""
|
|
365
|
+
browser = BrowserId(params["browser"])
|
|
366
|
+
nonce = params["nonce"]
|
|
367
|
+
endpoint = params["endpoint"]
|
|
368
|
+
if not await has_active_session(probe=self.probe):
|
|
369
|
+
return {"status": "unavailable"}
|
|
370
|
+
try:
|
|
371
|
+
await self.consent.obtain_key(browser_for(browser), reason=f"release {endpoint}")
|
|
372
|
+
except ConsentError:
|
|
373
|
+
return {"status": "denied"}
|
|
374
|
+
return {"status": "approved", "nonce": nonce, "endpoint": endpoint}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
async def _noop(_endpoint: BrowserEndpoint) -> None:
|
|
378
|
+
return None
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Detect whether this host has a live, unlocked console GUI session.
|
|
2
|
+
|
|
3
|
+
A machine is *active* only when a real person is sitting at it: an on-console
|
|
4
|
+
GUI session whose screen is unlocked. That is the one moment cookies can be
|
|
5
|
+
extracted — Touch ID is reachable and ``WhenUnlocked`` keychain items are
|
|
6
|
+
readable. A locked screen, an SSH-only headless box, or another user holding
|
|
7
|
+
the console via fast user switching all count as **not** active.
|
|
8
|
+
|
|
9
|
+
The system probe is :data:`ioreg`'s ``IOConsoleUsers``/``IOConsoleLocked``,
|
|
10
|
+
parsed as a plist. It answers from any session — including a daemon launched
|
|
11
|
+
under ``launchd`` in a different audit session — so it stays correct headless.
|
|
12
|
+
The probe is injected (:func:`probe_session`), so the decision logic runs in
|
|
13
|
+
unit tests against synthetic snapshots without touching macOS.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import getpass
|
|
19
|
+
import plistlib
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
22
|
+
|
|
23
|
+
import anyio
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
|
|
28
|
+
IOREG_ARGV = ("ioreg", "-n", "Root", "-d1", "-a")
|
|
29
|
+
|
|
30
|
+
ON_CONSOLE_KEY = "kCGSSessionOnConsoleKey"
|
|
31
|
+
USER_NAME_KEY = "kCGSSessionUserNameKey"
|
|
32
|
+
SCREEN_LOCKED_KEY = "CGSSessionScreenIsLocked"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class SessionSnapshot:
|
|
37
|
+
"""A point-in-time read of this host's console GUI session.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
on_console: A GUI session owns the physical console right now.
|
|
41
|
+
locked: The console screen is locked (screensaver or lock screen up).
|
|
42
|
+
console_user: The short username of the console session, or ``None``
|
|
43
|
+
when no GUI session is attached (headless / SSH-only).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
on_console: bool
|
|
47
|
+
locked: bool
|
|
48
|
+
console_user: str | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Verdict(NamedTuple):
|
|
52
|
+
on_console: bool
|
|
53
|
+
locked: bool
|
|
54
|
+
is_self: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_session(payload: bytes) -> SessionSnapshot:
|
|
58
|
+
root = plistlib.loads(payload)
|
|
59
|
+
match next((s for s in root.get("IOConsoleUsers", ()) if s.get(ON_CONSOLE_KEY)), None):
|
|
60
|
+
case None:
|
|
61
|
+
return SessionSnapshot(on_console=False, locked=False, console_user=None)
|
|
62
|
+
case session:
|
|
63
|
+
return SessionSnapshot(
|
|
64
|
+
on_console=True,
|
|
65
|
+
locked=bool(root.get("IOConsoleLocked", False)) or bool(session.get(SCREEN_LOCKED_KEY, False)),
|
|
66
|
+
console_user=session.get(USER_NAME_KEY),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def probe_session() -> SessionSnapshot:
|
|
71
|
+
return parse_session((await anyio.run_process(IOREG_ARGV, check=True)).stdout)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def has_active_session(*, probe: Callable[[], Awaitable[SessionSnapshot]] = probe_session) -> bool:
|
|
75
|
+
"""Whether this host has a live, unlocked console GUI session owned by this user.
|
|
76
|
+
|
|
77
|
+
True only when a real person is at the keyboard: a GUI session holds the
|
|
78
|
+
console, its screen is unlocked, and the console user is the user this
|
|
79
|
+
process runs as. A locked screen, a headless/SSH-only box, or another user
|
|
80
|
+
holding the console via fast user switching all return False.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
probe: The system session reader. Defaults to the real :func:`probe_session`;
|
|
84
|
+
inject a stub to drive the decision in tests.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
>>> if await has_active_session():
|
|
88
|
+
... await extract(...)
|
|
89
|
+
"""
|
|
90
|
+
snapshot = await probe()
|
|
91
|
+
match Verdict(snapshot.on_console, snapshot.locked, snapshot.console_user == getpass.getuser()):
|
|
92
|
+
case Verdict(on_console=True, locked=False, is_self=True):
|
|
93
|
+
return True
|
|
94
|
+
case _:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def session_summary(*, probe: Callable[[], Awaitable[SessionSnapshot]] = probe_session) -> dict[str, object]:
|
|
99
|
+
"""This host's console session state, shaped for the ``whoami`` RPC.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
probe: The system session reader. Defaults to the real :func:`probe_session`;
|
|
103
|
+
inject a stub to drive the result in tests.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
``{"on_console": bool, "locked": bool, "console_user": str | None}``.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
>>> await session_summary()
|
|
110
|
+
{'on_console': True, 'locked': False, 'console_user': 'alice'}
|
|
111
|
+
"""
|
|
112
|
+
snapshot = await probe()
|
|
113
|
+
return {
|
|
114
|
+
"on_console": snapshot.on_console,
|
|
115
|
+
"locked": snapshot.locked,
|
|
116
|
+
"console_user": snapshot.console_user,
|
|
117
|
+
}
|