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.
Files changed (174) hide show
  1. spanforge/__init__.py +815 -0
  2. spanforge/_ansi.py +93 -0
  3. spanforge/_batch_exporter.py +409 -0
  4. spanforge/_cli.py +2094 -0
  5. spanforge/_cli_audit.py +639 -0
  6. spanforge/_cli_compliance.py +711 -0
  7. spanforge/_cli_cost.py +243 -0
  8. spanforge/_cli_ops.py +791 -0
  9. spanforge/_cli_phase11.py +356 -0
  10. spanforge/_hooks.py +337 -0
  11. spanforge/_server.py +1708 -0
  12. spanforge/_span.py +1036 -0
  13. spanforge/_store.py +288 -0
  14. spanforge/_stream.py +664 -0
  15. spanforge/_trace.py +335 -0
  16. spanforge/_tracer.py +254 -0
  17. spanforge/actor.py +141 -0
  18. spanforge/alerts.py +469 -0
  19. spanforge/auto.py +464 -0
  20. spanforge/baseline.py +335 -0
  21. spanforge/cache.py +635 -0
  22. spanforge/compliance.py +325 -0
  23. spanforge/config.py +532 -0
  24. spanforge/consent.py +228 -0
  25. spanforge/consumer.py +377 -0
  26. spanforge/core/__init__.py +5 -0
  27. spanforge/core/compliance_mapping.py +1254 -0
  28. spanforge/cost.py +600 -0
  29. spanforge/debug.py +548 -0
  30. spanforge/deprecations.py +205 -0
  31. spanforge/drift.py +482 -0
  32. spanforge/egress.py +58 -0
  33. spanforge/eval.py +648 -0
  34. spanforge/event.py +1064 -0
  35. spanforge/exceptions.py +240 -0
  36. spanforge/explain.py +178 -0
  37. spanforge/export/__init__.py +69 -0
  38. spanforge/export/append_only.py +337 -0
  39. spanforge/export/cloud.py +357 -0
  40. spanforge/export/datadog.py +497 -0
  41. spanforge/export/grafana.py +320 -0
  42. spanforge/export/jsonl.py +195 -0
  43. spanforge/export/openinference.py +158 -0
  44. spanforge/export/otel_bridge.py +294 -0
  45. spanforge/export/otlp.py +811 -0
  46. spanforge/export/otlp_bridge.py +233 -0
  47. spanforge/export/redis_backend.py +282 -0
  48. spanforge/export/siem_schema.py +98 -0
  49. spanforge/export/siem_splunk.py +264 -0
  50. spanforge/export/siem_syslog.py +212 -0
  51. spanforge/export/webhook.py +299 -0
  52. spanforge/exporters/__init__.py +30 -0
  53. spanforge/exporters/console.py +271 -0
  54. spanforge/exporters/jsonl.py +144 -0
  55. spanforge/exporters/sqlite.py +142 -0
  56. spanforge/gate.py +1150 -0
  57. spanforge/governance.py +181 -0
  58. spanforge/hitl.py +295 -0
  59. spanforge/http.py +187 -0
  60. spanforge/inspect.py +427 -0
  61. spanforge/integrations/__init__.py +45 -0
  62. spanforge/integrations/_pricing.py +280 -0
  63. spanforge/integrations/anthropic.py +388 -0
  64. spanforge/integrations/azure_openai.py +133 -0
  65. spanforge/integrations/bedrock.py +292 -0
  66. spanforge/integrations/crewai.py +251 -0
  67. spanforge/integrations/gemini.py +351 -0
  68. spanforge/integrations/groq.py +442 -0
  69. spanforge/integrations/langchain.py +349 -0
  70. spanforge/integrations/langgraph.py +306 -0
  71. spanforge/integrations/llamaindex.py +373 -0
  72. spanforge/integrations/ollama.py +287 -0
  73. spanforge/integrations/openai.py +368 -0
  74. spanforge/integrations/together.py +483 -0
  75. spanforge/io.py +214 -0
  76. spanforge/lint.py +322 -0
  77. spanforge/metrics.py +417 -0
  78. spanforge/metrics_export.py +343 -0
  79. spanforge/migrate.py +402 -0
  80. spanforge/model_registry.py +278 -0
  81. spanforge/models.py +389 -0
  82. spanforge/namespaces/__init__.py +254 -0
  83. spanforge/namespaces/audit.py +256 -0
  84. spanforge/namespaces/cache.py +237 -0
  85. spanforge/namespaces/chain.py +77 -0
  86. spanforge/namespaces/confidence.py +72 -0
  87. spanforge/namespaces/consent.py +92 -0
  88. spanforge/namespaces/cost.py +179 -0
  89. spanforge/namespaces/decision.py +143 -0
  90. spanforge/namespaces/diff.py +157 -0
  91. spanforge/namespaces/drift.py +80 -0
  92. spanforge/namespaces/eval_.py +251 -0
  93. spanforge/namespaces/feedback.py +241 -0
  94. spanforge/namespaces/fence.py +193 -0
  95. spanforge/namespaces/guard.py +105 -0
  96. spanforge/namespaces/hitl.py +91 -0
  97. spanforge/namespaces/latency.py +72 -0
  98. spanforge/namespaces/prompt.py +190 -0
  99. spanforge/namespaces/redact.py +173 -0
  100. spanforge/namespaces/retrieval.py +379 -0
  101. spanforge/namespaces/runtime_governance.py +494 -0
  102. spanforge/namespaces/template.py +208 -0
  103. spanforge/namespaces/tool_call.py +77 -0
  104. spanforge/namespaces/trace.py +1029 -0
  105. spanforge/normalizer.py +171 -0
  106. spanforge/plugins.py +82 -0
  107. spanforge/presidio_backend.py +349 -0
  108. spanforge/processor.py +258 -0
  109. spanforge/prompt_registry.py +418 -0
  110. spanforge/py.typed +0 -0
  111. spanforge/redact.py +914 -0
  112. spanforge/regression.py +192 -0
  113. spanforge/runtime_policy.py +159 -0
  114. spanforge/sampling.py +511 -0
  115. spanforge/schema.py +183 -0
  116. spanforge/schemas/v1.0/schema.json +170 -0
  117. spanforge/schemas/v2.0/schema.json +536 -0
  118. spanforge/sdk/__init__.py +625 -0
  119. spanforge/sdk/_base.py +584 -0
  120. spanforge/sdk/_base.pyi +71 -0
  121. spanforge/sdk/_exceptions.py +1096 -0
  122. spanforge/sdk/_types.py +2184 -0
  123. spanforge/sdk/alert.py +1514 -0
  124. spanforge/sdk/alert.pyi +56 -0
  125. spanforge/sdk/audit.py +1196 -0
  126. spanforge/sdk/audit.pyi +67 -0
  127. spanforge/sdk/cec.py +1215 -0
  128. spanforge/sdk/cec.pyi +37 -0
  129. spanforge/sdk/config.py +641 -0
  130. spanforge/sdk/config.pyi +55 -0
  131. spanforge/sdk/enterprise.py +714 -0
  132. spanforge/sdk/enterprise.pyi +79 -0
  133. spanforge/sdk/explain.py +170 -0
  134. spanforge/sdk/fallback.py +432 -0
  135. spanforge/sdk/feedback.py +351 -0
  136. spanforge/sdk/gate.py +874 -0
  137. spanforge/sdk/gate.pyi +51 -0
  138. spanforge/sdk/identity.py +2114 -0
  139. spanforge/sdk/identity.pyi +47 -0
  140. spanforge/sdk/lineage.py +175 -0
  141. spanforge/sdk/observe.py +1065 -0
  142. spanforge/sdk/observe.pyi +50 -0
  143. spanforge/sdk/operator.py +338 -0
  144. spanforge/sdk/pii.py +1473 -0
  145. spanforge/sdk/pii.pyi +119 -0
  146. spanforge/sdk/pipelines.py +458 -0
  147. spanforge/sdk/pipelines.pyi +39 -0
  148. spanforge/sdk/policy.py +930 -0
  149. spanforge/sdk/rag.py +594 -0
  150. spanforge/sdk/rbac.py +280 -0
  151. spanforge/sdk/registry.py +430 -0
  152. spanforge/sdk/registry.pyi +46 -0
  153. spanforge/sdk/scope.py +279 -0
  154. spanforge/sdk/secrets.py +293 -0
  155. spanforge/sdk/secrets.pyi +25 -0
  156. spanforge/sdk/security.py +560 -0
  157. spanforge/sdk/security.pyi +57 -0
  158. spanforge/sdk/trust.py +472 -0
  159. spanforge/sdk/trust.pyi +41 -0
  160. spanforge/secrets.py +799 -0
  161. spanforge/signing.py +1179 -0
  162. spanforge/stats.py +100 -0
  163. spanforge/stream.py +560 -0
  164. spanforge/testing.py +378 -0
  165. spanforge/testing_mocks.py +1052 -0
  166. spanforge/trace.py +199 -0
  167. spanforge/types.py +696 -0
  168. spanforge/ulid.py +300 -0
  169. spanforge/validate.py +379 -0
  170. spanforge-1.0.0.dist-info/METADATA +1509 -0
  171. spanforge-1.0.0.dist-info/RECORD +174 -0
  172. spanforge-1.0.0.dist-info/WHEEL +4 -0
  173. spanforge-1.0.0.dist-info/entry_points.txt +5 -0
  174. 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
+ )