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 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.
@@ -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