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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Schema-version-aware async I/O against Chrome's SQLite cookie store.
|
|
2
|
+
|
|
3
|
+
Reads copy the live ``Cookies`` DB (plus its ``-wal``/``-shm``/``-journal`` sidecars)
|
|
4
|
+
into a private temp dir and open the copy read-write, so a running browser is never
|
|
5
|
+
disturbed and the WAL checkpoints into the copy before we ``SELECT``. Writes go to the
|
|
6
|
+
live DB, best-effort: a short ``busy_timeout`` plus a soft-busy return on a locked DB,
|
|
7
|
+
never a forced clobber.
|
|
8
|
+
|
|
9
|
+
Chrome's cookie schema drifts across versions: v18 carries ``is_same_party`` and a
|
|
10
|
+
``UNIQUE(host_key, top_frame_site_key, name, path)`` index, while v24 drops
|
|
11
|
+
``is_same_party``, adds ``source_type`` + ``has_cross_site_ancestor``, and widens the
|
|
12
|
+
unique index to include ``has_cross_site_ancestor``, ``source_scheme``, and
|
|
13
|
+
``source_port``. Every operation introspects the actual table and unique index via
|
|
14
|
+
``PRAGMA table_info`` / ``PRAGMA index_list`` / ``PRAGMA index_info`` rather than
|
|
15
|
+
hardcoding one column set.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import shutil
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
import aiosqlite
|
|
28
|
+
|
|
29
|
+
from cookiesync.cookie.crypto import encrypt_value
|
|
30
|
+
from cookiesync.cookie.domains import cookie_applies
|
|
31
|
+
from cookiesync.cookie.models import (
|
|
32
|
+
ChromeMicros,
|
|
33
|
+
EncryptedRow,
|
|
34
|
+
Host,
|
|
35
|
+
HostKey,
|
|
36
|
+
unix_to_chrome_micros,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Sequence
|
|
41
|
+
|
|
42
|
+
from cookiesync.cookie.browsers import Browser
|
|
43
|
+
from cookiesync.cookie.models import AesKey, Cookie
|
|
44
|
+
|
|
45
|
+
SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
|
|
46
|
+
|
|
47
|
+
BUSY_TIMEOUT_MS = 250
|
|
48
|
+
|
|
49
|
+
ROW_FIELD_DEFAULTS: dict[str, object] = {
|
|
50
|
+
"encrypted_value": b"",
|
|
51
|
+
"value": "",
|
|
52
|
+
"source_scheme": 2,
|
|
53
|
+
"source_port": 443,
|
|
54
|
+
"top_frame_site_key": "",
|
|
55
|
+
"has_cross_site_ancestor": 0,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _to_micros(value: int) -> ChromeMicros:
|
|
60
|
+
return ChromeMicros(int(value))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _row_from_columns(columns: tuple[str, ...], values: tuple[object, ...]) -> EncryptedRow:
|
|
64
|
+
cells = ROW_FIELD_DEFAULTS | dict(zip(columns, values, strict=True))
|
|
65
|
+
return EncryptedRow(
|
|
66
|
+
host_key=HostKey(cells["host_key"]),
|
|
67
|
+
name=cells["name"],
|
|
68
|
+
encrypted_value=bytes(ev) if (ev := cells["encrypted_value"]) is not None else b"",
|
|
69
|
+
value=cells["value"] or "",
|
|
70
|
+
path=cells["path"],
|
|
71
|
+
expires_utc=_to_micros(cells["expires_utc"]),
|
|
72
|
+
last_update_utc=_to_micros(cells.get("last_update_utc", 0)),
|
|
73
|
+
creation_utc=_to_micros(cells["creation_utc"]),
|
|
74
|
+
is_secure=bool(cells["is_secure"]),
|
|
75
|
+
is_httponly=bool(cells["is_httponly"]),
|
|
76
|
+
samesite=int(cells["samesite"]),
|
|
77
|
+
source_scheme=int(cells["source_scheme"]),
|
|
78
|
+
source_port=int(cells["source_port"]),
|
|
79
|
+
top_frame_site_key=str(cells["top_frame_site_key"]),
|
|
80
|
+
has_cross_site_ancestor=int(cells["has_cross_site_ancestor"]),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _table_columns(db: aiosqlite.Connection) -> tuple[str, ...]:
|
|
85
|
+
async with db.execute("PRAGMA table_info(cookies)") as cur:
|
|
86
|
+
return tuple(row[1] for row in await cur.fetchall())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _unique_index_columns(db: aiosqlite.Connection) -> tuple[str, ...]:
|
|
90
|
+
async with db.execute("PRAGMA index_list(cookies)") as cur:
|
|
91
|
+
indexes = await cur.fetchall()
|
|
92
|
+
name = next(idx[1] for idx in indexes if idx[2] and not idx[4])
|
|
93
|
+
async with db.execute(f"PRAGMA index_info({name})") as cur:
|
|
94
|
+
return tuple(col[2] for col in await cur.fetchall())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _copy_with_sidecars(db: Path, dest_dir: Path) -> Path:
|
|
98
|
+
copy = dest_dir / "Cookies"
|
|
99
|
+
shutil.copy2(db, copy)
|
|
100
|
+
for suffix in SIDECAR_SUFFIXES:
|
|
101
|
+
if (side := Path(f"{db}{suffix}")).is_file():
|
|
102
|
+
shutil.copy2(side, f"{copy}{suffix}")
|
|
103
|
+
return copy
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def read_rows(browser: Browser, profile: str) -> tuple[EncryptedRow, ...]:
|
|
107
|
+
"""Every cookie row in ``profile`` as raw ``EncryptedRow``s, read off a private copy.
|
|
108
|
+
|
|
109
|
+
The live ``Cookies`` DB and its WAL/journal sidecars are copied to a temp dir and the
|
|
110
|
+
copy is opened read-write so SQLite checkpoints the WAL before the read. Only columns
|
|
111
|
+
present in this store's schema are selected; absent columns fall back to their defaults.
|
|
112
|
+
"""
|
|
113
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="cookiesync-"))
|
|
114
|
+
try:
|
|
115
|
+
copy = _copy_with_sidecars(browser.cookies_db(profile), tmpdir)
|
|
116
|
+
async with aiosqlite.connect(copy) as db:
|
|
117
|
+
columns = await _table_columns(db)
|
|
118
|
+
async with db.execute(f"SELECT {', '.join(columns)} FROM cookies") as cur:
|
|
119
|
+
rows = await cur.fetchall()
|
|
120
|
+
return tuple(_row_from_columns(columns, tuple(values)) for values in rows)
|
|
121
|
+
finally:
|
|
122
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def list_profile_dirs(browser: Browser) -> tuple[str, ...]:
|
|
126
|
+
"""Profile directory names under ``browser`` that hold a ``Cookies`` DB, sorted."""
|
|
127
|
+
root = browser.data_root
|
|
128
|
+
return tuple(sorted(c.name for c in root.iterdir() if c.is_dir() and browser.cookies_db(c.name).is_file()))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def count_applicable(browser: Browser, profile: str, host: Host) -> int:
|
|
132
|
+
"""How many cookies in ``profile`` a browser would send to ``host`` (no decryption)."""
|
|
133
|
+
return sum(cookie_applies(row.host_key, host) for row in await read_rows(browser, profile))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def profile_info(browser: Browser) -> dict[str, dict[str, str]]:
|
|
137
|
+
"""Map each profile directory name to its ``{email, name}`` from ``Local State``.
|
|
138
|
+
|
|
139
|
+
Read from the browser's ``Local State`` JSON under ``profile.info_cache``: ``email``
|
|
140
|
+
comes from ``user_name`` and ``name`` from ``gaia_name`` (falling back to ``name``).
|
|
141
|
+
"""
|
|
142
|
+
cache = json.loads(browser.local_state().read_text()).get("profile", {}).get("info_cache", {})
|
|
143
|
+
return {
|
|
144
|
+
name: {
|
|
145
|
+
"email": info.get("user_name", ""),
|
|
146
|
+
"name": info.get("gaia_name", "") or info.get("name", ""),
|
|
147
|
+
}
|
|
148
|
+
for name, info in cache.items()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _insert_values(cookie: Cookie, encrypted: bytes) -> dict[str, object]:
|
|
153
|
+
creation = cookie.creation_utc if cookie.creation_utc > 0 else unix_to_chrome_micros(time.time())
|
|
154
|
+
has_expires = int(cookie.expires_utc > 0)
|
|
155
|
+
return {
|
|
156
|
+
"creation_utc": int(creation),
|
|
157
|
+
"host_key": cookie.host_key,
|
|
158
|
+
"top_frame_site_key": cookie.top_frame_site_key,
|
|
159
|
+
"name": cookie.name,
|
|
160
|
+
"value": "",
|
|
161
|
+
"encrypted_value": encrypted,
|
|
162
|
+
"path": cookie.path,
|
|
163
|
+
"expires_utc": int(cookie.expires_utc),
|
|
164
|
+
"is_secure": int(cookie.is_secure),
|
|
165
|
+
"is_httponly": int(cookie.is_httponly),
|
|
166
|
+
"last_access_utc": int(cookie.last_update_utc),
|
|
167
|
+
"has_expires": has_expires,
|
|
168
|
+
"is_persistent": has_expires,
|
|
169
|
+
"priority": 1,
|
|
170
|
+
"samesite": cookie.samesite,
|
|
171
|
+
"source_scheme": cookie.source_scheme,
|
|
172
|
+
"source_port": cookie.source_port,
|
|
173
|
+
"last_update_utc": int(cookie.last_update_utc),
|
|
174
|
+
"source_type": 0,
|
|
175
|
+
"has_cross_site_ancestor": cookie.has_cross_site_ancestor,
|
|
176
|
+
"is_same_party": 0,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _upsert_sql(columns: tuple[str, ...], conflict: tuple[str, ...]) -> str:
|
|
181
|
+
cols = ", ".join(columns)
|
|
182
|
+
placeholders = ", ".join(f":{c}" for c in columns)
|
|
183
|
+
updates = ", ".join(
|
|
184
|
+
f"{c} = excluded.{c}"
|
|
185
|
+
for c in ("encrypted_value", "value", "expires_utc", "last_update_utc", "is_secure", "is_httponly", "samesite")
|
|
186
|
+
if c in columns
|
|
187
|
+
)
|
|
188
|
+
return (
|
|
189
|
+
f"INSERT INTO cookies ({cols}) VALUES ({placeholders}) "
|
|
190
|
+
f"ON CONFLICT({', '.join(conflict)}) DO UPDATE SET {updates}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def write_rows(browser: Browser, profile: str, cookies: Sequence[Cookie], key: AesKey) -> int:
|
|
195
|
+
"""Encrypt and upsert ``cookies`` into ``profile``'s live ``Cookies`` DB; return rows written.
|
|
196
|
+
|
|
197
|
+
Each value is re-encrypted into a ``v10`` blob (the plaintext ``value`` column is left
|
|
198
|
+
empty) and written with ``INSERT ... ON CONFLICT(<this store's real unique index>) DO
|
|
199
|
+
UPDATE``, so a re-synced cookie collapses onto its existing row. The cookie's own
|
|
200
|
+
``last_update_utc`` and ``creation_utc`` are preserved, never stamped to "now". On a
|
|
201
|
+
locked database this returns ``-1`` (soft busy) rather than forcing a write.
|
|
202
|
+
"""
|
|
203
|
+
db_path = browser.cookies_db(profile)
|
|
204
|
+
async with aiosqlite.connect(db_path) as db:
|
|
205
|
+
await db.execute(f"PRAGMA busy_timeout = {BUSY_TIMEOUT_MS}")
|
|
206
|
+
columns = await _table_columns(db)
|
|
207
|
+
conflict = await _unique_index_columns(db)
|
|
208
|
+
sql = _upsert_sql(columns, conflict)
|
|
209
|
+
try:
|
|
210
|
+
for cookie in cookies:
|
|
211
|
+
values = _insert_values(cookie, encrypt_value(cookie.value, key, cookie.host_key))
|
|
212
|
+
await db.execute(sql, {c: values[c] for c in columns})
|
|
213
|
+
await db.commit()
|
|
214
|
+
except aiosqlite.OperationalError as exc:
|
|
215
|
+
if "locked" not in str(exc):
|
|
216
|
+
raise
|
|
217
|
+
return -1
|
|
218
|
+
return len(cookies)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The cookiesync sync daemon: active-session detection, the watch/sync loops, and the unix-socket RPC.
|
|
2
|
+
|
|
3
|
+
Public surface re-exported here is what the CLI and RPC layers call; everything else
|
|
4
|
+
in the package stays internal.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from cookiesync.daemon.rpc import Dispatcher, RpcError, call, serve
|
|
10
|
+
from cookiesync.daemon.server import AuthRequired, CachedKeySource, Daemon
|
|
11
|
+
from cookiesync.daemon.session import has_active_session, session_summary
|
|
12
|
+
from cookiesync.daemon.sync import NeedsAuth, converge, reconcile
|
|
13
|
+
from cookiesync.daemon.wire import Request, Response, cookie_from_wire, cookie_to_wire
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The ssh-backed peer source: drive a remote host's daemon to read and write its cookies.
|
|
2
|
+
|
|
3
|
+
A peer's cookies never leave its machine encrypted — decryption happens *remotely*, in the
|
|
4
|
+
remote daemon's live GUI session, so the Safe Storage key never crosses the wire.
|
|
5
|
+
``SshBackend`` is the client side of that exchange and the peer half of the sync
|
|
6
|
+
:class:`~cookiesync.daemon.sync.Source` seam: ``extract`` shells out to ``cookiesync rpc
|
|
7
|
+
extract`` on the peer and parses the wire cookie records it streams back; ``apply`` pipes
|
|
8
|
+
the merged wire payload to ``cookiesync rpc apply`` on the peer's stdin. The remote ``rpc``
|
|
9
|
+
command is wired by the integrator; this module owns the client and the payload shape.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from cookiesync.daemon.sync import Extracted
|
|
19
|
+
from cookiesync.daemon.wire import cookie_from_wire, cookie_to_wire
|
|
20
|
+
from cookiesync.transport import shell_quote, ssh
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
|
|
25
|
+
from cookiesync.cookie.models import Cookie
|
|
26
|
+
from cookiesync.state import BrowserId, SshTarget
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class SshBackend:
|
|
31
|
+
"""A peer host's cookie store, reached by driving its daemon over ssh.
|
|
32
|
+
|
|
33
|
+
The peer decrypts in its own GUI session, so cookies cross the wire already decrypted
|
|
34
|
+
and the peer's Safe Storage key never leaves its machine. ``origin`` is this host's own
|
|
35
|
+
target, forwarded on every call so the peer's daemon can suppress the echo back to us.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> SshBackend(SshTarget("me@laptop"), origin=self_target)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
target: SshTarget
|
|
42
|
+
origin: SshTarget
|
|
43
|
+
|
|
44
|
+
async def extract(self, browser: BrowserId, profile: str) -> Extracted:
|
|
45
|
+
"""Extract the peer's decrypted cookies for ``browser``/``profile``."""
|
|
46
|
+
payload = json.loads(
|
|
47
|
+
await ssh(
|
|
48
|
+
self.target,
|
|
49
|
+
"cookiesync rpc extract"
|
|
50
|
+
f" --browser {shell_quote(browser)} --profile {shell_quote(profile)}"
|
|
51
|
+
f" --origin {shell_quote(self.origin)}",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
return Extracted(tuple(cookie_from_wire(c) for c in payload["cookies"]))
|
|
55
|
+
|
|
56
|
+
async def apply(self, browser: BrowserId, profile: str, cookies: Sequence[Cookie]) -> int:
|
|
57
|
+
"""Apply the merged ``cookies`` to the peer's ``browser``/``profile`` store, returning rows written.
|
|
58
|
+
|
|
59
|
+
The merged set is piped as a JSON array of wire records to the peer's
|
|
60
|
+
``cookiesync rpc apply`` stdin; the peer re-encrypts with its own key.
|
|
61
|
+
"""
|
|
62
|
+
return json.loads(
|
|
63
|
+
await ssh(
|
|
64
|
+
self.target,
|
|
65
|
+
"cookiesync rpc apply"
|
|
66
|
+
f" --browser {shell_quote(browser)} --profile {shell_quote(profile)}"
|
|
67
|
+
f" --origin {shell_quote(self.origin)}",
|
|
68
|
+
stdin=json.dumps([cookie_to_wire(c) for c in cookies]).encode(),
|
|
69
|
+
)
|
|
70
|
+
)["applied"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Short-TTL, Secure-Enclave-wrapped cache for derived AES keys.
|
|
2
|
+
|
|
3
|
+
The sync daemon derives a browser's Safe Storage key behind one Touch ID tap, then
|
|
4
|
+
reuses it for a brief window so a burst of operations needs only a single prompt. The
|
|
5
|
+
plaintext key lives in process memory for that window, but the AT-REST cache bytes are
|
|
6
|
+
Secure-Enclave-wrapped: a leaked cache blob or a core dump is useless off-box, since
|
|
7
|
+
only the live per-boot Enclave key can unwrap it.
|
|
8
|
+
|
|
9
|
+
:class:`SecureEnclaveWrapper` drives the installed, Developer-ID-signed
|
|
10
|
+
``cookiesync-keyhelper.app`` (``cache-newkey`` / ``cache-wrap`` / ``cache-unwrap`` /
|
|
11
|
+
``cache-dropkey``). An ad-hoc helper is refused the Enclave, so a missing helper fails
|
|
12
|
+
closed — see :func:`cookiesync.paths.require_helper`. Tests inject a :class:`Wrapper`
|
|
13
|
+
double and a clock, so the cache logic is exercised without any macOS API.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import secrets
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Protocol
|
|
24
|
+
|
|
25
|
+
import anyio
|
|
26
|
+
|
|
27
|
+
from cookiesync import paths
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Wrapper(Protocol):
|
|
31
|
+
"""Wraps and unwraps key bytes so the at-rest cache value is opaque off-box."""
|
|
32
|
+
|
|
33
|
+
async def wrap(self, plaintext: bytes) -> bytes: ...
|
|
34
|
+
|
|
35
|
+
async def unwrap(self, blob: bytes) -> bytes: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class SecureEnclaveWrapper:
|
|
40
|
+
"""Wraps key bytes against a per-boot ephemeral Secure-Enclave P-256 key.
|
|
41
|
+
|
|
42
|
+
The Enclave key is created in :meth:`open` (one random label per process) and
|
|
43
|
+
destroyed in :meth:`close`, so wrapped blobs are unrecoverable after the daemon
|
|
44
|
+
exits or the machine reboots.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
>>> wrapper = await SecureEnclaveWrapper.open()
|
|
48
|
+
>>> blob = await wrapper.wrap(key)
|
|
49
|
+
>>> assert await wrapper.unwrap(blob) == key
|
|
50
|
+
>>> await wrapper.close()
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
helper: Path
|
|
54
|
+
label: str = field(default_factory=lambda: secrets.token_hex(8))
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
async def open(cls) -> SecureEnclaveWrapper:
|
|
58
|
+
wrapper = cls(paths.require_helper())
|
|
59
|
+
await anyio.run_process([str(wrapper.helper), "cache-newkey", wrapper.label])
|
|
60
|
+
return wrapper
|
|
61
|
+
|
|
62
|
+
async def wrap(self, plaintext: bytes) -> bytes:
|
|
63
|
+
return (await anyio.run_process([str(self.helper), "cache-wrap", self.label], input=plaintext)).stdout
|
|
64
|
+
|
|
65
|
+
async def unwrap(self, blob: bytes) -> bytes:
|
|
66
|
+
return (await anyio.run_process([str(self.helper), "cache-unwrap", self.label], input=blob)).stdout
|
|
67
|
+
|
|
68
|
+
async def close(self) -> None:
|
|
69
|
+
await anyio.run_process([str(self.helper), "cache-dropkey", self.label])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class Entry:
|
|
74
|
+
blob: bytes
|
|
75
|
+
expires_at: float
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class KeyCache:
|
|
80
|
+
"""A short-TTL cache of derived AES keys, each stored only as a wrapped blob.
|
|
81
|
+
|
|
82
|
+
``put`` wraps a key and records its expiry; ``get`` unwraps transiently and returns
|
|
83
|
+
``None`` once the entry is expired or evicted. The plaintext is never persisted to
|
|
84
|
+
disk and never logged. The clock is injectable for tests.
|
|
85
|
+
|
|
86
|
+
Example:
|
|
87
|
+
>>> cache = KeyCache(await SecureEnclaveWrapper.open())
|
|
88
|
+
>>> await cache.put(endpoint.id, key, ttl=30.0)
|
|
89
|
+
>>> assert await cache.get(endpoint.id) == key
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
wrapper: Wrapper
|
|
93
|
+
now: Callable[[], float] = time.monotonic
|
|
94
|
+
entries: dict[str, Entry] = field(default_factory=dict)
|
|
95
|
+
|
|
96
|
+
async def put(self, endpoint_id: str, key: bytes, ttl: float) -> None:
|
|
97
|
+
self.entries[endpoint_id] = Entry(await self.wrapper.wrap(key), self.now() + ttl)
|
|
98
|
+
|
|
99
|
+
async def get(self, endpoint_id: str) -> bytes | None:
|
|
100
|
+
match self.entries.get(endpoint_id):
|
|
101
|
+
case None:
|
|
102
|
+
return None
|
|
103
|
+
case Entry(expires_at=expires_at) if self.now() >= expires_at:
|
|
104
|
+
del self.entries[endpoint_id]
|
|
105
|
+
return None
|
|
106
|
+
case Entry(blob=blob):
|
|
107
|
+
return await self.wrapper.unwrap(blob)
|
|
108
|
+
|
|
109
|
+
def evict(self, endpoint_id: str) -> None:
|
|
110
|
+
self.entries.pop(endpoint_id, None)
|
|
111
|
+
|
|
112
|
+
def evict_all(self) -> None:
|
|
113
|
+
self.entries.clear()
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The watch engine: debounce a cookie-store write burst into one converge, with anti-echo.
|
|
2
|
+
|
|
3
|
+
A Chrome/Arc cookie write lands as a burst across the ``Cookies`` DB and its ``-wal``/
|
|
4
|
+
``-shm`` sidecars. The engine coalesces that burst, per endpoint, into a single
|
|
5
|
+
``evaluate`` once the store has been quiet for ``settings.watch_debounce``, then fires the
|
|
6
|
+
injected ``notify`` callback so the sync layer converges that endpoint with its peers.
|
|
7
|
+
|
|
8
|
+
Two filters keep the engine from chasing its own tail:
|
|
9
|
+
|
|
10
|
+
* **Anti-echo.** ``evaluate`` fingerprints the endpoint's *logical* cookie set via
|
|
11
|
+
:func:`logical_digest` — sorted ``(host_key, name, path, last_update_utc)`` tuples, cheap
|
|
12
|
+
and decryption-free — and compares it to the last digest the engine acted on. The sync
|
|
13
|
+
layer records the digest of the very set it is about to write via :meth:`record_applied`
|
|
14
|
+
just before writing; because the store preserves each cookie's ``last_update_utc``, the
|
|
15
|
+
self-induced write reproduces that same digest and is recognized as a no-op. Only a
|
|
16
|
+
genuinely new digest is recorded (*before* notifying) and notified.
|
|
17
|
+
* **Idle gate.** A store whose ``Cookies`` wall-clock mtime is within
|
|
18
|
+
``settings.idle_threshold`` is a browser actively writing; the engine debounces but does
|
|
19
|
+
not notify until it settles.
|
|
20
|
+
|
|
21
|
+
The watcher source, clocks, mtime reader, digest function, and notify callback are all
|
|
22
|
+
injected, so the debounce/anti-echo/idle logic runs in unit tests with no real filesystem,
|
|
23
|
+
browser, or macOS API.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import TYPE_CHECKING, NewType, Protocol
|
|
33
|
+
|
|
34
|
+
import anyio
|
|
35
|
+
|
|
36
|
+
from cookiesync.cookie import REGISTRY
|
|
37
|
+
from cookiesync.cookie.browsers import BrowserName
|
|
38
|
+
from cookiesync.cookie.stores import read_rows
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from cookiesync.cookie.browsers import Browser
|
|
42
|
+
from cookiesync.state import BrowserEndpoint, Settings
|
|
43
|
+
|
|
44
|
+
Digest = NewType("Digest", str)
|
|
45
|
+
|
|
46
|
+
type AwatchSource = Callable[[BrowserEndpoint], AsyncIterator[object]]
|
|
47
|
+
type Notify = Callable[[BrowserEndpoint], Awaitable[None]]
|
|
48
|
+
type DigestFn = Callable[[BrowserEndpoint], Awaitable[Digest]]
|
|
49
|
+
type Mtime = Callable[[BrowserEndpoint], Awaitable[float]]
|
|
50
|
+
type Sleep = Callable[[float], Awaitable[None]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LogicalRow(Protocol):
|
|
54
|
+
"""The fields :func:`logical_digest` keys on; satisfied by both ``Cookie`` and ``EncryptedRow``."""
|
|
55
|
+
|
|
56
|
+
host_key: str
|
|
57
|
+
name: str
|
|
58
|
+
path: str
|
|
59
|
+
last_update_utc: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def logical_digest(items: Iterable[LogicalRow]) -> Digest:
|
|
63
|
+
"""A cheap, decryption-free digest of a cookie set's logical identity.
|
|
64
|
+
|
|
65
|
+
Hashes the sorted ``(host_key, name, path, last_update_utc)`` tuples of every item. It
|
|
66
|
+
ignores encrypted values and schema-only columns, so it changes exactly when the logical
|
|
67
|
+
cookie set does. The sync layer digests the set it is about to write and the engine
|
|
68
|
+
fingerprints the store after the write; because ``last_update_utc`` is preserved on write,
|
|
69
|
+
the two agree and the induced filesystem event is recognized as the engine's own echo.
|
|
70
|
+
"""
|
|
71
|
+
payload = "\x00".join(
|
|
72
|
+
f"{item.host_key}\x1f{item.name}\x1f{item.path}\x1f{item.last_update_utc}"
|
|
73
|
+
for item in sorted(items, key=lambda i: (i.host_key, i.name, i.path, i.last_update_utc))
|
|
74
|
+
)
|
|
75
|
+
return Digest(hashlib.sha256(payload.encode("utf-8")).hexdigest())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def endpoint_browser(endpoint: BrowserEndpoint) -> Browser:
|
|
79
|
+
"""The :class:`~cookiesync.cookie.browsers.Browser` an endpoint names."""
|
|
80
|
+
return REGISTRY[BrowserName(endpoint.browser)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def watch_dir(endpoint: BrowserEndpoint) -> object:
|
|
84
|
+
"""The directory holding an endpoint's ``Cookies`` DB and its WAL/SHM sidecars."""
|
|
85
|
+
return endpoint_browser(endpoint).profile_dir(endpoint.profile)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def fingerprint(endpoint: BrowserEndpoint) -> Digest:
|
|
89
|
+
"""The :func:`logical_digest` of an endpoint's store, read off its raw rows.
|
|
90
|
+
|
|
91
|
+
Decryption-free: it reads every ``EncryptedRow`` and digests their logical identity, so
|
|
92
|
+
it changes exactly when the logical cookie set does — which is what the anti-echo
|
|
93
|
+
comparison turns on.
|
|
94
|
+
"""
|
|
95
|
+
return logical_digest(await read_rows(endpoint_browser(endpoint), endpoint.profile))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def cookies_mtime(endpoint: BrowserEndpoint) -> float:
|
|
99
|
+
"""The Unix mtime of an endpoint's live ``Cookies`` DB."""
|
|
100
|
+
return (await anyio.Path(endpoint_browser(endpoint).cookies_db(endpoint.profile)).stat()).st_mtime
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def watch_endpoint(endpoint: BrowserEndpoint) -> AsyncIterator[object]:
|
|
104
|
+
"""Yield once per ``watchfiles`` change batch on an endpoint's profile directory."""
|
|
105
|
+
from watchfiles import awatch
|
|
106
|
+
|
|
107
|
+
async for changes in awatch(watch_dir(endpoint)):
|
|
108
|
+
yield changes
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(slots=True)
|
|
112
|
+
class EndpointState:
|
|
113
|
+
deadline: float = 0.0
|
|
114
|
+
seq: int = 0
|
|
115
|
+
last_applied: Digest | None = None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(slots=True)
|
|
119
|
+
class Engine:
|
|
120
|
+
"""Debounces each local endpoint's cookie-store writes into anti-echoed converges.
|
|
121
|
+
|
|
122
|
+
``run`` opens one watcher per endpoint and, for each, coalesces a burst of filesystem
|
|
123
|
+
events into a single ``evaluate`` after ``settings.watch_debounce`` of quiet. ``evaluate``
|
|
124
|
+
fingerprints the endpoint, skips the self-induced echo and an actively-writing store,
|
|
125
|
+
and otherwise records the new digest before invoking ``notify``. The sync layer calls
|
|
126
|
+
:meth:`record_applied` right before it writes a cookie set back, so the write it triggers
|
|
127
|
+
is recognized as the engine's own echo.
|
|
128
|
+
|
|
129
|
+
``now`` is a monotonic clock for the debounce deadline; ``wall`` is wall-clock time,
|
|
130
|
+
compared against the store's wall-clock mtime for the idle gate. The watcher source,
|
|
131
|
+
both clocks, mtime reader, digest function, and notify callback are injected, so the
|
|
132
|
+
whole core runs in tests without a real store or browser.
|
|
133
|
+
|
|
134
|
+
Example:
|
|
135
|
+
>>> engine = Engine(settings, notify=converge)
|
|
136
|
+
>>> async with anyio.create_task_group() as tg:
|
|
137
|
+
... tg.start_soon(engine.run, endpoints)
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
settings: Settings
|
|
141
|
+
notify: Notify
|
|
142
|
+
watch: AwatchSource = watch_endpoint
|
|
143
|
+
digest: DigestFn = fingerprint
|
|
144
|
+
mtime: Mtime = cookies_mtime
|
|
145
|
+
now: Callable[[], float] = time.monotonic
|
|
146
|
+
wall: Callable[[], float] = time.time
|
|
147
|
+
sleep: Sleep = anyio.sleep
|
|
148
|
+
states: dict[str, EndpointState] = field(default_factory=dict)
|
|
149
|
+
|
|
150
|
+
def state(self, endpoint: BrowserEndpoint) -> EndpointState:
|
|
151
|
+
return self.states.setdefault(endpoint.id, EndpointState())
|
|
152
|
+
|
|
153
|
+
def current_digest(self, endpoint: BrowserEndpoint) -> Digest | None:
|
|
154
|
+
"""The last digest the engine acted on or the sync layer recorded for ``endpoint``."""
|
|
155
|
+
return self.state(endpoint).last_applied
|
|
156
|
+
|
|
157
|
+
def record_applied(self, endpoint_id: str, digest: Digest) -> None:
|
|
158
|
+
"""Record ``digest`` as the endpoint's applied state so the write it triggers is a no-op.
|
|
159
|
+
|
|
160
|
+
The sync layer calls this immediately before it writes a merged cookie set back to a
|
|
161
|
+
store; the watcher event that write produces then fingerprints to ``digest`` and is
|
|
162
|
+
suppressed as the engine's own echo.
|
|
163
|
+
"""
|
|
164
|
+
self.states.setdefault(endpoint_id, EndpointState()).last_applied = digest
|
|
165
|
+
|
|
166
|
+
async def run(self, endpoints: tuple[BrowserEndpoint, ...]) -> None:
|
|
167
|
+
"""Watch every endpoint concurrently until the surrounding task group is cancelled."""
|
|
168
|
+
async with anyio.create_task_group() as tg:
|
|
169
|
+
for endpoint in endpoints:
|
|
170
|
+
tg.start_soon(self.watch_loop, endpoint)
|
|
171
|
+
|
|
172
|
+
async def watch_loop(self, endpoint: BrowserEndpoint) -> None:
|
|
173
|
+
debounce = self.settings.watch_debounce.total_seconds()
|
|
174
|
+
state = self.state(endpoint)
|
|
175
|
+
async with anyio.create_task_group() as tg:
|
|
176
|
+
async for _ in self.watch(endpoint):
|
|
177
|
+
state.seq += 1
|
|
178
|
+
state.deadline = self.now() + debounce
|
|
179
|
+
tg.start_soon(self.fire_after, endpoint, state.seq)
|
|
180
|
+
|
|
181
|
+
async def fire_after(self, endpoint: BrowserEndpoint, seq: int) -> None:
|
|
182
|
+
state = self.state(endpoint)
|
|
183
|
+
await self.sleep(self.settings.watch_debounce.total_seconds())
|
|
184
|
+
if seq == state.seq and self.now() >= state.deadline:
|
|
185
|
+
await self.evaluate(endpoint)
|
|
186
|
+
|
|
187
|
+
async def evaluate(self, endpoint: BrowserEndpoint) -> None:
|
|
188
|
+
digest = await self.digest(endpoint)
|
|
189
|
+
state = self.state(endpoint)
|
|
190
|
+
if digest == state.last_applied:
|
|
191
|
+
return
|
|
192
|
+
if self.wall() - await self.mtime(endpoint) < self.settings.idle_threshold.total_seconds():
|
|
193
|
+
return
|
|
194
|
+
state.last_applied = digest
|
|
195
|
+
await self.notify(endpoint)
|