lichen-chat 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.
- lichen/__init__.py +1 -0
- lichen/__main__.py +3 -0
- lichen/cli.py +287 -0
- lichen/commands.py +675 -0
- lichen/config.py +153 -0
- lichen/crypto.py +78 -0
- lichen/devices.py +103 -0
- lichen/dms.py +167 -0
- lichen/files.py +214 -0
- lichen/groups.py +356 -0
- lichen/identity.py +71 -0
- lichen/keyfile.py +78 -0
- lichen/node.py +1473 -0
- lichen/outbox.py +105 -0
- lichen/phonebook.py +56 -0
- lichen/records.py +53 -0
- lichen/relay.py +262 -0
- lichen/thread.py +76 -0
- lichen/trust.py +125 -0
- lichen/tui.py +794 -0
- lichen/udp.py +369 -0
- lichen/vault.py +117 -0
- lichen/wire.py +240 -0
- lichen_chat-0.1.0.dist-info/METADATA +554 -0
- lichen_chat-0.1.0.dist-info/RECORD +30 -0
- lichen_chat-0.1.0.dist-info/WHEEL +5 -0
- lichen_chat-0.1.0.dist-info/entry_points.txt +2 -0
- lichen_chat-0.1.0.dist-info/licenses/LICENSE +202 -0
- lichen_chat-0.1.0.dist-info/licenses/NOTICE +4 -0
- lichen_chat-0.1.0.dist-info/top_level.txt +1 -0
lichen/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Lichen: decentralized chat where everyone keeps a signed, gossiped phonebook."""
|
lichen/__main__.py
ADDED
lichen/cli.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Interactive chat client.
|
|
2
|
+
|
|
3
|
+
Hub with an open port: python -m lichen --name hub --host <public ip> --bind 0.0.0.0
|
|
4
|
+
Hub behind Cloudflare Tunnel: python -m lichen --name hub --url wss://chat.example.com
|
|
5
|
+
Everyone else: python -m lichen --name alice --relay <hub address>
|
|
6
|
+
Or put the options in lichen.yaml and just run: python -m lichen (see config.py, --init)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import getpass
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import config, devices, keyfile
|
|
18
|
+
from .commands import Commands
|
|
19
|
+
from .files import DEFAULT_MAX_MB, human_size
|
|
20
|
+
from .identity import short
|
|
21
|
+
from .node import Node
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_args(argv=None) -> argparse.Namespace:
|
|
25
|
+
p = argparse.ArgumentParser(prog="lichen", description="Decentralized encrypted chat",
|
|
26
|
+
epilog="Options can also come from a YAML/JSON config file; flags override it.")
|
|
27
|
+
p.add_argument("--config", "-c", type=Path, default=None,
|
|
28
|
+
help=f"config file (default: {' / '.join(config.DEFAULT_FILES)} in the current folder, if present)")
|
|
29
|
+
p.add_argument("--init", nargs="?", const="lichen.yaml", default=None, metavar="FILE",
|
|
30
|
+
help="write a commented starter config (default lichen.yaml) and exit")
|
|
31
|
+
p.add_argument("--passphrase", dest="set_passphrase", action="store_true",
|
|
32
|
+
help="set, change or remove the passphrase protecting your key files, then exit")
|
|
33
|
+
p.add_argument("--export", metavar="FILE", default=None,
|
|
34
|
+
help="write your identity (keys, contacts, groups, chats) and lichen.yaml to FILE, encrypted, "
|
|
35
|
+
"for another device")
|
|
36
|
+
p.add_argument("--import", dest="import_file", metavar="FILE", default=None,
|
|
37
|
+
help="set up this device as you, from a file made with --export (also writes its lichen.yaml)")
|
|
38
|
+
for key, (kind, help_) in config.OPTIONS.items():
|
|
39
|
+
flag = "--" + key.replace("_", "-")
|
|
40
|
+
if kind is list:
|
|
41
|
+
p.add_argument(flag, action="append", default=None, help=help_)
|
|
42
|
+
else:
|
|
43
|
+
p.add_argument(flag, type=kind, default=None, help=help_)
|
|
44
|
+
args = p.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
if args.init:
|
|
47
|
+
try:
|
|
48
|
+
config.write_template(Path(args.init), args.name or "yourname",
|
|
49
|
+
(args.relay or ["chat.example.com:9000"])[0])
|
|
50
|
+
except config.ConfigError as e:
|
|
51
|
+
p.exit(1, f"{e}\n")
|
|
52
|
+
p.exit(0, f"wrote {args.init}; edit it, then run: lichen"
|
|
53
|
+
f"{'' if args.init in config.DEFAULT_FILES else ' --config ' + args.init}\n")
|
|
54
|
+
|
|
55
|
+
# Importing starts from the export's own config, not whatever lichen.yaml happens to be in this folder.
|
|
56
|
+
path = args.config or (None if args.import_file else config.find_default())
|
|
57
|
+
settings: dict = {}
|
|
58
|
+
if path:
|
|
59
|
+
try:
|
|
60
|
+
settings = config.load(path)
|
|
61
|
+
except (OSError, ValueError) as e: # ConfigError, bad JSON/YAML, missing file
|
|
62
|
+
p.exit(1, f"error: {e}\n")
|
|
63
|
+
for key in config.OPTIONS: # command line wins over the file
|
|
64
|
+
if getattr(args, key) is not None:
|
|
65
|
+
settings[key] = getattr(args, key)
|
|
66
|
+
if not settings.get("name") and not args.import_file: # (an import takes its name from the export)
|
|
67
|
+
p.exit(1, "error: a name is required (--name NAME, or `name:` in a config file; try --init)\n")
|
|
68
|
+
|
|
69
|
+
cfg = argparse.Namespace(**{key: settings.get(key) for key in config.OPTIONS})
|
|
70
|
+
cfg.config_path = path
|
|
71
|
+
cfg.set_passphrase = args.set_passphrase
|
|
72
|
+
cfg.export = args.export
|
|
73
|
+
cfg.import_file = args.import_file
|
|
74
|
+
cfg.port = cfg.port or 9000
|
|
75
|
+
cfg.bootstrap = list(cfg.bootstrap or [])
|
|
76
|
+
cfg.relay = list(cfg.relay or [])
|
|
77
|
+
cfg.bootstrap += [r for r in cfg.relay if r not in cfg.bootstrap]
|
|
78
|
+
return cfg
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def data_dir(args) -> Path:
|
|
82
|
+
return Path(args.data) if args.data else Path("data") / args.name
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def unlock(args) -> str | None:
|
|
86
|
+
"""Get the passphrase for encrypted key files: $LICHEN_PASSPHRASE, or ask (3 tries). None if the
|
|
87
|
+
keys aren't encrypted. Exits with a message if we can't get the right one."""
|
|
88
|
+
folder = data_dir(args)
|
|
89
|
+
if not keyfile.needs_passphrase(folder):
|
|
90
|
+
return None
|
|
91
|
+
from .identity import Identity
|
|
92
|
+
|
|
93
|
+
env = os.environ.get("LICHEN_PASSPHRASE")
|
|
94
|
+
attempts = [env] if env else []
|
|
95
|
+
if not env and sys.stdin.isatty():
|
|
96
|
+
attempts = [getpass.getpass(f"passphrase for {folder}: ") for _ in range(1)]
|
|
97
|
+
for i in range(3):
|
|
98
|
+
if i >= len(attempts):
|
|
99
|
+
if env or not sys.stdin.isatty():
|
|
100
|
+
break
|
|
101
|
+
attempts.append(getpass.getpass("wrong passphrase, try again: "))
|
|
102
|
+
try:
|
|
103
|
+
Identity.load_or_create(folder / "key", attempts[i])
|
|
104
|
+
return attempts[i]
|
|
105
|
+
except keyfile.WrongPassphrase:
|
|
106
|
+
continue
|
|
107
|
+
raise SystemExit("error: your key files are passphrase-protected; wrong or missing passphrase "
|
|
108
|
+
"(set LICHEN_PASSPHRASE to run unattended)")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def set_passphrase(args) -> None:
|
|
112
|
+
folder = data_dir(args)
|
|
113
|
+
if not (folder / "key").exists():
|
|
114
|
+
raise SystemExit(f"no keys in {folder} yet; run lichen once first")
|
|
115
|
+
old = None
|
|
116
|
+
if keyfile.needs_passphrase(folder):
|
|
117
|
+
old = getpass.getpass("current passphrase: ")
|
|
118
|
+
new = getpass.getpass("new passphrase (leave empty to remove protection): ")
|
|
119
|
+
if new and getpass.getpass("repeat new passphrase: ") != new:
|
|
120
|
+
raise SystemExit("passphrases didn't match; nothing changed")
|
|
121
|
+
try:
|
|
122
|
+
keyfile.change_passphrase(folder, old, new or None)
|
|
123
|
+
except keyfile.WrongPassphrase:
|
|
124
|
+
raise SystemExit("wrong current passphrase; nothing changed")
|
|
125
|
+
print("passphrase set: you'll be asked for it at startup" if new else "passphrase removed")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def export_config(args) -> str:
|
|
129
|
+
"""The lichen.yaml that travels with an export: our settings minus the ones about this machine."""
|
|
130
|
+
settings = {key: getattr(args, key, None) for key in config.PORTABLE}
|
|
131
|
+
# parse_args adds the relays to bootstrap; leave them out again, they're implied.
|
|
132
|
+
settings["bootstrap"] = [b for b in args.bootstrap if b not in args.relay]
|
|
133
|
+
return config.render(settings, "# Lichen config, exported with `lichen --export` (from the device called "
|
|
134
|
+
f"{args.name})."
|
|
135
|
+
"\n# Machine-specific options (data, downloads, host, bind, port, url) "
|
|
136
|
+
"are left out: set them here if this device needs them.")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def export_identity(args) -> None:
|
|
140
|
+
folder = data_dir(args)
|
|
141
|
+
key_pass = unlock(args)
|
|
142
|
+
print("Choose a passphrase for the export. Anyone with the file AND this passphrase can be you.")
|
|
143
|
+
passphrase = getpass.getpass("export passphrase: ")
|
|
144
|
+
if not passphrase or getpass.getpass("repeat: ") != passphrase:
|
|
145
|
+
raise SystemExit("passphrases empty or didn't match; nothing written")
|
|
146
|
+
try:
|
|
147
|
+
devices.export_identity(folder, Path(args.export), passphrase, key_pass, config=export_config(args))
|
|
148
|
+
except ValueError as e:
|
|
149
|
+
raise SystemExit(f"error: {e}")
|
|
150
|
+
print(f"wrote {args.export} (keys, chats, groups, contacts and a lichen.yaml). "
|
|
151
|
+
f"On the other device: lichen --import {Path(args.export).name}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def import_identity(args) -> None:
|
|
155
|
+
src = Path(args.import_file)
|
|
156
|
+
passphrase = getpass.getpass(f"passphrase for {args.import_file}: ")
|
|
157
|
+
try:
|
|
158
|
+
text = devices.export_config(src, passphrase)
|
|
159
|
+
settings = config.parse(text, False, "the export's lichen.yaml") if text else {}
|
|
160
|
+
except (OSError, ValueError) as e:
|
|
161
|
+
raise SystemExit(f"error: {e}")
|
|
162
|
+
args.name = args.name or settings.get("name")
|
|
163
|
+
if not args.name:
|
|
164
|
+
raise SystemExit("error: this export has no lichen.yaml in it (it's from an older version): "
|
|
165
|
+
"say what to call this device with --name NAME")
|
|
166
|
+
folder = data_dir(args)
|
|
167
|
+
keep = input("protect the keys on this device with the same passphrase? [Y/n] ").strip().lower() != "n"
|
|
168
|
+
try:
|
|
169
|
+
devices.import_identity(src, folder, passphrase, passphrase if keep else None)
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
raise SystemExit(f"error: {e}")
|
|
172
|
+
wrote = None
|
|
173
|
+
if text:
|
|
174
|
+
dest = Path(config.DEFAULT_FILES[0])
|
|
175
|
+
if args.config_path and Path(args.config_path).suffix.lower() in (".yaml", ".yml"):
|
|
176
|
+
dest = Path(args.config_path)
|
|
177
|
+
if dest.exists(): # never overwrite a config that's already here
|
|
178
|
+
dest = dest.with_name(f"{dest.stem}.from-export{dest.suffix}")
|
|
179
|
+
if not dest.exists():
|
|
180
|
+
dest.write_text(text, encoding="utf-8")
|
|
181
|
+
wrote = dest
|
|
182
|
+
print(f"this device is now you (data in {folder}).")
|
|
183
|
+
if wrote:
|
|
184
|
+
print(f"wrote {wrote} with your name and hubs from the export"
|
|
185
|
+
+ ("" if wrote.name in config.DEFAULT_FILES else f"; run: lichen --config {wrote}"))
|
|
186
|
+
else:
|
|
187
|
+
print("use the same hubs (relay) as your other device")
|
|
188
|
+
print("Run lichen: it will fetch everything your other devices have, through your hubs, even if they're off.")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def make_node(args, **callbacks) -> Node:
|
|
192
|
+
"""Build a Node from parsed options (shared by the CLI and the terminal UI)."""
|
|
193
|
+
return Node.from_dir(
|
|
194
|
+
data_dir(args),
|
|
195
|
+
args.name,
|
|
196
|
+
passphrase=getattr(args, "passphrase", None),
|
|
197
|
+
host=args.host or "127.0.0.1",
|
|
198
|
+
port=args.port,
|
|
199
|
+
bind=args.bind,
|
|
200
|
+
bootstrap=args.bootstrap,
|
|
201
|
+
relay=args.relay,
|
|
202
|
+
# Behind a relay with no host/url: don't advertise a private address nobody can reach.
|
|
203
|
+
direct=bool(args.host or args.url) or not args.relay,
|
|
204
|
+
url=args.url,
|
|
205
|
+
stun=args.stun,
|
|
206
|
+
downloads=Path(args.downloads).expanduser() if getattr(args, "downloads", None) else None,
|
|
207
|
+
max_file_mb=DEFAULT_MAX_MB if getattr(args, "max_file_mb", None) is None else args.max_file_mb,
|
|
208
|
+
auto_download_mb=getattr(args, "auto_download_mb", None) or 0,
|
|
209
|
+
vault_mb=getattr(args, "vault_mb", None),
|
|
210
|
+
**callbacks,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def run(args) -> None:
|
|
215
|
+
def on_message(rec, text, env):
|
|
216
|
+
who = rec.name if rec else short(env["from"])
|
|
217
|
+
print(f"\n<{who}> {text}", flush=True)
|
|
218
|
+
|
|
219
|
+
def on_group_message(group, rec, text, env, live):
|
|
220
|
+
who = rec.name if rec else short(env["from"])
|
|
221
|
+
when = "" if live else time.strftime(" (missed, %H:%M)", time.localtime(env["ts"]))
|
|
222
|
+
print(f"\n[{group.name}] <{who}> {text}{when}", flush=True)
|
|
223
|
+
|
|
224
|
+
def on_event(kind, d):
|
|
225
|
+
if kind != "file_offer":
|
|
226
|
+
return # invites, progress and results are already logged by the node
|
|
227
|
+
o = d["offer"]
|
|
228
|
+
rec = node.phonebook.get(o["from"])
|
|
229
|
+
who = (rec.name if rec else short(o["from"])) + ("" if d["trusted"] else " (not a contact)")
|
|
230
|
+
if o["status"] == "refused":
|
|
231
|
+
print(f"\n* {who} offered {o['name']} ({human_size(o['size'])}): refused, {o['error']}", flush=True)
|
|
232
|
+
else:
|
|
233
|
+
print(f"\n* {who} offers {o['name']} ({human_size(o['size'])}): /accept {o['name']} or /decline",
|
|
234
|
+
flush=True)
|
|
235
|
+
|
|
236
|
+
if args.config_path:
|
|
237
|
+
print(f"using config {args.config_path}")
|
|
238
|
+
node = make_node(args, on_message=on_message, on_group_message=on_group_message, on_event=on_event,
|
|
239
|
+
log=lambda s: print(f"\n* {s}", flush=True))
|
|
240
|
+
await node.start()
|
|
241
|
+
print(f"{args.name} ({short(node.ident.pubkey)}) reachable at {', '.join(node.addrs)}. /help for commands.")
|
|
242
|
+
|
|
243
|
+
commands = Commands(node, print)
|
|
244
|
+
loop = asyncio.get_running_loop()
|
|
245
|
+
try:
|
|
246
|
+
while True:
|
|
247
|
+
line = (await loop.run_in_executor(None, input, "> ")).strip()
|
|
248
|
+
if line and not await commands.run(line):
|
|
249
|
+
break
|
|
250
|
+
except (EOFError, KeyboardInterrupt):
|
|
251
|
+
pass
|
|
252
|
+
finally:
|
|
253
|
+
await node.stop()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main(argv=None) -> None:
|
|
257
|
+
args = parse_args(argv)
|
|
258
|
+
if args.set_passphrase:
|
|
259
|
+
set_passphrase(args)
|
|
260
|
+
return
|
|
261
|
+
if args.export:
|
|
262
|
+
export_identity(args)
|
|
263
|
+
return
|
|
264
|
+
if args.import_file:
|
|
265
|
+
import_identity(args)
|
|
266
|
+
return
|
|
267
|
+
args.passphrase = unlock(args) # before the full-screen UI takes over the terminal
|
|
268
|
+
ui = (args.ui or "auto").lower()
|
|
269
|
+
if ui not in ("auto", "tui", "cli"):
|
|
270
|
+
raise SystemExit(f"error: ui must be auto, tui or cli (got {ui!r})")
|
|
271
|
+
if ui == "auto":
|
|
272
|
+
ui = "tui" if sys.stdin.isatty() and sys.stdout.isatty() else "cli"
|
|
273
|
+
if ui == "tui":
|
|
274
|
+
try:
|
|
275
|
+
from .tui import ChatApp
|
|
276
|
+
except ImportError:
|
|
277
|
+
raise SystemExit("the terminal UI needs Textual: pip install textual (or use --ui cli)")
|
|
278
|
+
ChatApp(args).run()
|
|
279
|
+
return
|
|
280
|
+
# Piped output on Windows uses a legacy code page; don't crash on names/messages it can't encode.
|
|
281
|
+
for stream in (sys.stdout, sys.stderr):
|
|
282
|
+
if hasattr(stream, "reconfigure"):
|
|
283
|
+
stream.reconfigure(errors="replace")
|
|
284
|
+
try:
|
|
285
|
+
asyncio.run(run(args))
|
|
286
|
+
except KeyboardInterrupt:
|
|
287
|
+
pass
|