underwrit 0.3.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.
- underwrit/__init__.py +19 -0
- underwrit/__main__.py +3 -0
- underwrit/api.py +1763 -0
- underwrit/auth.py +348 -0
- underwrit/backup.py +125 -0
- underwrit/bundle.py +110 -0
- underwrit/cedar.py +150 -0
- underwrit/chain.py +519 -0
- underwrit/checkpoint.py +428 -0
- underwrit/cli.py +155 -0
- underwrit/db.py +377 -0
- underwrit/demo.py +310 -0
- underwrit/ecdsa_p256.py +108 -0
- underwrit/ed25519.py +171 -0
- underwrit/evidence.py +590 -0
- underwrit/mcp.py +147 -0
- underwrit/merkle.py +144 -0
- underwrit/migrate.py +186 -0
- underwrit/obs.py +108 -0
- underwrit/oidc.py +362 -0
- underwrit/opa.py +103 -0
- underwrit/policy.py +933 -0
- underwrit/ratelimit.py +46 -0
- underwrit/recorder.py +139 -0
- underwrit/replay.py +136 -0
- underwrit/retention.py +220 -0
- underwrit/store.py +607 -0
- underwrit/subject.py +57 -0
- underwrit/taskdraft.py +40 -0
- underwrit/tsa.py +180 -0
- underwrit-0.3.0.dist-info/METADATA +174 -0
- underwrit-0.3.0.dist-info/RECORD +37 -0
- underwrit-0.3.0.dist-info/WHEEL +5 -0
- underwrit-0.3.0.dist-info/entry_points.txt +2 -0
- underwrit-0.3.0.dist-info/licenses/LICENSE +202 -0
- underwrit-0.3.0.dist-info/licenses/NOTICE +4 -0
- underwrit-0.3.0.dist-info/top_level.txt +1 -0
underwrit/checkpoint.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""Signed checkpoints over the chain, and the key that signs them.
|
|
2
|
+
|
|
3
|
+
A checkpoint is a commitment to the whole log at one moment: its size and its Merkle root, signed
|
|
4
|
+
by the data plane's log key. It is the thing a bundle's inclusion proofs are checked against, the
|
|
5
|
+
thing a witness cosigns, and the thing an auditor keeps from one visit to the next so that the
|
|
6
|
+
consistency proof at the next visit shows the log only grew.
|
|
7
|
+
|
|
8
|
+
Format: the C2SP signed-note checkpoint (https://c2sp.org/tlog-checkpoint), which is what Sigstore
|
|
9
|
+
and Go's transparency logs use and what any witness in that ecosystem expects:
|
|
10
|
+
|
|
11
|
+
underwrit/<node> origin line
|
|
12
|
+
<tree size> ASCII decimal
|
|
13
|
+
<root, base64>
|
|
14
|
+
|
|
15
|
+
— underwrit/<node> <base64(key hash[4] ‖ Ed25519 signature)>
|
|
16
|
+
|
|
17
|
+
The leaf for entry i is `SHA-256(0x00 ‖ hash_i)` where `hash_i` is the entry's own chain hash as
|
|
18
|
+
hex bytes — so the tree commits to exactly what the chain commits to, and the two verify each other.
|
|
19
|
+
|
|
20
|
+
**The key lives in a file, not the database.** A database is backed up and copied; a key in it
|
|
21
|
+
is a key in every copy. The file is created 0600 on first start unless `UNDERWRIT_SIGNING_KEY_FILE`
|
|
22
|
+
points at one an operator provisioned (a mounted secret, a KMS-fetched file). The public key and
|
|
23
|
+
its id are served openly at `/v1/keys` and shipped in every bundle — and a verifier that has only
|
|
24
|
+
the bundle's copy proves self-consistency, not origin, which is why `verify.py` says so and accepts
|
|
25
|
+
a pinned key.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from . import db, ed25519, merkle
|
|
38
|
+
|
|
39
|
+
SCHEMA = [
|
|
40
|
+
"""CREATE TABLE IF NOT EXISTS checkpoints (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
size INTEGER NOT NULL,
|
|
43
|
+
root TEXT NOT NULL,
|
|
44
|
+
note TEXT NOT NULL,
|
|
45
|
+
key_id TEXT NOT NULL,
|
|
46
|
+
at REAL NOT NULL
|
|
47
|
+
)""",
|
|
48
|
+
"CREATE INDEX IF NOT EXISTS checkpoints_size ON checkpoints (size)",
|
|
49
|
+
# The last checkpoint each witness cosigned, keyed by where we send checkpoints. The primary
|
|
50
|
+
# witness is the control plane ("control"); UNDERWRIT_WITNESSES adds more. Each row holds that
|
|
51
|
+
# witness's cosigned note; `witness_state()` merges the lines of every witness at the latest
|
|
52
|
+
# size into one note, which is what the bundle ships.
|
|
53
|
+
"""CREATE TABLE IF NOT EXISTS witness_states (
|
|
54
|
+
url TEXT PRIMARY KEY,
|
|
55
|
+
witness TEXT NOT NULL DEFAULT '',
|
|
56
|
+
size INTEGER NOT NULL,
|
|
57
|
+
root TEXT NOT NULL,
|
|
58
|
+
cosigned_note TEXT NOT NULL,
|
|
59
|
+
at REAL NOT NULL
|
|
60
|
+
)""",
|
|
61
|
+
# External clocks: RFC 3161 time-stamp tokens and OpenTimestamps proofs over a checkpoint's
|
|
62
|
+
# body. The token is stored whole (base64) so the bundle ships the original bytes.
|
|
63
|
+
"""CREATE TABLE IF NOT EXISTS timestamps (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
size INTEGER NOT NULL,
|
|
66
|
+
root TEXT NOT NULL,
|
|
67
|
+
kind TEXT NOT NULL,
|
|
68
|
+
token TEXT NOT NULL,
|
|
69
|
+
meta_json TEXT NOT NULL DEFAULT '{}',
|
|
70
|
+
at REAL NOT NULL
|
|
71
|
+
)""",
|
|
72
|
+
"CREATE INDEX IF NOT EXISTS timestamps_size ON timestamps (size, root)",
|
|
73
|
+
# Every key that has signed a checkpoint on this plane, so a bundle from before a rotation can
|
|
74
|
+
# still be pinned and an auditor can see when each key was in use.
|
|
75
|
+
"""CREATE TABLE IF NOT EXISTS log_keys (
|
|
76
|
+
key_id TEXT PRIMARY KEY,
|
|
77
|
+
public_key TEXT NOT NULL,
|
|
78
|
+
origin TEXT NOT NULL DEFAULT '',
|
|
79
|
+
first_seen REAL NOT NULL,
|
|
80
|
+
last_seen REAL NOT NULL
|
|
81
|
+
)""",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def init(conn) -> None:
|
|
86
|
+
for ddl in SCHEMA:
|
|
87
|
+
conn.execute(db.ddl(ddl))
|
|
88
|
+
conn.commit()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------------------------
|
|
92
|
+
# Keys
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Key:
|
|
96
|
+
def __init__(self, seed: bytes, origin: str):
|
|
97
|
+
self.seed = seed
|
|
98
|
+
self.public = ed25519.public_key(seed)
|
|
99
|
+
self.origin = origin
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def key_hash(self) -> bytes:
|
|
103
|
+
"""C2SP signed-note key hash: first four bytes of SHA-256(name ‖ 0x0A ‖ 0x01 ‖ public key)."""
|
|
104
|
+
return hashlib.sha256(self.origin.encode() + b"\n\x01" + self.public).digest()[:4]
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def cosig_key_hash(self) -> bytes:
|
|
108
|
+
"""The key hash a witness line carries: SHA-256(name ‖ 0x0A ‖ 0x04 ‖ public key)[:4]. C2SP
|
|
109
|
+
gives cosignature/v1 keys their own algorithm byte (0x04) so external tooling can tell a
|
|
110
|
+
witness line from a log line by its hash."""
|
|
111
|
+
return hashlib.sha256(self.origin.encode() + b"\n\x04" + self.public).digest()[:4]
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def key_id(self) -> str:
|
|
115
|
+
return self.key_hash.hex()
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def cosig_key_id(self) -> str:
|
|
119
|
+
return self.cosig_key_hash.hex()
|
|
120
|
+
|
|
121
|
+
def public_json(self) -> dict:
|
|
122
|
+
return {"alg": "ed25519", "keyId": self.key_id, "cosigKeyId": self.cosig_key_id, "origin": self.origin,
|
|
123
|
+
"publicKey": base64.b64encode(self.public).decode(),
|
|
124
|
+
"fingerprint": hashlib.sha256(self.public).hexdigest()}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_or_create(path: str | Path, origin: str) -> tuple[Key, bool]:
|
|
128
|
+
"""The log key from `path`, created if absent. Returns (key, created)."""
|
|
129
|
+
p = Path(path)
|
|
130
|
+
if p.exists() and p.read_text().strip():
|
|
131
|
+
seed = bytes.fromhex(p.read_text().strip())
|
|
132
|
+
return Key(seed, origin), False
|
|
133
|
+
seed = ed25519.generate_seed()
|
|
134
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
p.write_text(seed.hex() + "\n")
|
|
136
|
+
try:
|
|
137
|
+
os.chmod(p, 0o600)
|
|
138
|
+
except OSError:
|
|
139
|
+
pass
|
|
140
|
+
return Key(seed, origin), True
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def default_key_path(db_path: str) -> str:
|
|
144
|
+
return os.environ.get("UNDERWRIT_SIGNING_KEY_FILE") or str(Path(db_path).with_suffix(".signing.key"))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --------------------------------------------------------------------------------------------
|
|
148
|
+
# The tree over the chain
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def leaves(conn) -> list[bytes]:
|
|
152
|
+
"""One leaf per live chain entry, in sequence order."""
|
|
153
|
+
rows = conn.execute("SELECT hash FROM audit_log ORDER BY seq ASC").fetchall()
|
|
154
|
+
return [merkle.leaf_hash((r["hash"] or "").encode("ascii")) for r in rows]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def seqs(conn) -> list[int]:
|
|
158
|
+
return [int(r["seq"]) for r in conn.execute("SELECT seq FROM audit_log ORDER BY seq ASC")]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def tree(conn) -> tuple[list[int], list[bytes]]:
|
|
162
|
+
"""(sequence numbers, leaves) so a caller can map a seq to its leaf index."""
|
|
163
|
+
rows = conn.execute("SELECT seq, hash FROM audit_log ORDER BY seq ASC").fetchall()
|
|
164
|
+
return ([int(r["seq"]) for r in rows],
|
|
165
|
+
[merkle.leaf_hash((r["hash"] or "").encode("ascii")) for r in rows])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------------------------------------------------
|
|
169
|
+
# Notes
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def note_body(origin: str, size: int, root: bytes) -> str:
|
|
173
|
+
return f"{origin}\n{size}\n{base64.b64encode(root).decode()}\n"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def sign_note(key: Key, size: int, root: bytes) -> str:
|
|
177
|
+
body = note_body(key.origin, size, root)
|
|
178
|
+
sig = ed25519.fast_sign(key.seed, body.encode())
|
|
179
|
+
line = f"— {key.origin} {base64.b64encode(key.key_hash + sig).decode()}"
|
|
180
|
+
return body + "\n" + line + "\n"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def parse_note(note: str) -> dict:
|
|
184
|
+
"""{origin, size, root (bytes), signatures: [(name, keyHash hex, sig bytes)]}."""
|
|
185
|
+
body, _, sigs = note.partition("\n\n")
|
|
186
|
+
lines = body.split("\n")
|
|
187
|
+
if len(lines) < 3:
|
|
188
|
+
raise ValueError("checkpoint note is too short")
|
|
189
|
+
out = {"origin": lines[0], "size": int(lines[1]), "root": base64.b64decode(lines[2]),
|
|
190
|
+
"signatures": []}
|
|
191
|
+
for line in sigs.splitlines():
|
|
192
|
+
if not line.startswith("— "):
|
|
193
|
+
continue
|
|
194
|
+
_, name, blob = line.split(" ", 2)
|
|
195
|
+
raw = base64.b64decode(blob)
|
|
196
|
+
out["signatures"].append((name, raw[:4].hex(), raw[4:]))
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def verify_note(note: str, public: bytes, origin: str) -> bool:
|
|
201
|
+
"""True when a signature by `public` for `origin` covers the note body."""
|
|
202
|
+
parsed = parse_note(note)
|
|
203
|
+
body = note_body(parsed["origin"], parsed["size"], parsed["root"]).encode()
|
|
204
|
+
want = hashlib.sha256(origin.encode() + b"\n\x01" + public).digest()[:4].hex()
|
|
205
|
+
return any(name == origin and kh == want and ed25519.fast_verify(public, body, sig)
|
|
206
|
+
for name, kh, sig in parsed["signatures"])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --------------------------------------------------------------------------------------------
|
|
210
|
+
# Issuing
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def issue(conn, key: Key, lv: list[bytes] | None = None) -> dict:
|
|
214
|
+
"""Sign the log as it stands now (or as the given leaf snapshot) and record the checkpoint."""
|
|
215
|
+
lv = leaves(conn) if lv is None else lv
|
|
216
|
+
root = merkle.root(lv)
|
|
217
|
+
note = sign_note(key, len(lv), root)
|
|
218
|
+
now = time.time()
|
|
219
|
+
conn.execute("INSERT INTO checkpoints (size, root, note, key_id, at) VALUES (?, ?, ?, ?, ?)",
|
|
220
|
+
(len(lv), root.hex(), note, key.key_id, now))
|
|
221
|
+
conn.execute("INSERT INTO log_keys (key_id, public_key, origin, first_seen, last_seen) VALUES (?, ?, ?, ?, ?) "
|
|
222
|
+
"ON CONFLICT (key_id) DO UPDATE SET last_seen = excluded.last_seen",
|
|
223
|
+
(key.key_id, base64.b64encode(key.public).decode(), key.origin, now, now))
|
|
224
|
+
conn.commit()
|
|
225
|
+
return {"size": len(lv), "root": root.hex(), "note": note, "keyId": key.key_id, "at": now}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def key_history(conn) -> list[dict]:
|
|
229
|
+
rows = conn.execute("SELECT * FROM log_keys ORDER BY first_seen ASC").fetchall()
|
|
230
|
+
return [{"alg": "ed25519", "keyId": r["key_id"], "publicKey": r["public_key"], "origin": r["origin"],
|
|
231
|
+
"firstSeen": r["first_seen"], "lastSeen": r["last_seen"]} for r in rows]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def latest(conn) -> dict | None:
|
|
235
|
+
r = conn.execute("SELECT * FROM checkpoints ORDER BY id DESC LIMIT 1").fetchone()
|
|
236
|
+
return ({"size": r["size"], "root": r["root"], "note": r["note"], "keyId": r["key_id"],
|
|
237
|
+
"at": r["at"]} if r else None)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def history(conn, limit: int = 50) -> list[dict]:
|
|
241
|
+
rows = conn.execute("SELECT * FROM checkpoints ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
242
|
+
return [{"size": r["size"], "root": r["root"], "keyId": r["key_id"], "at": r["at"]} for r in rows]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def consistency(conn, old_size: int) -> dict:
|
|
246
|
+
"""A consistency proof from an earlier size to the log as it stands now."""
|
|
247
|
+
lv = leaves(conn)
|
|
248
|
+
if old_size < 1 or old_size > len(lv):
|
|
249
|
+
raise ValueError(f"old size {old_size} outside 1..{len(lv)}")
|
|
250
|
+
proof = merkle.consistency_proof(old_size, lv)
|
|
251
|
+
return {"oldSize": old_size, "newSize": len(lv), "oldRoot": merkle.root(lv[:old_size]).hex(),
|
|
252
|
+
"newRoot": merkle.root(lv).hex(), "proof": [p.hex() for p in proof]}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def inclusion_for(conn, wanted_seqs: list[int], snapshot: tuple[list[int], list[bytes]] | None = None
|
|
256
|
+
) -> tuple[dict, dict[int, dict]]:
|
|
257
|
+
"""A fresh unsigned tree view plus, for each wanted seq, its leaf index and inclusion proof.
|
|
258
|
+
|
|
259
|
+
Unsigned: the caller signs the checkpoint (or uses one already issued at this size) so the
|
|
260
|
+
tree the proofs are against is the tree the signature covers. Pass `snapshot` (from `tree()`)
|
|
261
|
+
to build the proofs and the checkpoint from one read of the log.
|
|
262
|
+
"""
|
|
263
|
+
all_seqs, lv = snapshot if snapshot is not None else tree(conn)
|
|
264
|
+
index_of = {s: i for i, s in enumerate(all_seqs)}
|
|
265
|
+
root = merkle.root(lv)
|
|
266
|
+
out: dict[int, dict] = {}
|
|
267
|
+
for s in wanted_seqs:
|
|
268
|
+
i = index_of.get(s)
|
|
269
|
+
if i is None:
|
|
270
|
+
continue # not in the live log (sealed into the archive): no place in this tree
|
|
271
|
+
out[s] = {"leafIndex": i, "proof": [p.hex() for p in merkle.inclusion_proof(i, lv)]}
|
|
272
|
+
return {"size": len(lv), "root": root.hex()}, out
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# --------------------------------------------------------------------------------------------
|
|
276
|
+
# Cosignatures (https://c2sp.org/tlog-cosignature)
|
|
277
|
+
#
|
|
278
|
+
# A cosignature is a witness's statement: "as of time T, the largest consistent tree I have seen
|
|
279
|
+
# for this log has this size and root." It is a signature over
|
|
280
|
+
#
|
|
281
|
+
# cosignature/v1
|
|
282
|
+
# time <unix seconds>
|
|
283
|
+
# <the checkpoint body>
|
|
284
|
+
#
|
|
285
|
+
# carried as a signed-note line whose blob is key hash[4] ‖ time (8 bytes, big-endian) ‖ Ed25519
|
|
286
|
+
# signature. Verifiers ignore signature lines from names they do not know, so a log signature and
|
|
287
|
+
# any number of cosignatures share one note.
|
|
288
|
+
|
|
289
|
+
COSIG_PREFIX = "cosignature/v1\n"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cosign_note(witness: Key, note: str, at: float | None = None) -> str:
|
|
293
|
+
"""Append this witness's cosignature line to a checkpoint note."""
|
|
294
|
+
parsed = parse_note(note)
|
|
295
|
+
body = note_body(parsed["origin"], parsed["size"], parsed["root"])
|
|
296
|
+
t = int(time.time() if at is None else at)
|
|
297
|
+
msg = f"{COSIG_PREFIX}time {t}\n{body}".encode()
|
|
298
|
+
sig = ed25519.fast_sign(witness.seed, msg)
|
|
299
|
+
blob = witness.cosig_key_hash + t.to_bytes(8, "big") + sig
|
|
300
|
+
line = f"— {witness.origin} {base64.b64encode(blob).decode()}"
|
|
301
|
+
return note.rstrip("\n") + "\n" + line + "\n"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def verify_cosignature(note: str, witness_public: bytes, witness_origin: str) -> int | None:
|
|
305
|
+
"""The cosignature's timestamp when `witness_origin` cosigned this note, else None."""
|
|
306
|
+
parsed = parse_note(note)
|
|
307
|
+
body = note_body(parsed["origin"], parsed["size"], parsed["root"])
|
|
308
|
+
want = hashlib.sha256(witness_origin.encode() + b"\n\x04" + witness_public).digest()[:4].hex()
|
|
309
|
+
for name, kh, rest in parsed["signatures"]:
|
|
310
|
+
if name != witness_origin or kh != want or len(rest) != 72:
|
|
311
|
+
continue
|
|
312
|
+
t = int.from_bytes(rest[:8], "big")
|
|
313
|
+
msg = f"{COSIG_PREFIX}time {t}\n{body}".encode()
|
|
314
|
+
if ed25519.fast_verify(witness_public, msg, rest[8:]):
|
|
315
|
+
return t
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def witness_states(conn) -> list[dict]:
|
|
320
|
+
"""What each configured witness last cosigned."""
|
|
321
|
+
rows = conn.execute("SELECT * FROM witness_states ORDER BY url").fetchall()
|
|
322
|
+
return [{"url": r["url"], "witness": r["witness"], "size": r["size"], "root": r["root"],
|
|
323
|
+
"cosignedNote": r["cosigned_note"], "at": r["at"]} for r in rows]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def witness_state_for(conn, url: str) -> dict | None:
|
|
327
|
+
r = conn.execute("SELECT * FROM witness_states WHERE url = ?", (url,)).fetchone()
|
|
328
|
+
return ({"url": r["url"], "witness": r["witness"], "size": r["size"], "root": r["root"],
|
|
329
|
+
"cosignedNote": r["cosigned_note"], "at": r["at"]} if r else None)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def merge_notes(notes: list[str]) -> str:
|
|
333
|
+
"""One note carrying every distinct signature line of several notes over the same body."""
|
|
334
|
+
if not notes:
|
|
335
|
+
return ""
|
|
336
|
+
body, _, _ = notes[0].partition("\n\n")
|
|
337
|
+
lines: list[str] = []
|
|
338
|
+
for n in notes:
|
|
339
|
+
b, _, sigs = n.partition("\n\n")
|
|
340
|
+
if b != body:
|
|
341
|
+
continue
|
|
342
|
+
for line in sigs.splitlines():
|
|
343
|
+
if line.startswith("— ") and line not in lines:
|
|
344
|
+
lines.append(line)
|
|
345
|
+
return body + "\n\n" + "\n".join(lines) + "\n"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def witness_state(conn) -> dict | None:
|
|
349
|
+
"""The witnessed view at the largest size any witness has cosigned: the merged note of every
|
|
350
|
+
witness at that exact tree, with their origins. None until a witness has cosigned anything."""
|
|
351
|
+
states = witness_states(conn)
|
|
352
|
+
if not states:
|
|
353
|
+
return None
|
|
354
|
+
# A fork: two witnesses cosigned different roots at the same size. That is the split-view
|
|
355
|
+
# event witnesses exist to expose; it is surfaced, never merged or ranked away.
|
|
356
|
+
by_size: dict[int, set[str]] = {}
|
|
357
|
+
for st in states:
|
|
358
|
+
by_size.setdefault(st["size"], set()).add(st["root"])
|
|
359
|
+
forks = {n: roots for n, roots in by_size.items() if len(roots) > 1}
|
|
360
|
+
if forks:
|
|
361
|
+
n = max(forks)
|
|
362
|
+
return {"fork": True, "size": n, "roots": sorted(forks[n]), "cosignedNote": "",
|
|
363
|
+
"witness": "", "witnesses": [], "at": max(st["at"] for st in states),
|
|
364
|
+
"detail": [{"witness": st["witness"], "url": st["url"], "size": st["size"], "root": st["root"]}
|
|
365
|
+
for st in states if st["size"] == n],
|
|
366
|
+
"behind": []}
|
|
367
|
+
top = max(states, key=lambda st: (st["size"], st["at"]))
|
|
368
|
+
at_top = [st for st in states if st["size"] == top["size"] and st["root"] == top["root"]]
|
|
369
|
+
return {"size": top["size"], "root": top["root"], "cosignedNote": merge_notes([st["cosignedNote"] for st in at_top]),
|
|
370
|
+
"witness": ", ".join(st["witness"] for st in at_top), "witnesses": [st["witness"] for st in at_top],
|
|
371
|
+
"at": max(st["at"] for st in at_top),
|
|
372
|
+
"behind": [{"witness": st["witness"], "url": st["url"], "size": st["size"]}
|
|
373
|
+
for st in states if st not in at_top]}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def save_witness_state(conn, *, size: int, root: str, cosigned_note: str, witness: str, url: str = "control") -> None:
|
|
377
|
+
conn.execute("INSERT INTO witness_states (url, witness, size, root, cosigned_note, at) VALUES (?, ?, ?, ?, ?, ?) "
|
|
378
|
+
"ON CONFLICT (url) DO UPDATE SET witness = excluded.witness, size = excluded.size, "
|
|
379
|
+
"root = excluded.root, cosigned_note = excluded.cosigned_note, at = excluded.at",
|
|
380
|
+
(url, witness, size, root, cosigned_note, time.time()))
|
|
381
|
+
conn.commit()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# --------------------------------------------------------------------------------------------
|
|
385
|
+
# External clocks
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def imprint(cp: dict) -> bytes:
|
|
389
|
+
"""What a time-stamping authority signs over: SHA-256 of the checkpoint body. The TSA sees
|
|
390
|
+
only this digest, never the note — nothing about the log leaves the plane."""
|
|
391
|
+
body = note_body(parse_note(cp["note"])["origin"], cp["size"], bytes.fromhex(cp["root"]))
|
|
392
|
+
return hashlib.sha256(body.encode()).digest()
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def timestamps_for(conn, size: int, root: str) -> list[dict]:
|
|
396
|
+
rows = conn.execute("SELECT * FROM timestamps WHERE size = ? AND root = ? ORDER BY id", (size, root)).fetchall()
|
|
397
|
+
return [{"kind": r["kind"], "token": r["token"], "at": r["at"], **json.loads(r["meta_json"] or "{}")}
|
|
398
|
+
for r in rows]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def anchor(conn, cp: dict) -> list[dict]:
|
|
402
|
+
"""Ask every configured external clock to stamp this checkpoint; store what comes back.
|
|
403
|
+
|
|
404
|
+
Idempotent per (size, root): a checkpoint re-issued at the same tree is not re-stamped.
|
|
405
|
+
Failures are silent here (the plane logs them); a bundle says which clocks it carries.
|
|
406
|
+
"""
|
|
407
|
+
from . import tsa
|
|
408
|
+
|
|
409
|
+
have = {t["kind"] for t in timestamps_for(conn, cp["size"], cp["root"])}
|
|
410
|
+
out = []
|
|
411
|
+
digest = imprint(cp)
|
|
412
|
+
if "rfc3161" not in have:
|
|
413
|
+
tok = tsa.stamp(digest)
|
|
414
|
+
if tok:
|
|
415
|
+
meta = {k: tok.get(k) for k in ("tsa", "genTime", "serial", "imprint", "status", "hashAlg")}
|
|
416
|
+
conn.execute("INSERT INTO timestamps (size, root, kind, token, meta_json, at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
417
|
+
(cp["size"], cp["root"], "rfc3161", tok["token"], json.dumps(meta), time.time()))
|
|
418
|
+
out.append({"kind": "rfc3161", **meta})
|
|
419
|
+
if "ots" not in have:
|
|
420
|
+
proof = tsa.ots_submit(digest)
|
|
421
|
+
if proof:
|
|
422
|
+
meta = {k: proof[k] for k in ("calendar", "status", "submittedAt", "note")}
|
|
423
|
+
conn.execute("INSERT INTO timestamps (size, root, kind, token, meta_json, at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
424
|
+
(cp["size"], cp["root"], "ots", proof["proof"], json.dumps(meta), time.time()))
|
|
425
|
+
out.append({"kind": "ots", **meta})
|
|
426
|
+
if out:
|
|
427
|
+
conn.commit()
|
|
428
|
+
return out
|
underwrit/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Entry points.
|
|
2
|
+
|
|
3
|
+
python3 -m underwrit serve [port] # data plane :8787
|
|
4
|
+
python3 -m underwrit control [port] # control plane :8788
|
|
5
|
+
python3 -m underwrit demo # drive sessions through a running data plane
|
|
6
|
+
python3 -m underwrit demo --live # the incident scenario, live, waiting on the console
|
|
7
|
+
python3 -m underwrit demo-stack # both planes + console + seeded data + sign-in links
|
|
8
|
+
python3 -m underwrit migrate # apply schema migrations and print the versions
|
|
9
|
+
python3 -m underwrit verify [db] # whole-chain verdict for a database file
|
|
10
|
+
python3 -m underwrit backup <dest> # online copy of the data plane database, verified
|
|
11
|
+
python3 -m underwrit restore <src> [--force] # put a backup in place; ends in verify_chain()
|
|
12
|
+
python3 -m underwrit sweep [--dry-run] # run a retention sweep now
|
|
13
|
+
python3 -m underwrit mcp # serve the data plane as MCP tools over stdio
|
|
14
|
+
python3 -m underwrit policy-diff cand.json # the shadow report for a candidate policy, from the CLI
|
|
15
|
+
python3 -m underwrit break-glass --plane data|control --reason "..." [--role admin] [--ttl 3600]
|
|
16
|
+
|
|
17
|
+
Environment: UNDERWRIT_DB, UNDERWRIT_CONTROL_DB, UNDERWRIT_CONTROL_URL, UNDERWRIT_NODE_TOKEN[_FILE], UNDERWRIT_HOST,
|
|
18
|
+
UNDERWRIT_RETENTION_DAYS, UNDERWRIT_RETENTION, UNDERWRIT_RATE_LIMIT_PER_MINUTE, UNDERWRIT_TOKEN_TTL_SECONDS.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _out(obj) -> None:
|
|
28
|
+
print(json.dumps(obj, indent=2, sort_keys=True, default=str), flush=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _enterprise(cmd: str, rest: list, port) -> int:
|
|
32
|
+
"""Hand a command to Underwrit Control Plane when it is installed; say so plainly when it is not."""
|
|
33
|
+
try:
|
|
34
|
+
from underwrit_controlplane import cli as ecli
|
|
35
|
+
except ImportError:
|
|
36
|
+
print(f"`underwrit {cmd}` needs Underwrit Control Plane (the control plane, console and stack), which is "
|
|
37
|
+
"licensed commercially and distributed as container images, not on PyPI: see https://underwrit.io/#pricing. "
|
|
38
|
+
"The data plane runs alone with `underwrit serve`.", file=sys.stderr)
|
|
39
|
+
return 2
|
|
40
|
+
return ecli.main([cmd, *([str(port)] if port else []), *rest])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv=None) -> int:
|
|
44
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
45
|
+
cmd = argv[0] if argv else "serve"
|
|
46
|
+
rest = argv[1:]
|
|
47
|
+
port = int(rest[0]) if rest and rest[0].isdigit() else None
|
|
48
|
+
|
|
49
|
+
if cmd == "serve":
|
|
50
|
+
from . import api
|
|
51
|
+
api.serve(port=port or 8787)
|
|
52
|
+
elif cmd == "control":
|
|
53
|
+
return _enterprise("control", rest, port)
|
|
54
|
+
elif cmd == "demo":
|
|
55
|
+
from .demo import run
|
|
56
|
+
return run()
|
|
57
|
+
elif cmd == "demo-stack":
|
|
58
|
+
return _enterprise("demo-stack", rest, port)
|
|
59
|
+
elif cmd == "mcp":
|
|
60
|
+
from . import mcp
|
|
61
|
+
return mcp.main()
|
|
62
|
+
elif cmd == "migrate":
|
|
63
|
+
from . import api, db, migrate
|
|
64
|
+
c = db.connect(api.DB_PATH)
|
|
65
|
+
_out({"dataPlane": {"db": api.DB_PATH, "applied": migrate.data_plane(c),
|
|
66
|
+
"version": migrate.current(c, "data-plane")}})
|
|
67
|
+
try:
|
|
68
|
+
from underwrit_controlplane import control # the commercial control plane, when installed
|
|
69
|
+
except ImportError:
|
|
70
|
+
control = None
|
|
71
|
+
if control is not None:
|
|
72
|
+
cc = db.connect(control.DB_PATH)
|
|
73
|
+
_out({"controlPlane": {"db": control.DB_PATH, "applied": control.migrate_db(cc),
|
|
74
|
+
"version": migrate.current(cc, "control-plane")}})
|
|
75
|
+
elif cmd == "verify":
|
|
76
|
+
from . import api, backup
|
|
77
|
+
r = backup.verify(rest[0] if rest else api.DB_PATH)
|
|
78
|
+
_out(r)
|
|
79
|
+
return 0 if r.get("ok") else 1
|
|
80
|
+
elif cmd == "backup":
|
|
81
|
+
if not rest:
|
|
82
|
+
print("usage: python3 -m underwrit backup <dest>", file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
from . import api, backup
|
|
85
|
+
r = backup.backup(api.DB_PATH, rest[0])
|
|
86
|
+
_out(r)
|
|
87
|
+
return 0 if r["chain"].get("ok") is not False else 1
|
|
88
|
+
elif cmd == "restore":
|
|
89
|
+
if not rest:
|
|
90
|
+
print("usage: python3 -m underwrit restore <src> [--force]", file=sys.stderr)
|
|
91
|
+
return 2
|
|
92
|
+
from . import api, backup
|
|
93
|
+
r = backup.restore(rest[0], api.DB_PATH, force="--force" in rest)
|
|
94
|
+
_out(r)
|
|
95
|
+
return 0 if r["chain"].get("ok") else 1
|
|
96
|
+
elif cmd == "break-glass":
|
|
97
|
+
import getpass
|
|
98
|
+
opts = {rest[i].lstrip("-"): rest[i + 1] for i in range(0, len(rest) - 1, 2) if rest[i].startswith("--")}
|
|
99
|
+
plane = opts.get("plane", "data")
|
|
100
|
+
reason = opts.get("reason", "")
|
|
101
|
+
if not reason:
|
|
102
|
+
print("break glass needs --reason; it goes in the record", file=sys.stderr)
|
|
103
|
+
return 2
|
|
104
|
+
from . import auth, chain, db
|
|
105
|
+
who = opts.get("by") or getpass.getuser()
|
|
106
|
+
ttl = float(opts.get("ttl", "3600"))
|
|
107
|
+
role = opts.get("role", "admin")
|
|
108
|
+
if plane == "control":
|
|
109
|
+
try:
|
|
110
|
+
from underwrit_controlplane import control
|
|
111
|
+
except ImportError:
|
|
112
|
+
print("the control plane is part of Underwrit Control Plane, licensed commercially and shipped as container images, not on PyPI", file=sys.stderr)
|
|
113
|
+
return 2
|
|
114
|
+
token, meta = control.mint_break_glass(reason=reason, by=who, role=role, ttl=ttl, tenant=opts.get("tenant", ""))
|
|
115
|
+
else:
|
|
116
|
+
from . import api
|
|
117
|
+
c = api.conn()
|
|
118
|
+
token, meta = auth.break_glass(c, plane="data", reason=reason, by=who, role=role, ttl=ttl)
|
|
119
|
+
chain.record(c, actor=meta["subject"], actor_id=meta["id"], action="token.break_glass",
|
|
120
|
+
outcome="allowed", environment="", target=meta["subject"], reason=reason,
|
|
121
|
+
detail={"role": role, "ttlSeconds": ttl, "plane": "data"}, source_ip="")
|
|
122
|
+
print(f"BREAK GLASS — {plane} plane, role {role}, {int(ttl // 60)} minutes, taken by {who}")
|
|
123
|
+
print(f" reason: {reason}")
|
|
124
|
+
print(f" every request made with this credential is recorded by name.")
|
|
125
|
+
print(f" {token}")
|
|
126
|
+
print(f" revoke early: POST /v1/tokens/{meta['id']}/revoke")
|
|
127
|
+
elif cmd == "policy-diff":
|
|
128
|
+
if not rest:
|
|
129
|
+
print("usage: python3 -m underwrit policy-diff <candidate.json>", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
from . import api, policy as policy_mod, replay
|
|
132
|
+
current, _ = api.current_policy()
|
|
133
|
+
doc = json.loads(open(rest[0]).read())
|
|
134
|
+
candidate = policy_mod.Policy.from_json({**current.to_json(), **doc})
|
|
135
|
+
r = replay.replay(api.conn(), candidate, attribute_changes=True, current=current)
|
|
136
|
+
print(f" {r['decisions']} decisions in {r['sessions']} sessions re-judged")
|
|
137
|
+
print(f" callers stopped: {r['interrupts']['was']} -> {r['interrupts']['would']}")
|
|
138
|
+
for k, v in (r.get("attribution") or {}).items():
|
|
139
|
+
print(f" {k:<22} moved {v['changed']:>4} stopped {v['interruptsWas']} -> {v['interruptsWould']}")
|
|
140
|
+
for c in r["changed"][:50]:
|
|
141
|
+
print(f" {c['sessionId']:<18} {c['tool']:<28} {c['was']:<15} -> {c['would']:<15} {c['reasons'][0][:70] if c['reasons'] else ''}")
|
|
142
|
+
if len(r["changed"]) > 50:
|
|
143
|
+
print(f" … and {len(r['changed']) - 50} more")
|
|
144
|
+
return 0 if not r["changed"] else 3
|
|
145
|
+
elif cmd == "sweep":
|
|
146
|
+
from . import api, retention
|
|
147
|
+
r = retention.sweep(api.conn(), actor="operator:cli", dry_run="--dry-run" in rest)
|
|
148
|
+
_out(r)
|
|
149
|
+
elif cmd in ("version", "--version"):
|
|
150
|
+
from . import __version__
|
|
151
|
+
print(__version__)
|
|
152
|
+
else:
|
|
153
|
+
print(__doc__)
|
|
154
|
+
return 2
|
|
155
|
+
return 0
|