primarypath-server 1.0.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 (94) hide show
  1. app/__init__.py +351 -0
  2. app/_version.py +8 -0
  3. app/agents.py +150 -0
  4. app/alembic.ini +37 -0
  5. app/auth.py +714 -0
  6. app/chat.py +122 -0
  7. app/config.py +258 -0
  8. app/costgov.py +345 -0
  9. app/db.py +56 -0
  10. app/faults.py +26 -0
  11. app/grants.py +122 -0
  12. app/idempotency.py +58 -0
  13. app/jcs.py +117 -0
  14. app/ledger/__init__.py +41 -0
  15. app/ledger/fold.py +74 -0
  16. app/ledger/hashing.py +19 -0
  17. app/ledger/projection.py +389 -0
  18. app/ledger/store.py +1042 -0
  19. app/ledger/verify.py +31 -0
  20. app/main.py +50 -0
  21. app/maintenance.py +56 -0
  22. app/metrics.py +89 -0
  23. app/migrations/env.py +20 -0
  24. app/migrations/script.py.mako +23 -0
  25. app/migrations/versions/0001_baseline.py +19 -0
  26. app/migrations/versions/0002_events_and_projection.py +78 -0
  27. app/migrations/versions/0003_slack_messages.py +27 -0
  28. app/migrations/versions/0004_sessions.py +31 -0
  29. app/migrations/versions/0005_siem_cursor.py +26 -0
  30. app/migrations/versions/0006_teams_messages.py +27 -0
  31. app/migrations/versions/0007_tenant_scoping.py +50 -0
  32. app/migrations/versions/0008_policy_view.py +39 -0
  33. app/migrations/versions/0009_api_tokens.py +37 -0
  34. app/migrations/versions/0010_session_groups.py +23 -0
  35. app/migrations/versions/0011_budgets_and_evals.py +75 -0
  36. app/migrations/versions/0012_run_event_ts_index.py +23 -0
  37. app/migrations/versions/0013_agent_chains.py +102 -0
  38. app/migrations/versions/0014_dual_control.py +33 -0
  39. app/migrations/versions/0015_sampled_mode.py +24 -0
  40. app/migrations/versions/0016_sampling_salt.py +25 -0
  41. app/migrations/versions/0017_live_tokens_index.py +25 -0
  42. app/migrations/versions/0018_webhook_outbox.py +47 -0
  43. app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
  44. app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
  45. app/migrations/versions/0021_execution_grants.py +59 -0
  46. app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
  47. app/migrations/versions/0023_idempotency_keys.py +38 -0
  48. app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
  49. app/netguard.py +73 -0
  50. app/obslog.py +105 -0
  51. app/policy.py +390 -0
  52. app/ratelimit.py +78 -0
  53. app/routes/__init__.py +0 -0
  54. app/routes/admin.py +312 -0
  55. app/routes/agents.py +111 -0
  56. app/routes/approvals.py +576 -0
  57. app/routes/audit.py +213 -0
  58. app/routes/costgov.py +150 -0
  59. app/routes/health.py +53 -0
  60. app/routes/policy.py +72 -0
  61. app/routes/runs.py +115 -0
  62. app/routes/slack.py +40 -0
  63. app/routes/teams.py +29 -0
  64. app/routes/traces.py +55 -0
  65. app/schemas.py +248 -0
  66. app/security.py +203 -0
  67. app/siem.py +248 -0
  68. app/signing.py +342 -0
  69. app/sla.py +234 -0
  70. app/slack.py +282 -0
  71. app/teams.py +276 -0
  72. app/tokens.py +117 -0
  73. app/traces.py +309 -0
  74. app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  75. app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  76. app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  77. app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  78. app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  79. app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  80. app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  81. app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  82. app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  83. app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  84. app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  85. app/web-dist/assets/index-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/siem.py ADDED
@@ -0,0 +1,248 @@
1
+ """SIEM forwarding: the audit trail lands in the buyer's Splunk/Elastic/
2
+ syslog, ordered and resumable, without ever touching the append path.
3
+
4
+ At-least-once with dedup on `seq` at the SIEM (Elastic uses seq as _id;
5
+ Splunk/syslog analysts dedup on the seq field). The cursor advances only
6
+ after a successful send. A dead SIEM logs + retries next tick; approvals
7
+ never wait on it — the durable, signed truth stays in Postgres/F09.
8
+
9
+ Each record carries hash/prev_hash so an analyst can spot-check the
10
+ chain; the authoritative proof remains the signed F09 bundle.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import datetime as dt
16
+ import json
17
+ import logging
18
+
19
+ import httpx
20
+
21
+ log = logging.getLogger("primarypath.siem")
22
+
23
+ SIEM_SCHEMA_VERSION = 2
24
+
25
+
26
+ def siem_record(event: dict) -> dict:
27
+ """The stable, versioned flat record (documented in docs/SIEM.md). v2 (F24)
28
+ adds chain_id/agent_id/unit — `seq` is per chain now, so analysts dedup
29
+ on (tenant_id, chain_id, seq)."""
30
+ p = event["payload"]
31
+ if isinstance(p, str):
32
+ p = json.loads(p)
33
+ return {
34
+ "schema": SIEM_SCHEMA_VERSION,
35
+ "tenant_id": event.get("tenant_id", "default"),
36
+ "chain_id": event.get("chain_id", "default"),
37
+ "agent_id": event.get("agent_id"),
38
+ "unit": event.get("unit"),
39
+ "ts": dt.datetime.fromtimestamp(
40
+ event["ts_ms"] / 1000, tz=dt.UTC).isoformat(),
41
+ "ts_ms": event["ts_ms"],
42
+ "seq": event["seq"],
43
+ "type": event["type"],
44
+ "run_id": p.get("run_id"),
45
+ "approval_id": p.get("approval_id"),
46
+ "tool": p.get("tool"),
47
+ "reviewer": p.get("reviewer"),
48
+ "reviewer_role": p.get("reviewer_role"),
49
+ "reason": p.get("reason"),
50
+ "hash": event["hash"],
51
+ "prev_hash": event["prev_hash"],
52
+ }
53
+
54
+
55
+ # -- sinks ---------------------------------------------------------------------
56
+
57
+
58
+ def splunk_payload(records: list[dict]) -> str:
59
+ """HEC batch: newline-delimited event envelopes."""
60
+ return "\n".join(json.dumps({
61
+ "time": r["ts_ms"] / 1000,
62
+ "sourcetype": "primarypath:audit",
63
+ "event": r,
64
+ }) for r in records)
65
+
66
+
67
+ class SplunkHecSink:
68
+ def __init__(self, endpoint: str, token: str):
69
+ self.url = endpoint.rstrip("/") + "/services/collector/event"
70
+ self.http = httpx.AsyncClient(
71
+ headers={"Authorization": f"Splunk {token}"}, timeout=10)
72
+
73
+ async def send(self, records: list[dict]) -> None:
74
+ r = await self.http.post(self.url, content=splunk_payload(records))
75
+ r.raise_for_status()
76
+
77
+
78
+ def elastic_ndjson(index: str, records: list[dict]) -> str:
79
+ """_bulk body; _id = tenant:chain:seq makes redelivery idempotent
80
+ (seq alone is per chain, F24)."""
81
+ lines = []
82
+ for r in records:
83
+ _id = f"{r['tenant_id']}:{r['chain_id']}:{r['seq']}"
84
+ lines.append(json.dumps({"index": {"_index": index, "_id": _id}}))
85
+ lines.append(json.dumps(r))
86
+ return "\n".join(lines) + "\n"
87
+
88
+
89
+ class ElasticSink:
90
+ def __init__(self, endpoint: str, index: str = "primarypath-audit",
91
+ token: str | None = None):
92
+ headers = {"Content-Type": "application/x-ndjson"}
93
+ if token:
94
+ headers["Authorization"] = f"ApiKey {token}"
95
+ self.url = endpoint.rstrip("/") + "/_bulk"
96
+ self.index = index
97
+ self.http = httpx.AsyncClient(headers=headers, timeout=10)
98
+
99
+ async def send(self, records: list[dict]) -> None:
100
+ r = await self.http.post(self.url,
101
+ content=elastic_ndjson(self.index, records))
102
+ r.raise_for_status()
103
+ body = r.json()
104
+ if body.get("errors"):
105
+ raise RuntimeError(f"elastic bulk reported item errors: "
106
+ f"{str(body)[:300]}")
107
+
108
+
109
+ def _cef_escape(v) -> str:
110
+ return (str(v).replace("\\", "\\\\").replace("=", "\\=")
111
+ .replace("\n", " ").replace("\r", " "))
112
+
113
+
114
+ SEVERITY = {"approval_denied": 5, "approval_granted": 5,
115
+ "approval_endorsed": 5,
116
+ "approval_escalated": 7, "approval_requested": 4}
117
+
118
+
119
+ def cef_line(record: dict) -> str:
120
+ """CEF:0|vendor|product|version|signature|name|severity|extension"""
121
+ ext = " ".join(f"{k}={_cef_escape(v)}" for k, v in [
122
+ ("rt", record["ts_ms"]), ("cn1", record["seq"]),
123
+ ("cn1Label", "seq"), ("cs1", record["run_id"] or ""),
124
+ ("cs1Label", "run_id"), ("cs2", record["approval_id"] or ""),
125
+ ("cs2Label", "approval_id"), ("cs3", record["tool"] or ""),
126
+ ("cs3Label", "tool"), ("suser", record["reviewer"] or ""),
127
+ ("reason", record["reason"] or ""), ("fileHash", record["hash"]),
128
+ ("oldFileHash", record["prev_hash"]),
129
+ ])
130
+ sev = SEVERITY.get(record["type"], 3)
131
+ return (f"CEF:0|hidela|primarypath|{SIEM_SCHEMA_VERSION}|{record['type']}|"
132
+ f"{record['type']}|{sev}|{ext}")
133
+
134
+
135
+ class SyslogCefSink:
136
+ def __init__(self, host: str, port: int, protocol: str = "tcp"):
137
+ self.host, self.port = host, port
138
+ self.protocol = protocol
139
+
140
+ async def send(self, records: list[dict]) -> None:
141
+ payload = "".join(cef_line(r) + "\n" for r in records).encode()
142
+ if self.protocol == "udp":
143
+ loop = asyncio.get_running_loop()
144
+ transport, _ = await loop.create_datagram_endpoint(
145
+ asyncio.DatagramProtocol, remote_addr=(self.host, self.port))
146
+ try:
147
+ transport.sendto(payload)
148
+ finally:
149
+ transport.close()
150
+ else:
151
+ reader, writer = await asyncio.open_connection(self.host, self.port)
152
+ try:
153
+ writer.write(payload)
154
+ await writer.drain()
155
+ finally:
156
+ writer.close()
157
+ await writer.wait_closed()
158
+
159
+
160
+ def make_sink(settings):
161
+ if settings.siem == "splunk":
162
+ return SplunkHecSink(settings.siem_endpoint, settings.siem_token or "")
163
+ if settings.siem == "elastic":
164
+ return ElasticSink(settings.siem_endpoint,
165
+ index=settings.siem_index,
166
+ token=settings.siem_token)
167
+ if settings.siem == "syslog":
168
+ return SyslogCefSink(settings.siem_syslog_host,
169
+ settings.siem_syslog_port,
170
+ settings.siem_syslog_protocol)
171
+ return None
172
+
173
+
174
+ # -- the forwarder -----------------------------------------------------------------
175
+
176
+
177
+ class SiemForwarder:
178
+ def __init__(self, pool, sink, batch: int = 500,
179
+ interval_s: float = 2.0):
180
+ self.pool = pool
181
+ self.sink = sink
182
+ self.batch = batch
183
+ self.interval_s = interval_s
184
+ self._task: asyncio.Task | None = None
185
+
186
+ def start(self) -> None:
187
+ if self.sink is not None and self.interval_s > 0:
188
+ self._task = asyncio.get_running_loop().create_task(self._loop())
189
+
190
+ async def stop(self) -> None:
191
+ if self._task is not None:
192
+ self._task.cancel()
193
+ try:
194
+ await self._task
195
+ except asyncio.CancelledError:
196
+ pass
197
+
198
+ async def _loop(self) -> None:
199
+ while True:
200
+ try:
201
+ while await self.run_once():
202
+ pass # drain the backlog batch by batch
203
+ except Exception:
204
+ log.exception("siem forward failed; will retry")
205
+ await asyncio.sleep(self.interval_s)
206
+
207
+ async def run_once(self) -> bool:
208
+ """Ship one batch per tenant. Returns True when any tenant filled a
209
+ full batch (more may be waiting). Each tenant's cursor moves only
210
+ AFTER its successful send."""
211
+ more = False
212
+ async with self.pool.connection() as conn:
213
+ cur = await conn.execute("SELECT DISTINCT tenant_id FROM events")
214
+ tenants = [r[0] for r in await cur.fetchall()]
215
+ for tenant in tenants:
216
+ more = await self._run_once_tenant(tenant) or more
217
+ return more
218
+
219
+ async def _run_once_tenant(self, tenant: str) -> bool:
220
+ """The cursor tracks `rowid` (global ingest order): per-chain seq
221
+ restarts at 1 for every agent (F24), so it can no longer order a
222
+ tenant's whole feed."""
223
+ async with self.pool.connection() as conn:
224
+ cur = await conn.execute(
225
+ "SELECT last_rowid FROM siem_cursor WHERE tenant_id = %s",
226
+ (tenant,))
227
+ row = await cur.fetchone()
228
+ last = row[0] if row else 0
229
+ cur = await conn.execute(
230
+ "SELECT tenant_id, chain_id, agent_id, unit, rowid, seq, "
231
+ "ts_ms, type, payload, prev_hash, hash "
232
+ "FROM events WHERE tenant_id = %s AND rowid > %s "
233
+ "ORDER BY rowid LIMIT %s", (tenant, last, self.batch))
234
+ rows = await cur.fetchall()
235
+ if not rows:
236
+ return False
237
+ keys = ("tenant_id", "chain_id", "agent_id", "unit", "rowid", "seq",
238
+ "ts_ms", "type", "payload", "prev_hash", "hash")
239
+ events = [dict(zip(keys, r)) for r in rows]
240
+ records = [siem_record(e) for e in events]
241
+ await self.sink.send(records) # raises on failure: cursor untouched
242
+ async with self.pool.connection() as conn:
243
+ await conn.execute(
244
+ "INSERT INTO siem_cursor (tenant_id, last_rowid) "
245
+ "VALUES (%s, %s) ON CONFLICT (tenant_id) DO UPDATE "
246
+ "SET last_rowid = EXCLUDED.last_rowid",
247
+ (tenant, events[-1]["rowid"]))
248
+ return len(rows) == self.batch
app/signing.py ADDED
@@ -0,0 +1,342 @@
1
+ """Ed25519 signing of audit bundles, behind a pluggable backend.
2
+
3
+ Backends implement: key_id, public_key_b64, sign(bytes) -> bytes,
4
+ rotate() -> new key_id, pubring() -> {key_id: public_b64}.
5
+
6
+ - FileBackend (default): key generated on first run at signing_key_path
7
+ (0600, never leaves the box, never logged); ring file lives next to it.
8
+ - Pkcs11Backend: the private key lives in a customer HSM/KMS speaking
9
+ PKCS#11 (SoftHSM, Luna, CloudHSM, YubiHSM...). Signing happens inside
10
+ the token; the private key is NEVER materialized on this host. Cloud
11
+ KMS APIs (GCP/Azure/Vault Transit) are the same five-method surface —
12
+ a small addition, documented rather than shipped.
13
+
14
+ The bundle format is identical across backends: an Ed25519 signature
15
+ over canonical({count, first_seq, head, last_seq}) plus the embedded
16
+ public key, so the offline CLI and the browser verifier neither know nor
17
+ care where the private key lived.
18
+
19
+ Keys carry a key_id (sha256 of the raw public key, first 16 hex chars);
20
+ rotation adds the old public key to the ring so old exports remain
21
+ verifiable forever.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import hashlib
27
+ import json
28
+ import os
29
+ from pathlib import Path
30
+
31
+ from cryptography.hazmat.primitives import serialization
32
+ from cryptography.hazmat.primitives.asymmetric import ed25519
33
+
34
+ from .ledger.hashing import canonical
35
+
36
+
37
+ def _key_id(raw_public: bytes) -> str:
38
+ return hashlib.sha256(raw_public).hexdigest()[:16]
39
+
40
+
41
+ class _Ring:
42
+ """The public-key ring: append-only JSON of key_id -> base64 pubkey."""
43
+
44
+ def __init__(self, path: Path):
45
+ self.path = path
46
+
47
+ def load(self) -> dict[str, str]:
48
+ if self.path.exists():
49
+ return json.loads(self.path.read_text())
50
+ return {}
51
+
52
+ def add(self, raw_public: bytes) -> None:
53
+ ring = self.load()
54
+ kid = _key_id(raw_public)
55
+ if kid not in ring:
56
+ ring[kid] = base64.b64encode(raw_public).decode()
57
+ self.path.parent.mkdir(parents=True, exist_ok=True)
58
+ self.path.write_text(json.dumps(ring, indent=2, sort_keys=True))
59
+
60
+
61
+ class FileBackend:
62
+ def __init__(self, key_path: str):
63
+ self.key_path = Path(key_path)
64
+ self.ring = _Ring(Path(str(key_path) + ".pubring.json"))
65
+ self._key = self._load_or_create()
66
+
67
+ def _load_or_create(self) -> ed25519.Ed25519PrivateKey:
68
+ if self.key_path.exists():
69
+ key = serialization.load_pem_private_key(
70
+ self.key_path.read_bytes(), password=None)
71
+ if not isinstance(key, ed25519.Ed25519PrivateKey):
72
+ raise ValueError(f"{self.key_path} is not an Ed25519 key")
73
+ else:
74
+ key = ed25519.Ed25519PrivateKey.generate()
75
+ self._write_private(key)
76
+ self.ring.add(self._raw_public(key))
77
+ return key
78
+
79
+ def _write_private(self, key: ed25519.Ed25519PrivateKey) -> None:
80
+ self.key_path.parent.mkdir(parents=True, exist_ok=True)
81
+ pem = key.private_bytes(
82
+ serialization.Encoding.PEM,
83
+ serialization.PrivateFormat.PKCS8,
84
+ serialization.NoEncryption())
85
+ fd = os.open(self.key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
86
+ with os.fdopen(fd, "wb") as f:
87
+ f.write(pem)
88
+
89
+ @staticmethod
90
+ def _raw_public(key: ed25519.Ed25519PrivateKey) -> bytes:
91
+ return key.public_key().public_bytes(
92
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)
93
+
94
+ @property
95
+ def key_id(self) -> str:
96
+ return _key_id(self._raw_public(self._key))
97
+
98
+ @property
99
+ def public_key_b64(self) -> str:
100
+ return base64.b64encode(self._raw_public(self._key)).decode()
101
+
102
+ def sign(self, message: bytes) -> bytes:
103
+ return self._key.sign(message)
104
+
105
+ def rotate(self) -> str:
106
+ self._key = ed25519.Ed25519PrivateKey.generate()
107
+ self._write_private(self._key)
108
+ self.ring.add(self._raw_public(self._key))
109
+ return self.key_id
110
+
111
+ def pubring(self) -> dict[str, str]:
112
+ return self.ring.load()
113
+
114
+
115
+ ED25519_OID_DER = bytes.fromhex("06032b6570") # 1.3.101.112
116
+
117
+
118
+ class Pkcs11Backend:
119
+ """The private key lives in a PKCS#11 token; only signatures leave it."""
120
+
121
+ def __init__(self, module_path: str, token_label: str, pin: str,
122
+ key_label: str = "primarypath-audit", ring_path: str = "pubring.json"):
123
+ import pkcs11 # optional dependency: pip install python-pkcs11
124
+
125
+ self._pkcs11 = pkcs11
126
+ self.key_label = key_label
127
+ self.ring = _Ring(Path(ring_path))
128
+ token = pkcs11.lib(module_path).get_token(token_label=token_label)
129
+ try:
130
+ self._session = token.open(rw=True, user_pin=pin)
131
+ except pkcs11.exceptions.UserAlreadyLoggedIn:
132
+ # another session in this process already holds the login
133
+ self._session = token.open(rw=True)
134
+ self._public, self._private = self._find_or_create()
135
+ self.ring.add(self._raw_public())
136
+
137
+ def close(self) -> None:
138
+ self._session.close()
139
+
140
+ def _find_or_create(self):
141
+ from pkcs11 import Attribute, KeyType, ObjectClass
142
+ from pkcs11.exceptions import MultipleObjectsReturned, NoSuchKey
143
+
144
+ def _one(object_class):
145
+ try:
146
+ return self._session.get_key(object_class=object_class,
147
+ label=self.key_label)
148
+ except MultipleObjectsReturned:
149
+ # A crash between generate and destroy during rotate can leave
150
+ # two keypairs under the label. Both are valid; pick one
151
+ # deterministically so load never fails (better a working key
152
+ # than none). The straggler is harmless — signing continues.
153
+ keys = list(self._session.get_objects(
154
+ {Attribute.CLASS: object_class,
155
+ Attribute.LABEL: self.key_label}))
156
+ return keys[0]
157
+
158
+ try:
159
+ pub = _one(ObjectClass.PUBLIC_KEY)
160
+ priv = _one(ObjectClass.PRIVATE_KEY)
161
+ return pub, priv
162
+ except NoSuchKey:
163
+ params = self._session.create_domain_parameters(
164
+ KeyType.EC_EDWARDS, {Attribute.EC_PARAMS: ED25519_OID_DER},
165
+ local=True)
166
+ return params.generate_keypair(store=True, label=self.key_label)
167
+
168
+ def _raw_public(self) -> bytes:
169
+ from pkcs11 import Attribute
170
+
171
+ point = bytes(self._public[Attribute.EC_POINT])
172
+ # EC_POINT is a DER OCTET STRING (0x04 0x20 || 32 raw bytes)
173
+ return point[-32:]
174
+
175
+ @property
176
+ def key_id(self) -> str:
177
+ return _key_id(self._raw_public())
178
+
179
+ @property
180
+ def public_key_b64(self) -> str:
181
+ return base64.b64encode(self._raw_public()).decode()
182
+
183
+ def sign(self, message: bytes) -> bytes:
184
+ from pkcs11 import Mechanism
185
+
186
+ return bytes(self._private.sign(message, mechanism=Mechanism.EDDSA))
187
+
188
+ def rotate(self) -> str:
189
+ """New keypair in the token under the same label; the old public key
190
+ stays in the ring. GENERATE the new pair BEFORE destroying the old, so
191
+ a mid-rotation failure leaves a working signing key (two keys, both
192
+ valid) rather than none. The brief two-key window is tolerated by
193
+ _find_or_create."""
194
+ from pkcs11 import Attribute, KeyType
195
+
196
+ old_private, old_public = self._private, self._public
197
+ params = self._session.create_domain_parameters(
198
+ KeyType.EC_EDWARDS, {Attribute.EC_PARAMS: ED25519_OID_DER},
199
+ local=True)
200
+ new_public, new_private = params.generate_keypair(
201
+ store=True, label=self.key_label)
202
+ # the new key exists and is stored; only now retire the old one
203
+ self._public, self._private = new_public, new_private
204
+ self.ring.add(self._raw_public())
205
+ old_private.destroy()
206
+ old_public.destroy()
207
+ return self.key_id
208
+
209
+ def pubring(self) -> dict[str, str]:
210
+ return self.ring.load()
211
+
212
+
213
+ class Signer:
214
+ """Facade over a backend; the bundle format never changes."""
215
+
216
+ def __init__(self, source):
217
+ if isinstance(source, (str, Path)):
218
+ source = FileBackend(str(source))
219
+ self.backend = source
220
+
221
+ @property
222
+ def key_id(self) -> str:
223
+ return self.backend.key_id
224
+
225
+ @property
226
+ def public_key_b64(self) -> str:
227
+ return self.backend.public_key_b64
228
+
229
+ def rotate(self) -> str:
230
+ return self.backend.rotate()
231
+
232
+ def pubring(self) -> dict[str, str]:
233
+ return self.backend.pubring()
234
+
235
+ def sign_bundle(self, bundle: dict, bind_events: bool = False) -> dict:
236
+ """Sign a bundle. For a contiguous chain export, {head, count,
237
+ first_seq, last_seq} suffices: every event chains to the signed
238
+ head. A NON-contiguous slice (a run bundle) has no checkable
239
+ continuity, so `bind_events` commits the signature to every event
240
+ hash; without it, editing a non-last event and recomputing its
241
+ hash would still verify."""
242
+ events = bundle["events"]
243
+ record = {
244
+ "head": bundle["head"],
245
+ "count": len(events),
246
+ "first_seq": events[0]["seq"] if events else None,
247
+ "last_seq": events[-1]["seq"] if events else None,
248
+ }
249
+ out = dict(bundle)
250
+ if bind_events:
251
+ events_hash = hashlib.sha256(canonical(
252
+ {"hashes": [e["hash"] for e in events]}).encode("utf-8")
253
+ ).hexdigest()
254
+ record["events_hash"] = events_hash
255
+ out["events_hash"] = events_hash
256
+ sig = self.backend.sign(canonical(record).encode("utf-8"))
257
+ return {**out,
258
+ "key_id": self.key_id,
259
+ "public_key": self.public_key_b64,
260
+ "signature": base64.b64encode(sig).decode()}
261
+
262
+ def sign_chains(self, bundle: dict) -> dict:
263
+ """A multi-chain (whole-log) bundle: one signature over every
264
+ chain's head and extent (F24). Each chain still verifies alone."""
265
+ record = {"chains": [{
266
+ "chain_id": c["chain_id"],
267
+ "head": c["head"],
268
+ "count": len(c["events"]),
269
+ "first_seq": c["events"][0]["seq"] if c["events"] else None,
270
+ "last_seq": c["events"][-1]["seq"] if c["events"] else None,
271
+ } for c in sorted(bundle["chains"], key=lambda c: c["chain_id"])]}
272
+ sig = self.backend.sign(canonical(record).encode("utf-8"))
273
+ return {**bundle,
274
+ "key_id": self.key_id,
275
+ "public_key": self.public_key_b64,
276
+ "signature": base64.b64encode(sig).decode()}
277
+
278
+
279
+ def make_signer(settings) -> Signer | None:
280
+ if settings.signing_backend == "pkcs11":
281
+ return Signer(Pkcs11Backend(
282
+ module_path=settings.pkcs11_module,
283
+ token_label=settings.pkcs11_token_label,
284
+ pin=settings.pkcs11_pin,
285
+ key_label=settings.pkcs11_key_label,
286
+ ring_path=settings.signing_ring_path or "pubring.json"))
287
+ if settings.signing_key_path:
288
+ return Signer(FileBackend(settings.signing_key_path))
289
+ return None
290
+
291
+
292
+ class SignerRegistry:
293
+ """Every configured signing backend by evidence-profile name (F24):
294
+ 'file' (the on-box Ed25519 key) and 'kms' (PKCS#11 — the key lives in
295
+ the customer's HSM/KMS). An agent's evidence profile picks one per
296
+ export; 'none' exports unsigned. The 'default' signer is whatever the
297
+ deployment configured pre-F24, so unprofiled agents behave exactly as
298
+ before."""
299
+
300
+ def __init__(self, settings):
301
+ self._signers: dict[str, Signer] = {}
302
+ if settings.signing_key_path:
303
+ self._signers["file"] = Signer(
304
+ FileBackend(settings.signing_key_path))
305
+ if settings.signing_backend == "pkcs11":
306
+ self._signers["kms"] = Signer(Pkcs11Backend(
307
+ module_path=settings.pkcs11_module,
308
+ token_label=settings.pkcs11_token_label,
309
+ pin=settings.pkcs11_pin,
310
+ key_label=settings.pkcs11_key_label,
311
+ ring_path=settings.signing_ring_path or "pubring.json"))
312
+ self.default: Signer | None = (
313
+ self._signers.get("kms") or self._signers.get("file"))
314
+
315
+ def for_profile(self, profile: dict) -> Signer | None:
316
+ """The signer an agent's evidence profile demands.
317
+
318
+ Raises SigningUnavailable when the profile names a backend the
319
+ deployment has not configured — an export that silently downgraded
320
+ its evidence would be worse than a refusal."""
321
+ backend = profile.get("signing_backend", "file")
322
+ if backend == "none":
323
+ return None
324
+ signer = self._signers.get(backend)
325
+ if signer is None:
326
+ raise SigningUnavailable(
327
+ f"evidence profile demands '{backend}' signing, "
328
+ f"but no {backend} backend is configured")
329
+ return signer
330
+
331
+ def backends(self) -> list[str]:
332
+ return sorted(self._signers)
333
+
334
+ def pubring(self) -> dict[str, str]:
335
+ ring: dict[str, str] = {}
336
+ for s in self._signers.values():
337
+ ring.update(s.pubring())
338
+ return ring
339
+
340
+
341
+ class SigningUnavailable(Exception):
342
+ """An evidence profile demands a signing backend that is not wired."""