agentauth-receipts 0.1.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 (104) hide show
  1. agent_receipts/__init__.py +1 -0
  2. agent_receipts/cli.py +5 -0
  3. agentauth/__init__.py +99 -0
  4. agentauth/backend/__init__.py +9 -0
  5. agentauth/backend/api_keys.py +70 -0
  6. agentauth/backend/attestation.py +199 -0
  7. agentauth/backend/audit.py +137 -0
  8. agentauth/backend/biscuit_keys.py +44 -0
  9. agentauth/backend/capabilities.py +529 -0
  10. agentauth/backend/config.py +59 -0
  11. agentauth/backend/db.py +76 -0
  12. agentauth/backend/deps.py +50 -0
  13. agentauth/backend/errors.py +108 -0
  14. agentauth/backend/identity.py +846 -0
  15. agentauth/backend/main.py +62 -0
  16. agentauth/backend/models.py +270 -0
  17. agentauth/backend/routers/__init__.py +0 -0
  18. agentauth/backend/routers/identity.py +352 -0
  19. agentauth/backend/routers/verifier.py +69 -0
  20. agentauth/backend/schemas.py +190 -0
  21. agentauth/backend/secret_encryption.py +189 -0
  22. agentauth/backend/signing_keys.py +38 -0
  23. agentauth/identity/__init__.py +55 -0
  24. agentauth/identity/_capabilities.py +167 -0
  25. agentauth/identity/_devattest.py +207 -0
  26. agentauth/identity/_http.py +105 -0
  27. agentauth/identity/client.py +250 -0
  28. agentauth/identity/errors.py +125 -0
  29. agentauth/identity/logging.py +77 -0
  30. agentauth/identity/models.py +114 -0
  31. agentauth/identity/session.py +271 -0
  32. agentauth/receipts/__init__.py +275 -0
  33. agentauth/receipts/__main__.py +6 -0
  34. agentauth/receipts/_version.py +1 -0
  35. agentauth/receipts/approval.py +22 -0
  36. agentauth/receipts/assurance.py +239 -0
  37. agentauth/receipts/audit.py +838 -0
  38. agentauth/receipts/auditor.py +85 -0
  39. agentauth/receipts/authority_binding.py +238 -0
  40. agentauth/receipts/budget.py +49 -0
  41. agentauth/receipts/c2sp.py +224 -0
  42. agentauth/receipts/certificate.py +226 -0
  43. agentauth/receipts/cli.py +555 -0
  44. agentauth/receipts/compliance.py +395 -0
  45. agentauth/receipts/compose.py +260 -0
  46. agentauth/receipts/decision.py +398 -0
  47. agentauth/receipts/delegation.py +213 -0
  48. agentauth/receipts/diagnostics.py +99 -0
  49. agentauth/receipts/evidence.py +148 -0
  50. agentauth/receipts/evidence_refs.py +28 -0
  51. agentauth/receipts/explain.py +137 -0
  52. agentauth/receipts/export.py +1337 -0
  53. agentauth/receipts/fraud_tools.py +38 -0
  54. agentauth/receipts/handoff.py +75 -0
  55. agentauth/receipts/hash_util.py +15 -0
  56. agentauth/receipts/hpke.py +123 -0
  57. agentauth/receipts/identity_evidence.py +227 -0
  58. agentauth/receipts/inference.py +113 -0
  59. agentauth/receipts/lineage.py +60 -0
  60. agentauth/receipts/logging_config.py +27 -0
  61. agentauth/receipts/mandate.py +450 -0
  62. agentauth/receipts/mcp.py +353 -0
  63. agentauth/receipts/mcp_bridge.py +54 -0
  64. agentauth/receipts/mcp_client.py +327 -0
  65. agentauth/receipts/mcp_server.py +146 -0
  66. agentauth/receipts/otel.py +93 -0
  67. agentauth/receipts/partner_config.py +162 -0
  68. agentauth/receipts/partner_factory.py +41 -0
  69. agentauth/receipts/policy.py +127 -0
  70. agentauth/receipts/policy_engine.py +298 -0
  71. agentauth/receipts/preflight.py +147 -0
  72. agentauth/receipts/proof.py +221 -0
  73. agentauth/receipts/prover.py +143 -0
  74. agentauth/receipts/proving.py +32 -0
  75. agentauth/receipts/receipt_schema.py +190 -0
  76. agentauth/receipts/redact.py +112 -0
  77. agentauth/receipts/replay.py +134 -0
  78. agentauth/receipts/repo_agent/__init__.py +1 -0
  79. agentauth/receipts/repo_agent/commands.py +17 -0
  80. agentauth/receipts/repo_agent/engine.py +686 -0
  81. agentauth/receipts/repo_agent/policy.yaml +21 -0
  82. agentauth/receipts/repo_agent/server.py +88 -0
  83. agentauth/receipts/repo_agent/terminal.py +94 -0
  84. agentauth/receipts/resource_refs.py +75 -0
  85. agentauth/receipts/runtime.py +225 -0
  86. agentauth/receipts/scitt.py +259 -0
  87. agentauth/receipts/scitt_bundle.py +192 -0
  88. agentauth/receipts/session.py +148 -0
  89. agentauth/receipts/signing.py +286 -0
  90. agentauth/receipts/tamper.py +311 -0
  91. agentauth/receipts/tee.py +135 -0
  92. agentauth/receipts/tee_nitro.py +375 -0
  93. agentauth/receipts/tiles.py +184 -0
  94. agentauth/receipts/verification.py +61 -0
  95. agentauth/receipts/verifier_auth.py +162 -0
  96. agentauth/receipts/verifier_server.py +177 -0
  97. agentauth/receipts/witness.py +156 -0
  98. agentauth/receipts/wrapper.py +507 -0
  99. agentauth/workload_keys.py +195 -0
  100. agentauth_receipts-0.1.0.dist-info/METADATA +276 -0
  101. agentauth_receipts-0.1.0.dist-info/RECORD +104 -0
  102. agentauth_receipts-0.1.0.dist-info/WHEEL +4 -0
  103. agentauth_receipts-0.1.0.dist-info/entry_points.txt +3 -0
  104. agentauth_receipts-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ """Deprecated package name — import ``agentauth.receipts`` instead."""
agent_receipts/cli.py ADDED
@@ -0,0 +1,5 @@
1
+ """Deprecated CLI entry point — use ``agentauth.receipts.cli``."""
2
+
3
+ from agentauth.receipts.cli import main
4
+
5
+ __all__ = ["main"]
agentauth/__init__.py ADDED
@@ -0,0 +1,99 @@
1
+ """AgentAuth — one system for agent identity *and* verifiable execution.
2
+
3
+ AgentAuth attests an agent's identity (issuing short-lived, verifiable
4
+ JWT-SVID credentials and capability tokens) **and** proves what that agent
5
+ actually did (policy-checked decisions bound into a tamper-evident receipt).
6
+
7
+ The two layers share one developer surface:
8
+
9
+ from agentauth import AgentAuth
10
+
11
+ auth = AgentAuth(api_key="aa_...", dev_attestation=True) # localhost demos/tests
12
+ agent = auth.identify(agent_type="researcher", owner="alice@acme.ai",
13
+ scopes=["db:read"]) # L1/L2 — attested identity
14
+
15
+ # Production uses a platform/SPIRE-issued attestation document instead:
16
+ # agent = auth.identify("researcher", "alice@acme.ai",
17
+ # attestation_document=document)
18
+
19
+ receipted = agent.wrap(model, policy=policy) # L3/L4 — every call receipted,
20
+ result = receipted.run({"transaction_id": "t1"}) # bound to this identity
21
+
22
+ Sub-namespaces remain available for advanced use:
23
+
24
+ agentauth.identity # the identity client/session, errors, models
25
+ agentauth.receipts # the receipt runtime, policy engine, audit, verifier
26
+ agentauth.backend # the hosted FastAPI identity + verifier service
27
+ """
28
+ from __future__ import annotations
29
+
30
+ # --- L1/L2: identity (lightweight client surface) --------------------------- #
31
+ from agentauth.identity import (
32
+ AgentAuth,
33
+ AgentInfo,
34
+ AgentSession,
35
+ Credential,
36
+ ValidationResult,
37
+ )
38
+ from agentauth.identity.errors import (
39
+ AgentAuthError,
40
+ AgentNotFoundError,
41
+ AgentRevokedError,
42
+ BiscuitError,
43
+ CapabilityDeniedError,
44
+ InvalidAPIKeyError,
45
+ InvalidTokenError,
46
+ ProofOfPossessionError,
47
+ TokenExpiredError,
48
+ TransportError,
49
+ TTLOutOfRangeError,
50
+ )
51
+
52
+ # --- L3/L4: receipts (headline runtime surface) ----------------------------- #
53
+ from agentauth.receipts import (
54
+ AgentCertificate,
55
+ AgentWrapper,
56
+ AuditChain,
57
+ AuthorityBinding,
58
+ DecisionOutcome,
59
+ DecisionResult,
60
+ ExecutionProof,
61
+ Policy,
62
+ RunResult,
63
+ build_receipt_bundle,
64
+ verify_receipt_bundle,
65
+ )
66
+ from agentauth.receipts._version import __version__
67
+
68
+ __all__ = [
69
+ "__version__",
70
+ # identity
71
+ "AgentAuth",
72
+ "AgentSession",
73
+ "Credential",
74
+ "AgentInfo",
75
+ "ValidationResult",
76
+ "AgentAuthError",
77
+ "TransportError",
78
+ "InvalidAPIKeyError",
79
+ "InvalidTokenError",
80
+ "TokenExpiredError",
81
+ "AgentRevokedError",
82
+ "AgentNotFoundError",
83
+ "TTLOutOfRangeError",
84
+ "BiscuitError",
85
+ "ProofOfPossessionError",
86
+ "CapabilityDeniedError",
87
+ # receipts
88
+ "AgentWrapper",
89
+ "RunResult",
90
+ "Policy",
91
+ "AgentCertificate",
92
+ "AuthorityBinding",
93
+ "AuditChain",
94
+ "ExecutionProof",
95
+ "DecisionResult",
96
+ "DecisionOutcome",
97
+ "build_receipt_bundle",
98
+ "verify_receipt_bundle",
99
+ ]
@@ -0,0 +1,9 @@
1
+ """AgentAuth hosted service.
2
+
3
+ Backend components:
4
+ - Identity Service (app.identity) -- attests workloads and issues & validates
5
+ signed JWT-SVID agent credentials
6
+ - Identity event log (app.audit) -- append-only log of credential lifecycle events
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,70 @@
1
+ """API key generation and verification helpers."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import secrets
7
+
8
+ PBKDF2_ITERATIONS = 200_000
9
+ PBKDF2_DIGEST = "sha256"
10
+ KEY_PREFIX = "aa_"
11
+
12
+
13
+ def generate_api_key() -> tuple[str, str, str]:
14
+ """Return ``(full_api_key, lookup_prefix, encoded_hash)``."""
15
+ lookup = secrets.token_hex(8)
16
+ secret = secrets.token_urlsafe(32)
17
+ api_key = f"{KEY_PREFIX}{lookup}.{secret}"
18
+ return api_key, lookup, hash_api_key(api_key)
19
+
20
+
21
+ def api_key_lookup_prefix(api_key: str) -> str | None:
22
+ """Return the public lookup prefix encoded in a modern API key."""
23
+ if not api_key.startswith(KEY_PREFIX):
24
+ return None
25
+ body = api_key[len(KEY_PREFIX):]
26
+ lookup, sep, _secret = body.partition(".")
27
+ if not sep or not lookup:
28
+ return None
29
+ return lookup
30
+
31
+
32
+ def hash_api_key(api_key: str) -> str:
33
+ """Encode an API key with PBKDF2-HMAC for at-rest storage."""
34
+ salt = secrets.token_bytes(16)
35
+ digest = hashlib.pbkdf2_hmac(
36
+ PBKDF2_DIGEST,
37
+ api_key.encode("utf-8"),
38
+ salt,
39
+ PBKDF2_ITERATIONS,
40
+ )
41
+ return (
42
+ f"pbkdf2_{PBKDF2_DIGEST}${PBKDF2_ITERATIONS}$"
43
+ f"{salt.hex()}${digest.hex()}"
44
+ )
45
+
46
+
47
+ def verify_api_key(api_key: str, encoded_hash: str | None) -> bool:
48
+ """Constant-time verification of an API key against an encoded hash."""
49
+ if not encoded_hash:
50
+ return False
51
+ try:
52
+ method, iterations_text, salt_hex, digest_hex = encoded_hash.split("$", 3)
53
+ except ValueError:
54
+ return False
55
+ if method != f"pbkdf2_{PBKDF2_DIGEST}":
56
+ return False
57
+ try:
58
+ iterations = int(iterations_text)
59
+ salt = bytes.fromhex(salt_hex)
60
+ expected = bytes.fromhex(digest_hex)
61
+ except ValueError:
62
+ return False
63
+
64
+ actual = hashlib.pbkdf2_hmac(
65
+ PBKDF2_DIGEST,
66
+ api_key.encode("utf-8"),
67
+ salt,
68
+ iterations,
69
+ )
70
+ return hmac.compare_digest(actual, expected)
@@ -0,0 +1,199 @@
1
+ """Attestation: turning *verified evidence* into selectors.
2
+
3
+ This is the prototype's stand-in for SPIRE's two attestation stages. In
4
+ production a SPIRE Agent proves a workload's environment to the SPIRE Server in
5
+ two steps:
6
+
7
+ 1. **Node attestation** — the agent proves which node/environment it runs on
8
+ (Kubernetes projected SA token, AWS instance identity document, GCP metadata
9
+ token). The server verifies this cryptographically and derives *node
10
+ selectors*.
11
+ 2. **Workload attestation** — the (now-trusted) agent reports the calling
12
+ process's attributes (service account, pod labels, container image digest,
13
+ process UID), from which the server derives *workload selectors*.
14
+
15
+ We cannot call a live Kubernetes TokenReview or AWS IID endpoint from an
16
+ in-process service, so the workload presents a **signed attestation document**
17
+ (a JWT). The signature is verified against a node trust anchor an admin
18
+ registered for the tenant (``NodeAttestor``) — that *is* the node attestation,
19
+ and it is real cryptography: forging provenance requires the anchor's private
20
+ key. The verified document's ``workload`` block then feeds workload selector
21
+ derivation. The transport is simulated; the trust decision is not.
22
+
23
+ A caller never hands us a selector directly. Selectors are always *derived from
24
+ verified evidence*, which is the whole point: an agent cannot claim an identity,
25
+ only prove one.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from datetime import datetime
30
+
31
+ import jwt
32
+ from sqlalchemy import select
33
+ from sqlalchemy.orm import Session
34
+
35
+ from .errors import AttestationDeniedError
36
+ from .models import AttestationUse, Customer, NodeAttestor, utcnow
37
+
38
+ SUPPORTED_ATTESTOR_TYPES = {"k8s_psat", "aws_iid", "gcp_iit"}
39
+
40
+
41
+ # --------------------------------------------------------------------------- #
42
+ # Node selectors -- derived from the verified `node` block, per attestor type.
43
+ # --------------------------------------------------------------------------- #
44
+ def _node_selectors(attestor_type: str, node: dict) -> list[str]:
45
+ sel: list[str] = []
46
+ if attestor_type == "k8s_psat":
47
+ if node.get("cluster"):
48
+ sel.append(f"k8s_psat:cluster:{node['cluster']}")
49
+ if node.get("agent_ns"):
50
+ sel.append(f"k8s_psat:agent_ns:{node['agent_ns']}")
51
+ if node.get("agent_sa"):
52
+ sel.append(f"k8s_psat:agent_sa:{node['agent_sa']}")
53
+ elif attestor_type == "aws_iid":
54
+ if node.get("account"):
55
+ sel.append(f"aws_iid:account:{node['account']}")
56
+ if node.get("region"):
57
+ sel.append(f"aws_iid:region:{node['region']}")
58
+ if node.get("instance_id"):
59
+ sel.append(f"aws_iid:instance-id:{node['instance_id']}")
60
+ elif attestor_type == "gcp_iit":
61
+ if node.get("project_id"):
62
+ sel.append(f"gcp_iit:project-id:{node['project_id']}")
63
+ if node.get("zone"):
64
+ sel.append(f"gcp_iit:zone:{node['zone']}")
65
+ return sel
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # Workload selectors -- derived from the verified `workload` block.
70
+ # --------------------------------------------------------------------------- #
71
+ def derive_workload_selectors(workload: dict) -> list[str]:
72
+ """Map workload evidence to SPIRE-style workload selectors.
73
+
74
+ These describe the *process*: which namespace/service-account it runs as,
75
+ its pod labels, container image digest, and UID.
76
+ """
77
+ sel: list[str] = []
78
+ if workload.get("k8s_ns"):
79
+ sel.append(f"k8s:ns:{workload['k8s_ns']}")
80
+ if workload.get("k8s_sa"):
81
+ sel.append(f"k8s:sa:{workload['k8s_sa']}")
82
+ for key, value in sorted((workload.get("pod_labels") or {}).items()):
83
+ sel.append(f"k8s:pod-label:{key}:{value}")
84
+ if workload.get("image_digest"):
85
+ sel.append(f"docker:image-digest:{workload['image_digest']}")
86
+ if workload.get("unix_uid") is not None:
87
+ sel.append(f"unix:uid:{workload['unix_uid']}")
88
+ return sel
89
+
90
+
91
+ def record_attestation_use(
92
+ db: Session,
93
+ customer_id: str,
94
+ *,
95
+ jti: str,
96
+ expires_at: datetime,
97
+ ) -> None:
98
+ """Reject attestation documents that have already minted a credential."""
99
+ existing = db.scalar(
100
+ select(AttestationUse).where(
101
+ AttestationUse.customer_id == customer_id,
102
+ AttestationUse.jti == jti,
103
+ )
104
+ )
105
+ if existing is not None:
106
+ raise AttestationDeniedError(
107
+ "Attestation document has already been used to mint a credential.",
108
+ suggestion="Fetch a fresh attestation document from the node agent and call identify() again.",
109
+ attestation_jti=jti,
110
+ )
111
+ db.add(
112
+ AttestationUse(
113
+ customer_id=customer_id,
114
+ jti=jti,
115
+ expires_at=expires_at,
116
+ )
117
+ )
118
+ db.flush()
119
+
120
+
121
+ # --------------------------------------------------------------------------- #
122
+ # Node attestation -- verify the signed document against a trust anchor.
123
+ # --------------------------------------------------------------------------- #
124
+ def verify_node_attestation(
125
+ db: Session, customer: Customer, attestation_document: str
126
+ ) -> tuple[dict, list[str]]:
127
+ """Verify a signed attestation document and return ``(payload, selectors)``.
128
+
129
+ Tries the customer's registered node attestors; the first whose public key
130
+ verifies the document's RS256 signature wins (that proves the node). Raises
131
+ :class:`AttestationDeniedError` if the document is malformed, signed by an
132
+ unregistered key, or stale.
133
+ """
134
+ if not attestation_document or attestation_document.count(".") != 2:
135
+ raise AttestationDeniedError(
136
+ "Attestation document is missing or not a signed JWT.",
137
+ suggestion=(
138
+ "Present the signed attestation document your node agent issues "
139
+ "(a JWS with node + workload evidence)."
140
+ ),
141
+ )
142
+
143
+ # Read the declared type (unverified) so we only try anchors of that type.
144
+ try:
145
+ unverified = jwt.decode(attestation_document, options={"verify_signature": False})
146
+ except jwt.InvalidTokenError as exc:
147
+ raise AttestationDeniedError(
148
+ "Attestation document could not be parsed.",
149
+ suggestion="Ensure the document is a well-formed JWS.",
150
+ ) from exc
151
+
152
+ declared_type = unverified.get("type")
153
+ anchors = list(
154
+ db.scalars(
155
+ select(NodeAttestor).where(NodeAttestor.customer_id == customer.id)
156
+ ).all()
157
+ )
158
+ if not anchors:
159
+ raise AttestationDeniedError(
160
+ "No node attestors are registered for this tenant.",
161
+ suggestion=(
162
+ "Register a node trust anchor at POST /v1/node-attestors before "
163
+ "any workload can attest."
164
+ ),
165
+ )
166
+
167
+ candidates = [a for a in anchors if declared_type is None or a.type == declared_type]
168
+ expired = False
169
+ for anchor in candidates:
170
+ try:
171
+ payload = jwt.decode(
172
+ attestation_document,
173
+ anchor.public_pem,
174
+ algorithms=["RS256"],
175
+ audience=customer.id,
176
+ options={"require": ["jti", "exp"]},
177
+ )
178
+ except jwt.ExpiredSignatureError:
179
+ expired = True
180
+ continue
181
+ except jwt.InvalidTokenError:
182
+ continue
183
+ # Verified: this anchor proves the node.
184
+ node = payload.get("node") or {}
185
+ selectors = _node_selectors(anchor.type, node)
186
+ return payload, selectors
187
+
188
+ if expired:
189
+ raise AttestationDeniedError(
190
+ "Attestation document has expired.",
191
+ suggestion="Node evidence is short-lived; re-fetch it and attest again.",
192
+ )
193
+ raise AttestationDeniedError(
194
+ "Attestation document is not signed by any registered node attestor.",
195
+ suggestion=(
196
+ "The signing key must match a node trust anchor registered for this "
197
+ "tenant. A stolen workload credential cannot forge node provenance."
198
+ ),
199
+ )
@@ -0,0 +1,137 @@
1
+ """Append-only identity event log (hash-chained, DB-backed).
2
+
3
+ ``record_event`` is the single choke point the Identity Service calls to record
4
+ a credential lifecycle event (issuance, revocation, key rotation, attestor /
5
+ registration changes). Each event is appended to the ``audit_events`` table as a
6
+ tamper-evident hash chain: ``entry_hash = H(sequence, prev_hash, ts, type,
7
+ customer_id, ...fields)`` and each row links to the previous via ``prev_hash``.
8
+
9
+ This replaces the legacy flat ``audit.jsonl`` file: the trail now lives in the
10
+ same durable, queryable SQLite store as the rest of the identity data, so it can
11
+ be filtered by tenant and verified without parsing a side file.
12
+
13
+ Call sites pass no DB session: ``record_event`` opens its own short-lived
14
+ session so the audit write is independent of the caller's transaction (an
15
+ append-only log should persist regardless of the surrounding request).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import threading
22
+ import time
23
+ from datetime import datetime
24
+
25
+ from sqlalchemy import select
26
+ from sqlalchemy.exc import IntegrityError, OperationalError
27
+
28
+ from .db import SessionLocal
29
+ from .models import AuditEvent
30
+
31
+ _lock = threading.Lock()
32
+ _CHAIN_SCHEMA = "agentauth.audit.v1"
33
+ _APPEND_RETRIES = 5
34
+
35
+
36
+ def _canonical_json(value: object) -> str:
37
+ return json.dumps(value, default=str, sort_keys=True, separators=(",", ":"))
38
+
39
+
40
+ def _record_hash(record: dict) -> str:
41
+ """Hash the canonical event material (everything but the hash itself)."""
42
+ material = {key: value for key, value in record.items() if key != "entry_hash"}
43
+ return hashlib.sha256(_canonical_json(material).encode("utf-8")).hexdigest()
44
+
45
+
46
+ def _record_from_row(row: AuditEvent) -> dict:
47
+ """Reconstruct the hashed event material from a stored row."""
48
+ return {
49
+ "schema": _CHAIN_SCHEMA,
50
+ "sequence": row.sequence,
51
+ "prev_hash": row.prev_hash,
52
+ "ts": row.ts,
53
+ "type": row.type,
54
+ "customer_id": row.customer_id,
55
+ **(row.payload or {}),
56
+ }
57
+
58
+
59
+ def record_event(event_type: str, customer_id: str, **fields: object) -> dict:
60
+ """Append an identity event to the hash-chained log and return the record."""
61
+ for attempt in range(_APPEND_RETRIES):
62
+ with _lock, SessionLocal() as db:
63
+ try:
64
+ last = db.scalars(
65
+ select(AuditEvent).order_by(AuditEvent.sequence.desc()).limit(1)
66
+ ).first()
67
+ prev_hash = last.entry_hash if last is not None else None
68
+ next_sequence = (last.sequence + 1) if last is not None else 1
69
+
70
+ ts = datetime.utcnow().isoformat() + "Z"
71
+ record = {
72
+ "schema": _CHAIN_SCHEMA,
73
+ "sequence": next_sequence,
74
+ "prev_hash": prev_hash,
75
+ "ts": ts,
76
+ "type": event_type,
77
+ "customer_id": customer_id,
78
+ **fields,
79
+ }
80
+ record["entry_hash"] = _record_hash(record)
81
+
82
+ db.add(
83
+ AuditEvent(
84
+ sequence=next_sequence,
85
+ customer_id=customer_id,
86
+ type=event_type,
87
+ ts=ts,
88
+ payload=dict(fields),
89
+ prev_hash=prev_hash,
90
+ entry_hash=record["entry_hash"],
91
+ )
92
+ )
93
+ db.commit()
94
+ return record
95
+ except (IntegrityError, OperationalError):
96
+ db.rollback()
97
+ if attempt == _APPEND_RETRIES - 1:
98
+ raise
99
+ time.sleep(0.01 * (attempt + 1))
100
+ raise RuntimeError("failed to append audit event")
101
+
102
+
103
+ def read_events(customer_id: str | None = None) -> list[dict]:
104
+ """Return audit events (oldest first), optionally filtered by tenant.
105
+
106
+ Each event includes its ``entry_hash``; this is the queryable replacement
107
+ for reading the old JSONL file line by line.
108
+ """
109
+ stmt = select(AuditEvent).order_by(AuditEvent.sequence)
110
+ if customer_id is not None:
111
+ stmt = stmt.where(AuditEvent.customer_id == customer_id)
112
+ with SessionLocal() as db:
113
+ rows = db.scalars(stmt).all()
114
+ return [{**_record_from_row(row), "entry_hash": row.entry_hash} for row in rows]
115
+
116
+
117
+ def verify_event_log() -> list[str]:
118
+ """Return integrity issues for the identity event log (empty == intact)."""
119
+ with SessionLocal() as db:
120
+ rows = db.scalars(select(AuditEvent).order_by(AuditEvent.sequence)).all()
121
+
122
+ issues: list[str] = []
123
+ expected_prev_hash: str | None = None
124
+ expected_sequence = 1
125
+ for row in rows:
126
+ if row.sequence != expected_sequence:
127
+ issues.append(
128
+ f"sequence {row.sequence}: expected {expected_sequence}"
129
+ )
130
+ if row.prev_hash != expected_prev_hash:
131
+ issues.append(f"sequence {row.sequence}: prev_hash mismatch")
132
+ if _record_hash(_record_from_row(row)) != row.entry_hash:
133
+ issues.append(f"sequence {row.sequence}: entry_hash mismatch")
134
+ expected_prev_hash = row.entry_hash
135
+ expected_sequence += 1
136
+
137
+ return issues
@@ -0,0 +1,44 @@
1
+ """Encrypt Biscuit root private keys at rest."""
2
+ from __future__ import annotations
3
+
4
+ from .secret_encryption import (
5
+ encryption_enabled,
6
+ encrypt_secret,
7
+ decrypt_secret,
8
+ is_encrypted_value,
9
+ )
10
+
11
+ BISCUIT_CONTEXT = "biscuit_root_ed25519_v1"
12
+
13
+
14
+ def is_encrypted_private_hex(stored: str) -> bool:
15
+ return is_encrypted_value(stored)
16
+
17
+
18
+ def is_plaintext_private_hex(stored: str) -> bool:
19
+ if is_encrypted_private_hex(stored):
20
+ return False
21
+ try:
22
+ raw = bytes.fromhex(stored)
23
+ except ValueError:
24
+ return False
25
+ return len(raw) == 32
26
+
27
+
28
+ def encrypt_private_hex(plaintext_hex: str) -> str:
29
+ return encrypt_secret(plaintext_hex, context=BISCUIT_CONTEXT)
30
+
31
+
32
+ def decrypt_private_hex(stored: str) -> str:
33
+ if is_plaintext_private_hex(stored):
34
+ return stored
35
+ return decrypt_secret(stored, context=BISCUIT_CONTEXT)
36
+
37
+
38
+ def maybe_reencrypt_biscuit_root_key(db, root_key) -> None:
39
+ if not encryption_enabled() or is_encrypted_private_hex(root_key.private_hex):
40
+ return
41
+ root_key.private_hex = encrypt_private_hex(root_key.private_hex)
42
+ db.add(root_key)
43
+ db.commit()
44
+ db.refresh(root_key)