delcred 0.2.0__tar.gz
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-0.2.0/PKG-INFO +208 -0
- delcred-0.2.0/README.md +188 -0
- delcred-0.2.0/delcred/__init__.py +189 -0
- delcred-0.2.0/delcred/_common.py +53 -0
- delcred-0.2.0/delcred/audit.py +53 -0
- delcred-0.2.0/delcred/broker.py +268 -0
- delcred-0.2.0/delcred/cli.py +382 -0
- delcred-0.2.0/delcred/exchange.py +226 -0
- delcred-0.2.0/delcred/keys.py +76 -0
- delcred-0.2.0/delcred/policy.py +143 -0
- delcred-0.2.0/delcred/revocation.py +215 -0
- delcred-0.2.0/delcred/server.py +163 -0
- delcred-0.2.0/delcred/sidecar.py +195 -0
- delcred-0.2.0/delcred/tokens.py +258 -0
- delcred-0.2.0/delcred/trust.py +145 -0
- delcred-0.2.0/delcred.egg-info/PKG-INFO +208 -0
- delcred-0.2.0/delcred.egg-info/SOURCES.txt +28 -0
- delcred-0.2.0/delcred.egg-info/dependency_links.txt +1 -0
- delcred-0.2.0/delcred.egg-info/entry_points.txt +2 -0
- delcred-0.2.0/delcred.egg-info/requires.txt +6 -0
- delcred-0.2.0/delcred.egg-info/top_level.txt +1 -0
- delcred-0.2.0/pyproject.toml +38 -0
- delcred-0.2.0/setup.cfg +4 -0
- delcred-0.2.0/tests/test_cli_server_sidecar.py +498 -0
- delcred-0.2.0/tests/test_exchange.py +230 -0
- delcred-0.2.0/tests/test_keys.py +66 -0
- delcred-0.2.0/tests/test_policy.py +90 -0
- delcred-0.2.0/tests/test_revocation_broker.py +324 -0
- delcred-0.2.0/tests/test_tokens.py +260 -0
- delcred-0.2.0/tests/test_trust.py +184 -0
delcred-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: delcred
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Short-lived, per-task, per-user-scoped credentials for AI agents — RFC 8693 delegation + ephemeral API-key broker
|
|
5
|
+
Author: Calas Automations Inc.
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: agents,oauth,delegation,credentials,zero-trust,rfc8693
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Security
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: PyJWT>=2.8
|
|
16
|
+
Requires-Dist: cryptography>=41
|
|
17
|
+
Requires-Dist: PyYAML>=6
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# delcred — delegated credentials for AI agents
|
|
22
|
+
|
|
23
|
+
**Stop giving agents your real API keys.** delcred mints short-lived,
|
|
24
|
+
per-task, per-user-scoped credentials for agents: one credential naming both
|
|
25
|
+
the human (`sub`) and the agent (`act`), pinned to a single API, expiring in
|
|
26
|
+
minutes. Revoke the agent without locking out the human.
|
|
27
|
+
|
|
28
|
+
This is **market-gap #1 (Agent Identity & Delegated Credentials) merged with
|
|
29
|
+
#10 (Agent API Key Broker)** — the broker is the redemption half of
|
|
30
|
+
delegation: agents get *capability-shaped* credentials (a scoped JWT or an
|
|
31
|
+
opaque `dk_…` key), never the underlying secret.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install delcred
|
|
35
|
+
delcred init
|
|
36
|
+
# edit ~/.delcred/policy.yaml — one grant per (user, agent) pair
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 30-second tour
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# 1. identities (agent = actor_token, user = subject_token)
|
|
43
|
+
delcred agent-token billing-agent > /tmp/agent.jwt
|
|
44
|
+
delcred user-token u_alice > /tmp/user.jwt # dev only; prod uses your IdP's JWT
|
|
45
|
+
|
|
46
|
+
# 2. exchange → delegation (RFC 8693 style).
|
|
47
|
+
# Prefer files/env over CLI args — command lines are visible in `ps`.
|
|
48
|
+
delcred agent-token billing-agent > /tmp/agent.jwt
|
|
49
|
+
delcred user-token u_alice > /tmp/user.jwt # dev only; prod uses your IdP's JWT
|
|
50
|
+
delcred exchange --subject-token-file /tmp/user.jwt \
|
|
51
|
+
--actor-token-file /tmp/agent.jwt \
|
|
52
|
+
--scope "invoices:read" --resource https://api.acme.test/v1
|
|
53
|
+
# ($DELCRED_SUBJECT_TOKEN / $DELCRED_ACTOR_TOKEN work too)
|
|
54
|
+
# → eyJhbGciOiJFZERTQSIs... (15-min JWT: sub=u_alice, act=billing-agent)
|
|
55
|
+
|
|
56
|
+
# 3. or an opaque API-key-shaped credential instead of a JWT
|
|
57
|
+
delcred exchange ... --as-api-key
|
|
58
|
+
# → dk_7f3a...
|
|
59
|
+
|
|
60
|
+
# 4. verify — --resource is REQUIRED (audience pinning is the confused-deputy defense)
|
|
61
|
+
delcred verify <token> --resource https://api.acme.test/v1
|
|
62
|
+
|
|
63
|
+
# 5. revoke the agent — the human is untouched
|
|
64
|
+
delcred revoke --agent billing-agent --reason "suspected prompt injection"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## External IdPs (production subject tokens)
|
|
68
|
+
|
|
69
|
+
delcred is **not** an IdP — it never does login or OAuth flows. But it
|
|
70
|
+
accepts your IdP's user JWTs as `subject_token`: register the issuer once
|
|
71
|
+
(Ed25519 public key), then pass the IdP's tokens straight into `exchange`:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
delcred trust-add --iss https://idp.example.com --key-file ./idp-pub.pem
|
|
75
|
+
# → copies the key to ~/.delcred/trusted_issuers/ and records the mapping
|
|
76
|
+
# in policy.yaml under `trusted_issuers:`
|
|
77
|
+
|
|
78
|
+
delcred exchange --subject-token-file /tmp/idp-user.jwt \
|
|
79
|
+
--actor-token-file /tmp/agent.jwt \
|
|
80
|
+
--scope "invoices:read" --resource https://api.acme.test/v1
|
|
81
|
+
# subject claims come from the IdP token (iss/sub verified against the
|
|
82
|
+
# registered key); only EdDSA tokens are accepted, mirroring local policy.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or declare it directly in `policy.yaml`:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
trusted_issuers:
|
|
89
|
+
"https://idp.example.com": "trusted_issuers/idp.pem" # relative to ~/.delcred
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Notes: the local issuer (`iss: delcred`) can never be shadowed by this
|
|
93
|
+
registry; unknown issuers are rejected; a trusted token with `kind: agent`
|
|
94
|
+
is still refused as a subject (no token laundering).
|
|
95
|
+
|
|
96
|
+
## Library (5 lines)
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import delcred
|
|
100
|
+
|
|
101
|
+
tok = delcred.delegate(scope="invoices:read",
|
|
102
|
+
resource="https://api.acme.test/v1",
|
|
103
|
+
user_token=user_jwt, agent_token=agent_jwt)
|
|
104
|
+
claims = delcred.verify(tok, expected_resource="https://api.acme.test/v1")
|
|
105
|
+
|
|
106
|
+
key = delcred.delegate_api_key(...) # opaque dk_… credential
|
|
107
|
+
cap = delcred.verify_broker_key(key) # redeem → capability record
|
|
108
|
+
|
|
109
|
+
delcred.revoke_agent("billing-agent", reason="...") # kill switch
|
|
110
|
+
delcred.revoke_user("u_alice") # kill switch
|
|
111
|
+
delcred.revoke_token(jti) # single token
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## The sidecar pattern (recommended)
|
|
115
|
+
|
|
116
|
+
Agents should never see tokens at all. Run the sidecar; it mints, caches,
|
|
117
|
+
and injects credentials for allowlisted upstreams:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
delcred sidecar --port 8080 \
|
|
121
|
+
--map 'api.acme.test=invoices:read|https://api.acme.test/v1' \
|
|
122
|
+
--user-token-file /tmp/user.jwt --agent-token-file /tmp/agent.jwt
|
|
123
|
+
# agent calls http://localhost:8080 with X-Delcred-Upstream: api.acme.test
|
|
124
|
+
# sidecar injects: Authorization: Bearer <15-min delegation token>
|
|
125
|
+
#
|
|
126
|
+
# The sidecar's own identity can come from --user-token/--agent-token,
|
|
127
|
+
# --user-token-file/--agent-token-file, or $DELCRED_SIDECAR_USER_TOKEN /
|
|
128
|
+
# $DELCRED_SIDECAR_AGENT_TOKEN. Prefer files or env — command lines are
|
|
129
|
+
# visible in `ps`. The sidecar reads them once at startup into memory.
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Or run the HTTP API: `delcred serve --port 8400`
|
|
133
|
+
(`POST /exchange`, `/verify`, `/broker/redeem`, `/revoke`, `GET /health`).
|
|
134
|
+
|
|
135
|
+
## Policy (`~/.delcred/policy.yaml`)
|
|
136
|
+
|
|
137
|
+
Deny by default. A grant is the *only* thing that lets an exchange mint:
|
|
138
|
+
|
|
139
|
+
```yaml
|
|
140
|
+
grants:
|
|
141
|
+
- user: u_alice
|
|
142
|
+
agent: billing-agent
|
|
143
|
+
scopes: ["invoices:read"]
|
|
144
|
+
resources: ["https://api.acme.test/v1"] # exact match (RFC 8707)
|
|
145
|
+
max_ttl: 900 # seconds; global hard cap is 3600
|
|
146
|
+
single_use: false # true → jti burned on first verify
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Dry-run: `delcred policy check --user u_alice --agent billing-agent --scope invoices:read --resource https://api.acme.test/v1`
|
|
150
|
+
|
|
151
|
+
## Security model
|
|
152
|
+
|
|
153
|
+
- **Ed25519 only.** Strict `algorithms=["EdDSA"]`, issuer pinned, `typ: at+jwt`.
|
|
154
|
+
`none`/HS256/RSA confusion rejected.
|
|
155
|
+
- **Short lifetimes are the primary control:** default 15 min, hard cap 60 min,
|
|
156
|
+
clamped server-side no matter what the requester asks.
|
|
157
|
+
- **Confused-deputy defense:** every token carries an RFC 8707 resource
|
|
158
|
+
indicator; verifiers must check audience (`delcred verify --resource`).
|
|
159
|
+
- **Scope discipline:** requested scopes must be ⊆ the grant; agents can never
|
|
160
|
+
mint beyond their grant.
|
|
161
|
+
- **Revocation:** per-token `jti` denylist + per-agent and per-user kill
|
|
162
|
+
switches, checked on every verify. Single-use tokens burn on first verify.
|
|
163
|
+
- **Broker keys:** only SHA-256 hashes stored; constant-time compare on redeem;
|
|
164
|
+
uniform error messages (no key-existence oracle); every broker key is bound
|
|
165
|
+
to its delegation JWT — revoking the JWT's `jti` kills the key too.
|
|
166
|
+
- **Hygiene:** private keys 0600 (refuses to load otherwise), state dir 0700,
|
|
167
|
+
fcntl-locked append-only JSONL, audit log never contains token/key values,
|
|
168
|
+
token inputs via file/env (not `ps`-visible CLI args).
|
|
169
|
+
- **Trust boundary:** agent identity tokens are operator-asserted — whoever
|
|
170
|
+
controls the delcred home defines agent identities. Host access control
|
|
171
|
+
*is* the identity root.
|
|
172
|
+
|
|
173
|
+
## Limitations (read before production)
|
|
174
|
+
|
|
175
|
+
1. **Bearer tokens.** Delegation JWTs and `dk_…` keys are bearer credentials:
|
|
176
|
+
anyone holding one can use it until it expires. Mitigations shipped: 15-min
|
|
177
|
+
default TTL, single-use option, resource pinning, instant local revocation.
|
|
178
|
+
Sender-constrained tokens (DPoP/mTLS) are **not** in v1 — on the roadmap.
|
|
179
|
+
2. **Single-writer revocation.** The denylist lives in one delcred home.
|
|
180
|
+
There is no multi-host revocation sync in v1 — run one issuer per trust
|
|
181
|
+
domain, or accept that a revocation on host A doesn't reach host B.
|
|
182
|
+
3. **Single-use semantics.** The burn is atomic across processes and threads
|
|
183
|
+
(check-and-set under an exclusive lock): exactly one concurrent verify or
|
|
184
|
+
redeem wins, the rest are rejected. Don't use single-use as an exactly-once
|
|
185
|
+
*execution* primitive — it bounds credential replay, not downstream effects.
|
|
186
|
+
4. **Local user assertions are dev-only.** `delcred user-token` mints a
|
|
187
|
+
self-asserted subject token. In production, register your IdP with
|
|
188
|
+
`delcred trust-add` and pass its real user JWTs as `subject_token` —
|
|
189
|
+
delcred verifies signature/issuer/expiry against the registered key, not
|
|
190
|
+
your login flow. delcred is a token service, not an IdP.
|
|
191
|
+
5. **`delcred serve` binds localhost without auth.** Put TLS + authentication
|
|
192
|
+
in front before exposing it; anyone who can reach `/exchange` can mint
|
|
193
|
+
within policy.
|
|
194
|
+
6. **No chained delegation.** `may_act` (agent→sub-agent) is deferred to v2.
|
|
195
|
+
7. **Clock skew.** 60s leeway is assumed; larger drift breaks verification.
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
201
|
+
.venv/bin/python -m pytest tests/ -q
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Layout: `delcred/{tokens,keys,policy,exchange,revocation,broker,audit,cli,server,sidecar}.py`
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT — Calas Automations Inc.
|
delcred-0.2.0/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# delcred — delegated credentials for AI agents
|
|
2
|
+
|
|
3
|
+
**Stop giving agents your real API keys.** delcred mints short-lived,
|
|
4
|
+
per-task, per-user-scoped credentials for agents: one credential naming both
|
|
5
|
+
the human (`sub`) and the agent (`act`), pinned to a single API, expiring in
|
|
6
|
+
minutes. Revoke the agent without locking out the human.
|
|
7
|
+
|
|
8
|
+
This is **market-gap #1 (Agent Identity & Delegated Credentials) merged with
|
|
9
|
+
#10 (Agent API Key Broker)** — the broker is the redemption half of
|
|
10
|
+
delegation: agents get *capability-shaped* credentials (a scoped JWT or an
|
|
11
|
+
opaque `dk_…` key), never the underlying secret.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install delcred
|
|
15
|
+
delcred init
|
|
16
|
+
# edit ~/.delcred/policy.yaml — one grant per (user, agent) pair
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 30-second tour
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 1. identities (agent = actor_token, user = subject_token)
|
|
23
|
+
delcred agent-token billing-agent > /tmp/agent.jwt
|
|
24
|
+
delcred user-token u_alice > /tmp/user.jwt # dev only; prod uses your IdP's JWT
|
|
25
|
+
|
|
26
|
+
# 2. exchange → delegation (RFC 8693 style).
|
|
27
|
+
# Prefer files/env over CLI args — command lines are visible in `ps`.
|
|
28
|
+
delcred agent-token billing-agent > /tmp/agent.jwt
|
|
29
|
+
delcred user-token u_alice > /tmp/user.jwt # dev only; prod uses your IdP's JWT
|
|
30
|
+
delcred exchange --subject-token-file /tmp/user.jwt \
|
|
31
|
+
--actor-token-file /tmp/agent.jwt \
|
|
32
|
+
--scope "invoices:read" --resource https://api.acme.test/v1
|
|
33
|
+
# ($DELCRED_SUBJECT_TOKEN / $DELCRED_ACTOR_TOKEN work too)
|
|
34
|
+
# → eyJhbGciOiJFZERTQSIs... (15-min JWT: sub=u_alice, act=billing-agent)
|
|
35
|
+
|
|
36
|
+
# 3. or an opaque API-key-shaped credential instead of a JWT
|
|
37
|
+
delcred exchange ... --as-api-key
|
|
38
|
+
# → dk_7f3a...
|
|
39
|
+
|
|
40
|
+
# 4. verify — --resource is REQUIRED (audience pinning is the confused-deputy defense)
|
|
41
|
+
delcred verify <token> --resource https://api.acme.test/v1
|
|
42
|
+
|
|
43
|
+
# 5. revoke the agent — the human is untouched
|
|
44
|
+
delcred revoke --agent billing-agent --reason "suspected prompt injection"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## External IdPs (production subject tokens)
|
|
48
|
+
|
|
49
|
+
delcred is **not** an IdP — it never does login or OAuth flows. But it
|
|
50
|
+
accepts your IdP's user JWTs as `subject_token`: register the issuer once
|
|
51
|
+
(Ed25519 public key), then pass the IdP's tokens straight into `exchange`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
delcred trust-add --iss https://idp.example.com --key-file ./idp-pub.pem
|
|
55
|
+
# → copies the key to ~/.delcred/trusted_issuers/ and records the mapping
|
|
56
|
+
# in policy.yaml under `trusted_issuers:`
|
|
57
|
+
|
|
58
|
+
delcred exchange --subject-token-file /tmp/idp-user.jwt \
|
|
59
|
+
--actor-token-file /tmp/agent.jwt \
|
|
60
|
+
--scope "invoices:read" --resource https://api.acme.test/v1
|
|
61
|
+
# subject claims come from the IdP token (iss/sub verified against the
|
|
62
|
+
# registered key); only EdDSA tokens are accepted, mirroring local policy.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or declare it directly in `policy.yaml`:
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
trusted_issuers:
|
|
69
|
+
"https://idp.example.com": "trusted_issuers/idp.pem" # relative to ~/.delcred
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Notes: the local issuer (`iss: delcred`) can never be shadowed by this
|
|
73
|
+
registry; unknown issuers are rejected; a trusted token with `kind: agent`
|
|
74
|
+
is still refused as a subject (no token laundering).
|
|
75
|
+
|
|
76
|
+
## Library (5 lines)
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import delcred
|
|
80
|
+
|
|
81
|
+
tok = delcred.delegate(scope="invoices:read",
|
|
82
|
+
resource="https://api.acme.test/v1",
|
|
83
|
+
user_token=user_jwt, agent_token=agent_jwt)
|
|
84
|
+
claims = delcred.verify(tok, expected_resource="https://api.acme.test/v1")
|
|
85
|
+
|
|
86
|
+
key = delcred.delegate_api_key(...) # opaque dk_… credential
|
|
87
|
+
cap = delcred.verify_broker_key(key) # redeem → capability record
|
|
88
|
+
|
|
89
|
+
delcred.revoke_agent("billing-agent", reason="...") # kill switch
|
|
90
|
+
delcred.revoke_user("u_alice") # kill switch
|
|
91
|
+
delcred.revoke_token(jti) # single token
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## The sidecar pattern (recommended)
|
|
95
|
+
|
|
96
|
+
Agents should never see tokens at all. Run the sidecar; it mints, caches,
|
|
97
|
+
and injects credentials for allowlisted upstreams:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
delcred sidecar --port 8080 \
|
|
101
|
+
--map 'api.acme.test=invoices:read|https://api.acme.test/v1' \
|
|
102
|
+
--user-token-file /tmp/user.jwt --agent-token-file /tmp/agent.jwt
|
|
103
|
+
# agent calls http://localhost:8080 with X-Delcred-Upstream: api.acme.test
|
|
104
|
+
# sidecar injects: Authorization: Bearer <15-min delegation token>
|
|
105
|
+
#
|
|
106
|
+
# The sidecar's own identity can come from --user-token/--agent-token,
|
|
107
|
+
# --user-token-file/--agent-token-file, or $DELCRED_SIDECAR_USER_TOKEN /
|
|
108
|
+
# $DELCRED_SIDECAR_AGENT_TOKEN. Prefer files or env — command lines are
|
|
109
|
+
# visible in `ps`. The sidecar reads them once at startup into memory.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Or run the HTTP API: `delcred serve --port 8400`
|
|
113
|
+
(`POST /exchange`, `/verify`, `/broker/redeem`, `/revoke`, `GET /health`).
|
|
114
|
+
|
|
115
|
+
## Policy (`~/.delcred/policy.yaml`)
|
|
116
|
+
|
|
117
|
+
Deny by default. A grant is the *only* thing that lets an exchange mint:
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
grants:
|
|
121
|
+
- user: u_alice
|
|
122
|
+
agent: billing-agent
|
|
123
|
+
scopes: ["invoices:read"]
|
|
124
|
+
resources: ["https://api.acme.test/v1"] # exact match (RFC 8707)
|
|
125
|
+
max_ttl: 900 # seconds; global hard cap is 3600
|
|
126
|
+
single_use: false # true → jti burned on first verify
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Dry-run: `delcred policy check --user u_alice --agent billing-agent --scope invoices:read --resource https://api.acme.test/v1`
|
|
130
|
+
|
|
131
|
+
## Security model
|
|
132
|
+
|
|
133
|
+
- **Ed25519 only.** Strict `algorithms=["EdDSA"]`, issuer pinned, `typ: at+jwt`.
|
|
134
|
+
`none`/HS256/RSA confusion rejected.
|
|
135
|
+
- **Short lifetimes are the primary control:** default 15 min, hard cap 60 min,
|
|
136
|
+
clamped server-side no matter what the requester asks.
|
|
137
|
+
- **Confused-deputy defense:** every token carries an RFC 8707 resource
|
|
138
|
+
indicator; verifiers must check audience (`delcred verify --resource`).
|
|
139
|
+
- **Scope discipline:** requested scopes must be ⊆ the grant; agents can never
|
|
140
|
+
mint beyond their grant.
|
|
141
|
+
- **Revocation:** per-token `jti` denylist + per-agent and per-user kill
|
|
142
|
+
switches, checked on every verify. Single-use tokens burn on first verify.
|
|
143
|
+
- **Broker keys:** only SHA-256 hashes stored; constant-time compare on redeem;
|
|
144
|
+
uniform error messages (no key-existence oracle); every broker key is bound
|
|
145
|
+
to its delegation JWT — revoking the JWT's `jti` kills the key too.
|
|
146
|
+
- **Hygiene:** private keys 0600 (refuses to load otherwise), state dir 0700,
|
|
147
|
+
fcntl-locked append-only JSONL, audit log never contains token/key values,
|
|
148
|
+
token inputs via file/env (not `ps`-visible CLI args).
|
|
149
|
+
- **Trust boundary:** agent identity tokens are operator-asserted — whoever
|
|
150
|
+
controls the delcred home defines agent identities. Host access control
|
|
151
|
+
*is* the identity root.
|
|
152
|
+
|
|
153
|
+
## Limitations (read before production)
|
|
154
|
+
|
|
155
|
+
1. **Bearer tokens.** Delegation JWTs and `dk_…` keys are bearer credentials:
|
|
156
|
+
anyone holding one can use it until it expires. Mitigations shipped: 15-min
|
|
157
|
+
default TTL, single-use option, resource pinning, instant local revocation.
|
|
158
|
+
Sender-constrained tokens (DPoP/mTLS) are **not** in v1 — on the roadmap.
|
|
159
|
+
2. **Single-writer revocation.** The denylist lives in one delcred home.
|
|
160
|
+
There is no multi-host revocation sync in v1 — run one issuer per trust
|
|
161
|
+
domain, or accept that a revocation on host A doesn't reach host B.
|
|
162
|
+
3. **Single-use semantics.** The burn is atomic across processes and threads
|
|
163
|
+
(check-and-set under an exclusive lock): exactly one concurrent verify or
|
|
164
|
+
redeem wins, the rest are rejected. Don't use single-use as an exactly-once
|
|
165
|
+
*execution* primitive — it bounds credential replay, not downstream effects.
|
|
166
|
+
4. **Local user assertions are dev-only.** `delcred user-token` mints a
|
|
167
|
+
self-asserted subject token. In production, register your IdP with
|
|
168
|
+
`delcred trust-add` and pass its real user JWTs as `subject_token` —
|
|
169
|
+
delcred verifies signature/issuer/expiry against the registered key, not
|
|
170
|
+
your login flow. delcred is a token service, not an IdP.
|
|
171
|
+
5. **`delcred serve` binds localhost without auth.** Put TLS + authentication
|
|
172
|
+
in front before exposing it; anyone who can reach `/exchange` can mint
|
|
173
|
+
within policy.
|
|
174
|
+
6. **No chained delegation.** `may_act` (agent→sub-agent) is deferred to v2.
|
|
175
|
+
7. **Clock skew.** 60s leeway is assumed; larger drift breaks verification.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
181
|
+
.venv/bin/python -m pytest tests/ -q
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Layout: `delcred/{tokens,keys,policy,exchange,revocation,broker,audit,cli,server,sidecar}.py`
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT — Calas Automations Inc.
|
|
@@ -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())
|
|
@@ -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
|
|
@@ -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
|