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.
- app/__init__.py +351 -0
- app/_version.py +8 -0
- app/agents.py +150 -0
- app/alembic.ini +37 -0
- app/auth.py +714 -0
- app/chat.py +122 -0
- app/config.py +258 -0
- app/costgov.py +345 -0
- app/db.py +56 -0
- app/faults.py +26 -0
- app/grants.py +122 -0
- app/idempotency.py +58 -0
- app/jcs.py +117 -0
- app/ledger/__init__.py +41 -0
- app/ledger/fold.py +74 -0
- app/ledger/hashing.py +19 -0
- app/ledger/projection.py +389 -0
- app/ledger/store.py +1042 -0
- app/ledger/verify.py +31 -0
- app/main.py +50 -0
- app/maintenance.py +56 -0
- app/metrics.py +89 -0
- app/migrations/env.py +20 -0
- app/migrations/script.py.mako +23 -0
- app/migrations/versions/0001_baseline.py +19 -0
- app/migrations/versions/0002_events_and_projection.py +78 -0
- app/migrations/versions/0003_slack_messages.py +27 -0
- app/migrations/versions/0004_sessions.py +31 -0
- app/migrations/versions/0005_siem_cursor.py +26 -0
- app/migrations/versions/0006_teams_messages.py +27 -0
- app/migrations/versions/0007_tenant_scoping.py +50 -0
- app/migrations/versions/0008_policy_view.py +39 -0
- app/migrations/versions/0009_api_tokens.py +37 -0
- app/migrations/versions/0010_session_groups.py +23 -0
- app/migrations/versions/0011_budgets_and_evals.py +75 -0
- app/migrations/versions/0012_run_event_ts_index.py +23 -0
- app/migrations/versions/0013_agent_chains.py +102 -0
- app/migrations/versions/0014_dual_control.py +33 -0
- app/migrations/versions/0015_sampled_mode.py +24 -0
- app/migrations/versions/0016_sampling_salt.py +25 -0
- app/migrations/versions/0017_live_tokens_index.py +25 -0
- app/migrations/versions/0018_webhook_outbox.py +47 -0
- app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
- app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
- app/migrations/versions/0021_execution_grants.py +59 -0
- app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
- app/migrations/versions/0023_idempotency_keys.py +38 -0
- app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
- app/netguard.py +73 -0
- app/obslog.py +105 -0
- app/policy.py +390 -0
- app/ratelimit.py +78 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +312 -0
- app/routes/agents.py +111 -0
- app/routes/approvals.py +576 -0
- app/routes/audit.py +213 -0
- app/routes/costgov.py +150 -0
- app/routes/health.py +53 -0
- app/routes/policy.py +72 -0
- app/routes/runs.py +115 -0
- app/routes/slack.py +40 -0
- app/routes/teams.py +29 -0
- app/routes/traces.py +55 -0
- app/schemas.py +248 -0
- app/security.py +203 -0
- app/siem.py +248 -0
- app/signing.py +342 -0
- app/sla.py +234 -0
- app/slack.py +282 -0
- app/teams.py +276 -0
- app/tokens.py +117 -0
- app/traces.py +309 -0
- app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- app/web-dist/assets/index-B_JDe_G2.js +10 -0
- app/web-dist/assets/index-J7J-YtwM.css +1 -0
- app/web-dist/index.html +24 -0
- app/webhooks.py +405 -0
- primarypath_server-1.0.0.dist-info/METADATA +35 -0
- primarypath_server-1.0.0.dist-info/RECORD +94 -0
- primarypath_server-1.0.0.dist-info/WHEEL +5 -0
- primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
- primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
- 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."""
|