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.
- ehor/__init__.py +3 -0
- ehor/cli.py +457 -0
- ehor/crypto/__init__.py +0 -0
- ehor/crypto/keyfile.py +56 -0
- ehor/crypto/sealing.py +104 -0
- ehor/identity/__init__.py +21 -0
- ehor/identity/assertion.py +56 -0
- ehor/identity/backends.py +54 -0
- ehor/identity/base.py +67 -0
- ehor/identity/ctap.py +142 -0
- ehor/identity/enclave_helper.swift +38 -0
- ehor/identity/es256.py +38 -0
- ehor/identity/secure_enclave.py +126 -0
- ehor/identity/security_key.py +127 -0
- ehor/identity/software.py +82 -0
- ehor/identity/tools.py +56 -0
- ehor/identity/tpm.py +155 -0
- ehor/log/__init__.py +0 -0
- ehor/log/authorize.py +39 -0
- ehor/log/body.py +173 -0
- ehor/log/canonical.py +57 -0
- ehor/log/endpoint_announce.py +34 -0
- ehor/log/entry.py +212 -0
- ehor/log/envelope.py +118 -0
- ehor/log/key_announce.py +34 -0
- ehor/log/names.py +34 -0
- ehor/log/roster.py +281 -0
- ehor/log/verify.py +137 -0
- ehor/mcp/__init__.py +0 -0
- ehor/mcp/server.py +90 -0
- ehor/py.typed +0 -0
- ehor/relay/__init__.py +0 -0
- ehor/relay/server.py +130 -0
- ehor/roster/__init__.py +0 -0
- ehor/roster/book.py +171 -0
- ehor/service/__init__.py +0 -0
- ehor/service/api.py +136 -0
- ehor/service/config.py +82 -0
- ehor/service/daemon.py +56 -0
- ehor/service/device.py +309 -0
- ehor/service/direct_sync.py +175 -0
- ehor/service/directory.py +42 -0
- ehor/service/endpoints.py +22 -0
- ehor/service/invite.py +79 -0
- ehor/service/iroh_key.py +71 -0
- ehor/service/messenger.py +488 -0
- ehor/service/peer_sync.py +83 -0
- ehor/service/relay_policy.py +73 -0
- ehor/service/render.py +141 -0
- ehor/service/roster_admin.py +284 -0
- ehor/service/rpc.py +159 -0
- ehor/service/sealing.py +105 -0
- ehor/store/__init__.py +0 -0
- ehor/store/base.py +82 -0
- ehor/store/ingest.py +112 -0
- ehor/store/local_log.py +85 -0
- ehor/store/sqlite.py +276 -0
- ehor/sync/__init__.py +0 -0
- ehor/sync/session.py +209 -0
- ehor/sync/wire.py +166 -0
- ehor/transport/__init__.py +0 -0
- ehor/transport/directory.py +76 -0
- ehor/transport/iroh.py +311 -0
- ehor/transport/lan.py +170 -0
- ehor/transport/relays.py +28 -0
- ehor-0.1.1.dist-info/METADATA +288 -0
- ehor-0.1.1.dist-info/RECORD +70 -0
- ehor-0.1.1.dist-info/WHEEL +4 -0
- ehor-0.1.1.dist-info/entry_points.txt +2 -0
- ehor-0.1.1.dist-info/licenses/LICENSE +21 -0
ehor/__init__.py
ADDED
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, {})
|
ehor/crypto/__init__.py
ADDED
|
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
|
+
]
|