spanforge 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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/signing.py
ADDED
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
"""HMAC-SHA256 signing and tamper-evident audit chain for spanforge.
|
|
2
|
+
|
|
3
|
+
Provides compliance-grade audit log integrity without requiring a blockchain
|
|
4
|
+
or external service. All cryptography uses pure Python stdlib — no network
|
|
5
|
+
calls, no external dependencies.
|
|
6
|
+
|
|
7
|
+
Signing algorithm
|
|
8
|
+
-----------------
|
|
9
|
+
|
|
10
|
+
Each event is signed in two steps::
|
|
11
|
+
|
|
12
|
+
checksum = "sha256:" + sha256(canonical_payload_json).hexdigest()
|
|
13
|
+
sig_input = event_id + "|" + checksum + "|" + (prev_id or "")
|
|
14
|
+
signature = "hmac-sha256:" + HMAC-SHA256(sig_input, org_secret).hexdigest()
|
|
15
|
+
|
|
16
|
+
The *canonical payload JSON* uses ``sort_keys=True, separators=(",", ":")``
|
|
17
|
+
(compact, no whitespace) so the same payload always produces the same checksum
|
|
18
|
+
regardless of dict insertion order or Python version.
|
|
19
|
+
|
|
20
|
+
Chain linkage
|
|
21
|
+
-------------
|
|
22
|
+
|
|
23
|
+
Each event (except the first) stores the ``prev_id`` of its predecessor.
|
|
24
|
+
A missing or mismatched ``prev_id`` indicates a deleted or reordered event::
|
|
25
|
+
|
|
26
|
+
events[n].prev_id == events[n-1].event_id # must hold for every n > 0
|
|
27
|
+
|
|
28
|
+
Key rotation
|
|
29
|
+
------------
|
|
30
|
+
|
|
31
|
+
The HMAC key can be rotated mid-chain using :meth:`AuditStream.rotate_key`.
|
|
32
|
+
A key-rotation event (``EventType.AUDIT_KEY_ROTATED``) is inserted into the
|
|
33
|
+
chain, signed with the *current* key. All subsequent events are signed with
|
|
34
|
+
the *new* key. :func:`verify_chain` accepts a ``key_map`` argument that maps
|
|
35
|
+
rotation event IDs to the corresponding new secrets, enabling independent
|
|
36
|
+
chain verification across rotation boundaries.
|
|
37
|
+
|
|
38
|
+
Security requirements
|
|
39
|
+
---------------------
|
|
40
|
+
|
|
41
|
+
* The ``org_secret`` **never** appears in exception messages, ``__repr__``,
|
|
42
|
+
``__str__``, or ``__reduce__`` output.
|
|
43
|
+
* Signing failures always raise :exc:`~spanforge.exceptions.SigningError`
|
|
44
|
+
— never silently pass.
|
|
45
|
+
* Empty or whitespace-only secrets are rejected immediately.
|
|
46
|
+
* :func:`verify` uses :func:`hmac.compare_digest` for all comparisons to
|
|
47
|
+
prevent timing-based side-channel attacks.
|
|
48
|
+
|
|
49
|
+
Usage
|
|
50
|
+
-----
|
|
51
|
+
::
|
|
52
|
+
|
|
53
|
+
from spanforge import Event, EventType
|
|
54
|
+
from spanforge.signing import sign, verify, verify_chain, AuditStream
|
|
55
|
+
|
|
56
|
+
# Sign a single event
|
|
57
|
+
signed = sign(event, org_secret="corp-key-001")
|
|
58
|
+
assert verify(signed, org_secret="corp-key-001")
|
|
59
|
+
|
|
60
|
+
# Build a verifiable chain
|
|
61
|
+
stream = AuditStream(org_secret="corp-key-001", source="audit-daemon@1.0.0")
|
|
62
|
+
for evt in raw_events:
|
|
63
|
+
stream.append(evt)
|
|
64
|
+
|
|
65
|
+
result = stream.verify()
|
|
66
|
+
# result.valid → True
|
|
67
|
+
# result.first_tampered → None
|
|
68
|
+
# result.gaps → []
|
|
69
|
+
# result.tampered_count → 0
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import hashlib
|
|
75
|
+
import hmac as _hmac
|
|
76
|
+
import json
|
|
77
|
+
import logging
|
|
78
|
+
from dataclasses import dataclass, field
|
|
79
|
+
from typing import TYPE_CHECKING, Any
|
|
80
|
+
from typing import Protocol as _Protocol
|
|
81
|
+
from typing import runtime_checkable as _runtime_checkable
|
|
82
|
+
|
|
83
|
+
from spanforge.exceptions import SigningError, VerificationError
|
|
84
|
+
|
|
85
|
+
if TYPE_CHECKING:
|
|
86
|
+
import threading
|
|
87
|
+
from collections.abc import Sequence
|
|
88
|
+
|
|
89
|
+
from spanforge.event import Event
|
|
90
|
+
|
|
91
|
+
_log = logging.getLogger(__name__)
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"AsyncAuditStream",
|
|
95
|
+
"AuditStream",
|
|
96
|
+
"ChainVerificationResult",
|
|
97
|
+
"DictKeyResolver",
|
|
98
|
+
"EnvKeyResolver",
|
|
99
|
+
"KeyResolver",
|
|
100
|
+
"StaticKeyResolver",
|
|
101
|
+
"_event_mentions_subject",
|
|
102
|
+
"assert_verified",
|
|
103
|
+
"check_key_expiry",
|
|
104
|
+
"derive_key",
|
|
105
|
+
"sign",
|
|
106
|
+
"validate_key_strength",
|
|
107
|
+
"verify",
|
|
108
|
+
"verify_chain",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# ChainVerificationResult
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class ChainVerificationResult:
|
|
119
|
+
"""Immutable result returned by :func:`verify_chain` and :meth:`AuditStream.verify`.
|
|
120
|
+
|
|
121
|
+
Attributes:
|
|
122
|
+
valid: ``True`` only if **all** signatures are valid, **no**
|
|
123
|
+
``prev_id`` linkage gaps exist, and no tampering was
|
|
124
|
+
found.
|
|
125
|
+
first_tampered: The ``event_id`` of the first event whose signature
|
|
126
|
+
did not verify, or ``None`` if the chain is clean.
|
|
127
|
+
gaps: List of ``event_id`` values where the expected
|
|
128
|
+
``prev_id`` linkage is broken — each entry represents
|
|
129
|
+
a potential deletion or reordering.
|
|
130
|
+
tampered_count: Total number of events with invalid signatures across
|
|
131
|
+
the entire chain.
|
|
132
|
+
tombstone_count: Number of ``AUDIT_TOMBSTONE`` events found in the chain.
|
|
133
|
+
tombstone_event_ids: Event IDs of all tombstone events.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
valid: bool
|
|
137
|
+
first_tampered: str | None
|
|
138
|
+
gaps: list[str]
|
|
139
|
+
tampered_count: int
|
|
140
|
+
tombstone_count: int = 0
|
|
141
|
+
tombstone_event_ids: list[str] = field(default_factory=list)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# Internal crypto helpers
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _canonical_payload_bytes(payload: dict[str, Any]) -> bytes:
|
|
150
|
+
"""Return compact, sorted UTF-8 JSON bytes for *payload*.
|
|
151
|
+
|
|
152
|
+
Uses ``sort_keys=True`` for determinism across Python versions and
|
|
153
|
+
``separators=(",", ":")`` to eliminate optional whitespace.
|
|
154
|
+
"""
|
|
155
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
|
156
|
+
"utf-8"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _compute_checksum(payload: dict[str, Any]) -> str:
|
|
161
|
+
"""Return ``"sha256:<hex>"`` digest of the canonical payload JSON."""
|
|
162
|
+
digest = hashlib.sha256(_canonical_payload_bytes(payload)).hexdigest()
|
|
163
|
+
return f"sha256:{digest}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _compute_signature(
|
|
167
|
+
event_id: str,
|
|
168
|
+
checksum: str,
|
|
169
|
+
prev_id: str | None,
|
|
170
|
+
org_secret: str,
|
|
171
|
+
) -> str:
|
|
172
|
+
"""Return ``"hmac-sha256:<hex>"`` signature.
|
|
173
|
+
|
|
174
|
+
Message is ``"{event_id}|{checksum}|{prev_id or ''}"`` encoded as UTF-8.
|
|
175
|
+
"""
|
|
176
|
+
msg = f"{event_id}|{checksum}|{prev_id or ''}"
|
|
177
|
+
mac = _hmac.new(
|
|
178
|
+
key=org_secret.encode("utf-8"),
|
|
179
|
+
msg=msg.encode("utf-8"),
|
|
180
|
+
digestmod=hashlib.sha256,
|
|
181
|
+
)
|
|
182
|
+
return f"hmac-sha256:{mac.hexdigest()}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _validate_secret(org_secret: str) -> None:
|
|
186
|
+
"""Raise :exc:`~spanforge.exceptions.SigningError` if *org_secret* is empty or whitespace-only.
|
|
187
|
+
|
|
188
|
+
Security: the value of *org_secret* is **never** included in the error
|
|
189
|
+
message.
|
|
190
|
+
"""
|
|
191
|
+
if not isinstance(org_secret, str) or not org_secret.strip():
|
|
192
|
+
raise SigningError("org_secret must be a non-empty, non-whitespace string")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_MIN_KEY_LENGTH = 32 # minimum bytes (256-bit) for production keys
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def validate_key_strength(org_secret: str, *, min_length: int | None = None) -> list[str]:
|
|
199
|
+
"""Check the signing key against strength requirements.
|
|
200
|
+
|
|
201
|
+
Returns a list of warning strings. Empty list = strong key.
|
|
202
|
+
|
|
203
|
+
Checks:
|
|
204
|
+
* Minimum length (default 32 chars / 256-bit, or ``SPANFORGE_SIGNING_KEY_MIN_BITS`` env var)
|
|
205
|
+
* Not all-same character
|
|
206
|
+
* Not a well-known placeholder
|
|
207
|
+
* Mixed character classes (upper, lower, digit, special)
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
org_secret: The signing key to check.
|
|
211
|
+
min_length: Minimum key length in characters. When ``None``, uses
|
|
212
|
+
``SPANFORGE_SIGNING_KEY_MIN_BITS / 8`` or falls back to 32.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List of human-readable warning strings. Empty if key is strong.
|
|
216
|
+
"""
|
|
217
|
+
import os as _os
|
|
218
|
+
|
|
219
|
+
if min_length is None:
|
|
220
|
+
raw_bits = _os.environ.get("SPANFORGE_SIGNING_KEY_MIN_BITS")
|
|
221
|
+
if raw_bits is not None:
|
|
222
|
+
try:
|
|
223
|
+
min_length = max(1, int(raw_bits) // 8)
|
|
224
|
+
except (ValueError, TypeError):
|
|
225
|
+
min_length = _MIN_KEY_LENGTH
|
|
226
|
+
else:
|
|
227
|
+
min_length = _MIN_KEY_LENGTH
|
|
228
|
+
|
|
229
|
+
warnings: list[str] = []
|
|
230
|
+
if len(org_secret) < min_length:
|
|
231
|
+
warnings.append(f"Key length {len(org_secret)} < minimum {min_length} characters")
|
|
232
|
+
if len(set(org_secret)) == 1:
|
|
233
|
+
warnings.append("Key consists of a single repeated character")
|
|
234
|
+
_weak_keys = {
|
|
235
|
+
"spanforge-default",
|
|
236
|
+
"secret",
|
|
237
|
+
"password",
|
|
238
|
+
"changeme",
|
|
239
|
+
"test",
|
|
240
|
+
"key",
|
|
241
|
+
"demo",
|
|
242
|
+
}
|
|
243
|
+
if org_secret.lower().strip() in _weak_keys:
|
|
244
|
+
warnings.append("Key matches a well-known placeholder")
|
|
245
|
+
# Mixed character class check: at least 2 of (upper, lower, digit, special)
|
|
246
|
+
has_upper = any(c.isupper() for c in org_secret)
|
|
247
|
+
has_lower = any(c.islower() for c in org_secret)
|
|
248
|
+
has_digit = any(c.isdigit() for c in org_secret)
|
|
249
|
+
has_special = any(not c.isalnum() for c in org_secret)
|
|
250
|
+
char_classes = sum([has_upper, has_lower, has_digit, has_special])
|
|
251
|
+
if char_classes < 2:
|
|
252
|
+
warnings.append(
|
|
253
|
+
f"Key uses only {char_classes} character class(es); "
|
|
254
|
+
"recommend at least 2 (upper, lower, digit, special)"
|
|
255
|
+
)
|
|
256
|
+
return warnings
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def check_key_expiry(expires_at: str | None) -> tuple[str, int]:
|
|
260
|
+
"""Check the signing key expiry status.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
expires_at: ISO-8601 datetime string, or ``None`` (no expiry).
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
A tuple of ``(status, days)`` where *status* is one of:
|
|
267
|
+
- ``"no_expiry"`` — no expiration configured (days=0)
|
|
268
|
+
- ``"expired"`` — key has expired (days = days since expiry)
|
|
269
|
+
- ``"expiring_soon"`` — key expires within 7 days (days = days remaining)
|
|
270
|
+
- ``"valid"`` — key is valid (days = days remaining)
|
|
271
|
+
"""
|
|
272
|
+
if expires_at is None:
|
|
273
|
+
return ("no_expiry", 0)
|
|
274
|
+
from datetime import datetime, timezone
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
expiry = datetime.fromisoformat(expires_at)
|
|
278
|
+
if expiry.tzinfo is None:
|
|
279
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
280
|
+
now = datetime.now(timezone.utc)
|
|
281
|
+
delta = expiry - now
|
|
282
|
+
days = delta.days
|
|
283
|
+
if days < 0:
|
|
284
|
+
return ("expired", abs(days))
|
|
285
|
+
if days <= 7:
|
|
286
|
+
return ("expiring_soon", days)
|
|
287
|
+
except (ValueError, TypeError):
|
|
288
|
+
return ("no_expiry", 0)
|
|
289
|
+
else:
|
|
290
|
+
return ("valid", days)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def derive_key(
|
|
294
|
+
passphrase: str,
|
|
295
|
+
salt: bytes | None = None,
|
|
296
|
+
iterations: int = 600_000,
|
|
297
|
+
*,
|
|
298
|
+
context: str | None = None,
|
|
299
|
+
) -> tuple[str, bytes]:
|
|
300
|
+
"""Derive a signing key from a passphrase using PBKDF2-HMAC-SHA256.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
passphrase: The human-memorable passphrase.
|
|
304
|
+
salt: 16-byte salt. A random salt is generated if ``None``.
|
|
305
|
+
iterations: PBKDF2 iteration count (default 600,000 per OWASP 2023).
|
|
306
|
+
context: Optional context string for environment isolation.
|
|
307
|
+
When provided, it is appended to the passphrase
|
|
308
|
+
(``passphrase + "|" + context``) before derivation,
|
|
309
|
+
ensuring the same passphrase yields different keys
|
|
310
|
+
for different environments (e.g. ``"staging"`` vs
|
|
311
|
+
``"production"``).
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Tuple of ``(derived_key_hex, salt_bytes)``.
|
|
315
|
+
|
|
316
|
+
Example::
|
|
317
|
+
|
|
318
|
+
key, salt = derive_key("my strong passphrase", context="production")
|
|
319
|
+
# Store salt alongside the config; use key as org_secret.
|
|
320
|
+
"""
|
|
321
|
+
import os as _os
|
|
322
|
+
|
|
323
|
+
if salt is None:
|
|
324
|
+
salt = _os.urandom(16)
|
|
325
|
+
effective_passphrase = passphrase
|
|
326
|
+
if context:
|
|
327
|
+
effective_passphrase = f"{passphrase}|{context}"
|
|
328
|
+
derived = hashlib.pbkdf2_hmac(
|
|
329
|
+
"sha256",
|
|
330
|
+
effective_passphrase.encode("utf-8"),
|
|
331
|
+
salt,
|
|
332
|
+
iterations,
|
|
333
|
+
dklen=32,
|
|
334
|
+
)
|
|
335
|
+
return derived.hex(), salt
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# Public signing API
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def sign(
|
|
344
|
+
event: Event,
|
|
345
|
+
org_secret: str,
|
|
346
|
+
prev_event: Event | None = None,
|
|
347
|
+
) -> Event:
|
|
348
|
+
"""Sign *event* and return a new event with ``checksum``, ``signature``, and ``prev_id`` set.
|
|
349
|
+
|
|
350
|
+
The original *event* is not mutated — a new
|
|
351
|
+
:class:`~spanforge.event.Event` instance is returned.
|
|
352
|
+
|
|
353
|
+
Signing steps::
|
|
354
|
+
|
|
355
|
+
checksum = sha256(canonical_payload_json)
|
|
356
|
+
sig_input = event_id + "|" + checksum + "|" + (prev_id or "")
|
|
357
|
+
signature = HMAC-SHA256(sig_input, org_secret)
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
event: The event to sign.
|
|
361
|
+
org_secret: HMAC signing key (non-empty string).
|
|
362
|
+
prev_event: The immediately preceding event in the audit chain, or
|
|
363
|
+
``None`` if *event* is the first in the chain.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
A new :class:`~spanforge.event.Event` with ``checksum``, ``signature``,
|
|
367
|
+
and (if *prev_event* is given) ``prev_id`` populated.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
SigningError: If *org_secret* is empty or whitespace-only.
|
|
371
|
+
|
|
372
|
+
Example::
|
|
373
|
+
|
|
374
|
+
signed = sign(event, org_secret="my-key")
|
|
375
|
+
assert signed.checksum.startswith("sha256:")
|
|
376
|
+
assert signed.signature.startswith("hmac-sha256:")
|
|
377
|
+
"""
|
|
378
|
+
# Deferred import to avoid circular dependency at module-load time.
|
|
379
|
+
from spanforge.event import Event
|
|
380
|
+
|
|
381
|
+
_validate_secret(org_secret)
|
|
382
|
+
|
|
383
|
+
# GA-01-B: Check key expiry before signing.
|
|
384
|
+
try:
|
|
385
|
+
from spanforge.config import get_config
|
|
386
|
+
|
|
387
|
+
_cfg = get_config()
|
|
388
|
+
if _cfg.signing_key_expires_at:
|
|
389
|
+
_expiry_status, _expiry_days = check_key_expiry(_cfg.signing_key_expires_at)
|
|
390
|
+
if _expiry_status == "expired":
|
|
391
|
+
raise SigningError(
|
|
392
|
+
f"Signing key expired {_expiry_days} day(s) ago — rotate key before signing"
|
|
393
|
+
)
|
|
394
|
+
except ImportError:
|
|
395
|
+
_log.debug("spanforge.config unavailable — signing key enforcement skipped")
|
|
396
|
+
try:
|
|
397
|
+
from spanforge.config import get_config as _get_config
|
|
398
|
+
|
|
399
|
+
if _get_config().require_org_id and not getattr(event, "org_id", None):
|
|
400
|
+
raise SigningError("require_org_id is enabled but event.org_id is None or empty")
|
|
401
|
+
except ImportError:
|
|
402
|
+
_log.debug("spanforge.config unavailable — org_id enforcement skipped")
|
|
403
|
+
|
|
404
|
+
prev_id: str | None = prev_event.event_id if prev_event is not None else None
|
|
405
|
+
checksum = _compute_checksum(dict(event.payload))
|
|
406
|
+
signature = _compute_signature(event.event_id, checksum, prev_id, org_secret)
|
|
407
|
+
|
|
408
|
+
return Event(
|
|
409
|
+
schema_version=event.schema_version,
|
|
410
|
+
event_id=event.event_id,
|
|
411
|
+
event_type=event.event_type,
|
|
412
|
+
timestamp=event.timestamp,
|
|
413
|
+
source=event.source,
|
|
414
|
+
payload=dict(event.payload),
|
|
415
|
+
trace_id=event.trace_id,
|
|
416
|
+
span_id=event.span_id,
|
|
417
|
+
parent_span_id=event.parent_span_id,
|
|
418
|
+
org_id=event.org_id,
|
|
419
|
+
team_id=event.team_id,
|
|
420
|
+
actor_id=event.actor_id,
|
|
421
|
+
session_id=event.session_id,
|
|
422
|
+
tags=event.tags,
|
|
423
|
+
checksum=checksum,
|
|
424
|
+
signature=signature,
|
|
425
|
+
prev_id=prev_id,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def verify(event: Event, org_secret: str) -> bool:
|
|
430
|
+
"""Verify the checksum and HMAC signature of a single signed event.
|
|
431
|
+
|
|
432
|
+
Uses :func:`hmac.compare_digest` for both comparisons to guard against
|
|
433
|
+
timing-based side-channel attacks.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
event: The event to verify.
|
|
437
|
+
org_secret: HMAC signing key used when the event was signed.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
``True`` if both the checksum and signature are cryptographically
|
|
441
|
+
valid. ``False`` if either fails (tampered payload, wrong key, or
|
|
442
|
+
missing checksum/signature).
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
SigningError: If *org_secret* is empty or whitespace-only.
|
|
446
|
+
|
|
447
|
+
Note:
|
|
448
|
+
This function deliberately returns ``False`` rather than raising for
|
|
449
|
+
tampered events — :func:`verify_chain` calls it in a loop and needs
|
|
450
|
+
to accumulate all failures. For the strict raising variant see
|
|
451
|
+
:func:`assert_verified`.
|
|
452
|
+
|
|
453
|
+
Example::
|
|
454
|
+
|
|
455
|
+
if not verify(event, org_secret="my-key"):
|
|
456
|
+
raise RuntimeError(f"Tampered event: {event.event_id}")
|
|
457
|
+
"""
|
|
458
|
+
_validate_secret(org_secret)
|
|
459
|
+
|
|
460
|
+
if event.checksum is None or event.signature is None:
|
|
461
|
+
return False
|
|
462
|
+
|
|
463
|
+
expected_checksum = _compute_checksum(dict(event.payload))
|
|
464
|
+
if not _hmac.compare_digest(event.checksum, expected_checksum):
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
expected_signature = _compute_signature(
|
|
468
|
+
event.event_id, event.checksum, event.prev_id, org_secret
|
|
469
|
+
)
|
|
470
|
+
return _hmac.compare_digest(event.signature, expected_signature)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def assert_verified(event: Event, org_secret: str) -> None:
|
|
474
|
+
"""Assert that *event* passes cryptographic verification.
|
|
475
|
+
|
|
476
|
+
Strict variant of :func:`verify` that raises instead of returning
|
|
477
|
+
``False``.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
event: The event to verify.
|
|
481
|
+
org_secret: HMAC signing key used when the event was signed.
|
|
482
|
+
|
|
483
|
+
Raises:
|
|
484
|
+
VerificationError: If :func:`verify` returns ``False``.
|
|
485
|
+
SigningError: If *org_secret* is empty or whitespace-only.
|
|
486
|
+
|
|
487
|
+
Example::
|
|
488
|
+
|
|
489
|
+
assert_verified(event, org_secret="my-key") # raises on tamper
|
|
490
|
+
"""
|
|
491
|
+
if not verify(event, org_secret):
|
|
492
|
+
raise VerificationError(event_id=event.event_id)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _check_event_signature(
|
|
496
|
+
event: Event,
|
|
497
|
+
current_secret: str,
|
|
498
|
+
first_tampered: str | None,
|
|
499
|
+
tampered_count: int,
|
|
500
|
+
) -> tuple[str | None, int]:
|
|
501
|
+
"""Check signature validity; returns updated (first_tampered, tampered_count)."""
|
|
502
|
+
if not verify(event, current_secret):
|
|
503
|
+
tampered_count += 1
|
|
504
|
+
if first_tampered is None:
|
|
505
|
+
first_tampered = event.event_id
|
|
506
|
+
return first_tampered, tampered_count
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _check_chain_linkage(
|
|
510
|
+
event: Event,
|
|
511
|
+
i: int,
|
|
512
|
+
event_list: list[Event],
|
|
513
|
+
gaps: list[str],
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Check chain linkage; appends to gaps if a gap is detected."""
|
|
516
|
+
if i == 0:
|
|
517
|
+
if event.prev_id is not None:
|
|
518
|
+
gaps.append(event.event_id)
|
|
519
|
+
else:
|
|
520
|
+
expected_prev = event_list[i - 1].event_id
|
|
521
|
+
if event.prev_id != expected_prev:
|
|
522
|
+
gaps.append(event.event_id)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def verify_chain(
|
|
526
|
+
events: Sequence[Event],
|
|
527
|
+
org_secret: str,
|
|
528
|
+
key_map: dict[str, str] | None = None,
|
|
529
|
+
*,
|
|
530
|
+
key_resolver: KeyResolver | None = None,
|
|
531
|
+
default_key: str | None = None,
|
|
532
|
+
) -> ChainVerificationResult:
|
|
533
|
+
"""Verify an entire ordered sequence of signed events as an audit chain.
|
|
534
|
+
|
|
535
|
+
Performs three checks per event:
|
|
536
|
+
|
|
537
|
+
1. **Signature validity** — recomputes checksum and HMAC; flags mismatches.
|
|
538
|
+
2. **Chain linkage** — ``events[n].prev_id == events[n-1].event_id``.
|
|
539
|
+
3. **Head integrity** — ``events[0].prev_id`` must be ``None`` (no missing
|
|
540
|
+
predecessor); non-``None`` signals an undetected gap at the head.
|
|
541
|
+
|
|
542
|
+
Key rotation
|
|
543
|
+
~~~~~~~~~~~~
|
|
544
|
+
Pass ``key_map`` to handle chains that span a key rotation. The dict maps
|
|
545
|
+
a rotation event's ``event_id`` to the new secret that takes effect
|
|
546
|
+
**after** that event is verified::
|
|
547
|
+
|
|
548
|
+
result = verify_chain(events, org_secret="old-key",
|
|
549
|
+
key_map={"<rotation_event_id>": "new-key"})
|
|
550
|
+
|
|
551
|
+
Multi-tenant verification
|
|
552
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
553
|
+
Pass ``key_resolver`` to verify chains containing events from different
|
|
554
|
+
orgs. For each event with an ``org_id``, the resolver provides the
|
|
555
|
+
corresponding secret. Events without ``org_id`` use *default_key* (falls
|
|
556
|
+
back to *org_secret*).
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
events: Ordered sequence of events (earliest first). May be
|
|
560
|
+
empty — returns ``valid=True`` with no failures.
|
|
561
|
+
org_secret: HMAC signing key for the first chain segment.
|
|
562
|
+
key_map: Optional ``{rotation_event_id: new_secret}`` dict
|
|
563
|
+
enabling multi-segment verification after key rotation.
|
|
564
|
+
key_resolver: Optional :class:`KeyResolver` for per-org key resolution.
|
|
565
|
+
default_key: Fallback key for events without ``org_id`` when using
|
|
566
|
+
*key_resolver*. Defaults to *org_secret*.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
A :class:`ChainVerificationResult` with ``valid``, ``first_tampered``,
|
|
570
|
+
``gaps``, and ``tampered_count``.
|
|
571
|
+
|
|
572
|
+
Raises:
|
|
573
|
+
SigningError: If *org_secret* (or any value in *key_map*) is empty.
|
|
574
|
+
|
|
575
|
+
Example::
|
|
576
|
+
|
|
577
|
+
result = verify_chain(signed_events, org_secret="my-key")
|
|
578
|
+
if not result.valid:
|
|
579
|
+
print(f"First tampered: {result.first_tampered}")
|
|
580
|
+
print(f"Gaps (deleted events): {result.gaps}")
|
|
581
|
+
"""
|
|
582
|
+
_validate_secret(org_secret)
|
|
583
|
+
if key_map:
|
|
584
|
+
for new_secret in key_map.values():
|
|
585
|
+
_validate_secret(new_secret)
|
|
586
|
+
|
|
587
|
+
_default_key = default_key if default_key is not None else org_secret
|
|
588
|
+
current_secret = org_secret
|
|
589
|
+
km = key_map or {}
|
|
590
|
+
|
|
591
|
+
first_tampered: str | None = None
|
|
592
|
+
gaps: list[str] = []
|
|
593
|
+
tampered_count = 0
|
|
594
|
+
tombstone_count = 0
|
|
595
|
+
tombstone_event_ids: list[str] = []
|
|
596
|
+
|
|
597
|
+
_tombstone_type = "llm.audit.tombstone"
|
|
598
|
+
|
|
599
|
+
event_list = list(events)
|
|
600
|
+
|
|
601
|
+
for i, event in enumerate(event_list):
|
|
602
|
+
# Determine the secret to use for this event
|
|
603
|
+
verify_secret = current_secret
|
|
604
|
+
if key_resolver is not None:
|
|
605
|
+
oid = getattr(event, "org_id", None)
|
|
606
|
+
if oid:
|
|
607
|
+
try:
|
|
608
|
+
verify_secret = key_resolver.resolve(oid)
|
|
609
|
+
except Exception:
|
|
610
|
+
# If resolver fails, use default key
|
|
611
|
+
verify_secret = _default_key
|
|
612
|
+
else:
|
|
613
|
+
verify_secret = _default_key
|
|
614
|
+
|
|
615
|
+
first_tampered, tampered_count = _check_event_signature(
|
|
616
|
+
event, verify_secret, first_tampered, tampered_count
|
|
617
|
+
)
|
|
618
|
+
_check_chain_linkage(event, i, event_list, gaps)
|
|
619
|
+
if event.event_id in km:
|
|
620
|
+
current_secret = km[event.event_id]
|
|
621
|
+
|
|
622
|
+
# Track tombstone events
|
|
623
|
+
evt_type = str(event.event_type) if event.event_type else ""
|
|
624
|
+
if evt_type == _tombstone_type:
|
|
625
|
+
tombstone_count += 1
|
|
626
|
+
tombstone_event_ids.append(event.event_id)
|
|
627
|
+
|
|
628
|
+
valid = tampered_count == 0 and len(gaps) == 0
|
|
629
|
+
return ChainVerificationResult(
|
|
630
|
+
valid=valid,
|
|
631
|
+
first_tampered=first_tampered,
|
|
632
|
+
gaps=gaps,
|
|
633
|
+
tampered_count=tampered_count,
|
|
634
|
+
tombstone_count=tombstone_count,
|
|
635
|
+
tombstone_event_ids=tombstone_event_ids,
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# ---------------------------------------------------------------------------
|
|
640
|
+
# Subject mention helper
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _event_mentions_subject(event: Event, subject_id: str) -> bool:
|
|
645
|
+
"""Return ``True`` if *event* contains *subject_id* in searchable fields.
|
|
646
|
+
|
|
647
|
+
Scans ``actor_id``, ``session_id``, and flattened payload values.
|
|
648
|
+
Uses **exact** match to avoid false positives from substring overlap.
|
|
649
|
+
"""
|
|
650
|
+
if event.actor_id and event.actor_id == subject_id:
|
|
651
|
+
return True
|
|
652
|
+
if event.session_id and event.session_id == subject_id:
|
|
653
|
+
return True
|
|
654
|
+
# Scan payload values (shallow — one level deep)
|
|
655
|
+
payload = event.payload or {}
|
|
656
|
+
return any(isinstance(v, str) and v == subject_id for v in payload.values())
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# ---------------------------------------------------------------------------
|
|
660
|
+
# GA-04: Multi-tenant Key Resolvers
|
|
661
|
+
# ---------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
@_runtime_checkable
|
|
665
|
+
class KeyResolver(_Protocol):
|
|
666
|
+
"""Protocol for resolving signing keys per-org in multi-tenant setups.
|
|
667
|
+
|
|
668
|
+
Implementations must return a non-empty string secret for the given
|
|
669
|
+
``org_id``. Raise :exc:`~spanforge.exceptions.SigningError` if the key
|
|
670
|
+
cannot be resolved.
|
|
671
|
+
"""
|
|
672
|
+
|
|
673
|
+
def resolve(self, org_id: str) -> str:
|
|
674
|
+
"""Return the signing secret for *org_id*."""
|
|
675
|
+
... # pragma: no cover
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
class StaticKeyResolver:
|
|
679
|
+
"""Resolves every org to the same static key.
|
|
680
|
+
|
|
681
|
+
Useful for single-tenant deployments or testing.
|
|
682
|
+
"""
|
|
683
|
+
|
|
684
|
+
__slots__ = ("_secret",)
|
|
685
|
+
|
|
686
|
+
def __init__(self, secret: str) -> None:
|
|
687
|
+
_validate_secret(secret)
|
|
688
|
+
self._secret = secret
|
|
689
|
+
|
|
690
|
+
def resolve(self, org_id: str) -> str:
|
|
691
|
+
"""Return the static signing secret, ignoring org_id."""
|
|
692
|
+
return self._secret
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
class EnvKeyResolver:
|
|
696
|
+
"""Resolves the signing key from an environment variable.
|
|
697
|
+
|
|
698
|
+
The env var name is derived from the org_id:
|
|
699
|
+
``{prefix}{org_id.upper().replace('-', '_')}``.
|
|
700
|
+
|
|
701
|
+
Args:
|
|
702
|
+
prefix: Env var prefix (default ``"SPANFORGE_KEY_"``).
|
|
703
|
+
"""
|
|
704
|
+
|
|
705
|
+
__slots__ = ("_prefix",)
|
|
706
|
+
|
|
707
|
+
def __init__(self, prefix: str = "SPANFORGE_KEY_") -> None:
|
|
708
|
+
self._prefix = prefix
|
|
709
|
+
|
|
710
|
+
def resolve(self, org_id: str) -> str:
|
|
711
|
+
"""Look up the signing key from the environment variable for org_id."""
|
|
712
|
+
import os as _os
|
|
713
|
+
|
|
714
|
+
var_name = self._prefix + org_id.upper().replace("-", "_")
|
|
715
|
+
secret = _os.environ.get(var_name, "")
|
|
716
|
+
if not secret.strip():
|
|
717
|
+
raise SigningError(
|
|
718
|
+
f"No signing key found for org '{org_id}' (expected env var {var_name})"
|
|
719
|
+
)
|
|
720
|
+
return secret
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
class DictKeyResolver:
|
|
724
|
+
"""Resolves signing keys from an in-memory dictionary.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
keys: Mapping of ``org_id`` → signing secret.
|
|
728
|
+
"""
|
|
729
|
+
|
|
730
|
+
__slots__ = ("_keys",)
|
|
731
|
+
|
|
732
|
+
def __init__(self, keys: dict[str, str]) -> None:
|
|
733
|
+
for secret in keys.values():
|
|
734
|
+
_validate_secret(secret)
|
|
735
|
+
self._keys = dict(keys)
|
|
736
|
+
|
|
737
|
+
def resolve(self, org_id: str) -> str:
|
|
738
|
+
"""Look up the signing key for org_id from the in-memory dictionary."""
|
|
739
|
+
secret = self._keys.get(org_id)
|
|
740
|
+
if not secret:
|
|
741
|
+
raise SigningError(f"No signing key found for org '{org_id}' in key map")
|
|
742
|
+
return secret
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# ---------------------------------------------------------------------------
|
|
746
|
+
# AuditStream
|
|
747
|
+
# ---------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
class AuditStream:
|
|
751
|
+
"""Tamper-evident HMAC-signed audit chain stream.
|
|
752
|
+
|
|
753
|
+
Sequential event stream that HMAC-signs every appended event and links
|
|
754
|
+
them via ``prev_id``, forming a tamper-evident audit chain.
|
|
755
|
+
|
|
756
|
+
The signing secret is **never** exposed in :func:`repr`, :func:`str`, or
|
|
757
|
+
any exception message.
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
org_secret: HMAC signing key (non-empty string).
|
|
761
|
+
source: The ``source`` field used for auto-generated audit events
|
|
762
|
+
such as key-rotation events. Must follow the
|
|
763
|
+
``tool-name@x.y.z`` format accepted by
|
|
764
|
+
:class:`~spanforge.event.Event`.
|
|
765
|
+
|
|
766
|
+
Raises:
|
|
767
|
+
SigningError: If *org_secret* is empty or whitespace-only.
|
|
768
|
+
|
|
769
|
+
Example::
|
|
770
|
+
|
|
771
|
+
stream = AuditStream(org_secret="corp-key", source="audit-daemon@1.0.0")
|
|
772
|
+
for event in events:
|
|
773
|
+
stream.append(event)
|
|
774
|
+
stream.rotate_key("corp-key-v2", metadata={"reason": "scheduled"})
|
|
775
|
+
result = stream.verify()
|
|
776
|
+
assert result.valid
|
|
777
|
+
"""
|
|
778
|
+
|
|
779
|
+
_events: list[Event]
|
|
780
|
+
_initial_secret: str
|
|
781
|
+
_key_map: dict[str, str]
|
|
782
|
+
_key_resolver: KeyResolver | None
|
|
783
|
+
_lock: threading.RLock
|
|
784
|
+
_org_secret: str
|
|
785
|
+
_require_org_id: bool
|
|
786
|
+
_source: str
|
|
787
|
+
|
|
788
|
+
__slots__ = (
|
|
789
|
+
"_events",
|
|
790
|
+
"_initial_secret",
|
|
791
|
+
"_key_map",
|
|
792
|
+
"_key_resolver",
|
|
793
|
+
"_lock",
|
|
794
|
+
"_org_secret",
|
|
795
|
+
"_require_org_id",
|
|
796
|
+
"_source",
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
def __init__(
|
|
800
|
+
self,
|
|
801
|
+
org_secret: str,
|
|
802
|
+
source: str,
|
|
803
|
+
*,
|
|
804
|
+
key_resolver: KeyResolver | None = None,
|
|
805
|
+
require_org_id: bool = False,
|
|
806
|
+
) -> None:
|
|
807
|
+
_validate_secret(org_secret)
|
|
808
|
+
object.__setattr__(self, "_initial_secret", org_secret)
|
|
809
|
+
object.__setattr__(self, "_org_secret", org_secret)
|
|
810
|
+
object.__setattr__(self, "_source", source)
|
|
811
|
+
object.__setattr__(self, "_events", [])
|
|
812
|
+
# maps rotation_event_id → new_secret for verify()
|
|
813
|
+
object.__setattr__(self, "_key_map", {})
|
|
814
|
+
object.__setattr__(self, "_key_resolver", key_resolver)
|
|
815
|
+
object.__setattr__(self, "_require_org_id", require_org_id)
|
|
816
|
+
# Protects _events list and _org_secret during concurrent appends / rotations.
|
|
817
|
+
object.__setattr__(self, "_lock", __import__("threading").RLock())
|
|
818
|
+
|
|
819
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
820
|
+
"""Block external attribute mutation.
|
|
821
|
+
|
|
822
|
+
Internal code uses :func:`object.__setattr__` directly.
|
|
823
|
+
"""
|
|
824
|
+
raise AttributeError(
|
|
825
|
+
f"AuditStream is immutable externally — attribute '{name}' cannot be set. "
|
|
826
|
+
"Use append() or rotate_key() to modify the stream."
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def __repr__(self) -> str:
|
|
830
|
+
"""Safe repr that never exposes the signing secret."""
|
|
831
|
+
return f"<AuditStream events={len(self._events)}>"
|
|
832
|
+
|
|
833
|
+
def __str__(self) -> str:
|
|
834
|
+
return f"<AuditStream events={len(self._events)}>"
|
|
835
|
+
|
|
836
|
+
def __len__(self) -> int:
|
|
837
|
+
return len(self._events)
|
|
838
|
+
|
|
839
|
+
# ------------------------------------------------------------------
|
|
840
|
+
# Properties
|
|
841
|
+
# ------------------------------------------------------------------
|
|
842
|
+
|
|
843
|
+
@property
|
|
844
|
+
def events(self) -> list[Event]:
|
|
845
|
+
"""A read-only copy of all signed events in the stream.
|
|
846
|
+
|
|
847
|
+
Returns a new list each call so callers cannot mutate the internal
|
|
848
|
+
state.
|
|
849
|
+
"""
|
|
850
|
+
return list(self._events)
|
|
851
|
+
|
|
852
|
+
# ------------------------------------------------------------------
|
|
853
|
+
# Mutation methods (guarded)
|
|
854
|
+
# ------------------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
def append(self, event: Event) -> Event:
|
|
857
|
+
"""Sign *event*, link it to the chain, append, and return the signed event.
|
|
858
|
+
|
|
859
|
+
The given *event* is not mutated. A new
|
|
860
|
+
:class:`~spanforge.event.Event` with ``checksum``, ``signature``, and
|
|
861
|
+
``prev_id`` set is returned **and** stored in the stream.
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
event: The unsigned (or partially signed) event to add.
|
|
865
|
+
|
|
866
|
+
Returns:
|
|
867
|
+
The freshly signed event with full chain linkage.
|
|
868
|
+
|
|
869
|
+
Raises:
|
|
870
|
+
SigningError: If the current signing key is somehow invalid
|
|
871
|
+
(should not happen if the stream was constructed
|
|
872
|
+
correctly).
|
|
873
|
+
"""
|
|
874
|
+
with self._lock:
|
|
875
|
+
# GA-04-C: Enforce require_org_id at the stream level.
|
|
876
|
+
if self._require_org_id and not getattr(event, "org_id", None):
|
|
877
|
+
raise SigningError("require_org_id is enabled but event.org_id is None or empty")
|
|
878
|
+
# If a key_resolver is configured and the event has an org_id, use it.
|
|
879
|
+
resolver: KeyResolver | None = self._key_resolver
|
|
880
|
+
secret: str = self._org_secret
|
|
881
|
+
if resolver is not None and event.org_id is not None:
|
|
882
|
+
secret = resolver.resolve(event.org_id)
|
|
883
|
+
events_list: list[Event] = self._events
|
|
884
|
+
prev_event: Event | None = events_list[-1] if events_list else None
|
|
885
|
+
# GA-06-A: Sign and append under the same lock to guarantee
|
|
886
|
+
# prev_event linkage is never stale under concurrent appends.
|
|
887
|
+
signed = sign(event, secret, prev_event=prev_event)
|
|
888
|
+
events_list.append(signed)
|
|
889
|
+
return signed
|
|
890
|
+
|
|
891
|
+
def rotate_key(
|
|
892
|
+
self,
|
|
893
|
+
new_secret: str,
|
|
894
|
+
metadata: dict[str, str] | None = None,
|
|
895
|
+
) -> Event:
|
|
896
|
+
"""Rotate the signing key: append a key-rotation event and switch keys.
|
|
897
|
+
|
|
898
|
+
The key-rotation event is signed with the **current** key, ensuring
|
|
899
|
+
continuity of the chain at the rotation boundary. All events appended
|
|
900
|
+
after this call are signed with *new_secret*.
|
|
901
|
+
|
|
902
|
+
Args:
|
|
903
|
+
new_secret: The new HMAC signing key (non-empty string).
|
|
904
|
+
metadata: Optional ``str → str`` payload fields for the
|
|
905
|
+
rotation event (e.g. ``{"reason": "scheduled",
|
|
906
|
+
"rotated_by": "ops-team"}``).
|
|
907
|
+
|
|
908
|
+
Returns:
|
|
909
|
+
The signed key-rotation :class:`~spanforge.event.Event`.
|
|
910
|
+
|
|
911
|
+
Raises:
|
|
912
|
+
SigningError: If *new_secret* is empty or whitespace-only.
|
|
913
|
+
|
|
914
|
+
Example::
|
|
915
|
+
|
|
916
|
+
stream.rotate_key("new-secret-v2", metadata={"reason": "annual"})
|
|
917
|
+
"""
|
|
918
|
+
# Deferred imports to avoid circular dependency at module-load time.
|
|
919
|
+
from spanforge.event import Event
|
|
920
|
+
from spanforge.types import EventType
|
|
921
|
+
|
|
922
|
+
_validate_secret(new_secret)
|
|
923
|
+
|
|
924
|
+
# Include SHA-256 hash of new secret so verifiers can detect key substitution.
|
|
925
|
+
new_secret_hash = hashlib.sha256(new_secret.encode("utf-8")).hexdigest()
|
|
926
|
+
payload: dict[str, str] = {
|
|
927
|
+
"rotation_marker": "true",
|
|
928
|
+
"new_secret_hash": new_secret_hash,
|
|
929
|
+
}
|
|
930
|
+
if metadata:
|
|
931
|
+
payload.update(metadata)
|
|
932
|
+
# Ensure rotated_by is always present for audit trail completeness.
|
|
933
|
+
if "rotated_by" not in payload:
|
|
934
|
+
payload["rotated_by"] = "unknown"
|
|
935
|
+
|
|
936
|
+
rotation_event = Event(
|
|
937
|
+
event_type=EventType.AUDIT_KEY_ROTATED,
|
|
938
|
+
source=self._source,
|
|
939
|
+
payload=payload,
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
# Hold the lock for the entire sign + key-switch so that no other
|
|
943
|
+
# thread can append events between the rotation event and the first
|
|
944
|
+
# event signed with the new key.
|
|
945
|
+
with self._lock:
|
|
946
|
+
events_list: list[Event] = self._events
|
|
947
|
+
prev_event: Event | None = events_list[-1] if events_list else None
|
|
948
|
+
signed_rotation = sign(rotation_event, self._org_secret, prev_event=prev_event)
|
|
949
|
+
events_list.append(signed_rotation)
|
|
950
|
+
|
|
951
|
+
# After this event_id, use new_secret for subsequent events.
|
|
952
|
+
# Both key_map update and secret switch happen inside the lock
|
|
953
|
+
# so other threads see a consistent state.
|
|
954
|
+
key_map: dict[str, str] = self._key_map
|
|
955
|
+
key_map[signed_rotation.event_id] = new_secret
|
|
956
|
+
object.__setattr__(self, "_org_secret", new_secret)
|
|
957
|
+
|
|
958
|
+
return signed_rotation
|
|
959
|
+
|
|
960
|
+
# ------------------------------------------------------------------
|
|
961
|
+
# Verification
|
|
962
|
+
# ------------------------------------------------------------------
|
|
963
|
+
|
|
964
|
+
def verify(self) -> ChainVerificationResult:
|
|
965
|
+
"""Verify the entire chain, respecting any key-rotation boundaries.
|
|
966
|
+
|
|
967
|
+
Internally calls :func:`verify_chain` with the initial secret and the
|
|
968
|
+
accumulated ``key_map`` from all :meth:`rotate_key` calls.
|
|
969
|
+
|
|
970
|
+
Returns:
|
|
971
|
+
A :class:`ChainVerificationResult` reflecting the state of the
|
|
972
|
+
complete chain.
|
|
973
|
+
"""
|
|
974
|
+
key_map: dict[str, str] = self._key_map
|
|
975
|
+
return verify_chain(
|
|
976
|
+
self._events,
|
|
977
|
+
org_secret=self._initial_secret,
|
|
978
|
+
key_map=dict(key_map) if key_map else None,
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
def erase_subject(
|
|
982
|
+
self,
|
|
983
|
+
subject_id: str,
|
|
984
|
+
*,
|
|
985
|
+
erased_by: str = "unknown",
|
|
986
|
+
reason: str = "GDPR Art.17 right to erasure",
|
|
987
|
+
request_ref: str = "",
|
|
988
|
+
) -> list[Event]:
|
|
989
|
+
"""Replace all events mentioning *subject_id* with TOMBSTONE events.
|
|
990
|
+
|
|
991
|
+
Scans the chain for events whose ``actor_id``, ``session_id``, or
|
|
992
|
+
payload values contain *subject_id*. Each matched event is replaced
|
|
993
|
+
in-place with an ``AUDIT_TOMBSTONE`` event that preserves the original
|
|
994
|
+
``event_id`` and chain linkage (``prev_id``, ``checksum``,
|
|
995
|
+
``signature``) so the chain remains verifiable.
|
|
996
|
+
|
|
997
|
+
The tombstone payload records the original ``event_type`` and reason.
|
|
998
|
+
**No PII is retained** in the tombstone.
|
|
999
|
+
|
|
1000
|
+
Args:
|
|
1001
|
+
subject_id: The data-subject identifier to erase (e.g. user ID,
|
|
1002
|
+
email, or session token).
|
|
1003
|
+
erased_by: Identity of the operator performing the erasure.
|
|
1004
|
+
reason: Free-text reason recorded in each tombstone.
|
|
1005
|
+
request_ref: External reference for the erasure request
|
|
1006
|
+
(e.g. ticket ID or GDPR request number).
|
|
1007
|
+
|
|
1008
|
+
Returns:
|
|
1009
|
+
List of the tombstone :class:`Event` instances that replaced
|
|
1010
|
+
original events. Empty if no matches were found.
|
|
1011
|
+
"""
|
|
1012
|
+
from spanforge.event import Event as _Event
|
|
1013
|
+
from spanforge.types import EventType
|
|
1014
|
+
|
|
1015
|
+
tombstones: list[Event] = []
|
|
1016
|
+
with self._lock:
|
|
1017
|
+
events_list: list[Event] = self._events
|
|
1018
|
+
for idx, event in enumerate(events_list):
|
|
1019
|
+
if not _event_mentions_subject(event, subject_id):
|
|
1020
|
+
continue
|
|
1021
|
+
|
|
1022
|
+
# Build tombstone payload — no PII
|
|
1023
|
+
tombstone_payload = {
|
|
1024
|
+
"erased_subject_id_hash": hashlib.sha256(
|
|
1025
|
+
subject_id.encode("utf-8")
|
|
1026
|
+
).hexdigest(),
|
|
1027
|
+
"original_event_type": str(event.event_type),
|
|
1028
|
+
"erased_by": erased_by,
|
|
1029
|
+
"reason": reason,
|
|
1030
|
+
}
|
|
1031
|
+
if request_ref:
|
|
1032
|
+
tombstone_payload["erasure_request_ref"] = request_ref
|
|
1033
|
+
|
|
1034
|
+
tombstone = _Event(
|
|
1035
|
+
schema_version=event.schema_version,
|
|
1036
|
+
event_id=event.event_id,
|
|
1037
|
+
event_type=EventType.AUDIT_TOMBSTONE.value,
|
|
1038
|
+
timestamp=event.timestamp,
|
|
1039
|
+
source=self._source,
|
|
1040
|
+
payload=tombstone_payload,
|
|
1041
|
+
trace_id=event.trace_id,
|
|
1042
|
+
span_id=event.span_id,
|
|
1043
|
+
parent_span_id=event.parent_span_id,
|
|
1044
|
+
org_id=event.org_id,
|
|
1045
|
+
team_id=event.team_id,
|
|
1046
|
+
actor_id=None,
|
|
1047
|
+
session_id=None,
|
|
1048
|
+
tags=event.tags,
|
|
1049
|
+
checksum=event.checksum,
|
|
1050
|
+
signature=event.signature,
|
|
1051
|
+
prev_id=event.prev_id,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
# Re-sign to maintain chain integrity
|
|
1055
|
+
prev_event: Event | None = events_list[idx - 1] if idx > 0 else None
|
|
1056
|
+
signed_tombstone = sign(tombstone, self._org_secret, prev_event=prev_event)
|
|
1057
|
+
|
|
1058
|
+
events_list[idx] = signed_tombstone
|
|
1059
|
+
tombstones.append(signed_tombstone)
|
|
1060
|
+
|
|
1061
|
+
# Re-sign subsequent event to maintain linkage
|
|
1062
|
+
if idx + 1 < len(events_list):
|
|
1063
|
+
next_evt = events_list[idx + 1]
|
|
1064
|
+
re_signed = sign(next_evt, self._org_secret, prev_event=signed_tombstone)
|
|
1065
|
+
events_list[idx + 1] = re_signed
|
|
1066
|
+
|
|
1067
|
+
return tombstones
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
# ---------------------------------------------------------------------------
|
|
1071
|
+
# GA-06: AsyncAuditStream — asyncio-native audit chain
|
|
1072
|
+
# ---------------------------------------------------------------------------
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
class AsyncAuditStream:
|
|
1076
|
+
"""Asyncio-native tamper-evident HMAC-signed audit chain.
|
|
1077
|
+
|
|
1078
|
+
Mirrors the API of :class:`AuditStream` but uses :class:`asyncio.Lock`
|
|
1079
|
+
instead of :class:`threading.RLock`, making it safe for ``async def``
|
|
1080
|
+
code paths without blocking the event loop.
|
|
1081
|
+
|
|
1082
|
+
Args:
|
|
1083
|
+
org_secret: HMAC signing key (non-empty string).
|
|
1084
|
+
source: The ``source`` field for auto-generated audit events.
|
|
1085
|
+
key_resolver: Optional :class:`KeyResolver` for multi-tenant setups.
|
|
1086
|
+
|
|
1087
|
+
Example::
|
|
1088
|
+
|
|
1089
|
+
stream = AsyncAuditStream(org_secret="key", source="svc@1.0.0")
|
|
1090
|
+
signed = await stream.append(event)
|
|
1091
|
+
result = await stream.verify()
|
|
1092
|
+
"""
|
|
1093
|
+
|
|
1094
|
+
__slots__ = (
|
|
1095
|
+
"_events",
|
|
1096
|
+
"_initial_secret",
|
|
1097
|
+
"_key_map",
|
|
1098
|
+
"_key_resolver",
|
|
1099
|
+
"_lock",
|
|
1100
|
+
"_org_secret",
|
|
1101
|
+
"_source",
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
def __init__(
|
|
1105
|
+
self,
|
|
1106
|
+
org_secret: str,
|
|
1107
|
+
source: str,
|
|
1108
|
+
*,
|
|
1109
|
+
key_resolver: KeyResolver | None = None,
|
|
1110
|
+
) -> None:
|
|
1111
|
+
import asyncio
|
|
1112
|
+
|
|
1113
|
+
_validate_secret(org_secret)
|
|
1114
|
+
self._initial_secret = org_secret
|
|
1115
|
+
self._org_secret = org_secret
|
|
1116
|
+
self._source = source
|
|
1117
|
+
self._events: list[Any] = []
|
|
1118
|
+
self._key_map: dict[str, str] = {}
|
|
1119
|
+
self._key_resolver = key_resolver
|
|
1120
|
+
self._lock = asyncio.Lock()
|
|
1121
|
+
|
|
1122
|
+
def __repr__(self) -> str:
|
|
1123
|
+
return f"<AsyncAuditStream events={len(self._events)}>"
|
|
1124
|
+
|
|
1125
|
+
def __len__(self) -> int:
|
|
1126
|
+
return len(self._events)
|
|
1127
|
+
|
|
1128
|
+
@property
|
|
1129
|
+
def events(self) -> list[Any]:
|
|
1130
|
+
"""A read-only copy of all signed events."""
|
|
1131
|
+
return list(self._events)
|
|
1132
|
+
|
|
1133
|
+
async def append(self, event: Any) -> Any:
|
|
1134
|
+
"""Sign *event*, link it to the chain, and return the signed event."""
|
|
1135
|
+
async with self._lock:
|
|
1136
|
+
resolver = self._key_resolver
|
|
1137
|
+
secret = self._org_secret
|
|
1138
|
+
if resolver is not None and getattr(event, "org_id", None):
|
|
1139
|
+
secret = resolver.resolve(event.org_id)
|
|
1140
|
+
prev_event = self._events[-1] if self._events else None
|
|
1141
|
+
signed = sign(event, secret, prev_event=prev_event)
|
|
1142
|
+
self._events.append(signed)
|
|
1143
|
+
return signed
|
|
1144
|
+
|
|
1145
|
+
async def rotate_key(
|
|
1146
|
+
self,
|
|
1147
|
+
new_secret: str,
|
|
1148
|
+
metadata: dict[str, str] | None = None,
|
|
1149
|
+
) -> Any:
|
|
1150
|
+
"""Rotate the signing key (async version)."""
|
|
1151
|
+
from spanforge.event import Event
|
|
1152
|
+
from spanforge.types import EventType
|
|
1153
|
+
|
|
1154
|
+
_validate_secret(new_secret)
|
|
1155
|
+
async with self._lock:
|
|
1156
|
+
rotation_payload = {"action": "key_rotation"}
|
|
1157
|
+
if metadata:
|
|
1158
|
+
rotation_payload.update(metadata)
|
|
1159
|
+
|
|
1160
|
+
rotation_event = Event(
|
|
1161
|
+
event_type=EventType.AUDIT_KEY_ROTATED,
|
|
1162
|
+
source=self._source,
|
|
1163
|
+
payload=rotation_payload,
|
|
1164
|
+
)
|
|
1165
|
+
prev_event = self._events[-1] if self._events else None
|
|
1166
|
+
signed_rotation = sign(rotation_event, self._org_secret, prev_event=prev_event)
|
|
1167
|
+
self._events.append(signed_rotation)
|
|
1168
|
+
self._key_map[signed_rotation.event_id] = new_secret
|
|
1169
|
+
self._org_secret = new_secret
|
|
1170
|
+
return signed_rotation
|
|
1171
|
+
|
|
1172
|
+
async def verify(self) -> ChainVerificationResult:
|
|
1173
|
+
"""Verify the full chain (async wrapper)."""
|
|
1174
|
+
async with self._lock:
|
|
1175
|
+
return verify_chain(
|
|
1176
|
+
self._events,
|
|
1177
|
+
self._initial_secret,
|
|
1178
|
+
key_map=self._key_map,
|
|
1179
|
+
)
|