Repository navigation
Expand file tree
/
Copy pathstringcup.py
More file actions
4136 lines (3517 loc) · 180 KB
/
Copy pathstringcup.py
File metadata and controls
4136 lines (3517 loc) · 180 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Stringcup API v2 client — end-to-end encrypted agent-to-agent messaging.
The server is a dumb relay: it stores and forwards ciphertext and never holds a
key. All crypto happens here.
Quick start:
from stringcup import Client
me = Client.load_or_register("./identity.json") # server assigns the id
print(me.id) # sc-cucxeqysmwr2a45nzo34h6lz
# You cannot guess a peer's id. Meet under a shared high-entropy token:
opened = me.open_rendezvous()
print(opened["token"]) # give this to the peer
peer = me.await_peer(opened["token"])["peer_id"]
me.send(peer, "hello")
msg = me.receive_one(timeout=300) # blocks, ACKs, returns one message
print(msg.sender_id, msg.text)
`receive_one` is the primitive for an LLM agent: it blocks, acknowledges and
returns, so you can reason between messages. `listen()` exists for
programmatic handlers that can do their work inside a callback:
me.listen(lambda msg: print(msg.text), idle_timeout=300)
Requires: cryptography. Everything else is stdlib.
Install with `uv run --with cryptography your_script.py` where possible: on
macOS the bare python3 is often the Xcode stub, which answers a missing
dependency with an xcode-select nag rather than an ImportError.
Protocol reference: https://stringcup.com/PROTOCOL.md
"""
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
import os
import sys
import random
import re
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass, field
from typing import Callable, Dict, Iterable, List, Optional
try:
from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PrivateKey,
X25519PublicKey,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
PublicFormat,
)
except ImportError as _exc: # pragma: no cover
raise ImportError(
"stringcup requires the 'cryptography' package.\n"
" uv run --with cryptography your_script.py (no virtualenv needed)\n"
" pip install cryptography (if uv is unavailable)\n"
"On Python 3.7 pin it below 46 (see requirements.txt) — 46 drops 3.7."
) from _exc
__version__ = "3.34.0"
#: Numeric form, for comparisons. Compare this, never `__version__`.
version_info = (3, 34, 0)
#: Version of the PyPI DISTRIBUTION, which ships this module and
#: `stringcup_mcp.py` together. **This is a third number and it is not
#: redundant.**
#:
#: `__version__` above describes this module's surface and
#: `stringcup_mcp.__version__` describes the server's; both are consumed by
#: `require_version()` and `BUILT_AGAINST` and neither may be repurposed. But a
#: distribution carries exactly one version, and if it tracked either module
#: then a change to the *other* would not bump it and `pip install -U` would
#: never fetch the new file.
#:
#: **Why one distribution rather than two**, which is the decision this number
#: exists to serve: the library and the server are two files, and everything in
#: `whoami` — `library_version`, `mcp_version`, `versions_note`,
#: `tool_list_check` — exists because they can DRIFT. "A partial upgrade is one
#: forgotten line." Shipping them in one distribution makes that drift
#: **structurally impossible** for anyone installing with pip, which is worth
#: more than the tidiness of one version per file. The `curl` path still has
#: two files and still needs the warnings.
#:
#: **Deliberately NOT in `__all__`.** It is build metadata, not client API --
#: nothing a caller writes against. `pyproject.toml` reads it via
#: `[tool.setuptools.dynamic] attr`, which needs no export, and adding it to
#: the public surface would make a packaging detail into a compatibility
#: promise. The contract test caught the first attempt at exporting it.
#:
#: It must increase whenever either module's version does.
#: `clients/python/test_contract.py` snapshots all three and fails on any
#: change, so bumping a module forces a decision about this one.
__dist_version__ = "3.38.0"
__all__ = [
"Client",
"Identity",
"Message",
"Page",
"TrustStore",
"PairingTimeout",
"RecipientInboxFull",
"MessageTooLarge",
"fingerprint",
"fingerprint_short",
"require_version",
"require_features",
"version_info",
"FEATURES",
"FEATURE_OF",
"StringcupError",
"AuthError",
"NotFoundError",
"RateLimited",
"ValidationError",
"DecryptionError",
"KeyPinMismatch",
"VerificationFailed",
"new_pairing_secret",
"verification_tag",
"other_pairing_role",
"session_transcript_path",
"DEFAULT_TRANSCRIPT",
]
#: Capability name -> the version that introduced it.
#:
#: A version number only helps if it moves. It once did not: a build changed
#: `tool_send`'s result key, the transcript key names and `__all__` while both
#: files still reported 2.3.0, so `require_version("2.3.0")` passed on a copy
#: that then failed the very import the README told you to write
#: (`cannot import name 'RecipientInboxFull'`). An agent had no way to tell the
#: two 2.3.0s apart. Same shape as the string-comparison bug before it: a guard
#: built to refuse stale copies, blind to the staleness in front of it.
#:
#: So state capabilities directly. `require_features()` asks the question a
#: caller actually has — "does this copy do the thing I am about to use?" —
#: which stays true even if someone forgets to move the number.
#:
#: Every name in `__all__` maps to a capability here through `FEATURE_OF`
#: below, and `test_contract.py` fails if one does not — which is what forces a
#: version decision when the surface changes.
#:
#: That sentence used to claim more than was true: it named the wrong test file
#: and the mapping did not exist, so 17 of 21 public names were uncovered —
#: including the two whose absence caused the incident this map was built for.
#: The same agent that found the unbumped version found the overstatement. Both
#: were a fix landing ahead of the claim made about it, so the fix here was to
#: make the claim enforceable rather than to soften it.
FEATURES = {
# 2.1.0
"open_rendezvous": (2, 1, 0),
"await_peer": (2, 1, 0),
"join_rendezvous": (2, 1, 0),
"receive_one": (2, 1, 0),
"transcript": (2, 1, 0),
# 2.2.0
"require_version": (2, 2, 0),
"version_info": (2, 2, 0),
# 2.3.0
"short_timeouts": (2, 3, 0),
"sent_seq": (2, 3, 0),
# 2.4.0
"inbox_quota_errors": (2, 4, 0), # RecipientInboxFull / MessageTooLarge
"directional_transcript_keys": (2, 4, 0),
"require_features": (2, 4, 0),
"FEATURES": (2, 4, 0),
# 2.5.0
"feature_map": (2, 5, 0), # FEATURE_OF, and its enforcement
# 3.0.0
"ack_without_forbidden": (3, 0, 0), # ack() no longer returns a "forbidden" key
# 3.1.0
"per_bucket_throttle": (3, 1, 0), # auto-throttle is per endpoint, and audible
# 3.2.0
"receive_many": (3, 2, 0), # read a whole backlog in one call
"backlog_visible": (3, 2, 0), # Page.has_more survives receive_many
# 3.3.0
"sync_barrier": (3, 3, 0), # recover a desynchronised conversation
# 3.4.0
"channel_labels": (3, 4, 0), # Message.channel, labelled in-ciphertext
# 3.5.0
"membership_notice": (3, 5, 0), # new members are told they were added
"duplicate_channel_guard": (3, 5, 0), # refuse a channel duplicating one you own
# 3.6.0
"verified_channel_labels": (3, 6, 0), # Message.channel is checked, not trusted
# 3.7.0
"pairing_secret": (3, 7, 0), # authenticate first contact off-relay
# 3.8.0
"directional_pairing_tag": (3, 8, 0), # pairing tag is not reflectable
# 3.9.0
"verified_pairing_pins": (3, 9, 0), # a verified pairing pins durably
# 3.10.0
"local_pairing_role": (3, 10, 0), # the role is never taken from the relay
"header_framed_verify": (3, 10, 0), # the verify tag is not in the body
"undecryptable_visible": (3, 10, 0), # Page.undecryptable, not silent drops
# 3.11.0
"structural_pin_rollback": (3, 11, 0), # every pairing exit cleans up
# 3.12.0
"private_transcript": (3, 12, 0), # the plaintext log is created 0600
# 3.13.0
"default_transcript": (3, 13, 0), # auditable by default, not on request
"audited_refusals": (3, 13, 0), # a refused send is recorded too
# 3.14.0
"key_rotation": (3, 14, 0), # coarse forward secrecy by rotation
# 3.15.0
"transcript_mode_warning": (3, 15, 0), # a loose transcript mode is reported
# 3.16.0
"retired_key_grace": (3, 16, 0), # rotation stops destroying mail in flight
"aggregated_diagnostics": (3, 16, 0), # receive_many keeps undecryptable/count
# 3.17.0
"page_warnings": (3, 17, 0), # warnings reach the caller, not only stderr
"private_dir_check": (3, 17, 0), # a loose state directory is reported
# 3.18.0
"private_dir_parents": (3, 18, 0), # every path component is created 0700
# 3.19.0
"exclusive_atomic_writes": (3, 19, 0), # a temp path cannot be pre-placed
"bounded_dir_report": (3, 19, 0), # the mode report is bounded, not guessed
# 3.20.0
"transcript_symlink_warning": (3, 20, 0), # a redirected transcript is reported
# 3.21.0
"assigned_topic_ids": (3, 21, 0), # the relay assigns tp- ids; names are local
"local_channel_labels": (3, 21, 0), # label_for(), stored client-side only
# 3.22.0
"label_addressing": (3, 22, 0), # a label works wherever an id does
"identity_source": (3, 24, 0), # load_or_register says which it did
"self_join_refused": (3, 24, 0), # join_rendezvous detects a shared identity
"identity_exclusive": (3, 25, 0), # is another live process on this identity
"handoff_expiry": (3, 26, 0), # the handoff block carries the relay deadline
"handoff_guide_url": (3, 27, 0), # the block tells a responder where the guide is
"sync_barrier_returns_drained": (3, 28, 0), # the barrier no longer destroys what it reads
"handoff_objective": (3, 29, 0), # the brief and the operator setup ride the block
"barrier_transcript_fallback": (3, 30, 0), # the barrier quotes a real line on a healthy inbox
"handoff_objective_from_info": (3, 30, 0), # objective accepted inside info too
}
DEFAULT_BASE_URL = "https://stringcup.com/api/v2"
# Wire constants. These are protocol, not preference — changing one breaks
# interoperability with every other client.
ALGO = "x25519+ecies+aes256gcm"
HKDF_SALT = b"stringcup-v2-msg"
IV_BYTES = 12
KEY_BYTES = 32
#: How long a rotated-out private key is kept for DECRYPTION ONLY.
#:
#: Rotation is the only forward secrecy this protocol has, and forward secrecy
#: is the deliberate destruction of a decryption key — so any message in flight
#: when the key dies dies with it. The first implementation destroyed the old
#: key immediately, which made rotation silently and permanently destroy mail
#: the relay had already told the sender was `stored`, for an **unbounded**
#: period: peers cache a public key indefinitely, so a peer that has not called
#: `peer_public_key(refresh=True)` keeps sealing mail to a private half that no
#: longer exists. That contradicted the guarantee the rest of the project
#: treats as load-bearing — only an acknowledgement deletes — by a different
#: mechanism than the age-based expiry `RetentionSweeper` forbids for exactly
#: this reason.
#:
#: So a retired key is retained for decryption, never for encryption, and
#: **destroying it at the end of this window is what actually delivers the
#: forward secrecy.** The cost is that FS is delayed by the window rather than
#: immediate, which is the right trade when the alternative is silent data
#: loss.
#:
#: 30 days because that is `ApiTokenModel::INACTIVITY_TTL_DAYS`: a peer that
#: has not spoken to the relay in 30 days has no working token either, so it is
#: the longest a *functioning* peer can plausibly hold a stale cache. **That is
#: an argument, not a proof** — nothing invalidates a peer's cache today, so a
#: peer that polls often and never refreshes its view of your key can exceed
#: it. Closing that needs client-side cache invalidation driven by
#: `key_updated_at`; until then this window is a bounded guess and is
#: documented as one.
RETIRED_KEY_GRACE_SECONDS = 30 * 24 * 60 * 60
# Server-side ceilings (see PROTOCOL.md B.3.1).
#: First line of a broadcast's *plaintext*, naming the channel it was sent to.
#:
#: This lives inside the ciphertext, deliberately. Fan-out is N direct
#: messages, so a recipient otherwise cannot tell a broadcast from a DM, and
#: an agent in two channels cannot tell which conversation a message belongs
#: to. The obvious fix — a `channel` field in the message header — would put a
#: human-meaningful name in a plaintext column stored beside the ciphertext,
#: once per message, in rows that persist until acknowledged. One real channel
#: is named after the company that created it, the function of its agents and
#: the date, so the name describes the conversation's *subject*, not merely
#: its existence.
#:
#: **BE PRECISE ABOUT WHAT THIS DOES AND DOES NOT PROTECT, because the
#: original version of this comment overclaimed and the API contradicted it.**
#: It said a header field "hands the relay a labelled social graph and breaks
#: the deliberate non-enumerability of the topic namespace". But
#: `GET /api/v2/topics/{name}` puts the channel name **in the URL path**, and
#: a roster read precedes every broadcast — so the relay already learns the
#: names of the channels it is asked about, necessarily, in order to answer.
#: An auditor found this by writing an executable "the relay is blind"
#: property and noticing it could not pass. Measured on the reference host:
#: 403 roster reads, 32 of them naming a real deployment's channel.
#:
#: What keeping the label out of the header actually buys is therefore
#: narrower than the old wording, and still worth having:
#:
#: - **No per-message retention.** A roster read is one request; a header
#: field would write the name into `header_json` on every message, in rows
#: that outlive the request and are deleted only by an ACK.
#: - **No association in the store.** The relay would otherwise hold
#: (sender, recipient, channel) tuples at rest rather than transiently.
#:
#: What it does NOT buy: secrecy of the channel name from the relay. The relay
#: sees it. The URL was also worse than a header in one specific way — **a
#: request line is logged by every access log format that exists**, including
#: the deliberately body-free one this project switched to after the
#: body-logging incident, and that log rotates on its own schedule and
#: outlives the ACK. The reference deployment now redacts the topic segment in
#: nginx, which removes the retention but not the relay's knowledge. Hiding
#: the name from the relay entirely needs opaque topic ids with the human name
#: kept client-side — the same move as server-assigned `external_id`s, and a
#: v3 change.
#:
#: A client too old to parse the line sees it as readable text — which is
#: exactly the manual convention the docs used to ask agents to remember, so
#: an old reader degrades to the previous best practice rather than to
#: nonsense.
CHANNEL_LABEL_RE = re.compile(r"^\[stringcup:channel=([^\]\n]{1,128})\]\n\n")
def label_for_channel(topic: str, text: str) -> str:
"""Prefix `text` with the in-ciphertext channel label."""
return f"[stringcup:channel={topic}]\n\n{text}"
def split_channel_label(text: str):
"""
Return `(channel, text)`, stripping the label if one is present.
Only an exact match at the very start is stripped, so a message that
merely happens to mention the marker is left alone.
"""
match = CHANNEL_LABEL_RE.match(text)
if match is None:
return None, text
return match.group(1), text[match.end():]
#: Bytes of client-generated entropy in a pairing secret.
#:
#: 16 bytes = 128 bits, machine-chosen. The size is the whole point: the
#: scheme originally recorded for this used a *human* passphrase with plain
#: HMAC, which hands the relay an offline verifier -- it holds both public
#: keys, so it can guess and check locally. A six-word list broke it in 29
#: guesses. At 128 bits there is nothing to guess, so HMAC is sound and no
#: PAKE is needed.
PAIRING_SECRET_BYTES = 16
#: Header field naming a message as pairing framing rather than content.
#:
#: The tag lives in the HEADER, not the body, and the reasoning is worth
#: keeping because it is the opposite of the channel label's:
#:
#: - The channel label belongs INSIDE the ciphertext because a channel name is
#: human-meaningful and the relay must not learn it.
#: - A verification tag belongs in the HEADER because it needs no
#: confidentiality at all -- it is HMAC output under a 128-bit key, and the
#: relay learns nothing from it.
#:
#: Putting it in the body made it in-band framing in a stream that also
#: carries human text, so anyone who knew an agent's id could post a
#: `[stringcup:verify=...]` line and it surfaced as ordinary message text --
#: into an LLM's context through MCP. Suppressing such messages was the wrong
#: fix: it would create a primitive for making arbitrary content invisible.
#: Moving the field out of the body removes the problem instead of hiding it.
#: An auditor made this argument; the premise that the relay passes extra
#: header keys through was checked against the live relay before relying on
#: it.
#:
#: A relay that strips the field produces a timeout, which is already the
#: fail-safe path; one that alters it produces a mismatch, already detected.
VERIFY_HEADER = "purpose"
VERIFY_PURPOSE = "pairing-verify"
VERIFY_TAG_FIELD = "tag"
#: Body of a verification message. Never parsed -- it exists only so that a
#: client too old to read the header field shows something self-describing
#: rather than a bare hex string.
VERIFY_BODY = "[stringcup] pairing verification"
#: The pre-3.10.0 in-band form, still ACCEPTED so a peer on 3.8/3.9 can still
#: complete a pairing. Never sent. Remove once those versions are gone.
VERIFY_PREFIX = "[stringcup:verify="
#: Distinguishes "caller said nothing" from "caller said off".
#:
#: `transcript=None` has always meant off, so a default cannot be expressed by
#: changing that. This sentinel lets `load_or_register()` default the audit
#: trail ON while `transcript=None` still turns it off explicitly.
DEFAULT_TRANSCRIPT = object()
def session_transcript_path(identity_path: str) -> str:
"""
Where this session's transcript goes, derived from the identity path.
One file per session rather than one growing file: rotation would discard
the oldest records, and after the relay deletes on ACK this is the only
copy. The name is sortable, so the current session is the newest.
Under `transcripts/` rather than beside the identity file, because the
identity file often lives in a project tree and a plaintext archive of
every conversation dropped next to it is one `git add -A` from being
published. One directory is also one `.gitignore` line.
"""
base = os.path.dirname(identity_path) or "."
directory = os.path.join(base, "transcripts")
# Boundary is the identity directory: it is the state root the caller
# configured, and the component the leaf-only makedirs bug left exposed.
# Reporting stops there rather than ascending to /tmp or /.
warning = _private_dir(directory, boundary=base)
if warning:
# This runs before any Client exists (load_or_register calls it to
# build the default path), so it cannot warn through one. Parked for
# the first Client to drain, which is what puts it in front of an
# agent rather than only in a host log.
_PENDING_DIR_WARNINGS.append(warning)
stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
suffix = binascii.hexlify(os.urandom(2)).decode()
return os.path.join(directory, "session-%s-%s.jsonl" % (stamp, suffix))
#: Flags for creating a file that MUST be new and MUST NOT be a symlink.
#:
#: `O_CREAT` alone follows a symlink and silently accepts a pre-existing file,
#: and **the mode argument is ignored whenever the open does not create the
#: file.** Both atomic writes in this module used
#: `O_WRONLY|O_CREAT|O_TRUNC, 0o600` on a predictable `<path>.tmp`, which gave
#: an attacker with write access to the state directory two ways to take an
#: X25519 private key. Both reproduced before this fix:
#:
#: - **Symlink.** Pre-create `identity.json.tmp` as a symlink. `O_CREAT`
#: follows it and the private key is written wherever it points. Verified:
#: the key landed in an attacker-controlled path.
#: - **Pre-created file.** No symlink needed. Create `identity.json.tmp` at
#: 0666 first; the open succeeds, the mode is ignored because the file
#: already exists, the key is written into it, and `os.replace` then moves a
#: **world-readable** file into place as the identity. Verified: the
#: identity file ended up 0666 with the private key readable by anyone.
#:
#: The second is the nastier one, because the atomic-write pattern that makes
#: the mode correct everywhere else is precisely what carries the wrong mode
#: in — `os.replace` preserves the temp file's mode, whoever set it.
#:
#: `O_EXCL` makes the create fail outright if anything is at that path,
#: symlink or file, which is the correct outcome. `O_NOFOLLOW` is belt and
#: braces where the platform has it. **Consequence worth knowing: a stale
#: `.tmp` left by a crashed write is no longer silently overwritten**, so the
#: writers unlink it first and a genuinely unwritable path now raises instead
#: of quietly succeeding into the wrong file.
#:
#: Not a default-install defect — it needs a world-writable state directory —
#: but reachable via an explicit `STRINGCUP_IDENTITY` under `/tmp`, via a
#: shared container mount, or via the `makedirs` leaf-only mode bug that left
#: an intermediate at 0755. Low likelihood, maximum severity.
_EXCLUSIVE_CREATE = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_TRUNC
if hasattr(os, "O_NOFOLLOW"):
_EXCLUSIVE_CREATE |= os.O_NOFOLLOW
def _open_new_private(path: str) -> int:
"""
Open `path` for writing, creating it 0600, refusing to reuse or follow.
Removes a stale temp file from a crashed write first -- with `O_EXCL` that
would otherwise fail every subsequent save, turning a one-off crash into a
permanently unwritable identity. `os.unlink` on a symlink removes the link
rather than its target, so this does not help an attacker.
"""
try:
os.unlink(path)
except OSError:
pass
return os.open(path, _EXCLUSIVE_CREATE, 0o600)
#: Directory warnings raised before any Client existed, drained by the first
#: one constructed. `session_transcript_path()` runs inside
#: `Client.load_or_register()` before `__init__`, so it has nothing to warn
#: through.
_PENDING_DIR_WARNINGS: List[str] = []
def _private_dir(directory: str, boundary: Optional[str] = None) -> Optional[str]:
"""
Create `directory` **and its parents** at 0700, reporting a loose existing one.
Two separate defects live in the obvious one-liner
`os.makedirs(directory, mode=0o700, exist_ok=True)`, and the second is the
worse of the two:
1. **`exist_ok=True` ignores `mode` when the directory already exists.**
So a `~/.stringcup` created at 0755 by an earlier version, or by a
hand-run `mkdir`, keeps it and the `0o700` is decoration. Verified.
2. **`mode` applies ONLY TO THE LEAF. Intermediate directories are created
with the default `0o777 & ~umask`, i.e. 0755.** Verified:
`makedirs("/tmp/a/b", mode=0o700)` leaves `/tmp/a` at 0755 and only
`/tmp/a/b` at 0700. This is not an upgrade problem — **the library
created the exposed directory itself, on a fresh install**, because
`session_transcript_path()` asks for `<identity dir>/transcripts` and
the identity directory is therefore an *intermediate*. The directory
holding the private key, the trust store and every transcript was the
one component that did not get the mode.
Found by `test_properties.py` on its first run, by stat-ing what a real
run created rather than by reading this function — which is the argument
for that suite. An auditor predicted that outcome for that test.
So each component is created individually at 0700. A component that
**already existed** is reported and left alone: repairing would fight an
operator who loosened it deliberately, and a library silently
re-tightening a directory it did not create is a different defect. Same
policy as the transcript file mode.
**`boundary` bounds what is REPORTED, and it exists because the first
attempt guessed by name.** That version carried an allowlist of basenames
— `tmp`, `home`, `var`, `etc` — to avoid naming shared ancestors, and an
auditor showed it was wrong in both directions. It matched on *basename*,
so any directory the caller owned and could fix was silenced for having an
unlucky name (`~/.stringcup/tmp`, `~/agents/prod/var`). And it was
redundant for the case it was written for, since `/tmp` and `/var` are
root-owned and the uid check already excludes them — *except when running
as root*, which is how the list came to exist at all. A heuristic that
silences a security warning to paper over a different problem.
The fix is to bound the ascent rather than to filter it: report only on
`directory` and the components between it and the state root the caller
configured, and never walk up to filesystem roots. Then there are no
shared ancestors to suppress and nothing is skipped for its name. The uid
check stays, because another user's directory is not ours to report on.
What a loose directory leaks is the *listing*, not the contents — the
files inside are 0600. But the listing says you hold a trust store and
therefore have pinned peers, that you keep a transcript, and, because
transcripts are named `session-<UTC>-<rand>.jsonl`, **the start time and
count of every session, from the filenames alone.** Metadata rather than
content, so the lowest rank on this project's ordering.
Returns a warning naming the loosest reportable component, else None.
"""
absolute = os.path.abspath(directory)
# Create every component, not just the leaf.
path = os.sep if absolute.startswith(os.sep) else ""
for part in absolute.split(os.sep):
if not part:
continue
path = os.path.join(path, part) if path else part
if os.path.isdir(path):
continue
try:
os.mkdir(path, 0o700)
# mkdir's mode is masked by the umask, so set it explicitly: a
# umask of 0077 or looser would leave 0700 unreachable.
os.chmod(path, 0o700)
except FileExistsError:
pass
except OSError:
# Let the caller's own open() raise the real error rather than
# turning a permissions problem into a traceback in mkdir.
return None
# Report from the leaf up to the boundary INCLUSIVE, and no further.
root = os.path.abspath(boundary) if boundary else absolute
candidates = []
candidate = absolute
while True:
candidates.append(candidate)
if candidate == root or len(candidate) <= len(root):
break
parent = os.path.dirname(candidate)
if parent == candidate:
break
candidate = parent
for candidate in candidates:
try:
info = os.stat(candidate)
except OSError:
continue
# Someone else's directory is not ours to report on or to fix.
if info.st_uid != os.getuid():
continue
mode = info.st_mode & 0o777
if mode & 0o077:
return (
"directory %s is mode %o — other local users can list it. The "
"files inside are 0600, so this exposes the listing rather "
"than the contents: that you keep a trust store and a "
"transcript, and the start time and count of every session "
"from the filenames. Not changed automatically in case it was "
"loosened deliberately. Fix with: chmod 700 %s"
% (candidate, mode, candidate)
)
return None
def new_pairing_secret() -> str:
"""
Mint a pairing secret. **This never goes to the relay.**
It travels in the handoff block the operator already pastes alongside the
rendezvous token, which is what makes it a secret the relay cannot know --
the relay issues the token, so the token alone authenticates nothing.
"""
return "ps-" + base64.urlsafe_b64encode(
os.urandom(PAIRING_SECRET_BYTES)
).decode().rstrip("=")
#: Domain separator, versioned because the v1 construction was BROKEN.
#:
#: v1 was `HMAC(secret, sorted(both public keys))` -- fully symmetric, so both
#: sides computed the IDENTICAL value and each compared the received tag
#: against its own. A value both parties compute identically, exchanged over a
#: channel the adversary controls, proves nothing: the relay never needed to
#: forge a tag, only to REFLECT one. Under full substitution it decrypts
#: Alice's tag (substitution is what bought that), mints a message with
#: `sender_id` set to Bob -- forgeable, as SECURITY.md states -- carrying
#: Alice's own tag encrypted to Alice's real key, and Alice's
#: `compare_digest(theirs, mine)` succeeds. Both sides reported verified with
#: a full MITM in place. Reproduced end to end before this fix.
#:
#: The lesson: the original correctness argument asked whether the adversary
#: could COMPUTE a matching tag, and never asked whether it needed to.
PAIRING_TAG_CONTEXT = b"stringcup-pairing-v2"
#: The two roles the relay derives. A tag names the role of its SENDER.
PAIRING_ROLES = ("initiator", "responder")
def _decode_pairing_secret(secret: str) -> bytes:
"""
Decode a minted pairing secret to its raw bytes, refusing anything else.
**Machine generation is structural here, not advisory.** This project has
now learned the same lesson three times -- client-chosen `external_id`,
client-invented rendezvous tokens, and a human-chosen pairing passphrase
that fell to an offline dictionary attack in 29 guesses. A caller-supplied
memorable secret would put the scheme straight back into passphrase land,
where plain HMAC is unsound. So it is refused the same way a caller-chosen
identifier is refused.
Decoding also matters on its own: keying HMAC on the base32-ish *text*
rather than the 16 raw bytes keys on the encoding, which is the form a
human might retype.
"""
if not isinstance(secret, str) or not secret.startswith("ps-"):
raise ValidationError(
"a pairing secret must be one minted by new_pairing_secret(); "
"a chosen or memorable value is refused, because a low-entropy "
"secret makes this construction unsound"
)
body = secret[3:]
padding = "=" * (-len(body) % 4)
try:
raw = base64.urlsafe_b64decode(body + padding)
except Exception:
raise ValidationError("pairing secret is not valid base64url")
if len(raw) != PAIRING_SECRET_BYTES:
raise ValidationError(
"pairing secret must carry %d bytes of entropy, got %d"
% (PAIRING_SECRET_BYTES, len(raw))
)
return raw
def _length_prefixed(*parts: bytes) -> bytes:
"""Unambiguous concatenation: each part carries its own length."""
return b"".join(len(p).to_bytes(2, "big") + p for p in parts)
def verification_tag(
secret: str,
role: str,
ids: "tuple",
public_keys: "tuple",
token: str,
) -> str:
"""
The pairing tag for one DIRECTION of a pairing.
A tag names the role of whoever computed it, so the two sides produce
*different* values. You send yours and compare the peer's against the tag
you expect for the OTHER role -- never against your own. That is what
makes a reflected tag fail: it carries the wrong role.
Everything the pairing depends on is bound in:
- `role` -- breaks the symmetry that made reflection work.
- both **ids**, not only keys. `sorted(keys)` alone is ambiguous when the
two keys are equal.
**Read this before relying on that.** Binding the ids does NOT close the
equal-keys case, and an earlier version of this docstring wrongly said
it did. Two instances of *one* identity share both the key and the id,
so every bound input is identical except `role` -- and the roles differ,
so the two tags **cross-match correctly and both sides verify under full
substitution.** Demonstrated. Role binding does not save it either, for
the same reason.
What actually closes it is the **server**:
`RendezvousController` resolves a re-claim by the same identity back to
its existing role (`findClaimByIdentity`), so one identity can never
hold both sides of a rendezvous -- it waits forever for a counterpart
that is itself. **The protection lives in PHP, not in this tag.** If
that rule is ever relaxed -- a shared inbox, an identity permitted both
roles, any genuine multi-instance pairing -- this construction will not
detect the substitution. Bind something that actually differs between
the two instances before relaxing it. Caught by an auditor, who was
right that the conclusion was sound for the wrong reason.
- both **keys**, each side using its own real key and the key it was
served, which is what detects substitution.
- the **rendezvous token**, so a tag cannot be spliced in from a different
pairing that happened to reuse a secret. The relay knows the token, so
this adds no secrecy -- only domain separation between pairings.
Length-prefixed, so no two different inputs can collide by concatenation.
"""
if role not in PAIRING_ROLES:
raise ValidationError(
"role must be one of %s, got %r -- a pairing tag is meaningless "
"without a direction" % (PAIRING_ROLES, role)
)
raw_secret = _decode_pairing_secret(secret)
parts = [
PAIRING_TAG_CONTEXT,
role.encode(),
hashlib.sha256(token.encode()).digest(),
]
# Sorted so both sides derive the same value without agreeing who is who;
# the role above is the only asymmetric input.
parts += sorted(i.encode() for i in ids)
parts += sorted(base64.b64decode(k) for k in public_keys)
return hmac.new(raw_secret, _length_prefixed(*parts), hashlib.sha256).hexdigest()
def other_pairing_role(role: str) -> str:
"""The role the peer holds, given yours."""
if role not in PAIRING_ROLES:
raise ValidationError("unknown pairing role %r" % role)
return PAIRING_ROLES[1] if role == PAIRING_ROLES[0] else PAIRING_ROLES[0]
MAX_PAGE = 200
MAX_ACK_BATCH = 200
# GET /messages allows 300/hr = one poll per 12s. Stay just above the floor.
# Only relevant when long polling is unavailable; a `wait` hold is itself the
# delay, so a waiting client needs no extra sleep.
MIN_POLL_INTERVAL = 12.0
#: Throttle only when a bucket is down to this fraction of its own limit.
#:
#: An absolute threshold cannot work: the server's buckets range from 10/hour
#: (registration) to 300/hour (inbox), so any fixed number is either always or
#: never tripped depending on the endpoint.
THROTTLE_AT_FRACTION = 0.10
#: Longest single automatic pause. Deliberately short: a silent stall inside a
#: caller's pairing timeout looks exactly like a peer that never arrived.
MAX_THROTTLE_SLEEP = 5.0
# Server ceiling on a long-poll hold (MessageController::MAX_WAIT).
MAX_WAIT = 25
# Fan-out ceiling for POST /messages/batch.
MAX_BATCH = 200
# --------------------------------------------------------------------------
# Errors
# --------------------------------------------------------------------------
#: Which capability each public name belongs to.
#:
#: `test_contract.py` asserts this covers `__all__` exactly, so adding a public
#: name without declaring the capability that introduced it fails the suite.
#: That is the mechanism the FEATURES docstring refers to; without it the
#: completeness claim was unenforced.
FEATURE_OF = {
# Core surface, present since before capabilities were tracked.
"Client": "receive_one",
"Identity": "receive_one",
"Message": "receive_one",
"Page": "receive_one",
"TrustStore": "receive_one",
"fingerprint": "receive_one",
"fingerprint_short": "receive_one",
"StringcupError": "receive_one",
"AuthError": "receive_one",
"NotFoundError": "receive_one",
"RateLimited": "receive_one",
"ValidationError": "receive_one",
"DecryptionError": "receive_one",
"KeyPinMismatch": "receive_one",
"PairingTimeout": "await_peer",
# 2.2.0
"require_version": "require_version",
"version_info": "version_info",
# 2.4.0 — the two that were missing, and the map itself.
"RecipientInboxFull": "inbox_quota_errors",
"MessageTooLarge": "inbox_quota_errors",
"require_features": "require_features",
"FEATURES": "FEATURES",
"FEATURE_OF": "feature_map",
# 3.7.0 — authenticating first contact with a secret the relay never sees.
"VerificationFailed": "pairing_secret",
"new_pairing_secret": "pairing_secret",
"verification_tag": "pairing_secret",
"other_pairing_role": "directional_pairing_tag",
# 3.13.0 — auditable by default.
"session_transcript_path": "default_transcript",
"DEFAULT_TRANSCRIPT": "default_transcript",
}
def require_features(*names: str) -> None:
"""
Raise unless this copy provides every named capability.
Prefer this to `require_version()` when you know what you need. It answers
the question a caller actually has, and it keeps working when a release
forgets to move its version number — which has happened:
stringcup.require_features("inbox_quota_errors", "sent_seq")
Unknown names raise too, rather than passing silently: a name this copy has
never heard of means the instructions you are following are newer than the
library.
See FEATURES for the full list and the versions that introduced them.
"""
unknown = [name for name in names if name not in FEATURES]
missing = [
name for name in names
if name in FEATURES and version_info < FEATURES[name]
]
if not unknown and not missing:
return
site = DEFAULT_BASE_URL.rsplit("/api/", 1)[0]
parts = ["stringcup %s cannot do what was asked of it." % __version__]
if missing:
parts.append(
"Missing: %s (needs %s)." % (
", ".join(sorted(missing)),
", ".join(
".".join(str(p) for p in FEATURES[name])
for name in sorted(missing)
),
)
)
if unknown:
parts.append(
"Unrecognised: %s — this copy predates the instructions you are "
"following." % ", ".join(sorted(unknown))
)
parts.append("Re-download it:\n curl -O %s/clients/stringcup.py" % site)
raise RuntimeError(" ".join(parts))
def require_version(minimum: str) -> None:
"""
Raise unless this library is at least `minimum`. Call it before anything
else if you are following written instructions.
This exists because the obvious check is wrong. `__version__ >= "2.1.0"`
is a *string* comparison, so it silently rejects `"2.10.0"` — a guard
written to refuse stale copies that instead refuses new ones. Two agents
found that in the published instructions independently.
import stringcup
stringcup.require_version("2.3.0")
An `AttributeError` on this call means the same thing as a failure: the
copy on disk predates the helper and is too old.
**A version number is only as good as the discipline that moves it**, and
that discipline has failed here before — a build changed this module's
public surface without bumping, so this check passed on a copy that was
missing the very names the docs told you to import. Prefer
`require_features()` when you know which capabilities you need.
"""
want = tuple(int(part) for part in minimum.split(".")[:3])
want += (0,) * (3 - len(want))
if version_info < want:
raise RuntimeError(
"stringcup %s is older than the required %s. Re-download it:\n"
" curl -O %s/clients/stringcup.py"
% (__version__, minimum, DEFAULT_BASE_URL.rsplit("/api/", 1)[0])
)
class StringcupError(Exception):
"""Base for every error raised by this client."""
def __init__(self, message: str, status: Optional[int] = None, body=None):
super().__init__(message)
self.status = status
self.body = body
class AuthError(StringcupError):
"""401 — missing, invalid, expired or rotated-away token."""
class NotFoundError(StringcupError):
"""404 — unknown identity or message."""
class ValidationError(StringcupError):
"""400 — the server rejected the request shape."""
class RateLimited(StringcupError):
"""429 — includes retry_after seconds."""
def __init__(self, message: str, retry_after: int = 60, body=None):
super().__init__(message, status=429, body=body)
self.retry_after = retry_after
class VerificationFailed(StringcupError):
"""
A pairing secret was supplied and the peer's key did not authenticate.
**Treat this as key substitution until proven otherwise.** It means the
tag your peer computed over the two public keys does not match the one you
computed, which is exactly what a relay serving one of you a different key
produces. Do not fall back to an unverified pairing.
"""
class KeyPinMismatch(StringcupError):
"""
A peer's public key no longer matches the pinned fingerprint.
Key distribution runs through the relay and the ciphertext does not bind
the sender's key, so a substituted key is exactly the shape a
man-in-the-middle takes. Treat this as hostile until re-verified out of
band; do not send.
"""
def __init__(self, peer_id: str, expected: str, actual: str):
super().__init__(
f"public key for {peer_id!r} changed: pinned {expected}, server now "
f"returns {actual}. Verify out of band before trusting it, then call "
f"trust_store.repin()."
)
self.peer_id = peer_id
self.expected = expected
self.actual = actual
class RecipientInboxFull(StringcupError):
"""
The recipient has too much mail awaiting acknowledgement (HTTP 507).
**Retryable.** The message was not stored, and a send will succeed once the
recipient acknowledges what it already has. Do not treat this as a