decisionlock 0.2.0rc4__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 (100) hide show
  1. decisionlock/__init__.py +5 -0
  2. decisionlock/__main__.py +4 -0
  3. decisionlock/_meta.py +12 -0
  4. decisionlock/_version.py +1 -0
  5. decisionlock/adapters/__init__.py +1 -0
  6. decisionlock/adapters/broker/__init__.py +17 -0
  7. decisionlock/adapters/broker/mock.py +63 -0
  8. decisionlock/adapters/broker/production.py +337 -0
  9. decisionlock/adapters/crypto/__init__.py +15 -0
  10. decisionlock/adapters/crypto/ed25519.py +131 -0
  11. decisionlock/adapters/evidence/__init__.py +19 -0
  12. decisionlock/adapters/evidence/bundle.py +308 -0
  13. decisionlock/adapters/executor/__init__.py +3 -0
  14. decisionlock/adapters/executor/memory.py +58 -0
  15. decisionlock/adapters/protocols/__init__.py +1 -0
  16. decisionlock/adapters/protocols/mcp/__init__.py +64 -0
  17. decisionlock/adapters/protocols/mcp/http_executor.py +232 -0
  18. decisionlock/adapters/protocols/mcp/http_gateway.py +502 -0
  19. decisionlock/adapters/protocols/mcp/http_gateway_types.py +26 -0
  20. decisionlock/adapters/protocols/mcp/http_security.py +46 -0
  21. decisionlock/adapters/protocols/mcp/http_server.py +268 -0
  22. decisionlock/adapters/protocols/mcp/intent.py +74 -0
  23. decisionlock/adapters/protocols/mcp/jsonrpc.py +42 -0
  24. decisionlock/adapters/protocols/mcp/modern.py +382 -0
  25. decisionlock/adapters/protocols/mcp/protected.py +1139 -0
  26. decisionlock/adapters/protocols/mcp/schema.py +28 -0
  27. decisionlock/adapters/protocols/mcp/stdio.py +340 -0
  28. decisionlock/adapters/reconciliation/__init__.py +16 -0
  29. decisionlock/adapters/reconciliation/mcp_http.py +249 -0
  30. decisionlock/adapters/reconciliation/mcp_http_worker.py +30 -0
  31. decisionlock/adapters/reconciliation/memory.py +15 -0
  32. decisionlock/adapters/reconciliation/provider_store.py +203 -0
  33. decisionlock/adapters/storage/__init__.py +0 -0
  34. decisionlock/adapters/storage/sqlite/__init__.py +12 -0
  35. decisionlock/adapters/storage/sqlite/reconciliation.py +455 -0
  36. decisionlock/adapters/storage/sqlite/state.py +1494 -0
  37. decisionlock/application/__init__.py +22 -0
  38. decisionlock/application/provider_reconciliation.py +66 -0
  39. decisionlock/application/reconciliation_worker.py +211 -0
  40. decisionlock/application/service.py +1052 -0
  41. decisionlock/audit.py +530 -0
  42. decisionlock/changed.py +58 -0
  43. decisionlock/cli/__init__.py +79 -0
  44. decisionlock/cli/__main__.py +4 -0
  45. decisionlock/cli/app.py +861 -0
  46. decisionlock/cli/io.py +60 -0
  47. decisionlock/compatibility/__init__.py +3 -0
  48. decisionlock/compatibility/policy_v1.py +42 -0
  49. decisionlock/compatibility/v1_cli.py +614 -0
  50. decisionlock/compatibility/v1_demo.py +129 -0
  51. decisionlock/demo.py +147 -0
  52. decisionlock/discovery.py +82 -0
  53. decisionlock/doctor.py +108 -0
  54. decisionlock/domain/__init__.py +181 -0
  55. decisionlock/domain/credential_authority.py +115 -0
  56. decisionlock/domain/enums.py +126 -0
  57. decisionlock/domain/models.py +838 -0
  58. decisionlock/domain/policy.py +136 -0
  59. decisionlock/domain/provider_contracts.py +176 -0
  60. decisionlock/engine/__init__.py +1 -0
  61. decisionlock/engine/attestation.py +122 -0
  62. decisionlock/engine/authority.py +212 -0
  63. decisionlock/engine/canonical.py +58 -0
  64. decisionlock/engine/causal.py +143 -0
  65. decisionlock/engine/checkpoints.py +72 -0
  66. decisionlock/engine/credentials.py +129 -0
  67. decisionlock/engine/execution.py +362 -0
  68. decisionlock/engine/field_integrity.py +376 -0
  69. decisionlock/engine/policy.py +267 -0
  70. decisionlock/engine/policy_intelligence.py +140 -0
  71. decisionlock/engine/provider_contracts.py +59 -0
  72. decisionlock/engine/provider_results.py +50 -0
  73. decisionlock/engine/receipts.py +261 -0
  74. decisionlock/gate.py +20 -0
  75. decisionlock/http_cli.py +263 -0
  76. decisionlock/mcp_stdio.py +679 -0
  77. decisionlock/models.py +157 -0
  78. decisionlock/policy.py +606 -0
  79. decisionlock/policy_packs.py +184 -0
  80. decisionlock/ports/__init__.py +63 -0
  81. decisionlock/ports/interfaces.py +318 -0
  82. decisionlock/ports/reconciliation_work.py +49 -0
  83. decisionlock/posture.py +244 -0
  84. decisionlock/py.typed +0 -0
  85. decisionlock/reconcile_cli.py +162 -0
  86. decisionlock/reconcile_worker_cli.py +246 -0
  87. decisionlock/render.py +217 -0
  88. decisionlock/resources/default_policy_v2.yaml +64 -0
  89. decisionlock/resources/demo-intent.json +23 -0
  90. decisionlock/risk.py +158 -0
  91. decisionlock/secrets.py +89 -0
  92. decisionlock/security/__init__.py +5 -0
  93. decisionlock/security/files.py +137 -0
  94. decisionlock/service_deployment.py +51 -0
  95. decisionlock-0.2.0rc4.dist-info/METADATA +569 -0
  96. decisionlock-0.2.0rc4.dist-info/RECORD +100 -0
  97. decisionlock-0.2.0rc4.dist-info/WHEEL +5 -0
  98. decisionlock-0.2.0rc4.dist-info/entry_points.txt +5 -0
  99. decisionlock-0.2.0rc4.dist-info/licenses/LICENSE +9 -0
  100. decisionlock-0.2.0rc4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """DecisionLock action-integrity kernel."""
2
+
3
+ from ._version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ from decisionlock.cli import entrypoint
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(entrypoint())
decisionlock/_meta.py ADDED
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from ._version import __version__
6
+
7
+
8
+ def package_version() -> str:
9
+ try:
10
+ return version("decisionlock")
11
+ except PackageNotFoundError:
12
+ return __version__
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0rc4"
@@ -0,0 +1 @@
1
+ """Infrastructure adapters for DecisionLock ports."""
@@ -0,0 +1,17 @@
1
+ from .mock import MockCredentialBroker
2
+ from .production import (
3
+ BrokeredCredentialAuthority,
4
+ CredentialAuthorityError,
5
+ EnvironmentCredentialSource,
6
+ SQLiteCredentialProfileRepository,
7
+ credential_profile_binding_digest,
8
+ )
9
+
10
+ __all__ = [
11
+ "BrokeredCredentialAuthority",
12
+ "CredentialAuthorityError",
13
+ "EnvironmentCredentialSource",
14
+ "MockCredentialBroker",
15
+ "SQLiteCredentialProfileRepository",
16
+ "credential_profile_binding_digest",
17
+ ]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from uuid import uuid4
5
+
6
+ from decisionlock.adapters.crypto import Ed25519BrokerGrantVerifier, Ed25519Signer
7
+ from decisionlock.domain import ApprovalReceipt, CredentialGrant, CredentialRequest, DecisionReceipt
8
+ from decisionlock.engine.canonical import digest
9
+ from decisionlock.engine.credentials import (
10
+ attest_credential_grant,
11
+ validate_credential_request,
12
+ )
13
+
14
+
15
+ class MockCredentialBroker:
16
+ """Contract-faithful broker used for demos and tests; never holds a real secret."""
17
+
18
+ def __init__(
19
+ self,
20
+ name: str = "mock-broker",
21
+ signer: Ed25519Signer | None = None,
22
+ ) -> None:
23
+ self.name = name
24
+ self._signer = signer or Ed25519Signer.generate("mock-broker-ed25519")
25
+
26
+ @property
27
+ def verifier(self) -> Ed25519BrokerGrantVerifier:
28
+ return Ed25519BrokerGrantVerifier(
29
+ {(self.name, self._signer.key_id): self._signer.public_key()}
30
+ )
31
+
32
+ def attest_for_testing(self, grant: CredentialGrant) -> CredentialGrant:
33
+ """Re-attest a synthetic grant for negative ceiling tests and the offline demo."""
34
+
35
+ return attest_credential_grant(grant, self._signer)
36
+
37
+ def issue(
38
+ self,
39
+ receipt: DecisionReceipt,
40
+ request: CredentialRequest,
41
+ approval: ApprovalReceipt | None = None,
42
+ ) -> CredentialGrant:
43
+ validate_credential_request(receipt, request, approval_verified=approval is not None)
44
+ now = datetime.now(UTC)
45
+ handle = f"opaque://{self.name}/{uuid4().hex}"
46
+ grant = CredentialGrant(
47
+ grant_id="grant_" + uuid4().hex,
48
+ issuance_id="iss_" + uuid4().hex,
49
+ decision_id=receipt.decision_id,
50
+ trace_id=receipt.trace_id,
51
+ mode="opaque_handle",
52
+ credential_handle=handle,
53
+ credential_handle_digest=digest(handle),
54
+ audience=request.audience,
55
+ scopes=request.scopes,
56
+ tenant_id=request.tenant_id,
57
+ issued_at=now,
58
+ expires_at=now + timedelta(seconds=request.requested_ttl_seconds),
59
+ broker=self.name,
60
+ broker_key_id=self._signer.key_id,
61
+ attestation="",
62
+ )
63
+ return attest_credential_grant(grant, self._signer)
@@ -0,0 +1,337 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sqlite3
5
+ from datetime import UTC, datetime, timedelta
6
+ from pathlib import Path
7
+ from typing import Mapping
8
+ from uuid import uuid4
9
+
10
+ from decisionlock.adapters.crypto import Ed25519BrokerGrantVerifier, Ed25519Signer
11
+ from decisionlock.domain import (
12
+ ApprovalReceipt,
13
+ CredentialAuthorityProfile,
14
+ CredentialAuthorityProfileRecord,
15
+ CredentialGrant,
16
+ CredentialProfileStatus,
17
+ CredentialRequest,
18
+ DecisionReceipt,
19
+ ProviderIdentity,
20
+ VerifiedCredentialAuthority,
21
+ )
22
+ from decisionlock.engine.canonical import digest
23
+ from decisionlock.engine.credentials import attest_credential_grant, validate_credential_request
24
+ from decisionlock.ports import (
25
+ CredentialMaterialSource,
26
+ CredentialProfileRepository,
27
+ ResolvedCredentialMaterial,
28
+ )
29
+ from decisionlock.security.files import ensure_private_file
30
+
31
+
32
+ class CredentialAuthorityError(ValueError):
33
+ pass
34
+
35
+
36
+ def credential_profile_binding_digest(profile: CredentialAuthorityProfile) -> str:
37
+ return digest(profile)
38
+
39
+
40
+ class EnvironmentCredentialSource:
41
+ """Resolve a credential from the process environment only at dispatch time."""
42
+
43
+ def __init__(self, environment_variable: str) -> None:
44
+ if (
45
+ not environment_variable
46
+ or "=" in environment_variable
47
+ or "\x00" in environment_variable
48
+ ):
49
+ raise ValueError("credential_source.environment_variable_invalid")
50
+ self.environment_variable = environment_variable
51
+
52
+ @property
53
+ def binding_digest(self) -> str:
54
+ return digest({"kind": "environment", "name": self.environment_variable})
55
+
56
+ def read(self) -> str:
57
+ value = os.environ.get(self.environment_variable)
58
+ if value is None:
59
+ raise CredentialAuthorityError("credential.source_unavailable")
60
+ return value
61
+
62
+
63
+ class SQLiteCredentialProfileRepository:
64
+ """Durable non-secret credential profile and revocation state."""
65
+
66
+ _SCHEMA = """
67
+ CREATE TABLE IF NOT EXISTS credential_profiles(
68
+ profile_id TEXT PRIMARY KEY,
69
+ binding_digest TEXT UNIQUE NOT NULL,
70
+ audience TEXT NOT NULL,
71
+ tenant_key TEXT NOT NULL,
72
+ profile_json TEXT NOT NULL,
73
+ status TEXT NOT NULL,
74
+ revoked_at TEXT,
75
+ revoke_reason TEXT
76
+ );
77
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_active_credential_profile_audience_tenant
78
+ ON credential_profiles(audience, tenant_key) WHERE status = 'active';
79
+ """
80
+
81
+ def __init__(self, path: Path) -> None:
82
+ self.path = path
83
+ ensure_private_file(path)
84
+ with self._connect() as connection:
85
+ connection.executescript(self._SCHEMA)
86
+
87
+ def _connect(self) -> sqlite3.Connection:
88
+ connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
89
+ connection.row_factory = sqlite3.Row
90
+ connection.execute("PRAGMA busy_timeout=10000")
91
+ connection.execute("PRAGMA trusted_schema=OFF")
92
+ connection.execute("PRAGMA synchronous=FULL")
93
+ connection.execute("PRAGMA secure_delete=ON")
94
+ return connection
95
+
96
+ @staticmethod
97
+ def _tenant_key(tenant_id: str | None) -> str:
98
+ return tenant_id or ""
99
+
100
+ @staticmethod
101
+ def _record(row: sqlite3.Row) -> CredentialAuthorityProfileRecord:
102
+ profile = CredentialAuthorityProfile.model_validate_json(str(row["profile_json"]))
103
+ revoked_at = row["revoked_at"]
104
+ return CredentialAuthorityProfileRecord(
105
+ profile=profile,
106
+ binding_digest=str(row["binding_digest"]),
107
+ status=CredentialProfileStatus(str(row["status"])),
108
+ revoked_at=(
109
+ datetime.fromisoformat(str(revoked_at)) if revoked_at is not None else None
110
+ ),
111
+ revoke_reason=(str(row["revoke_reason"]) if row["revoke_reason"] is not None else None),
112
+ )
113
+
114
+ def store_credential_profile(self, profile: CredentialAuthorityProfile) -> None:
115
+ binding_digest = credential_profile_binding_digest(profile)
116
+ with self._connect() as connection:
117
+ connection.execute("BEGIN IMMEDIATE")
118
+ existing = connection.execute(
119
+ "SELECT * FROM credential_profiles WHERE profile_id = ?", (profile.profile_id,)
120
+ ).fetchone()
121
+ if existing is not None:
122
+ record = self._record(existing)
123
+ if record.status == CredentialProfileStatus.REVOKED:
124
+ connection.execute("ROLLBACK")
125
+ raise CredentialAuthorityError("credential.profile_revoked")
126
+ if record.binding_digest != binding_digest:
127
+ connection.execute("ROLLBACK")
128
+ raise CredentialAuthorityError("credential.profile_conflict")
129
+ connection.execute("COMMIT")
130
+ return
131
+ try:
132
+ connection.execute(
133
+ """
134
+ INSERT INTO credential_profiles(
135
+ profile_id,binding_digest,audience,tenant_key,profile_json,status
136
+ ) VALUES(?,?,?,?,?,?)
137
+ """,
138
+ (
139
+ profile.profile_id,
140
+ binding_digest,
141
+ profile.audience,
142
+ self._tenant_key(profile.tenant_id),
143
+ profile.model_dump_json(),
144
+ CredentialProfileStatus.ACTIVE.value,
145
+ ),
146
+ )
147
+ except sqlite3.IntegrityError as exc:
148
+ connection.execute("ROLLBACK")
149
+ raise CredentialAuthorityError("credential.profile_conflict") from exc
150
+ connection.execute("COMMIT")
151
+
152
+ def get_credential_profile(self, profile_id: str) -> CredentialAuthorityProfileRecord | None:
153
+ with self._connect() as connection:
154
+ row = connection.execute(
155
+ "SELECT * FROM credential_profiles WHERE profile_id = ?", (profile_id,)
156
+ ).fetchone()
157
+ return self._record(row) if row is not None else None
158
+
159
+ def find_active_credential_profile(
160
+ self, audience: str, tenant_id: str | None
161
+ ) -> CredentialAuthorityProfileRecord | None:
162
+ with self._connect() as connection:
163
+ row = connection.execute(
164
+ """
165
+ SELECT * FROM credential_profiles
166
+ WHERE audience = ? AND tenant_key = ? AND status = ?
167
+ """,
168
+ (
169
+ audience,
170
+ self._tenant_key(tenant_id),
171
+ CredentialProfileStatus.ACTIVE.value,
172
+ ),
173
+ ).fetchone()
174
+ return self._record(row) if row is not None else None
175
+
176
+ def get_credential_profile_by_binding_digest(
177
+ self, binding_digest: str
178
+ ) -> CredentialAuthorityProfileRecord | None:
179
+ with self._connect() as connection:
180
+ row = connection.execute(
181
+ "SELECT * FROM credential_profiles WHERE binding_digest = ?", (binding_digest,)
182
+ ).fetchone()
183
+ return self._record(row) if row is not None else None
184
+
185
+ def revoke_credential_profile(
186
+ self, profile_id: str, revoked_at: datetime, reason: str
187
+ ) -> CredentialAuthorityProfileRecord:
188
+ if revoked_at.tzinfo is None or revoked_at.utcoffset() is None:
189
+ raise CredentialAuthorityError("credential.revocation_timestamp_invalid")
190
+ if not reason:
191
+ raise CredentialAuthorityError("credential.revocation_reason_required")
192
+ with self._connect() as connection:
193
+ result = connection.execute(
194
+ """
195
+ UPDATE credential_profiles
196
+ SET status = ?, revoked_at = ?, revoke_reason = ?
197
+ WHERE profile_id = ? AND status = ?
198
+ """,
199
+ (
200
+ CredentialProfileStatus.REVOKED.value,
201
+ revoked_at.isoformat(),
202
+ reason,
203
+ profile_id,
204
+ CredentialProfileStatus.ACTIVE.value,
205
+ ),
206
+ )
207
+ if result.rowcount != 1:
208
+ raise CredentialAuthorityError("credential.profile_not_active")
209
+ record = self.get_credential_profile(profile_id)
210
+ if record is None:
211
+ raise CredentialAuthorityError("credential.profile_missing")
212
+ return record
213
+
214
+
215
+ class BrokeredCredentialAuthority:
216
+ """Issue signed non-secret grants and resolve provider credentials only at dispatch time."""
217
+
218
+ def __init__(
219
+ self,
220
+ *,
221
+ name: str,
222
+ signer: Ed25519Signer,
223
+ profiles: CredentialProfileRepository,
224
+ sources: Mapping[str, CredentialMaterialSource],
225
+ ) -> None:
226
+ if not name:
227
+ raise ValueError("credential.broker_name_required")
228
+ self.name = name
229
+ self._signer = signer
230
+ self._profiles = profiles
231
+ self._sources = dict(sources)
232
+
233
+ @property
234
+ def verifier(self) -> Ed25519BrokerGrantVerifier:
235
+ return Ed25519BrokerGrantVerifier(
236
+ {(self.name, self._signer.key_id): self._signer.public_key()}
237
+ )
238
+
239
+ def issue(
240
+ self,
241
+ receipt: DecisionReceipt,
242
+ request: CredentialRequest,
243
+ approval: ApprovalReceipt | None = None,
244
+ ) -> CredentialGrant:
245
+ validate_credential_request(receipt, request, approval_verified=approval is not None)
246
+ record = self._profiles.find_active_credential_profile(request.audience, request.tenant_id)
247
+ if record is None:
248
+ raise CredentialAuthorityError("credential.profile_unavailable")
249
+ profile = record.profile
250
+ if request.scopes != profile.scopes:
251
+ # A static/external secret is not attenuated merely because a caller asks for fewer
252
+ # scopes. Exact equality prevents DecisionLock from claiming least privilege it cannot
253
+ # technically enforce at the provider.
254
+ raise CredentialAuthorityError("credential.profile_scope_mismatch")
255
+ source = self._sources.get(profile.profile_id)
256
+ if source is None or source.binding_digest != profile.source_binding_digest:
257
+ raise CredentialAuthorityError("credential.source_binding_mismatch")
258
+ now = datetime.now(UTC)
259
+ if profile.expires_at is not None and profile.expires_at <= now:
260
+ raise CredentialAuthorityError("credential.profile_expired")
261
+ expires_at = min(
262
+ now + timedelta(seconds=request.requested_ttl_seconds),
263
+ receipt.expires_at,
264
+ *([profile.expires_at] if profile.expires_at is not None else []),
265
+ )
266
+ if expires_at <= now:
267
+ raise CredentialAuthorityError("credential.grant_window_invalid")
268
+ grant = CredentialGrant(
269
+ grant_id="grant_" + uuid4().hex,
270
+ issuance_id="iss_" + uuid4().hex,
271
+ decision_id=receipt.decision_id,
272
+ trace_id=receipt.trace_id,
273
+ mode="brokered_execution",
274
+ credential_handle=None,
275
+ credential_handle_digest=record.binding_digest,
276
+ audience=request.audience,
277
+ scopes=request.scopes,
278
+ tenant_id=request.tenant_id,
279
+ issued_at=now,
280
+ expires_at=expires_at,
281
+ broker=self.name,
282
+ broker_key_id=self._signer.key_id,
283
+ attestation="",
284
+ )
285
+ return attest_credential_grant(grant, self._signer)
286
+
287
+ def resolve(
288
+ self,
289
+ authority: VerifiedCredentialAuthority,
290
+ provider: ProviderIdentity,
291
+ *,
292
+ now: datetime | None = None,
293
+ ) -> ResolvedCredentialMaterial:
294
+ now = now or datetime.now(UTC)
295
+ if authority.mode != "brokered_execution":
296
+ raise CredentialAuthorityError("credential.brokered_mode_required")
297
+ if authority.broker != self.name or authority.broker_key_id != self._signer.key_id:
298
+ raise CredentialAuthorityError("credential.broker_binding_mismatch")
299
+ if authority.expires_at <= now:
300
+ raise CredentialAuthorityError("credential.expired")
301
+ record = self._profiles.get_credential_profile_by_binding_digest(
302
+ authority.credential_handle_digest
303
+ )
304
+ if record is None:
305
+ raise CredentialAuthorityError("credential.profile_missing")
306
+ if record.status != CredentialProfileStatus.ACTIVE:
307
+ raise CredentialAuthorityError("credential.profile_revoked")
308
+ profile = record.profile
309
+ if profile.expires_at is not None and profile.expires_at <= now:
310
+ raise CredentialAuthorityError("credential.profile_expired")
311
+ if profile.provider != provider:
312
+ raise CredentialAuthorityError("credential.provider_binding_mismatch")
313
+ if (
314
+ authority.audience != profile.audience
315
+ or authority.scopes != profile.scopes
316
+ or authority.tenant_id != profile.tenant_id
317
+ ):
318
+ raise CredentialAuthorityError("credential.profile_binding_mismatch")
319
+ source = self._sources.get(profile.profile_id)
320
+ if source is None or source.binding_digest != profile.source_binding_digest:
321
+ raise CredentialAuthorityError("credential.source_binding_mismatch")
322
+ secret = source.read()
323
+ if (
324
+ not secret
325
+ or secret != secret.strip()
326
+ or "\r" in secret
327
+ or "\n" in secret
328
+ or "\x00" in secret
329
+ ):
330
+ raise CredentialAuthorityError("credential.material_invalid")
331
+ header_value = f"{profile.header_scheme} {secret}" if profile.header_scheme else secret
332
+ return ResolvedCredentialMaterial(
333
+ profile_id=profile.profile_id,
334
+ header_name=profile.header_name,
335
+ header_value=header_value,
336
+ source_binding_digest=profile.source_binding_digest,
337
+ )
@@ -0,0 +1,15 @@
1
+ from .ed25519 import (
2
+ Ed25519BrokerGrantVerifier,
3
+ Ed25519Signer,
4
+ Ed25519Verifier,
5
+ decode_signature,
6
+ encode_signature,
7
+ )
8
+
9
+ __all__ = [
10
+ "Ed25519BrokerGrantVerifier",
11
+ "Ed25519Signer",
12
+ "Ed25519Verifier",
13
+ "decode_signature",
14
+ "encode_signature",
15
+ ]
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ from pathlib import Path
6
+
7
+ from cryptography.exceptions import InvalidSignature
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
10
+
11
+ from decisionlock.security.files import read_regular_file, secure_atomic_write
12
+
13
+
14
+ class Ed25519Signer:
15
+ def __init__(self, private_key: Ed25519PrivateKey, key_id: str = "local-ed25519") -> None:
16
+ self._private_key = private_key
17
+ self._key_id = key_id
18
+
19
+ @property
20
+ def key_id(self) -> str:
21
+ return self._key_id
22
+
23
+ def sign(self, payload: bytes) -> bytes:
24
+ return self._private_key.sign(payload)
25
+
26
+ def public_key(self) -> Ed25519PublicKey:
27
+ return self._private_key.public_key()
28
+
29
+ @classmethod
30
+ def generate(cls, key_id: str = "local-ed25519") -> Ed25519Signer:
31
+ return cls(Ed25519PrivateKey.generate(), key_id)
32
+
33
+ def save_private(self, path: Path) -> None:
34
+ secure_atomic_write(
35
+ path,
36
+ self._private_key.private_bytes(
37
+ serialization.Encoding.PEM,
38
+ serialization.PrivateFormat.PKCS8,
39
+ serialization.NoEncryption(),
40
+ ),
41
+ mode=0o600,
42
+ )
43
+
44
+ def save_public(self, path: Path) -> None:
45
+ secure_atomic_write(
46
+ path,
47
+ self.public_key().public_bytes(
48
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
49
+ ),
50
+ mode=0o644,
51
+ )
52
+
53
+ @classmethod
54
+ def load_private(cls, path: Path, key_id: str = "local-ed25519") -> Ed25519Signer:
55
+ key = serialization.load_pem_private_key(read_regular_file(path), password=None)
56
+ if not isinstance(key, Ed25519PrivateKey):
57
+ raise TypeError("not an Ed25519 private key")
58
+ return cls(key, key_id)
59
+
60
+
61
+ class Ed25519Verifier:
62
+ def __init__(self, keys: dict[str, Ed25519PublicKey]):
63
+ self._keys = keys
64
+
65
+ def verify(self, key_id: str, payload: bytes, signature: bytes) -> bool:
66
+ key = self._keys.get(key_id)
67
+ if key is None:
68
+ return False
69
+ try:
70
+ key.verify(signature, payload)
71
+ return True
72
+ except InvalidSignature:
73
+ return False
74
+
75
+ @classmethod
76
+ def from_public_file(cls, path: Path, key_id: str = "local-ed25519") -> Ed25519Verifier:
77
+ key = serialization.load_pem_public_key(read_regular_file(path))
78
+ if not isinstance(key, Ed25519PublicKey):
79
+ raise TypeError("not an Ed25519 public key")
80
+ return cls({key_id: key})
81
+
82
+
83
+ class Ed25519BrokerGrantVerifier:
84
+ """Explicit trust store for provider-neutral broker grant attestations."""
85
+
86
+ def __init__(self, keys: dict[tuple[str, str], Ed25519PublicKey]) -> None:
87
+ self._keys = keys
88
+
89
+ def verify(
90
+ self,
91
+ broker_id: str,
92
+ key_id: str,
93
+ payload: bytes,
94
+ signature: bytes,
95
+ ) -> bool:
96
+ key = self._keys.get((broker_id, key_id))
97
+ if key is None:
98
+ return False
99
+ try:
100
+ key.verify(signature, payload)
101
+ return True
102
+ except InvalidSignature:
103
+ return False
104
+
105
+ @classmethod
106
+ def from_public_file(
107
+ cls,
108
+ path: Path,
109
+ *,
110
+ broker_id: str,
111
+ key_id: str = "mock-broker-ed25519",
112
+ ) -> Ed25519BrokerGrantVerifier:
113
+ key = serialization.load_pem_public_key(read_regular_file(path))
114
+ if not isinstance(key, Ed25519PublicKey):
115
+ raise TypeError("not an Ed25519 public key")
116
+ return cls({(broker_id, key_id): key})
117
+
118
+
119
+ def encode_signature(value: bytes) -> str:
120
+ return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
121
+
122
+
123
+ def decode_signature(value: str) -> bytes:
124
+ try:
125
+ return base64.b64decode(
126
+ value + "=" * ((4 - len(value) % 4) % 4),
127
+ altchars=b"-_",
128
+ validate=True,
129
+ )
130
+ except (binascii.Error, ValueError) as exc:
131
+ raise ValueError("invalid signature encoding") from exc
@@ -0,0 +1,19 @@
1
+ from .bundle import (
2
+ MAX_EVIDENCE_ARTIFACT_BYTES,
3
+ MAX_EVIDENCE_ARTIFACTS,
4
+ MAX_EVIDENCE_METADATA_BYTES,
5
+ MAX_EVIDENCE_TOTAL_BYTES,
6
+ EvidenceVerificationError,
7
+ build_bundle,
8
+ verify_bundle,
9
+ )
10
+
11
+ __all__ = [
12
+ "MAX_EVIDENCE_ARTIFACT_BYTES",
13
+ "MAX_EVIDENCE_ARTIFACTS",
14
+ "MAX_EVIDENCE_METADATA_BYTES",
15
+ "MAX_EVIDENCE_TOTAL_BYTES",
16
+ "EvidenceVerificationError",
17
+ "build_bundle",
18
+ "verify_bundle",
19
+ ]