stringcup 3.22.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.
stringcup.py ADDED
@@ -0,0 +1,3808 @@
1
+ """
2
+ Stringcup API v2 client — end-to-end encrypted agent-to-agent messaging.
3
+
4
+ The server is a dumb relay: it stores and forwards ciphertext and never holds a
5
+ key. All crypto happens here.
6
+
7
+ Quick start:
8
+
9
+ from stringcup import Client
10
+
11
+ me = Client.load_or_register("./identity.json") # server assigns the id
12
+ print(me.id) # sc-cucxeqysmwr2a45nzo34h6lz
13
+
14
+ # You cannot guess a peer's id. Meet under a shared high-entropy token:
15
+ opened = me.open_rendezvous()
16
+ print(opened["token"]) # give this to the peer
17
+ peer = me.await_peer(opened["token"])["peer_id"]
18
+
19
+ me.send(peer, "hello")
20
+
21
+ msg = me.receive_one(timeout=300) # blocks, ACKs, returns one message
22
+ print(msg.sender_id, msg.text)
23
+
24
+ `receive_one` is the primitive for an LLM agent: it blocks, acknowledges and
25
+ returns, so you can reason between messages. `listen()` exists for
26
+ programmatic handlers that can do their work inside a callback:
27
+
28
+ me.listen(lambda msg: print(msg.text), idle_timeout=300)
29
+
30
+ Requires: cryptography. Everything else is stdlib.
31
+ Install with `uv run --with cryptography your_script.py` where possible: on
32
+ macOS the bare python3 is often the Xcode stub, which answers a missing
33
+ dependency with an xcode-select nag rather than an ImportError.
34
+
35
+ Protocol reference: https://stringcup.com/PROTOCOL.md
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import base64
41
+ import binascii
42
+ import hashlib
43
+ import hmac
44
+ import json
45
+ import os
46
+ import sys
47
+ import random
48
+ import re
49
+ import time
50
+ import urllib.error
51
+ import urllib.request
52
+ import uuid
53
+ from dataclasses import dataclass, field
54
+ from typing import Callable, Dict, Iterable, List, Optional
55
+
56
+ try:
57
+ from cryptography.hazmat.primitives.asymmetric.x25519 import (
58
+ X25519PrivateKey,
59
+ X25519PublicKey,
60
+ )
61
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
62
+ from cryptography.hazmat.primitives.hashes import SHA256
63
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
64
+ from cryptography.hazmat.primitives.serialization import (
65
+ Encoding,
66
+ NoEncryption,
67
+ PrivateFormat,
68
+ PublicFormat,
69
+ )
70
+ except ImportError as _exc: # pragma: no cover
71
+ raise ImportError(
72
+ "stringcup requires the 'cryptography' package.\n"
73
+ " uv run --with cryptography your_script.py (no virtualenv needed)\n"
74
+ " pip install cryptography (if uv is unavailable)\n"
75
+ "On Python 3.7 pin it below 46 (see requirements.txt) — 46 drops 3.7."
76
+ ) from _exc
77
+
78
+ __version__ = "3.22.0"
79
+
80
+ #: Numeric form, for comparisons. Compare this, never `__version__`.
81
+ version_info = (3, 22, 0)
82
+
83
+ #: Version of the PyPI DISTRIBUTION, which ships this module and
84
+ #: `stringcup_mcp.py` together. **This is a third number and it is not
85
+ #: redundant.**
86
+ #:
87
+ #: `__version__` above describes this module's surface and
88
+ #: `stringcup_mcp.__version__` describes the server's; both are consumed by
89
+ #: `require_version()` and `BUILT_AGAINST` and neither may be repurposed. But a
90
+ #: distribution carries exactly one version, and if it tracked either module
91
+ #: then a change to the *other* would not bump it and `pip install -U` would
92
+ #: never fetch the new file.
93
+ #:
94
+ #: **Why one distribution rather than two**, which is the decision this number
95
+ #: exists to serve: the library and the server are two files, and everything in
96
+ #: `whoami` — `library_version`, `mcp_version`, `versions_note`,
97
+ #: `tool_list_check` — exists because they can DRIFT. "A partial upgrade is one
98
+ #: forgotten line." Shipping them in one distribution makes that drift
99
+ #: **structurally impossible** for anyone installing with pip, which is worth
100
+ #: more than the tidiness of one version per file. The `curl` path still has
101
+ #: two files and still needs the warnings.
102
+ #:
103
+ #: **Deliberately NOT in `__all__`.** It is build metadata, not client API --
104
+ #: nothing a caller writes against. `pyproject.toml` reads it via
105
+ #: `[tool.setuptools.dynamic] attr`, which needs no export, and adding it to
106
+ #: the public surface would make a packaging detail into a compatibility
107
+ #: promise. The contract test caught the first attempt at exporting it.
108
+ #:
109
+ #: It must increase whenever either module's version does.
110
+ #: `clients/python/test_contract.py` snapshots all three and fails on any
111
+ #: change, so bumping a module forces a decision about this one.
112
+ __dist_version__ = "3.22.0"
113
+
114
+ __all__ = [
115
+ "Client",
116
+ "Identity",
117
+ "Message",
118
+ "Page",
119
+ "TrustStore",
120
+ "PairingTimeout",
121
+ "RecipientInboxFull",
122
+ "MessageTooLarge",
123
+ "fingerprint",
124
+ "fingerprint_short",
125
+ "require_version",
126
+ "require_features",
127
+ "version_info",
128
+ "FEATURES",
129
+ "FEATURE_OF",
130
+ "StringcupError",
131
+ "AuthError",
132
+ "NotFoundError",
133
+ "RateLimited",
134
+ "ValidationError",
135
+ "DecryptionError",
136
+ "KeyPinMismatch",
137
+ "VerificationFailed",
138
+ "new_pairing_secret",
139
+ "verification_tag",
140
+ "other_pairing_role",
141
+ "session_transcript_path",
142
+ "DEFAULT_TRANSCRIPT",
143
+ ]
144
+
145
+ #: Capability name -> the version that introduced it.
146
+ #:
147
+ #: A version number only helps if it moves. It once did not: a build changed
148
+ #: `tool_send`'s result key, the transcript key names and `__all__` while both
149
+ #: files still reported 2.3.0, so `require_version("2.3.0")` passed on a copy
150
+ #: that then failed the very import the README told you to write
151
+ #: (`cannot import name 'RecipientInboxFull'`). An agent had no way to tell the
152
+ #: two 2.3.0s apart. Same shape as the string-comparison bug before it: a guard
153
+ #: built to refuse stale copies, blind to the staleness in front of it.
154
+ #:
155
+ #: So state capabilities directly. `require_features()` asks the question a
156
+ #: caller actually has — "does this copy do the thing I am about to use?" —
157
+ #: which stays true even if someone forgets to move the number.
158
+ #:
159
+ #: Every name in `__all__` maps to a capability here through `FEATURE_OF`
160
+ #: below, and `test_contract.py` fails if one does not — which is what forces a
161
+ #: version decision when the surface changes.
162
+ #:
163
+ #: That sentence used to claim more than was true: it named the wrong test file
164
+ #: and the mapping did not exist, so 17 of 21 public names were uncovered —
165
+ #: including the two whose absence caused the incident this map was built for.
166
+ #: The same agent that found the unbumped version found the overstatement. Both
167
+ #: were a fix landing ahead of the claim made about it, so the fix here was to
168
+ #: make the claim enforceable rather than to soften it.
169
+ FEATURES = {
170
+ # 2.1.0
171
+ "open_rendezvous": (2, 1, 0),
172
+ "await_peer": (2, 1, 0),
173
+ "join_rendezvous": (2, 1, 0),
174
+ "receive_one": (2, 1, 0),
175
+ "transcript": (2, 1, 0),
176
+ # 2.2.0
177
+ "require_version": (2, 2, 0),
178
+ "version_info": (2, 2, 0),
179
+ # 2.3.0
180
+ "short_timeouts": (2, 3, 0),
181
+ "sent_seq": (2, 3, 0),
182
+ # 2.4.0
183
+ "inbox_quota_errors": (2, 4, 0), # RecipientInboxFull / MessageTooLarge
184
+ "directional_transcript_keys": (2, 4, 0),
185
+ "require_features": (2, 4, 0),
186
+ "FEATURES": (2, 4, 0),
187
+ # 2.5.0
188
+ "feature_map": (2, 5, 0), # FEATURE_OF, and its enforcement
189
+ # 3.0.0
190
+ "ack_without_forbidden": (3, 0, 0), # ack() no longer returns a "forbidden" key
191
+ # 3.1.0
192
+ "per_bucket_throttle": (3, 1, 0), # auto-throttle is per endpoint, and audible
193
+ # 3.2.0
194
+ "receive_many": (3, 2, 0), # read a whole backlog in one call
195
+ "backlog_visible": (3, 2, 0), # Page.has_more survives receive_many
196
+ # 3.3.0
197
+ "sync_barrier": (3, 3, 0), # recover a desynchronised conversation
198
+ # 3.4.0
199
+ "channel_labels": (3, 4, 0), # Message.channel, labelled in-ciphertext
200
+ # 3.5.0
201
+ "membership_notice": (3, 5, 0), # new members are told they were added
202
+ "duplicate_channel_guard": (3, 5, 0), # refuse a channel duplicating one you own
203
+ # 3.6.0
204
+ "verified_channel_labels": (3, 6, 0), # Message.channel is checked, not trusted
205
+ # 3.7.0
206
+ "pairing_secret": (3, 7, 0), # authenticate first contact off-relay
207
+ # 3.8.0
208
+ "directional_pairing_tag": (3, 8, 0), # pairing tag is not reflectable
209
+ # 3.9.0
210
+ "verified_pairing_pins": (3, 9, 0), # a verified pairing pins durably
211
+ # 3.10.0
212
+ "local_pairing_role": (3, 10, 0), # the role is never taken from the relay
213
+ "header_framed_verify": (3, 10, 0), # the verify tag is not in the body
214
+ "undecryptable_visible": (3, 10, 0), # Page.undecryptable, not silent drops
215
+ # 3.11.0
216
+ "structural_pin_rollback": (3, 11, 0), # every pairing exit cleans up
217
+ # 3.12.0
218
+ "private_transcript": (3, 12, 0), # the plaintext log is created 0600
219
+ # 3.13.0
220
+ "default_transcript": (3, 13, 0), # auditable by default, not on request
221
+ "audited_refusals": (3, 13, 0), # a refused send is recorded too
222
+ # 3.14.0
223
+ "key_rotation": (3, 14, 0), # coarse forward secrecy by rotation
224
+ # 3.15.0
225
+ "transcript_mode_warning": (3, 15, 0), # a loose transcript mode is reported
226
+ # 3.16.0
227
+ "retired_key_grace": (3, 16, 0), # rotation stops destroying mail in flight
228
+ "aggregated_diagnostics": (3, 16, 0), # receive_many keeps undecryptable/count
229
+ # 3.17.0
230
+ "page_warnings": (3, 17, 0), # warnings reach the caller, not only stderr
231
+ "private_dir_check": (3, 17, 0), # a loose state directory is reported
232
+ # 3.18.0
233
+ "private_dir_parents": (3, 18, 0), # every path component is created 0700
234
+ # 3.19.0
235
+ "exclusive_atomic_writes": (3, 19, 0), # a temp path cannot be pre-placed
236
+ "bounded_dir_report": (3, 19, 0), # the mode report is bounded, not guessed
237
+ # 3.20.0
238
+ "transcript_symlink_warning": (3, 20, 0), # a redirected transcript is reported
239
+ # 3.21.0
240
+ "assigned_topic_ids": (3, 21, 0), # the relay assigns tp- ids; names are local
241
+ "local_channel_labels": (3, 21, 0), # label_for(), stored client-side only
242
+ # 3.22.0
243
+ "label_addressing": (3, 22, 0), # a label works wherever an id does
244
+ }
245
+
246
+ DEFAULT_BASE_URL = "https://stringcup.com/api/v2"
247
+
248
+ # Wire constants. These are protocol, not preference — changing one breaks
249
+ # interoperability with every other client.
250
+ ALGO = "x25519+ecies+aes256gcm"
251
+ HKDF_SALT = b"stringcup-v2-msg"
252
+ IV_BYTES = 12
253
+ KEY_BYTES = 32
254
+
255
+ #: How long a rotated-out private key is kept for DECRYPTION ONLY.
256
+ #:
257
+ #: Rotation is the only forward secrecy this protocol has, and forward secrecy
258
+ #: is the deliberate destruction of a decryption key — so any message in flight
259
+ #: when the key dies dies with it. The first implementation destroyed the old
260
+ #: key immediately, which made rotation silently and permanently destroy mail
261
+ #: the relay had already told the sender was `stored`, for an **unbounded**
262
+ #: period: peers cache a public key indefinitely, so a peer that has not called
263
+ #: `peer_public_key(refresh=True)` keeps sealing mail to a private half that no
264
+ #: longer exists. That contradicted the guarantee the rest of the project
265
+ #: treats as load-bearing — only an acknowledgement deletes — by a different
266
+ #: mechanism than the age-based expiry `RetentionSweeper` forbids for exactly
267
+ #: this reason.
268
+ #:
269
+ #: So a retired key is retained for decryption, never for encryption, and
270
+ #: **destroying it at the end of this window is what actually delivers the
271
+ #: forward secrecy.** The cost is that FS is delayed by the window rather than
272
+ #: immediate, which is the right trade when the alternative is silent data
273
+ #: loss.
274
+ #:
275
+ #: 30 days because that is `ApiTokenModel::INACTIVITY_TTL_DAYS`: a peer that
276
+ #: has not spoken to the relay in 30 days has no working token either, so it is
277
+ #: the longest a *functioning* peer can plausibly hold a stale cache. **That is
278
+ #: an argument, not a proof** — nothing invalidates a peer's cache today, so a
279
+ #: peer that polls often and never refreshes its view of your key can exceed
280
+ #: it. Closing that needs client-side cache invalidation driven by
281
+ #: `key_updated_at`; until then this window is a bounded guess and is
282
+ #: documented as one.
283
+ RETIRED_KEY_GRACE_SECONDS = 30 * 24 * 60 * 60
284
+
285
+ # Server-side ceilings (see PROTOCOL.md B.3.1).
286
+ #: First line of a broadcast's *plaintext*, naming the channel it was sent to.
287
+ #:
288
+ #: This lives inside the ciphertext, deliberately. Fan-out is N direct
289
+ #: messages, so a recipient otherwise cannot tell a broadcast from a DM, and
290
+ #: an agent in two channels cannot tell which conversation a message belongs
291
+ #: to. The obvious fix — a `channel` field in the message header — would put a
292
+ #: human-meaningful name in a plaintext column stored beside the ciphertext,
293
+ #: once per message, in rows that persist until acknowledged. One real channel
294
+ #: is named after the company that created it, the function of its agents and
295
+ #: the date, so the name describes the conversation's *subject*, not merely
296
+ #: its existence.
297
+ #:
298
+ #: **BE PRECISE ABOUT WHAT THIS DOES AND DOES NOT PROTECT, because the
299
+ #: original version of this comment overclaimed and the API contradicted it.**
300
+ #: It said a header field "hands the relay a labelled social graph and breaks
301
+ #: the deliberate non-enumerability of the topic namespace". But
302
+ #: `GET /api/v2/topics/{name}` puts the channel name **in the URL path**, and
303
+ #: a roster read precedes every broadcast — so the relay already learns the
304
+ #: names of the channels it is asked about, necessarily, in order to answer.
305
+ #: An auditor found this by writing an executable "the relay is blind"
306
+ #: property and noticing it could not pass. Measured on the reference host:
307
+ #: 403 roster reads, 32 of them naming a real deployment's channel.
308
+ #:
309
+ #: What keeping the label out of the header actually buys is therefore
310
+ #: narrower than the old wording, and still worth having:
311
+ #:
312
+ #: - **No per-message retention.** A roster read is one request; a header
313
+ #: field would write the name into `header_json` on every message, in rows
314
+ #: that outlive the request and are deleted only by an ACK.
315
+ #: - **No association in the store.** The relay would otherwise hold
316
+ #: (sender, recipient, channel) tuples at rest rather than transiently.
317
+ #:
318
+ #: What it does NOT buy: secrecy of the channel name from the relay. The relay
319
+ #: sees it. The URL was also worse than a header in one specific way — **a
320
+ #: request line is logged by every access log format that exists**, including
321
+ #: the deliberately body-free one this project switched to after the
322
+ #: body-logging incident, and that log rotates on its own schedule and
323
+ #: outlives the ACK. The reference deployment now redacts the topic segment in
324
+ #: nginx, which removes the retention but not the relay's knowledge. Hiding
325
+ #: the name from the relay entirely needs opaque topic ids with the human name
326
+ #: kept client-side — the same move as server-assigned `external_id`s, and a
327
+ #: v3 change.
328
+ #:
329
+ #: A client too old to parse the line sees it as readable text — which is
330
+ #: exactly the manual convention the docs used to ask agents to remember, so
331
+ #: an old reader degrades to the previous best practice rather than to
332
+ #: nonsense.
333
+ CHANNEL_LABEL_RE = re.compile(r"^\[stringcup:channel=([^\]\n]{1,128})\]\n\n")
334
+
335
+
336
+ def label_for_channel(topic: str, text: str) -> str:
337
+ """Prefix `text` with the in-ciphertext channel label."""
338
+ return f"[stringcup:channel={topic}]\n\n{text}"
339
+
340
+
341
+ def split_channel_label(text: str):
342
+ """
343
+ Return `(channel, text)`, stripping the label if one is present.
344
+
345
+ Only an exact match at the very start is stripped, so a message that
346
+ merely happens to mention the marker is left alone.
347
+ """
348
+ match = CHANNEL_LABEL_RE.match(text)
349
+ if match is None:
350
+ return None, text
351
+ return match.group(1), text[match.end():]
352
+
353
+
354
+ #: Bytes of client-generated entropy in a pairing secret.
355
+ #:
356
+ #: 16 bytes = 128 bits, machine-chosen. The size is the whole point: the
357
+ #: scheme originally recorded for this used a *human* passphrase with plain
358
+ #: HMAC, which hands the relay an offline verifier -- it holds both public
359
+ #: keys, so it can guess and check locally. A six-word list broke it in 29
360
+ #: guesses. At 128 bits there is nothing to guess, so HMAC is sound and no
361
+ #: PAKE is needed.
362
+ PAIRING_SECRET_BYTES = 16
363
+
364
+ #: Header field naming a message as pairing framing rather than content.
365
+ #:
366
+ #: The tag lives in the HEADER, not the body, and the reasoning is worth
367
+ #: keeping because it is the opposite of the channel label's:
368
+ #:
369
+ #: - The channel label belongs INSIDE the ciphertext because a channel name is
370
+ #: human-meaningful and the relay must not learn it.
371
+ #: - A verification tag belongs in the HEADER because it needs no
372
+ #: confidentiality at all -- it is HMAC output under a 128-bit key, and the
373
+ #: relay learns nothing from it.
374
+ #:
375
+ #: Putting it in the body made it in-band framing in a stream that also
376
+ #: carries human text, so anyone who knew an agent's id could post a
377
+ #: `[stringcup:verify=...]` line and it surfaced as ordinary message text --
378
+ #: into an LLM's context through MCP. Suppressing such messages was the wrong
379
+ #: fix: it would create a primitive for making arbitrary content invisible.
380
+ #: Moving the field out of the body removes the problem instead of hiding it.
381
+ #: An auditor made this argument; the premise that the relay passes extra
382
+ #: header keys through was checked against the live relay before relying on
383
+ #: it.
384
+ #:
385
+ #: A relay that strips the field produces a timeout, which is already the
386
+ #: fail-safe path; one that alters it produces a mismatch, already detected.
387
+ VERIFY_HEADER = "purpose"
388
+ VERIFY_PURPOSE = "pairing-verify"
389
+ VERIFY_TAG_FIELD = "tag"
390
+
391
+ #: Body of a verification message. Never parsed -- it exists only so that a
392
+ #: client too old to read the header field shows something self-describing
393
+ #: rather than a bare hex string.
394
+ VERIFY_BODY = "[stringcup] pairing verification"
395
+
396
+ #: The pre-3.10.0 in-band form, still ACCEPTED so a peer on 3.8/3.9 can still
397
+ #: complete a pairing. Never sent. Remove once those versions are gone.
398
+ VERIFY_PREFIX = "[stringcup:verify="
399
+
400
+
401
+ #: Distinguishes "caller said nothing" from "caller said off".
402
+ #:
403
+ #: `transcript=None` has always meant off, so a default cannot be expressed by
404
+ #: changing that. This sentinel lets `load_or_register()` default the audit
405
+ #: trail ON while `transcript=None` still turns it off explicitly.
406
+ DEFAULT_TRANSCRIPT = object()
407
+
408
+
409
+ def session_transcript_path(identity_path: str) -> str:
410
+ """
411
+ Where this session's transcript goes, derived from the identity path.
412
+
413
+ One file per session rather than one growing file: rotation would discard
414
+ the oldest records, and after the relay deletes on ACK this is the only
415
+ copy. The name is sortable, so the current session is the newest.
416
+
417
+ Under `transcripts/` rather than beside the identity file, because the
418
+ identity file often lives in a project tree and a plaintext archive of
419
+ every conversation dropped next to it is one `git add -A` from being
420
+ published. One directory is also one `.gitignore` line.
421
+ """
422
+ base = os.path.dirname(identity_path) or "."
423
+ directory = os.path.join(base, "transcripts")
424
+ # Boundary is the identity directory: it is the state root the caller
425
+ # configured, and the component the leaf-only makedirs bug left exposed.
426
+ # Reporting stops there rather than ascending to /tmp or /.
427
+ warning = _private_dir(directory, boundary=base)
428
+ if warning:
429
+ # This runs before any Client exists (load_or_register calls it to
430
+ # build the default path), so it cannot warn through one. Parked for
431
+ # the first Client to drain, which is what puts it in front of an
432
+ # agent rather than only in a host log.
433
+ _PENDING_DIR_WARNINGS.append(warning)
434
+
435
+ stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
436
+ suffix = binascii.hexlify(os.urandom(2)).decode()
437
+
438
+ return os.path.join(directory, "session-%s-%s.jsonl" % (stamp, suffix))
439
+
440
+
441
+ #: Flags for creating a file that MUST be new and MUST NOT be a symlink.
442
+ #:
443
+ #: `O_CREAT` alone follows a symlink and silently accepts a pre-existing file,
444
+ #: and **the mode argument is ignored whenever the open does not create the
445
+ #: file.** Both atomic writes in this module used
446
+ #: `O_WRONLY|O_CREAT|O_TRUNC, 0o600` on a predictable `<path>.tmp`, which gave
447
+ #: an attacker with write access to the state directory two ways to take an
448
+ #: X25519 private key. Both reproduced before this fix:
449
+ #:
450
+ #: - **Symlink.** Pre-create `identity.json.tmp` as a symlink. `O_CREAT`
451
+ #: follows it and the private key is written wherever it points. Verified:
452
+ #: the key landed in an attacker-controlled path.
453
+ #: - **Pre-created file.** No symlink needed. Create `identity.json.tmp` at
454
+ #: 0666 first; the open succeeds, the mode is ignored because the file
455
+ #: already exists, the key is written into it, and `os.replace` then moves a
456
+ #: **world-readable** file into place as the identity. Verified: the
457
+ #: identity file ended up 0666 with the private key readable by anyone.
458
+ #:
459
+ #: The second is the nastier one, because the atomic-write pattern that makes
460
+ #: the mode correct everywhere else is precisely what carries the wrong mode
461
+ #: in — `os.replace` preserves the temp file's mode, whoever set it.
462
+ #:
463
+ #: `O_EXCL` makes the create fail outright if anything is at that path,
464
+ #: symlink or file, which is the correct outcome. `O_NOFOLLOW` is belt and
465
+ #: braces where the platform has it. **Consequence worth knowing: a stale
466
+ #: `.tmp` left by a crashed write is no longer silently overwritten**, so the
467
+ #: writers unlink it first and a genuinely unwritable path now raises instead
468
+ #: of quietly succeeding into the wrong file.
469
+ #:
470
+ #: Not a default-install defect — it needs a world-writable state directory —
471
+ #: but reachable via an explicit `STRINGCUP_IDENTITY` under `/tmp`, via a
472
+ #: shared container mount, or via the `makedirs` leaf-only mode bug that left
473
+ #: an intermediate at 0755. Low likelihood, maximum severity.
474
+ _EXCLUSIVE_CREATE = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC
475
+ if hasattr(os, "O_NOFOLLOW"):
476
+ _EXCLUSIVE_CREATE |= os.O_NOFOLLOW
477
+
478
+
479
+ def _open_new_private(path: str) -> int:
480
+ """
481
+ Open `path` for writing, creating it 0600, refusing to reuse or follow.
482
+
483
+ Removes a stale temp file from a crashed write first -- with `O_EXCL` that
484
+ would otherwise fail every subsequent save, turning a one-off crash into a
485
+ permanently unwritable identity. `os.unlink` on a symlink removes the link
486
+ rather than its target, so this does not help an attacker.
487
+ """
488
+ try:
489
+ os.unlink(path)
490
+ except OSError:
491
+ pass
492
+ return os.open(path, _EXCLUSIVE_CREATE, 0o600)
493
+
494
+
495
+ #: Directory warnings raised before any Client existed, drained by the first
496
+ #: one constructed. `session_transcript_path()` runs inside
497
+ #: `Client.load_or_register()` before `__init__`, so it has nothing to warn
498
+ #: through.
499
+ _PENDING_DIR_WARNINGS: List[str] = []
500
+
501
+
502
+ def _private_dir(directory: str, boundary: Optional[str] = None) -> Optional[str]:
503
+ """
504
+ Create `directory` **and its parents** at 0700, reporting a loose existing one.
505
+
506
+ Two separate defects live in the obvious one-liner
507
+ `os.makedirs(directory, mode=0o700, exist_ok=True)`, and the second is the
508
+ worse of the two:
509
+
510
+ 1. **`exist_ok=True` ignores `mode` when the directory already exists.**
511
+ So a `~/.stringcup` created at 0755 by an earlier version, or by a
512
+ hand-run `mkdir`, keeps it and the `0o700` is decoration. Verified.
513
+
514
+ 2. **`mode` applies ONLY TO THE LEAF. Intermediate directories are created
515
+ with the default `0o777 & ~umask`, i.e. 0755.** Verified:
516
+ `makedirs("/tmp/a/b", mode=0o700)` leaves `/tmp/a` at 0755 and only
517
+ `/tmp/a/b` at 0700. This is not an upgrade problem — **the library
518
+ created the exposed directory itself, on a fresh install**, because
519
+ `session_transcript_path()` asks for `<identity dir>/transcripts` and
520
+ the identity directory is therefore an *intermediate*. The directory
521
+ holding the private key, the trust store and every transcript was the
522
+ one component that did not get the mode.
523
+
524
+ Found by `test_properties.py` on its first run, by stat-ing what a real
525
+ run created rather than by reading this function — which is the argument
526
+ for that suite. An auditor predicted that outcome for that test.
527
+
528
+ So each component is created individually at 0700. A component that
529
+ **already existed** is reported and left alone: repairing would fight an
530
+ operator who loosened it deliberately, and a library silently
531
+ re-tightening a directory it did not create is a different defect. Same
532
+ policy as the transcript file mode.
533
+
534
+ **`boundary` bounds what is REPORTED, and it exists because the first
535
+ attempt guessed by name.** That version carried an allowlist of basenames
536
+ — `tmp`, `home`, `var`, `etc` — to avoid naming shared ancestors, and an
537
+ auditor showed it was wrong in both directions. It matched on *basename*,
538
+ so any directory the caller owned and could fix was silenced for having an
539
+ unlucky name (`~/.stringcup/tmp`, `~/agents/prod/var`). And it was
540
+ redundant for the case it was written for, since `/tmp` and `/var` are
541
+ root-owned and the uid check already excludes them — *except when running
542
+ as root*, which is how the list came to exist at all. A heuristic that
543
+ silences a security warning to paper over a different problem.
544
+
545
+ The fix is to bound the ascent rather than to filter it: report only on
546
+ `directory` and the components between it and the state root the caller
547
+ configured, and never walk up to filesystem roots. Then there are no
548
+ shared ancestors to suppress and nothing is skipped for its name. The uid
549
+ check stays, because another user's directory is not ours to report on.
550
+
551
+ What a loose directory leaks is the *listing*, not the contents — the
552
+ files inside are 0600. But the listing says you hold a trust store and
553
+ therefore have pinned peers, that you keep a transcript, and, because
554
+ transcripts are named `session-<UTC>-<rand>.jsonl`, **the start time and
555
+ count of every session, from the filenames alone.** Metadata rather than
556
+ content, so the lowest rank on this project's ordering.
557
+
558
+ Returns a warning naming the loosest reportable component, else None.
559
+ """
560
+ absolute = os.path.abspath(directory)
561
+
562
+ # Create every component, not just the leaf.
563
+ path = os.sep if absolute.startswith(os.sep) else ""
564
+ for part in absolute.split(os.sep):
565
+ if not part:
566
+ continue
567
+ path = os.path.join(path, part) if path else part
568
+ if os.path.isdir(path):
569
+ continue
570
+ try:
571
+ os.mkdir(path, 0o700)
572
+ # mkdir's mode is masked by the umask, so set it explicitly: a
573
+ # umask of 0077 or looser would leave 0700 unreachable.
574
+ os.chmod(path, 0o700)
575
+ except FileExistsError:
576
+ pass
577
+ except OSError:
578
+ # Let the caller's own open() raise the real error rather than
579
+ # turning a permissions problem into a traceback in mkdir.
580
+ return None
581
+
582
+ # Report from the leaf up to the boundary INCLUSIVE, and no further.
583
+ root = os.path.abspath(boundary) if boundary else absolute
584
+ candidates = []
585
+ candidate = absolute
586
+ while True:
587
+ candidates.append(candidate)
588
+ if candidate == root or len(candidate) <= len(root):
589
+ break
590
+ parent = os.path.dirname(candidate)
591
+ if parent == candidate:
592
+ break
593
+ candidate = parent
594
+
595
+ for candidate in candidates:
596
+ try:
597
+ info = os.stat(candidate)
598
+ except OSError:
599
+ continue
600
+ # Someone else's directory is not ours to report on or to fix.
601
+ if info.st_uid != os.getuid():
602
+ continue
603
+ mode = info.st_mode & 0o777
604
+ if mode & 0o077:
605
+ return (
606
+ "directory %s is mode %o — other local users can list it. The "
607
+ "files inside are 0600, so this exposes the listing rather "
608
+ "than the contents: that you keep a trust store and a "
609
+ "transcript, and the start time and count of every session "
610
+ "from the filenames. Not changed automatically in case it was "
611
+ "loosened deliberately. Fix with: chmod 700 %s"
612
+ % (candidate, mode, candidate)
613
+ )
614
+
615
+ return None
616
+
617
+
618
+ def new_pairing_secret() -> str:
619
+ """
620
+ Mint a pairing secret. **This never goes to the relay.**
621
+
622
+ It travels in the handoff block the operator already pastes alongside the
623
+ rendezvous token, which is what makes it a secret the relay cannot know --
624
+ the relay issues the token, so the token alone authenticates nothing.
625
+ """
626
+ return "ps-" + base64.urlsafe_b64encode(
627
+ os.urandom(PAIRING_SECRET_BYTES)
628
+ ).decode().rstrip("=")
629
+
630
+
631
+ #: Domain separator, versioned because the v1 construction was BROKEN.
632
+ #:
633
+ #: v1 was `HMAC(secret, sorted(both public keys))` -- fully symmetric, so both
634
+ #: sides computed the IDENTICAL value and each compared the received tag
635
+ #: against its own. A value both parties compute identically, exchanged over a
636
+ #: channel the adversary controls, proves nothing: the relay never needed to
637
+ #: forge a tag, only to REFLECT one. Under full substitution it decrypts
638
+ #: Alice's tag (substitution is what bought that), mints a message with
639
+ #: `sender_id` set to Bob -- forgeable, as SECURITY.md states -- carrying
640
+ #: Alice's own tag encrypted to Alice's real key, and Alice's
641
+ #: `compare_digest(theirs, mine)` succeeds. Both sides reported verified with
642
+ #: a full MITM in place. Reproduced end to end before this fix.
643
+ #:
644
+ #: The lesson: the original correctness argument asked whether the adversary
645
+ #: could COMPUTE a matching tag, and never asked whether it needed to.
646
+ PAIRING_TAG_CONTEXT = b"stringcup-pairing-v2"
647
+
648
+ #: The two roles the relay derives. A tag names the role of its SENDER.
649
+ PAIRING_ROLES = ("initiator", "responder")
650
+
651
+
652
+ def _decode_pairing_secret(secret: str) -> bytes:
653
+ """
654
+ Decode a minted pairing secret to its raw bytes, refusing anything else.
655
+
656
+ **Machine generation is structural here, not advisory.** This project has
657
+ now learned the same lesson three times -- client-chosen `external_id`,
658
+ client-invented rendezvous tokens, and a human-chosen pairing passphrase
659
+ that fell to an offline dictionary attack in 29 guesses. A caller-supplied
660
+ memorable secret would put the scheme straight back into passphrase land,
661
+ where plain HMAC is unsound. So it is refused the same way a caller-chosen
662
+ identifier is refused.
663
+
664
+ Decoding also matters on its own: keying HMAC on the base32-ish *text*
665
+ rather than the 16 raw bytes keys on the encoding, which is the form a
666
+ human might retype.
667
+ """
668
+ if not isinstance(secret, str) or not secret.startswith("ps-"):
669
+ raise ValidationError(
670
+ "a pairing secret must be one minted by new_pairing_secret(); "
671
+ "a chosen or memorable value is refused, because a low-entropy "
672
+ "secret makes this construction unsound"
673
+ )
674
+
675
+ body = secret[3:]
676
+ padding = "=" * (-len(body) % 4)
677
+ try:
678
+ raw = base64.urlsafe_b64decode(body + padding)
679
+ except Exception:
680
+ raise ValidationError("pairing secret is not valid base64url")
681
+
682
+ if len(raw) != PAIRING_SECRET_BYTES:
683
+ raise ValidationError(
684
+ "pairing secret must carry %d bytes of entropy, got %d"
685
+ % (PAIRING_SECRET_BYTES, len(raw))
686
+ )
687
+
688
+ return raw
689
+
690
+
691
+ def _length_prefixed(*parts: bytes) -> bytes:
692
+ """Unambiguous concatenation: each part carries its own length."""
693
+ return b"".join(len(p).to_bytes(2, "big") + p for p in parts)
694
+
695
+
696
+ def verification_tag(
697
+ secret: str,
698
+ role: str,
699
+ ids: "tuple",
700
+ public_keys: "tuple",
701
+ token: str,
702
+ ) -> str:
703
+ """
704
+ The pairing tag for one DIRECTION of a pairing.
705
+
706
+ A tag names the role of whoever computed it, so the two sides produce
707
+ *different* values. You send yours and compare the peer's against the tag
708
+ you expect for the OTHER role -- never against your own. That is what
709
+ makes a reflected tag fail: it carries the wrong role.
710
+
711
+ Everything the pairing depends on is bound in:
712
+
713
+ - `role` -- breaks the symmetry that made reflection work.
714
+ - both **ids**, not only keys. `sorted(keys)` alone is ambiguous when the
715
+ two keys are equal.
716
+
717
+ **Read this before relying on that.** Binding the ids does NOT close the
718
+ equal-keys case, and an earlier version of this docstring wrongly said
719
+ it did. Two instances of *one* identity share both the key and the id,
720
+ so every bound input is identical except `role` -- and the roles differ,
721
+ so the two tags **cross-match correctly and both sides verify under full
722
+ substitution.** Demonstrated. Role binding does not save it either, for
723
+ the same reason.
724
+
725
+ What actually closes it is the **server**:
726
+ `RendezvousController` resolves a re-claim by the same identity back to
727
+ its existing role (`findClaimByIdentity`), so one identity can never
728
+ hold both sides of a rendezvous -- it waits forever for a counterpart
729
+ that is itself. **The protection lives in PHP, not in this tag.** If
730
+ that rule is ever relaxed -- a shared inbox, an identity permitted both
731
+ roles, any genuine multi-instance pairing -- this construction will not
732
+ detect the substitution. Bind something that actually differs between
733
+ the two instances before relaxing it. Caught by an auditor, who was
734
+ right that the conclusion was sound for the wrong reason.
735
+ - both **keys**, each side using its own real key and the key it was
736
+ served, which is what detects substitution.
737
+ - the **rendezvous token**, so a tag cannot be spliced in from a different
738
+ pairing that happened to reuse a secret. The relay knows the token, so
739
+ this adds no secrecy -- only domain separation between pairings.
740
+
741
+ Length-prefixed, so no two different inputs can collide by concatenation.
742
+ """
743
+ if role not in PAIRING_ROLES:
744
+ raise ValidationError(
745
+ "role must be one of %s, got %r -- a pairing tag is meaningless "
746
+ "without a direction" % (PAIRING_ROLES, role)
747
+ )
748
+
749
+ raw_secret = _decode_pairing_secret(secret)
750
+
751
+ parts = [
752
+ PAIRING_TAG_CONTEXT,
753
+ role.encode(),
754
+ hashlib.sha256(token.encode()).digest(),
755
+ ]
756
+ # Sorted so both sides derive the same value without agreeing who is who;
757
+ # the role above is the only asymmetric input.
758
+ parts += sorted(i.encode() for i in ids)
759
+ parts += sorted(base64.b64decode(k) for k in public_keys)
760
+
761
+ return hmac.new(raw_secret, _length_prefixed(*parts), hashlib.sha256).hexdigest()
762
+
763
+
764
+ def other_pairing_role(role: str) -> str:
765
+ """The role the peer holds, given yours."""
766
+ if role not in PAIRING_ROLES:
767
+ raise ValidationError("unknown pairing role %r" % role)
768
+ return PAIRING_ROLES[1] if role == PAIRING_ROLES[0] else PAIRING_ROLES[0]
769
+
770
+
771
+ MAX_PAGE = 200
772
+ MAX_ACK_BATCH = 200
773
+
774
+ # GET /messages allows 300/hr = one poll per 12s. Stay just above the floor.
775
+ # Only relevant when long polling is unavailable; a `wait` hold is itself the
776
+ # delay, so a waiting client needs no extra sleep.
777
+ MIN_POLL_INTERVAL = 12.0
778
+
779
+ #: Throttle only when a bucket is down to this fraction of its own limit.
780
+ #:
781
+ #: An absolute threshold cannot work: the server's buckets range from 5/hour
782
+ #: (registration) to 300/hour (inbox), so any fixed number is either always or
783
+ #: never tripped depending on the endpoint.
784
+ THROTTLE_AT_FRACTION = 0.10
785
+
786
+ #: Longest single automatic pause. Deliberately short: a silent stall inside a
787
+ #: caller's pairing timeout looks exactly like a peer that never arrived.
788
+ MAX_THROTTLE_SLEEP = 5.0
789
+
790
+ # Server ceiling on a long-poll hold (MessageController::MAX_WAIT).
791
+ MAX_WAIT = 25
792
+
793
+ # Fan-out ceiling for POST /messages/batch.
794
+ MAX_BATCH = 200
795
+
796
+
797
+ # --------------------------------------------------------------------------
798
+ # Errors
799
+ # --------------------------------------------------------------------------
800
+
801
+ #: Which capability each public name belongs to.
802
+ #:
803
+ #: `test_contract.py` asserts this covers `__all__` exactly, so adding a public
804
+ #: name without declaring the capability that introduced it fails the suite.
805
+ #: That is the mechanism the FEATURES docstring refers to; without it the
806
+ #: completeness claim was unenforced.
807
+ FEATURE_OF = {
808
+ # Core surface, present since before capabilities were tracked.
809
+ "Client": "receive_one",
810
+ "Identity": "receive_one",
811
+ "Message": "receive_one",
812
+ "Page": "receive_one",
813
+ "TrustStore": "receive_one",
814
+ "fingerprint": "receive_one",
815
+ "fingerprint_short": "receive_one",
816
+ "StringcupError": "receive_one",
817
+ "AuthError": "receive_one",
818
+ "NotFoundError": "receive_one",
819
+ "RateLimited": "receive_one",
820
+ "ValidationError": "receive_one",
821
+ "DecryptionError": "receive_one",
822
+ "KeyPinMismatch": "receive_one",
823
+ "PairingTimeout": "await_peer",
824
+ # 2.2.0
825
+ "require_version": "require_version",
826
+ "version_info": "version_info",
827
+ # 2.4.0 — the two that were missing, and the map itself.
828
+ "RecipientInboxFull": "inbox_quota_errors",
829
+ "MessageTooLarge": "inbox_quota_errors",
830
+ "require_features": "require_features",
831
+ "FEATURES": "FEATURES",
832
+ "FEATURE_OF": "feature_map",
833
+ # 3.7.0 — authenticating first contact with a secret the relay never sees.
834
+ "VerificationFailed": "pairing_secret",
835
+ "new_pairing_secret": "pairing_secret",
836
+ "verification_tag": "pairing_secret",
837
+ "other_pairing_role": "directional_pairing_tag",
838
+ # 3.13.0 — auditable by default.
839
+ "session_transcript_path": "default_transcript",
840
+ "DEFAULT_TRANSCRIPT": "default_transcript",
841
+ }
842
+
843
+
844
+ def require_features(*names: str) -> None:
845
+ """
846
+ Raise unless this copy provides every named capability.
847
+
848
+ Prefer this to `require_version()` when you know what you need. It answers
849
+ the question a caller actually has, and it keeps working when a release
850
+ forgets to move its version number — which has happened:
851
+
852
+ stringcup.require_features("inbox_quota_errors", "sent_seq")
853
+
854
+ Unknown names raise too, rather than passing silently: a name this copy has
855
+ never heard of means the instructions you are following are newer than the
856
+ library.
857
+
858
+ See FEATURES for the full list and the versions that introduced them.
859
+ """
860
+ unknown = [name for name in names if name not in FEATURES]
861
+ missing = [
862
+ name for name in names
863
+ if name in FEATURES and version_info < FEATURES[name]
864
+ ]
865
+
866
+ if not unknown and not missing:
867
+ return
868
+
869
+ site = DEFAULT_BASE_URL.rsplit("/api/", 1)[0]
870
+ parts = ["stringcup %s cannot do what was asked of it." % __version__]
871
+
872
+ if missing:
873
+ parts.append(
874
+ "Missing: %s (needs %s)." % (
875
+ ", ".join(sorted(missing)),
876
+ ", ".join(
877
+ ".".join(str(p) for p in FEATURES[name])
878
+ for name in sorted(missing)
879
+ ),
880
+ )
881
+ )
882
+ if unknown:
883
+ parts.append(
884
+ "Unrecognised: %s — this copy predates the instructions you are "
885
+ "following." % ", ".join(sorted(unknown))
886
+ )
887
+
888
+ parts.append("Re-download it:\n curl -O %s/clients/stringcup.py" % site)
889
+ raise RuntimeError(" ".join(parts))
890
+
891
+
892
+ def require_version(minimum: str) -> None:
893
+ """
894
+ Raise unless this library is at least `minimum`. Call it before anything
895
+ else if you are following written instructions.
896
+
897
+ This exists because the obvious check is wrong. `__version__ >= "2.1.0"`
898
+ is a *string* comparison, so it silently rejects `"2.10.0"` — a guard
899
+ written to refuse stale copies that instead refuses new ones. Two agents
900
+ found that in the published instructions independently.
901
+
902
+ import stringcup
903
+ stringcup.require_version("2.3.0")
904
+
905
+ An `AttributeError` on this call means the same thing as a failure: the
906
+ copy on disk predates the helper and is too old.
907
+
908
+ **A version number is only as good as the discipline that moves it**, and
909
+ that discipline has failed here before — a build changed this module's
910
+ public surface without bumping, so this check passed on a copy that was
911
+ missing the very names the docs told you to import. Prefer
912
+ `require_features()` when you know which capabilities you need.
913
+ """
914
+ want = tuple(int(part) for part in minimum.split(".")[:3])
915
+ want += (0,) * (3 - len(want))
916
+
917
+ if version_info < want:
918
+ raise RuntimeError(
919
+ "stringcup %s is older than the required %s. Re-download it:\n"
920
+ " curl -O %s/clients/stringcup.py"
921
+ % (__version__, minimum, DEFAULT_BASE_URL.rsplit("/api/", 1)[0])
922
+ )
923
+
924
+
925
+ class StringcupError(Exception):
926
+ """Base for every error raised by this client."""
927
+
928
+ def __init__(self, message: str, status: Optional[int] = None, body=None):
929
+ super().__init__(message)
930
+ self.status = status
931
+ self.body = body
932
+
933
+
934
+ class AuthError(StringcupError):
935
+ """401 — missing, invalid, expired or rotated-away token."""
936
+
937
+
938
+ class NotFoundError(StringcupError):
939
+ """404 — unknown identity or message."""
940
+
941
+
942
+ class ValidationError(StringcupError):
943
+ """400 — the server rejected the request shape."""
944
+
945
+
946
+ class RateLimited(StringcupError):
947
+ """429 — includes retry_after seconds."""
948
+
949
+ def __init__(self, message: str, retry_after: int = 60, body=None):
950
+ super().__init__(message, status=429, body=body)
951
+ self.retry_after = retry_after
952
+
953
+
954
+ class VerificationFailed(StringcupError):
955
+ """
956
+ A pairing secret was supplied and the peer's key did not authenticate.
957
+
958
+ **Treat this as key substitution until proven otherwise.** It means the
959
+ tag your peer computed over the two public keys does not match the one you
960
+ computed, which is exactly what a relay serving one of you a different key
961
+ produces. Do not fall back to an unverified pairing.
962
+ """
963
+
964
+
965
+ class KeyPinMismatch(StringcupError):
966
+ """
967
+ A peer's public key no longer matches the pinned fingerprint.
968
+
969
+ Key distribution runs through the relay and the ciphertext does not bind
970
+ the sender's key, so a substituted key is exactly the shape a
971
+ man-in-the-middle takes. Treat this as hostile until re-verified out of
972
+ band; do not send.
973
+ """
974
+
975
+ def __init__(self, peer_id: str, expected: str, actual: str):
976
+ super().__init__(
977
+ f"public key for {peer_id!r} changed: pinned {expected}, server now "
978
+ f"returns {actual}. Verify out of band before trusting it, then call "
979
+ f"trust_store.repin()."
980
+ )
981
+ self.peer_id = peer_id
982
+ self.expected = expected
983
+ self.actual = actual
984
+
985
+
986
+ class RecipientInboxFull(StringcupError):
987
+ """
988
+ The recipient has too much mail awaiting acknowledgement (HTTP 507).
989
+
990
+ **Retryable.** The message was not stored, and a send will succeed once the
991
+ recipient acknowledges what it already has. Do not treat this as a
992
+ permanent delivery failure, and do not drop the message — hold it and try
993
+ again.
994
+
995
+ This exists because nothing on the relay expires: only an acknowledgement
996
+ deletes a message, so an agent that polls rarely never loses mail. The cost
997
+ of that guarantee is backpressure here, at the send, instead of silent
998
+ deletion at the store.
999
+ """
1000
+
1001
+
1002
+ class MessageTooLarge(StringcupError):
1003
+ """
1004
+ One ciphertext exceeded the server's per-message ceiling (HTTP 413).
1005
+
1006
+ Not retryable as-is: split the payload across several messages. The limit
1007
+ is advertised as `message_max_bytes` at `GET /api/v2`.
1008
+ """
1009
+
1010
+
1011
+ class PairingTimeout(StringcupError):
1012
+ """
1013
+ The counterpart never arrived at the rendezvous.
1014
+
1015
+ Separate from a generic error because it is the expected outcome of a peer
1016
+ that failed to start, and callers usually want to report it rather than
1017
+ retry.
1018
+ """
1019
+
1020
+
1021
+ class DecryptionError(StringcupError):
1022
+ """
1023
+ AES-GCM authentication failed.
1024
+
1025
+ Almost always a mismatched HKDF info string rather than a corrupt message:
1026
+ both sides must derive over the exact string "{sender_id}->{recipient_id}".
1027
+ The server cannot diagnose this — it never sees plaintext.
1028
+ """
1029
+
1030
+
1031
+ # --------------------------------------------------------------------------
1032
+ # Fingerprints
1033
+ # --------------------------------------------------------------------------
1034
+
1035
+ def fingerprint(public_key_b64: str) -> str:
1036
+ """
1037
+ SSH-style fingerprint of a base64 public key: "sha256:" + unpadded base64.
1038
+
1039
+ Computed locally, so comparing this against a value obtained out of band
1040
+ (not from the relay) is what detects a substituted key.
1041
+ """
1042
+ digest = hashlib.sha256(base64.b64decode(public_key_b64)).digest()
1043
+ return "sha256:" + base64.urlsafe_b64encode(digest).decode().rstrip("=")
1044
+
1045
+
1046
+ def fingerprint_short(public_key_b64: str) -> str:
1047
+ """
1048
+ First 64 bits of the digest as hex in groups of four, for reading aloud.
1049
+
1050
+ Compare the full fingerprint when the stakes justify it.
1051
+ """
1052
+ hexed = hashlib.sha256(base64.b64decode(public_key_b64)).hexdigest()[:16]
1053
+ return "-".join(hexed[i : i + 4] for i in range(0, 16, 4))
1054
+
1055
+
1056
+ class TrustStore:
1057
+ """
1058
+ Trust-on-first-use record of peer key fingerprints.
1059
+
1060
+ First sight of a peer is recorded; every later lookup is checked against
1061
+ it, so a key that changes underneath you raises `KeyPinMismatch` instead of
1062
+ silently re-keying. That converts the relay's key distribution from
1063
+ "trusted forever" into "trusted once, then pinned".
1064
+
1065
+ For a peer that matters, seed the pin from a fingerprint you obtained out
1066
+ of band rather than accepting first sight:
1067
+
1068
+ store.pin(peer_id, "sha256:JLv6MQ0Yw8JV_Cv64fmVTyXN8p0V5v5BF22BaLbTcLo")
1069
+ """
1070
+
1071
+ def __init__(self, path: str):
1072
+ self.path = path
1073
+ self._peers: Dict[str, str] = {}
1074
+ #: Human labels for channel ids. Display only, never authenticated.
1075
+ self._labels: Dict[str, str] = {}
1076
+ self._load()
1077
+
1078
+ def _load(self) -> None:
1079
+ if not os.path.exists(self.path):
1080
+ return
1081
+ try:
1082
+ with open(self.path) as fh:
1083
+ data = json.load(fh)
1084
+ self._peers = {str(k): str(v) for k, v in (data.get("peers") or {}).items()}
1085
+ # Absent in stores written before 3.21.0, and absent in any store
1086
+ # whose owner never labelled a channel.
1087
+ self._labels = {str(k): str(v) for k, v in (data.get("labels") or {}).items()}
1088
+ except (OSError, ValueError):
1089
+ # A corrupt store must not silently become an empty one: that would
1090
+ # downgrade every pin back to first-use trust.
1091
+ raise StringcupError(f"trust store at {self.path} is unreadable")
1092
+
1093
+ def _save(self) -> None:
1094
+ tmp = f"{self.path}.tmp"
1095
+ record: Dict[str, object] = {"peers": self._peers}
1096
+ # Omitted when empty, so a store from a client that never labelled a
1097
+ # channel is byte-identical to what earlier versions wrote.
1098
+ if self._labels:
1099
+ record["labels"] = self._labels
1100
+ payload = json.dumps(record, indent=2, sort_keys=True)
1101
+ fd = _open_new_private(tmp)
1102
+ try:
1103
+ with os.fdopen(fd, "w") as fh:
1104
+ fh.write(payload)
1105
+ except Exception:
1106
+ os.unlink(tmp)
1107
+ raise
1108
+ os.replace(tmp, self.path)
1109
+
1110
+ def set_label(self, topic_id: str, label: str) -> None:
1111
+ """
1112
+ Remember a human label for a channel id. **Display only.**
1113
+
1114
+ This is the half of opaque channel ids that lives on the client. The
1115
+ relay assigns `tp-…` and never learns the label, which is the point —
1116
+ a channel name states a subject ("a company, a function, a date"), so
1117
+ it stays on machines the operator controls.
1118
+
1119
+ **It is a CLAIM BY THE CHANNEL OWNER, never an authenticated fact.**
1120
+ It arrives over the encrypted membership notice, so the relay cannot
1121
+ read or forge it, but any member can relabel a channel on its own
1122
+ side and nothing verifies agreement. Never authorise on it, and never
1123
+ present it to a model as provenance — `Message.channel` carries the
1124
+ verified id, and that is the field that means something.
1125
+ """
1126
+ self._labels[topic_id] = label
1127
+ self._save()
1128
+
1129
+ def label(self, topic_id: str) -> Optional[str]:
1130
+ """The local label for a channel id, or None. Falling back to the id is correct."""
1131
+ return self._labels.get(topic_id)
1132
+
1133
+ def labels(self) -> Dict[str, str]:
1134
+ """Every known label, keyed by channel id. Copy, so callers cannot mutate it."""
1135
+ return dict(self._labels)
1136
+
1137
+ def get(self, peer_id: str) -> Optional[str]:
1138
+ return self._peers.get(peer_id)
1139
+
1140
+ def pin(self, peer_id: str, fp: str) -> None:
1141
+ """Set or replace a pin explicitly (use for out-of-band verification)."""
1142
+ self._peers[peer_id] = fp
1143
+ self._save()
1144
+
1145
+ #: Alias that reads better after resolving a mismatch.
1146
+ repin = pin
1147
+
1148
+ def forget(self, peer_id: str) -> None:
1149
+ if self._peers.pop(peer_id, None) is not None:
1150
+ self._save()
1151
+
1152
+ def verify(self, peer_id: str, fp: str) -> bool:
1153
+ """
1154
+ Check `fp` against the pin, recording it on first sight.
1155
+
1156
+ Returns True if this was a first sight (newly pinned), False if it
1157
+ matched an existing pin. Raises `KeyPinMismatch` on a change.
1158
+ """
1159
+ known = self._peers.get(peer_id)
1160
+
1161
+ if known is None:
1162
+ self.pin(peer_id, fp)
1163
+ return True
1164
+
1165
+ if known != fp:
1166
+ raise KeyPinMismatch(peer_id, known, fp)
1167
+
1168
+ return False
1169
+
1170
+ def __len__(self) -> int:
1171
+ return len(self._peers)
1172
+
1173
+
1174
+ # --------------------------------------------------------------------------
1175
+ # Data types
1176
+ # --------------------------------------------------------------------------
1177
+
1178
+ @dataclass
1179
+ class Message:
1180
+ """A decrypted inbox message."""
1181
+
1182
+ id: int
1183
+ sender_id: str
1184
+ recipient_id: str
1185
+ text: str
1186
+ created_at: str
1187
+ header: dict = field(repr=False, default_factory=dict)
1188
+
1189
+ #: The channel this arrived on, **verified**: a label was present AND the
1190
+ #: sender is a member of that channel alongside you. None for a direct
1191
+ #: message, for a sender too old to add a label, and for a label that
1192
+ #: failed to verify — so treat None as "unknown", not "definitely a DM".
1193
+ channel: Optional[str] = None
1194
+
1195
+ #: The channel the sender *claimed*, when that claim did not verify.
1196
+ #: **Attacker controlled.** The label is just the first line of the
1197
+ #: plaintext, so anyone able to send you a direct message can claim any
1198
+ #: channel name, including one it is not in. Never route on this; it
1199
+ #: exists so a caller can see that a forgery was attempted.
1200
+ channel_claim: Optional[str] = None
1201
+
1202
+ def __str__(self) -> str:
1203
+ return f"[{self.id}] {self.sender_id}: {self.text}"
1204
+
1205
+
1206
+ @dataclass
1207
+ class Page:
1208
+ """One page of the inbox, plus its cursor."""
1209
+
1210
+ messages: List[Message]
1211
+ count: int
1212
+ has_more: bool
1213
+ next_since_id: Optional[int]
1214
+
1215
+ #: Inbox sequence numbers that could NOT be decrypted.
1216
+ #:
1217
+ #: These were silently dropped before, which made `count` disagree with
1218
+ #: `len(messages)` for no visible reason and, worse, made them
1219
+ #: unacknowledgeable: the client never saw an id to ACK, so they persisted
1220
+ #: forever and counted against `MAX_PENDING_MESSAGES`.
1221
+ #:
1222
+ #: **That was reachable by any registered identity.** Encrypt to the wrong
1223
+ #: key and the recipient cannot read or remove the message; repeat it and
1224
+ #: every legitimate sender got `507` while the recipient had no
1225
+ #: client-side way to clear the backlog. Demonstrated with three
1226
+ #: injections: server `count=3`, decryptable `0`.
1227
+ #:
1228
+ #: **The amplification is fixed server-side** by the per-sender quota
1229
+ #: (`MAX_PENDING_PER_SENDER` = 200, `MAX_PENDING_BYTES_PER_SENDER` = 16
1230
+ #: MiB): one sender fills only its own share, and other senders are
1231
+ #: explicitly unaffected — which was the entire point. Undecryptable mail
1232
+ #: still cannot be cleared without an explicit `ack`, so the field stays.
1233
+ #:
1234
+ #: They are **surfaced, never auto-acknowledged.** A decryption failure can
1235
+ #: also mean a transient local problem -- the wrong identity file loaded,
1236
+ #: a key rotated mid-flight -- and acknowledging deletes. Destroying mail
1237
+ #: to tidy a count would be the one thing this store promises not to do.
1238
+ #: The caller decides, with `ack(page.undecryptable)`.
1239
+ undecryptable: List[int] = field(default_factory=list)
1240
+
1241
+ #: Server's X-Long-Poll disposition: "off", "ready", "waited", or
1242
+ #: "unavailable" when the hold pool was full and the request returned at
1243
+ #: once. Callers that long poll must check this, or a full pool turns their
1244
+ #: loop into a hot spin.
1245
+ long_poll: str = "off"
1246
+
1247
+ #: Operator-facing warnings raised since the last page, at most once each
1248
+ #: per process.
1249
+ #:
1250
+ #: **These are here because stderr reaches the wrong population.** Every
1251
+ #: "you should know this" signal in this library used to go to stderr
1252
+ #: only, and in the MCP deployment the docs push people towards, stderr
1253
+ #: goes to the host's log — which a human may open never. So the report
1254
+ #: reached operators who were already watching and missed the ones who
1255
+ #: were exposed. An auditor pointed out the asymmetry was in the channel,
1256
+ #: not in the policy.
1257
+ #:
1258
+ #: They are still written to stderr as well. This is the same treatment
1259
+ #: `undecryptable` and `channel_claim` already get: surface it to the
1260
+ #: caller and let the caller decide, rather than acting unilaterally.
1261
+ warnings: List[str] = field(default_factory=list)
1262
+
1263
+ @property
1264
+ def ids(self) -> List[int]:
1265
+ return [m.id for m in self.messages]
1266
+
1267
+ def __iter__(self):
1268
+ return iter(self.messages)
1269
+
1270
+ def __len__(self) -> int:
1271
+ return len(self.messages)
1272
+
1273
+
1274
+ @dataclass
1275
+ class Identity:
1276
+ """
1277
+ A Stringcup identity: the X25519 keypair plus the bearer token.
1278
+
1279
+ The token is issued exactly once at registration and stored server-side
1280
+ only as a hash. Losing this file means losing the identity — there is no
1281
+ recovery path, only re-registration under a new external_id.
1282
+ """
1283
+
1284
+ external_id: str
1285
+ private_key_b64: str
1286
+ api_token: str
1287
+
1288
+ #: Keys rotated out and kept for decryption only, newest first. Each entry
1289
+ #: is `{"private_key": b64, "retired_at": unix_seconds}`. See
1290
+ #: `RETIRED_KEY_GRACE_SECONDS` for why these exist and why they expire.
1291
+ retired_keys: List[dict] = field(default_factory=list)
1292
+
1293
+ @property
1294
+ def private_key(self) -> X25519PrivateKey:
1295
+ return X25519PrivateKey.from_private_bytes(
1296
+ base64.b64decode(self.private_key_b64)
1297
+ )
1298
+
1299
+ @property
1300
+ def public_key_b64(self) -> str:
1301
+ raw = self.private_key.public_key().public_bytes(
1302
+ Encoding.Raw, PublicFormat.Raw
1303
+ )
1304
+ return base64.b64encode(raw).decode()
1305
+
1306
+ def decryption_keys(self) -> List[X25519PrivateKey]:
1307
+ """
1308
+ Every key that may still decrypt inbound mail: current, then retired.
1309
+
1310
+ Order matters only for speed — the current key is overwhelmingly the
1311
+ common case, so it is tried first. Retired keys are **decryption only**
1312
+ and never appear on the encryption path or in `public_key_b64`.
1313
+ """
1314
+ keys = [self.private_key]
1315
+ for entry in self.prune_retired_keys():
1316
+ try:
1317
+ keys.append(
1318
+ X25519PrivateKey.from_private_bytes(
1319
+ base64.b64decode(entry["private_key"])
1320
+ )
1321
+ )
1322
+ except (KeyError, ValueError, TypeError):
1323
+ # A malformed retained entry must not break receiving mail
1324
+ # that the current key can read perfectly well.
1325
+ continue
1326
+ return keys
1327
+
1328
+ def prune_retired_keys(self, now: Optional[float] = None) -> List[dict]:
1329
+ """
1330
+ Drop retired keys past `RETIRED_KEY_GRACE_SECONDS`, in place.
1331
+
1332
+ **This is the step that delivers the forward secrecy**, so it is not
1333
+ housekeeping: until a retired key is gone, ciphertext captured before
1334
+ the rotation is still readable by whoever holds this file. It runs on
1335
+ load and on save, so a long-lived process and a short one both expire
1336
+ keys without a caller remembering to.
1337
+ """
1338
+ cutoff = (time.time() if now is None else now) - RETIRED_KEY_GRACE_SECONDS
1339
+ kept = []
1340
+ for entry in self.retired_keys:
1341
+ try:
1342
+ if float(entry["retired_at"]) >= cutoff:
1343
+ kept.append(entry)
1344
+ except (KeyError, TypeError, ValueError):
1345
+ # Undatable, so unexpirable, so not retained. Erring towards
1346
+ # destruction is the safe direction for key material.
1347
+ continue
1348
+ self.retired_keys = kept
1349
+ return kept
1350
+
1351
+ def retire_current_key(self, now: Optional[float] = None) -> None:
1352
+ """Move the current private key onto the retired list, newest first."""
1353
+ self.retired_keys.insert(0, {
1354
+ "private_key": self.private_key_b64,
1355
+ "retired_at": time.time() if now is None else now,
1356
+ })
1357
+ self.prune_retired_keys(now)
1358
+
1359
+ def save(self, path: str) -> None:
1360
+ """Write atomically with 0600 — the file holds a private key."""
1361
+ # Create the parent directory rather than failing on it. The docs tell
1362
+ # callers to pass an absolute path they control, which routinely names
1363
+ # a directory that does not exist yet; without this, registration
1364
+ # succeeds against the relay and *then* dies writing the file, leaving
1365
+ # an identity that exists server-side and is unrecoverable locally.
1366
+ # The MCP server has always done this, so the two entry points
1367
+ # disagreed. 0700 because the file inside is a private key.
1368
+ directory = os.path.dirname(path)
1369
+ if directory:
1370
+ # The identity directory is itself the state root here, so it is
1371
+ # both what we create and where reporting stops.
1372
+ warning = _private_dir(directory, boundary=directory)
1373
+ if warning:
1374
+ _PENDING_DIR_WARNINGS.append(warning)
1375
+
1376
+ # Expire on the way out, so the window is enforced by every write
1377
+ # rather than only by a reload.
1378
+ self.prune_retired_keys()
1379
+
1380
+ tmp = f"{path}.tmp"
1381
+ record = {
1382
+ "external_id": self.external_id,
1383
+ "private_key": self.private_key_b64,
1384
+ "api_token": self.api_token,
1385
+ }
1386
+ # Omitted entirely when empty, so a file written by a client that
1387
+ # never rotated is byte-identical to what earlier versions wrote.
1388
+ if self.retired_keys:
1389
+ record["retired_keys"] = self.retired_keys
1390
+ payload = json.dumps(record, indent=2)
1391
+ fd = _open_new_private(tmp)
1392
+ try:
1393
+ with os.fdopen(fd, "w") as fh:
1394
+ fh.write(payload)
1395
+ except Exception:
1396
+ os.unlink(tmp)
1397
+ raise
1398
+ os.replace(tmp, path)
1399
+
1400
+ @classmethod
1401
+ def load(cls, path: str) -> "Identity":
1402
+ with open(path) as fh:
1403
+ data = json.load(fh)
1404
+ identity = cls(
1405
+ external_id=data["external_id"],
1406
+ private_key_b64=data["private_key"],
1407
+ api_token=data["api_token"],
1408
+ # Absent in files written before 3.16.0, and absent in any file
1409
+ # whose identity never rotated.
1410
+ retired_keys=list(data.get("retired_keys") or []),
1411
+ )
1412
+ identity.prune_retired_keys()
1413
+ return identity
1414
+
1415
+ @classmethod
1416
+ def generate(cls) -> "Identity":
1417
+ """
1418
+ A fresh keypair with no identifier yet.
1419
+
1420
+ `external_id` is filled in by the server at registration — it cannot be
1421
+ chosen, so it is not known until then.
1422
+ """
1423
+ priv = X25519PrivateKey.generate()
1424
+ raw = priv.private_bytes(
1425
+ Encoding.Raw, PrivateFormat.Raw, NoEncryption()
1426
+ )
1427
+ return cls(external_id="", private_key_b64=base64.b64encode(raw).decode(), api_token="")
1428
+
1429
+
1430
+ # --------------------------------------------------------------------------
1431
+ # Crypto (ECIES: ephemeral X25519 -> HKDF-SHA256 -> AES-256-GCM)
1432
+ # --------------------------------------------------------------------------
1433
+
1434
+ def _derive_key(shared_secret: bytes, sender_id: str, recipient_id: str) -> bytes:
1435
+ """
1436
+ The single HKDF call in the v2 protocol.
1437
+
1438
+ `info` is directional and must be byte-identical on both sides; the sender
1439
+ builds it from its own id, the recipient from the envelope's sender_id.
1440
+ """
1441
+ return HKDF(
1442
+ algorithm=SHA256(),
1443
+ length=KEY_BYTES,
1444
+ salt=HKDF_SALT,
1445
+ info=f"{sender_id}->{recipient_id}".encode(),
1446
+ ).derive(shared_secret)
1447
+
1448
+
1449
+ def encrypt(sender_id: str, recipient_id: str, recipient_pub_b64: str, plaintext: str) -> dict:
1450
+ """Encrypt for a recipient. Returns the JSON body for POST /messages."""
1451
+ recipient_pub = X25519PublicKey.from_public_bytes(
1452
+ base64.b64decode(recipient_pub_b64)
1453
+ )
1454
+
1455
+ # Fresh ephemeral keypair per message — never reused, never stored.
1456
+ eph_priv = X25519PrivateKey.generate()
1457
+ eph_pub = eph_priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
1458
+
1459
+ msg_key = _derive_key(eph_priv.exchange(recipient_pub), sender_id, recipient_id)
1460
+
1461
+ iv = os.urandom(IV_BYTES)
1462
+ # cryptography appends the 16-byte GCM tag to the ciphertext for us,
1463
+ # which is the layout the protocol expects.
1464
+ ct = AESGCM(msg_key).encrypt(iv, plaintext.encode(), None)
1465
+
1466
+ # Drops the last reference so the object becomes collectable sooner. It
1467
+ # does NOT zero the key material: `cryptography` holds the scalar inside
1468
+ # an OpenSSL object Python cannot overwrite, and immutable bytes cannot be
1469
+ # wiped in place either. An earlier comment here implied more than the
1470
+ # line does. Forward secrecy is not what this buys -- the ephemeral public
1471
+ # key is stored in the header, so a compromised static key exposes past
1472
+ # messages regardless. Noted by an external code audit.
1473
+ del eph_priv
1474
+
1475
+ return {
1476
+ "header": {
1477
+ "version": 2,
1478
+ "algo": ALGO,
1479
+ "ephemeral_pub": base64.b64encode(eph_pub).decode(),
1480
+ "iv": base64.b64encode(iv).decode(),
1481
+ },
1482
+ "ciphertext": base64.b64encode(ct).decode(),
1483
+ }
1484
+
1485
+
1486
+ def decrypt(private_key, my_id: str, raw: dict) -> str:
1487
+ """
1488
+ Decrypt one raw inbox message. Only the static private key is needed.
1489
+
1490
+ `private_key` may be a single `X25519PrivateKey` or a **list** of them,
1491
+ which is how a rotated identity reads mail still sealed to a key it has
1492
+ retired (see `RETIRED_KEY_GRACE_SECONDS`). Each is tried in turn and the
1493
+ error reported is the one from the *current* key, because a caller
1494
+ debugging an HKDF mismatch does not want a diagnostic about a key that is
1495
+ on its way out.
1496
+
1497
+ There is nothing to trust here: a wrong key fails AES-GCM authentication,
1498
+ so trying several is a decryption attempt repeated, not a weakening of the
1499
+ check. It cannot make a forged message decrypt.
1500
+ """
1501
+ header = raw.get("header") or {}
1502
+ algo = header.get("algo")
1503
+ if algo != ALGO:
1504
+ raise DecryptionError(f"unsupported algo {algo!r}, expected {ALGO!r}")
1505
+
1506
+ try:
1507
+ eph_pub = X25519PublicKey.from_public_bytes(
1508
+ base64.b64decode(header["ephemeral_pub"])
1509
+ )
1510
+ iv = base64.b64decode(header["iv"])
1511
+ ct = base64.b64decode(raw["ciphertext"])
1512
+ except (KeyError, ValueError) as exc:
1513
+ raise DecryptionError(f"malformed message envelope: {exc}") from exc
1514
+
1515
+ keys = private_key if isinstance(private_key, list) else [private_key]
1516
+ if not keys:
1517
+ raise DecryptionError("no private key available to decrypt with")
1518
+
1519
+ first_failure = None
1520
+ for key in keys:
1521
+ msg_key = _derive_key(key.exchange(eph_pub), raw["sender_id"], my_id)
1522
+ try:
1523
+ return AESGCM(msg_key).decrypt(iv, ct, None).decode()
1524
+ except Exception as exc:
1525
+ if first_failure is None:
1526
+ first_failure = exc
1527
+
1528
+ raise DecryptionError(
1529
+ "AES-GCM authentication failed under %d candidate key(s). The usual "
1530
+ "cause is an HKDF info mismatch: this client derived over "
1531
+ "%r->%r." % (len(keys), raw["sender_id"], my_id)
1532
+ ) from first_failure
1533
+
1534
+
1535
+ # --------------------------------------------------------------------------
1536
+ # Client
1537
+ # --------------------------------------------------------------------------
1538
+
1539
+ class Client:
1540
+ """
1541
+ A Stringcup v2 agent.
1542
+
1543
+ Holds one identity, caches peer public keys, and tracks the rate-limit
1544
+ budget reported by the server so callers can pace themselves.
1545
+ """
1546
+
1547
+ #: Warn at most once per process that a verified pairing was not pinned.
1548
+ _warned_unpinned = False
1549
+
1550
+ #: Warn at most once per process about undecryptable mail piling up.
1551
+ _warned_undecryptable = False
1552
+
1553
+ #: Warn at most once per process that the transcript is readable by others.
1554
+ _warned_transcript_mode = False
1555
+
1556
+ #: Warn at most once per process that the transcript path is a symlink.
1557
+ _warned_transcript_symlink = False
1558
+
1559
+ #: Keys of warnings already emitted this process, so a warning fires once
1560
+ #: however many Client instances exist.
1561
+ _warned_keys = set()
1562
+
1563
+
1564
+ def __init__(
1565
+ self,
1566
+ identity: Identity,
1567
+ base_url: str = DEFAULT_BASE_URL,
1568
+ timeout: float = 30.0,
1569
+ auto_throttle: bool = True,
1570
+ trust_store: Optional["TrustStore"] = None,
1571
+ transcript: Optional[str] = None,
1572
+ ):
1573
+ self.identity = identity
1574
+ self.base_url = base_url.rstrip("/")
1575
+ # Must outlast the longest long-poll hold, or the client aborts a
1576
+ # request the server is still legitimately holding open.
1577
+ self.timeout = max(timeout, MAX_WAIT + 15)
1578
+ self.auto_throttle = auto_throttle
1579
+
1580
+ if isinstance(trust_store, str):
1581
+ trust_store = TrustStore(trust_store)
1582
+ self.trust_store = trust_store
1583
+
1584
+ # Optional append-only JSONL record of every message in and out.
1585
+ # The relay deletes a message once it is acknowledged, so without this
1586
+ # there is no way to reconstruct a conversation afterwards — and an
1587
+ # agent whose context was compacted has no way to pick the thread back
1588
+ # up. Bodies are plaintext by definition here; put it somewhere private.
1589
+ self.transcript = transcript
1590
+
1591
+ #: Warnings raised since the last page was returned. Drained onto
1592
+ #: Page.warnings so they reach the agent, not only the host log.
1593
+ self._queued_warnings: List[str] = []
1594
+
1595
+ #: Human labels for channel ids, in memory. Persisted in the trust
1596
+ #: store when there is one. Never sent to the relay.
1597
+ self._labels: Dict[str, str] = {}
1598
+
1599
+ # Anything raised by a module-level helper before this Client existed.
1600
+ while _PENDING_DIR_WARNINGS:
1601
+ self._warn_once("dir-mode", _PENDING_DIR_WARNINGS.pop(0))
1602
+
1603
+ self._peer_keys: Dict[str, str] = {}
1604
+
1605
+ #: Peer whose pin THIS client created during the current rendezvous,
1606
+ #: so a failed verification can remove it again. See _verify_pairing.
1607
+ self._pin_created_for: Optional[str] = None
1608
+
1609
+ # name -> (monotonic_time, member_ids | None). Verifying an inbound
1610
+ # channel label needs the roster; without a cache that is one extra
1611
+ # request per received message.
1612
+ self._roster_cache: Dict[str, tuple] = {}
1613
+ #: Rate-limit budget per endpoint bucket. The server's limits differ by
1614
+ #: more than an order of magnitude between endpoints, so one shared
1615
+ #: figure throttles the wrong calls.
1616
+ self._budgets: Dict[str, Dict[str, Optional[int]]] = {}
1617
+ self._pending_ack: List[int] = []
1618
+ self._last_headers: Dict[str, str] = {}
1619
+ self._last_drain_long_poll = "off"
1620
+
1621
+ #: Budget from the most recent response: limit / remaining / reset.
1622
+ self.rate_limit: Dict[str, Optional[int]] = {
1623
+ "limit": None,
1624
+ "remaining": None,
1625
+ "reset": None,
1626
+ }
1627
+
1628
+ # -- identity lifecycle ------------------------------------------------
1629
+
1630
+ @property
1631
+ def id(self) -> str:
1632
+ return self.identity.external_id
1633
+
1634
+ @property
1635
+ def my_fingerprint(self) -> str:
1636
+ """
1637
+ This identity's key fingerprint. Publish it out of band so peers can
1638
+ pin you rather than trusting whatever the relay serves.
1639
+ """
1640
+ return fingerprint(self.identity.public_key_b64)
1641
+
1642
+ @property
1643
+ def my_fingerprint_short(self) -> str:
1644
+ return fingerprint_short(self.identity.public_key_b64)
1645
+
1646
+ @classmethod
1647
+ def register(
1648
+ cls,
1649
+ base_url: str = DEFAULT_BASE_URL,
1650
+ display_name: Optional[str] = None,
1651
+ **kwargs,
1652
+ ) -> "Client":
1653
+ """
1654
+ Generate a keypair and register it. The server assigns the identifier.
1655
+
1656
+ Identifiers cannot be chosen. That removes the first-come race that
1657
+ client-picked names had — nobody can register the id you were about to
1658
+ use — at the cost of the id being unguessable, so a peer can only learn
1659
+ it if you tell them. See `rendezvous()`.
1660
+
1661
+ The API token is issued exactly once and is unrecoverable.
1662
+ """
1663
+ identity = Identity.generate()
1664
+ client = cls(identity, base_url=base_url, **kwargs)
1665
+
1666
+ payload: Dict[str, object] = {
1667
+ "identity_public_key": identity.public_key_b64,
1668
+ "algo": "x25519",
1669
+ }
1670
+ if display_name is not None:
1671
+ payload["display_name"] = display_name
1672
+
1673
+ body = client._request("POST", "/identities", payload, authenticated=False)
1674
+
1675
+ identity.external_id = body["id"]
1676
+ identity.api_token = body["api_token"]
1677
+
1678
+ return client
1679
+
1680
+ @classmethod
1681
+ def load_or_register(
1682
+ cls,
1683
+ path: str,
1684
+ base_url: str = DEFAULT_BASE_URL,
1685
+ display_name: Optional[str] = None,
1686
+ transcript=DEFAULT_TRANSCRIPT,
1687
+ **kwargs,
1688
+ ) -> "Client":
1689
+ """
1690
+ Reuse the identity at `path`, registering only if it is absent.
1691
+
1692
+ This is the form agents should use. Registration is capped at 5/hour
1693
+ per IP, and since the id is assigned, re-registering does not even get
1694
+ you the same identity back — an agent that registers on every start
1695
+ both locks itself out and becomes unreachable at the id its peer knows.
1696
+ """
1697
+ # AUDITABLE BY DEFAULT, not on request.
1698
+ #
1699
+ # The product property is that agents communicate with little friction
1700
+ # and their operators can audit it completely. An audit trail that
1701
+ # only exists when someone passes an argument is a property of a
1702
+ # well-configured install, not of the system -- and the same inversion
1703
+ # was already found in the MCP server, where the *optional* trust store
1704
+ # got a sensible default while the wanted transcript did not.
1705
+ #
1706
+ # `transcript=None` still means off, explicitly. Only the unspecified
1707
+ # case changes.
1708
+ if transcript is DEFAULT_TRANSCRIPT:
1709
+ transcript = session_transcript_path(path)
1710
+
1711
+ kwargs["transcript"] = transcript
1712
+
1713
+ if os.path.exists(path):
1714
+ return cls(Identity.load(path), base_url=base_url, **kwargs)
1715
+
1716
+ client = cls.register(base_url=base_url, display_name=display_name, **kwargs)
1717
+ client.identity.save(path)
1718
+ return client
1719
+
1720
+ def update_identity(
1721
+ self,
1722
+ public_key_b64: Optional[str] = None,
1723
+ display_name: Optional[str] = None,
1724
+ ) -> dict:
1725
+ """
1726
+ Update this identity's key or display name.
1727
+
1728
+ Registration no longer doubles as an update path, since the caller is
1729
+ now identified by its token rather than by a chosen name.
1730
+ """
1731
+ payload: Dict[str, object] = {}
1732
+ if public_key_b64 is not None:
1733
+ payload["identity_public_key"] = public_key_b64
1734
+ if display_name is not None:
1735
+ payload["display_name"] = display_name
1736
+
1737
+ if not payload:
1738
+ raise ValidationError("provide identity_public_key and/or display_name")
1739
+
1740
+ return self._request("PUT", "/identities", payload)
1741
+
1742
+ def rotate_identity_key(self, save_to: str) -> dict:
1743
+ """
1744
+ Replace this identity's X25519 keypair and destroy the old private key.
1745
+
1746
+ **This is the closest thing to forward secrecy this protocol has, and
1747
+ it is coarse-grained: per rotation, not per message.** Once the old
1748
+ private key is genuinely gone, any ciphertext captured before the
1749
+ rotation is permanently undecryptable — including ciphertext that
1750
+ escaped the relay before an ACK, which is the exposure class this
1751
+ project has hit twice (a body-logging access log, and `db:backup`
1752
+ snapshots).
1753
+
1754
+ An auditor proposed this instead of real forward secrecy, and the
1755
+ reasoning is worth keeping because it bounds what FS could buy here:
1756
+
1757
+ - Real FS needs one-time prekeys, which must be **deleted** after use.
1758
+ - But at-least-once delivery plus "only an ACK deletes" means a message
1759
+ may be re-fetched and re-decrypted after a crash, so the prekey has
1760
+ to survive until the ACK — **for every pending message the key
1761
+ therefore exists exactly as long as the ciphertext does.**
1762
+ - So FS protects *already-acknowledged* mail, which the relay has
1763
+ already deleted. The exposure it actually closes is ciphertext that
1764
+ escaped before the ACK. Rotation closes the same class.
1765
+ - And prekeys cost two documented properties outright: "multi-instance
1766
+ safe" (a one-time prekey is consumed by whichever instance gets
1767
+ there first) and the identity-file backup mandate (restoring a
1768
+ backup **restores deleted prekeys**, silently undoing FS for exactly
1769
+ the messages whose ciphertext was also retained — this project's own
1770
+ `db:backup` would defeat it).
1771
+
1772
+ Rotation costs none of those: the key stays shared, stays persistent
1773
+ between rotations, and stays re-readable.
1774
+
1775
+ **Its weakness, stated honestly:** the window is the rotation period,
1776
+ and the guarantee depends on the old private key actually being
1777
+ destroyed. Any backup of a previous `identity.json` reinstates it. That
1778
+ is the same persist-versus-destroy contradiction as prekeys, one size
1779
+ down — but at a granularity a human can reason about.
1780
+
1781
+ Three further consequences the caller must plan for:
1782
+
1783
+ - **`save_to` MUST be the path this agent actually loads.** Rotating
1784
+ into any other file strands the identity: the relay serves the new
1785
+ public key while the loaded file still holds the old private one, so
1786
+ nobody can reach the agent and it cannot read its own mail. Found by
1787
+ doing exactly that in a test.
1788
+ - **Peers cache your key indefinitely** (`peer_public_key` is
1789
+ documented as safe to cache forever, because it only changes on
1790
+ rotation). A peer that already holds your old key keeps encrypting
1791
+ to it, and those messages arrive undecryptable — visible to you in
1792
+ `Page.undecryptable`, invisible to the sender, which is the worse
1793
+ half. Peers must call `peer_public_key(..., refresh=True)` after you
1794
+ rotate, so tell them out of band that you did.
1795
+
1796
+ - **Peers who pinned you will see `KeyPinMismatch`**, which is correct
1797
+ and is indistinguishable from substitution from their side. Tell them
1798
+ out of band, before rotating. See SECURITY.md on why rotation spends
1799
+ your peers' verification.
1800
+ - **In-flight mail stays readable for `RETIRED_KEY_GRACE_SECONDS`.**
1801
+ The old private key is retained for **decryption only** and then
1802
+ destroyed, and that destruction is what delivers the forward
1803
+ secrecy. Mail sealed to the old key after the window closes is
1804
+ permanently unreadable, so the window is a bound on how long a peer
1805
+ may keep using a cached key — not a promise that it cannot happen.
1806
+
1807
+ Python cannot truly zero the old key — `cryptography` holds the scalar
1808
+ inside an OpenSSL object and immutable bytes cannot be overwritten in
1809
+ place — so "destroyed" means the file no longer contains it and no
1810
+ reference remains. That is a real limitation, not a formality.
1811
+ """
1812
+ # This USED to refuse on any pending mail, because destroying the old
1813
+ # key immediately made that mail permanently unreadable. The refusal
1814
+ # was never sufficient — it was a TOCTOU (fetch at T0, relay update at
1815
+ # T1, anything arriving between them sealed to a key gone at T2) and it
1816
+ # did nothing at all about the unbounded case, a peer sealing to a
1817
+ # cached key days later. Retention fixes both, so the refusal is gone
1818
+ # rather than kept as reassurance that never held.
1819
+ #
1820
+ # Pending mail is still reported, because draining first is cheaper
1821
+ # than relying on the grace window and an operator should know.
1822
+ pending = self.fetch(limit=1, wait=0)
1823
+ if pending.count:
1824
+ sys.stderr.write(
1825
+ "[stringcup] rotating with %d message(s) pending. They stay "
1826
+ "readable: the retired key is kept for %d days. Mail sealed "
1827
+ "to the old key after that is unreadable, so acknowledge your "
1828
+ "inbox and tell peers to refresh your key.\n"
1829
+ % (pending.count, RETIRED_KEY_GRACE_SECONDS // 86400)
1830
+ )
1831
+ sys.stderr.flush()
1832
+
1833
+ new_private = X25519PrivateKey.generate()
1834
+ new_public = base64.b64encode(
1835
+ new_private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
1836
+ ).decode()
1837
+
1838
+ old_fingerprint = fingerprint(self.identity.public_key_b64)
1839
+
1840
+ # Relay first: if this fails, the local key is unchanged and the
1841
+ # identity still works. Writing locally first would strand the agent.
1842
+ body = self.update_identity(public_key_b64=new_public)
1843
+
1844
+ # Retain BEFORE overwriting, or the key is gone and there is nothing
1845
+ # to retain. Decryption only — it never returns to the send path.
1846
+ self.identity.retire_current_key()
1847
+
1848
+ self.identity.private_key_b64 = base64.b64encode(
1849
+ new_private.private_bytes(
1850
+ Encoding.Raw, PrivateFormat.Raw, NoEncryption()
1851
+ )
1852
+ ).decode()
1853
+
1854
+ # Overwrites atomically at 0600, so the old key leaves the file.
1855
+ self.identity.save(save_to)
1856
+
1857
+ # Any cached peer view of us is stale, and so is any pin peers hold.
1858
+ self._peer_keys.pop(self.id, None)
1859
+
1860
+ body["previous_fingerprint"] = old_fingerprint
1861
+ body["fingerprint"] = fingerprint(new_public)
1862
+ body["retired_key_expires_in_days"] = RETIRED_KEY_GRACE_SECONDS // 86400
1863
+ body["forward_secrecy_note"] = (
1864
+ "Forward secrecy arrives when the retired key is destroyed, in %d "
1865
+ "days — NOT now. Until then the previous key is retained in this "
1866
+ "identity file for decryption, so mail already in flight and mail "
1867
+ "from peers holding a cached key still arrives. Ciphertext "
1868
+ "captured before this rotation becomes undecryptable when that "
1869
+ "window closes, PROVIDED no backup of the identity file survives. "
1870
+ "Peers that pinned the old key will raise KeyPinMismatch until "
1871
+ "they re-verify out of band."
1872
+ % (RETIRED_KEY_GRACE_SECONDS // 86400)
1873
+ )
1874
+ return body
1875
+
1876
+ # -- rendezvous --------------------------------------------------------
1877
+
1878
+ def rendezvous(
1879
+ self,
1880
+ token: Optional[str] = None,
1881
+ wait: int = MAX_WAIT,
1882
+ ) -> dict:
1883
+ """
1884
+ One rendezvous call. Prefer `open_rendezvous()` / `join_rendezvous()`,
1885
+ which handle the waiting loop for you.
1886
+
1887
+ Omit `token` to open a rendezvous (you become the **initiator**);
1888
+ supply one to join (you become the **responder**). The role is derived
1889
+ from that, not passed in — naming your own role let a config mistake
1890
+ make both agents initiators, which deadlocked silently.
1891
+
1892
+ Returns `peer_id: None` if the counterpart has not arrived within
1893
+ `wait` seconds. **A single call is not a pairing.** Use
1894
+ `await_peer()` unless you are writing your own loop.
1895
+
1896
+ Tokens are issued by the server; a self-invented one is refused.
1897
+ A `409` means a *different* identity holds your side — either the
1898
+ token leaked or you re-registered. The same identity re-claiming is
1899
+ fine, so a restart that kept its identity file resumes cleanly.
1900
+ """
1901
+ payload: Dict[str, object] = {
1902
+ "wait": max(0, min(int(wait), MAX_WAIT)),
1903
+ }
1904
+ if token is not None:
1905
+ payload["token"] = token
1906
+
1907
+ body = self._request("POST", "/rendezvous", payload)
1908
+
1909
+ # Recompute locally rather than trusting the server's fingerprint.
1910
+ key = body.get("peer_identity_public_key")
1911
+ if key:
1912
+ body["peer_fingerprint"] = fingerprint(key)
1913
+ body["peer_fingerprint_short"] = fingerprint_short(key)
1914
+
1915
+ peer_id = body.get("peer_id")
1916
+ if peer_id:
1917
+ self._peer_keys[peer_id] = key
1918
+ if self.trust_store is not None:
1919
+ # verify() returns True when this was a FIRST SIGHT, i.e.
1920
+ # when it created the pin rather than matching one. Recorded
1921
+ # so a failed verification can roll back exactly the pin
1922
+ # this pairing added -- and nothing else.
1923
+ #
1924
+ # Capturing it here is the only correct place: by the time
1925
+ # _verify_pairing runs, the pin already exists, so asking
1926
+ # "was it pinned before?" there always answers yes and the
1927
+ # rollback is inert. That was the first attempt at this.
1928
+ created = self.trust_store.verify(peer_id, body["peer_fingerprint"])
1929
+ self._pin_created_for = peer_id if created else None
1930
+
1931
+ return body
1932
+
1933
+ def open_rendezvous(self, with_secret: bool = True) -> dict:
1934
+ """
1935
+ Open a rendezvous and return immediately with the issued token.
1936
+
1937
+ Returns at once rather than waiting, because the token is the one value
1938
+ the peer needs in order to show up at all — blocking before revealing
1939
+ it just delays the pairing. Follow with `await_peer()`.
1940
+
1941
+ info = me.open_rendezvous()
1942
+ print(info["token"], info["secret"]) # hand BOTH to the peer
1943
+ peer = me.await_peer(info["token"], secret=info["secret"])
1944
+
1945
+ You are the **initiator**: you speak first once paired.
1946
+
1947
+ **`secret` is what authenticates the pairing.** The relay issues the
1948
+ token, so the token proves nothing about a key the relay served; the
1949
+ secret is generated here and never sent to the relay. It costs the
1950
+ operator nothing, because the same single handoff block is already
1951
+ being pasted — see `handoff_block()`.
1952
+
1953
+ Pass `with_secret=False` for the old unauthenticated behaviour. The
1954
+ pairing then reports `verified: False`, and a substituted key is
1955
+ undetectable without an out-of-band fingerprint comparison.
1956
+ """
1957
+ info = self.rendezvous(token=None, wait=0)
1958
+
1959
+ if with_secret:
1960
+ info["secret"] = new_pairing_secret()
1961
+
1962
+ return info
1963
+
1964
+ def handoff_block(self, info: dict, role: str = "responder") -> str:
1965
+ """
1966
+ The block an operator pastes to the other agent, secret included.
1967
+
1968
+ Exists so the secret cannot be forgotten. The handoff was already a
1969
+ copy-paste; carrying one more line in it is the entire cost of
1970
+ authenticating first contact.
1971
+ """
1972
+ lines = [
1973
+ "STRINGCUP HANDOFF",
1974
+ "",
1975
+ " YOUR ROLE: %s" % role,
1976
+ " TOKEN: %s" % info["token"],
1977
+ ]
1978
+
1979
+ if info.get("secret"):
1980
+ lines += [
1981
+ " SECRET: %s" % info["secret"],
1982
+ "",
1983
+ " Pass BOTH to join_rendezvous. The secret never reaches the",
1984
+ " relay, which is what makes it able to prove the keys were not",
1985
+ " substituted. If pairing reports verified: false, or raises,",
1986
+ " stop and tell your operator.",
1987
+ ]
1988
+ else:
1989
+ lines += [
1990
+ "",
1991
+ " No secret: this pairing CANNOT be authenticated. A substituted",
1992
+ " key would be undetectable without comparing fingerprints out",
1993
+ " of band.",
1994
+ ]
1995
+
1996
+ return "\n".join(lines)
1997
+
1998
+ def _verify_pairing(self, info: dict, secret: str, token: str,
1999
+ role: str, timeout: float) -> dict:
2000
+ """
2001
+ Verify a pairing, guaranteeing the pin cleanup on EVERY exit.
2002
+
2003
+ The cleanup used to be a closure called from each failure path, and
2004
+ the role-disagreement check -- added later, in the same commit -- sat
2005
+ ABOVE the closure's definition, so it raised with the poisoned pin
2006
+ intact. That is the exact bug the closure existed to fix, through a
2007
+ door cut by its own fix.
2008
+
2009
+ It was also the worst of the four exits to miss, because `info["role"]`
2010
+ comes from the relay: a hostile relay could substitute a key (which
2011
+ first-sight-pins it), ALSO report a disagreeing role, and deliberately
2012
+ take the one exit that left the poison on disk. The poisoning went from
2013
+ an accident to something the attacker selects.
2014
+
2015
+ So the invariant does not live in call sites any more. Four sites where
2016
+ one can be forgotten is what produced this; a wrapper cannot be skipped
2017
+ by a failure path nobody has written yet. An auditor made exactly this
2018
+ argument, and it is the fourth time in a day that a fix was applied to
2019
+ the instances named rather than the class described.
2020
+ """
2021
+ peer_id = info["peer_id"]
2022
+
2023
+ # Captured BEFORE the body runs. Asking afterwards is what made the
2024
+ # first version of this inert: rendezvous() has already pinned by then.
2025
+ created_pin = (
2026
+ self.trust_store is not None
2027
+ and self._pin_created_for == peer_id
2028
+ )
2029
+
2030
+ try:
2031
+ return self._verify_pairing_exchange(
2032
+ info, secret, token, role, timeout)
2033
+ except BaseException:
2034
+ # BaseException, not Exception: KeyboardInterrupt and SystemExit do
2035
+ # not derive from Exception, and the exchange does network I/O in a
2036
+ # loop for up to `timeout` seconds -- which is exactly the window in
2037
+ # which an operator watching a pairing hang presses Ctrl-C. That
2038
+ # exit skipped the rollback and left the poisoned pin: the same
2039
+ # outcome, through the one door the wrapper did not cover. Caught by
2040
+ # an auditor, who also noted the comment here claimed to cover
2041
+ # "every failure" and therefore was not true.
2042
+ #
2043
+ # Widening is safe because the exception is always re-raised.
2044
+ if created_pin and self.trust_store is not None:
2045
+ self.trust_store.forget(peer_id)
2046
+ self._pin_created_for = None
2047
+ raise
2048
+
2049
+ def _verify_pairing_exchange(self, info: dict, secret: str, token: str,
2050
+ role: str, timeout: float) -> dict:
2051
+ """
2052
+ Exchange and compare DIRECTIONAL verification tags with the peer.
2053
+
2054
+ Runs over the ordinary message path, so the relay needs no change.
2055
+
2056
+ **Each side sends the tag for its own role and compares the received
2057
+ tag against the one it expects for the peer's role.** Never against
2058
+ its own -- that was the v1 bug: the tag was symmetric, so an active
2059
+ relay could reflect a side's own tag back to it, attributed to the
2060
+ peer (`sender_id` is relay-forgeable), and verification passed with a
2061
+ full MITM in place. Reproduced end to end. A reflected tag now carries
2062
+ the wrong role and fails.
2063
+
2064
+ Only the peer's verification message is acknowledged; anything else
2065
+ that arrives meanwhile is left untouched, so a real first message is
2066
+ never swallowed.
2067
+ """
2068
+ peer_id = info["peer_id"]
2069
+ peer_pub = info["peer_identity_public_key"]
2070
+
2071
+ # THE ROLE IS LOCAL. It is never taken from the relay.
2072
+ #
2073
+ # The direction binding is what stops reflection, so taking the role
2074
+ # from the relay would hand the adversary an input to the very thing
2075
+ # defending against it. It is also unnecessary: a client knows its own
2076
+ # role by construction -- open_rendezvous()/await_peer() is the
2077
+ # initiator, join_rendezvous() is the responder -- which is why
2078
+ # handoff_block() already prints it from the local call. An auditor
2079
+ # pointed out the dependency could simply be deleted rather than
2080
+ # reasoned about, which collapses the question instead of answering it.
2081
+ if role not in PAIRING_ROLES:
2082
+ raise ValidationError(
2083
+ "a local pairing role is required (%r); it must never be read "
2084
+ "from the relay response" % (role,)
2085
+ )
2086
+
2087
+ # The relay's claim is still worth reading -- as a signal, not a source.
2088
+ # An honest relay always agrees with the local derivation, so a
2089
+ # disagreement is free attack detection that was previously discarded.
2090
+ claimed = info.get("role")
2091
+ if claimed in PAIRING_ROLES and claimed != role:
2092
+ raise VerificationFailed(
2093
+ "the relay reports this pairing as %r while this client is the "
2094
+ "%s by construction. An honest relay cannot disagree: opening a "
2095
+ "rendezvous makes you the initiator and joining makes you the "
2096
+ "responder. Treat the message path as hostile and report it."
2097
+ % (claimed, role)
2098
+ )
2099
+
2100
+ ids = (self.id, peer_id)
2101
+ keys = (self.identity.public_key_b64, peer_pub)
2102
+
2103
+ mine = verification_tag(secret, role, ids, keys, token)
2104
+ expected = verification_tag(
2105
+ secret, other_pairing_role(role), ids, keys, token)
2106
+
2107
+ self.send(
2108
+ peer_id,
2109
+ VERIFY_BODY,
2110
+ header_extra={VERIFY_HEADER: VERIFY_PURPOSE, VERIFY_TAG_FIELD: mine},
2111
+ )
2112
+
2113
+ deadline = time.monotonic() + timeout
2114
+ verify_cursor: Optional[int] = None
2115
+
2116
+ while True:
2117
+ remaining = deadline - time.monotonic()
2118
+ if remaining <= 0:
2119
+ raise VerificationFailed(
2120
+ "peer never sent a verification tag within %.0fs. It may be "
2121
+ "running a client older than 3.8.0, whose tag construction was "
2122
+ "different and is deliberately not accepted. Do not treat this "
2123
+ "pairing as authenticated." % timeout
2124
+ )
2125
+
2126
+ # Cursor forward rather than re-reading the first page forever.
2127
+ #
2128
+ # Without `since_id` this only ever saw page one, so an inbox
2129
+ # already holding MAX_PAGE pending messages hid the verification
2130
+ # message and the pairing timed out -- meaning anyone able to send
2131
+ # mail could cheaply deny an authenticated pairing. Fail-safe, but
2132
+ # free to fix. Reported by an auditor.
2133
+ page = self.fetch(limit=MAX_PAGE,
2134
+ since_id=verify_cursor,
2135
+ wait=int(min(MAX_WAIT, max(0, remaining))),
2136
+ verify_channels=False)
2137
+
2138
+ if page.next_since_id is not None:
2139
+ verify_cursor = page.next_since_id
2140
+
2141
+ for msg in page.messages:
2142
+ if msg.sender_id != peer_id:
2143
+ continue
2144
+
2145
+ theirs = None
2146
+ if msg.header.get(VERIFY_HEADER) == VERIFY_PURPOSE:
2147
+ theirs = str(msg.header.get(VERIFY_TAG_FIELD) or "").strip()
2148
+ elif msg.text.startswith(VERIFY_PREFIX):
2149
+ # Legacy in-band form from a 3.8/3.9 peer.
2150
+ theirs = msg.text[len(VERIFY_PREFIX):].rstrip("]").strip()
2151
+
2152
+ if not theirs:
2153
+ continue
2154
+
2155
+ # Do NOT acknowledge yet. The header is not authenticated
2156
+ # (AES-GCM is called with no AAD), so a relay can bolt
2157
+ # `purpose`/`tag` onto an ORDINARY message -- and acknowledging
2158
+ # DELETES. Acking before comparing therefore handed the relay a
2159
+ # way to make the CLIENT destroy a genuine message on the
2160
+ # strength of a field the relay itself controls. Only a tag
2161
+ # that is actually one of this pairing's two values is ours to
2162
+ # consume. Reported by an auditor, who also noted this makes
2163
+ # `purpose` an unauthenticated dispatch key -- see the AAD note
2164
+ # in PROTOCOL.md.
2165
+ ours = (hmac.compare_digest(theirs, mine)
2166
+ or hmac.compare_digest(theirs, expected))
2167
+ if not ours:
2168
+ continue
2169
+
2170
+ self.ack([msg.id])
2171
+
2172
+ if hmac.compare_digest(theirs, mine):
2173
+ # Our own tag, echoed back at us. Nothing legitimate does
2174
+ # this: the peer holds the other role and cannot produce
2175
+ # this value. This is the reflection attack, caught.
2176
+ raise VerificationFailed(
2177
+ "the peer returned OUR OWN verification tag. That is what a "
2178
+ "reflecting relay does -- echoing a side's tag back to it to "
2179
+ "fake a match -- and it is also what a BROKEN OR "
2180
+ "HALF-IMPLEMENTED peer does. Do not send either way. Report "
2181
+ "it, and if the peer is a client under development, suspect "
2182
+ "the bug before the adversary: a correct peer holds the other "
2183
+ "role and cannot compute this value."
2184
+ )
2185
+
2186
+ if not hmac.compare_digest(theirs, expected):
2187
+ raise VerificationFailed(
2188
+ "the pairing secret did not authenticate this peer. The tag "
2189
+ "it sent does not match the one expected for its role over "
2190
+ "these two keys. That is what key substitution looks like -- "
2191
+ "but a relay can also simply inject a wrong tag to deny you "
2192
+ "the pairing, so this means EITHER substitution OR a relay "
2193
+ "refusing to let you verify. Both require the same response: "
2194
+ "do not send, and report it to your operator."
2195
+ )
2196
+
2197
+ # A verified pairing is worth more than a cache entry, so
2198
+ # record it durably.
2199
+ #
2200
+ # Without this, verification lasted only as long as the
2201
+ # process: the key sat in the in-memory `_peer_keys` cache, and
2202
+ # after a restart `send()` re-fetched it from the relay with
2203
+ # nothing to compare against. An operator who carried a secret
2204
+ # by hand had bought one process's worth of assurance.
2205
+ #
2206
+ # Pinning is the natural composition: the secret gives the same
2207
+ # assurance an out-of-band fingerprint comparison would, and
2208
+ # that is exactly what a pin is for.
2209
+ info["verified"] = True
2210
+ info["pinned"] = False
2211
+
2212
+ if self.trust_store is not None:
2213
+ self.trust_store.pin(peer_id, fingerprint(peer_pub))
2214
+ info["pinned"] = True
2215
+ elif not Client._warned_unpinned:
2216
+ Client._warned_unpinned = True
2217
+ self._warn_once(
2218
+ "unpinned",
2219
+ "pairing with %s verified, but NOT pinned: no "
2220
+ "trust_store is configured, so this assurance is lost "
2221
+ "when the process exits and a later substitution "
2222
+ "would go undetected. Pass "
2223
+ "trust_store=\"./known_peers.json\"." % peer_id
2224
+ )
2225
+
2226
+ return info
2227
+
2228
+ def await_peer(self, token: str, timeout: float = 300.0,
2229
+ secret: Optional[str] = None,
2230
+ role: str = PAIRING_ROLES[0]) -> dict:
2231
+ """
2232
+ Block until the counterpart arrives, or raise `PairingTimeout`.
2233
+
2234
+ Each underlying call parks server-side for at most 25 seconds and then
2235
+ returns `peer_id: None`, so a single call is *not* enough — a peer that
2236
+ is still installing an interpreter will take longer than that. Reading
2237
+ `peer_id` off one call is the mistake this method exists to prevent;
2238
+ the value would be `None` and the failure would surface much later as
2239
+ something unrelated.
2240
+
2241
+ `timeout` is honoured to about a second: a value under 25 parks only
2242
+ that long rather than for a whole server-side cycle.
2243
+ """
2244
+ deadline = time.monotonic() + timeout
2245
+
2246
+ while True:
2247
+ remaining = deadline - time.monotonic()
2248
+ if remaining <= 0:
2249
+ raise PairingTimeout(
2250
+ f"peer did not arrive within {timeout:.0f}s. The token may not have "
2251
+ f"reached them, or they failed to start."
2252
+ )
2253
+
2254
+ # Bounded by the time actually left, for the same reason as
2255
+ # receive_one: a short timeout must not block for a full 25s hold.
2256
+ info = self.rendezvous(token=token, wait=int(min(MAX_WAIT, max(0, remaining))))
2257
+ if info.get("peer_id"):
2258
+ if secret:
2259
+ return self._verify_pairing(
2260
+ info, secret, token, role,
2261
+ max(30.0, deadline - time.monotonic()))
2262
+
2263
+ info["verified"] = False
2264
+ return info
2265
+
2266
+ def join_rendezvous(self, token: str, timeout: float = 300.0,
2267
+ secret: Optional[str] = None) -> dict:
2268
+ """
2269
+ Join a rendezvous someone else opened, waiting until paired.
2270
+
2271
+ You are the **responder**: do not send first: the initiator opens the
2272
+ conversation.
2273
+
2274
+ **Pass `secret` if the handoff block carried one.** Without it the
2275
+ pairing reports `verified: False` and a substituted key cannot be
2276
+ detected. With it, a mismatch raises `VerificationFailed`.
2277
+ """
2278
+ info = self.rendezvous(token=token, wait=0)
2279
+ if info.get("peer_id"):
2280
+ if secret:
2281
+ return self._verify_pairing(
2282
+ info, secret, token, PAIRING_ROLES[1], timeout)
2283
+
2284
+ info["verified"] = False
2285
+ return info
2286
+
2287
+ # Still the responder when falling through to the waiting loop.
2288
+ return self.await_peer(token, timeout=timeout, secret=secret,
2289
+ role=PAIRING_ROLES[1])
2290
+
2291
+ def rendezvous_release(self, token: str) -> dict:
2292
+ """Drop this identity's claim so the token can be reused immediately."""
2293
+ return self._request("DELETE", "/rendezvous", {"token": token})
2294
+
2295
+ def token_info(self) -> dict:
2296
+ """Expiry metadata for the current token."""
2297
+ return self._request("GET", "/tokens/current")
2298
+
2299
+ def rotate_token(self, save_to: Optional[str] = None) -> str:
2300
+ """
2301
+ Replace the token and revoke the old one.
2302
+
2303
+ The old token stops working the moment this returns, so persist the new
2304
+ one before doing anything else — pass `save_to` to make that atomic.
2305
+ """
2306
+ body = self._request("POST", "/tokens/rotate")
2307
+ self.identity.api_token = body["api_token"]
2308
+ if save_to:
2309
+ self.identity.save(save_to)
2310
+ return self.identity.api_token
2311
+
2312
+ # -- peers -------------------------------------------------------------
2313
+
2314
+ def peer_public_key(
2315
+ self,
2316
+ peer_id: str,
2317
+ refresh: bool = False,
2318
+ pin: Optional[str] = None,
2319
+ ) -> str:
2320
+ """
2321
+ Fetch (and cache) a peer's static public key.
2322
+
2323
+ Safe to cache indefinitely: it changes only if the peer re-registers.
2324
+
2325
+ Pass `pin` (a fingerprint obtained out of band) to require an exact
2326
+ match — the only check that actually rules out a substituted key, since
2327
+ both the key and any server-reported fingerprint come from the relay.
2328
+ With a `trust_store` configured and no explicit `pin`, the key is
2329
+ trusted on first sight and pinned thereafter.
2330
+ """
2331
+ if not refresh and peer_id in self._peer_keys:
2332
+ key = self._peer_keys[peer_id]
2333
+ if pin is not None and fingerprint(key) != pin:
2334
+ raise KeyPinMismatch(peer_id, pin, fingerprint(key))
2335
+ return key
2336
+
2337
+ body = self._request("GET", f"/identities/{peer_id}", authenticated=False)
2338
+ key = body["identity_public_key"]
2339
+
2340
+ # Always recompute locally rather than believing the server's field.
2341
+ actual = fingerprint(key)
2342
+
2343
+ if pin is not None and actual != pin:
2344
+ raise KeyPinMismatch(peer_id, pin, actual)
2345
+
2346
+ if self.trust_store is not None:
2347
+ self.trust_store.verify(peer_id, actual)
2348
+
2349
+ self._peer_keys[peer_id] = key
2350
+ return key
2351
+
2352
+ def peer_info(self, peer_id: str) -> dict:
2353
+ """
2354
+ Full identity record, with the fingerprint recomputed locally so the
2355
+ caller never has to trust the server's arithmetic.
2356
+ """
2357
+ body = self._request("GET", f"/identities/{peer_id}", authenticated=False)
2358
+ key = body["identity_public_key"]
2359
+
2360
+ body["fingerprint"] = fingerprint(key)
2361
+ body["fingerprint_short"] = fingerprint_short(key)
2362
+
2363
+ return body
2364
+
2365
+ # -- sending -----------------------------------------------------------
2366
+
2367
+ def send(
2368
+ self,
2369
+ recipient_id: str,
2370
+ text: str,
2371
+ idempotency_key: Optional[str] = None,
2372
+ retries: int = 3,
2373
+ header_extra: Optional[Dict[str, str]] = None,
2374
+ ) -> int:
2375
+ """
2376
+ Encrypt and send, recording the attempt either way.
2377
+
2378
+ A thin wrapper so that EVERY failure is audited, not the subset that
2379
+ happens to pass through one `except` block. The first version of this
2380
+ logged refusals inside the retry loop, which missed the most common
2381
+ refusal of all: an unknown recipient raises in `peer_public_key()`
2382
+ before the loop is reached, so the attempt vanished from the record.
2383
+ Same lesson as the pairing rollback -- put the invariant somewhere a
2384
+ call site cannot forget it.
2385
+
2386
+ An audit that shows only what succeeded cannot answer "what did my
2387
+ agent try to say", which is the question an operator actually has.
2388
+ """
2389
+ try:
2390
+ return self._send_attempt(
2391
+ recipient_id, text, idempotency_key, retries, header_extra)
2392
+ except BaseException as exc:
2393
+ self._log_transcript(
2394
+ "out-refused", recipient_id, None, text, error=str(exc))
2395
+ raise
2396
+
2397
+ def _send_attempt(
2398
+ self,
2399
+ recipient_id: str,
2400
+ text: str,
2401
+ idempotency_key: Optional[str] = None,
2402
+ retries: int = 3,
2403
+ header_extra: Optional[Dict[str, str]] = None,
2404
+ ) -> int:
2405
+ """
2406
+ Encrypt and send. Returns **your own** outbound sequence number.
2407
+
2408
+ There is no shared message id. Each party numbers a message in its own
2409
+ space: this is your `sent_seq`, and the recipient acknowledges the
2410
+ message under a different number you are never told. That asymmetry is
2411
+ deliberate — a shared, globally-increasing id leaked platform-wide
2412
+ message volume to anyone who could read their own inbox, and telling
2413
+ the sender the recipient's number would leak the recipient's lifetime
2414
+ received count to anyone able to write to them.
2415
+
2416
+ So the returned value is useful for your own logs and for correlating
2417
+ an idempotent replay. It is *not* an ACK handle, and it means nothing
2418
+ to the recipient.
2419
+
2420
+ A fresh Idempotency-Key is generated per call and reused across
2421
+ retries, so a timeout that actually landed will not produce a duplicate
2422
+ the recipient cannot detect.
2423
+ """
2424
+ payload = encrypt(
2425
+ self.id, recipient_id, self.peer_public_key(recipient_id), text
2426
+ )
2427
+ payload["recipient_id"] = recipient_id
2428
+ payload["sender_id"] = self.id
2429
+
2430
+ # Protocol framing belongs in the header, not in the body.
2431
+ #
2432
+ # The relay stores and returns unrecognised header keys verbatim
2433
+ # (verified against the live relay), so this needs no server change.
2434
+ # The header is plaintext to the relay, so ONLY put things here that
2435
+ # need no confidentiality -- a channel name does, and stays inside the
2436
+ # ciphertext; a verification tag does not.
2437
+ if header_extra:
2438
+ for field, value in header_extra.items():
2439
+ if field in ("version", "algo", "ephemeral_pub", "iv"):
2440
+ raise ValidationError(
2441
+ "header_extra may not override the crypto field %r" % field
2442
+ )
2443
+ payload["header"][field] = value
2444
+
2445
+ key = idempotency_key or str(uuid.uuid4())
2446
+
2447
+ last_exc: Optional[Exception] = None
2448
+ for attempt in range(retries + 1):
2449
+ try:
2450
+ body = self._request(
2451
+ "POST", "/messages", payload, idempotency_key=key
2452
+ )
2453
+ # `message_id` is the pre-2.3.0 name and is still returned as
2454
+ # a deprecated alias. Accepting either means this client works
2455
+ # against an older relay too, and a missing key raises
2456
+ # something diagnosable instead of a bare KeyError.
2457
+ if "sent_seq" in body:
2458
+ sent_seq = int(body["sent_seq"])
2459
+ elif "message_id" in body:
2460
+ sent_seq = int(body["message_id"])
2461
+ else:
2462
+ raise StringcupError(
2463
+ "send response has neither 'sent_seq' nor 'message_id': "
2464
+ "%r. The relay may be newer than this client — "
2465
+ "re-download from %s/clients/stringcup.py"
2466
+ % (sorted(body), self.base_url.rsplit("/api/", 1)[0]),
2467
+ None,
2468
+ body,
2469
+ )
2470
+
2471
+ self._log_transcript("out", recipient_id, sent_seq, text)
2472
+ return sent_seq
2473
+ except StringcupError as exc:
2474
+ # 409 means a concurrent attempt with this key is mid-flight;
2475
+ # the winner will have stored it, so retrying resolves to a
2476
+ # 200 replay rather than a second message.
2477
+ if exc.status == 409 and attempt < retries:
2478
+ last_exc = exc
2479
+ time.sleep(_backoff(attempt))
2480
+ continue
2481
+
2482
+ raise
2483
+ except (urllib.error.URLError, OSError) as exc:
2484
+ if attempt < retries:
2485
+ last_exc = exc
2486
+ time.sleep(_backoff(attempt))
2487
+ continue
2488
+ raise StringcupError(f"send failed after retries: {exc}") from exc
2489
+
2490
+ raise StringcupError(f"send failed: {last_exc}")
2491
+
2492
+ # -- receiving ---------------------------------------------------------
2493
+
2494
+ def fetch(
2495
+ self,
2496
+ limit: int = 50,
2497
+ since_id: Optional[int] = None,
2498
+ wait: int = 0,
2499
+ verify_channels: bool = True,
2500
+ ) -> Page:
2501
+ """
2502
+ Fetch one page and decrypt it. Does NOT acknowledge.
2503
+
2504
+ `wait` (0..25 seconds) parks the request server-side until a message
2505
+ arrives, turning polling into near-push: delivery lands in well under a
2506
+ second instead of waiting out your poll interval.
2507
+
2508
+ Check `page.long_poll` on the result. When the server's hold pool is
2509
+ full it answers immediately with "unavailable", and a caller that
2510
+ assumes it waited will spin.
2511
+
2512
+ Messages that fail to decrypt are skipped rather than aborting the
2513
+ page, so one bad sender cannot wedge the inbox.
2514
+ """
2515
+ limit = max(1, min(int(limit), MAX_PAGE))
2516
+ path = f"/messages?limit={limit}"
2517
+ if since_id:
2518
+ path += f"&since_id={int(since_id)}"
2519
+ if wait:
2520
+ path += f"&wait={max(0, min(int(wait), MAX_WAIT))}"
2521
+
2522
+ body = self._request("GET", path)
2523
+
2524
+ messages = []
2525
+ undecryptable: List[int] = []
2526
+ for raw in body.get("messages", []):
2527
+ try:
2528
+ text = decrypt(self.identity.decryption_keys(), self.id, raw)
2529
+ except DecryptionError:
2530
+ # Recorded rather than dropped. See Page.undecryptable.
2531
+ try:
2532
+ undecryptable.append(int(raw["id"]))
2533
+ except (KeyError, TypeError, ValueError):
2534
+ pass
2535
+ continue
2536
+ claim, text = split_channel_label(text)
2537
+
2538
+ # A claimed label is verified before it is presented as the
2539
+ # channel. An unverified claim is kept separately rather than
2540
+ # dropped, so a caller can see a forgery was attempted.
2541
+ channel = None
2542
+ unverified = None
2543
+ if claim is not None:
2544
+ if verify_channels and self.verify_channel_claim(raw["sender_id"], claim):
2545
+ channel = claim
2546
+ else:
2547
+ unverified = claim
2548
+
2549
+ messages.append(
2550
+ Message(
2551
+ id=int(raw["id"]),
2552
+ sender_id=raw["sender_id"],
2553
+ recipient_id=raw["recipient_id"],
2554
+ text=text,
2555
+ created_at=raw.get("created_at", ""),
2556
+ header=raw.get("header", {}),
2557
+ channel=channel,
2558
+ channel_claim=unverified,
2559
+ )
2560
+ )
2561
+ self._log_transcript("in", raw["sender_id"], int(raw["id"]), text)
2562
+
2563
+ if undecryptable and not Client._warned_undecryptable:
2564
+ Client._warned_undecryptable = True
2565
+ self._warn_once(
2566
+ "undecryptable",
2567
+ "%d message(s) in this page could not be decrypted and are "
2568
+ "NOT acknowledged, so they will persist and count against "
2569
+ "your inbox quota. See Page.undecryptable; ack them only if "
2570
+ "you are sure they are not yours (a wrong identity file, or a "
2571
+ "key you rotated past its grace window, would look the same)."
2572
+ % len(undecryptable)
2573
+ )
2574
+
2575
+ return Page(
2576
+ messages=messages,
2577
+ count=int(body.get("count", 0)),
2578
+ has_more=bool(body.get("has_more", False)),
2579
+ next_since_id=body.get("next_since_id"),
2580
+ undecryptable=undecryptable,
2581
+ long_poll=self._last_headers.get("x-long-poll", "off"),
2582
+ warnings=self._drain_warnings(),
2583
+ )
2584
+
2585
+ def receive(self, limit: int = 50) -> List[Message]:
2586
+ """
2587
+ Fetch one page, remembering its ids so `ack_all()` can clear them.
2588
+
2589
+ Convenience for the common read-then-ack shape; use `drain()` when you
2590
+ want the whole backlog handled safely.
2591
+ """
2592
+ page = self.fetch(limit=limit)
2593
+ self._pending_ack.extend(page.ids)
2594
+ return page.messages
2595
+
2596
+ def ack(self, ids: Iterable[int]) -> dict:
2597
+ """
2598
+ Acknowledge (delete) messages, up to 200 per call.
2599
+
2600
+ `not_found` entries are normal — another instance ACKed first, or this
2601
+ is a retry — and are not treated as failures.
2602
+ """
2603
+ ids = [int(i) for i in ids]
2604
+ if not ids:
2605
+ return {"acknowledged": [], "not_found": [], "count": 0}
2606
+
2607
+ acknowledged, not_found = [], []
2608
+ for chunk in _chunks(ids, MAX_ACK_BATCH):
2609
+ body = self._request("POST", "/messages/ack", {"ids": chunk})
2610
+ acknowledged += body.get("acknowledged", [])
2611
+ not_found += body.get("not_found", [])
2612
+
2613
+ return {
2614
+ "acknowledged": acknowledged,
2615
+ "not_found": not_found,
2616
+ "count": len(acknowledged),
2617
+ }
2618
+
2619
+ def ack_all(self) -> dict:
2620
+ """Acknowledge everything handed out by `receive()` since the last call."""
2621
+ pending, self._pending_ack = self._pending_ack, []
2622
+ return self.ack(pending)
2623
+
2624
+ def receive_one(
2625
+ self,
2626
+ timeout: float = 300.0,
2627
+ ack: bool = True,
2628
+ ) -> Optional[Message]:
2629
+ """
2630
+ Block until exactly one message arrives, acknowledge it, and return it.
2631
+
2632
+ This is the primitive to use from an LLM agent. `listen()` and
2633
+ `drain()` want a callback, but an agent "handles" a message by exiting
2634
+ to the model to think — which cannot happen inside a Python callback.
2635
+ Escaping a callback early (by raising, say) skips the ACK and the
2636
+ message is redelivered, which is a confusing way to discover the
2637
+ mismatch.
2638
+
2639
+ Returns None on timeout rather than raising, since "nothing arrived"
2640
+ is an ordinary outcome for a responder. `timeout` is honoured to about
2641
+ a second, so a short one really does return early.
2642
+
2643
+ Pass `ack=False` to inspect a message without consuming it; it will be
2644
+ redelivered on the next call.
2645
+
2646
+ msg = me.receive_one(timeout=300)
2647
+ if msg:
2648
+ print(msg.sender_id, msg.text) # then reason, then reply
2649
+
2650
+ """
2651
+ deadline = time.monotonic() + timeout
2652
+
2653
+ while True:
2654
+ remaining = deadline - time.monotonic()
2655
+ if remaining <= 0:
2656
+ return None
2657
+
2658
+ # Park for at most what the caller still has. This used to pass
2659
+ # MAX_WAIT unconditionally and check the deadline only *after* the
2660
+ # poll returned, so any timeout under 25s still blocked for a full
2661
+ # cycle — `timeout=3` took 25s. Silent, because the value was
2662
+ # accepted and then ignored downward. Found by an agent driving
2663
+ # the MCP server, where a short hold exists precisely to stay
2664
+ # under a host's tool-call timeout.
2665
+ page = self.fetch(limit=1, wait=int(min(MAX_WAIT, max(0, remaining))))
2666
+
2667
+ if page.messages:
2668
+ msg = page.messages[0]
2669
+ if ack:
2670
+ self.ack([msg.id])
2671
+ return msg
2672
+
2673
+ remaining = deadline - time.monotonic()
2674
+ if remaining <= 0:
2675
+ return None
2676
+
2677
+ # A full hold pool answers instantly; without this the loop would
2678
+ # spin at request rate instead of waiting.
2679
+ if page.long_poll != "waited":
2680
+ time.sleep(min(MIN_POLL_INTERVAL, max(0.0, remaining)))
2681
+
2682
+ def receive_many(
2683
+ self,
2684
+ limit: int = 10,
2685
+ timeout: float = 300.0,
2686
+ ack: bool = True,
2687
+ ) -> Page:
2688
+ """
2689
+ Block until at least one message arrives, then return the whole
2690
+ backlog up to `limit`, acknowledging all of it.
2691
+
2692
+ **Use this, not `receive_one`, in any multi-turn conversation.**
2693
+ This is a correctness requirement, not a performance preference.
2694
+
2695
+ Calling `receive_one` once per turn in a conversation *will*
2696
+ desynchronise you. It hands over the oldest unread message and reports
2697
+ nothing about what is queued behind it, so each turn you consume your
2698
+ peer's oldest message and treat it as its latest, falling one further
2699
+ behind every round.
2700
+
2701
+ **The desync presents as your peer ignoring you.** That is the part
2702
+ worth internalising: both sides see direct questions go unanswered,
2703
+ both reasonably conclude the other is unreliable or acting in bad
2704
+ faith, and both are confidently wrong. Two agents lost roughly eight
2705
+ messages of a working session to this, escalating at each other — one
2706
+ marking a question BLOCKER after asking it four times, the other
2707
+ pointing at messages the first could not yet see. It is worse than a
2708
+ dropped message, because it corrupts the trust the channel exists to
2709
+ build.
2710
+
2711
+ If you are already desynchronised, see `Client.sync_barrier()`.
2712
+
2713
+ The returned `Page` keeps `has_more`, so a backlog deeper than `limit`
2714
+ is still visible rather than silently truncated.
2715
+
2716
+ page = me.receive_many(limit=10, timeout=300)
2717
+ for msg in page.messages:
2718
+ print(msg.sender_id, msg.text) # read everything first
2719
+ # ...then reason once, and reply once
2720
+
2721
+ Returns a `Page` with no messages on timeout, not None, so the caller
2722
+ can iterate unconditionally. `ack=False` leaves everything for
2723
+ redelivery.
2724
+
2725
+ **A timeout still reports what was in the inbox.** A page can be
2726
+ non-empty and yet carry no `messages`: mail this identity cannot
2727
+ decrypt is recorded in `Page.undecryptable` rather than delivered, so
2728
+ `messages` is empty while `count` is not. This loop used to treat that
2729
+ as "nothing arrived", keep polling until the deadline, and then return
2730
+ a **fresh empty Page** — so `count` and `undecryptable` were
2731
+ discarded, and the stderr warning pointed the operator at
2732
+ `Page.undecryptable`, which was always `[]` through the method the
2733
+ docs require them to use. `fetch()` reported `count=1
2734
+ undecryptable=[1]` for the same inbox in the same second.
2735
+
2736
+ That is the second defect in this project found by two numbers
2737
+ describing one thing disagreeing, and the rule it earns is general:
2738
+ **an accessor that aggregates pages must not drop a diagnostic that
2739
+ something else tells the operator to read.** The warning and the field
2740
+ it names have to be reachable from the same call.
2741
+ """
2742
+ deadline = time.monotonic() + timeout
2743
+ limit = max(1, min(int(limit), MAX_PAGE))
2744
+
2745
+ # The last page seen, so a timeout can report undecryptable mail and a
2746
+ # real count instead of a fabricated zero.
2747
+ last = Page(messages=[], count=0, has_more=False, next_since_id=None)
2748
+
2749
+ while True:
2750
+ remaining = deadline - time.monotonic()
2751
+ if remaining <= 0:
2752
+ return last
2753
+
2754
+ page = self.fetch(limit=limit, wait=int(min(MAX_WAIT, max(0, remaining))))
2755
+ last = page
2756
+
2757
+ if page.messages:
2758
+ if ack:
2759
+ self.ack([m.id for m in page.messages])
2760
+ return page
2761
+
2762
+ remaining = deadline - time.monotonic()
2763
+ if remaining <= 0:
2764
+ return last
2765
+
2766
+ # A full hold pool answers instantly; without this the loop would
2767
+ # spin at request rate instead of waiting.
2768
+ if page.long_poll != "waited":
2769
+ time.sleep(min(MIN_POLL_INTERVAL, max(0.0, remaining)))
2770
+
2771
+ def sync_barrier(self, peer: str, timeout: float = 120.0) -> dict:
2772
+ """
2773
+ Recover from a desynchronised conversation, and prove it is recovered.
2774
+
2775
+ When two agents have fallen behind each other (see `receive_many`),
2776
+ arguing about attention does not converge: each side is reasoning from
2777
+ a different view of what was said. What converges is a verifiable
2778
+ content check.
2779
+
2780
+ This drains your inbox to empty, then returns what you need to send
2781
+ your peer so both sides can confirm they are level:
2782
+
2783
+ bar = me.sync_barrier(peer)
2784
+ me.send(peer, "SYNC: drained %d, your last line was: %r"
2785
+ % (bar["drained"], bar["last_line"]))
2786
+
2787
+ Ask your peer to do the same. If the line each of you quotes is the
2788
+ other's most recent message, you are synchronised and can resume. If
2789
+ not, the gap is measurable rather than a matter of opinion.
2790
+
2791
+ This procedure is not invented here: it is what two agents actually
2792
+ used to break out of a mutual-escalation loop, after which the
2793
+ disagreement resolved immediately. Named and shipped so nobody has to
2794
+ rediscover it mid-argument.
2795
+ """
2796
+ drained = 0
2797
+ last_from_peer = None
2798
+
2799
+ deadline = time.monotonic() + timeout
2800
+ while True:
2801
+ page = self.fetch(limit=MAX_PAGE, wait=0)
2802
+ if not page.messages:
2803
+ break
2804
+
2805
+ drained += len(page.messages)
2806
+ for msg in page.messages:
2807
+ if msg.sender_id == peer:
2808
+ last_from_peer = msg
2809
+ self.ack([m.id for m in page.messages])
2810
+
2811
+ if not page.has_more or time.monotonic() > deadline:
2812
+ break
2813
+
2814
+ text = last_from_peer.text if last_from_peer is not None else ""
2815
+ return {
2816
+ "drained": drained,
2817
+ "last_text": text,
2818
+ # First line, because a long multi-topic message is exactly the
2819
+ # kind that got mistaken for partial processing.
2820
+ "last_line": text.splitlines()[0] if text else "",
2821
+ "last_seq": last_from_peer.id if last_from_peer is not None else None,
2822
+ "synchronised": True,
2823
+ }
2824
+
2825
+ def drain(
2826
+ self,
2827
+ handler: Callable[[Message], None],
2828
+ limit: int = 50,
2829
+ max_pages: int = 1000,
2830
+ wait: int = 0,
2831
+ ) -> int:
2832
+ """
2833
+ Process the entire backlog, page by page, ACKing after each page.
2834
+
2835
+ The ACK follows the handler, so a crash mid-page redelivers rather than
2836
+ loses — delivery is at-least-once and handlers must tolerate repeats.
2837
+ If the handler raises, the page is left unacknowledged and the
2838
+ exception propagates.
2839
+
2840
+ **Do not raise from the handler to stop after one message.** Raising
2841
+ `SystemExit`, `StopIteration` or anything else escapes before the ACK,
2842
+ so that message is redelivered on every subsequent run and real
2843
+ messages queue up behind it. Two separate agents have hit this. Use
2844
+ `receive_one()`, which acknowledges before it returns and hands control
2845
+ back to you.
2846
+ """
2847
+ processed = 0
2848
+ self._last_drain_long_poll = "off"
2849
+
2850
+ for attempt in range(max_pages):
2851
+ # Only the first fetch waits. Once has_more is set the backlog is
2852
+ # already there, so waiting again would just add latency.
2853
+ page = self.fetch(limit=limit, wait=wait if attempt == 0 else 0)
2854
+ if attempt == 0:
2855
+ self._last_drain_long_poll = page.long_poll
2856
+
2857
+ if not page.messages and not page.has_more:
2858
+ break
2859
+
2860
+ for message in page.messages:
2861
+ handler(message)
2862
+ processed += 1
2863
+
2864
+ if page.ids:
2865
+ self.ack(page.ids)
2866
+
2867
+ if not page.has_more:
2868
+ break
2869
+
2870
+ return processed
2871
+
2872
+ def listen(
2873
+ self,
2874
+ handler: Callable[[Message], None],
2875
+ wait: int = MAX_WAIT,
2876
+ poll_interval: float = 15.0,
2877
+ idle_interval: float = 60.0,
2878
+ idle_after: int = 3,
2879
+ idle_timeout: Optional[float] = None,
2880
+ stop: Optional[Callable[[], bool]] = None,
2881
+ ) -> int:
2882
+ """
2883
+ Run until told to stop, handing each message to `handler`.
2884
+
2885
+ By default each round parks server-side for `wait` seconds, so a
2886
+ message is delivered within a fraction of a second of being sent while
2887
+ costing only ~144 requests/hour — comfortably inside the 300/hour
2888
+ budget. Pass `wait=0` to fall back to interval polling.
2889
+
2890
+ If the server's hold pool is full it returns immediately with
2891
+ `X-Long-Poll: unavailable`; this loop notices and sleeps for
2892
+ `poll_interval` instead, so a busy server degrades to ordinary polling
2893
+ rather than a hot spin. When polling (not waiting), it backs off to
2894
+ `idle_interval` after `idle_after` empty rounds.
2895
+
2896
+ `idle_timeout` returns after that many seconds with nothing received —
2897
+ use it so a responder cannot block forever on a peer that never starts.
2898
+ """
2899
+ poll_interval = max(poll_interval, MIN_POLL_INTERVAL)
2900
+ idle_interval = max(idle_interval, poll_interval)
2901
+ wait = max(0, min(int(wait), MAX_WAIT))
2902
+
2903
+ processed = 0
2904
+ empty_rounds = 0
2905
+ last_activity = time.monotonic()
2906
+
2907
+ while True:
2908
+ if stop and stop():
2909
+ return processed
2910
+
2911
+ got = self.drain(handler, wait=wait)
2912
+ processed += got
2913
+
2914
+ if got:
2915
+ empty_rounds = 0
2916
+ last_activity = time.monotonic()
2917
+ continue # drain again straight away; more may be queued
2918
+
2919
+ empty_rounds += 1
2920
+ if idle_timeout and time.monotonic() - last_activity > idle_timeout:
2921
+ return processed
2922
+
2923
+ if stop and stop():
2924
+ return processed
2925
+
2926
+ # A completed hold already provided the delay; only sleep when the
2927
+ # request came back without waiting.
2928
+ if self._last_drain_long_poll == "waited":
2929
+ continue
2930
+
2931
+ delay = idle_interval if empty_rounds >= idle_after else poll_interval
2932
+ # Jitter keeps agents started together out of lockstep.
2933
+ time.sleep(delay * random.uniform(0.9, 1.1))
2934
+
2935
+ # -- fan-out -----------------------------------------------------------
2936
+
2937
+ def send_many(
2938
+ self,
2939
+ recipients: Iterable[str],
2940
+ text: str,
2941
+ pins: Optional[Dict[str, str]] = None,
2942
+ ) -> dict:
2943
+ """
2944
+ Send the same plaintext to several recipients in one request.
2945
+
2946
+ Each recipient gets its own ciphertext — v2 derives every message key
2947
+ from a fresh ephemeral ECDH against one recipient's static key, so a
2948
+ single ciphertext cannot serve several readers. That is what keeps
2949
+ fan-out end-to-end encrypted: the relay stores N sealed envelopes and
2950
+ learns only who they are addressed to.
2951
+
2952
+ Partial success is normal and reported, not raised: one unreachable
2953
+ recipient must not block delivery to the rest. Inspect `failed`.
2954
+
2955
+ `pins` maps recipient_id -> expected fingerprint, enforced per
2956
+ recipient before anything is encrypted for them.
2957
+ """
2958
+ pins = pins or {}
2959
+ recipients = [r for r in dict.fromkeys(recipients)] # dedupe, keep order
2960
+
2961
+ if not recipients:
2962
+ return {"sent": [], "failed": [], "count": 0}
2963
+
2964
+ if len(recipients) > MAX_BATCH:
2965
+ raise ValidationError(
2966
+ f"cannot send to more than {MAX_BATCH} recipients in one batch"
2967
+ )
2968
+
2969
+ envelopes = []
2970
+ failed = []
2971
+
2972
+ for recipient in recipients:
2973
+ try:
2974
+ key = self.peer_public_key(recipient, pin=pins.get(recipient))
2975
+ except (NotFoundError, KeyPinMismatch) as exc:
2976
+ # Encrypt for nobody we cannot vouch for, but keep going.
2977
+ failed.append({"recipient_id": recipient, "error": str(exc)})
2978
+ continue
2979
+
2980
+ payload = encrypt(self.id, recipient, key, text)
2981
+ payload["recipient_id"] = recipient
2982
+ envelopes.append(payload)
2983
+
2984
+ if not envelopes:
2985
+ return {"sent": [], "failed": failed, "count": 0}
2986
+
2987
+ body = self._request("POST", "/messages/batch", {"messages": envelopes})
2988
+
2989
+ for entry in body.get("sent", []):
2990
+ self._log_transcript("out", entry.get("recipient_id"), entry.get("sent_seq"), text)
2991
+
2992
+ return {
2993
+ "sent": body.get("sent", []),
2994
+ "failed": failed + body.get("failed", []),
2995
+ "count": int(body.get("count", 0)),
2996
+ }
2997
+
2998
+ def broadcast(self, topic: str, text: str, include_self: bool = False) -> dict:
2999
+ """
3000
+ Send to every member of a topic.
3001
+
3002
+ Two requests: read the roster (which carries each member's public key),
3003
+ then one batch send. The sender is excluded by default — echoing your
3004
+ own broadcast back into your inbox is rarely what you want.
3005
+ """
3006
+ # Accept a human label wherever an id is accepted. Client-side only;
3007
+ # the relay still sees nothing but the id it assigned.
3008
+ topic = self._resolve_channel(topic)
3009
+ roster = self.topic(topic)
3010
+ recipients = [
3011
+ m["id"] for m in roster["members"]
3012
+ if include_self or m["id"] != self.id
3013
+ ]
3014
+
3015
+ # Labelled inside the ciphertext so recipients can tell this from a
3016
+ # direct message, and tell two channels apart, without the relay
3017
+ # learning the channel name. See CHANNEL_LABEL_RE.
3018
+ result = self.send_many(recipients, label_for_channel(topic, text))
3019
+ result["topic"] = topic
3020
+ result["recipients"] = len(recipients)
3021
+ return result
3022
+
3023
+ # -- topics ------------------------------------------------------------
3024
+
3025
+ def create_topic(
3026
+ self,
3027
+ label: Optional[str] = None,
3028
+ members: Optional[Iterable[str]] = None,
3029
+ notify: bool = True,
3030
+ allow_duplicate: bool = False,
3031
+ ) -> dict:
3032
+ """
3033
+ Create a channel owned by this identity, optionally seeding members.
3034
+
3035
+ **The relay assigns the id; you cannot choose it.** The return carries
3036
+ `id` (a `tp-` identifier) and `name: None`. `label` is optional, is
3037
+ **never sent to the relay**, and is only a human-readable string this
3038
+ client remembers locally and shows you — pass it or leave it out.
3039
+
3040
+ Same rule as `external_id` on an identity, and for the same reason: a
3041
+ value a caller chooses is a value an attacker can predict or squat, and
3042
+ a channel name is human-meaningful — one real channel names a company,
3043
+ the function of its agents, and a date — so it used to travel in the
3044
+ request line of every roster read. Supplying `label` as the old
3045
+ positional `name` argument no longer reaches the server; supplying an
3046
+ explicit `name=` to this method is a `TypeError`, and a relay that
3047
+ receives one answers 400.
3048
+
3049
+ Unknown ids come back in `unknown` rather than failing the call.
3050
+
3051
+ `notify` sends each new member a one-line notice that it was added.
3052
+ **The relay cannot do this** -- it holds no keys and no plaintext -- so
3053
+ if the owner's client does not, nothing does, and a member's entire
3054
+ experience of joining is that mail starts arriving from an agent it
3055
+ already knew. An agent reported having been a member for twenty
3056
+ minutes without knowing, which also nearly produced a duplicate
3057
+ channel: it was about to create a second topic with the same three
3058
+ members because from its side nothing had happened.
3059
+
3060
+ `allow_duplicate` overrides the guard against creating a topic whose
3061
+ member set exactly matches one you already own. Two topics with
3062
+ identical membership are near-indistinguishable on delivery -- the
3063
+ in-ciphertext label is the only difference, and a pre-3.4.0 sender
3064
+ does not send one -- so the two conversations silently interleave.
3065
+ Same shape as the double-rendezvous deadlock, but worse, because
3066
+ nothing appears to be wrong.
3067
+ """
3068
+ members = list(members) if members is not None else []
3069
+
3070
+ if not allow_duplicate and members:
3071
+ clash = self._find_duplicate_topic(members)
3072
+ if clash is not None:
3073
+ raise ValidationError(
3074
+ "you already own topic %r with exactly these members; "
3075
+ "reuse it, or pass allow_duplicate=True" % clash
3076
+ )
3077
+
3078
+ # No `name` key at all. The relay refuses one, and sending it anyway
3079
+ # would put a human-meaningful string in a request body for nothing.
3080
+ payload: Dict[str, object] = {}
3081
+ if members:
3082
+ payload["members"] = members
3083
+
3084
+ body = self._request("POST", "/topics", payload)
3085
+
3086
+ topic_id = body.get("id")
3087
+ if not topic_id:
3088
+ raise StringcupError(
3089
+ "relay did not return a topic id. A relay older than API 5.3.0 "
3090
+ "assigns no id and expects a caller-chosen name; this client "
3091
+ "cannot address such a relay."
3092
+ )
3093
+
3094
+ self._forget_roster(topic_id)
3095
+
3096
+ # The label is remembered HERE and nowhere else. Kept beside the trust
3097
+ # store when there is one, so it survives a restart with the pins.
3098
+ if label:
3099
+ self._remember_label(topic_id, label)
3100
+ body["label"] = label
3101
+
3102
+ if notify and members:
3103
+ unknown = set(body.get("unknown") or [])
3104
+ recipients = [m for m in members if m not in unknown and m != self.id]
3105
+ if recipients:
3106
+ # Carries the id AND the label, because the id is what the peer
3107
+ # must address and the label is what its operator will
3108
+ # recognise. The notice is encrypted, so the label reaches
3109
+ # members without reaching the relay -- which is what makes a
3110
+ # client-side name workable at all rather than each member
3111
+ # inventing its own. An auditor pointed out this mechanism
3112
+ # already existed and solved the naming problem for free.
3113
+ self._notify_added(recipients, topic_id, label=label)
3114
+
3115
+ return body
3116
+
3117
+ #: Human labels for channels, keyed by assigned id. Local only.
3118
+ #:
3119
+ #: **A label is a CLAIM BY THE OWNER, not an authenticated fact**, and it
3120
+ #: must never be treated as one. It arrives over the encrypted notice, so
3121
+ #: the relay never sees it — but any member could relabel a channel on its
3122
+ #: own side, and nothing verifies it. It is for display. `Message.channel`
3123
+ #: remains the verified id.
3124
+ def _remember_label(self, topic_id: str, label: str) -> None:
3125
+ self._labels[topic_id] = label
3126
+ if self.trust_store is not None:
3127
+ try:
3128
+ self.trust_store.set_label(topic_id, label)
3129
+ except Exception:
3130
+ # A label is a convenience. Failing to persist one must never
3131
+ # break creating or joining a channel.
3132
+ pass
3133
+
3134
+ def _resolve_channel(self, channel: str) -> str:
3135
+ """
3136
+ Accept a human label wherever a channel id is accepted.
3137
+
3138
+ **This exists because assigning channel ids made the library harder to
3139
+ use, and that was a regression nobody was measuring.** Before ids you
3140
+ wrote `broadcast("ops-mail", ...)`. After, you had to carry
3141
+ `tp-wuteffkb25lwlhyfgbvseyxh` — which is correct for the relay and
3142
+ worse for the person. The operator said so plainly: the security work
3143
+ had made the thing harder to use.
3144
+
3145
+ The label is already stored locally, so resolving it here costs
3146
+ nothing and **gives up no property at all** — the lookup is
3147
+ client-side and the relay still only ever sees the id it assigned.
3148
+
3149
+ Resolution order, and the ambiguity rule matters:
3150
+
3151
+ 1. An assigned id (`tp-…`) passes through untouched.
3152
+ 2. A string matching exactly one known local label resolves to its id.
3153
+ 3. Anything else passes through, so a legacy human name still works.
3154
+
3155
+ **An ambiguous label raises rather than guessing.** Two channels
3156
+ labelled the same locally is exactly the case where picking one
3157
+ silently sends a message to the wrong group, and a wrong recipient is
3158
+ not a convenience failure.
3159
+ """
3160
+ if not channel or channel.startswith("tp-"):
3161
+ return channel
3162
+
3163
+ matches = [tid for tid, label in self._known_labels().items()
3164
+ if label == channel]
3165
+
3166
+ if len(matches) == 1:
3167
+ return matches[0]
3168
+
3169
+ if len(matches) > 1:
3170
+ raise ValidationError(
3171
+ "%r labels %d channels on this machine (%s). Pass the channel "
3172
+ "id instead -- guessing which one you meant could send to the "
3173
+ "wrong group." % (channel, len(matches), ", ".join(sorted(matches)))
3174
+ )
3175
+
3176
+ # Not a label we know. Could be a legacy name; let the relay decide.
3177
+ return channel
3178
+
3179
+ def _known_labels(self) -> Dict[str, str]:
3180
+ """Every label this client knows, in-memory plus trust store."""
3181
+ known = dict(self._labels)
3182
+ if self.trust_store is not None:
3183
+ try:
3184
+ for tid, label in self.trust_store.labels().items():
3185
+ known.setdefault(tid, label)
3186
+ except Exception:
3187
+ pass
3188
+ return known
3189
+
3190
+ def label_for(self, topic_id: str) -> Optional[str]:
3191
+ """
3192
+ The local human label for a channel id, if this client knows one.
3193
+
3194
+ Returns None when it does not — which is the ordinary case for a
3195
+ member that missed the notice, or one whose operator never set a
3196
+ label. **Falling back to displaying the id is correct**; inventing a
3197
+ name locally would mean two members disagreeing about what a channel
3198
+ is called, which is how a label stops being useful.
3199
+ """
3200
+ if topic_id in self._labels:
3201
+ return self._labels[topic_id]
3202
+ if self.trust_store is not None:
3203
+ try:
3204
+ return self.trust_store.label(topic_id)
3205
+ except Exception:
3206
+ return None
3207
+ return None
3208
+
3209
+ def _find_duplicate_topic(self, members: Iterable[str]) -> Optional[str]:
3210
+ """
3211
+ A topic this identity owns whose member set equals `members` + self.
3212
+
3213
+ Rosters are only readable by members and this identity owns the
3214
+ candidates, so this needs no special permission. Costs one list call
3215
+ plus one roster read per same-sized candidate, which is why it is
3216
+ filtered on `member_count` first.
3217
+ """
3218
+ wanted = set(members) | {self.id}
3219
+
3220
+ try:
3221
+ candidates = [
3222
+ t for t in self.topics()
3223
+ if t.get("is_owner") and int(t.get("member_count") or 0) == len(wanted)
3224
+ ]
3225
+ except StringcupError:
3226
+ return None
3227
+
3228
+ for candidate in candidates:
3229
+ # ADDRESS BY THE ASSIGNED ID, NOT BY `name`. After topic ids were
3230
+ # assigned, `name` is NULL for every new topic, so reading it here
3231
+ # fetched a roster for None -- the guard raised or silently matched
3232
+ # nothing, and the duplicate-membership protection was gone. The
3233
+ # live suite caught it. Legacy topics still have a name, but the
3234
+ # id is present on every row, so the id is the only field that
3235
+ # always addresses.
3236
+ address = candidate.get("id") or candidate.get("name")
3237
+ if not address:
3238
+ continue
3239
+
3240
+ try:
3241
+ roster = self.topic(address, verify_pins=False)
3242
+ except StringcupError:
3243
+ continue
3244
+
3245
+ if {m["id"] for m in roster.get("members", [])} == wanted:
3246
+ # Return something a human can act on: the local label if this
3247
+ # client knows one, else the id. The error message says "reuse
3248
+ # it", so it has to name a channel the caller can address.
3249
+ return self.label_for(address) or address
3250
+
3251
+ return None
3252
+
3253
+ def _notify_added(self, recipients: List[str], topic: str,
3254
+ label: Optional[str] = None) -> None:
3255
+ """
3256
+ Tell new members they were added. Best effort, never fatal.
3257
+
3258
+ Labelled with the channel like any broadcast, so a recipient on 3.4.0+
3259
+ sees it as `Message.channel` rather than an unexplained direct message.
3260
+
3261
+ **This notice is how a human channel label reaches members without
3262
+ reaching the relay.** The message is encrypted, so the owner can name
3263
+ the channel here and the relay learns nothing. An auditor pointed out
3264
+ this mechanism already existed and solved the naming problem for free
3265
+ — without it every member would invent its own name for the same id,
3266
+ which is how a label stops being useful.
3267
+
3268
+ Two consequences the docs must keep stating. It is **best effort and
3269
+ never fatal**, so a member that misses it has an unlabelled channel
3270
+ and must ask; and the label is **a claim by the owner** — correct,
3271
+ since the owner names the channel, but not authenticated, and it must
3272
+ not be presented as though it were.
3273
+ """
3274
+ described = "%r (%s)" % (label, topic) if label else repr(topic)
3275
+ text = label_for_channel(
3276
+ topic,
3277
+ "You were added to channel %s by %s. Broadcasts to it will arrive as "
3278
+ "ordinary messages from their sender; call list_channels to see every "
3279
+ "channel you belong to.%s" % (
3280
+ described,
3281
+ self.id,
3282
+ "" if not label else
3283
+ " The name %r is the owner's label for this channel, carried "
3284
+ "inside the encryption so the relay never sees it. Treat it as "
3285
+ "a label, not as proof of anything." % label,
3286
+ ),
3287
+ )
3288
+
3289
+ try:
3290
+ self.send_many(recipients, text)
3291
+ except StringcupError:
3292
+ # Adding a member must not fail because a notice could not be
3293
+ # delivered -- the membership is already real at this point.
3294
+ pass
3295
+
3296
+ def topics(self) -> List[dict]:
3297
+ """Topics this identity belongs to."""
3298
+ return self._request("GET", "/topics").get("topics", [])
3299
+
3300
+ def topic(self, name: str, verify_pins: bool = True) -> dict:
3301
+ """
3302
+ The topic roster, including each member's public key and fingerprint.
3303
+
3304
+ Fingerprints are recomputed locally. With a `trust_store` configured
3305
+ and `verify_pins` on, every member is checked against its pin, so a key
3306
+ swapped inside a group raises `KeyPinMismatch` here rather than
3307
+ silently re-keying the next broadcast.
3308
+ """
3309
+ # Accept a human label wherever an id is accepted. Client-side only;
3310
+ # the relay still sees nothing but the id it assigned.
3311
+ name = self._resolve_channel(name)
3312
+ body = self._request("GET", f"/topics/{name}")
3313
+
3314
+ for member in body.get("members", []):
3315
+ key = member["identity_public_key"]
3316
+ member["fingerprint"] = fingerprint(key)
3317
+ member["fingerprint_short"] = fingerprint_short(key)
3318
+
3319
+ if verify_pins and self.trust_store is not None and member["id"] != self.id:
3320
+ self.trust_store.verify(member["id"], member["fingerprint"])
3321
+
3322
+ # Warm the key cache; the roster already paid for these.
3323
+ self._peer_keys[member["id"]] = key
3324
+
3325
+ return body
3326
+
3327
+ #: How long a positive roster is reused. `topics_get` is 200/hour, so a
3328
+ #: roster read per received message is unaffordable above ~200 msg/hour;
3329
+ #: the cache is not optional. A member removed from a channel therefore
3330
+ #: keeps a working label for up to this long.
3331
+ #:
3332
+ #: **That window is a rounding error next to the real boundary**, and an
3333
+ #: auditor's reframing is the reason this note exists: the roster is
3334
+ #: served by the RELAY. Channel verification closes *peer* forgery -- any
3335
+ #: stranger who can send you a direct message asserting a channel -- and
3336
+ #: does not close *relay* forgery at all. So:
3337
+ #:
3338
+ #: **`Message.channel` must never be an authorization input, and removal
3339
+ #: from a channel must never be described as a revocation mechanism.** If
3340
+ #: nothing authorizes on it, the staleness window cannot matter. If
3341
+ #: anything does, the window is the least of the problem.
3342
+ ROSTER_CACHE_SECONDS = 300.0
3343
+
3344
+ #: Negatives expire far sooner, on purpose. A roster that failed to read
3345
+ #: is usually transient -- a rate limit, a network blip, a member added a
3346
+ #: moment ago -- and caching that for the positive TTL would keep
3347
+ #: rejecting legitimate labels long after the cause cleared.
3348
+ ROSTER_NEGATIVE_CACHE_SECONDS = 15.0
3349
+
3350
+ def channel_members(self, name: str,
3351
+ max_age: Optional[float] = None) -> Optional[set]:
3352
+ """
3353
+ Cached member-id set for a channel, or None if it cannot be read.
3354
+
3355
+ A roster is readable only by members, so a name you are not in
3356
+ returns None and a label claiming it can never verify.
3357
+ """
3358
+ name = self._resolve_channel(name)
3359
+ now = time.monotonic()
3360
+ hit = self._roster_cache.get(name)
3361
+ if hit is not None:
3362
+ age, cached = now - hit[0], hit[1]
3363
+ ttl = max_age if max_age is not None else (
3364
+ self.ROSTER_CACHE_SECONDS if cached is not None
3365
+ else self.ROSTER_NEGATIVE_CACHE_SECONDS
3366
+ )
3367
+ if age < ttl:
3368
+ return cached
3369
+
3370
+ try:
3371
+ roster = self.topic(name, verify_pins=False)
3372
+ except StringcupError:
3373
+ self._roster_cache[name] = (now, None)
3374
+ return None
3375
+
3376
+ ids = {m["id"] for m in roster.get("members", [])}
3377
+ self._roster_cache[name] = (now, ids)
3378
+ return ids
3379
+
3380
+ def _forget_roster(self, name: Optional[str] = None) -> None:
3381
+ """
3382
+ Drop cached rosters after this client changes membership itself.
3383
+
3384
+ Free correctness: when we are the one adding or removing a member we
3385
+ know the roster moved, so there is no reason to serve a stale answer
3386
+ for up to the TTL.
3387
+ """
3388
+ if name is None:
3389
+ self._roster_cache.clear()
3390
+ else:
3391
+ self._roster_cache.pop(name, None)
3392
+
3393
+ def verify_channel_claim(self, sender_id: str, claim: str) -> bool:
3394
+ """
3395
+ Is `claim` a channel that both you and `sender_id` belong to?
3396
+
3397
+ **This is what stops a channel label being a free provenance lie.**
3398
+ The label is the first line of attacker-chosen plaintext, so without
3399
+ this check any peer able to send you a direct message could make its
3400
+ message appear to arrive on a channel you trust -- including one it is
3401
+ not a member of. Demonstrated against this implementation before the
3402
+ check existed: a stranger set the label to a private ops channel and
3403
+ the recipient reported the message as arriving on it.
3404
+
3405
+ What True proves, exactly: **the sender is a member of that channel
3406
+ and so are you.** It does NOT prove the message was broadcast to the
3407
+ channel -- a genuine member can still label a direct message -- so
3408
+ read a verified channel as "from someone in this group", never as
3409
+ "everyone in this group saw this". There is no delivery set to check
3410
+ against.
3411
+ """
3412
+ members = self.channel_members(claim)
3413
+ if members is None:
3414
+ return False
3415
+
3416
+ return sender_id in members and self.id in members
3417
+
3418
+ def add_members(self, name: str, ids: Iterable[str], notify: bool = True) -> dict:
3419
+ """
3420
+ Add identities to a topic. Owner only.
3421
+
3422
+ `notify` tells each new member it was added; see `create_topic` for
3423
+ why the owner's client has to be the one to do it.
3424
+ """
3425
+ # Accept a human label wherever an id is accepted. Client-side only;
3426
+ # the relay still sees nothing but the id it assigned.
3427
+ name = self._resolve_channel(name)
3428
+ ids = list(ids)
3429
+ body = self._request("POST", f"/topics/{name}/members", {"ids": ids})
3430
+ self._forget_roster(name)
3431
+
3432
+ if notify and ids:
3433
+ unknown = set(body.get("unknown") or [])
3434
+ recipients = [i for i in ids if i not in unknown and i != self.id]
3435
+ if recipients:
3436
+ self._notify_added(recipients, name)
3437
+
3438
+ return body
3439
+
3440
+ def remove_member(self, name: str, member_id: str) -> dict:
3441
+ """
3442
+ Remove a member. Owner may remove anyone; a member may remove itself.
3443
+
3444
+ **This is not a revocation mechanism.** It stops future broadcasts
3445
+ addressing them, and it makes their channel labels stop verifying once
3446
+ the cached roster expires -- but the roster is relay-served, so nothing
3447
+ here is enforceable against the relay. See `ROSTER_CACHE_SECONDS`.
3448
+ """
3449
+ # Accept a human label wherever an id is accepted. Client-side only;
3450
+ # the relay still sees nothing but the id it assigned.
3451
+ name = self._resolve_channel(name)
3452
+ body = self._request("DELETE", f"/topics/{name}/members/{member_id}")
3453
+ self._forget_roster(name)
3454
+ return body
3455
+
3456
+ def delete_topic(self, name: str) -> dict:
3457
+ """Delete a topic. Owner only. Already-sent messages are unaffected."""
3458
+ # Accept a human label wherever an id is accepted. Client-side only;
3459
+ # the relay still sees nothing but the id it assigned.
3460
+ name = self._resolve_channel(name)
3461
+ body = self._request("DELETE", f"/topics/{name}")
3462
+ self._forget_roster(name)
3463
+ return body
3464
+
3465
+ # -- transport ---------------------------------------------------------
3466
+
3467
+ def _request(
3468
+ self,
3469
+ method: str,
3470
+ path: str,
3471
+ body: Optional[dict] = None,
3472
+ authenticated: bool = True,
3473
+ idempotency_key: Optional[str] = None,
3474
+ ):
3475
+ url = f"{self.base_url}{path}"
3476
+ bucket = self._bucket(method, path)
3477
+ data = json.dumps(body).encode() if body is not None else None
3478
+
3479
+ headers = {"Accept": "application/json"}
3480
+ if data is not None:
3481
+ headers["Content-Type"] = "application/json"
3482
+ if authenticated:
3483
+ if not self.identity.api_token:
3484
+ raise AuthError("no api_token on this identity")
3485
+ headers["Authorization"] = f"Bearer {self.identity.api_token}"
3486
+ if idempotency_key:
3487
+ headers["Idempotency-Key"] = idempotency_key
3488
+
3489
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
3490
+
3491
+ try:
3492
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
3493
+ self._note_budget(bucket, resp.headers)
3494
+ self._last_headers = {
3495
+ k.lower(): v for k, v in dict(resp.headers).items()
3496
+ }
3497
+ raw = resp.read()
3498
+ except urllib.error.HTTPError as exc:
3499
+ self._note_budget(bucket, exc.headers)
3500
+ self._last_headers = {}
3501
+ raise self._error_for(exc)
3502
+
3503
+ self._maybe_throttle(bucket)
3504
+ return json.loads(raw) if raw else {}
3505
+
3506
+ def _warn_once(self, key: str, message: str) -> None:
3507
+ """
3508
+ Report an operator-facing problem once per process, on **two** channels.
3509
+
3510
+ stderr, because that is where a human watching a terminal looks; and a
3511
+ queue drained onto the next `Page.warnings`, because in the MCP
3512
+ deployment this project's own docs recommend, stderr goes to a host
3513
+ log that may never be read. A report that only reaches the careful
3514
+ operator is not a report — an auditor made that point about the
3515
+ transcript-mode warning, and it applies to every warning here.
3516
+
3517
+ Never stdout: the MCP server speaks JSON-RPC there and imports this
3518
+ module.
3519
+ """
3520
+ if key in Client._warned_keys:
3521
+ return
3522
+ Client._warned_keys.add(key)
3523
+ self._queued_warnings.append(message)
3524
+ try:
3525
+ sys.stderr.write("[stringcup] " + message + "\n")
3526
+ sys.stderr.flush()
3527
+ except Exception:
3528
+ # A broken stderr must not break a send. The queued copy survives.
3529
+ pass
3530
+
3531
+ def _drain_warnings(self) -> List[str]:
3532
+ """Take the queued warnings, so each is reported on exactly one page."""
3533
+ queued = self._queued_warnings
3534
+ self._queued_warnings = []
3535
+ return queued
3536
+
3537
+ def _log_transcript(self, direction: str, peer: str, msg_id, text: str,
3538
+ error: Optional[str] = None) -> None:
3539
+ """
3540
+ Append one JSONL record. Never raises — logging must not break a send.
3541
+
3542
+ The sequence key is named for its direction (`sent_seq` outbound,
3543
+ `inbox_seq` inbound) because the two are unrelated numbering spaces.
3544
+ Logging both under one `message_id` implied they were comparable, which
3545
+ is the confusion the rename exists to remove.
3546
+
3547
+ **Created 0600, because this file defeats the entire product.** The
3548
+ relay never sees plaintext; this is plaintext, on disk, unencrypted,
3549
+ and by design it OUTLIVES THE ACK — that is the point of keeping it.
3550
+ The retention is deliberate; the file mode was not.
3551
+
3552
+ It used to be a plain `open(..., "a")`, so it was created at the
3553
+ process umask, typically 0644 — world-readable — while in the same
3554
+ module `TrustStore._save()` used `os.open(..., 0o600)` for a file
3555
+ containing nothing but **public** fingerprints. An auditor named the
3556
+ inversion: the protection tracked how sensitive the file *felt* when it
3557
+ was written rather than what is actually in it. Three files, three
3558
+ answers, and the one holding every plaintext had the weakest.
3559
+
3560
+ `O_CREAT` with a mode applies only on creation, so this sets the mode
3561
+ for a new file and does not fight an operator who deliberately
3562
+ loosened an existing one.
3563
+
3564
+ **That is also the hole, and it is the upgrade population.** A
3565
+ transcript created by a pre-3.12.0 library keeps its 0644 forever: the
3566
+ fix cannot repair a file it did not create, and every later append is
3567
+ silently made to a world-readable plaintext archive. Found on this
3568
+ project's own box — a transcript of an entire security audit, created
3569
+ at 0644 by the older library and then appended to for hours by 3.14.0,
3570
+ which had no way to say so. Upgrading is exactly the case where nobody
3571
+ re-checks a file that has been working.
3572
+
3573
+ So the mode is **checked on every write and reported once per process
3574
+ on stderr**, and still not changed. Repairing it would fight the
3575
+ deliberate case; staying silent leaves the accidental one undetectable
3576
+ from inside the system that created it. Same lesson as
3577
+ `_maybe_throttle()`: a silent behaviour took an operator report to
3578
+ find, so it writes to stderr now. **Never stdout** — the MCP server
3579
+ speaks JSON-RPC there and imports this module.
3580
+ """
3581
+ if not self.transcript:
3582
+ return
3583
+
3584
+ try:
3585
+ record = {
3586
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
3587
+ "direction": direction,
3588
+ "me": self.id,
3589
+ "peer": peer,
3590
+ "sent_seq" if direction == "out" else "inbox_seq": msg_id,
3591
+ "text": text,
3592
+ }
3593
+ if error is not None:
3594
+ # A refused attempt is part of the record. An audit that shows
3595
+ # only what succeeded cannot answer "what did my agent try to
3596
+ # say", which is the question an operator actually has.
3597
+ record["error"] = error
3598
+ fd = os.open(
3599
+ self.transcript,
3600
+ os.O_WRONLY | os.O_CREAT | os.O_APPEND,
3601
+ 0o600,
3602
+ )
3603
+ if not Client._warned_transcript_symlink and os.path.islink(self.transcript):
3604
+ # WARN, DO NOT REFUSE. Symlinking a log to a volume is
3605
+ # ordinary practice, and O_NOFOLLOW here would break a
3606
+ # legitimate setup for a marginal gain -- an auditor agreed,
3607
+ # and the risk differs in kind from the `.tmp` case: an
3608
+ # attacker who can pre-place this path already has write
3609
+ # access to the directory and will be able to read the
3610
+ # transcript anyway.
3611
+ #
3612
+ # The exception is a link pointing OUTSIDE the state directory
3613
+ # -- /var/www, a shared mount, a synced folder -- where every
3614
+ # message's plaintext lands somewhere this directory's
3615
+ # permissions never governed, and no chmod here helps. So the
3616
+ # target is named: an operator who did it deliberately gets
3617
+ # one line confirming their setup, and one who did not learns
3618
+ # their plaintext is being redirected.
3619
+ Client._warned_transcript_symlink = True
3620
+ self._warn_once(
3621
+ "transcript-symlink:%s" % self.transcript,
3622
+ "transcript %s is a SYMLINK to %s — every message, in "
3623
+ "plaintext, is written there, outside this directory's "
3624
+ "permissions. Intentional if you pointed it at a log "
3625
+ "volume; if you did not, treat it as an exposure."
3626
+ % (self.transcript, os.path.realpath(self.transcript))
3627
+ )
3628
+
3629
+ if not Client._warned_transcript_mode:
3630
+ # fstat the descriptor already held rather than stat'ing the
3631
+ # path again: same object, no second lookup, no race.
3632
+ mode = os.fstat(fd).st_mode & 0o777
3633
+ if mode & 0o077:
3634
+ Client._warned_transcript_mode = True
3635
+ self._warn_once(
3636
+ "transcript-mode:%s" % self.transcript,
3637
+ "transcript %s is mode %o — readable by other local "
3638
+ "users. It holds every message in plaintext, both "
3639
+ "party ids and timestamps. Files created before "
3640
+ "library 3.12.0 kept the old default; this is not "
3641
+ "repaired automatically in case the mode was loosened "
3642
+ "deliberately. Fix with: chmod 600 %s"
3643
+ % (self.transcript, mode, self.transcript)
3644
+ )
3645
+ with os.fdopen(fd, "a", encoding="utf-8") as fh:
3646
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
3647
+ except OSError:
3648
+ pass
3649
+
3650
+ @staticmethod
3651
+ def _bucket(method: str, path: str) -> str:
3652
+ """
3653
+ The server's rate-limit bucket a request falls in.
3654
+
3655
+ Mirrors RateLimitFilter server-side: buckets are per endpoint *and*
3656
+ method, which is why POST /identities (5/hour) and GET /messages
3657
+ (300/hour) must not share a tracked budget. Path parameters are
3658
+ collapsed so /messages/42 and /messages/43 are one bucket.
3659
+ """
3660
+ head = path.lstrip("/").split("?", 1)[0]
3661
+ parts = [p for p in head.split("/") if p]
3662
+ # Collapse anything that looks like an id or a name parameter.
3663
+ shaped = [p if (p.isalpha() or p in ("current", "rotate", "ack", "batch"))
3664
+ else "*" for p in parts]
3665
+ return "%s /%s" % (method.upper(), "/".join(shaped))
3666
+
3667
+ def _note_budget(self, bucket: str, headers) -> None:
3668
+ """
3669
+ Record the budget the server reported, against the bucket it describes.
3670
+
3671
+ Also mirrored into `self.rate_limit` for the documented
3672
+ `{limit, remaining, reset}` surface, which reflects the most recent
3673
+ response — useful for display, wrong for throttling decisions, which is
3674
+ why `_maybe_throttle` reads the per-bucket store instead.
3675
+ """
3676
+ budget = self._budgets.setdefault(
3677
+ bucket, {"limit": None, "remaining": None, "reset": None})
3678
+
3679
+ for key, header in (
3680
+ ("limit", "X-RateLimit-Limit"),
3681
+ ("remaining", "X-RateLimit-Remaining"),
3682
+ ("reset", "X-RateLimit-Reset"),
3683
+ ):
3684
+ value = headers.get(header)
3685
+ if value is not None:
3686
+ try:
3687
+ budget[key] = int(value)
3688
+ self.rate_limit[key] = int(value)
3689
+ except ValueError:
3690
+ pass
3691
+
3692
+ def _maybe_throttle(self, bucket: str) -> None:
3693
+ """
3694
+ Spread the tail of a budget over the time left in its window.
3695
+
3696
+ Without this an agent burns its allowance early and then stalls for the
3697
+ remainder of the hour; the server tells us enough to avoid that.
3698
+
3699
+ Two things here were wrong and caused a real pairing failure.
3700
+
3701
+ **The threshold was absolute.** It slept whenever `remaining <= 10`,
3702
+ applied to buckets whose limits range from 5/hour (registration) to
3703
+ 300/hour (inbox). Registration can *never* report more than 5
3704
+ remaining, so it always tripped: a fresh registration reporting 4 of 5
3705
+ — a budget 80% intact — slept the full 30 seconds. It is now a fraction
3706
+ of the bucket's own limit, so "nearly exhausted" means what it says.
3707
+
3708
+ **The budget was global.** One `rate_limit` dict was overwritten by
3709
+ every response, so a figure from the 5/hour registration bucket
3710
+ throttled the *next* call even when that endpoint had 119 of 120 left.
3711
+ Budgets are now tracked per bucket.
3712
+
3713
+ Together those made an agent sleep ~30s immediately after registering,
3714
+ silently. Two agents pairing would miss each other's rendezvous window
3715
+ while one sat in that sleep — and a stop-and-retry appeared to fix it,
3716
+ because the retry reused the saved identity and never registered again.
3717
+
3718
+ The sleep is also capped far lower now. A 30-second silent stall inside
3719
+ a caller's pairing timeout is indistinguishable from a dead peer, which
3720
+ is the failure this is supposed to prevent, not cause.
3721
+ """
3722
+ if not self.auto_throttle:
3723
+ return
3724
+
3725
+ budget = self._budgets.get(bucket)
3726
+ if not budget:
3727
+ return
3728
+
3729
+ limit = budget.get("limit")
3730
+ remaining = budget.get("remaining")
3731
+ reset = budget.get("reset")
3732
+ if limit is None or remaining is None or reset is None:
3733
+ return
3734
+
3735
+ # Nearly exhausted, relative to this bucket's own allowance.
3736
+ if remaining > max(1, int(limit * THROTTLE_AT_FRACTION)):
3737
+ return
3738
+
3739
+ seconds_left = reset - int(time.time())
3740
+ if seconds_left <= 0:
3741
+ return
3742
+
3743
+ nap = min(seconds_left / max(remaining, 1), MAX_THROTTLE_SLEEP)
3744
+ if nap <= 0:
3745
+ return
3746
+
3747
+ # Never silently. stdout is reserved (the MCP server speaks JSON-RPC on
3748
+ # it), so this goes to stderr.
3749
+ sys.stderr.write(
3750
+ "[stringcup] %s budget nearly spent (%s of %s left, window resets in "
3751
+ "%ds) — pausing %.1fs\n" % (bucket, remaining, limit, seconds_left, nap)
3752
+ )
3753
+ sys.stderr.flush()
3754
+ time.sleep(nap)
3755
+
3756
+ @staticmethod
3757
+ def _error_for(exc: urllib.error.HTTPError) -> StringcupError:
3758
+ try:
3759
+ body = json.loads(exc.read())
3760
+ except Exception:
3761
+ body = None
3762
+
3763
+ detail = ""
3764
+ if isinstance(body, dict):
3765
+ messages = body.get("messages")
3766
+ if isinstance(messages, dict):
3767
+ detail = str(messages.get("error", ""))
3768
+ elif isinstance(messages, list) and messages:
3769
+ detail = str(messages[0])
3770
+ detail = detail or str(body.get("error", ""))
3771
+ detail = detail or exc.reason
3772
+
3773
+ status = exc.code
3774
+ if status == 401:
3775
+ return AuthError(f"unauthorized: {detail}", status, body)
3776
+ if status == 404:
3777
+ return NotFoundError(f"not found: {detail}", status, body)
3778
+ if status == 400:
3779
+ return ValidationError(f"invalid request: {detail}", status, body)
3780
+ if status == 413:
3781
+ return MessageTooLarge(f"message too large: {detail}", status, body)
3782
+ if status == 507:
3783
+ # Distinct from a validation error on purpose: the request was
3784
+ # fine, the recipient is simply behind. Callers should retry.
3785
+ return RecipientInboxFull(f"recipient inbox full: {detail}", status, body)
3786
+ if status == 429:
3787
+ try:
3788
+ retry_after = int(exc.headers.get("Retry-After", 60))
3789
+ except (TypeError, ValueError):
3790
+ retry_after = 60
3791
+ return RateLimited(
3792
+ f"rate limited, retry in {retry_after}s: {detail}", retry_after, body
3793
+ )
3794
+ return StringcupError(f"HTTP {status}: {detail}", status, body)
3795
+
3796
+
3797
+ # --------------------------------------------------------------------------
3798
+ # Helpers
3799
+ # --------------------------------------------------------------------------
3800
+
3801
+ def _chunks(items: List[int], size: int):
3802
+ for i in range(0, len(items), size):
3803
+ yield items[i : i + size]
3804
+
3805
+
3806
+ def _backoff(attempt: int, base: float = 0.5, cap: float = 8.0) -> float:
3807
+ """Exponential backoff with jitter."""
3808
+ return min(base * (2 ** attempt), cap) * random.uniform(0.8, 1.2)