flowstatemcp 2.5.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.
Files changed (143) hide show
  1. flowstate/__init__.py +34 -0
  2. flowstate/__main__.py +7 -0
  3. flowstate/activity.py +224 -0
  4. flowstate/agent_guide.py +251 -0
  5. flowstate/app_keys.py +141 -0
  6. flowstate/approvals.py +974 -0
  7. flowstate/approve_page.py +565 -0
  8. flowstate/ask.py +107 -0
  9. flowstate/autosession.py +480 -0
  10. flowstate/backup.py +201 -0
  11. flowstate/brief.py +420 -0
  12. flowstate/card_offer.py +208 -0
  13. flowstate/card_taps.py +165 -0
  14. flowstate/card_trust.py +127 -0
  15. flowstate/claims.py +461 -0
  16. flowstate/cleanup.py +619 -0
  17. flowstate/cli.py +728 -0
  18. flowstate/cli_data.py +434 -0
  19. flowstate/cli_more.py +302 -0
  20. flowstate/cli_review.py +459 -0
  21. flowstate/cloudflared_check.py +49 -0
  22. flowstate/conflict_card_ui.py +286 -0
  23. flowstate/conflict_cards.py +1193 -0
  24. flowstate/conflict_reopen.py +320 -0
  25. flowstate/conflicts.py +420 -0
  26. flowstate/connections.py +33 -0
  27. flowstate/database.py +2191 -0
  28. flowstate/dbconn.py +308 -0
  29. flowstate/deletes.py +860 -0
  30. flowstate/embeddings.py +354 -0
  31. flowstate/erase.py +1047 -0
  32. flowstate/extras.py +71 -0
  33. flowstate/git_capture.py +157 -0
  34. flowstate/guard.py +224 -0
  35. flowstate/handoffs.py +168 -0
  36. flowstate/history.py +755 -0
  37. flowstate/history_page.py +276 -0
  38. flowstate/http_app.py +204 -0
  39. flowstate/identity.py +387 -0
  40. flowstate/install_name.py +140 -0
  41. flowstate/ledger.py +272 -0
  42. flowstate/legacy_args.py +65 -0
  43. flowstate/links.py +609 -0
  44. flowstate/markdown_io.py +242 -0
  45. flowstate/meaning.py +398 -0
  46. flowstate/memory_import.py +92 -0
  47. flowstate/migrations/001_auto_sessions.sql +16 -0
  48. flowstate/migrations/002_review_queue.sql +33 -0
  49. flowstate/migrations/003_links.sql +47 -0
  50. flowstate/migrations/004_review_mode.sql +24 -0
  51. flowstate/migrations/005_history.sql +21 -0
  52. flowstate/migrations/006_activity.sql +26 -0
  53. flowstate/migrations/007_meaning_index.sql +16 -0
  54. flowstate/migrations/008_git_capture.sql +12 -0
  55. flowstate/migrations/009_hard_rules.sql +13 -0
  56. flowstate/migrations/010_handoffs.sql +14 -0
  57. flowstate/migrations/011_record_meta.sql +13 -0
  58. flowstate/migrations/012_accountability.sql +71 -0
  59. flowstate/migrations/013_todo_claims.sql +36 -0
  60. flowstate/migrations/014_decision_conflicts.sql +41 -0
  61. flowstate/migrations/015_session_runs.sql +17 -0
  62. flowstate/migrations/016_write_receipts.sql +35 -0
  63. flowstate/migrations/017_run_views.sql +26 -0
  64. flowstate/migrations/018_run_views_upto.sql +4 -0
  65. flowstate/migrations/019_approvals.sql +47 -0
  66. flowstate/migrations/020_source_stamps.sql +10 -0
  67. flowstate/migrations/021_review_links.sql +11 -0
  68. flowstate/migrations/022_review_start_tickets.sql +10 -0
  69. flowstate/migrations/023_conflict_cards.sql +68 -0
  70. flowstate/migrations/024_rule_confirmations.sql +7 -0
  71. flowstate/migrations/025_confirmed_hash.sql +7 -0
  72. flowstate/migrations/__init__.py +149 -0
  73. flowstate/models.py +307 -0
  74. flowstate/names.py +157 -0
  75. flowstate/offsite.py +386 -0
  76. flowstate/offsite_crypto.py +272 -0
  77. flowstate/paths.py +265 -0
  78. flowstate/popup.py +238 -0
  79. flowstate/priority.py +66 -0
  80. flowstate/privatefs.py +476 -0
  81. flowstate/project_history.py +710 -0
  82. flowstate/provenance.py +696 -0
  83. flowstate/receipts.py +377 -0
  84. flowstate/relay/__init__.py +72 -0
  85. flowstate/relay/acme.py +271 -0
  86. flowstate/relay/api.py +164 -0
  87. flowstate/relay/cli.py +344 -0
  88. flowstate/relay/crypto.py +137 -0
  89. flowstate/relay/ct.py +118 -0
  90. flowstate/relay/runner.py +177 -0
  91. flowstate/relay/store.py +191 -0
  92. flowstate/relay/tunnel.py +413 -0
  93. flowstate/release_signers +14 -0
  94. flowstate/remote_auth.py +1219 -0
  95. flowstate/review.py +320 -0
  96. flowstate/review_apply.py +140 -0
  97. flowstate/review_card_ui.py +241 -0
  98. flowstate/review_cards.py +564 -0
  99. flowstate/review_mode.py +274 -0
  100. flowstate/review_page.py +1361 -0
  101. flowstate/review_phone.py +465 -0
  102. flowstate/review_tunnel.py +338 -0
  103. flowstate/routing.py +392 -0
  104. flowstate/rules.py +418 -0
  105. flowstate/safety.py +98 -0
  106. flowstate/saves.py +83 -0
  107. flowstate/scheduler.py +160 -0
  108. flowstate/schema.sql +713 -0
  109. flowstate/search.py +355 -0
  110. flowstate/secret_store.py +271 -0
  111. flowstate/secrets_scan.py +135 -0
  112. flowstate/selfupdate.py +1020 -0
  113. flowstate/server.py +1299 -0
  114. flowstate/session_contents.py +382 -0
  115. flowstate/session_records.py +629 -0
  116. flowstate/site_style.py +46 -0
  117. flowstate/stored.py +158 -0
  118. flowstate/story.py +442 -0
  119. flowstate/surface.py +173 -0
  120. flowstate/tasks.py +231 -0
  121. flowstate/term.py +40 -0
  122. flowstate/tool_labels.py +104 -0
  123. flowstate/tooldefs.py +383 -0
  124. flowstate/tools/__init__.py +118 -0
  125. flowstate/tools/core.py +545 -0
  126. flowstate/tools/files.py +416 -0
  127. flowstate/tools/git_ops.py +1081 -0
  128. flowstate/tools/intelligence.py +376 -0
  129. flowstate/tools/learning.py +282 -0
  130. flowstate/tools/problems.py +159 -0
  131. flowstate/tools/sessions.py +266 -0
  132. flowstate/tools/story.py +48 -0
  133. flowstate/tools/todos.py +79 -0
  134. flowstate/tools/utils.py +71 -0
  135. flowstate/tools/variables.py +345 -0
  136. flowstate/updates.py +482 -0
  137. flowstate/urls.py +7 -0
  138. flowstate/waiting.py +828 -0
  139. flowstate/who.py +302 -0
  140. flowstatemcp-2.5.0.dist-info/METADATA +88 -0
  141. flowstatemcp-2.5.0.dist-info/RECORD +143 -0
  142. flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
  143. flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
flowstate/relay/cli.py ADDED
@@ -0,0 +1,344 @@
1
+ """`flowstate relay ...` commands. Plain-language output; never prints the device token or a key.
2
+
3
+ flowstate relay enroll --code CODE [--staging] [--skip-cert]
4
+ flowstate relay run [--db FILE] [--port 8768]
5
+ flowstate relay status
6
+ flowstate relay renew [--force] [--staging | --production]
7
+ flowstate relay enable | disable
8
+ flowstate relay forget [--yes]
9
+ flowstate relay ct-check
10
+ flowstate relay ct-ack --id CRTSH_ID
11
+ flowstate relay chat-approvals [on|off] [--db FILE]
12
+
13
+ For relay developers only: FLOWSTATE_RELAY_DOMAIN (default fsmcp.com), FLOWSTATE_RELAY_API,
14
+ FLOWSTATE_RELAY_TUNNEL (host:port), FLOWSTATE_RELAY_TUNNEL_SNI, FLOWSTATE_RELAY_CA_FILE,
15
+ FLOWSTATE_RELAY_ACME_DIRECTORY and FLOWSTATE_RELAY_DIR point the agent somewhere else (https
16
+ only, except loopback). Every relay command prints a warning first when FLOWSTATE_RELAY_DOMAIN
17
+ is anything other than fsmcp.com.
18
+ """
19
+
20
+ import os
21
+ import sys
22
+ import time
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+ from . import API_BASE, TUNNEL_HOST, TUNNEL_PORT, RelayError, RelayWaitlisted
27
+ from .. import term
28
+
29
+
30
+ def say(msg: str) -> None:
31
+ # text from apps and records is in many lines: no escape code reaches the terminal (NEW-7)
32
+ print(term.safe(msg, newlines=True), flush=True)
33
+
34
+
35
+ def _store():
36
+ from .store import RelayStore
37
+ return RelayStore()
38
+
39
+
40
+ def _when(ts) -> str:
41
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "never"
42
+
43
+
44
+ def _tunnel_env():
45
+ raw = os.environ.get("FLOWSTATE_RELAY_TUNNEL") or f"{TUNNEL_HOST}:{TUNNEL_PORT}"
46
+ host, _, port = raw.rpartition(":")
47
+ if not host or not port.isdigit():
48
+ raise RelayError("FLOWSTATE_RELAY_TUNNEL must be host:port")
49
+ return host, int(port), os.environ.get("FLOWSTATE_RELAY_TUNNEL_SNI") or TUNNEL_HOST
50
+
51
+
52
+ # ---------------------------------------------------------------- commands
53
+ def cmd_enroll(a) -> int:
54
+ from . import acme, api
55
+ from . import crypto as C
56
+ store = _store()
57
+ if not C.available():
58
+ say("The relay needs the Python package 'cryptography', which is not installed here. "
59
+ "Nothing was changed.")
60
+ return 1
61
+ if store.enrolled():
62
+ cfg = store.config()
63
+ say(f"Already enrolled as {cfg.get('hostname')}. To start over: flowstate relay forget")
64
+ return 1
65
+ base = os.environ.get("FLOWSTATE_RELAY_API") or API_BASE
66
+ api.check_url(base)
67
+ host, port, sni = _tunnel_env()
68
+ ca = os.environ.get("FLOWSTATE_RELAY_CA_FILE")
69
+ store.ensure_dir() # refuses a git folder before anything goes over the network
70
+ try:
71
+ got = api.enroll(base, a.code, api.client_context(ca))
72
+ except RelayWaitlisted as e:
73
+ say(str(e))
74
+ say("No token or key was saved on this computer. Local FlowState keeps working as before.")
75
+ return 1
76
+ store.save_enrollment(got["handle"], got["device_token"], api=base, tunnel_host=host,
77
+ tunnel_port=port, tunnel_sni=sni, staging=a.staging, ca_file=ca)
78
+ acme.new_tls_key(store)
79
+ say(f"Enrolled. Your relay address: https://{got['hostname']}/mcp")
80
+ say(f"Device token and keys saved (owner-only) in {store.root}. The TLS key was made on "
81
+ f"this computer and never leaves it.")
82
+ if a.skip_cert:
83
+ say("Certificate: not requested yet (flowstate relay renew, or it happens on `relay run`).")
84
+ else:
85
+ rc = _renew(store, force=False)
86
+ if rc:
87
+ say("Enrollment is kept; `flowstate relay run` will try the certificate again.")
88
+ say("Next: set the owner passphrase if you haven't (flowstate remote passphrase), then "
89
+ "start it with: flowstate relay run")
90
+ return 0
91
+
92
+
93
+ def _renew(store, force: bool) -> int:
94
+ from . import acme
95
+ try:
96
+ r = acme.ensure_cert(store, force=force)
97
+ except RelayError as e:
98
+ say(f"Certificate NOT obtained: {e}")
99
+ return 1
100
+ info = r["info"]
101
+ staging = " (Let's Encrypt STAGING: browsers and AI apps will not trust it)" \
102
+ if store.config().get("staging") else ""
103
+ if r["renewed"]:
104
+ say(f"Certificate obtained for {', '.join(info['names'])}, valid until "
105
+ f"{info['not_after']:%Y-%m-%d}{staging}.")
106
+ else:
107
+ say(f"Certificate is fine until {info['not_after']:%Y-%m-%d}{staging}; nothing to do.")
108
+ return 0
109
+
110
+
111
+ def cmd_renew(a) -> int:
112
+ store = _store()
113
+ if not store.enrolled():
114
+ say("The relay is not set up (flowstate relay enroll --code <code>).")
115
+ return 1
116
+ force = a.force
117
+ if a.staging or a.production:
118
+ cfg = store.config()
119
+ want = bool(a.staging)
120
+ if bool(cfg.get("staging")) != want:
121
+ cfg["staging"] = want
122
+ store.save_config(cfg)
123
+ force = True
124
+ return _renew(store, force)
125
+
126
+
127
+ def cmd_run(a) -> int:
128
+ import asyncio
129
+ import logging
130
+ from . import runner
131
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
132
+ db = Path(a.db).expanduser() if a.db else None
133
+ try:
134
+ asyncio.run(runner.run(db_path=db, port=a.port))
135
+ except KeyboardInterrupt:
136
+ say("Relay stopped.")
137
+ return 0
138
+
139
+
140
+ def cmd_status(a) -> int:
141
+ from . import crypto as C
142
+ from .store import CERT, STATUS_FRESH_SECONDS, active_alerts
143
+ store = _store()
144
+ cfg = store.config()
145
+ if not cfg.get("handle"):
146
+ say("Relay: not set up (off). Local FlowState works without it.")
147
+ return 0
148
+ say(f"Relay: {'ON' if cfg.get('enabled') else 'OFF (flowstate relay enable)'}")
149
+ say(f"Handle: {cfg['handle']}")
150
+ say(f"URL: https://{cfg['hostname']}/mcp")
151
+ say(f"Device token: {'stored' if store.read_bytes('device-token') else 'MISSING'} (not shown)")
152
+ pem = store.read_bytes(CERT)
153
+ if pem and C.available():
154
+ info = C.cert_info(pem)
155
+ say(f"Certificate: valid until {info['not_after']:%Y-%m-%d %H:%M} UTC "
156
+ f"({C.days_left(info):.0f} days; renews 30 days before)"
157
+ + (" [Let's Encrypt STAGING]" if cfg.get("staging") else ""))
158
+ else:
159
+ say("Certificate: none yet (flowstate relay renew)")
160
+ st = store.status()
161
+ fresh = st.get("updated") and time.time() - st["updated"] < STATUS_FRESH_SECONDS
162
+ if fresh:
163
+ say(f"Tunnel: {st.get('tunnel')} {st.get('detail') or ''}".rstrip()
164
+ + f" (agent pid {st.get('pid')}, local port {st.get('port')})")
165
+ else:
166
+ say("Tunnel: not running" + (f" (last seen {_when(st.get('updated'))})" if st.get("updated") else ""))
167
+ if st.get("cert_error"):
168
+ say(f"Last certificate problem: {st['cert_error']}")
169
+ ctd = store.ct()
170
+ if ctd.get("last_attempt"):
171
+ say(f"CT check: last {_when(ctd.get('last_check'))}, "
172
+ f"{ctd.get('certificates', 0)} certificate(s) seen"
173
+ + (f"; last attempt failed: {ctd['error']}" if ctd.get("error") else ""))
174
+ else:
175
+ say("CT check: not run yet (flowstate relay ct-check)")
176
+ alerts = active_alerts(ctd)
177
+ for al in alerts:
178
+ say(f"!! WARNING: certificate crt.sh id {al['crtsh_id']} for {', '.join(al['names'])} "
179
+ f"(issuer {al['issuer']}, from {al['not_before'][:10]}) was NOT made with this "
180
+ f"computer's key. Someone else can impersonate your relay address. Check "
181
+ f"https://crt.sh/?id={al['crtsh_id']}, run `flowstate relay disable`, and contact "
182
+ f"support. If you know it is yours: flowstate relay ct-ack --id {al['crtsh_id']}")
183
+ from .. import remote_auth as RA
184
+ if not RA.AuthStore().has_passphrase():
185
+ say("Owner passphrase: NOT set; apps can't be approved (flowstate remote passphrase)")
186
+ return 2 if alerts else 0
187
+
188
+
189
+ def cmd_enable(a) -> int:
190
+ store = _store()
191
+ store.set_enabled(True)
192
+ say("Relay turned on. Start it with: flowstate relay run")
193
+ return 0
194
+
195
+
196
+ def cmd_disable(a) -> int:
197
+ store = _store()
198
+ store.set_enabled(False)
199
+ say("Relay turned off. A running `flowstate relay run` stops within a few seconds. Your "
200
+ "enrollment and keys are kept (flowstate relay forget deletes them).")
201
+ return 0
202
+
203
+
204
+ def cmd_forget(a) -> int:
205
+ store = _store()
206
+ if not store.root.exists():
207
+ say("Nothing to forget: the relay was never set up here.")
208
+ return 0
209
+ if not a.yes:
210
+ ans = input(f"Delete the relay device token, keys and certificate in {store.root}? "
211
+ f"Type 'forget' to confirm: ")
212
+ if ans.strip() != "forget":
213
+ say("Nothing deleted.")
214
+ return 1
215
+ gone = store.forget()
216
+ say(f"Deleted: {', '.join(gone) or 'nothing'}. A running relay stops within a few seconds. "
217
+ f"The handle stays reserved at the relay until you remove it in your account.")
218
+ return 0
219
+
220
+
221
+ def cmd_ct_check(a) -> int:
222
+ from . import ct
223
+ from .store import active_alerts
224
+ store = _store()
225
+ r = ct.run_check(store)
226
+ if r.get("error"):
227
+ say(f"CT check could not finish: {r['error']}")
228
+ alerts = active_alerts(r)
229
+ if alerts:
230
+ say(f"!! {len(alerts)} certificate(s) for {r['hostname']} use a key that is not this "
231
+ f"computer's. See: flowstate relay status")
232
+ return 2
233
+ say(f"CT check: {r['certificates']} certificate(s) for {r['hostname']}, all made with this "
234
+ f"computer's key." if not r.get("error") else "No mismatches found so far.")
235
+ return 1 if r.get("error") else 0
236
+
237
+
238
+ def cmd_ct_ack(a) -> int:
239
+ from . import ct
240
+ ok = ct.acknowledge(_store(), a.id)
241
+ say(f"Alert for crt.sh id {a.id} acknowledged." if ok else f"No open alert for crt.sh id {a.id}.")
242
+ return 0 if ok else 1
243
+
244
+
245
+ CHAT_ON = ("AIs connected through the relay can approve and reject review items in the chat "
246
+ "when they quote you (except deletes and turning review off while approvals are on, "
247
+ "and \"did you say this?\" checks: those stay yours alone).")
248
+ CHAT_OFF = ("AIs connected through the relay can't approve or reject review items; they tell "
249
+ "you to decide on your phone instead, from your saved review link (the default). "
250
+ "Apps on this computer are not affected.")
251
+
252
+
253
+ def cmd_chat_approvals(a) -> int:
254
+ """May an AI on the relay approve / reject review items in the chat? Off by default.
255
+ Turning it on asks for `y` in your own terminal (an AI's shell has none); off needs nothing.
256
+ There is no tool for it, so an AI can't switch it on through FlowState."""
257
+ from .. import approvals
258
+ from .. import server as S
259
+ from ..cli_review import _db, open_terminal
260
+ db = _db(a)
261
+ S.init_backend(db) # migrations first (019 holds the settings)
262
+ S.backend.tracker = None
263
+ if S.backend.error:
264
+ say(f"FAILED: FlowState database unavailable: {S.backend.error}")
265
+ return 1
266
+ if a.state is None:
267
+ on = approvals.relay_chat_on(db)
268
+ say(f"Chat approvals through the relay are {'on' if on else 'off'}. "
269
+ + (CHAT_ON if on else CHAT_OFF))
270
+ say("Change it: flowstate relay chat-approvals on|off")
271
+ return 0
272
+ if a.state == "on" and not approvals.relay_chat_on(db):
273
+ tty = open_terminal()
274
+ if tty is None:
275
+ say("Refused: turning chat approvals on asks you to confirm in a terminal, and there "
276
+ "is none here (piped input doesn't count). Run it yourself in a terminal window.")
277
+ return 2
278
+ try:
279
+ if tty.ask("Let AIs connected through the relay approve and reject review items in "
280
+ "the chat? Type y to confirm: ").lower() not in ("y", "yes"):
281
+ say("Not confirmed; nothing changed.")
282
+ return 1
283
+ finally:
284
+ tty.close()
285
+ try:
286
+ res = approvals.set_relay_chat(db, a.state == "on")
287
+ except approvals.ApprovalError as e:
288
+ say(f"FAILED: {e}")
289
+ return 1
290
+ say(f"Chat approvals through the relay are now {res['now']}. "
291
+ + (CHAT_ON if res["now"] == "on" else CHAT_OFF))
292
+ return 0
293
+
294
+
295
+ # ---------------------------------------------------------------- parser
296
+ def register(sub) -> None:
297
+ p = sub.add_parser("relay", help="optional relay: reach this FlowState from cloud AIs "
298
+ "(end-to-end TLS; off by default)")
299
+ rs = p.add_subparsers(dest="relay_action", required=True)
300
+ q = rs.add_parser("enroll", help="exchange a one-time code for a handle and device token")
301
+ q.add_argument("--code", required=True)
302
+ q.add_argument("--staging", action="store_true", help="use Let's Encrypt staging (testing)")
303
+ q.add_argument("--skip-cert", action="store_true", help="don't request the certificate now")
304
+ q.set_defaults(fn=_guard(cmd_enroll))
305
+ q = rs.add_parser("run", help="run the relay agent (with FlowState's public listener)")
306
+ q.add_argument("--db")
307
+ q.add_argument("--port", type=int, default=8768, help="local listener port (127.0.0.1)")
308
+ q.set_defaults(fn=_guard(cmd_run))
309
+ q = rs.add_parser("status", help="handle, URL, certificate, tunnel, CT check")
310
+ q.set_defaults(fn=_guard(cmd_status))
311
+ q = rs.add_parser("renew", help="obtain / renew the certificate now if needed")
312
+ q.add_argument("--force", action="store_true")
313
+ g = q.add_mutually_exclusive_group()
314
+ g.add_argument("--staging", action="store_true", help="switch to Let's Encrypt staging")
315
+ g.add_argument("--production", action="store_true", help="switch to Let's Encrypt production")
316
+ q.set_defaults(fn=_guard(cmd_renew))
317
+ rs.add_parser("enable", help="turn the relay back on").set_defaults(fn=_guard(cmd_enable))
318
+ rs.add_parser("disable", help="turn the relay off (keeps keys)").set_defaults(fn=_guard(cmd_disable))
319
+ q = rs.add_parser("forget", help="delete the device token, keys and certificate")
320
+ q.add_argument("--yes", action="store_true", help="don't ask")
321
+ q.set_defaults(fn=_guard(cmd_forget))
322
+ rs.add_parser("ct-check", help="check Certificate Transparency now").set_defaults(fn=_guard(cmd_ct_check))
323
+ q = rs.add_parser("ct-ack", help="acknowledge a CT alert you have checked")
324
+ q.add_argument("--id", type=int, required=True)
325
+ q.set_defaults(fn=_guard(cmd_ct_ack))
326
+ q = rs.add_parser("chat-approvals", help="may AIs on the relay approve / reject review items "
327
+ "in the chat? (default off: phone only)")
328
+ q.add_argument("state", nargs="?", choices=["on", "off"], help="omit to show the setting")
329
+ q.add_argument("--db")
330
+ q.set_defaults(fn=_guard(cmd_chat_approvals))
331
+
332
+
333
+ def _guard(fn):
334
+ def run(a) -> int:
335
+ from . import domain_warning
336
+ warn = domain_warning()
337
+ if warn:
338
+ print(warn, file=sys.stderr, flush=True)
339
+ try:
340
+ return fn(a)
341
+ except RelayError as e:
342
+ say(f"Relay: {e}")
343
+ return 1
344
+ return run
@@ -0,0 +1,137 @@
1
+ """Key and certificate helpers for the relay. Uses the `cryptography` package, imported only
2
+ here and only when a relay command runs (it is not a declared FlowState dependency yet; today
3
+ it arrives with `mcp` via `pyjwt[crypto]`). Without it the relay commands say so and the rest
4
+ of FlowState is unaffected.
5
+
6
+ Every private key is generated on this computer and only ever written to an owner-only file.
7
+ """
8
+
9
+ import base64
10
+ import hashlib
11
+ import json
12
+ from datetime import datetime, timezone
13
+ from typing import Optional
14
+
15
+ from . import RelayError
16
+
17
+
18
+ def b64u(data: bytes) -> str:
19
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
20
+
21
+
22
+ def _lib():
23
+ try:
24
+ from cryptography import x509
25
+ from cryptography.hazmat.primitives import hashes, serialization
26
+ from cryptography.hazmat.primitives.asymmetric import ec
27
+ from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
28
+ from cryptography.x509.oid import NameOID
29
+ except ImportError as e: # pragma: no cover - depends on the environment
30
+ raise RelayError("the relay needs the Python package 'cryptography' "
31
+ "(not installed in this environment)") from e
32
+ return x509, hashes, serialization, ec, decode_dss_signature, NameOID
33
+
34
+
35
+ def available() -> bool:
36
+ try:
37
+ _lib()
38
+ return True
39
+ except RelayError:
40
+ return False
41
+
42
+
43
+ # ---------------------------------------------------------------- keys
44
+ def new_key_pem() -> bytes:
45
+ """A fresh ECDSA P-256 private key, PKCS#8 PEM (unencrypted; the file is 0600)."""
46
+ _, _, ser, ec, _, _ = _lib()
47
+ key = ec.generate_private_key(ec.SECP256R1())
48
+ return key.private_bytes(ser.Encoding.PEM, ser.PrivateFormat.PKCS8, ser.NoEncryption())
49
+
50
+
51
+ def load_key(pem: bytes):
52
+ _, _, ser, ec, _, _ = _lib()
53
+ key = ser.load_pem_private_key(pem, password=None)
54
+ if not isinstance(key, ec.EllipticCurvePrivateKey) or key.curve.name != "secp256r1":
55
+ raise RelayError("relay key is not an ECDSA P-256 key")
56
+ return key
57
+
58
+
59
+ def spki_sha256(public_key) -> str:
60
+ """Hex SHA-256 of the DER SubjectPublicKeyInfo (what the CT watch compares)."""
61
+ _, _, ser, _, _, _ = _lib()
62
+ der = public_key.public_bytes(ser.Encoding.DER, ser.PublicFormat.SubjectPublicKeyInfo)
63
+ return hashlib.sha256(der).hexdigest()
64
+
65
+
66
+ def key_spki_sha256(pem: bytes) -> str:
67
+ return spki_sha256(load_key(pem).public_key())
68
+
69
+
70
+ # ---------------------------------------------------------------- JWS (ACME)
71
+ def jwk(key) -> dict:
72
+ nums = key.public_key().public_numbers()
73
+ return {"crv": "P-256", "kty": "EC", "x": b64u(nums.x.to_bytes(32, "big")),
74
+ "y": b64u(nums.y.to_bytes(32, "big"))}
75
+
76
+
77
+ def jwk_thumbprint(key) -> str:
78
+ """RFC 7638 thumbprint: SHA-256 over the required members in lexicographic order."""
79
+ j = jwk(key)
80
+ canon = json.dumps({k: j[k] for k in ("crv", "kty", "x", "y")}, separators=(",", ":"))
81
+ return b64u(hashlib.sha256(canon.encode()).digest())
82
+
83
+
84
+ def sign_es256(key, data: bytes) -> bytes:
85
+ """ES256 JWS signature: raw r || s, 32 bytes each (RFC 7518 3.4)."""
86
+ _, hashes, _, ec, decode_dss, _ = _lib()
87
+ r, s = decode_dss(key.sign(data, ec.ECDSA(hashes.SHA256())))
88
+ return r.to_bytes(32, "big") + s.to_bytes(32, "big")
89
+
90
+
91
+ def csr_der(key, hostname: str) -> bytes:
92
+ x509, hashes, ser, _, _, NameOID = _lib()
93
+ csr = (x509.CertificateSigningRequestBuilder()
94
+ .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
95
+ .add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False)
96
+ .sign(key, hashes.SHA256()))
97
+ return csr.public_bytes(ser.Encoding.DER)
98
+
99
+
100
+ # ---------------------------------------------------------------- certificates
101
+ def _names(cert) -> list:
102
+ x509 = _lib()[0]
103
+ try:
104
+ san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
105
+ return [n.lower() for n in san.get_values_for_type(x509.DNSName)]
106
+ except x509.ExtensionNotFound:
107
+ return []
108
+
109
+
110
+ def cert_info(pem: bytes) -> dict:
111
+ """First certificate of a PEM chain (or one DER certificate): names, expiry, issuer and
112
+ key fingerprint."""
113
+ x509 = _lib()[0]
114
+ try:
115
+ cert = (x509.load_pem_x509_certificate(pem) if b"-----BEGIN" in pem
116
+ else x509.load_der_x509_certificate(pem))
117
+ except ValueError as e:
118
+ raise RelayError(f"not a PEM certificate ({e})") from e
119
+ return {"names": _names(cert), "not_after": cert.not_valid_after_utc,
120
+ "not_before": cert.not_valid_before_utc, "issuer": cert.issuer.rfc4514_string(),
121
+ "serial": format(cert.serial_number, "x"), "spki_sha256": spki_sha256(cert.public_key())}
122
+
123
+
124
+ def days_left(info: dict, now: Optional[datetime] = None) -> float:
125
+ now = now or datetime.now(timezone.utc)
126
+ return (info["not_after"] - now).total_seconds() / 86400
127
+
128
+
129
+ def covers(pattern: str, hostname: str) -> bool:
130
+ """Does a certificate name (possibly `*.parent`) cover `hostname`?"""
131
+ pattern, hostname = pattern.lower().rstrip("."), hostname.lower().rstrip(".")
132
+ if pattern == hostname:
133
+ return True
134
+ if pattern.startswith("*."):
135
+ head, _, rest = hostname.partition(".")
136
+ return bool(head) and rest == pattern[2:]
137
+ return False
flowstate/relay/ct.py ADDED
@@ -0,0 +1,118 @@
1
+ """Certificate Transparency watch.
2
+
3
+ Every publicly trusted certificate is logged in CT. Once a day the agent asks crt.sh for
4
+ every certificate that covers this computer's relay hostname (exact name and the
5
+ `*.fsmcp.com` wildcard), downloads each new one and compares its public key with
6
+ the local key(s). A certificate made with any other key means someone else (for example
7
+ whoever controls the relay's DNS) got a certificate for the hostname and could read relay
8
+ traffic: it is logged as an ERROR, kept in ct.json and shown at the top of `start_here` and
9
+ in `flowstate relay status` until the user acknowledges it.
10
+
11
+ Only the hostname is sent to crt.sh, with a plain "flowstate" user agent (no version). Like
12
+ any web request it also shows crt.sh this computer's IP address; the privacy policy says so.
13
+ """
14
+
15
+ import json
16
+ import logging
17
+ import time
18
+ from typing import Callable, Optional
19
+ from urllib.parse import quote
20
+
21
+ from . import HOST_SUFFIX, RelayError
22
+ from . import crypto as C
23
+ from .api import http_request
24
+
25
+ log = logging.getLogger("flowstate.relay.ct")
26
+
27
+ CRT_SH = "https://crt.sh/"
28
+ CHECK_EVERY = 24 * 3600
29
+ MAX_FETCH = 50 # certificates downloaded per run (be polite to crt.sh)
30
+
31
+
32
+ def _fetch(url: str) -> bytes:
33
+ status, _, body = http_request("GET", url, headers={"Accept": "*/*", "User-Agent": "flowstate"},
34
+ timeout=60)
35
+ if status != 200:
36
+ raise RelayError(f"crt.sh answered {status}")
37
+ return body
38
+
39
+
40
+ def queries(hostname: str) -> list:
41
+ return [hostname, f"*.{HOST_SUFFIX}"]
42
+
43
+
44
+ def check(hostname: str, known_spki: list, previous: Optional[dict] = None,
45
+ fetch: Callable[[str], bytes] = _fetch, now: Optional[float] = None) -> dict:
46
+ """One CT pass. Returns the new ct.json content (previous alerts are kept)."""
47
+ prev = previous or {}
48
+ seen = {str(k): v for k, v in (prev.get("seen") or {}).items()}
49
+ alerts = list(prev.get("alerts") or [])
50
+ out = {"hostname": hostname, "last_attempt": int(now or time.time()), "seen": seen,
51
+ "alerts": alerts, "last_check": prev.get("last_check"), "error": None,
52
+ "certificates": prev.get("certificates", 0)}
53
+ try:
54
+ entries = {}
55
+ for q in queries(hostname):
56
+ data = json.loads(fetch(f"{CRT_SH}?q={quote(q)}&output=json") or b"[]")
57
+ for e in data if isinstance(data, list) else []:
58
+ names = str(e.get("name_value", "")).split()
59
+ if e.get("id") is not None and any(C.covers(n, hostname) for n in names):
60
+ entries[str(e["id"])] = e
61
+ fetched = 0
62
+ for cid, e in sorted(entries.items(), key=lambda kv: int(kv[0])):
63
+ if cid in seen:
64
+ continue
65
+ if fetched >= MAX_FETCH:
66
+ break
67
+ fetched += 1
68
+ info = C.cert_info(fetch(f"{CRT_SH}?d={quote(cid)}"))
69
+ if not any(C.covers(n, hostname) for n in info["names"]):
70
+ seen[cid] = "other-name"
71
+ continue
72
+ if info["spki_sha256"] in known_spki:
73
+ seen[cid] = "ok"
74
+ continue
75
+ seen[cid] = "ALERT"
76
+ alert = {"crtsh_id": int(cid), "issuer": info["issuer"], "names": info["names"],
77
+ "not_before": info["not_before"].isoformat(),
78
+ "not_after": info["not_after"].isoformat(),
79
+ "spki_sha256": info["spki_sha256"], "found_at": out["last_attempt"]}
80
+ alerts.append(alert)
81
+ log.error("RELAY SECURITY WARNING: certificate crt.sh id %s for %s (%s) uses a key "
82
+ "that is not this computer's. Someone else obtained a certificate for "
83
+ "your relay hostname. Run `flowstate relay disable` and check "
84
+ "https://crt.sh/?id=%s", cid, hostname, ", ".join(info["names"]), cid)
85
+ out["certificates"] = len(entries)
86
+ out["last_check"] = out["last_attempt"]
87
+ except (RelayError, ValueError, KeyError, TypeError) as e:
88
+ out["error"] = f"{type(e).__name__}: {str(e)[:200]}"
89
+ log.warning("CT check for %s could not finish: %s", hostname, out["error"])
90
+ out["ok"] = not any(not a.get("acknowledged") for a in alerts)
91
+ return out
92
+
93
+
94
+ def run_check(store, fetch: Callable[[str], bytes] = _fetch) -> dict:
95
+ cfg = store.config()
96
+ if not cfg.get("hostname"):
97
+ raise RelayError("the relay is not set up")
98
+ result = check(cfg["hostname"], list(cfg.get("known_spki") or []), store.ct(), fetch)
99
+ store.save_ct(result)
100
+ return result
101
+
102
+
103
+ def due(store, now: Optional[float] = None) -> bool:
104
+ last = store.ct().get("last_attempt") or 0
105
+ return (now or time.time()) - last >= CHECK_EVERY
106
+
107
+
108
+ def acknowledge(store, crtsh_id: int) -> bool:
109
+ data = store.ct()
110
+ hit = False
111
+ for a in data.get("alerts") or []:
112
+ if a.get("crtsh_id") == crtsh_id and not a.get("acknowledged"):
113
+ a["acknowledged"] = int(time.time())
114
+ hit = True
115
+ if hit:
116
+ data["ok"] = not any(not a.get("acknowledged") for a in data["alerts"])
117
+ store.save_ct(data)
118
+ return hit