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.
- agent_receipts/__init__.py +1 -0
- agent_receipts/cli.py +5 -0
- agentauth/__init__.py +99 -0
- agentauth/backend/__init__.py +9 -0
- agentauth/backend/api_keys.py +70 -0
- agentauth/backend/attestation.py +199 -0
- agentauth/backend/audit.py +137 -0
- agentauth/backend/biscuit_keys.py +44 -0
- agentauth/backend/capabilities.py +529 -0
- agentauth/backend/config.py +59 -0
- agentauth/backend/db.py +76 -0
- agentauth/backend/deps.py +50 -0
- agentauth/backend/errors.py +108 -0
- agentauth/backend/identity.py +846 -0
- agentauth/backend/main.py +62 -0
- agentauth/backend/models.py +270 -0
- agentauth/backend/routers/__init__.py +0 -0
- agentauth/backend/routers/identity.py +352 -0
- agentauth/backend/routers/verifier.py +69 -0
- agentauth/backend/schemas.py +190 -0
- agentauth/backend/secret_encryption.py +189 -0
- agentauth/backend/signing_keys.py +38 -0
- agentauth/identity/__init__.py +55 -0
- agentauth/identity/_capabilities.py +167 -0
- agentauth/identity/_devattest.py +207 -0
- agentauth/identity/_http.py +105 -0
- agentauth/identity/client.py +250 -0
- agentauth/identity/errors.py +125 -0
- agentauth/identity/logging.py +77 -0
- agentauth/identity/models.py +114 -0
- agentauth/identity/session.py +271 -0
- agentauth/receipts/__init__.py +275 -0
- agentauth/receipts/__main__.py +6 -0
- agentauth/receipts/_version.py +1 -0
- agentauth/receipts/approval.py +22 -0
- agentauth/receipts/assurance.py +239 -0
- agentauth/receipts/audit.py +838 -0
- agentauth/receipts/auditor.py +85 -0
- agentauth/receipts/authority_binding.py +238 -0
- agentauth/receipts/budget.py +49 -0
- agentauth/receipts/c2sp.py +224 -0
- agentauth/receipts/certificate.py +226 -0
- agentauth/receipts/cli.py +555 -0
- agentauth/receipts/compliance.py +395 -0
- agentauth/receipts/compose.py +260 -0
- agentauth/receipts/decision.py +398 -0
- agentauth/receipts/delegation.py +213 -0
- agentauth/receipts/diagnostics.py +99 -0
- agentauth/receipts/evidence.py +148 -0
- agentauth/receipts/evidence_refs.py +28 -0
- agentauth/receipts/explain.py +137 -0
- agentauth/receipts/export.py +1337 -0
- agentauth/receipts/fraud_tools.py +38 -0
- agentauth/receipts/handoff.py +75 -0
- agentauth/receipts/hash_util.py +15 -0
- agentauth/receipts/hpke.py +123 -0
- agentauth/receipts/identity_evidence.py +227 -0
- agentauth/receipts/inference.py +113 -0
- agentauth/receipts/lineage.py +60 -0
- agentauth/receipts/logging_config.py +27 -0
- agentauth/receipts/mandate.py +450 -0
- agentauth/receipts/mcp.py +353 -0
- agentauth/receipts/mcp_bridge.py +54 -0
- agentauth/receipts/mcp_client.py +327 -0
- agentauth/receipts/mcp_server.py +146 -0
- agentauth/receipts/otel.py +93 -0
- agentauth/receipts/partner_config.py +162 -0
- agentauth/receipts/partner_factory.py +41 -0
- agentauth/receipts/policy.py +127 -0
- agentauth/receipts/policy_engine.py +298 -0
- agentauth/receipts/preflight.py +147 -0
- agentauth/receipts/proof.py +221 -0
- agentauth/receipts/prover.py +143 -0
- agentauth/receipts/proving.py +32 -0
- agentauth/receipts/receipt_schema.py +190 -0
- agentauth/receipts/redact.py +112 -0
- agentauth/receipts/replay.py +134 -0
- agentauth/receipts/repo_agent/__init__.py +1 -0
- agentauth/receipts/repo_agent/commands.py +17 -0
- agentauth/receipts/repo_agent/engine.py +686 -0
- agentauth/receipts/repo_agent/policy.yaml +21 -0
- agentauth/receipts/repo_agent/server.py +88 -0
- agentauth/receipts/repo_agent/terminal.py +94 -0
- agentauth/receipts/resource_refs.py +75 -0
- agentauth/receipts/runtime.py +225 -0
- agentauth/receipts/scitt.py +259 -0
- agentauth/receipts/scitt_bundle.py +192 -0
- agentauth/receipts/session.py +148 -0
- agentauth/receipts/signing.py +286 -0
- agentauth/receipts/tamper.py +311 -0
- agentauth/receipts/tee.py +135 -0
- agentauth/receipts/tee_nitro.py +375 -0
- agentauth/receipts/tiles.py +184 -0
- agentauth/receipts/verification.py +61 -0
- agentauth/receipts/verifier_auth.py +162 -0
- agentauth/receipts/verifier_server.py +177 -0
- agentauth/receipts/witness.py +156 -0
- agentauth/receipts/wrapper.py +507 -0
- agentauth/workload_keys.py +195 -0
- agentauth_receipts-0.1.0.dist-info/METADATA +276 -0
- agentauth_receipts-0.1.0.dist-info/RECORD +104 -0
- agentauth_receipts-0.1.0.dist-info/WHEEL +4 -0
- agentauth_receipts-0.1.0.dist-info/entry_points.txt +3 -0
- 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
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)
|