retalk 0.0.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.
retalk/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """retalk: a minimal, self-hosted, end-to-end-encrypted message bus (client library + CLI)."""
2
+
3
+ from .user import PinMismatchError, User, canonical_hash, fingerprint
4
+
5
+ __all__ = ["User", "PinMismatchError", "fingerprint", "canonical_hash"]
6
+ __version__ = "0.0.1"
retalk/cli.py ADDED
@@ -0,0 +1,439 @@
1
+ """retalk command-line interface.
2
+
3
+ An identity lives in a folder (created by `retalk init`) containing
4
+ store.db: keys encrypted at rest, sessions, saved peers, outbox.
5
+
6
+ Store resolution for every command, in order:
7
+ -s DIR > -u [NAME] > STORE env > user-level "default" identity
8
+ (if it exists) > error.
9
+ Only `init` ever creates an identity; every other command refuses loudly
10
+ when none exists.
11
+
12
+ The pickle secret is read from PICKLE_SECRET or prompted (never echoed).
13
+ The server URL comes from --server, SERVER_URL, or the value saved at
14
+ init. Identity banners go to stderr so stdout stays clean for --json.
15
+ """
16
+
17
+ import argparse
18
+ import getpass
19
+ import json
20
+ import os
21
+ import re
22
+ import sqlite3
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ from .user import User
28
+
29
+ STORE_FILE = "store.db"
30
+ ID_RE = re.compile(r"^[0-9a-f]{32}$")
31
+
32
+
33
+ def _data_home() -> Path:
34
+ return Path(os.environ.get("XDG_DATA_HOME",
35
+ Path.home() / ".local" / "share")) / "retalk"
36
+
37
+
38
+ def _die(msg: str, code: int = 2):
39
+ print(f"retalk: {msg}", file=sys.stderr)
40
+ sys.exit(code)
41
+
42
+
43
+ def _resolve_store(args, creating: bool = False) -> Path:
44
+ if getattr(args, "dir", None):
45
+ d = Path(args.dir)
46
+ elif args.user_level is not None:
47
+ d = _data_home() / args.user_level
48
+ elif not creating and os.environ.get("STORE"):
49
+ d = Path(os.environ["STORE"])
50
+ elif not creating and (_data_home() / "default" / STORE_FILE).exists():
51
+ d = _data_home() / "default"
52
+ elif creating:
53
+ _die("init needs a location: a directory argument or -u [NAME]")
54
+ else:
55
+ _die("no identity specified: use -s DIR, -u [NAME], the STORE env "
56
+ "var, or create one with `retalk init`")
57
+ if not creating and not (d / STORE_FILE).exists():
58
+ _die(f"no identity at {d} — create one with `retalk init {d}`")
59
+ return d
60
+
61
+
62
+ def _secret(confirm: bool = False) -> str:
63
+ s = os.environ.get("PICKLE_SECRET")
64
+ if s:
65
+ return s
66
+ if not sys.stdin.isatty():
67
+ _die("PICKLE_SECRET is not set and there is no terminal to prompt on")
68
+ s = getpass.getpass("pickle secret (unlocks this identity): ")
69
+ if confirm and getpass.getpass("repeat to confirm: ") != s:
70
+ _die("secrets do not match")
71
+ return s
72
+
73
+
74
+ def _store_sql(store_db: Path, query: str, *params) -> list:
75
+ conn = sqlite3.connect(store_db)
76
+ try:
77
+ with conn:
78
+ return conn.execute(query, params).fetchall()
79
+ finally:
80
+ conn.close()
81
+
82
+
83
+ def _meta(store_db: Path, key: str) -> str | None:
84
+ rows = _store_sql(store_db, "SELECT v FROM meta WHERE k=?", key)
85
+ return rows[0][0] if rows else None
86
+
87
+
88
+ def _saved_peers(store_db: Path) -> dict:
89
+ _store_sql(store_db, "CREATE TABLE IF NOT EXISTS peers("
90
+ "name TEXT PRIMARY KEY, id TEXT, pin TEXT)")
91
+ return {name: (pid, pin) for name, pid, pin in
92
+ _store_sql(store_db, "SELECT name, id, pin FROM peers")}
93
+
94
+
95
+ def _open_user(args, need_server: bool = True, banner: bool = True) -> User:
96
+ d = _resolve_store(args)
97
+ store_db = d / STORE_FILE
98
+ server = (getattr(args, "server", None) or os.environ.get("SERVER_URL")
99
+ or _meta(store_db, "server_url") or "")
100
+ if need_server and not server:
101
+ _die("no server URL: pass --server, set SERVER_URL, or save one "
102
+ "at init time")
103
+ peers = _saved_peers(store_db)
104
+ pins = {pid: pin for _, (pid, pin) in peers.items() if pin}
105
+ names = {pid: name for name, (pid, _) in peers.items()}
106
+ try:
107
+ u = User(server, _secret(), name=_meta(store_db, "name") or "",
108
+ store=str(store_db), pins=pins, names=names)
109
+ except Exception:
110
+ _die(f"could not unlock the identity at {d} (wrong secret?)")
111
+ if banner:
112
+ print(f"using {u.name or 'user'} ({u.user_id()}) from {d}",
113
+ file=sys.stderr)
114
+ return u
115
+
116
+
117
+ def _ensure_published(u: User):
118
+ """Make sure our keys exist on this server before acting.
119
+
120
+ Asks the server rather than trusting a local flag, so a wiped or
121
+ replaced server database heals automatically on the next command."""
122
+ if not u._call("count_keys")["has_fallback"]:
123
+ u.publish()
124
+
125
+
126
+ def _peer_to_id(peer: str, store_db: Path) -> str:
127
+ peers = _saved_peers(store_db)
128
+ if peer in peers:
129
+ return peers[peer][0]
130
+ if ID_RE.match(peer):
131
+ return peer
132
+ _die(f"unknown peer '{peer}': `retalk add {peer} <user-id>` first, "
133
+ "or pass a 32-hex user id")
134
+
135
+
136
+ # ---------- commands ----------
137
+
138
+ def cmd_init(args):
139
+ if args.directory:
140
+ args.dir = args.directory
141
+ d = _resolve_store(args, creating=True)
142
+ if (d / STORE_FILE).exists():
143
+ _die(f"an identity already exists at {d}")
144
+ d.mkdir(parents=True, exist_ok=True)
145
+ secret = _secret(confirm=True)
146
+ server = args.server or os.environ.get("SERVER_URL") or ""
147
+ u = User(server, secret, name=args.name or "",
148
+ store=str(d / STORE_FILE))
149
+ if args.name:
150
+ u._meta_set("name", args.name)
151
+ if server:
152
+ u._meta_set("server_url", server)
153
+ print(f"created identity at {d}", file=sys.stderr)
154
+ print("user id (share out-of-band; it is address + pin in one):",
155
+ file=sys.stderr)
156
+ print(u.user_id())
157
+
158
+
159
+ def cmd_id(args):
160
+ u = _open_user(args, need_server=False, banner=False)
161
+ if args.json:
162
+ print(json.dumps({"user_id": u.user_id(),
163
+ "identity_key": u.identity_key(),
164
+ "name": u.name}))
165
+ else:
166
+ print(u.user_id())
167
+
168
+
169
+ def cmd_add(args):
170
+ d = _resolve_store(args)
171
+ if not ID_RE.match(args.user_id):
172
+ _die("user id must be 32 hex characters")
173
+ if ID_RE.match(args.name):
174
+ _die("peer name looks like a user id — give it a human name")
175
+ store_db = d / STORE_FILE
176
+ _saved_peers(store_db) # ensure the table exists
177
+ _store_sql(store_db,
178
+ "INSERT INTO peers(name, id, pin) VALUES(?,?,?) "
179
+ "ON CONFLICT(name) DO UPDATE SET id=excluded.id, "
180
+ "pin=excluded.pin",
181
+ args.name, args.user_id, args.pin)
182
+ print(f"added {args.name} -> {args.user_id}", file=sys.stderr)
183
+
184
+
185
+ def cmd_send(args):
186
+ u = _open_user(args)
187
+ to = _peer_to_id(args.peer, _resolve_store(args) / STORE_FILE)
188
+
189
+ _ensure_published(u)
190
+ u.send(to, args.text)
191
+ print(f"sent to {args.peer}", file=sys.stderr)
192
+
193
+
194
+ def cmd_receive(args):
195
+ u = _open_user(args)
196
+
197
+ def emit(batch):
198
+ for sender, name, text in batch:
199
+ if args.json:
200
+ print(json.dumps({"from": sender, "name": name,
201
+ "text": text}), flush=True)
202
+ else:
203
+ print(f"{name or sender}: {text}", flush=True)
204
+
205
+ try:
206
+ _ensure_published(u)
207
+ emit(u.receive())
208
+ if not args.follow:
209
+ return
210
+ last_maintain = time.monotonic()
211
+ while True:
212
+ time.sleep(2)
213
+ emit(u.receive())
214
+ if time.monotonic() - last_maintain > 60:
215
+ u.maintain()
216
+ last_maintain = time.monotonic()
217
+ except KeyboardInterrupt:
218
+ pass
219
+
220
+
221
+ def main():
222
+ common = argparse.ArgumentParser(add_help=False)
223
+ raw = argparse.RawDescriptionHelpFormatter
224
+
225
+ common = argparse.ArgumentParser(add_help=False)
226
+ g = common.add_argument_group("identity selection (shared by every command)")
227
+ g.add_argument("-s", "--store", dest="dir", metavar="DIR",
228
+ help="use the identity in directory DIR "
229
+ "(created earlier by `retalk init DIR`)")
230
+ g.add_argument("-u", "--user-level", nargs="?", const="default",
231
+ default=None, metavar="NAME",
232
+ help="use the user-level identity NAME, stored under "
233
+ "~/.local/share/retalk/NAME/ (defaults to 'default' "
234
+ "when NAME is omitted)")
235
+ g.add_argument("--server", metavar="URL",
236
+ help="server URL for this invocation; overrides the "
237
+ "SERVER_URL env var and the URL saved at init")
238
+
239
+ p = argparse.ArgumentParser(
240
+ prog="retalk",
241
+ formatter_class=raw,
242
+ description="""\
243
+ End-to-end-encrypted messages between users, relayed by a server that is
244
+ never trusted: it stores only public keys and ciphertext, and every
245
+ request to it is signed with your key (no accounts, no tokens, no
246
+ registration). A "user" is anything with a keypair and a mailbox — an AI
247
+ agent, a service, or you.
248
+
249
+ Your USER ID is a 32-hex fingerprint of your public keys. It is both your
250
+ address and your peers' proof of your keys: share it over any channel the
251
+ server does not control (chat, email, in person).""",
252
+ epilog="""\
253
+ how every command finds your identity (first match wins):
254
+ 1. -s DIR an explicit identity directory
255
+ 2. -u [NAME] a named user-level identity (~/.local/share/retalk/)
256
+ 3. STORE env var same as -s
257
+ 4. the user-level identity called 'default', if you created one
258
+ 5. otherwise: error. Only `retalk init` ever creates an identity, so a
259
+ mistyped path fails loudly instead of silently
260
+ creating a fresh one.
261
+
262
+ environment variables:
263
+ PICKLE_SECRET the secret that unlocks your keys; prompted interactively
264
+ when unset (use the env var for scripts and daemons)
265
+ SERVER_URL server to talk to (init can save one per identity instead)
266
+ STORE identity directory, like -s
267
+
268
+ output conventions:
269
+ stdout carries results (ids, messages, --json lines); everything else —
270
+ banners, progress, errors — goes to stderr, so pipes stay clean. Every
271
+ command that acts prints `using <name> (<id>) from <dir>` to stderr
272
+ so you always know which identity acted. Exit codes: 0 ok, 2 usage or
273
+ refusal (no identity, wrong secret, unknown peer).
274
+
275
+ quickstart:
276
+ retalk init -u --name alice-1 --server https://server.example.com
277
+ retalk add bob <bob's user id>
278
+ retalk send bob "hello"
279
+ retalk receive --follow
280
+
281
+ run `retalk <command> --help` for the full story of each command.""")
282
+ sub = p.add_subparsers(dest="command", required=True,
283
+ metavar="{init,id,add,send,receive}")
284
+
285
+ sp = sub.add_parser(
286
+ "init", parents=[common], formatter_class=raw,
287
+ help="create a new identity (the only command that ever does)",
288
+ description="""\
289
+ Create a new identity: generate an encryption keypair, encrypt it with a
290
+ secret you choose, and store it in a folder of your choosing. Prints the
291
+ new USER ID on stdout — share it with peers out-of-band; it is both your
292
+ address and the fingerprint they verify you by.
293
+
294
+ The location is mandatory: either a directory argument (project-local) or
295
+ -u [NAME] (user-level, under ~/.local/share/retalk/). The folder will
296
+ contain store.db — keys (encrypted), sessions, saved peers, and the
297
+ outbox of not-yet-acknowledged messages. Back it up to keep the identity;
298
+ delete it to destroy the identity.
299
+
300
+ The secret is prompted twice (or taken from PICKLE_SECRET). It encrypts
301
+ your private keys at rest and is required by every later command. It
302
+ cannot be recovered: losing it means losing this identity and all its
303
+ conversations.
304
+
305
+ init is offline — it does not contact the server. Keys are published
306
+ automatically the first time you send or receive.""",
307
+ epilog="""\
308
+ examples:
309
+ retalk init ./alice identity in ./alice/
310
+ retalk init -u user-level 'default' identity
311
+ retalk init -u work --name work-bot \\
312
+ --server https://srv.example.com named identity, server saved""")
313
+ sp.add_argument("directory", nargs="?",
314
+ help="folder to hold the identity (alternative to -u)")
315
+ sp.add_argument("--name", metavar="NAME",
316
+ help="display name attached to your messages; peers see "
317
+ "it marked '~NAME' because it is not verified — "
318
+ "only their locally saved peer name for you is")
319
+ sp.set_defaults(fn=cmd_init)
320
+
321
+ sp = sub.add_parser(
322
+ "id", parents=[common], formatter_class=raw,
323
+ help="print my user id",
324
+ description="""\
325
+ Print this identity's USER ID (32 hex chars) on stdout.
326
+
327
+ The ID is the sha256 fingerprint of your public keys, which makes it
328
+ self-verifying: a peer who knows your ID can detect any attempt by the
329
+ server to hand out substitute keys for you. Sharing it is therefore
330
+ sharing your address and your key fingerprint in one string. It contains
331
+ no secret — it is safe to post publicly.
332
+
333
+ Needs your secret (to open the store) but never contacts the server.""",
334
+ epilog="""\
335
+ examples:
336
+ retalk id id of the default identity
337
+ retalk id -s ./alice id of a project-local identity
338
+ retalk id --json {"user_id", "identity_key", "name"}""")
339
+ sp.add_argument("--json", action="store_true",
340
+ help="emit JSON with user_id, identity_key (base64 "
341
+ "Curve25519 public key), and name")
342
+ sp.set_defaults(fn=cmd_id)
343
+
344
+ sp = sub.add_parser(
345
+ "add", parents=[common], formatter_class=raw,
346
+ help="save a peer's user id under a local name",
347
+ description="""\
348
+ Save a peer's USER ID under a short local name, so `send bob ...` works
349
+ and incoming messages from that ID display as 'bob' instead of an
350
+ unverified '~name'. The name is yours alone — it never travels over
351
+ the network and the peer never learns it.
352
+
353
+ Get the peer's ID out-of-band (they run `retalk id`). Adding an existing
354
+ name overwrites it. No secret needed and no server contact — this only
355
+ writes your local peers table.""",
356
+ epilog="""\
357
+ examples:
358
+ retalk add bob f1041c25c87351d8550b31cc6b13ab04
359
+ retalk add bob <id> --pin "vGY3...=" also pin bob's full identity key
360
+
361
+ The fingerprint ID already pins the peer's keys; --pin adds an explicit
362
+ second check of the full identity key for belt-and-braces.""")
363
+ sp.add_argument("name", help="local name for this peer (e.g. 'bob')")
364
+ sp.add_argument("user_id", help="the peer's 32-hex user id")
365
+ sp.add_argument("--pin", metavar="KEY",
366
+ help="peer's full base64 identity key, verified against "
367
+ "everything the server serves for this peer")
368
+ sp.set_defaults(fn=cmd_add)
369
+
370
+ sp = sub.add_parser(
371
+ "send", parents=[common], formatter_class=raw,
372
+ help="encrypt and send one message",
373
+ description="""\
374
+ Encrypt TEXT for one peer and hand the ciphertext to the server, then
375
+ exit. The server (and anyone watching it) sees only the ciphertext and
376
+ the metadata sender/recipient/time/size — never the content.
377
+
378
+ PEER is a name saved with `retalk add`, or a raw 32-hex user id. The
379
+ first message to a new peer performs the key handshake automatically
380
+ (claiming one of the peer's one-time keys from the server); if the
381
+ server's served keys do not match the peer's ID fingerprint or your
382
+ saved --pin, the send refuses with PIN MISMATCH instead of encrypting to
383
+ an impostor key.
384
+
385
+ Delivery is tracked: the message stays in your local outbox until the
386
+ peer's client acknowledges decrypting it (acks arrive during your next
387
+ `receive`). Unacknowledged messages are re-sent automatically by
388
+ `receive --follow`, so nothing is lost if the server dies or is swapped.
389
+ On first contact with a server your public keys are published
390
+ automatically.""",
391
+ epilog="""\
392
+ examples:
393
+ retalk send bob "hello"
394
+ retalk send f1041c25c87351d8550b31cc6b13ab04 "hi, stranger"
395
+ retalk send bob "psst" -s ./alice --server http://127.0.0.1:8766""")
396
+ sp.add_argument("peer", help="saved peer name, or a raw 32-hex user id")
397
+ sp.add_argument("text", help="the message plaintext (quote it)")
398
+ sp.set_defaults(fn=cmd_send)
399
+
400
+ sp = sub.add_parser(
401
+ "receive", parents=[common], formatter_class=raw,
402
+ help="decrypt pending messages",
403
+ description="""\
404
+ Fetch this identity's mailbox from the server, decrypt each message, and
405
+ print it — `name: text` per line, where name is your saved peer name for
406
+ the sender, or their unverified self-chosen name marked '~', or the
407
+ bare sender id. Each successfully decrypted message is acknowledged back
408
+ to its sender (encrypted, like everything else).
409
+
410
+ Without --follow: drain the mailbox once and exit (good for cron and
411
+ scripts). With --follow: poll every 2 seconds until interrupted, and once
412
+ a minute run key maintenance — replenish one-time keys on the server,
413
+ rotate the fallback key daily, and re-send any of your own messages that
414
+ have gone unacknowledged for 2 minutes.
415
+
416
+ Messages the server already handed over are never served again, so pipe
417
+ --json output somewhere durable if you need a log.""",
418
+ epilog="""\
419
+ examples:
420
+ retalk receive drain once, human-readable
421
+ retalk receive --follow live tail + key maintenance
422
+ retalk receive --json | jq .text script-friendly, one object per line
423
+
424
+ json fields per message: "from" (sender id), "name" (your peer name,
425
+ '~name', or ''), "text" (the plaintext).""")
426
+ sp.add_argument("--follow", action="store_true",
427
+ help="keep polling every 2s and maintain keys every "
428
+ "60s until ctrl-c")
429
+ sp.add_argument("--json", action="store_true",
430
+ help="one JSON object per message on stdout "
431
+ "(banners stay on stderr)")
432
+ sp.set_defaults(fn=cmd_receive)
433
+
434
+ args = p.parse_args()
435
+ args.fn(args)
436
+
437
+
438
+ if __name__ == "__main__":
439
+ main()