authyouragent 0.3.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.
- authyouragent/__init__.py +34 -0
- authyouragent/__main__.py +6 -0
- authyouragent/agent.py +476 -0
- authyouragent/mcp_server.py +397 -0
- authyouragent/site.py +589 -0
- authyouragent/takeover.py +330 -0
- authyouragent/vault_cli.py +214 -0
- authyouragent/vault_seccomp.json +1236 -0
- authyouragent-0.3.0.dist-info/METADATA +149 -0
- authyouragent-0.3.0.dist-info/RECORD +13 -0
- authyouragent-0.3.0.dist-info/WHEEL +5 -0
- authyouragent-0.3.0.dist-info/entry_points.txt +3 -0
- authyouragent-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Auth Your Agent — Python SDK.
|
|
2
|
+
|
|
3
|
+
Two halves:
|
|
4
|
+
|
|
5
|
+
* :mod:`authyouragent.agent` — for AI agents. Holds the agent's private key,
|
|
6
|
+
asks the user's phone for approval, and signs every request (DPoP).
|
|
7
|
+
* :mod:`authyouragent.site` — for websites. Verifies that a request really
|
|
8
|
+
comes from an agent the user approved, and whether a sensitive action was
|
|
9
|
+
confirmed (step-up).
|
|
10
|
+
|
|
11
|
+
Quick start (agent)::
|
|
12
|
+
|
|
13
|
+
from authyouragent import AgentClient
|
|
14
|
+
agent = AgentClient(base_url="https://authyouragent.com",
|
|
15
|
+
agent_id="ag_…", privkey_pem=open("agent.pem").read())
|
|
16
|
+
agent.ensure_grant("jobs.example.com", scopes=["list"])
|
|
17
|
+
r = agent.request("GET", "https://jobs.example.com/api/jobs")
|
|
18
|
+
|
|
19
|
+
Quick start (site)::
|
|
20
|
+
|
|
21
|
+
from authyouragent import SiteVerifier, AuthError
|
|
22
|
+
verifier = SiteVerifier("https://authyouragent.com")
|
|
23
|
+
auth = verifier.verify(request) # raises AuthError
|
|
24
|
+
|
|
25
|
+
Documentation: https://authyouragent.com/docs
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .agent import AgentClient, AgentError, keygen, agent_jwk
|
|
29
|
+
from .site import SiteVerifier, AuthError, Auth
|
|
30
|
+
|
|
31
|
+
__all__ = ["AgentClient", "AgentError", "keygen", "agent_jwk",
|
|
32
|
+
"SiteVerifier", "AuthError", "Auth"]
|
|
33
|
+
__version__ = "0.3.0"
|
|
34
|
+
DEFAULT_CLOUD = "https://authyouragent.com"
|
authyouragent/agent.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""authyouragent.agent — agent-side SDK for the Auth Your Agent cloud (vertical slice).
|
|
2
|
+
|
|
3
|
+
An agent that wants to act on a user's behalf on a site:
|
|
4
|
+
|
|
5
|
+
s = AgentClient(base_url="https://authyouragent.com",
|
|
6
|
+
agent_id="ag_xxx", privkey_pem="-----BEGIN PRIVATE KEY-----...")
|
|
7
|
+
s.ensure_grant("jobboard.example", scopes=["list", "apply"])
|
|
8
|
+
# → pushes a biometric approval to the user's phone; blocks until answered
|
|
9
|
+
s.call("jobboard.example", "GET", "https://jobs.example.com/list",
|
|
10
|
+
token=..., dpop=...)
|
|
11
|
+
|
|
12
|
+
In practice the agent embeds this around its HTTP calls:
|
|
13
|
+
|
|
14
|
+
req = s.request("GET", "https://jobs.example.com/jobs")
|
|
15
|
+
# sends Authorization: Bearer <AT> + DPoP header; auto-refreshes tokens;
|
|
16
|
+
# auto-requests step-up when the site returns 403 stepup_required
|
|
17
|
+
|
|
18
|
+
Key material: the agent holds its Ed25519/EC P-256 private key; the cloud
|
|
19
|
+
never sees it. The DPoP key IS the agent key (single key, per RFC 9449).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
import json
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
import jwt as pyjwt
|
|
29
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
30
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
31
|
+
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentError(Exception):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AgentClient:
|
|
39
|
+
def __init__(self, base_url, agent_id, privkey_pem, poll_interval=1.5,
|
|
40
|
+
timeout=60, verify=True):
|
|
41
|
+
self.base = base_url.rstrip("/")
|
|
42
|
+
self.agent_id = agent_id
|
|
43
|
+
self.key = serialization.load_pem_private_key(
|
|
44
|
+
privkey_pem.encode() if isinstance(privkey_pem, str) else privkey_pem,
|
|
45
|
+
password=None)
|
|
46
|
+
self.pub = self.key.public_key()
|
|
47
|
+
self.poll_interval = poll_interval
|
|
48
|
+
self.client = httpx.Client(timeout=timeout, verify=verify)
|
|
49
|
+
# per-site token cache: an access token is audience-bound (aud=site),
|
|
50
|
+
# so a token minted for site A must never be sent to site B.
|
|
51
|
+
self._access_by_site = {} # site -> (token, exp_float)
|
|
52
|
+
self._refresh_by_site = {} # site -> refresh token
|
|
53
|
+
self._last_site = None
|
|
54
|
+
self._lock = threading.RLock()
|
|
55
|
+
self._jwk = {
|
|
56
|
+
"kty": "EC", "crv": "P-256",
|
|
57
|
+
"x": b64(self.pub.public_numbers().x.to_bytes(32, "big")),
|
|
58
|
+
"y": b64(self.pub.public_numbers().y.to_bytes(32, "big")),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------ legacy cache accessors
|
|
62
|
+
# Back-compat for callers/tests that poke `_access` / `_refresh` directly:
|
|
63
|
+
# reading returns the most-recently-used site's entry; assigning None
|
|
64
|
+
# clears the cache for ALL sites.
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def _access(self):
|
|
68
|
+
return self._access_by_site.get(self._last_site)
|
|
69
|
+
|
|
70
|
+
@_access.setter
|
|
71
|
+
def _access(self, v):
|
|
72
|
+
with self._lock:
|
|
73
|
+
if v is None:
|
|
74
|
+
self._access_by_site.clear()
|
|
75
|
+
elif self._last_site is not None:
|
|
76
|
+
self._access_by_site[self._last_site] = v
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def _refresh(self):
|
|
80
|
+
return self._refresh_by_site.get(self._last_site)
|
|
81
|
+
|
|
82
|
+
@_refresh.setter
|
|
83
|
+
def _refresh(self, v):
|
|
84
|
+
with self._lock:
|
|
85
|
+
if v is None:
|
|
86
|
+
self._refresh_by_site.clear()
|
|
87
|
+
elif self._last_site is not None:
|
|
88
|
+
self._refresh_by_site[self._last_site] = v
|
|
89
|
+
|
|
90
|
+
def _poll_headers(self):
|
|
91
|
+
# the cloud only reveals a txn's stepup_token to its own agent: every
|
|
92
|
+
# poll carries a FRESH short-lived agent JWT (they expire)
|
|
93
|
+
return {"X-Agent-JWT": self._agent_jwt()}
|
|
94
|
+
|
|
95
|
+
# ------------------------------------------------------------ crypto
|
|
96
|
+
|
|
97
|
+
def _agent_jwt(self, ttl=300):
|
|
98
|
+
"""Signed statement of agency, verified by the cloud against our pubkey."""
|
|
99
|
+
now = int(time.time())
|
|
100
|
+
return pyjwt.encode(
|
|
101
|
+
{"iss": self.agent_id, "sub": self.agent_id,
|
|
102
|
+
"iat": now, "exp": now + ttl, "type": "agent"},
|
|
103
|
+
self.key, algorithm="ES256")
|
|
104
|
+
|
|
105
|
+
def _dpop_proof(self, method, url, access_token=None):
|
|
106
|
+
ath = None
|
|
107
|
+
if access_token:
|
|
108
|
+
import hashlib, base64
|
|
109
|
+
ath = base64.urlsafe_b64encode(
|
|
110
|
+
hashlib.sha256(access_token.encode()).digest()
|
|
111
|
+
).rstrip(b"=").decode()
|
|
112
|
+
payload = {"htm": method.upper(),
|
|
113
|
+
# 'url' for the current cloud, 'htu' (RFC 9449 name)
|
|
114
|
+
# for clouds that accept the standard claim
|
|
115
|
+
"url": url, "htu": url, "iat": int(time.time()),
|
|
116
|
+
"jti": "j_" + _rand()[:12]}
|
|
117
|
+
if ath:
|
|
118
|
+
payload["ath"] = ath
|
|
119
|
+
# RFC 9449: the key (jwk) lives in the DPoP proof's JOSE header.
|
|
120
|
+
header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": self._jwk}
|
|
121
|
+
h = b64(json.dumps(header, separators=(",", ":")).encode())
|
|
122
|
+
p = b64(json.dumps(payload, separators=(",", ":")).encode())
|
|
123
|
+
signing = (h + "." + p).encode()
|
|
124
|
+
der = self.key.sign(signing, ec.ECDSA(hashes.SHA256()))
|
|
125
|
+
# JWS ES256 signatures are raw r||s, 64 bytes (RFC 7518 §3.4), not DER
|
|
126
|
+
r, s_ = decode_dss_signature(der)
|
|
127
|
+
return h + "." + p + "." + b64(r.to_bytes(32, "big") + s_.to_bytes(32, "big"))
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------ grants
|
|
130
|
+
|
|
131
|
+
def ensure_grant(self, site, scopes, wait=True):
|
|
132
|
+
"""Ask the cloud for access to `site`. Pushes approval to the user's
|
|
133
|
+
phone; if `wait`, blocks until the user approves/denies (max ~5 min).
|
|
134
|
+
Returns True if the grant is usable."""
|
|
135
|
+
try:
|
|
136
|
+
r = self.client.post(f"{self.base}/api/v1/authz-requests", json={
|
|
137
|
+
"agent_id": self.agent_id, "site": site,
|
|
138
|
+
"scopes": scopes, "agent_jwt": self._agent_jwt()})
|
|
139
|
+
except httpx.HTTPError as e:
|
|
140
|
+
raise AgentError(f"cloud unreachable: {e}")
|
|
141
|
+
if r.status_code == 403:
|
|
142
|
+
# The only 403 this endpoint emits is check_agent's
|
|
143
|
+
# "agent revoked by owner" (409 means "grant already active").
|
|
144
|
+
# Surface revocation loudly; anything else -> try the token path.
|
|
145
|
+
detail = ""
|
|
146
|
+
try:
|
|
147
|
+
b = r.json()
|
|
148
|
+
detail = (b.get("detail") if isinstance(b.get("detail"), str)
|
|
149
|
+
else b.get("error", "")) or ""
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
if "revoked" in detail.lower():
|
|
153
|
+
raise AgentError(f"agent revoked by owner: {detail}")
|
|
154
|
+
return self._try_token(site)
|
|
155
|
+
if r.status_code == 409:
|
|
156
|
+
return True
|
|
157
|
+
if r.status_code >= 400:
|
|
158
|
+
raise AgentError(f"authz request failed: {r.status_code} {r.text[:200]}")
|
|
159
|
+
txn = _txn_id(r)
|
|
160
|
+
print(f"[authyouragent] approval requested ({txn}) — waiting for phone…")
|
|
161
|
+
if not wait:
|
|
162
|
+
return None
|
|
163
|
+
deadline = time.time() + 330
|
|
164
|
+
while time.time() < deadline:
|
|
165
|
+
try:
|
|
166
|
+
p = self.client.get(f"{self.base}/api/v1/authz-requests/{txn}",
|
|
167
|
+
headers=self._poll_headers())
|
|
168
|
+
except httpx.HTTPError:
|
|
169
|
+
time.sleep(self.poll_interval)
|
|
170
|
+
continue
|
|
171
|
+
if p.status_code >= 500:
|
|
172
|
+
# transient server error — keep polling (the phone may still
|
|
173
|
+
# respond), do not kill the whole flow over one bad poll
|
|
174
|
+
time.sleep(self.poll_interval)
|
|
175
|
+
continue
|
|
176
|
+
if p.status_code == 404:
|
|
177
|
+
raise AgentError("approval txn vanished from the cloud")
|
|
178
|
+
try:
|
|
179
|
+
b = p.json()
|
|
180
|
+
st = b.get("status", "pending") if isinstance(b, dict) else "pending"
|
|
181
|
+
except Exception:
|
|
182
|
+
st = "pending"
|
|
183
|
+
if st == "approved":
|
|
184
|
+
print("[authyouragent] approved ✓")
|
|
185
|
+
return self._try_token(site)
|
|
186
|
+
if st in ("denied", "expired"):
|
|
187
|
+
print(f"[authyouragent] {st}")
|
|
188
|
+
raise AgentError(f"approval {st} by user")
|
|
189
|
+
time.sleep(self.poll_interval)
|
|
190
|
+
raise AgentError("approval timed out")
|
|
191
|
+
|
|
192
|
+
def _try_token(self, site):
|
|
193
|
+
try:
|
|
194
|
+
self._get_access(site)
|
|
195
|
+
return True
|
|
196
|
+
except AgentError:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------ tokens
|
|
200
|
+
|
|
201
|
+
def _get_access(self, site, rotate=True):
|
|
202
|
+
with self._lock:
|
|
203
|
+
return self._get_access_locked(site, rotate)
|
|
204
|
+
|
|
205
|
+
def _get_access_locked(self, site, rotate=True):
|
|
206
|
+
# cache entry: self._access_by_site[site] = (access_token, exp_float)
|
|
207
|
+
self._last_site = site
|
|
208
|
+
cur = self._access_by_site.get(site)
|
|
209
|
+
if cur and float(cur[1]) > time.time() + 30:
|
|
210
|
+
return cur[0]
|
|
211
|
+
refresh = self._refresh_by_site.get(site)
|
|
212
|
+
try:
|
|
213
|
+
if refresh:
|
|
214
|
+
body = {"agent_id": self.agent_id, "site": site,
|
|
215
|
+
"agent_jwt": self._agent_jwt(),
|
|
216
|
+
"refresh_token": refresh}
|
|
217
|
+
r = self.client.post(f"{self.base}/api/v1/token", json=body)
|
|
218
|
+
# the cloud ROTATES the refresh token on every issuance; a
|
|
219
|
+
# stale/rotated-away token is a 401 — drop it and retry once
|
|
220
|
+
# via the initial-approval path (which re-mints the grant's
|
|
221
|
+
# refresh). A misbehaving agent must not crash on rotation.
|
|
222
|
+
if r.status_code == 401:
|
|
223
|
+
self._refresh_by_site.pop(site, None)
|
|
224
|
+
body = {"agent_id": self.agent_id, "site": site,
|
|
225
|
+
"agent_jwt": self._agent_jwt()}
|
|
226
|
+
r = self.client.post(f"{self.base}/api/v1/token", json=body)
|
|
227
|
+
else:
|
|
228
|
+
body = {"agent_id": self.agent_id, "site": site,
|
|
229
|
+
"agent_jwt": self._agent_jwt()}
|
|
230
|
+
r = self.client.post(f"{self.base}/api/v1/token", json=body)
|
|
231
|
+
except httpx.HTTPError as e:
|
|
232
|
+
raise AgentError(f"cloud unreachable: {e}")
|
|
233
|
+
if r.status_code == 403:
|
|
234
|
+
raise AgentError("no grant — call ensure_grant() first")
|
|
235
|
+
if r.status_code >= 400:
|
|
236
|
+
raise AgentError(f"token fetch failed: {r.status_code} {r.text[:200]}")
|
|
237
|
+
try:
|
|
238
|
+
d = r.json()
|
|
239
|
+
at = d["access_token"]
|
|
240
|
+
except Exception:
|
|
241
|
+
raise AgentError(f"token response malformed: {r.text[:200]}")
|
|
242
|
+
exp = None
|
|
243
|
+
# the token endpoint returns expires_in (seconds); prefer it
|
|
244
|
+
try:
|
|
245
|
+
if d.get("expires_in") is not None:
|
|
246
|
+
exp = time.time() + float(d["expires_in"])
|
|
247
|
+
except (TypeError, ValueError):
|
|
248
|
+
exp = None
|
|
249
|
+
if exp is None:
|
|
250
|
+
try:
|
|
251
|
+
claims = pyjwt.decode(at, options={"verify_signature": False})
|
|
252
|
+
exp = float(claims.get("exp"))
|
|
253
|
+
except Exception:
|
|
254
|
+
exp = time.time()
|
|
255
|
+
self._access_by_site[site] = (at, exp)
|
|
256
|
+
if d.get("refresh_token"):
|
|
257
|
+
self._refresh_by_site[site] = d["refresh_token"]
|
|
258
|
+
else:
|
|
259
|
+
self._refresh_by_site.pop(site, None)
|
|
260
|
+
return at
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------ calls
|
|
263
|
+
|
|
264
|
+
def request(self, method, url, stepup_action=None, site=None, **kwargs):
|
|
265
|
+
"""Perform an HTTP request through Auth Your Agent: attaches Bearer + DPoP,
|
|
266
|
+
auto-refreshes tokens, and on a 403 'stepup_required' from the site,
|
|
267
|
+
requests a one-time step-up grant (phone prompt) and retries ONCE.
|
|
268
|
+
Each attempt mints a FRESH DPoP proof (and a fresh access token if
|
|
269
|
+
the old one is within 30s of expiry) — reusing a proof from the
|
|
270
|
+
first attempt 401s after ~2 min (DPoP iat window) or once the
|
|
271
|
+
access token has rolled.
|
|
272
|
+
|
|
273
|
+
`site` is the site id you were granted; it defaults to the URL's
|
|
274
|
+
host. Pass it when the site is reached at another address (a
|
|
275
|
+
local test server, a regional API host)."""
|
|
276
|
+
site = site or url.split("/")[2].split(":")[0]
|
|
277
|
+
at = self._get_access(site)
|
|
278
|
+
headers = dict(kwargs.pop("headers", {}))
|
|
279
|
+
resp = self._do_request(method, url, at, headers, kwargs)
|
|
280
|
+
if resp.status_code == 403 and stepup_action:
|
|
281
|
+
try:
|
|
282
|
+
body = resp.json()
|
|
283
|
+
except Exception:
|
|
284
|
+
return resp
|
|
285
|
+
# tolerate both the flat {"error": ...} and FastAPI's nested
|
|
286
|
+
# {"detail": {"error": ...}} shapes
|
|
287
|
+
if not isinstance(body, dict):
|
|
288
|
+
return resp # not a step-up signal; surface as-is
|
|
289
|
+
err_obj = body.get("detail") if isinstance(body.get("detail"), dict) else body
|
|
290
|
+
if err_obj.get("error") == "stepup_required":
|
|
291
|
+
st = self._stepup(site, stepup_action)
|
|
292
|
+
if st:
|
|
293
|
+
# retry: fresh token (the old one may have expired
|
|
294
|
+
# during the phone wait) + fresh DPoP proof bound to it
|
|
295
|
+
headers["X-AuthYourAgent-Stepup"] = st
|
|
296
|
+
at2 = self._get_access(site, rotate=False)
|
|
297
|
+
return self._do_request(method, url, at2, headers, kwargs)
|
|
298
|
+
return resp
|
|
299
|
+
|
|
300
|
+
def _do_request(self, method, url, at, headers, kwargs):
|
|
301
|
+
# DPoP: one fresh proof PER request (htm/url/iat/ath all bind to
|
|
302
|
+
# this exact call — RFC 9449).
|
|
303
|
+
h = dict(headers)
|
|
304
|
+
h["Authorization"] = f"Bearer {at}"
|
|
305
|
+
h["DPoP"] = self._dpop_proof(method, url, at)
|
|
306
|
+
try:
|
|
307
|
+
return self.client.request(method, url, headers=h, **kwargs)
|
|
308
|
+
except httpx.HTTPError as e:
|
|
309
|
+
raise AgentError(f"request failed: {e}")
|
|
310
|
+
|
|
311
|
+
def check_status(self):
|
|
312
|
+
"""Check whether this agent is still authorized by the owner.
|
|
313
|
+
Returns a dict with 'status' (active/revoked), 'global_flag',
|
|
314
|
+
'agent_name', and 'revoked_sites'.
|
|
315
|
+
Raises AgentError if the server cannot be reached or the JWT is invalid."""
|
|
316
|
+
try:
|
|
317
|
+
r = self.client.get(
|
|
318
|
+
f"{self.base}/api/v1/agent-status",
|
|
319
|
+
headers={"X-Agent-JWT": self._agent_jwt()},
|
|
320
|
+
params={"agent_id": self.agent_id},
|
|
321
|
+
timeout=10)
|
|
322
|
+
except httpx.HTTPError as e:
|
|
323
|
+
raise AgentError(f"status check failed: {e}")
|
|
324
|
+
if r.status_code == 401:
|
|
325
|
+
raise AgentError("agent JWT rejected — the key may be invalid or the agent deleted")
|
|
326
|
+
if r.status_code == 403:
|
|
327
|
+
return {"status": "revoked", "global_flag": "revoked", "revoked_sites": []}
|
|
328
|
+
if r.status_code != 200:
|
|
329
|
+
raise AgentError(f"status check returned {r.status_code}: {r.text[:200]}")
|
|
330
|
+
return r.json()
|
|
331
|
+
|
|
332
|
+
def report_status(self, status, site="", detail=""):
|
|
333
|
+
"""Report this agent's current status to the server so the owner's
|
|
334
|
+
dashboard shows what the agent is doing. Best-effort: errors are
|
|
335
|
+
printed but not raised, so a reporting failure never blocks the agent."""
|
|
336
|
+
try:
|
|
337
|
+
r = self.client.post(
|
|
338
|
+
f"{self.base}/api/v1/agent-status",
|
|
339
|
+
json={"agent_id": self.agent_id, "agent_jwt": self._agent_jwt(),
|
|
340
|
+
"status": status, "site": site, "detail": detail},
|
|
341
|
+
timeout=10)
|
|
342
|
+
if r.status_code >= 400:
|
|
343
|
+
print(f"[authyouragent] status report failed: {r.status_code}")
|
|
344
|
+
except Exception as e:
|
|
345
|
+
print(f"[authyouragent] status report failed: {e}")
|
|
346
|
+
|
|
347
|
+
def _stepup(self, site, action):
|
|
348
|
+
try:
|
|
349
|
+
r = self.client.post(f"{self.base}/api/v1/stepup", json={
|
|
350
|
+
"agent_id": self.agent_id, "site": site, "action": action,
|
|
351
|
+
"agent_jwt": self._agent_jwt()})
|
|
352
|
+
except httpx.HTTPError as e:
|
|
353
|
+
print(f"[authyouragent] stepup request failed: {e}")
|
|
354
|
+
return None
|
|
355
|
+
if r.status_code >= 400:
|
|
356
|
+
print(f"[authyouragent] stepup request failed: {r.text}")
|
|
357
|
+
return None
|
|
358
|
+
txn = _txn_id(r)
|
|
359
|
+
print(f"[authyouragent] step-up requested ({txn}) — waiting for phone…")
|
|
360
|
+
deadline = time.time() + 330
|
|
361
|
+
while time.time() < deadline:
|
|
362
|
+
try:
|
|
363
|
+
p = self.client.get(f"{self.base}/api/v1/authz-requests/{txn}",
|
|
364
|
+
headers=self._poll_headers())
|
|
365
|
+
except httpx.HTTPError:
|
|
366
|
+
time.sleep(self.poll_interval)
|
|
367
|
+
continue
|
|
368
|
+
if p.status_code >= 500:
|
|
369
|
+
# transient server error — keep polling (the phone may still
|
|
370
|
+
# respond), do not kill the whole flow over one bad poll
|
|
371
|
+
time.sleep(self.poll_interval)
|
|
372
|
+
continue
|
|
373
|
+
if p.status_code == 404:
|
|
374
|
+
return None
|
|
375
|
+
try:
|
|
376
|
+
st = p.json()
|
|
377
|
+
except Exception:
|
|
378
|
+
st = None
|
|
379
|
+
if not isinstance(st, dict):
|
|
380
|
+
time.sleep(self.poll_interval)
|
|
381
|
+
continue
|
|
382
|
+
if st.get("status") == "approved" and st.get("stepup_token"):
|
|
383
|
+
print("[authyouragent] step-up approved ✓")
|
|
384
|
+
return st["stepup_token"]
|
|
385
|
+
if st.get("status") in ("denied", "expired"):
|
|
386
|
+
print(f"[authyouragent] step-up {st.get('status')}")
|
|
387
|
+
return None
|
|
388
|
+
time.sleep(self.poll_interval)
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _txn_id(r):
|
|
393
|
+
try:
|
|
394
|
+
b = r.json()
|
|
395
|
+
except Exception:
|
|
396
|
+
raise AgentError(f"cloud returned non-JSON: {r.text[:200]}")
|
|
397
|
+
if not isinstance(b, dict) or not b.get("txn_id"):
|
|
398
|
+
raise AgentError(f"cloud response missing txn_id: {r.text[:200]}")
|
|
399
|
+
return b["txn_id"]
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _rand(n=16):
|
|
403
|
+
import secrets
|
|
404
|
+
return secrets.token_hex(n)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def b64(x: bytes) -> str:
|
|
408
|
+
import base64
|
|
409
|
+
return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def b64dec(s: str) -> bytes:
|
|
413
|
+
import base64
|
|
414
|
+
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def keygen():
|
|
418
|
+
"""§10.1: generate an agent keypair LOCALLY, in the agent runtime.
|
|
419
|
+
The private key never touches the network. Returns (privkey_pem, pubkey_pem)."""
|
|
420
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
421
|
+
priv = key.private_bytes(
|
|
422
|
+
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
|
423
|
+
serialization.NoEncryption()).decode()
|
|
424
|
+
pub = key.public_key().public_bytes(
|
|
425
|
+
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
|
|
426
|
+
).decode()
|
|
427
|
+
return priv, pub
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def agent_jwk(pubkey_pem: str) -> dict:
|
|
431
|
+
"""Emit the RFC 7515 JWK the PWA posts when registering an agent whose
|
|
432
|
+
key was generated locally by the agent runtime."""
|
|
433
|
+
pub = serialization.load_pem_public_key(
|
|
434
|
+
pubkey_pem.encode() if isinstance(pubkey_pem, str) else pubkey_pem)
|
|
435
|
+
n = pub.public_numbers()
|
|
436
|
+
return {"kty": "EC", "crv": "P-256",
|
|
437
|
+
"x": b64(n.x.to_bytes(32, "big")),
|
|
438
|
+
"y": b64(n.y.to_bytes(32, "big"))}
|
|
439
|
+
|
|
440
|
+
# ------------------------------------------------------------ CLI
|
|
441
|
+
# python -m authyouragent keygen --name "Hermes JARVIS"
|
|
442
|
+
# -> prints {"agent_key": {...}} ; the agent keeps the privkey, and the
|
|
443
|
+
# user's phone registers the pubkey with the cloud (cloud never sees
|
|
444
|
+
# the private key — §10.1).
|
|
445
|
+
|
|
446
|
+
def _cli_keygen(name):
|
|
447
|
+
priv, pub = keygen()
|
|
448
|
+
print(json.dumps({
|
|
449
|
+
"agent_key": {
|
|
450
|
+
"name": name,
|
|
451
|
+
"jwk": agent_jwk(pub),
|
|
452
|
+
"pubkey_pem": pub,
|
|
453
|
+
"privkey_pem": priv,
|
|
454
|
+
"note": ("agent runtime: keep privkey_pem local. "
|
|
455
|
+
"Phone/PWA: register jwk (+name) with the cloud; "
|
|
456
|
+
"the cloud will return agent_id, which the runtime "
|
|
457
|
+
"uses to build its AgentClient."),
|
|
458
|
+
}
|
|
459
|
+
}, indent=2))
|
|
460
|
+
|
|
461
|
+
def _cli_main(argv=None):
|
|
462
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
463
|
+
if argv[:1] == ["vault"]:
|
|
464
|
+
from .vault_cli import main as vault_main
|
|
465
|
+
return vault_main(argv[1:])
|
|
466
|
+
import argparse
|
|
467
|
+
ap = argparse.ArgumentParser(prog="authyouragent", epilog="also: authyouragent vault up|down|status|env")
|
|
468
|
+
ap.add_argument("cmd", choices=["keygen"])
|
|
469
|
+
ap.add_argument("--name", default="agent")
|
|
470
|
+
args = ap.parse_args(argv)
|
|
471
|
+
if args.cmd == "keygen":
|
|
472
|
+
_cli_keygen(args.name)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
if __name__ == "__main__":
|
|
476
|
+
_cli_main()
|