delcred 0.2.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.
delcred/__init__.py ADDED
@@ -0,0 +1,189 @@
1
+ """delcred — short-lived, per-task, per-user-scoped credentials for AI agents.
2
+
3
+ Five-line happy path::
4
+
5
+ import delcred
6
+
7
+ tok = delcred.delegate(
8
+ user="u_123", agent="billing-agent",
9
+ scope="invoices:read", resource="https://api.acme.test/v1",
10
+ user_token=user_jwt, agent_token=agent_jwt,
11
+ )
12
+ claims = delcred.verify(tok, expected_resource="https://api.acme.test/v1")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from . import keys, policy, tokens
21
+ from ._common import DEFAULT_TTL
22
+ from .audit import AuditLog
23
+ from .broker import Broker
24
+ from .exchange import (
25
+ TOKEN_TYPE_BROKER_KEY,
26
+ TOKEN_TYPE_JWT,
27
+ ExchangeError,
28
+ )
29
+ from .exchange import (
30
+ exchange as _exchange,
31
+ )
32
+ from .exchange import (
33
+ verify as _verify,
34
+ )
35
+ from .policy import PolicyError
36
+ from .revocation import RevocationList
37
+ from .tokens import ExpiredToken, InvalidToken, TokenError
38
+ from .trust import TrustConfigError, add_trusted_issuer, trusted_issuers
39
+
40
+ __version__ = "0.2.0"
41
+ __all__ = [
42
+ "DEFAULT_TTL",
43
+ "TOKEN_TYPE_BROKER_KEY",
44
+ "TOKEN_TYPE_JWT",
45
+ "ExchangeError",
46
+ "ExpiredToken",
47
+ "InvalidToken",
48
+ "PolicyError",
49
+ "TokenError",
50
+ "TrustConfigError",
51
+ "__version__",
52
+ "agent_token",
53
+ "delegate",
54
+ "delegate_api_key",
55
+ "init",
56
+ "revoke_agent",
57
+ "revoke_token",
58
+ "revoke_user",
59
+ "trust_add",
60
+ "trusted_issuers",
61
+ "user_token",
62
+ "verify",
63
+ "verify_broker_key",
64
+ ]
65
+
66
+
67
+ def init(base: Path | None = None, force: bool = False) -> Path:
68
+ """Create a delcred home: issuer keypair + default policy. Returns home path."""
69
+ from ._common import home as _home
70
+ from .keys import key_paths
71
+
72
+ h = base or _home()
73
+ priv, _ = key_paths(h)
74
+ if priv.exists() and not force:
75
+ raise FileExistsError(f"issuer key already exists at {priv} (use force=True)")
76
+ keys.generate(h)
77
+ policy.policy_path(h).write_text(policy.default_policy())
78
+ AuditLog(h).record("init", home=str(h))
79
+ return h
80
+
81
+
82
+ def delegate(
83
+ *,
84
+ user: str | None = None,
85
+ agent: str | None = None,
86
+ scope: str,
87
+ resource: str,
88
+ user_token: str,
89
+ agent_token: str,
90
+ ttl: int = DEFAULT_TTL,
91
+ base: Path | None = None,
92
+ ) -> str:
93
+ """Mint a delegation JWT via RFC 8693 exchange. Returns the token."""
94
+ result = _exchange(
95
+ subject_token=user_token,
96
+ actor_token=agent_token,
97
+ scope=scope,
98
+ resource=resource,
99
+ requested_token_type=TOKEN_TYPE_JWT,
100
+ ttl=ttl,
101
+ base=base,
102
+ )
103
+ return result.credential
104
+
105
+
106
+ def delegate_api_key(
107
+ *,
108
+ scope: str,
109
+ resource: str,
110
+ user_token: str,
111
+ agent_token: str,
112
+ ttl: int = DEFAULT_TTL,
113
+ base: Path | None = None,
114
+ ) -> str:
115
+ """Mint an opaque ephemeral API key (``dk_…``) backed by a delegation."""
116
+ result = _exchange(
117
+ subject_token=user_token,
118
+ actor_token=agent_token,
119
+ scope=scope,
120
+ resource=resource,
121
+ requested_token_type=TOKEN_TYPE_BROKER_KEY,
122
+ ttl=ttl,
123
+ base=base,
124
+ )
125
+ return result.credential
126
+
127
+
128
+ def verify(
129
+ token: str,
130
+ *,
131
+ expected_resource: str,
132
+ base: Path | None = None,
133
+ ) -> dict[str, Any]:
134
+ """Verify a delegation JWT (signature → expiry → revocation → audience).
135
+
136
+ ``expected_resource`` is required — audience pinning is the
137
+ confused-deputy defense and must not be skippable.
138
+ """
139
+ return _verify(token, expected_resource=expected_resource, base=base)
140
+
141
+
142
+ def verify_broker_key(key: str, base: Path | None = None) -> dict[str, Any]:
143
+ """Redeem an opaque broker key → capability record (burns single-use keys)."""
144
+ from ._common import home as _home
145
+
146
+ return Broker(base or _home()).redeem(key)
147
+
148
+
149
+ def revoke_token(jti: str, reason: str = "", base: Path | None = None) -> None:
150
+ from ._common import home as _home
151
+
152
+ RevocationList(base or _home()).revoke_jti(jti, reason=reason)
153
+
154
+
155
+ def revoke_agent(agent: str, reason: str = "", base: Path | None = None) -> None:
156
+ """Kill switch: invalidate every credential for this agent, user untouched."""
157
+ from ._common import home as _home
158
+
159
+ h = base or _home()
160
+ RevocationList(h).revoke_agent(agent, reason=reason)
161
+ AuditLog(h).record("agent_revoked", agent=agent, reason=reason)
162
+
163
+
164
+ def revoke_user(user: str, reason: str = "", base: Path | None = None) -> None:
165
+ """Kill switch: invalidate every delegation made by this user."""
166
+ from ._common import home as _home
167
+
168
+ h = base or _home()
169
+ RevocationList(h).revoke_user(user, reason=reason)
170
+ AuditLog(h).record("user_revoked", user=user, reason=reason)
171
+
172
+
173
+ def agent_token(agent_id: str, ttl: int = 86400, base: Path | None = None) -> str:
174
+ from ._common import home as _home
175
+
176
+ return tokens.mint_agent_token(agent_id, ttl=ttl, base=base or _home())
177
+
178
+
179
+ def trust_add(iss: str, key_file: str, base: Path | None = None) -> Path:
180
+ """Trust an external IdP's subject tokens: register its Ed25519 public key."""
181
+ from ._common import home as _home
182
+
183
+ return add_trusted_issuer(iss, key_file, base=base or _home())
184
+
185
+
186
+ def user_token(user_id: str, ttl: int = 3600, base: Path | None = None) -> str:
187
+ from ._common import home as _home
188
+
189
+ return tokens.mint_user_token(user_id, ttl=ttl, base=base or _home())
delcred/_common.py ADDED
@@ -0,0 +1,53 @@
1
+ """delcred home-directory layout and shared helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ from pathlib import Path
9
+
10
+ DEFAULT_TTL = 900 # 15 minutes — the primary blast-radius control
11
+ MAX_TTL = 3600 # hard cap: 60 minutes, enforced server-side
12
+ CLOCK_SKEW_LEEWAY = 60 # seconds of acceptable clock skew
13
+ ISSUER_ID = "delcred"
14
+ KEY_PREFIX = "dk_" # opaque broker keys look like: dk_<urlsafe>
15
+
16
+
17
+ def home() -> Path:
18
+ """State directory: $DELCRED_HOME or ~/.delcred (created 0700)."""
19
+ p = Path(os.environ.get("DELCRED_HOME", Path.home() / ".delcred"))
20
+ p.mkdir(mode=0o700, parents=True, exist_ok=True)
21
+ # Harden pre-existing dirs too.
22
+ try:
23
+ os.chmod(p, 0o700)
24
+ except OSError:
25
+ pass
26
+ return p
27
+
28
+
29
+ def assert_private(path: Path) -> None:
30
+ """Refuse to use a private key file that is readable by group/other."""
31
+ mode = stat.S_IMODE(path.stat().st_mode)
32
+ if mode & 0o077:
33
+ raise PermissionError(
34
+ f"refusing to use {path}: permissions {oct(mode)} are too open "
35
+ "(must be 0600 or stricter)"
36
+ )
37
+
38
+
39
+ def write_private(path: Path, data: bytes) -> None:
40
+ """Write a secret file with 0600 permissions (no races via O_CREAT|O_EXCL)."""
41
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
42
+ try:
43
+ os.write(fd, data)
44
+ finally:
45
+ os.close(fd)
46
+ os.chmod(path, 0o600)
47
+
48
+
49
+ def load_json(path: Path, default):
50
+ try:
51
+ return json.loads(path.read_text())
52
+ except (FileNotFoundError, json.JSONDecodeError):
53
+ return default
delcred/audit.py ADDED
@@ -0,0 +1,53 @@
1
+ """Append-only audit log (JSONL).
2
+
3
+ Every security-relevant event is logged with actor identities, jti, scope,
4
+ resource, and outcome — but NEVER token values or raw API keys.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fcntl
10
+ import json
11
+ import os
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ._common import home
18
+
19
+ # Intra-process half of mutual exclusion (see revocation.py): keeps
20
+ # concurrent threads from interleaving JSON lines.
21
+ _thread_lock = threading.Lock()
22
+
23
+
24
+ class AuditLog:
25
+ def __init__(self, base: Path | None = None):
26
+ self.path = (base or home()) / "audit.jsonl"
27
+ self.path.touch(exist_ok=True)
28
+
29
+ def record(self, event: str, **fields: Any) -> None:
30
+ entry = {"ts": int(time.time()), "event": event}
31
+ entry.update(fields)
32
+ # Defense in depth: never allow a token/key value into the log, even
33
+ # if a caller passes one by mistake.
34
+ for banned in ("token", "subject_token", "actor_token", "api_key", "key"):
35
+ entry.pop(banned, None)
36
+ with _thread_lock, open(self.path, "a") as f:
37
+ fcntl.flock(f, fcntl.LOCK_EX)
38
+ try:
39
+ f.write(json.dumps(entry) + "\n")
40
+ f.flush()
41
+ os.fsync(f.fileno())
42
+ finally:
43
+ fcntl.flock(f, fcntl.LOCK_UN)
44
+
45
+ def tail(self, n: int = 20) -> list[dict[str, Any]]:
46
+ lines = self.path.read_text().splitlines()
47
+ out = []
48
+ for line in lines[-n:]:
49
+ try:
50
+ out.append(json.loads(line))
51
+ except json.JSONDecodeError:
52
+ continue
53
+ return out
delcred/broker.py ADDED
@@ -0,0 +1,268 @@
1
+ """Ephemeral API-key broker (merged market-gap #10).
2
+
3
+ Problem: agents today hold raw API keys (Stripe keys, DB passwords) in their
4
+ environment — one prompt injection and the key is exfiltrated. delcred's
5
+ answer: the agent never sees the real key. Instead it gets an *opaque,
6
+ short-lived, single-purpose* key (``dk_<urlsafe>``) that maps to a delegation
7
+ record. Redeeming it yields the *capability* (a delegation JWT scoped to one
8
+ resource), never the underlying secret.
9
+
10
+ Storage: only SHA-256 hashes of keys are kept (constant-time compare on
11
+ redeem). Records carry the same TTL + revocation semantics as JWT
12
+ delegations. This module is intentionally the *redemption half* of the
13
+ exchange — every broker key is born from a policy-checked delegation.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import fcntl
19
+ import hashlib
20
+ import hmac
21
+ import json
22
+ import os
23
+ import secrets
24
+ import threading
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from ._common import KEY_PREFIX, home
30
+ from .audit import AuditLog
31
+ from .revocation import RevocationList
32
+ from .tokens import InvalidToken, decode_unverified
33
+
34
+ # fcntl locks are process-associated (see revocation.py): this module-level
35
+ # lock provides the intra-process half of mutual exclusion for the broker
36
+ # file. Never nest it.
37
+ _thread_lock = threading.Lock()
38
+
39
+ # Grace added to revocation entries so a burned jti stays denylisted past
40
+ # the token's exp + clock-skew leeway (finding #5).
41
+ BURN_GRACE = 300
42
+
43
+
44
+ def _now() -> int:
45
+ return int(time.time())
46
+
47
+
48
+ def _hash(key: str) -> str:
49
+ return hashlib.sha256(key.encode()).hexdigest()
50
+
51
+
52
+ class BrokerError(Exception):
53
+ pass
54
+
55
+
56
+ class Broker:
57
+ """Issues and redeems opaque ephemeral keys backed by delegation records."""
58
+
59
+ def __init__(self, base: Path | None = None):
60
+ self.base = base or home()
61
+ self.path = self.base / "broker.jsonl"
62
+ self.path.touch(exist_ok=True)
63
+ self.revocations = RevocationList(self.base)
64
+ self.audit = AuditLog(self.base)
65
+
66
+ def _append(self, record: dict[str, Any]) -> None:
67
+ with _thread_lock, open(self.path, "a") as f:
68
+ fcntl.flock(f, fcntl.LOCK_EX)
69
+ try:
70
+ f.write(json.dumps(record) + "\n")
71
+ f.flush()
72
+ os.fsync(f.fileno())
73
+ finally:
74
+ fcntl.flock(f, fcntl.LOCK_UN)
75
+
76
+ def _records(self) -> list[dict[str, Any]]:
77
+ rows = []
78
+ with _thread_lock, open(self.path) as f:
79
+ fcntl.flock(f, fcntl.LOCK_SH)
80
+ try:
81
+ for line in f:
82
+ line = line.strip()
83
+ if line:
84
+ try:
85
+ rows.append(json.loads(line))
86
+ except json.JSONDecodeError:
87
+ continue
88
+ finally:
89
+ fcntl.flock(f, fcntl.LOCK_UN)
90
+ return rows
91
+
92
+ def issue(
93
+ self,
94
+ *,
95
+ user: str,
96
+ agent: str,
97
+ scope: str,
98
+ resource: str,
99
+ ttl: int,
100
+ delegation_jwt: str,
101
+ single_use: bool = False,
102
+ label: str = "",
103
+ ) -> str:
104
+ """Create an opaque key bound to an already-minted delegation JWT."""
105
+ raw = KEY_PREFIX + secrets.token_urlsafe(32)
106
+ now = _now()
107
+ try:
108
+ delegation_jti = decode_unverified(delegation_jwt).get("jti")
109
+ except InvalidToken:
110
+ delegation_jti = None
111
+ self._append(
112
+ {
113
+ "key_hash": _hash(raw),
114
+ "jti": f"broker-{secrets.token_urlsafe(12)}",
115
+ # Finding #1: bind the key to its delegation JWT so revoking
116
+ # the JWT's jti also kills this key.
117
+ "delegation_jti": delegation_jti,
118
+ "user": user,
119
+ "agent": agent,
120
+ "scope": scope,
121
+ "resource": resource,
122
+ "issued_at": now,
123
+ "expires_at": now + ttl,
124
+ "single_use": single_use,
125
+ "label": label,
126
+ # NOTE: the raw key is never written anywhere; only its hash.
127
+ }
128
+ )
129
+ return raw
130
+
131
+ def _reject(self, reason: str, **fields: Any) -> BrokerError:
132
+ # Finding #6: uniform outward message (no key-existence oracle);
133
+ # distinct reason only in the audit log, never with key material.
134
+ self.audit.record("broker_redeem_failed", reason=reason, **fields)
135
+ return BrokerError("invalid API key")
136
+
137
+ def check(self, key: str) -> bool:
138
+ """Non-consuming validity check (no single-use burn).
139
+
140
+ Used by the sidecar to re-validate cached broker keys.
141
+ Returns True only if the key would redeem successfully.
142
+ """
143
+ want = _hash(key)
144
+ match: dict[str, Any] | None = None
145
+ for rec in self._records():
146
+ if hmac.compare_digest(rec.get("key_hash", ""), want):
147
+ match = rec
148
+ break
149
+ if match is None or match["expires_at"] < _now():
150
+ return False
151
+ claims = {
152
+ "jti": match["jti"],
153
+ "sub": match["user"],
154
+ "act": {"sub": match["agent"]},
155
+ }
156
+ revoked, _ = self.revocations.is_revoked(claims)
157
+ if not revoked and match.get("delegation_jti"):
158
+ revoked, _ = self.revocations.is_revoked(
159
+ {**claims, "jti": match["delegation_jti"]}
160
+ )
161
+ # NOTE: single-use keys report False once consumed — the sidecar
162
+ # then mints a fresh one instead of serving a dead credential.
163
+ return not revoked
164
+
165
+ def redeem(self, key: str) -> dict[str, Any]:
166
+ """Validate an opaque key → capability record. Burns single-use keys.
167
+
168
+ Raises BrokerError("invalid API key") on any failure — unknown,
169
+ expired, or revoked are indistinguishable by design.
170
+ """
171
+ want = _hash(key)
172
+ match: dict[str, Any] | None = None
173
+ for rec in self._records():
174
+ if hmac.compare_digest(rec.get("key_hash", ""), want):
175
+ match = rec
176
+ break
177
+ if match is None:
178
+ raise self._reject("unknown_key")
179
+ now = _now()
180
+ if match["expires_at"] < now:
181
+ raise self._reject("expired", jti=match["jti"])
182
+ claims = {
183
+ "jti": match["jti"],
184
+ "sub": match["user"],
185
+ "act": {"sub": match["agent"]},
186
+ }
187
+ revoked, why = self.revocations.is_revoked(claims)
188
+ # Finding #1: also honor revocation of the bound delegation JWT.
189
+ if not revoked and match.get("delegation_jti"):
190
+ revoked, why = self.revocations.is_revoked(
191
+ {**claims, "jti": match["delegation_jti"]}
192
+ )
193
+ if revoked:
194
+ raise self._reject("revoked", jti=match["jti"], detail=why)
195
+ if match.get("single_use"):
196
+ # Atomic burn: exactly one concurrent redeem wins; the losers get
197
+ # the uniform "invalid API key" (no double-spend, no oracle).
198
+ burned = self.revocations.burn_once(
199
+ match["jti"],
200
+ reason="single-use broker key consumed",
201
+ expires_at=match["expires_at"] + BURN_GRACE,
202
+ )
203
+ if not burned:
204
+ raise self._reject("replay", jti=match["jti"])
205
+ self.audit.record(
206
+ "broker_redeemed",
207
+ jti=match["jti"],
208
+ user=match["user"],
209
+ agent=match["agent"],
210
+ resource=match["resource"],
211
+ )
212
+ return {
213
+ "user": match["user"],
214
+ "agent": match["agent"],
215
+ "scope": match["scope"],
216
+ "resource": match["resource"],
217
+ "expires_at": match["expires_at"],
218
+ "label": match.get("label", ""),
219
+ }
220
+
221
+ def revoke_key(self, key: str, reason: str = "") -> bool:
222
+ """Revoke by raw key value (hashes it, denylists the record's jti)."""
223
+ want = _hash(key)
224
+ for rec in self._records():
225
+ if hmac.compare_digest(rec.get("key_hash", ""), want):
226
+ self.revocations.revoke_jti(
227
+ rec["jti"],
228
+ reason=reason or "broker key revoked",
229
+ expires_at=rec["expires_at"],
230
+ )
231
+ return True
232
+ return False
233
+
234
+ def prune(self) -> int:
235
+ """Drop expired records. Returns number removed.
236
+
237
+ The read-filter-replace cycle holds an exclusive lock throughout:
238
+ keys issued concurrently (mid-prune) are never lost to the replace.
239
+ """
240
+ now = _now()
241
+ with _thread_lock, open(self.path, "a+") as f:
242
+ fcntl.flock(f, fcntl.LOCK_EX)
243
+ try:
244
+ f.seek(0)
245
+ kept, dropped = [], 0
246
+ for line in f:
247
+ line = line.strip()
248
+ if not line:
249
+ continue
250
+ try:
251
+ r = json.loads(line)
252
+ except json.JSONDecodeError:
253
+ continue
254
+ if r.get("expires_at", 0) < now:
255
+ dropped += 1
256
+ continue
257
+ kept.append(r)
258
+ if dropped:
259
+ tmp = self.path.with_suffix(".tmp")
260
+ with open(tmp, "w") as tf:
261
+ for r in kept:
262
+ tf.write(json.dumps(r) + "\n")
263
+ tf.flush()
264
+ os.fsync(tf.fileno())
265
+ os.replace(tmp, self.path)
266
+ return dropped
267
+ finally:
268
+ fcntl.flock(f, fcntl.LOCK_UN)