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/auth.py ADDED
@@ -0,0 +1,348 @@
1
+ """Who is calling, and what they may do.
2
+
3
+ The rule this module exists for: **an approver's identity comes from the token, never from the
4
+ request body.** Before this, `/v1/decisions/:id/resolve` took `{"approver": "priya"}` and believed
5
+ it — so anybody who could reach the port could approve anything under any name, and the audit trail
6
+ would record the name they chose. A record of who approved something is worthless if the subject of
7
+ the record supplies it.
8
+
9
+ Five roles, and the separation between approver and admin is the point:
10
+
11
+ | role | may |
12
+ |------------|------------------------------------------------------------|
13
+ | `agent` | open sessions, propose actions, report outcomes |
14
+ | `approver` | resolve held decisions — and nothing else |
15
+ | `admin` | distribute policy, mint and revoke tokens, read everything |
16
+ | `auditor` | read sessions and evidence; change nothing |
17
+ | `node` | a data plane authenticating upward to the control plane |
18
+
19
+ An `agent` cannot resolve. That is the whole shape of the control: a runtime that could approve its
20
+ own held decision has an approval gate it can open, which is the thing the product sells against.
21
+ And an `admin` cannot resolve either — the role that mints approver tokens must not also approve
22
+ with them.
23
+
24
+ **Production needs two distinct people.** Counted as people, not as calls: approving twice from two
25
+ tokens held by the same subject advances nothing. Quorum applies where enforcement is on, because a
26
+ shadow-mode hold blocks nothing and demanding a quorum to acknowledge it would teach people to click
27
+ through both.
28
+
29
+ Tokens are stored as SHA-256, never in the clear. The database is backed up, copied to a laptop, and
30
+ read by whoever has the file; a token in it is a credential leaked to all of them. A token carries a
31
+ tenant (empty on a data plane, which is single-tenant by construction) and may carry an expiry;
32
+ rotation mints a successor and revokes the predecessor in one step, and the successor records which
33
+ token it replaced so the lineage is in the record.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import hashlib
39
+ import hmac
40
+ import os
41
+ import secrets
42
+ import time
43
+
44
+ from . import db, oidc
45
+
46
+ ROLES = ("agent", "approver", "admin", "auditor", "node")
47
+
48
+ # What each role may do. Deny by default: an action absent here is refused for everyone, so adding an
49
+ # endpoint without deciding who may call it fails closed rather than open.
50
+ GRANTS: dict[str, frozenset[str]] = {
51
+ "agent": frozenset({"session.open", "session.close", "decide", "outcome", "decision.read",
52
+ "evidence.read", "whoami"}),
53
+ "approver": frozenset({"decision.resolve", "decision.read", "read", "evidence.read", "whoami"}),
54
+ "admin": frozenset({"policy.write", "policy.read", "token.mint", "token.revoke", "token.read",
55
+ "read", "decision.read", "evidence.read", "retention.read",
56
+ "retention.write", "hold.write", "tenant.create", "tenant.read", "metrics",
57
+ "whoami"}),
58
+ "auditor": frozenset({"read", "decision.read", "evidence.read", "policy.read", "retention.read",
59
+ "metrics", "whoami"}),
60
+ # A data plane authenticating *upward* to the control plane. It may fetch the policy it is to
61
+ # enforce and report what it decided, and nothing else — a node credential lives on a customer's
62
+ # infrastructure, so it must not be able to rewrite the policy the rest of the fleet receives.
63
+ "node": frozenset({"policy.read", "report", "whoami"}),
64
+ }
65
+ # `read` is the console's floor; `evidence.read` is separate because a pack is the thing you hand to
66
+ # an outsider, and being able to watch decisions go by is not the same as being able to export them.
67
+
68
+ TWO_PERSON = frozenset({"decision.resolve"})
69
+
70
+ SCHEMA = [
71
+ """CREATE TABLE IF NOT EXISTS tokens (
72
+ id TEXT PRIMARY KEY,
73
+ digest TEXT NOT NULL UNIQUE,
74
+ subject TEXT NOT NULL,
75
+ role TEXT NOT NULL,
76
+ tenant TEXT NOT NULL DEFAULT '',
77
+ created_at REAL NOT NULL,
78
+ expires_at REAL,
79
+ revoked_at REAL,
80
+ rotated_from TEXT NOT NULL DEFAULT ''
81
+ )""",
82
+ "CREATE INDEX IF NOT EXISTS tokens_digest ON tokens (digest)",
83
+ """CREATE TABLE IF NOT EXISTS approvals (
84
+ decision_id TEXT NOT NULL,
85
+ subject TEXT NOT NULL,
86
+ verdict TEXT NOT NULL,
87
+ reason TEXT NOT NULL DEFAULT '',
88
+ at REAL NOT NULL,
89
+ PRIMARY KEY (decision_id, subject)
90
+ )""",
91
+ ]
92
+
93
+ # Columns added after the table first shipped. Applied when absent, so a database created before
94
+ # they existed gains them without a rebuild.
95
+ _ADDED_COLUMNS = (
96
+ ("tenant", "TEXT NOT NULL DEFAULT ''"),
97
+ ("expires_at", "REAL"),
98
+ ("rotated_from", "TEXT NOT NULL DEFAULT ''"),
99
+ # Break glass: an emergency credential minted from the host, never over the API. Every request
100
+ # it makes is chained by name, because emergency access that leaves no trace is the incident.
101
+ ("break_glass", "INTEGER NOT NULL DEFAULT 0"),
102
+ ("note", "TEXT NOT NULL DEFAULT ''"),
103
+ )
104
+
105
+
106
+ def init(conn) -> None:
107
+ for ddl in SCHEMA:
108
+ conn.execute(db.ddl(ddl))
109
+ have = db.table_columns(conn, "tokens")
110
+ for name, ddl in _ADDED_COLUMNS:
111
+ if name not in have:
112
+ conn.execute(db.ddl(f"ALTER TABLE tokens ADD COLUMN {name} {ddl}"))
113
+ conn.commit()
114
+
115
+
116
+ def default_ttl() -> float | None:
117
+ """Seconds a newly minted token lives unless the caller says otherwise. Unset means no expiry."""
118
+ raw = os.environ.get("UNDERWRIT_TOKEN_TTL_SECONDS", "").strip()
119
+ return float(raw) if raw else None
120
+
121
+
122
+ def _digest(token: str) -> str:
123
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
124
+
125
+
126
+ def _meta(r) -> dict:
127
+ keys = r.keys()
128
+ return {"id": r["id"], "subject": r["subject"], "role": r["role"],
129
+ "tenant": r["tenant"] or "", "createdAt": r["created_at"],
130
+ "expiresAt": r["expires_at"], "revokedAt": r["revoked_at"],
131
+ "rotatedFrom": r["rotated_from"] or "",
132
+ "breakGlass": bool(r["break_glass"]) if "break_glass" in keys else False,
133
+ "note": (r["note"] or "") if "note" in keys else ""}
134
+
135
+
136
+ def mint(conn, *, subject: str, role: str, tenant: str = "", ttl: float | None = None,
137
+ rotated_from: str = "", break_glass: bool = False, note: str = "") -> tuple[str, dict]:
138
+ """Create a token. Returned once, in the clear, and never recoverable afterwards.
139
+
140
+ `ttl` in seconds; `None` takes the environment default (`UNDERWRIT_TOKEN_TTL_SECONDS`), and `0`
141
+ means no expiry — which is what a bootstrap admin gets, because a first credential that expires
142
+ out from under the operator is a locked door with the key inside.
143
+ """
144
+ if role not in ROLES:
145
+ raise ValueError(f"unknown role {role!r}; expected one of {', '.join(ROLES)}")
146
+ if not subject:
147
+ raise ValueError("subject is required — an approval attributed to nobody is not a record")
148
+ ttl = default_ttl() if ttl is None else ttl
149
+ if ttl is not None and ttl < 0:
150
+ raise ValueError("ttl must be positive, or 0 for no expiry")
151
+ now = time.time()
152
+ token = f"underwrit_{'bgl' if break_glass else role[:3]}_{secrets.token_urlsafe(32)}"
153
+ tid = f"tok-{secrets.token_hex(6)}"
154
+ conn.execute(
155
+ "INSERT INTO tokens (id, digest, subject, role, tenant, created_at, expires_at, rotated_from, "
156
+ "break_glass, note) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
157
+ (tid, _digest(token), subject, role, tenant or "", now,
158
+ (now + ttl) if ttl else None, rotated_from or "", 1 if break_glass else 0, note or ""),
159
+ )
160
+ conn.commit()
161
+ return token, {"id": tid, "subject": subject, "role": role, "tenant": tenant or "",
162
+ "expiresAt": (now + ttl) if ttl else None, "rotatedFrom": rotated_from or "",
163
+ "breakGlass": bool(break_glass), "note": note or ""}
164
+
165
+
166
+ def revoke(conn, token_id: str) -> bool:
167
+ cur = conn.execute("UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
168
+ (time.time(), token_id))
169
+ conn.commit()
170
+ return bool(getattr(cur, "rowcount", 0))
171
+
172
+
173
+ def get(conn, token_id: str) -> dict | None:
174
+ r = conn.execute("SELECT * FROM tokens WHERE id = ?", (token_id,)).fetchone()
175
+ return _meta(r) if r else None
176
+
177
+
178
+ def rotate(conn, token_id: str, *, ttl: float | None = None) -> tuple[str, dict] | None:
179
+ """Mint a successor with the same subject, role and tenant, and revoke the predecessor.
180
+
181
+ One step, so there is never a moment with two live tokens for one purpose — and the successor
182
+ names the token it replaced, which is how an auditor follows a credential through its life.
183
+ """
184
+ r = conn.execute("SELECT * FROM tokens WHERE id = ? AND revoked_at IS NULL", (token_id,)).fetchone()
185
+ if not r:
186
+ return None
187
+ if "break_glass" in r.keys() and r["break_glass"]:
188
+ # An emergency credential is short-lived by construction; a successor without the flag and
189
+ # without the clock would turn a break-glass event into standing access.
190
+ raise ValueError("break-glass tokens cannot be rotated; issue a new one from the host if still needed")
191
+ remaining = (float(r["expires_at"]) - time.time()) if r["expires_at"] else None
192
+ if remaining is not None:
193
+ if remaining <= 0:
194
+ raise ValueError("token has expired; mint a new one")
195
+ # A successor never outlives what the predecessor had left, whatever TTL was asked for.
196
+ ttl = remaining if ttl is None or ttl <= 0 or ttl > remaining else ttl
197
+ token, meta = mint(conn, subject=r["subject"], role=r["role"], tenant=r["tenant"] or "",
198
+ ttl=ttl, rotated_from=token_id)
199
+ revoke(conn, token_id)
200
+ return token, meta
201
+
202
+
203
+ def list_tokens(conn, *, tenant: str | None = None, include_revoked: bool = False) -> list[dict]:
204
+ """Every token's metadata. Never the token, never the digest — neither has a reader."""
205
+ sql = "SELECT * FROM tokens WHERE 1=1"
206
+ params: list[object] = []
207
+ if tenant is not None:
208
+ sql += " AND tenant = ?"
209
+ params.append(tenant)
210
+ if not include_revoked:
211
+ sql += " AND revoked_at IS NULL"
212
+ sql += " ORDER BY created_at DESC"
213
+ now = time.time()
214
+ out = []
215
+ for r in conn.execute(sql, params).fetchall():
216
+ m = _meta(r)
217
+ m["expired"] = bool(m["expiresAt"] and m["expiresAt"] <= now)
218
+ out.append(m)
219
+ return out
220
+
221
+
222
+ def identify(conn, header: str | None, *, plane: str = "data") -> dict | None:
223
+ """Resolve a bearer to an identity. `None` means unauthenticated, never a default one.
224
+
225
+ An Underwrit token first; otherwise, when an identity provider is configured, a JWT from it. The
226
+ identity dict is the same shape either way, so nothing downstream knows or cares where a
227
+ person signed in — the gates, the quorum and the chain treat both alike.
228
+ """
229
+ if not header:
230
+ return None
231
+ raw = header[7:].strip() if header.lower().startswith("bearer ") else header.strip()
232
+ if not raw:
233
+ return None
234
+ if not raw.startswith("underwrit_"):
235
+ return oidc.identify(raw, plane=plane)
236
+ want = _digest(raw)
237
+ now = time.time()
238
+ rows = conn.execute(
239
+ "SELECT id, digest, subject, role, tenant, expires_at, break_glass FROM tokens "
240
+ "WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)", (now,)
241
+ ).fetchall()
242
+ for r in rows:
243
+ # Constant-time compare against every candidate rather than a lookup by digest. A SELECT on
244
+ # the digest is fine cryptographically, but this keeps the comparison itself uniform and the
245
+ # table is small; if it ever is not, index it and compare the single row the same way.
246
+ if hmac.compare_digest(r["digest"], want):
247
+ return {"tokenId": r["id"], "subject": r["subject"], "role": r["role"],
248
+ "tenant": r["tenant"] or "", "expiresAt": r["expires_at"],
249
+ "breakGlass": bool(r["break_glass"]), "via": "break-glass" if r["break_glass"] else "token"}
250
+ return None
251
+
252
+
253
+ def break_glass(conn, *, plane: str, reason: str, by: str, role: str = "admin", ttl: float = 3600,
254
+ tenant: str = "") -> tuple[str, dict]:
255
+ """The emergency credential. Minted from the host, never over the API.
256
+
257
+ For when the identity provider is down, the last admin token is lost, or an approver is
258
+ needed at three in the morning and nobody with the role can be reached. It is short-lived,
259
+ named for who took it and why, and every request it makes is chained as `break-glass.<action>`
260
+ — so the trail of an emergency is the fullest trail in the log, not the thinnest.
261
+ """
262
+ if not reason.strip():
263
+ raise ValueError("break glass needs a reason; it goes in the record")
264
+ if role not in ("admin", "approver", "auditor"):
265
+ raise ValueError("break glass mints admin, approver or auditor")
266
+ subject = f"break-glass:{by or 'operator'}"
267
+ token, meta = mint(conn, subject=subject, role=role, tenant=tenant, ttl=ttl, break_glass=True,
268
+ note=f"{plane}: {reason.strip()}")
269
+ return token, meta
270
+
271
+
272
+ class Denied(Exception):
273
+ """Refused, with the reason a caller can act on.
274
+
275
+ 401 and 403 are kept apart deliberately. They need opposite responses: an unknown token means
276
+ sign in again, a known token without the grant means ask somebody for access. Telling a person
277
+ they lack a permission they actually hold sends them somewhere they cannot fix it.
278
+ """
279
+
280
+ def __init__(self, status: int, reason: str):
281
+ super().__init__(reason)
282
+ self.status = status
283
+ self.reason = reason
284
+
285
+
286
+ def require(identity: dict | None, action: str) -> dict:
287
+ if identity is None:
288
+ raise Denied(401, "no valid token")
289
+ grants = GRANTS.get(identity["role"], frozenset())
290
+ if action not in grants:
291
+ raise Denied(403, f"role {identity['role']!r} cannot {action}")
292
+ return identity
293
+
294
+
295
+ # --------------------------------------------------------------------------------------------
296
+ # Quorum
297
+
298
+
299
+ def record_approval(conn, *, decision_id: str, subject: str, verdict: str, reason: str) -> None:
300
+ # ON CONFLICT DO NOTHING, not INSERT OR IGNORE: the latter is SQLite-only and this runs on
301
+ # Postgres too, where it is a syntax error — and the ledger silently failing to record is how a
302
+ # two-person rule quietly becomes a one-person rule.
303
+ conn.execute(
304
+ "INSERT INTO approvals (decision_id, subject, verdict, reason, at) VALUES (?, ?, ?, ?, ?) "
305
+ "ON CONFLICT (decision_id, subject) DO NOTHING",
306
+ (decision_id, subject, verdict, reason, time.time()),
307
+ )
308
+ conn.commit()
309
+
310
+
311
+ def approvals_for(conn, decision_id: str) -> list[dict]:
312
+ rows = conn.execute(
313
+ "SELECT subject, verdict, reason, at FROM approvals WHERE decision_id = ? ORDER BY at ASC",
314
+ (decision_id,),
315
+ ).fetchall()
316
+ return [{"subject": r["subject"], "verdict": r["verdict"], "reason": r["reason"], "at": r["at"]}
317
+ for r in rows]
318
+
319
+
320
+ def needs_two(environment: str, enforcing: bool) -> bool:
321
+ """Production, and only where enforcement is actually on.
322
+
323
+ A shadow-mode hold blocks nothing. Requiring a second person to acknowledge one would train
324
+ everybody to click through both, which costs the quorum its meaning by the time it matters.
325
+ """
326
+ return enforcing and environment == "production"
327
+
328
+
329
+ def quorum(conn, *, decision_id: str, environment: str, enforcing: bool) -> dict:
330
+ """Whether enough distinct people have answered, and who."""
331
+ got = approvals_for(conn, decision_id)
332
+ needed = 2 if needs_two(environment, enforcing) else 1
333
+ # Distinct subjects. Two tokens held by one person are one person; the ledger is keyed on
334
+ # subject precisely so that a second token cannot become a second approver.
335
+ people = []
336
+ for a in got:
337
+ if a["subject"] not in people:
338
+ people.append(a["subject"])
339
+ denied = [a for a in got if a["verdict"] == "deny"]
340
+ return {
341
+ "needed": needed,
342
+ "have": len(people),
343
+ "met": bool(denied) or len(people) >= needed,
344
+ "approvers": people,
345
+ # One refusal settles it. Waiting for a second person to agree that something should not
346
+ # happen would be an odd kind of safety.
347
+ "outcome": "deny" if denied else ("allow" if len(people) >= needed else "pending"),
348
+ }
underwrit/backup.py ADDED
@@ -0,0 +1,125 @@
1
+ """Backups that end in `verify_chain()`, and restores that refuse to overwrite quietly.
2
+
3
+ A backup of an audit chain that is not verified is a file. Verified, it is evidence: the copy has
4
+ the same hashes as the live log had at the moment of copying, and a restore that verifies clean is
5
+ a restore of the record rather than of a database that happens to contain one.
6
+
7
+ SQLite only, through the online backup API — a file copy of a database with a writer open is a
8
+ corrupt database with the right name. On Postgres use `pg_dump`, then run `python3 -m underwrit verify`
9
+ against the restored database; the verdict is the same function either way.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import shutil
16
+ import sqlite3
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from . import chain, db
21
+
22
+
23
+ def _sha256(path: Path) -> str:
24
+ h = hashlib.sha256()
25
+ with path.open("rb") as f:
26
+ for block in iter(lambda: f.read(1 << 20), b""):
27
+ h.update(block)
28
+ return h.hexdigest()
29
+
30
+
31
+ def verify(db_path: str | Path) -> dict:
32
+ """The whole-chain verdict for a database file. Never `ok: true` for a file with no chain."""
33
+ p = Path(db_path)
34
+ if not db.is_postgres() and not p.exists():
35
+ return {"ok": None, "checked": 0, "brokenAt": None, "detail": f"{p} does not exist"}
36
+ c = db.connect(p)
37
+ try:
38
+ if db.is_postgres():
39
+ tables = {r["name"] for r in c.execute(
40
+ "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = 'public'")}
41
+ else:
42
+ tables = {r["name"] for r in c.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
43
+ if "audit_log" not in tables:
44
+ return {"ok": None, "checked": 0, "brokenAt": None,
45
+ "detail": "no audit_log table — this is not a underwrit database"}
46
+ result = chain.verify_chain(c)
47
+ if result.get("checked") == 0 and result.get("ok"):
48
+ # An empty chain verifies vacuously. Say so instead of reporting a pass.
49
+ result = {**result, "ok": None, "detail": "the chain is empty — nothing to verify"}
50
+ if "checkpoints" in tables:
51
+ # The chain hashes agree with themselves; does the tree agree with what was signed and
52
+ # what the witness saw? A restore of an older copy passes the chain check and still
53
+ # leaves every later checkpoint refused by the witness ("the log shrank").
54
+ from . import checkpoint as cp_mod, merkle
55
+ latest = cp_mod.latest(c)
56
+ lv = cp_mod.leaves(c)
57
+ result["size"] = len(lv)
58
+ if latest:
59
+ result["checkpoint"] = {"size": latest["size"], "keyId": latest["keyId"],
60
+ "matchesTree": latest["size"] == len(lv) and latest["root"] == merkle.root(lv).hex()}
61
+ if latest["size"] > len(lv):
62
+ result["ok"] = False
63
+ result["detail"] = (f"the latest checkpoint covers {latest['size']} entries but the log holds "
64
+ f"{len(lv)}: this copy is older than what was already signed")
65
+ states = cp_mod.witness_states(c) if "witness_states" in tables else []
66
+ if states:
67
+ witnessed = max(st["size"] for st in states)
68
+ result["witnessedSize"] = witnessed
69
+ result["behindWitness"] = witnessed > len(lv)
70
+ if witnessed > len(lv):
71
+ result["detail"] = ((result.get("detail") or "") + " " if result.get("detail") else "") + (
72
+ f"a witness has cosigned size {witnessed}; restoring this copy ({len(lv)} entries) will make "
73
+ f"every later checkpoint refused as a shrunk log until the witness is re-pinned by an operator")
74
+ return result
75
+ finally:
76
+ c.close()
77
+
78
+
79
+ def backup(db_path: str | Path, dest: str | Path) -> dict:
80
+ """Copy the live database with the online backup API, then verify the copy."""
81
+ if db.is_postgres():
82
+ raise RuntimeError("DATABASE_URL is set: back up with pg_dump, then `python3 -m underwrit verify`")
83
+ src_path, dest_path = Path(db_path), Path(dest)
84
+ if not src_path.exists():
85
+ raise FileNotFoundError(f"{src_path} does not exist")
86
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
87
+ src = sqlite3.connect(str(src_path))
88
+ dst = sqlite3.connect(str(dest_path))
89
+ try:
90
+ src.backup(dst)
91
+ finally:
92
+ dst.close()
93
+ src.close()
94
+ result = {
95
+ "source": str(src_path), "path": str(dest_path), "bytes": dest_path.stat().st_size,
96
+ "sha256": _sha256(dest_path), "at": time.time(), "chain": verify(dest_path),
97
+ }
98
+ Path(str(dest_path) + ".sha256").write_text(f"{result['sha256']} {dest_path.name}\n")
99
+ return result
100
+
101
+
102
+ def restore(src: str | Path, db_path: str | Path, *, force: bool = False) -> dict:
103
+ """Put a backup in place of the live database, then verify what is now live.
104
+
105
+ Refuses to overwrite a non-empty database unless told to. Stop the service first: a restore
106
+ under a running writer is a race that ends with two chains in one file.
107
+ """
108
+ if db.is_postgres():
109
+ raise RuntimeError("DATABASE_URL is set: restore with pg_restore, then `python3 -m underwrit verify`")
110
+ src_path, dest_path = Path(src), Path(db_path)
111
+ if not src_path.exists():
112
+ raise FileNotFoundError(f"{src_path} does not exist")
113
+ if dest_path.exists() and dest_path.stat().st_size > 0 and not force:
114
+ raise FileExistsError(f"{dest_path} exists and is not empty; pass force=True to replace it")
115
+ expected = verify(src_path)
116
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
117
+ tmp = dest_path.with_suffix(dest_path.suffix + ".restoring")
118
+ shutil.copyfile(src_path, tmp)
119
+ tmp.replace(dest_path)
120
+ for side in ("-wal", "-shm"):
121
+ stale = Path(str(dest_path) + side)
122
+ if stale.exists():
123
+ stale.unlink()
124
+ return {"source": str(src_path), "path": str(dest_path), "sha256": _sha256(dest_path),
125
+ "at": time.time(), "sourceChain": expected, "chain": verify(dest_path)}
underwrit/bundle.py ADDED
@@ -0,0 +1,110 @@
1
+ """Zip an evidence pack in memory, as format version 2.
2
+
3
+ Written to a buffer rather than a temp directory because the bundle is served over HTTP and a file
4
+ on disk between assembly and response is a copy of somebody's record sitting where nobody is
5
+ managing its retention.
6
+
7
+ Version 2 adds three things to the pack: a signed checkpoint over the whole log (size and Merkle
8
+ root), an inclusion proof per entry against that root, and an Ed25519 signature over the manifest.
9
+ The proofs are computed here and handed to `evidence.build`, which never queries anything but the
10
+ chain tables — the tree lives in `checkpoint.py`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import hashlib
17
+ import hmac
18
+ import io
19
+ import json
20
+ import zipfile
21
+
22
+ from . import checkpoint as cp_mod, ed25519, evidence
23
+ from .subject import Subject
24
+
25
+
26
+ def tree_for(conn, entries: list[dict], key: cp_mod.Key | None) -> dict | None:
27
+ """Proofs for these entries against a checkpoint signed now. None when there is no key."""
28
+ if key is None:
29
+ return None
30
+ snapshot = cp_mod.tree(conn)
31
+ view, proofs = cp_mod.inclusion_for(conn, [e["seq"] for e in entries], snapshot=snapshot)
32
+ latest = cp_mod.latest(conn)
33
+ if not latest or latest["size"] != view["size"] or latest["root"] != view["root"]:
34
+ # Sign the very leaves the proofs were built from: an append between the two reads must
35
+ # not leave honest proofs pointing at a tree the signature does not cover.
36
+ latest = cp_mod.issue(conn, key, snapshot[1])
37
+ note, witnessed = latest["note"], None
38
+ state = cp_mod.witness_state(conn)
39
+ if state and state.get("fork"):
40
+ witnessed = {"fork": True, "size": state["size"], "roots": state["roots"], "witnesses": state["detail"],
41
+ "note": "witnesses disagree on the root at this size; no cosignature is shipped "
42
+ "until the fork is resolved by an operator"}
43
+ elif state and state["size"] == latest["size"] and state["root"] == latest["root"]:
44
+ # The witness has cosigned this exact tree: ship the cosigned note, so the bundle carries
45
+ # a second party's statement that this history is the one it saw.
46
+ note, witnessed = state["cosignedNote"], {"witness": state["witness"], "at": state["at"]}
47
+ elif state:
48
+ # The witness saw an earlier size. The bundle says so, and an auditor can ask the control
49
+ # plane for the consistency proof from that size — the tree here must extend it.
50
+ witnessed = {"witness": state["witness"], "at": state["at"], "size": state["size"],
51
+ "root": state["root"], "note": "witnessed at an earlier size; this checkpoint must be "
52
+ "consistent with it"}
53
+ if witnessed and not witnessed.get("fork") and state.get("witnesses"):
54
+ witnessed["witnesses"] = state["witnesses"]
55
+ return {"size": latest["size"], "root": latest["root"], "note": note,
56
+ "keyId": latest["keyId"], "issuedAt": latest["at"], "proofs": proofs,
57
+ "key": key.public_json(), "witnessed": witnessed,
58
+ "timestamps": cp_mod.timestamps_for(conn, latest["size"], latest["root"])}
59
+
60
+
61
+ def files_for(conn, subject: Subject, key: cp_mod.Key | None) -> tuple[dict, dict[str, bytes]]:
62
+ entries = evidence.entries_for(conn, subject)
63
+ tree = tree_for(conn, entries, key)
64
+ pack = evidence.build(conn, subject, tree=tree, entries=entries)
65
+ files = {
66
+ "evidence.json": json.dumps(pack, indent=2, sort_keys=True).encode("utf-8"),
67
+ "README.md": evidence.readme(pack).encode("utf-8"),
68
+ "verify.py": evidence.verifier().encode("utf-8"),
69
+ }
70
+ if tree:
71
+ files["checkpoint"] = tree["note"].encode("utf-8")
72
+ # External clocks, as the original bytes: `openssl ts -verify` and the ots client read these.
73
+ for t in tree.get("timestamps") or []:
74
+ name = {"rfc3161": "timestamps/checkpoint.tsr", "ots": "timestamps/checkpoint.ots"}.get(t["kind"])
75
+ if name and name not in files:
76
+ files[name] = base64.b64decode(t["token"])
77
+ return pack, files
78
+
79
+
80
+ def manifest_for(files: dict[str, bytes], key: cp_mod.Key | None) -> dict:
81
+ """Digests of every file but itself, signed. A manifest cannot contain its own digest."""
82
+ manifest: dict = {"format": evidence.FORMAT_VERSION,
83
+ "files": {n: hashlib.sha256(d).hexdigest() for n, d in files.items()}}
84
+ body = evidence.canonical(manifest["files"])
85
+ if key is not None:
86
+ manifest["signatures"] = [{
87
+ "alg": "ed25519", "keyId": key.key_id, "origin": key.origin,
88
+ "publicKey": base64.b64encode(key.public).decode(),
89
+ "signature": base64.b64encode(ed25519.fast_sign(key.seed, body)).decode(),
90
+ }]
91
+ legacy = evidence.signing_key()
92
+ if legacy:
93
+ # The version-1 HMAC, kept for verifiers that share the secret. It proves origin only to
94
+ # a party who could have forged it, which is why version 2 signs asymmetrically.
95
+ manifest["signature"] = hmac.new(legacy.encode("utf-8"), body, hashlib.sha256).hexdigest()
96
+ return manifest
97
+
98
+
99
+ def zip_for(conn, subject: Subject, key: cp_mod.Key | None = None) -> tuple[bytes, str]:
100
+ _, files = files_for(conn, subject, key)
101
+ files["manifest.json"] = json.dumps(manifest_for(files, key), indent=2, sort_keys=True).encode("utf-8")
102
+ buf = io.BytesIO()
103
+ # Deterministic: a fixed timestamp so the same pack zips to the same bytes, which is what lets
104
+ # two people compare bundles by digest rather than by unpacking them.
105
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
106
+ for name in sorted(files):
107
+ info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
108
+ info.external_attr = 0o644 << 16
109
+ z.writestr(info, files[name])
110
+ return buf.getvalue(), f"underwrit-{subject.id}.zip"