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 +189 -0
- delcred/_common.py +53 -0
- delcred/audit.py +53 -0
- delcred/broker.py +268 -0
- delcred/cli.py +382 -0
- delcred/exchange.py +226 -0
- delcred/keys.py +76 -0
- delcred/policy.py +143 -0
- delcred/revocation.py +215 -0
- delcred/server.py +163 -0
- delcred/sidecar.py +195 -0
- delcred/tokens.py +258 -0
- delcred/trust.py +145 -0
- delcred-0.2.0.dist-info/METADATA +208 -0
- delcred-0.2.0.dist-info/RECORD +18 -0
- delcred-0.2.0.dist-info/WHEEL +5 -0
- delcred-0.2.0.dist-info/entry_points.txt +2 -0
- delcred-0.2.0.dist-info/top_level.txt +1 -0
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)
|