stasima 0.1.4__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.
- stasima/__init__.py +4 -0
- stasima/admin.py +306 -0
- stasima/airlock.py +217 -0
- stasima/audit_log.py +179 -0
- stasima/authz.py +54 -0
- stasima/canon.py +188 -0
- stasima/cap_server.py +1113 -0
- stasima/config.py +179 -0
- stasima/entries.py +46 -0
- stasima/local_capstore.py +627 -0
- stasima/map_index.py +471 -0
- stasima/orientation.py +88 -0
- stasima/tui.py +311 -0
- stasima-0.1.4.dist-info/METADATA +127 -0
- stasima-0.1.4.dist-info/RECORD +19 -0
- stasima-0.1.4.dist-info/WHEEL +4 -0
- stasima-0.1.4.dist-info/entry_points.txt +4 -0
- stasima-0.1.4.dist-info/licenses/LICENSE +201 -0
- stasima-0.1.4.dist-info/licenses/NOTICE +12 -0
stasima/__init__.py
ADDED
stasima/admin.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""
|
|
3
|
+
Admin CLI — practitioner-side maintenance + promotion. NOT model-facing: these are operator ops,
|
|
4
|
+
and `land` (promotion to canon) IS the human gate, performed here out of band.
|
|
5
|
+
|
|
6
|
+
stasima-admin --config stasima.toml <command>
|
|
7
|
+
|
|
8
|
+
bootstrap <dir> seed an EMPTY canon from a folder of .md entries (one-time)
|
|
9
|
+
totp-provision generate the airlock TOTP secret (prints the otpauth:// URI)
|
|
10
|
+
totp-check <code> verify a code from your app (consumes nothing; diagnoses clock skew)
|
|
11
|
+
inbox [--all] [--read PATH] the practitioner's mail, from the cockpit (pull)
|
|
12
|
+
backup <dest> full LOCAL backup of everything that is truth: git mirror (all refs+tags),
|
|
13
|
+
consistent audit snapshot, config, TOTP secret. For a same-trust machine
|
|
14
|
+
move — carries the secret. Run it anywhere: synced folder, external drive.
|
|
15
|
+
mirror <url> off-machine backup to a git REMOTE (e.g. a private repo): content refs +
|
|
16
|
+
a consistent audit snapshot on refs/backup/audit, verified. The TOTP secret
|
|
17
|
+
is NEVER pushed. Run on a cadence for durable, off-box state.
|
|
18
|
+
status canon head, perspectives, proposals, audit health
|
|
19
|
+
reindex rebuild the MAP index from git
|
|
20
|
+
reconcile backfill audit events for committed ops missing one
|
|
21
|
+
verify check the audit chain (+ the git-anchored checkpoint)
|
|
22
|
+
anchor write the audit head into git now
|
|
23
|
+
preview <id> dry-run a proposal merge (conflicts / changed paths)
|
|
24
|
+
land <id> [--by X] approve + land a proposal to canon (audit + reindex + anchor)
|
|
25
|
+
proposals every proposal's lifecycle: open / landed / closed (+ lands_behind, raw)
|
|
26
|
+
close <id> <reason> close a lingering proposal (tombstone; terminal for seats; the gate's own
|
|
27
|
+
duty to clear what will not land — nothing is deleted)
|
|
28
|
+
"""
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import subprocess as sp
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
from .config import Config
|
|
37
|
+
from .entries import compose_entry
|
|
38
|
+
from .cap_server import components_from_config
|
|
39
|
+
from .canon import (reindex_from_git, land_and_record, canon_seq, seq_display, LOG_DIR,
|
|
40
|
+
proposal_statuses, close_proposal)
|
|
41
|
+
from .audit_log import reconcile_from_git, anchor_audit_head, verify_against_anchor
|
|
42
|
+
from .local_capstore import Approval, MergeConflict, CanonAppendOnly, PERSP_PREFIX as PERSP, PROP_PREFIX as PROP
|
|
43
|
+
from .airlock import generate_secret, otpauth_uri, verify_code, totp_at, STEP
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _first_heading(text: str):
|
|
47
|
+
for line in text.splitlines():
|
|
48
|
+
if line.startswith("# "):
|
|
49
|
+
return line[2:].strip()
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _qr_ascii(data: str):
|
|
54
|
+
"""ASCII QR of `data`, or None if the optional qrcode package isn't installed."""
|
|
55
|
+
try:
|
|
56
|
+
import qrcode
|
|
57
|
+
except ImportError:
|
|
58
|
+
return None
|
|
59
|
+
import io
|
|
60
|
+
qr = qrcode.QRCode(border=2)
|
|
61
|
+
qr.add_data(data)
|
|
62
|
+
qr.make(fit=True)
|
|
63
|
+
buf = io.StringIO()
|
|
64
|
+
qr.print_ascii(out=buf, invert=True) # dark-terminal polarity; the URI below is the fallback
|
|
65
|
+
return buf.getvalue()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run(args) -> dict:
|
|
69
|
+
cfg = Config.load(args.config)
|
|
70
|
+
store, index, embedder, audit, authz, airlock = components_from_config(cfg)
|
|
71
|
+
|
|
72
|
+
if args.cmd == "totp-provision":
|
|
73
|
+
issuer = cfg.deployment_name or "Stasima"
|
|
74
|
+
uri = lambda s: otpauth_uri(s, label=f"{issuer}:practitioner", issuer=issuer)
|
|
75
|
+
path = cfg.resolved_airlock_secret()
|
|
76
|
+
if os.path.exists(path) and not args.force:
|
|
77
|
+
if args.qr: # re-display the EXISTING secret's QR — no rotation
|
|
78
|
+
with open(path, encoding="utf-8") as f:
|
|
79
|
+
secret = f.read().strip()
|
|
80
|
+
qr = _qr_ascii(uri(secret))
|
|
81
|
+
print(qr if qr else "(pip install qrcode for a scannable QR)")
|
|
82
|
+
return {"secret_path": path, "otpauth_uri": uri(secret), "rotated": False,
|
|
83
|
+
"note": "existing secret re-displayed; scan the QR or enter the secret= value manually"}
|
|
84
|
+
raise SystemExit(f"secret already exists at {path} — pass --force to rotate "
|
|
85
|
+
f"(rotating invalidates the practitioner's current authenticator entry), "
|
|
86
|
+
f"or --qr to re-display it")
|
|
87
|
+
secret = generate_secret()
|
|
88
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
89
|
+
f.write(secret + "\n")
|
|
90
|
+
if args.qr:
|
|
91
|
+
qr = _qr_ascii(uri(secret))
|
|
92
|
+
print(qr if qr else "(pip install qrcode for a scannable QR)")
|
|
93
|
+
return {"secret_path": path, "otpauth_uri": uri(secret),
|
|
94
|
+
"note": "scan the QR (or enter the secret= value manually); the secret stays "
|
|
95
|
+
"server-side, never in git — if the QR won't scan, terminal polarity is the "
|
|
96
|
+
"usual culprit; the manual key always works"}
|
|
97
|
+
|
|
98
|
+
if args.cmd == "totp-check":
|
|
99
|
+
# verification only — consumes no windows, approves nothing; safe to run as often as you like
|
|
100
|
+
spath = cfg.resolved_airlock_secret()
|
|
101
|
+
if not os.path.exists(spath):
|
|
102
|
+
raise SystemExit(f"no secret at {spath} — run totp-provision first")
|
|
103
|
+
with open(spath, encoding="utf-8") as f:
|
|
104
|
+
secret = f.read().strip()
|
|
105
|
+
now = time.time()
|
|
106
|
+
w = verify_code(secret, args.code, now)
|
|
107
|
+
if w is not None:
|
|
108
|
+
return {"valid": True, "matched_window": w, "current_window": int(now // STEP),
|
|
109
|
+
"note": "your authenticator and the server agree — the airlock will accept codes"}
|
|
110
|
+
cur = int(now // STEP)
|
|
111
|
+
for delta in range(-10, 11): # diagnose clock skew beyond the ±1 acceptance
|
|
112
|
+
if totp_at(secret, cur + delta) == str(args.code).strip():
|
|
113
|
+
return {"valid": False, "matched_window": cur + delta,
|
|
114
|
+
"skew": f"{delta:+d} windows (≈ {delta * STEP:+d}s)",
|
|
115
|
+
"note": "code is from the right secret but outside the ±1-window acceptance — "
|
|
116
|
+
"sync the server or phone clock"}
|
|
117
|
+
return {"valid": False,
|
|
118
|
+
"note": "no match within ±10 windows — mistyped code, or the app holds a different/old secret "
|
|
119
|
+
"(re-provision with --force and re-add to the app)"}
|
|
120
|
+
|
|
121
|
+
if args.cmd == "bootstrap":
|
|
122
|
+
if not os.path.isdir(os.path.join(cfg.git_dir, "objects")):
|
|
123
|
+
sp.run(["git", "init", "--bare", "-q", cfg.git_dir], check=True) # create the bare repo if missing
|
|
124
|
+
if store.resolve_ref(cfg.canon_ref) is not None:
|
|
125
|
+
raise SystemExit("canon already exists — add entries via propose + land, not bootstrap")
|
|
126
|
+
changes = {}
|
|
127
|
+
for root, _, files in os.walk(args.seed_dir):
|
|
128
|
+
for fn in sorted(files):
|
|
129
|
+
if not fn.endswith(".md"):
|
|
130
|
+
continue
|
|
131
|
+
rel = os.path.relpath(os.path.join(root, fn), args.seed_dir).replace(os.sep, "/")
|
|
132
|
+
with open(os.path.join(root, fn), encoding="utf-8") as f:
|
|
133
|
+
text = f.read()
|
|
134
|
+
if not text.lstrip().startswith("---"): # plain markdown -> wrap with a sensible envelope
|
|
135
|
+
title = _first_heading(text) or os.path.splitext(fn)[0].replace("-", " ").title()
|
|
136
|
+
etype = "ori" if rel.startswith("technical/orientation/") else "kno"
|
|
137
|
+
text = compose_entry({"type": etype, "title": title, "status": "active"}, text)
|
|
138
|
+
changes[rel] = text.encode()
|
|
139
|
+
if not changes:
|
|
140
|
+
raise SystemExit(f"no .md files found under {args.seed_dir!r}")
|
|
141
|
+
r = store.bootstrap_canon(changes, "Bootstrap canon")
|
|
142
|
+
return {"bootstrapped": r.oid, "entries": sorted(changes), "indexed": reindex_from_git(store, index, embedder)}
|
|
143
|
+
|
|
144
|
+
if args.cmd == "status":
|
|
145
|
+
ok, bad = audit.verify()
|
|
146
|
+
unread = [m for m in index.inbox("practitioner") if not audit.is_read("practitioner", m.path)]
|
|
147
|
+
return {"canon_head": store.resolve_ref(cfg.canon_ref),
|
|
148
|
+
"canon_seq": seq_display(canon_seq(store, cfg.seq_origin)),
|
|
149
|
+
"perspectives": [r.name[len(PERSP):] for r in store.list_refs(PERSP)],
|
|
150
|
+
"proposals": [r.name[len(PROP):] for r in store.list_refs(PROP)],
|
|
151
|
+
"staged": airlock.staged(),
|
|
152
|
+
"practitioner_unread": len(unread),
|
|
153
|
+
"audit_events": audit.count(), "audit_verify_ok": ok,
|
|
154
|
+
"audit_vs_anchor": verify_against_anchor(store, audit)}
|
|
155
|
+
|
|
156
|
+
if args.cmd == "inbox":
|
|
157
|
+
if args.read:
|
|
158
|
+
audit.append_read("practitioner", args.read)
|
|
159
|
+
return {"marked_read": args.read}
|
|
160
|
+
msgs = index.inbox("practitioner")
|
|
161
|
+
if not args.all:
|
|
162
|
+
msgs = [m for m in msgs if not audit.is_read("practitioner", m.path)]
|
|
163
|
+
return {"unread" if not args.all else "all":
|
|
164
|
+
[{"path": m.path, "from": m.authoring_instance, "subject": m.subject,
|
|
165
|
+
"coordinates": m.links} for m in msgs],
|
|
166
|
+
"note": "read a message body with: kip_get equivalent -> git show <perspective>:<path>; "
|
|
167
|
+
"mark handled with: inbox --read <path>"}
|
|
168
|
+
|
|
169
|
+
if args.cmd == "backup":
|
|
170
|
+
# everything that is TRUTH, in one destination: full-ref git mirror (consistent by nature),
|
|
171
|
+
# a consistent audit snapshot (sqlite backup API, safe against a live server), config + secret.
|
|
172
|
+
# The map index is a derived cache and is deliberately not backed up.
|
|
173
|
+
import shutil
|
|
174
|
+
import sqlite3 as _sq
|
|
175
|
+
os.makedirs(args.dest, exist_ok=True)
|
|
176
|
+
mirror = os.path.join(args.dest, "stasima-mirror.git")
|
|
177
|
+
if not os.path.isdir(os.path.join(mirror, "objects")):
|
|
178
|
+
sp.run(["git", "init", "--bare", "-q", mirror], check=True)
|
|
179
|
+
store.set_remote("backup", mirror)
|
|
180
|
+
sync = store.push_all("backup")
|
|
181
|
+
audit_copy = os.path.join(args.dest, "audit.sqlite")
|
|
182
|
+
dst = _sq.connect(audit_copy)
|
|
183
|
+
audit.conn.backup(dst)
|
|
184
|
+
dst.close()
|
|
185
|
+
copied = ["stasima-mirror.git", "audit.sqlite"]
|
|
186
|
+
for src in (args.config, cfg.resolved_airlock_secret()):
|
|
187
|
+
if src and os.path.exists(src):
|
|
188
|
+
shutil.copy2(src, args.dest)
|
|
189
|
+
copied.append(os.path.basename(src))
|
|
190
|
+
ok = not sync["missing_on_remote"] and not sync["oid_mismatch"]
|
|
191
|
+
return {"dest": args.dest, "git_sync_ok": ok, "synced_refs": len(sync["synced"]),
|
|
192
|
+
"audit_events": audit.count(), "copied": copied}
|
|
193
|
+
|
|
194
|
+
if args.cmd == "mirror":
|
|
195
|
+
# off-machine backup to a GIT REMOTE (e.g. a PRIVATE repo) in one command: content refs +
|
|
196
|
+
# a consistent audit snapshot on a dedicated ref (refs/backup/audit), both verified.
|
|
197
|
+
# The TOTP secret is deliberately NEVER pushed — it is the airlock key and is re-provisionable;
|
|
198
|
+
# keep it out of any remote, even a private one. (Use `backup <dest>` for a local bundle that
|
|
199
|
+
# DOES carry the secret, for a same-trust machine move.)
|
|
200
|
+
import sqlite3 as _sq
|
|
201
|
+
import tempfile
|
|
202
|
+
fd, snap = tempfile.mkstemp(suffix=".sqlite")
|
|
203
|
+
os.close(fd)
|
|
204
|
+
try:
|
|
205
|
+
dst = _sq.connect(snap) # consistent snapshot, safe against a live server
|
|
206
|
+
audit.conn.backup(dst)
|
|
207
|
+
dst.close()
|
|
208
|
+
with open(snap, "rb") as f:
|
|
209
|
+
store.commit_file("refs/backup/audit", "audit.sqlite", f.read(),
|
|
210
|
+
f"audit snapshot ({audit.count()} events)")
|
|
211
|
+
finally:
|
|
212
|
+
os.remove(snap)
|
|
213
|
+
sync = store.mirror_push("mirror", args.url,
|
|
214
|
+
extra_refspecs=["refs/backup/audit:refs/backup/audit"])
|
|
215
|
+
ok = not sync["missing_on_remote"] and not sync["oid_mismatch"]
|
|
216
|
+
return {"remote": args.url, "git_sync_ok": ok, "synced_refs": len(sync["synced"]),
|
|
217
|
+
"audit_events": audit.count(),
|
|
218
|
+
"note": "content refs + audit snapshot pushed; TOTP secret NOT sent (re-provision on restore)"}
|
|
219
|
+
|
|
220
|
+
if args.cmd == "reindex":
|
|
221
|
+
return {"reindexed": reindex_from_git(store, index, embedder)}
|
|
222
|
+
|
|
223
|
+
if args.cmd == "proposals":
|
|
224
|
+
return {"proposals": proposal_statuses(store)}
|
|
225
|
+
|
|
226
|
+
if args.cmd == "close":
|
|
227
|
+
return close_proposal(store, audit, args.proposal_id, args.reason, "practitioner")
|
|
228
|
+
|
|
229
|
+
if args.cmd == "reconcile":
|
|
230
|
+
return {"backfilled": reconcile_from_git(store, audit)}
|
|
231
|
+
|
|
232
|
+
if args.cmd == "verify":
|
|
233
|
+
ok, bad = audit.verify()
|
|
234
|
+
return {"audit_verify_ok": ok, "first_bad_seq": bad,
|
|
235
|
+
"audit_vs_anchor": verify_against_anchor(store, audit)}
|
|
236
|
+
|
|
237
|
+
if args.cmd == "anchor":
|
|
238
|
+
return {"anchored": anchor_audit_head(store, audit)}
|
|
239
|
+
|
|
240
|
+
if args.cmd == "preview":
|
|
241
|
+
s = store.preview_merge(PROP + args.proposal_id, cfg.canon_ref)
|
|
242
|
+
logs = [p for p in (s.added + s.modified) if p.startswith(LOG_DIR)]
|
|
243
|
+
return {"conflicts": s.conflicts, "authors": s.authoring_instances,
|
|
244
|
+
"adds": s.added, "removes": s.removed, "modifies": s.modified,
|
|
245
|
+
"would_remove_canon": s.removed, # non-empty -> `land` will REFUSE (append-only)
|
|
246
|
+
"log_entries": logs, "log_entry_ok": len(logs) == 1,
|
|
247
|
+
"expected_seq": format(canon_seq(store, cfg.seq_origin) + 1, "x")}
|
|
248
|
+
|
|
249
|
+
if args.cmd == "land":
|
|
250
|
+
approver = args.by or sorted(cfg.approvers)[0]
|
|
251
|
+
if approver not in cfg.approvers:
|
|
252
|
+
raise SystemExit(f"{approver!r} is not a configured approver ({sorted(cfg.approvers)})")
|
|
253
|
+
try:
|
|
254
|
+
prepared = store.prepare_merge(PROP + args.proposal_id, cfg.canon_ref)
|
|
255
|
+
except MergeConflict as e:
|
|
256
|
+
raise SystemExit(f"conflict — not landing: {e}")
|
|
257
|
+
except CanonAppendOnly as e:
|
|
258
|
+
raise SystemExit(f"append-only — not landing: {e}")
|
|
259
|
+
try:
|
|
260
|
+
return land_and_record(store, index, embedder, audit, prepared,
|
|
261
|
+
Approval(prepared.candidate_oid, approver, "cli-confirm"),
|
|
262
|
+
origin=cfg.seq_origin)
|
|
263
|
+
except ValueError as e:
|
|
264
|
+
raise SystemExit(f"not landing: {e}")
|
|
265
|
+
|
|
266
|
+
raise SystemExit(f"unknown command {args.cmd!r}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
270
|
+
ap = argparse.ArgumentParser(prog="stasima-admin", description="Stasima maintenance + promotion")
|
|
271
|
+
ap.add_argument("--config", default=os.environ.get("STASIMA_CONFIG"))
|
|
272
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
273
|
+
for c in ("status", "reindex", "reconcile", "verify", "anchor", "proposals"):
|
|
274
|
+
sub.add_parser(c)
|
|
275
|
+
cl = sub.add_parser("close")
|
|
276
|
+
cl.add_argument("proposal_id")
|
|
277
|
+
cl.add_argument("reason", help="why this proposal will not land (recorded in the tombstone + audit)")
|
|
278
|
+
sub.add_parser("bootstrap").add_argument("seed_dir", help="folder of .md entries to seed an empty canon")
|
|
279
|
+
tp = sub.add_parser("totp-provision")
|
|
280
|
+
tp.add_argument("--force", action="store_true", help="rotate an existing secret")
|
|
281
|
+
tp.add_argument("--qr", action="store_true", help="render a scannable ASCII QR (re-displays if the secret exists)")
|
|
282
|
+
sub.add_parser("totp-check").add_argument("code", help="a code from your authenticator app")
|
|
283
|
+
sub.add_parser("backup").add_argument("dest", help="destination folder for the full backup (carries the secret)")
|
|
284
|
+
sub.add_parser("mirror").add_argument("url", help="git remote URL (e.g. a PRIVATE repo) — content + audit, no secret")
|
|
285
|
+
ib = sub.add_parser("inbox")
|
|
286
|
+
ib.add_argument("--all", action="store_true", help="include already-read messages")
|
|
287
|
+
ib.add_argument("--read", default=None, metavar="PATH", help="mark a message path as read")
|
|
288
|
+
sub.add_parser("preview").add_argument("proposal_id")
|
|
289
|
+
land = sub.add_parser("land")
|
|
290
|
+
land.add_argument("proposal_id")
|
|
291
|
+
land.add_argument("--by", default=None, help="approver (defaults to the first configured)")
|
|
292
|
+
return ap
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def main(argv=None) -> dict:
|
|
296
|
+
try: # Windows consoles default to cp1252, which can't print the QR block chars (or em dashes)
|
|
297
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
result = run(build_parser().parse_args(argv))
|
|
301
|
+
print(json.dumps(result, indent=2, default=str))
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
main()
|
stasima/airlock.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""
|
|
3
|
+
Airlock — TOTP two-phase remote approval (the mediated-channel counterpart of console `land`).
|
|
4
|
+
|
|
5
|
+
At the console, the console itself is the out-of-band channel, so admin `land` stays a single
|
|
6
|
+
approval. When the practitioner approves *through an instance conversation* (phone, relay), the
|
|
7
|
+
channel is the thing to defend against: the airlock binds presence-proofs (TOTP codes) to the
|
|
8
|
+
existing prepare/land two-phase gate so no single code — harvested, relayed, or replayed — can
|
|
9
|
+
both stage and land, and nothing can land unreviewed.
|
|
10
|
+
|
|
11
|
+
open --code 1--> staged --(review: floor..ceiling)--code 2--> landed
|
|
12
|
+
^ |
|
|
13
|
+
+---- revert ----+ (abort: FREE, no code; TTL expiry: lazy auto-revert)
|
|
14
|
+
|
|
15
|
+
Why the 120s floor: a TOTP code lives at most ~90s (30s step, +/-1 window acceptance), so any code
|
|
16
|
+
visible at staging time is arithmetically dead by the earliest legal landing moment. Strict window
|
|
17
|
+
ordering (code 2 strictly later than code 1) and consume-once (a window number is never accepted
|
|
18
|
+
twice for one purpose) close the same-window and replay paths as defense in depth. Content-binding
|
|
19
|
+
(landing names the staged oid) means what lands is exactly what was staged — a swap fails closed.
|
|
20
|
+
|
|
21
|
+
Abort is free by design: charging presence-proof to *decline* would incentivize landing.
|
|
22
|
+
|
|
23
|
+
Honest residual: the practitioner's view of what was staged flows through the relaying instance.
|
|
24
|
+
Content-binding makes swap-after-stage impossible and the audit trail makes deception detectable
|
|
25
|
+
after the fact; it does NOT make the relay's display trustworthy in the moment. The console remains
|
|
26
|
+
the stronger channel.
|
|
27
|
+
|
|
28
|
+
State (open | staged | landed, with revert folding back to open) is derived from the audit log —
|
|
29
|
+
staging is operational, not content; nothing here touches the storage spine. The clock is
|
|
30
|
+
injectable for tests; server time is authoritative for every gate.
|
|
31
|
+
"""
|
|
32
|
+
import base64
|
|
33
|
+
import hashlib
|
|
34
|
+
import hmac
|
|
35
|
+
import os
|
|
36
|
+
import struct
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
from .local_capstore import MergePreparation, MergeSummary, Approval, PROP_PREFIX as PROP
|
|
40
|
+
|
|
41
|
+
STEP = 30 # RFC 6238 time step (seconds)
|
|
42
|
+
DIGITS = 6
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AirlockError(Exception):
|
|
46
|
+
"""A gate refused. The message names the failed gate and both values where applicable."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------- TOTP (RFC 6238, stdlib only)
|
|
50
|
+
def generate_secret() -> str:
|
|
51
|
+
return base64.b32encode(os.urandom(20)).decode()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def otpauth_uri(secret: str, label: str = "Stasima:practitioner", issuer: str = "Stasima") -> str:
|
|
55
|
+
return (f"otpauth://totp/{label}?secret={secret}&issuer={issuer}"
|
|
56
|
+
f"&algorithm=SHA1&digits={DIGITS}&period={STEP}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def totp_at(secret: str, window: int) -> str:
|
|
60
|
+
"""The code for an absolute window number (window = unix_time // STEP)."""
|
|
61
|
+
key = base64.b32decode(secret.strip(), casefold=True)
|
|
62
|
+
mac = hmac.new(key, struct.pack(">Q", window), hashlib.sha1).digest()
|
|
63
|
+
off = mac[-1] & 0x0F
|
|
64
|
+
code = (struct.unpack(">I", mac[off:off + 4])[0] & 0x7FFFFFFF) % (10 ** DIGITS)
|
|
65
|
+
return f"{code:0{DIGITS}d}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def verify_code(secret: str, code: str, now: float):
|
|
69
|
+
"""Accept the current window +/-1 (clock skew). Returns the MATCHED window number, else None."""
|
|
70
|
+
w = int(now // STEP)
|
|
71
|
+
for cand in (w, w - 1, w + 1):
|
|
72
|
+
if hmac.compare_digest(totp_at(secret, cand), str(code).strip()):
|
|
73
|
+
return cand
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------- the gate
|
|
78
|
+
class Airlock:
|
|
79
|
+
def __init__(self, store, audit, *, secret_path, land_fn, validate_fn, approver,
|
|
80
|
+
floor_s: int = 120, ceiling_s: int = 7200, clock=time.time, prop_prefix: str = PROP):
|
|
81
|
+
# floor must exceed worst-case code lifetime (STEP + one window of skew acceptance ~= 90s)
|
|
82
|
+
# so that no code obtained at staging survives to the landing moment.
|
|
83
|
+
self.store = store
|
|
84
|
+
self.audit = audit
|
|
85
|
+
self.secret_path = secret_path
|
|
86
|
+
self.land_fn = land_fn # (prepared, approval) -> land_and_record result
|
|
87
|
+
self.validate_fn = validate_fn # (prepared) -> log-entry seq (early feedback; land re-validates)
|
|
88
|
+
self.approver = approver
|
|
89
|
+
self.floor_s = floor_s
|
|
90
|
+
self.ceiling_s = ceiling_s
|
|
91
|
+
self.clock = clock
|
|
92
|
+
self.prop_prefix = prop_prefix
|
|
93
|
+
|
|
94
|
+
# ---- secret + code consumption ----
|
|
95
|
+
def _secret(self) -> str:
|
|
96
|
+
if not os.path.exists(self.secret_path):
|
|
97
|
+
raise AirlockError("airlock not provisioned — run: admin totp-provision")
|
|
98
|
+
with open(self.secret_path, encoding="utf-8") as f:
|
|
99
|
+
return f.read().strip()
|
|
100
|
+
|
|
101
|
+
def _consume(self, code, purpose, obj, min_window=None) -> int:
|
|
102
|
+
"""Verify a code and burn its window for this purpose. Codes are never logged — windows are."""
|
|
103
|
+
w = verify_code(self._secret(), code, self.clock())
|
|
104
|
+
if w is None:
|
|
105
|
+
self.audit.append("system", "totp_reject", detail={"purpose": purpose, "object": obj,
|
|
106
|
+
"reason": "invalid"})
|
|
107
|
+
raise AirlockError("invalid code")
|
|
108
|
+
if min_window is not None and w <= min_window:
|
|
109
|
+
self.audit.append("system", "totp_reject", detail={"purpose": purpose, "object": obj,
|
|
110
|
+
"window": w, "min_window": min_window,
|
|
111
|
+
"reason": "not strictly later"})
|
|
112
|
+
raise AirlockError(f"code is from window {w}, which is not strictly later than the staging "
|
|
113
|
+
f"window {min_window} — wait for a fresh code")
|
|
114
|
+
for e in self.audit.events(op="totp_accept"):
|
|
115
|
+
if e["detail"].get("window") == w and e["detail"].get("purpose") == purpose:
|
|
116
|
+
self.audit.append("system", "totp_reject", detail={"purpose": purpose, "object": obj,
|
|
117
|
+
"window": w, "reason": "replay"})
|
|
118
|
+
raise AirlockError(f"a code from window {w} was already used for {purpose} (consume-once)")
|
|
119
|
+
self.audit.append("practitioner", "totp_accept", detail={"window": w, "purpose": purpose, "object": obj})
|
|
120
|
+
return w
|
|
121
|
+
|
|
122
|
+
# ---- state machine (derived from the audit log; TTL is lazy) ----
|
|
123
|
+
def _fold(self, proposal_id):
|
|
124
|
+
cur, ev = "open", None
|
|
125
|
+
full_ref = self.prop_prefix + proposal_id
|
|
126
|
+
for e in self.audit.events():
|
|
127
|
+
d = e["detail"]
|
|
128
|
+
if e["op"] == "airlock_stage" and d.get("proposal") == proposal_id:
|
|
129
|
+
cur, ev = "staged", e
|
|
130
|
+
elif e["op"] == "airlock_revert" and d.get("proposal") == proposal_id:
|
|
131
|
+
cur, ev = "open", e
|
|
132
|
+
elif e["op"] == "land_merge" and d.get("proposal") == full_ref:
|
|
133
|
+
cur, ev = "landed", e
|
|
134
|
+
return cur, ev
|
|
135
|
+
|
|
136
|
+
def state(self, proposal_id) -> dict:
|
|
137
|
+
st, e = self._fold(proposal_id)
|
|
138
|
+
if st == "staged":
|
|
139
|
+
d = e["detail"]
|
|
140
|
+
if self.clock() - d["staged_at"] > self.ceiling_s: # lazy TTL: observed -> reverted
|
|
141
|
+
self.audit.append("system", "airlock_revert", target_ref=self.prop_prefix + proposal_id,
|
|
142
|
+
detail={"proposal": proposal_id, "reason": "ttl",
|
|
143
|
+
"staged_oid": d["staged_oid"]})
|
|
144
|
+
return {"state": "open", "reverted": "ttl"}
|
|
145
|
+
return {"state": "staged", "staged_oid": d["staged_oid"], "staged_at": d["staged_at"],
|
|
146
|
+
"window": d["window"], "changed_paths": d.get("changed_paths", []),
|
|
147
|
+
"lands_after": d["staged_at"] + self.floor_s,
|
|
148
|
+
"expires_at": d["staged_at"] + self.ceiling_s}
|
|
149
|
+
return {"state": st}
|
|
150
|
+
|
|
151
|
+
def staged(self) -> list:
|
|
152
|
+
"""Currently staged proposals (cockpit view)."""
|
|
153
|
+
return [{"proposal_id": pid, "staged_oid": st["staged_oid"],
|
|
154
|
+
"lands_after": st["lands_after"], "expires_at": st["expires_at"]}
|
|
155
|
+
for pid, st in self._staged_proposals()]
|
|
156
|
+
|
|
157
|
+
def _staged_proposals(self):
|
|
158
|
+
seen, out = set(), []
|
|
159
|
+
for e in self.audit.events(op="airlock_stage"):
|
|
160
|
+
pid = e["detail"]["proposal"]
|
|
161
|
+
if pid in seen:
|
|
162
|
+
continue
|
|
163
|
+
seen.add(pid)
|
|
164
|
+
st = self.state(pid)
|
|
165
|
+
if st["state"] == "staged":
|
|
166
|
+
out.append((pid, st))
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
# ---- the three ops ----
|
|
170
|
+
def stage(self, proposal_id, code) -> dict:
|
|
171
|
+
"""Code 1: freeze the proposal, prepare the merge, start the review clock."""
|
|
172
|
+
if self.state(proposal_id)["state"] == "staged":
|
|
173
|
+
raise AirlockError(f"{proposal_id} is already staged")
|
|
174
|
+
# prove the proposal stageable BEFORE consuming the code — failures must not burn windows
|
|
175
|
+
prepared = self.store.prepare_merge(self.prop_prefix + proposal_id, self.store.canon_ref)
|
|
176
|
+
seq = self.validate_fn(prepared)
|
|
177
|
+
w = self._consume(code, "stage", proposal_id)
|
|
178
|
+
staged_at = self.clock()
|
|
179
|
+
self.audit.append("practitioner", "airlock_stage", target_ref=self.prop_prefix + proposal_id,
|
|
180
|
+
result_oid=prepared.candidate_oid,
|
|
181
|
+
detail={"proposal": proposal_id, "staged_oid": prepared.candidate_oid,
|
|
182
|
+
"window": w, "staged_at": staged_at, "seq": seq,
|
|
183
|
+
"changed_paths": prepared.summary.changed_paths})
|
|
184
|
+
return {"proposal_id": proposal_id, "staged_oid": prepared.candidate_oid,
|
|
185
|
+
"staged_at": staged_at, "lands_after": staged_at + self.floor_s,
|
|
186
|
+
"expires_at": staged_at + self.ceiling_s,
|
|
187
|
+
"changed_paths": prepared.summary.changed_paths, "log_seq": seq}
|
|
188
|
+
|
|
189
|
+
def land(self, staged_oid_prefix, code) -> dict:
|
|
190
|
+
"""Code 2: after the review floor, strictly later window, bound to the staged content."""
|
|
191
|
+
if len(str(staged_oid_prefix)) < 8:
|
|
192
|
+
raise AirlockError("staged oid prefix too short — give at least 8 hex characters")
|
|
193
|
+
matches = [(pid, st) for pid, st in self._staged_proposals()
|
|
194
|
+
if st["staged_oid"].startswith(staged_oid_prefix)]
|
|
195
|
+
if not matches:
|
|
196
|
+
raise AirlockError(f"no staged proposal matches oid prefix {staged_oid_prefix!r} (content-binding)")
|
|
197
|
+
if len(matches) > 1:
|
|
198
|
+
raise AirlockError(f"oid prefix {staged_oid_prefix!r} is ambiguous across staged proposals")
|
|
199
|
+
pid, st = matches[0]
|
|
200
|
+
elapsed = self.clock() - st["staged_at"]
|
|
201
|
+
if elapsed < self.floor_s: # gates before code verification — a floor miss must not burn a window
|
|
202
|
+
raise AirlockError(f"review floor not met: {elapsed:.0f}s since staging, floor is "
|
|
203
|
+
f"{self.floor_s}s — review, then retry with a fresh code")
|
|
204
|
+
w2 = self._consume(code, "land", pid, min_window=st["window"])
|
|
205
|
+
prepared = MergePreparation(candidate_oid=st["staged_oid"], into=self.store.canon_ref,
|
|
206
|
+
proposal_ref=self.prop_prefix + pid,
|
|
207
|
+
summary=MergeSummary(st.get("changed_paths", []), [], []))
|
|
208
|
+
return self.land_fn(prepared, Approval(st["staged_oid"], self.approver, f"airlock-totp-w{w2}"))
|
|
209
|
+
|
|
210
|
+
def revert(self, proposal_id) -> dict:
|
|
211
|
+
"""Abort a staged review. FREE — never requires a code (charging for decline would
|
|
212
|
+
incentivize landing). Returns the proposal to open with its entries intact."""
|
|
213
|
+
if self.state(proposal_id)["state"] != "staged":
|
|
214
|
+
raise AirlockError(f"{proposal_id} is not staged")
|
|
215
|
+
self.audit.append("system", "airlock_revert", target_ref=self.prop_prefix + proposal_id,
|
|
216
|
+
detail={"proposal": proposal_id, "reason": "manual"})
|
|
217
|
+
return {"proposal_id": proposal_id, "state": "open"}
|