ehor 0.1.1__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.
Files changed (70) hide show
  1. ehor/__init__.py +3 -0
  2. ehor/cli.py +457 -0
  3. ehor/crypto/__init__.py +0 -0
  4. ehor/crypto/keyfile.py +56 -0
  5. ehor/crypto/sealing.py +104 -0
  6. ehor/identity/__init__.py +21 -0
  7. ehor/identity/assertion.py +56 -0
  8. ehor/identity/backends.py +54 -0
  9. ehor/identity/base.py +67 -0
  10. ehor/identity/ctap.py +142 -0
  11. ehor/identity/enclave_helper.swift +38 -0
  12. ehor/identity/es256.py +38 -0
  13. ehor/identity/secure_enclave.py +126 -0
  14. ehor/identity/security_key.py +127 -0
  15. ehor/identity/software.py +82 -0
  16. ehor/identity/tools.py +56 -0
  17. ehor/identity/tpm.py +155 -0
  18. ehor/log/__init__.py +0 -0
  19. ehor/log/authorize.py +39 -0
  20. ehor/log/body.py +173 -0
  21. ehor/log/canonical.py +57 -0
  22. ehor/log/endpoint_announce.py +34 -0
  23. ehor/log/entry.py +212 -0
  24. ehor/log/envelope.py +118 -0
  25. ehor/log/key_announce.py +34 -0
  26. ehor/log/names.py +34 -0
  27. ehor/log/roster.py +281 -0
  28. ehor/log/verify.py +137 -0
  29. ehor/mcp/__init__.py +0 -0
  30. ehor/mcp/server.py +90 -0
  31. ehor/py.typed +0 -0
  32. ehor/relay/__init__.py +0 -0
  33. ehor/relay/server.py +130 -0
  34. ehor/roster/__init__.py +0 -0
  35. ehor/roster/book.py +171 -0
  36. ehor/service/__init__.py +0 -0
  37. ehor/service/api.py +136 -0
  38. ehor/service/config.py +82 -0
  39. ehor/service/daemon.py +56 -0
  40. ehor/service/device.py +309 -0
  41. ehor/service/direct_sync.py +175 -0
  42. ehor/service/directory.py +42 -0
  43. ehor/service/endpoints.py +22 -0
  44. ehor/service/invite.py +79 -0
  45. ehor/service/iroh_key.py +71 -0
  46. ehor/service/messenger.py +488 -0
  47. ehor/service/peer_sync.py +83 -0
  48. ehor/service/relay_policy.py +73 -0
  49. ehor/service/render.py +141 -0
  50. ehor/service/roster_admin.py +284 -0
  51. ehor/service/rpc.py +159 -0
  52. ehor/service/sealing.py +105 -0
  53. ehor/store/__init__.py +0 -0
  54. ehor/store/base.py +82 -0
  55. ehor/store/ingest.py +112 -0
  56. ehor/store/local_log.py +85 -0
  57. ehor/store/sqlite.py +276 -0
  58. ehor/sync/__init__.py +0 -0
  59. ehor/sync/session.py +209 -0
  60. ehor/sync/wire.py +166 -0
  61. ehor/transport/__init__.py +0 -0
  62. ehor/transport/directory.py +76 -0
  63. ehor/transport/iroh.py +311 -0
  64. ehor/transport/lan.py +170 -0
  65. ehor/transport/relays.py +28 -0
  66. ehor-0.1.1.dist-info/METADATA +288 -0
  67. ehor-0.1.1.dist-info/RECORD +70 -0
  68. ehor-0.1.1.dist-info/WHEEL +4 -0
  69. ehor-0.1.1.dist-info/entry_points.txt +2 -0
  70. ehor-0.1.1.dist-info/licenses/LICENSE +21 -0
ehor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("ehor")
ehor/cli.py ADDED
@@ -0,0 +1,457 @@
1
+ import argparse
2
+ import asyncio
3
+ import contextlib
4
+ import getpass
5
+ import logging
6
+ import os
7
+ import signal
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+ from ehor.identity import HardwareUnavailableError, IdentityError, KeyHandle
13
+ from ehor.identity.backends import Backend
14
+ from ehor.identity.security_key import Authenticator, SecurityKeySigner
15
+ from ehor.log.entry import EntryContent, EntryFormatError
16
+ from ehor.log.names import parse_address
17
+ from ehor.log.roster import Roster, RosterError
18
+ from ehor.mcp.server import build_server
19
+ from ehor.relay.server import Relay, RelayError
20
+ from ehor.service.config import ConfigError, DeviceConfig, default_home
21
+ from ehor.service.daemon import DEFAULT_SYNC_INTERVAL, Daemon, Syncer
22
+ from ehor.service.device import Device, security_key_signer
23
+ from ehor.service.direct_sync import DirectSync
24
+ from ehor.service.invite import Invite, InviteError
25
+ from ehor.service.iroh_key import IrohKeyError
26
+ from ehor.service.peer_sync import DEFAULT_PEER_INTERVAL, PeerSync
27
+ from ehor.service.relay_policy import RelayGate, devices_to_reach
28
+ from ehor.service.rpc import RpcClient, RpcError, default_endpoint
29
+ from ehor.transport.iroh import IrohMode, IrohTransport
30
+ from ehor.transport.lan import ZeroconfDiscovery
31
+ from ehor.transport.relays import RelayDirectory
32
+
33
+ PASSPHRASE_ENV = "EHOR_PASSPHRASE" # noqa: S105 - env var name, not a secret
34
+ DEFAULT_RELAY_PORT = 7447
35
+ AUTO_KEY = "auto"
36
+ KEY_CHOICES = [AUTO_KEY, *(backend.value for backend in Backend)]
37
+ _KEY_HELP = "where the signing key lives; auto uses a hardware key if this machine has one"
38
+ _SECURITY_KEY_HELP = "label of the enrolled security key to sign with, if there are several"
39
+ _FIDO2_EXTRA = "security keys need the fido2 extra: pip install 'ehor[fido2]'"
40
+
41
+
42
+ class CliError(Exception):
43
+ pass
44
+
45
+
46
+ def main(argv: Sequence[str] | None = None) -> int:
47
+ args = _parser().parse_args(argv)
48
+ try:
49
+ return int(args.run(args))
50
+ except (CliError, ConfigError, IdentityError, IrohKeyError, RelayError, RpcError) as exc:
51
+ sys.stderr.write(f"error: {exc}\n")
52
+ return 1
53
+
54
+
55
+ def _parser() -> argparse.ArgumentParser:
56
+ parser = argparse.ArgumentParser(
57
+ prog="ehor", description="Signed messaging between Claude agents."
58
+ )
59
+ parser.add_argument("--home", type=Path, default=None, help="device folder")
60
+ commands = parser.add_subparsers(required=True, metavar="command")
61
+
62
+ init = commands.add_parser("init", help="start a new team on this device")
63
+ init.add_argument("--handle", required=True, help="your short name, e.g. gef")
64
+ init.add_argument("--team-name", default="", help="display name for the team")
65
+ init.add_argument("--exchange", type=Path, help="shared folder for swapping entries")
66
+ init.add_argument("--key", choices=KEY_CHOICES, default=AUTO_KEY, help=_KEY_HELP)
67
+ init.set_defaults(run=_init)
68
+
69
+ invite = commands.add_parser("invite", help="print this team's invite code")
70
+ invite.add_argument("--relay", help="host:port of a relay to put in the code")
71
+ invite.set_defaults(run=_invite)
72
+
73
+ join = commands.add_parser("join", help="join a team from an invite code")
74
+ join.add_argument("code", help="invite code from a teammate")
75
+ join.add_argument("--handle", required=True, help="your short name, e.g. tobi")
76
+ join.add_argument("--exchange", type=Path, help="shared folder for swapping entries")
77
+ join.add_argument("--key", choices=KEY_CHOICES, default=AUTO_KEY, help=_KEY_HELP)
78
+ join.set_defaults(run=_join)
79
+
80
+ _add_roster_commands(commands)
81
+ _add_relay_commands(commands)
82
+
83
+ serve = commands.add_parser("serve", help="run the background service")
84
+ serve.add_argument("--interval", type=float, default=DEFAULT_SYNC_INTERVAL)
85
+ serve.add_argument("--no-lan", action="store_true", help="do not sync over the local network")
86
+ serve.add_argument("--no-p2p", action="store_true", help="do not connect to teammates directly")
87
+ serve.add_argument("--lan-interval", type=float, default=DEFAULT_PEER_INTERVAL)
88
+ serve.set_defaults(run=_serve)
89
+
90
+ commands.add_parser("mcp", help="run the MCP server for Claude").set_defaults(run=_mcp)
91
+ for tool in ("digest", "inbox", "team"):
92
+ commands.add_parser(tool, help=f"show {tool}").set_defaults(run=_tool, tool=tool)
93
+ return parser
94
+
95
+
96
+ def _add_roster_commands(commands: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
97
+ roster = commands.add_parser("roster", help="show or change the team roster")
98
+ roster.set_defaults(run=_roster_show)
99
+ roster_commands = roster.add_subparsers(metavar="action")
100
+ add = roster_commands.add_parser("add", help="add a member or a device (admins only)")
101
+ add.add_argument("handle")
102
+ add.add_argument("key", help="device key printed by `ehor join`")
103
+ add.add_argument("--role", action="append", default=[], help="a role such as @mobile")
104
+ add.add_argument("--channel", action="append", default=[], help="a channel such as #mobile")
105
+ add.add_argument("--admin", action="store_true", help="allow this member to change the roster")
106
+ add.add_argument("--label", default="", help="device name shown in the roster")
107
+ add.add_argument(
108
+ "--hardware", action="store_true", help="the key is in hardware, as `join` reported"
109
+ )
110
+ add.set_defaults(run=_roster_add)
111
+ remove = roster_commands.add_parser("remove", help="remove a member (admins only)")
112
+ remove.add_argument("handle")
113
+ remove.set_defaults(run=_roster_remove)
114
+ relay_add = roster_commands.add_parser("relay-add", help="add a relay (admins only)")
115
+ relay_add.add_argument("address", help="host:port the relay listens on")
116
+ relay_add.add_argument("key", help="relay key printed by `ehor relay init`")
117
+ relay_add.set_defaults(run=_roster_relay_add)
118
+ relay_remove = roster_commands.add_parser("relay-remove", help="remove a relay (admins only)")
119
+ relay_remove.add_argument("address")
120
+ relay_remove.set_defaults(run=_roster_relay_remove)
121
+ enroll = roster_commands.add_parser(
122
+ "enroll-key",
123
+ help="sign your roster changes with a FIDO2 security key from now on (admins only)",
124
+ )
125
+ enroll.add_argument("--label", default="security-key", help="name for this key")
126
+ enroll.set_defaults(run=_roster_enroll_key)
127
+ for command in (add, remove, relay_add, relay_remove, enroll):
128
+ command.add_argument("--security-key", metavar="LABEL", help=_SECURITY_KEY_HELP)
129
+
130
+
131
+ def _add_relay_commands(commands: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
132
+ relay = commands.add_parser("relay", help="set up or run a relay for a team")
133
+ relay_commands = relay.add_subparsers(required=True, metavar="action")
134
+ relay_init = relay_commands.add_parser("init", help="create a relay from an invite code")
135
+ relay_init.add_argument("--invite", required=True, help="invite code from a team admin")
136
+ relay_init.set_defaults(run=_relay_init)
137
+ relay_serve = relay_commands.add_parser("serve", help="run the relay")
138
+ relay_serve.add_argument("--listen", default=f"0.0.0.0:{DEFAULT_RELAY_PORT}", help="host:port")
139
+ relay_serve.set_defaults(run=_relay_serve)
140
+
141
+
142
+ def _home(args: argparse.Namespace) -> Path:
143
+ home: Path | None = args.home
144
+ return home if home is not None else default_home()
145
+
146
+
147
+ def _passphrase(*, confirm: bool) -> bytes:
148
+ from_env = os.environ.get(PASSPHRASE_ENV)
149
+ if from_env:
150
+ return from_env.encode()
151
+ passphrase = getpass.getpass("Key passphrase: ")
152
+ if confirm and getpass.getpass("Repeat passphrase: ") != passphrase:
153
+ raise CliError("passphrases do not match")
154
+ if not passphrase:
155
+ raise CliError("passphrase must not be empty")
156
+ return passphrase.encode()
157
+
158
+
159
+ def _init(args: argparse.Namespace) -> int:
160
+ home = _home(args)
161
+ exchange = None if args.exchange is None else args.exchange.resolve()
162
+ with Device.create_team(
163
+ home,
164
+ args.handle,
165
+ _passphrase(confirm=True),
166
+ team_name=args.team_name,
167
+ exchange=exchange,
168
+ key_backend=_key_backend(args),
169
+ ) as device:
170
+ sys.stdout.write(
171
+ f"created team with {args.handle} as admin in {home}\n"
172
+ f"{_describe_key(device.config.key_backend)}\n"
173
+ f"invite {device.invite.encode()}\n"
174
+ "next: run `ehor serve`, then add `ehor mcp` to Claude\n"
175
+ )
176
+ return 0
177
+
178
+
179
+ def _invite(args: argparse.Namespace) -> int:
180
+ home = _home(args)
181
+ config = DeviceConfig.load(home)
182
+ try:
183
+ current = Invite.decode(asyncio.run(RpcClient(default_endpoint(home)).call("invite")))
184
+ except RpcError:
185
+ current = Invite(config.team, config.genesis, config.relay, Device.stored_endpoint_id(home))
186
+ try:
187
+ code = Invite(
188
+ current.team, current.genesis, args.relay or current.relay, current.iroh
189
+ ).encode()
190
+ except InviteError as exc:
191
+ raise CliError(str(exc)) from exc
192
+ sys.stdout.write(f"{code}\nshare this with a teammate; they run `ehor join {code}`\n")
193
+ return 0
194
+
195
+
196
+ def _join(args: argparse.Namespace) -> int:
197
+ try:
198
+ invite = Invite.decode(args.code)
199
+ except InviteError as exc:
200
+ raise CliError(str(exc)) from exc
201
+ home = _home(args)
202
+ exchange = None if args.exchange is None else args.exchange.resolve()
203
+ with Device.join(
204
+ home,
205
+ invite,
206
+ args.handle,
207
+ _passphrase(confirm=True),
208
+ exchange=exchange,
209
+ key_backend=_key_backend(args),
210
+ ) as device:
211
+ hardware = " --hardware" if device.config.key_backend.hardware else ""
212
+ sys.stdout.write(
213
+ f"joined as {args.handle} in {home}\n"
214
+ f"{_describe_key(device.config.key_backend)}\n"
215
+ "ask an admin to run:\n"
216
+ f" ehor roster add {args.handle} {device.author.hex()}{hardware}\n"
217
+ )
218
+ return 0
219
+
220
+
221
+ def _key_backend(args: argparse.Namespace) -> Backend | None:
222
+ return None if args.key == AUTO_KEY else Backend(args.key)
223
+
224
+
225
+ def _describe_key(backend: Backend) -> str:
226
+ if backend.hardware:
227
+ return f"signing key {backend} (hardware, cannot be copied off this machine)"
228
+ return f"signing key {backend} (passphrase-protected file)"
229
+
230
+
231
+ def _roster_show(args: argparse.Namespace) -> int:
232
+ return _call(args, "roster.show", {})
233
+
234
+
235
+ def _roster_add(args: argparse.Namespace) -> int:
236
+ params: dict[str, object] = {
237
+ "handle": args.handle,
238
+ "key": args.key,
239
+ "roles": args.role,
240
+ "channels": args.channel,
241
+ "admin": args.admin,
242
+ "label": args.label,
243
+ "hardware": args.hardware,
244
+ }
245
+ return _change_roster(args, "roster.add", params)
246
+
247
+
248
+ def _roster_remove(args: argparse.Namespace) -> int:
249
+ return _change_roster(args, "roster.remove", {"handle": args.handle})
250
+
251
+
252
+ def _roster_relay_add(args: argparse.Namespace) -> int:
253
+ return _change_roster(args, "roster.relay_add", {"address": args.address, "key": args.key})
254
+
255
+
256
+ def _roster_relay_remove(args: argparse.Namespace) -> int:
257
+ return _change_roster(args, "roster.relay_remove", {"address": args.address})
258
+
259
+
260
+ def _roster_enroll_key(args: argparse.Namespace) -> int:
261
+ home = _home(args)
262
+ keys = security_key_signer(home, _connect_security_key)
263
+ enrolled = _enrolled_key(keys, args.security_key)
264
+ sys.stderr.write("touch the new security key to create its ehor credential\n")
265
+ handle = keys.create_key(args.label)
266
+ params: dict[str, object] = {"key": keys.public_key(handle).hex(), "label": args.label}
267
+ try:
268
+ if enrolled is None:
269
+ result = _rpc(home, "roster.enroll", params)
270
+ else:
271
+ _ask(
272
+ f"now plug in only your enrolled security key {enrolled.key_id!r}, then press Enter"
273
+ )
274
+ signer = security_key_signer(home, _connect_security_key)
275
+ result = _sign_with_security_key(home, signer, enrolled, "roster.enroll", params)
276
+ except BaseException:
277
+ keys.forget(handle)
278
+ raise
279
+ sys.stdout.write(f"{result}\nroster changes by you now need a touch of {args.label!r}\n")
280
+ return 0
281
+
282
+
283
+ def _change_roster(args: argparse.Namespace, method: str, params: dict[str, object]) -> int:
284
+ """Roster changes go through an enrolled security key when this device has one."""
285
+ home = _home(args)
286
+ keys = security_key_signer(home, _connect_security_key)
287
+ enrolled = _enrolled_key(keys, args.security_key)
288
+ if enrolled is None:
289
+ sys.stdout.write(_rpc(home, method, params) + "\n")
290
+ return 0
291
+ sys.stdout.write(_sign_with_security_key(home, keys, enrolled, method, params) + "\n")
292
+ return 0
293
+
294
+
295
+ def _enrolled_key(keys: SecurityKeySigner, label: str | None) -> KeyHandle | None:
296
+ labels = keys.labels()
297
+ if label is not None:
298
+ if label not in labels:
299
+ raise CliError(
300
+ f"no security key named {label!r}; enrolled: {', '.join(labels) or 'none'}"
301
+ )
302
+ return KeyHandle(SecurityKeySigner.backend, label)
303
+ if not labels:
304
+ return None
305
+ if len(labels) > 1:
306
+ raise CliError(f"pick a security key with --security-key: {', '.join(labels)}")
307
+ return KeyHandle(SecurityKeySigner.backend, labels[0])
308
+
309
+
310
+ def _sign_with_security_key(
311
+ home: Path, keys: SecurityKeySigner, handle: KeyHandle, method: str, params: dict[str, object]
312
+ ) -> str:
313
+ """Have the service draft the change, sign it with a touch here, and hand it back."""
314
+ draft = _rpc(home, method, {**params, "security_key": keys.public_key(handle).hex()})
315
+ try:
316
+ content = EntryContent.decode(bytes.fromhex(draft))
317
+ version = Roster.decode(content.body).version
318
+ except (ValueError, EntryFormatError, RosterError) as exc:
319
+ raise CliError(f"the service sent an unusable draft: {exc}") from exc
320
+ sys.stderr.write(f"touch your security key {handle.key_id!r} to sign roster v{version}\n")
321
+ assertion = keys.sign_assertion(handle, content.signing_input)
322
+ submit: dict[str, object] = {
323
+ "content": draft,
324
+ "auth_data": assertion.auth_data.hex(),
325
+ "signature": assertion.signature.hex(),
326
+ }
327
+ return _rpc(home, "roster.submit", submit)
328
+
329
+
330
+ def _connect_security_key() -> Authenticator:
331
+ try:
332
+ from ehor.identity.ctap import Ctap2Authenticator # noqa: PLC0415 - fido2 is an optional extra
333
+ except ImportError as exc:
334
+ raise HardwareUnavailableError(_FIDO2_EXTRA) from exc
335
+ return Ctap2Authenticator.connect(pin=lambda: getpass.getpass("Security key PIN: "))
336
+
337
+
338
+ def _ask(prompt: str) -> None:
339
+ sys.stderr.write(f"{prompt}\n")
340
+ sys.stdin.readline()
341
+
342
+
343
+ def _relay_home(args: argparse.Namespace) -> Path:
344
+ home: Path | None = args.home
345
+ return home if home is not None else Path.home() / ".ehor-relay"
346
+
347
+
348
+ def _relay_init(args: argparse.Namespace) -> int:
349
+ try:
350
+ invite = Invite.decode(args.invite)
351
+ except InviteError as exc:
352
+ raise CliError(str(exc)) from exc
353
+ home = _relay_home(args)
354
+ with Relay.create(home, invite.team, invite.genesis, _passphrase(confirm=True)) as relay:
355
+ sys.stdout.write(
356
+ f"created relay in {home}\n"
357
+ "run it with `ehor relay serve`, then ask an admin to run:\n"
358
+ f" ehor roster relay-add <public-host>:{DEFAULT_RELAY_PORT} {relay.key.hex()}\n"
359
+ )
360
+ return 0
361
+
362
+
363
+ def _relay_serve(args: argparse.Namespace) -> int:
364
+ parsed = parse_address(args.listen)
365
+ if parsed is None:
366
+ raise CliError(f"--listen must be host:port, got {args.listen!r}")
367
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
368
+ home = _relay_home(args)
369
+ with Relay.open(home, _passphrase(confirm=False)) as relay:
370
+ sys.stderr.write(f"ehor relay {relay.key.hex()} listening on {args.listen}\n")
371
+ asyncio.run(_run_relay(relay, *parsed))
372
+ return 0
373
+
374
+
375
+ async def _run_relay(relay: Relay, host: str, port: int) -> None:
376
+ stop = asyncio.Event()
377
+ loop = asyncio.get_running_loop()
378
+ for sig in (signal.SIGINT, signal.SIGTERM):
379
+ with contextlib.suppress(NotImplementedError):
380
+ loop.add_signal_handler(sig, stop.set)
381
+ try:
382
+ await relay.serve(host, port, stop)
383
+ except asyncio.CancelledError:
384
+ stop.set()
385
+
386
+
387
+ def _call(args: argparse.Namespace, method: str, params: dict[str, object]) -> int:
388
+ sys.stdout.write(_rpc(_home(args), method, params) + "\n")
389
+ return 0
390
+
391
+
392
+ def _rpc(home: Path, method: str, params: dict[str, object]) -> str:
393
+ return asyncio.run(RpcClient(default_endpoint(home)).call(method, params))
394
+
395
+
396
+ def _serve(args: argparse.Namespace) -> int:
397
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
398
+ home = _home(args)
399
+ with Device.open(home, _passphrase(confirm=False)) as device:
400
+ sys.stderr.write(f"ehor serving {device.config.handle} from {home}\n")
401
+ direct = None if args.no_p2p else _direct_sync(device)
402
+ peers: list[Syncer] = [_relay_sync(device, args.lan_interval, direct)]
403
+ if not args.no_lan:
404
+ peers.append(_lan_sync(device, args.lan_interval))
405
+ if direct is not None:
406
+ peers.append(direct)
407
+ asyncio.run(_run_daemon(Daemon(device, default_endpoint(home), args.interval, peers)))
408
+ return 0
409
+
410
+
411
+ def _relay_sync(device: Device, interval: float, direct: DirectSync | None) -> PeerSync:
412
+ directory = RelayDirectory(device.relay_addresses)
413
+ gate = RelayGate(
414
+ lambda: direct is not None and direct.running,
415
+ lambda: None if direct is None else direct.last_round,
416
+ lambda: devices_to_reach(device.roster.current, device.author),
417
+ lambda: frozenset(key for key, _ in device.direct_peers()),
418
+ )
419
+ return PeerSync(device.sync_node, directory, interval=interval, listen=False, should_sync=gate)
420
+
421
+
422
+ def _direct_sync(device: Device) -> DirectSync | None:
423
+ if device.endpoint_id is None:
424
+ logging.getLogger(__name__).info(
425
+ "direct connections are off: install them with pip install 'ehor[p2p]'"
426
+ )
427
+ return None
428
+ transport = IrohTransport(device.iroh_secret, IrohMode.INTERNET)
429
+ return DirectSync(
430
+ device.sync_node, transport, device.direct_peers, announce=device.announce_endpoint
431
+ )
432
+
433
+
434
+ def _lan_sync(device: Device, interval: float) -> PeerSync:
435
+ discovery = ZeroconfDiscovery(device.config.team, device.author)
436
+ return PeerSync(device.sync_node, discovery, interval=interval)
437
+
438
+
439
+ async def _run_daemon(daemon: Daemon) -> None:
440
+ stop = asyncio.Event()
441
+ loop = asyncio.get_running_loop()
442
+ for sig in (signal.SIGINT, signal.SIGTERM):
443
+ with contextlib.suppress(NotImplementedError):
444
+ loop.add_signal_handler(sig, stop.set)
445
+ try:
446
+ await daemon.run(stop)
447
+ except asyncio.CancelledError:
448
+ stop.set()
449
+
450
+
451
+ def _mcp(args: argparse.Namespace) -> int:
452
+ build_server(RpcClient(default_endpoint(_home(args)))).run("stdio")
453
+ return 0
454
+
455
+
456
+ def _tool(args: argparse.Namespace) -> int:
457
+ return _call(args, args.tool, {})
File without changes
ehor/crypto/keyfile.py ADDED
@@ -0,0 +1,56 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from cryptography.hazmat.primitives import serialization
5
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
6
+
7
+ _KEY_FILE_MODE = 0o600
8
+
9
+
10
+ class KeyFileError(Exception):
11
+ pass
12
+
13
+
14
+ class EncryptionKeyFile:
15
+ """This device's X25519 decryption key, kept as a passphrase-encrypted PKCS#8 file."""
16
+
17
+ def __init__(self, path: Path, passphrase: bytes) -> None:
18
+ if not passphrase:
19
+ raise KeyFileError("passphrase must not be empty")
20
+ self._path = path
21
+ self._passphrase = passphrase
22
+
23
+ def exists(self) -> bool:
24
+ return self._path.exists()
25
+
26
+ def create(self) -> X25519PrivateKey:
27
+ key = X25519PrivateKey.generate()
28
+ pem = key.private_bytes(
29
+ serialization.Encoding.PEM,
30
+ serialization.PrivateFormat.PKCS8,
31
+ serialization.BestAvailableEncryption(self._passphrase),
32
+ )
33
+ self._path.parent.mkdir(parents=True, exist_ok=True)
34
+ try:
35
+ fd = os.open(self._path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, _KEY_FILE_MODE)
36
+ except FileExistsError as exc:
37
+ raise KeyFileError(f"{self._path} already exists") from exc
38
+ with os.fdopen(fd, "wb") as file:
39
+ file.write(pem)
40
+ return key
41
+
42
+ def load(self) -> X25519PrivateKey:
43
+ try:
44
+ pem = self._path.read_bytes()
45
+ except FileNotFoundError as exc:
46
+ raise KeyFileError(f"no encryption key at {self._path}") from exc
47
+ try:
48
+ key = serialization.load_pem_private_key(pem, self._passphrase)
49
+ except ValueError as exc:
50
+ raise KeyFileError("cannot decrypt the encryption key") from exc
51
+ if not isinstance(key, X25519PrivateKey):
52
+ raise KeyFileError(f"{self._path} is not an X25519 key")
53
+ return key
54
+
55
+ def load_or_create(self) -> X25519PrivateKey:
56
+ return self.load() if self.exists() else self.create()
ehor/crypto/sealing.py ADDED
@@ -0,0 +1,104 @@
1
+ import hashlib
2
+ import secrets
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+
6
+ from cryptography.exceptions import InvalidTag
7
+ from cryptography.hazmat.primitives import hashes
8
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
9
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
10
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
11
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
12
+
13
+ PUBLIC_KEY_SIZE = 32
14
+ HINT_SIZE = 8
15
+ NONCE_SIZE = 12
16
+ CONTENT_KEY_SIZE = 32
17
+ WRAPPED_KEY_SIZE = CONTENT_KEY_SIZE + 16
18
+ _WRAP_INFO = b"ehor/wrap/v0\x00"
19
+ _WRAP_NONCE = bytes(NONCE_SIZE)
20
+
21
+
22
+ class SealError(Exception):
23
+ pass
24
+
25
+
26
+ class NotARecipientError(SealError):
27
+ pass
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Recipient:
32
+ public: bytes
33
+
34
+ @property
35
+ def hint(self) -> bytes:
36
+ return recipient_hint(self.public)
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class Sealed:
41
+ ephemeral: bytes
42
+ keys: tuple[tuple[bytes, bytes], ...]
43
+ nonce: bytes
44
+ data: bytes
45
+
46
+
47
+ def recipient_hint(public: bytes) -> bytes:
48
+ """Lets a device find its wrapped key without trying every entry."""
49
+ return hashlib.sha256(public).digest()[:HINT_SIZE]
50
+
51
+
52
+ def public_bytes(key: X25519PrivateKey) -> bytes:
53
+ return key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
54
+
55
+
56
+ def seal(plaintext: bytes, recipients: Iterable[Recipient], aad: bytes) -> Sealed:
57
+ """Encrypt once with a fresh content key, then wrap that key for each recipient.
58
+
59
+ Each message uses a new ephemeral X25519 key, so every wrapping key is
60
+ unique and the fixed wrap nonce is never reused under the same key.
61
+ """
62
+ unique = {recipient.hint: recipient for recipient in recipients}
63
+ if not unique:
64
+ raise SealError("no recipients")
65
+ content_key = AESGCM.generate_key(bit_length=CONTENT_KEY_SIZE * 8)
66
+ nonce = secrets.token_bytes(NONCE_SIZE)
67
+ data = AESGCM(content_key).encrypt(nonce, plaintext, aad)
68
+ ephemeral = X25519PrivateKey.generate()
69
+ ephemeral_public = public_bytes(ephemeral)
70
+ keys = []
71
+ for hint, recipient in sorted(unique.items()):
72
+ kek = _wrapping_key(ephemeral, recipient.public, ephemeral_public, recipient.public)
73
+ keys.append((hint, AESGCM(kek).encrypt(_WRAP_NONCE, content_key, hint)))
74
+ return Sealed(ephemeral_public, tuple(keys), nonce, data)
75
+
76
+
77
+ def open_sealed(sealed: Sealed, private: X25519PrivateKey, aad: bytes) -> bytes:
78
+ own_public = public_bytes(private)
79
+ hint = recipient_hint(own_public)
80
+ wrapped = next((key for key_hint, key in sealed.keys if key_hint == hint), None)
81
+ if wrapped is None:
82
+ raise NotARecipientError("this device was not a recipient")
83
+ kek = _wrapping_key(private, sealed.ephemeral, sealed.ephemeral, own_public)
84
+ try:
85
+ content_key = AESGCM(kek).decrypt(_WRAP_NONCE, wrapped, hint)
86
+ return AESGCM(content_key).decrypt(sealed.nonce, sealed.data, aad)
87
+ except InvalidTag as exc:
88
+ raise SealError("cannot decrypt: wrong key or tampered data") from exc
89
+
90
+
91
+ def _wrapping_key(
92
+ private: X25519PrivateKey, peer_public: bytes, ephemeral_public: bytes, recipient_public: bytes
93
+ ) -> bytes:
94
+ try:
95
+ shared = private.exchange(X25519PublicKey.from_public_bytes(peer_public))
96
+ except ValueError as exc:
97
+ raise SealError("invalid x25519 public key") from exc
98
+ hkdf = HKDF(
99
+ algorithm=hashes.SHA256(),
100
+ length=CONTENT_KEY_SIZE,
101
+ salt=None,
102
+ info=_WRAP_INFO + ephemeral_public + recipient_public,
103
+ )
104
+ return hkdf.derive(shared)
@@ -0,0 +1,21 @@
1
+ from ehor.identity.base import (
2
+ AssertionSigner,
3
+ HardwareUnavailableError,
4
+ IdentityError,
5
+ InvalidLabelError,
6
+ KeyExistsError,
7
+ KeyHandle,
8
+ KeyNotFoundError,
9
+ Signer,
10
+ )
11
+
12
+ __all__ = [
13
+ "AssertionSigner",
14
+ "HardwareUnavailableError",
15
+ "IdentityError",
16
+ "InvalidLabelError",
17
+ "KeyExistsError",
18
+ "KeyHandle",
19
+ "KeyNotFoundError",
20
+ "Signer",
21
+ ]