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,241 @@
|
|
|
1
|
+
"""The sync merge pass: gather every endpoint's cookies, union newest-wins, idempotently apply.
|
|
2
|
+
|
|
3
|
+
``converge`` runs one merge pass for a single tracked browser group. It decrypts this
|
|
4
|
+
host's cookies (via the cached Safe Storage key — never prompting here), pulls each peer's
|
|
5
|
+
decrypted cookies over ssh, merges with the pure union newest-wins rule, then writes the
|
|
6
|
+
merged set back to any endpoint whose rows differ — preserving the winning
|
|
7
|
+
``last_update_utc`` and recording the applied digest with the watch engine *before* the
|
|
8
|
+
write, so the induced filesystem event is recognized as a self-echo and skipped.
|
|
9
|
+
|
|
10
|
+
Cookie ``last_update_utc`` is absolute Chrome time (microseconds since 1601 UTC) and is
|
|
11
|
+
host-independent, so a raw newest-wins comparison is convergent across NTP-synced tailnet
|
|
12
|
+
machines without any clock-skew correction. The merge preserves each winner's original
|
|
13
|
+
``last_update_utc`` on every host, so the anti-echo digest the watch engine records matches
|
|
14
|
+
the store's fingerprint after the write.
|
|
15
|
+
|
|
16
|
+
``reconcile`` is the time-based backup: ``converge`` over every tracked browser group.
|
|
17
|
+
|
|
18
|
+
This host and every peer are reached through the one uniform :class:`Source` seam
|
|
19
|
+
(``extract``/``apply``), so the merge logic runs in unit tests against fakes — with the
|
|
20
|
+
sources injected — without ssh or a real cookie store.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import TYPE_CHECKING, Protocol
|
|
28
|
+
|
|
29
|
+
from cookiesync.cookie import merge
|
|
30
|
+
from cookiesync.daemon.engine import logical_digest
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
34
|
+
|
|
35
|
+
from cookiesync.cookie.browsers import Browser
|
|
36
|
+
from cookiesync.cookie.models import Cookie
|
|
37
|
+
from cookiesync.daemon.cache import KeyCache
|
|
38
|
+
from cookiesync.state import BrowserEndpoint, BrowserId, SshTarget
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class NeedsAuth(Exception):
|
|
42
|
+
"""No cached Safe Storage key for the endpoint's browser; a prompt is required first.
|
|
43
|
+
|
|
44
|
+
Raised when ``converge`` finds the local key cache cold. ``converge`` never prompts —
|
|
45
|
+
the caller obtains consent and seeds the cache, then retries.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Engine(Protocol):
|
|
50
|
+
"""The watch engine's anti-echo seam: record an endpoint's applied digest before writing.
|
|
51
|
+
|
|
52
|
+
Recording the digest first means the filesystem event the write induces is recognized
|
|
53
|
+
as this daemon's own and suppressed, rather than re-triggering a sync.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def record_applied(self, endpoint_id: str, digest: str) -> None: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class Extracted:
|
|
61
|
+
"""A source's decrypted cookies for the requested browser profile."""
|
|
62
|
+
|
|
63
|
+
cookies: tuple[Cookie, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Source(Protocol):
|
|
67
|
+
"""One endpoint's cookie store, reached the same way whether it is local or a peer.
|
|
68
|
+
|
|
69
|
+
Both the in-process local source and the ssh-backed peer satisfy this seam: ``extract``
|
|
70
|
+
returns the decrypted cookies, and ``apply`` writes a merged set back. The Safe Storage
|
|
71
|
+
key never crosses this boundary — the source decrypts and re-encrypts in its own session.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
async def extract(self, browser: BrowserId, profile: str) -> Extracted: ...
|
|
75
|
+
|
|
76
|
+
async def apply(self, browser: BrowserId, profile: str, cookies: Sequence[Cookie]) -> int: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Gathered:
|
|
81
|
+
"""One endpoint's decrypted cookies for the group, with the source that yielded them."""
|
|
82
|
+
|
|
83
|
+
endpoint: BrowserEndpoint
|
|
84
|
+
source: Source
|
|
85
|
+
cookies: tuple[Cookie, ...]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def target_row(cookie: Cookie) -> tuple:
|
|
89
|
+
"""The full logical row used to decide whether an endpoint already holds a cookie.
|
|
90
|
+
|
|
91
|
+
Covers every value-bearing field, so an idempotent apply skips only when the endpoint's
|
|
92
|
+
stored row matches the winner exactly — including its preserved ``last_update_utc``.
|
|
93
|
+
"""
|
|
94
|
+
return (
|
|
95
|
+
cookie.host_key,
|
|
96
|
+
cookie.name,
|
|
97
|
+
cookie.value,
|
|
98
|
+
cookie.path,
|
|
99
|
+
int(cookie.expires_utc),
|
|
100
|
+
int(cookie.last_update_utc),
|
|
101
|
+
cookie.is_secure,
|
|
102
|
+
cookie.is_httponly,
|
|
103
|
+
cookie.samesite,
|
|
104
|
+
cookie.source_scheme,
|
|
105
|
+
cookie.source_port,
|
|
106
|
+
cookie.top_frame_site_key,
|
|
107
|
+
cookie.has_cross_site_ancestor,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def row_set(cookies: Iterable[Cookie]) -> frozenset[tuple]:
|
|
112
|
+
return frozenset(target_row(c) for c in cookies)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def gather(endpoint: BrowserEndpoint, source: Source) -> Gathered:
|
|
116
|
+
extracted = await source.extract(endpoint.browser, endpoint.profile)
|
|
117
|
+
return Gathered(endpoint, source, extracted.cookies)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def apply_to(gathered: Gathered, merged: tuple[Cookie, ...], *, engine: Engine) -> bool:
|
|
121
|
+
if row_set(merged) == row_set(gathered.cookies):
|
|
122
|
+
return False
|
|
123
|
+
engine.record_applied(gathered.endpoint.id, logical_digest(merged))
|
|
124
|
+
await gathered.source.apply(gathered.endpoint.browser, gathered.endpoint.profile, merged)
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
async def converge(
|
|
129
|
+
endpoint: BrowserEndpoint,
|
|
130
|
+
peers: Sequence[BrowserEndpoint],
|
|
131
|
+
*,
|
|
132
|
+
origin: SshTarget | None = None,
|
|
133
|
+
self_target: SshTarget,
|
|
134
|
+
cache: KeyCache,
|
|
135
|
+
engine: Engine,
|
|
136
|
+
local_source: Source,
|
|
137
|
+
source_for: Callable[[SshTarget], Source],
|
|
138
|
+
) -> tuple[Cookie, ...]:
|
|
139
|
+
"""Merge one browser group across this host and its peers, then idempotently apply.
|
|
140
|
+
|
|
141
|
+
Gathers ``endpoint``'s decrypted cookies through ``local_source`` (the consent gate is
|
|
142
|
+
the caller's; a cold key cache raises :class:`NeedsAuth` rather than prompting) and each
|
|
143
|
+
peer's cookies through ``source_for(peer.host)``, skipping ``origin`` so a sync is never
|
|
144
|
+
echoed straight back to the host that triggered it. The union newest-wins
|
|
145
|
+
:func:`~cookiesync.cookie.merge` selects per cookie by raw ``last_update_utc`` — absolute
|
|
146
|
+
Chrome time, host-independent and convergent on NTP-synced machines — and the result is
|
|
147
|
+
written to any endpoint whose stored rows differ, preserving the winning
|
|
148
|
+
``last_update_utc`` and recording the applied digest with ``engine`` *before* the write,
|
|
149
|
+
so the induced filesystem event is suppressed. Same-machine endpoints converge through
|
|
150
|
+
``local_source`` in-process, with no ssh.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
endpoint: This host's local endpoint for the browser group.
|
|
154
|
+
peers: The other tracked endpoints for the same browser, local or remote.
|
|
155
|
+
origin: The host that triggered this sync, skipped to avoid an echo; ``None`` for a
|
|
156
|
+
time-based reconcile that touches every endpoint.
|
|
157
|
+
self_target: This host's own ssh target; endpoints on it converge in-process.
|
|
158
|
+
cache: The short-TTL key cache; a cold entry for ``endpoint`` raises
|
|
159
|
+
:class:`NeedsAuth`.
|
|
160
|
+
engine: The watch engine, told the applied digest before each write.
|
|
161
|
+
local_source: This machine's cookie source (extract/apply behind the consent gate).
|
|
162
|
+
source_for: Builds the :class:`Source` for a peer target; injected for tests.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
The merged cookie set that was reconciled across the group.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
NeedsAuth: The local key cache is cold for ``endpoint``; obtain consent and retry.
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
>>> await converge(local, peers, self_target=self, cache=cache, engine=engine,
|
|
172
|
+
... local_source=local, source_for=lambda t: SshBackend(t, origin=self))
|
|
173
|
+
"""
|
|
174
|
+
if await cache.get(endpoint.id) is None:
|
|
175
|
+
raise NeedsAuth(f"no cached key for {endpoint.id}; obtain consent before converging")
|
|
176
|
+
sources = [
|
|
177
|
+
(endpoint, local_source),
|
|
178
|
+
*(
|
|
179
|
+
(peer, local_source if peer.host == self_target else source_for(peer.host))
|
|
180
|
+
for peer in peers
|
|
181
|
+
if peer.host != origin
|
|
182
|
+
),
|
|
183
|
+
]
|
|
184
|
+
gathered = [await gather(ep, src) for ep, src in sources]
|
|
185
|
+
merged = merge(*(g.cookies for g in gathered))
|
|
186
|
+
for g in gathered:
|
|
187
|
+
await apply_to(g, merged, engine=engine)
|
|
188
|
+
return merged
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def reconcile(
|
|
192
|
+
endpoints: Sequence[BrowserEndpoint],
|
|
193
|
+
*,
|
|
194
|
+
self_target: SshTarget,
|
|
195
|
+
registry: dict[BrowserId, Browser],
|
|
196
|
+
cache: KeyCache,
|
|
197
|
+
engine: Engine,
|
|
198
|
+
local_source: Source,
|
|
199
|
+
source_for: Callable[[SshTarget], Source],
|
|
200
|
+
) -> dict[str, tuple[Cookie, ...]]:
|
|
201
|
+
"""The time-based backup: ``converge`` over every tracked browser group.
|
|
202
|
+
|
|
203
|
+
Groups ``endpoints`` by browser, anchors each group on this host's local endpoint, and
|
|
204
|
+
runs :func:`converge` with no ``origin`` so every endpoint is reconciled. A group with no
|
|
205
|
+
local endpoint on this host is skipped — there is nothing here to merge from.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
endpoints: Every tracked endpoint across all hosts and browsers.
|
|
209
|
+
self_target: This host's own ssh target.
|
|
210
|
+
registry: The browser registry, mapping each :class:`~cookiesync.state.BrowserId` to
|
|
211
|
+
its :class:`~cookiesync.cookie.browsers.Browser`; the anchored group is skipped
|
|
212
|
+
when its browser is not registered.
|
|
213
|
+
cache: The short-TTL key cache.
|
|
214
|
+
engine: The watch engine, told each applied digest before its write.
|
|
215
|
+
local_source: This machine's cookie source.
|
|
216
|
+
source_for: Builds the :class:`Source` for a peer target; injected for tests.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Each anchored endpoint's id mapped to the merged set reconciled for its group.
|
|
220
|
+
|
|
221
|
+
Example:
|
|
222
|
+
>>> await reconcile(endpoints, self_target=self, registry=REGISTRY, cache=cache,
|
|
223
|
+
... engine=engine, local_source=local, source_for=make_ssh_source)
|
|
224
|
+
"""
|
|
225
|
+
groups: dict[BrowserId, list[BrowserEndpoint]] = defaultdict(list)
|
|
226
|
+
for endpoint in endpoints:
|
|
227
|
+
groups[endpoint.browser].append(endpoint)
|
|
228
|
+
results: dict[str, tuple[Cookie, ...]] = {}
|
|
229
|
+
for browser_id, group in groups.items():
|
|
230
|
+
if browser_id not in registry or (anchor := next((e for e in group if e.host == self_target), None)) is None:
|
|
231
|
+
continue
|
|
232
|
+
results[anchor.id] = await converge(
|
|
233
|
+
anchor,
|
|
234
|
+
[e for e in group if e is not anchor],
|
|
235
|
+
self_target=self_target,
|
|
236
|
+
cache=cache,
|
|
237
|
+
engine=engine,
|
|
238
|
+
local_source=local_source,
|
|
239
|
+
source_for=source_for,
|
|
240
|
+
)
|
|
241
|
+
return results
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The newline-delimited JSON wire format the daemon RPC speaks.
|
|
2
|
+
|
|
3
|
+
One ``Request`` line in, one ``Response`` line out, then the connection closes.
|
|
4
|
+
``Cookie`` objects cross the wire as the JSON-safe dict ``dataclasses.asdict``
|
|
5
|
+
produces, since every ``Cookie`` field is a ``str``/``int``/``bool``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from cookiesync.cookie import Cookie
|
|
15
|
+
from cookiesync.cookie.models import ChromeMicros, HostKey
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class Request:
|
|
20
|
+
"""A single daemon command, encoded as one JSON line.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
>>> Request("status", {"endpoint": "host:chrome:Default"})
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
method: str
|
|
27
|
+
params: dict = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Response:
|
|
32
|
+
"""The daemon's reply to one ``Request``, encoded as one JSON line.
|
|
33
|
+
|
|
34
|
+
``error`` is set only when the request failed; ``result`` carries the handler's
|
|
35
|
+
return on success.
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> Response(ok=True, result={"applied": 3})
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
ok: bool
|
|
42
|
+
result: dict | list | None = None
|
|
43
|
+
error: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cookie_to_wire(cookie: Cookie) -> dict:
|
|
47
|
+
"""A ``Cookie`` as the JSON-safe dict that crosses the wire."""
|
|
48
|
+
return dataclasses.asdict(cookie)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cookie_from_wire(data: dict) -> Cookie:
|
|
52
|
+
"""A wire dict back into a ``Cookie``, re-branding its primitive fields."""
|
|
53
|
+
return Cookie(
|
|
54
|
+
host_key=HostKey(data["host_key"]),
|
|
55
|
+
name=data["name"],
|
|
56
|
+
value=data["value"],
|
|
57
|
+
path=data["path"],
|
|
58
|
+
expires_utc=ChromeMicros(data["expires_utc"]),
|
|
59
|
+
last_update_utc=ChromeMicros(data["last_update_utc"]),
|
|
60
|
+
creation_utc=ChromeMicros(data["creation_utc"]),
|
|
61
|
+
is_secure=data["is_secure"],
|
|
62
|
+
is_httponly=data["is_httponly"],
|
|
63
|
+
samesite=data["samesite"],
|
|
64
|
+
source_scheme=data["source_scheme"],
|
|
65
|
+
source_port=data["source_port"],
|
|
66
|
+
top_frame_site_key=data["top_frame_site_key"],
|
|
67
|
+
has_cross_site_ancestor=data["has_cross_site_ancestor"],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def encode_request(req: Request) -> bytes:
|
|
72
|
+
"""A ``Request`` as one newline-terminated JSON line."""
|
|
73
|
+
return json.dumps({"method": req.method, "params": req.params}).encode() + b"\n"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def decode_request(line: bytes) -> Request:
|
|
77
|
+
"""One JSON line back into a ``Request``."""
|
|
78
|
+
data = json.loads(line)
|
|
79
|
+
return Request(method=data["method"], params=data.get("params", {}))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def encode_response(resp: Response) -> bytes:
|
|
83
|
+
"""A ``Response`` as one newline-terminated JSON line."""
|
|
84
|
+
return json.dumps({"ok": resp.ok, "result": resp.result, "error": resp.error}).encode() + b"\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def decode_response(line: bytes) -> Response:
|
|
88
|
+
"""One JSON line back into a ``Response``."""
|
|
89
|
+
data = json.loads(line)
|
|
90
|
+
return Response(ok=data["ok"], result=data.get("result"), error=data.get("error"))
|
cookiesync/helper.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Install and classify the signed Secure-Enclave key helper ``.app``.
|
|
2
|
+
|
|
3
|
+
``cookiesync install`` installs the helper through Homebrew —
|
|
4
|
+
``brew install yasyf/tap/cookiesync-keyhelper`` against the shared central tap. Homebrew
|
|
5
|
+
downloads the release asset, verifies its checksum, and lays the stapled, notarized
|
|
6
|
+
``.app`` down in the cask appdir; cookiesync then asserts the installed bundle carries a
|
|
7
|
+
valid Developer-ID anchor before trusting it. An ad-hoc, unsigned, or wrong-anchor bundle
|
|
8
|
+
is refused — macOS 15/26 SIGKILL an ad-hoc signature at exec and refuse it the Secure
|
|
9
|
+
Enclave.
|
|
10
|
+
|
|
11
|
+
The Developer-ID anchor check is the same designated-requirement OID
|
|
12
|
+
(``1.2.840.113635.100.6.2.6``) the release workflow seals against, so a bundle that
|
|
13
|
+
``codesign --verify --strict`` would pass on its own (e.g. an ad-hoc signature) still
|
|
14
|
+
classifies as ``UNSIGNED`` here.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import anyio
|
|
25
|
+
|
|
26
|
+
from cookiesync import paths
|
|
27
|
+
|
|
28
|
+
CODESIGN = "/usr/bin/codesign"
|
|
29
|
+
DEVELOPER_ID_REQUIREMENT = "anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists"
|
|
30
|
+
BREW_CASK = "yasyf/tap/cookiesync-keyhelper"
|
|
31
|
+
PROBE_VAULT = "cookiesync-doctor-probe"
|
|
32
|
+
CONTRACT_LINE = re.compile(rb"^biometry=(?:true|false) passcode=(?:true|false) vault=(?:true|false)$", re.MULTILINE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HelperState(Enum):
|
|
36
|
+
OK = "ok"
|
|
37
|
+
UNSIGNED = "unsigned"
|
|
38
|
+
MISSING = "missing"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class HelperInstallError(Exception):
|
|
42
|
+
"""The signed key helper could not be installed or verified."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def developer_id_signed(app_path: Path) -> bool:
|
|
46
|
+
"""Whether ``app_path`` has an intact signature anchored to Apple's Developer ID.
|
|
47
|
+
|
|
48
|
+
Runs ``codesign --verify --strict`` with the Developer-ID designated-requirement
|
|
49
|
+
OID, so an ad-hoc or wrong-anchor signature (which a bare ``--verify`` would accept)
|
|
50
|
+
returns ``False``.
|
|
51
|
+
"""
|
|
52
|
+
result = await anyio.run_process(
|
|
53
|
+
[CODESIGN, "--verify", "--strict", "-R", DEVELOPER_ID_REQUIREMENT, str(app_path)],
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
return result.returncode == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def helper_state() -> HelperState:
|
|
60
|
+
"""Classify the installed key helper as ``OK`` (Developer-ID signed), ``UNSIGNED``, or ``MISSING``."""
|
|
61
|
+
if not paths.helper_binary().is_file():
|
|
62
|
+
return HelperState.MISSING
|
|
63
|
+
return HelperState.OK if await developer_id_signed(paths.helper_app_path()) else HelperState.UNSIGNED
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def supports_contract() -> bool:
|
|
67
|
+
"""Whether the installed helper honours the key-helper subcommand contract cookiesync depends on.
|
|
68
|
+
|
|
69
|
+
The cask version can lag the package version, and cookiesync resolves the helper by name,
|
|
70
|
+
so a stale or incompatible bundle may sit at the expected path. Runs the read-only
|
|
71
|
+
``vault-status`` subcommand against a fake vault — it never triggers a Touch ID prompt —
|
|
72
|
+
and passes iff the helper exits 0 and emits the documented
|
|
73
|
+
``biometry=<bool> passcode=<bool> vault=<bool>`` contract line.
|
|
74
|
+
"""
|
|
75
|
+
result = await anyio.run_process(
|
|
76
|
+
[str(paths.helper_binary()), "vault-status", PROBE_VAULT],
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
return result.returncode == 0 and CONTRACT_LINE.search(result.stdout) is not None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def install_helper() -> Path:
|
|
83
|
+
"""Install the signed Secure-Enclave key helper via Homebrew, then verify its Developer-ID anchor.
|
|
84
|
+
|
|
85
|
+
Runs ``brew install yasyf/tap/cookiesync-keyhelper`` — Homebrew downloads the release
|
|
86
|
+
asset, checksum-verifies it, and lays the stapled, notarized ``.app`` into the cask
|
|
87
|
+
appdir — then locates the installed bundle and asserts it is Developer-ID signed. Raises
|
|
88
|
+
:class:`HelperInstallError` if Homebrew is absent, the install fails, the bundle cannot
|
|
89
|
+
be located, or the anchor check fails: an unverified bundle is never trusted.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The installed ``cookiesync-keyhelper.app`` path.
|
|
93
|
+
"""
|
|
94
|
+
if (brew := shutil.which("brew")) is None:
|
|
95
|
+
raise HelperInstallError(
|
|
96
|
+
"Homebrew is required to install the signed key helper; install it from "
|
|
97
|
+
"https://brew.sh, then rerun 'cookiesync install'"
|
|
98
|
+
)
|
|
99
|
+
result = await anyio.run_process([brew, "install", "--cask", BREW_CASK], check=False)
|
|
100
|
+
if result.returncode != 0:
|
|
101
|
+
raise HelperInstallError(f"'brew install --cask {BREW_CASK}' failed: {result.stderr.decode().strip()}")
|
|
102
|
+
app_path = paths.helper_app_path()
|
|
103
|
+
if not await anyio.Path(app_path).is_dir():
|
|
104
|
+
raise HelperInstallError(
|
|
105
|
+
f"'brew install --cask {BREW_CASK}' reported success but no bundle is present at {app_path}"
|
|
106
|
+
)
|
|
107
|
+
if not await developer_id_signed(app_path):
|
|
108
|
+
raise HelperInstallError(
|
|
109
|
+
f"refusing to trust {app_path.name}: it is not Developer-ID-signed "
|
|
110
|
+
"(codesign --verify --strict and the Developer-ID anchor must both pass)"
|
|
111
|
+
)
|
|
112
|
+
return app_path
|
cookiesync/paths.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""On-disk locations for cookiesync's config dir, state file, RPC socket, reconcile lock, and signed helper.
|
|
2
|
+
|
|
3
|
+
The config dir honours ``XDG_CONFIG_HOME`` (falling back to ``~/.config``) and holds a
|
|
4
|
+
``cookiesync`` subdirectory shared by every writer of ``state.json``. The signed
|
|
5
|
+
Secure-Enclave helper ships as a Homebrew cask (``yasyf/tap/cookiesync-keyhelper``) and
|
|
6
|
+
installs its ``.app`` into the Homebrew cask appdir — ``/Applications`` by default, or
|
|
7
|
+
``~/Applications`` when brew runs without admin rights.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
CONFIG_SUBDIR = "cookiesync"
|
|
16
|
+
DATA_SUBDIR = "cookiesync"
|
|
17
|
+
STATE_FILE = "state.json"
|
|
18
|
+
SOCK_FILE = "rpc.sock"
|
|
19
|
+
LOCK_FILE = "reconcile.lock"
|
|
20
|
+
HELPER_APP = "cookiesync-keyhelper.app"
|
|
21
|
+
HELPER_EXECUTABLE = "cookiesync-keyhelper"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HelperError(Exception):
|
|
25
|
+
"""The signed Secure-Enclave helper is not installed; run ``cookiesync install`` to fetch it."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def config_dir() -> Path:
|
|
29
|
+
"""The cookiesync config directory under ``XDG_CONFIG_HOME`` or ``~/.config``."""
|
|
30
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / CONFIG_SUBDIR
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def data_dir() -> Path:
|
|
34
|
+
"""The cookiesync data directory under ``XDG_DATA_HOME`` or ``~/.local/share``."""
|
|
35
|
+
return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / DATA_SUBDIR
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cask_app_dirs() -> tuple[Path, ...]:
|
|
39
|
+
"""The Homebrew cask appdirs an ``app`` stanza may install into, most-specific first."""
|
|
40
|
+
return Path("/Applications"), Path.home() / "Applications"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def helper_app_path() -> Path:
|
|
44
|
+
"""The cask-installed ``cookiesync-keyhelper.app`` bundle path.
|
|
45
|
+
|
|
46
|
+
``brew install yasyf/tap/cookiesync-keyhelper`` moves the signed ``.app`` into the
|
|
47
|
+
Homebrew cask appdir — ``/Applications`` by default, or ``~/Applications`` when brew
|
|
48
|
+
runs without admin rights. Returns the first appdir that holds the bundle, falling back
|
|
49
|
+
to the default appdir so a not-yet-installed helper still reports a stable path.
|
|
50
|
+
"""
|
|
51
|
+
dirs = cask_app_dirs()
|
|
52
|
+
return next((app for d in dirs if (app := d / HELPER_APP).is_dir()), dirs[0] / HELPER_APP)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def helper_binary() -> Path:
|
|
56
|
+
"""The signed helper's inner executable, e.g. ``…/cookiesync-keyhelper.app/Contents/MacOS/cookiesync-keyhelper``."""
|
|
57
|
+
return helper_app_path() / "Contents" / "MacOS" / HELPER_EXECUTABLE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def require_helper() -> Path:
|
|
61
|
+
"""The signed helper executable, or raise :class:`HelperError` if it is not installed.
|
|
62
|
+
|
|
63
|
+
The Secure-Enclave key vault and key cache run inside a Developer-ID-signed,
|
|
64
|
+
notarized ``.app``; an ad-hoc build is SIGKILLed at exec by AMFI and cannot touch
|
|
65
|
+
the Enclave. Callers fail closed on a missing helper rather than degrading to an
|
|
66
|
+
unsigned fallback.
|
|
67
|
+
"""
|
|
68
|
+
if not (binary := helper_binary()).is_file():
|
|
69
|
+
raise HelperError(
|
|
70
|
+
f"cookiesync key helper not found at {binary}; run 'cookiesync install' to fetch the signed helper"
|
|
71
|
+
)
|
|
72
|
+
return binary
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def state_path() -> Path:
|
|
76
|
+
"""The ``state.json`` file holding this host's cookiesync configuration."""
|
|
77
|
+
return config_dir() / STATE_FILE
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sock_path() -> Path:
|
|
81
|
+
"""The daemon's RPC unix socket."""
|
|
82
|
+
return config_dir() / SOCK_FILE
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def lock_path() -> Path:
|
|
86
|
+
"""The reconcile lock file every cross-process ``state.json`` writer serializes on."""
|
|
87
|
+
return config_dir() / LOCK_FILE
|
cookiesync/py.typed
ADDED
|
File without changes
|
cookiesync/registry.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Bridge to the ``reposync`` host registry over its ``--json`` contract.
|
|
2
|
+
|
|
3
|
+
``reposync host ls --json`` and ``reposync self --json`` emit a versioned envelope
|
|
4
|
+
(``{"version": 1, "self": ..., "hosts": [...]}``); we pin ``version == 1`` and fail
|
|
5
|
+
loud on any drift so a contract change can never be silently mis-parsed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from subprocess import CalledProcessError
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import anyio
|
|
15
|
+
|
|
16
|
+
from cookiesync.state import SshTarget
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
|
|
21
|
+
REGISTRY_VERSION = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RegistryError(Exception):
|
|
25
|
+
"""The ``reposync`` registry could not be read or violated its ``--json`` contract."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _reposync(*args: str) -> dict:
|
|
29
|
+
cmd = f"reposync {' '.join(args)}"
|
|
30
|
+
try:
|
|
31
|
+
proc = await anyio.run_process(["reposync", *args])
|
|
32
|
+
except FileNotFoundError as exc:
|
|
33
|
+
raise RegistryError("reposync is not installed or not on PATH") from exc
|
|
34
|
+
except CalledProcessError as exc:
|
|
35
|
+
raise RegistryError(f"{cmd} failed: {exc.stderr.decode().strip() or f'exit {exc.returncode}'}") from exc
|
|
36
|
+
try:
|
|
37
|
+
payload = json.loads(proc.stdout)
|
|
38
|
+
except json.JSONDecodeError as exc:
|
|
39
|
+
raise RegistryError(f"{cmd} emitted non-JSON output") from exc
|
|
40
|
+
match payload:
|
|
41
|
+
case {"version": version, **rest} if version == REGISTRY_VERSION:
|
|
42
|
+
return rest
|
|
43
|
+
case {"version": version}:
|
|
44
|
+
raise RegistryError(f"{cmd} reported version {version}, expected {REGISTRY_VERSION}")
|
|
45
|
+
case _:
|
|
46
|
+
raise RegistryError(f"{cmd} omitted the version field")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _targets(hosts: Sequence[str]) -> tuple[SshTarget, ...]:
|
|
50
|
+
return tuple(SshTarget(host) for host in hosts)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def reposync_registry() -> tuple[SshTarget, tuple[SshTarget, ...]]:
|
|
54
|
+
"""The local target and every peer host, parsed from ``reposync host ls --json``.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A ``(self_target, peer_hosts)`` pair; ``peer_hosts`` is empty when this host
|
|
58
|
+
stands alone.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
RegistryError: The envelope is missing or pins a version other than ``1``.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> self_target, hosts = await reposync_registry()
|
|
65
|
+
"""
|
|
66
|
+
data = await _reposync("host", "ls", "--json")
|
|
67
|
+
return SshTarget(data["self"]), _targets(data["hosts"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def reposync_self() -> SshTarget:
|
|
71
|
+
"""This machine's own SSH target, parsed from ``reposync self --json``.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
RegistryError: The envelope is missing or pins a version other than ``1``.
|
|
75
|
+
|
|
76
|
+
Example:
|
|
77
|
+
>>> await reposync_self()
|
|
78
|
+
"""
|
|
79
|
+
return SshTarget((await _reposync("self", "--json"))["self"])
|