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.
- decisionlock/__init__.py +5 -0
- decisionlock/__main__.py +4 -0
- decisionlock/_meta.py +12 -0
- decisionlock/_version.py +1 -0
- decisionlock/adapters/__init__.py +1 -0
- decisionlock/adapters/broker/__init__.py +17 -0
- decisionlock/adapters/broker/mock.py +63 -0
- decisionlock/adapters/broker/production.py +337 -0
- decisionlock/adapters/crypto/__init__.py +15 -0
- decisionlock/adapters/crypto/ed25519.py +131 -0
- decisionlock/adapters/evidence/__init__.py +19 -0
- decisionlock/adapters/evidence/bundle.py +308 -0
- decisionlock/adapters/executor/__init__.py +3 -0
- decisionlock/adapters/executor/memory.py +58 -0
- decisionlock/adapters/protocols/__init__.py +1 -0
- decisionlock/adapters/protocols/mcp/__init__.py +64 -0
- decisionlock/adapters/protocols/mcp/http_executor.py +232 -0
- decisionlock/adapters/protocols/mcp/http_gateway.py +502 -0
- decisionlock/adapters/protocols/mcp/http_gateway_types.py +26 -0
- decisionlock/adapters/protocols/mcp/http_security.py +46 -0
- decisionlock/adapters/protocols/mcp/http_server.py +268 -0
- decisionlock/adapters/protocols/mcp/intent.py +74 -0
- decisionlock/adapters/protocols/mcp/jsonrpc.py +42 -0
- decisionlock/adapters/protocols/mcp/modern.py +382 -0
- decisionlock/adapters/protocols/mcp/protected.py +1139 -0
- decisionlock/adapters/protocols/mcp/schema.py +28 -0
- decisionlock/adapters/protocols/mcp/stdio.py +340 -0
- decisionlock/adapters/reconciliation/__init__.py +16 -0
- decisionlock/adapters/reconciliation/mcp_http.py +249 -0
- decisionlock/adapters/reconciliation/mcp_http_worker.py +30 -0
- decisionlock/adapters/reconciliation/memory.py +15 -0
- decisionlock/adapters/reconciliation/provider_store.py +203 -0
- decisionlock/adapters/storage/__init__.py +0 -0
- decisionlock/adapters/storage/sqlite/__init__.py +12 -0
- decisionlock/adapters/storage/sqlite/reconciliation.py +455 -0
- decisionlock/adapters/storage/sqlite/state.py +1494 -0
- decisionlock/application/__init__.py +22 -0
- decisionlock/application/provider_reconciliation.py +66 -0
- decisionlock/application/reconciliation_worker.py +211 -0
- decisionlock/application/service.py +1052 -0
- decisionlock/audit.py +530 -0
- decisionlock/changed.py +58 -0
- decisionlock/cli/__init__.py +79 -0
- decisionlock/cli/__main__.py +4 -0
- decisionlock/cli/app.py +861 -0
- decisionlock/cli/io.py +60 -0
- decisionlock/compatibility/__init__.py +3 -0
- decisionlock/compatibility/policy_v1.py +42 -0
- decisionlock/compatibility/v1_cli.py +614 -0
- decisionlock/compatibility/v1_demo.py +129 -0
- decisionlock/demo.py +147 -0
- decisionlock/discovery.py +82 -0
- decisionlock/doctor.py +108 -0
- decisionlock/domain/__init__.py +181 -0
- decisionlock/domain/credential_authority.py +115 -0
- decisionlock/domain/enums.py +126 -0
- decisionlock/domain/models.py +838 -0
- decisionlock/domain/policy.py +136 -0
- decisionlock/domain/provider_contracts.py +176 -0
- decisionlock/engine/__init__.py +1 -0
- decisionlock/engine/attestation.py +122 -0
- decisionlock/engine/authority.py +212 -0
- decisionlock/engine/canonical.py +58 -0
- decisionlock/engine/causal.py +143 -0
- decisionlock/engine/checkpoints.py +72 -0
- decisionlock/engine/credentials.py +129 -0
- decisionlock/engine/execution.py +362 -0
- decisionlock/engine/field_integrity.py +376 -0
- decisionlock/engine/policy.py +267 -0
- decisionlock/engine/policy_intelligence.py +140 -0
- decisionlock/engine/provider_contracts.py +59 -0
- decisionlock/engine/provider_results.py +50 -0
- decisionlock/engine/receipts.py +261 -0
- decisionlock/gate.py +20 -0
- decisionlock/http_cli.py +263 -0
- decisionlock/mcp_stdio.py +679 -0
- decisionlock/models.py +157 -0
- decisionlock/policy.py +606 -0
- decisionlock/policy_packs.py +184 -0
- decisionlock/ports/__init__.py +63 -0
- decisionlock/ports/interfaces.py +318 -0
- decisionlock/ports/reconciliation_work.py +49 -0
- decisionlock/posture.py +244 -0
- decisionlock/py.typed +0 -0
- decisionlock/reconcile_cli.py +162 -0
- decisionlock/reconcile_worker_cli.py +246 -0
- decisionlock/render.py +217 -0
- decisionlock/resources/default_policy_v2.yaml +64 -0
- decisionlock/resources/demo-intent.json +23 -0
- decisionlock/risk.py +158 -0
- decisionlock/secrets.py +89 -0
- decisionlock/security/__init__.py +5 -0
- decisionlock/security/files.py +137 -0
- decisionlock/service_deployment.py +51 -0
- decisionlock-0.2.0rc4.dist-info/METADATA +569 -0
- decisionlock-0.2.0rc4.dist-info/RECORD +100 -0
- decisionlock-0.2.0rc4.dist-info/WHEEL +5 -0
- decisionlock-0.2.0rc4.dist-info/entry_points.txt +5 -0
- decisionlock-0.2.0rc4.dist-info/licenses/LICENSE +9 -0
- decisionlock-0.2.0rc4.dist-info/top_level.txt +1 -0
decisionlock/__init__.py
ADDED
decisionlock/__main__.py
ADDED
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__
|
decisionlock/_version.py
ADDED
|
@@ -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
|
+
]
|