cli-login 0.1.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.
- cli_login/__init__.py +4 -0
- cli_login/broker/__init__.py +0 -0
- cli_login/broker/handler.py +310 -0
- cli_login/broker/pages.py +175 -0
- cli_login/broker/throttle.py +25 -0
- cli_login/broker/transactions.py +201 -0
- cli_login/errors.py +29 -0
- cli_login/locks.py +111 -0
- cli_login/oauth.py +143 -0
- cli_login/remote.py +208 -0
- cli_login/session.py +213 -0
- cli_login/stores/__init__.py +122 -0
- cli_login/stores/agent.py +466 -0
- cli_login/stores/kernel_keyring.py +202 -0
- cli_login/stores/macos.py +177 -0
- cli_login/stores/secret_service.py +112 -0
- cli_login/stores/windows.py +211 -0
- cli_login/target.py +101 -0
- cli_login/testing.py +258 -0
- cli_login-0.1.0.dist-info/METADATA +104 -0
- cli_login-0.1.0.dist-info/RECORD +23 -0
- cli_login-0.1.0.dist-info/WHEEL +4 -0
- cli_login-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""The memory agent: secrets held in memory only, behind a private Unix socket (ssh-agent style).
|
|
2
|
+
|
|
3
|
+
For cloud machines that have no desktop keyring and no usable kernel keyring. One agent serves one product
|
|
4
|
+
namespace. It never writes a secret to disk, and after it stops (or the machine restarts) the person approves
|
|
5
|
+
again. There is no plaintext fallback anywhere in this module.
|
|
6
|
+
|
|
7
|
+
Run it as ``python -m cli_login.stores.agent serve|status|stop --namespace <namespace>``; a product
|
|
8
|
+
mounts the same functions under its own CLI name (``<product> auth agent``).
|
|
9
|
+
|
|
10
|
+
Security model:
|
|
11
|
+
- the socket lives in a 0700 directory under the product's state directory and is created 0600;
|
|
12
|
+
- the server checks the peer's user id on every connection (``SO_PEERCRED`` on Linux, ``getpeereid`` on macOS)
|
|
13
|
+
and the client checks the server's, so a different user can neither read nor impersonate it;
|
|
14
|
+
- other processes of the same user can talk to it, exactly as with ssh-agent;
|
|
15
|
+
- core dumps are disabled, so a crash does not write the secrets out.
|
|
16
|
+
|
|
17
|
+
Windows is not supported in this version: the module imports, but ``serve`` and the client say so clearly.
|
|
18
|
+
Use the Credential Manager store there.
|
|
19
|
+
|
|
20
|
+
Protocol: each connection carries one request and one answer, each a 4-byte big-endian length followed by a
|
|
21
|
+
JSON object. Operations: ``ping``, ``get``, ``set``, ``delete``, ``stop``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import ctypes
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import signal
|
|
32
|
+
import socket
|
|
33
|
+
import stat
|
|
34
|
+
import struct
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
from ..errors import AuthError, StoreUnavailable
|
|
42
|
+
from ..locks import default_state_dir
|
|
43
|
+
|
|
44
|
+
ENV_SOCKET = "CLI_LOGIN_AGENT_SOCKET"
|
|
45
|
+
MAX_MESSAGE = 1 << 20 # one MiB per message: a session record is a few KiB
|
|
46
|
+
_SAFE = re.compile(r"[A-Za-z0-9._-]{1,200}")
|
|
47
|
+
_IO_TIMEOUT = 5.0
|
|
48
|
+
# sockaddr_un.sun_path is 104 bytes on macOS and 108 on Linux, including the terminating NUL.
|
|
49
|
+
_MAX_SOCKET_PATH = 100
|
|
50
|
+
|
|
51
|
+
UNSUPPORTED_ON_WINDOWS = (
|
|
52
|
+
"The memory agent is not supported on Windows in this version; use Windows Credential Manager "
|
|
53
|
+
"(the default store there)."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _require_unix() -> None:
|
|
58
|
+
if sys.platform == "win32" or not hasattr(socket, "AF_UNIX"):
|
|
59
|
+
raise AuthError(UNSUPPORTED_ON_WINDOWS)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def agent_command(namespace: str, *, state_dir: Path | str | None = None, socket_path: Path | str | None = None) -> str:
|
|
63
|
+
"""The command that starts this namespace's agent, for guidance in error messages."""
|
|
64
|
+
command = f"{os.path.basename(sys.executable) or 'python'} -m cli_login.stores.agent serve --namespace {namespace}"
|
|
65
|
+
if state_dir is not None:
|
|
66
|
+
command += f" --state-dir {state_dir}"
|
|
67
|
+
if socket_path is not None:
|
|
68
|
+
command += f" --socket {socket_path}"
|
|
69
|
+
return command + " --background"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def socket_path_for(namespace: str, state_dir: Path | str | None = None, environ=None) -> Path:
|
|
73
|
+
"""Where the namespace's agent listens: the environment override, else the state directory."""
|
|
74
|
+
override = (os.environ if environ is None else environ).get(ENV_SOCKET)
|
|
75
|
+
if override:
|
|
76
|
+
return Path(override)
|
|
77
|
+
base = Path(state_dir) if state_dir is not None else default_state_dir(namespace)
|
|
78
|
+
return base / "agent" / "agent.sock"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
82
|
+
# Peer identity
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def peer_uid(sock: socket.socket) -> int:
|
|
86
|
+
"""The user id of the process on the other end of a Unix socket."""
|
|
87
|
+
if sys.platform.startswith("linux"):
|
|
88
|
+
data = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
|
89
|
+
return struct.unpack("3i", data)[1]
|
|
90
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
91
|
+
uid, gid = ctypes.c_uint32(), ctypes.c_uint32()
|
|
92
|
+
if libc.getpeereid(sock.fileno(), ctypes.byref(uid), ctypes.byref(gid)) != 0:
|
|
93
|
+
raise OSError(ctypes.get_errno(), "getpeereid failed")
|
|
94
|
+
return uid.value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
98
|
+
# Wire format
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_exact(sock: socket.socket, size: int) -> bytes:
|
|
102
|
+
chunks = []
|
|
103
|
+
while size:
|
|
104
|
+
chunk = sock.recv(min(size, 65536))
|
|
105
|
+
if not chunk:
|
|
106
|
+
raise ConnectionError("connection closed")
|
|
107
|
+
chunks.append(chunk)
|
|
108
|
+
size -= len(chunk)
|
|
109
|
+
return b"".join(chunks)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def send_message(sock: socket.socket, message: dict) -> None:
|
|
113
|
+
data = json.dumps(message, separators=(",", ":")).encode("utf-8")
|
|
114
|
+
if len(data) > MAX_MESSAGE:
|
|
115
|
+
raise ValueError("message too large")
|
|
116
|
+
sock.sendall(struct.pack(">I", len(data)) + data)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_message(sock: socket.socket) -> dict:
|
|
120
|
+
(size,) = struct.unpack(">I", _read_exact(sock, 4))
|
|
121
|
+
if size > MAX_MESSAGE:
|
|
122
|
+
raise ValueError("message too large")
|
|
123
|
+
message = json.loads(_read_exact(sock, size).decode("utf-8"))
|
|
124
|
+
if not isinstance(message, dict):
|
|
125
|
+
raise TypeError("message must be an object")
|
|
126
|
+
return message
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
130
|
+
# Directory and process hygiene
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _private_directory(path: Path, *, create: bool) -> None:
|
|
134
|
+
"""A directory only this user can enter. Refuses a symlink, a foreign owner or loose permissions."""
|
|
135
|
+
if path.is_symlink():
|
|
136
|
+
raise AuthError("The memory agent directory must not be a symlink.")
|
|
137
|
+
if not path.exists():
|
|
138
|
+
if not create:
|
|
139
|
+
raise FileNotFoundError(str(path))
|
|
140
|
+
try:
|
|
141
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
142
|
+
path.chmod(0o700)
|
|
143
|
+
except OSError:
|
|
144
|
+
raise AuthError("Unable to create the private memory agent directory; check permissions.") from None
|
|
145
|
+
info = path.lstat()
|
|
146
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
|
147
|
+
raise AuthError(
|
|
148
|
+
"The memory agent directory must be owned by you with mode 0700; "
|
|
149
|
+
f"fix it or set {ENV_SOCKET} to a socket inside a private directory."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _harden_process() -> None:
|
|
154
|
+
"""Best effort: no core dumps and no ptrace-style reads of the agent's memory by other processes."""
|
|
155
|
+
try:
|
|
156
|
+
import resource
|
|
157
|
+
|
|
158
|
+
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
|
159
|
+
except (ImportError, ValueError, OSError):
|
|
160
|
+
pass
|
|
161
|
+
if sys.platform.startswith("linux"):
|
|
162
|
+
try:
|
|
163
|
+
ctypes.CDLL(None, use_errno=True).prctl(4, 0, 0, 0, 0) # PR_SET_DUMPABLE = 0
|
|
164
|
+
except (OSError, AttributeError):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _check_length(path: Path) -> None:
|
|
169
|
+
if len(os.fsencode(path)) > _MAX_SOCKET_PATH:
|
|
170
|
+
raise AuthError(
|
|
171
|
+
"The memory agent socket path is too long for a Unix socket; "
|
|
172
|
+
f"set {ENV_SOCKET} to a shorter path inside a private directory."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
177
|
+
# The client
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class AgentClient:
|
|
181
|
+
"""Talks to an agent over its socket, checking that the agent runs as the same user."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, path: Path | str, namespace: str):
|
|
184
|
+
_require_unix()
|
|
185
|
+
self.path = Path(path)
|
|
186
|
+
self.namespace = namespace
|
|
187
|
+
|
|
188
|
+
def _not_running(self) -> StoreUnavailable:
|
|
189
|
+
return StoreUnavailable(
|
|
190
|
+
"The memory agent is not running. Start it with: "
|
|
191
|
+
+ agent_command(self.namespace, socket_path=os.environ.get(ENV_SOCKET) and self.path or None)
|
|
192
|
+
+ " and then sign in again."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def request(self, message: dict) -> dict:
|
|
196
|
+
try:
|
|
197
|
+
_private_directory(self.path.parent, create=False)
|
|
198
|
+
info = self.path.lstat()
|
|
199
|
+
except FileNotFoundError:
|
|
200
|
+
raise self._not_running() from None
|
|
201
|
+
except AuthError as error:
|
|
202
|
+
raise StoreUnavailable(str(error)) from None
|
|
203
|
+
if not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.geteuid():
|
|
204
|
+
raise StoreUnavailable("The memory agent socket is not owned by you; refusing to use it.")
|
|
205
|
+
_check_length(self.path)
|
|
206
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
207
|
+
try:
|
|
208
|
+
sock.settimeout(_IO_TIMEOUT)
|
|
209
|
+
try:
|
|
210
|
+
sock.connect(os.fspath(self.path))
|
|
211
|
+
except (FileNotFoundError, ConnectionRefusedError):
|
|
212
|
+
raise self._not_running() from None
|
|
213
|
+
try:
|
|
214
|
+
if peer_uid(sock) != os.geteuid():
|
|
215
|
+
raise StoreUnavailable("The memory agent runs as a different user; refusing to use it.")
|
|
216
|
+
send_message(sock, message)
|
|
217
|
+
answer = read_message(sock)
|
|
218
|
+
except StoreUnavailable:
|
|
219
|
+
raise
|
|
220
|
+
except (OSError, ValueError, TypeError):
|
|
221
|
+
raise StoreUnavailable(
|
|
222
|
+
"The memory agent did not answer; stop it with `... stop` and start it again."
|
|
223
|
+
) from None
|
|
224
|
+
finally:
|
|
225
|
+
sock.close()
|
|
226
|
+
if not answer.get("ok"):
|
|
227
|
+
raise AuthError("The memory agent refused the request.")
|
|
228
|
+
return answer
|
|
229
|
+
|
|
230
|
+
def ping(self) -> dict:
|
|
231
|
+
answer = self.request({"op": "ping"})
|
|
232
|
+
if answer.get("namespace") != self.namespace:
|
|
233
|
+
raise StoreUnavailable("The memory agent at this socket serves a different product; refusing to use it.")
|
|
234
|
+
return answer
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class AgentStore:
|
|
238
|
+
"""The session store backed by a running memory agent."""
|
|
239
|
+
|
|
240
|
+
name = "memory agent"
|
|
241
|
+
|
|
242
|
+
def __init__(self, namespace: str, *, state_dir=None, socket_path=None, environ=None):
|
|
243
|
+
if not namespace:
|
|
244
|
+
raise AuthError("A store namespace is required.")
|
|
245
|
+
self.namespace = namespace
|
|
246
|
+
path = Path(socket_path) if socket_path is not None else socket_path_for(namespace, state_dir, environ)
|
|
247
|
+
self.client = AgentClient(path, namespace)
|
|
248
|
+
|
|
249
|
+
def probe(self) -> None:
|
|
250
|
+
self.client.ping()
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _key(key: str) -> str:
|
|
254
|
+
if not isinstance(key, str) or not _SAFE.fullmatch(key):
|
|
255
|
+
raise AuthError("Invalid session key.")
|
|
256
|
+
return key
|
|
257
|
+
|
|
258
|
+
def get(self, key):
|
|
259
|
+
return self.client.request({"op": "get", "key": self._key(key)}).get("value")
|
|
260
|
+
|
|
261
|
+
def set(self, key, value):
|
|
262
|
+
self.client.request({"op": "set", "key": self._key(key), "value": value})
|
|
263
|
+
|
|
264
|
+
def delete(self, key):
|
|
265
|
+
self.client.request({"op": "delete", "key": self._key(key)})
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ---------------------------------------------------------------------------------------------------------
|
|
269
|
+
# The server
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class MemoryAgent:
|
|
273
|
+
"""The agent's state: a dictionary and a stop flag. No file is ever opened for it."""
|
|
274
|
+
|
|
275
|
+
def __init__(self, namespace: str):
|
|
276
|
+
self.namespace = namespace
|
|
277
|
+
self._secrets: dict[str, str] = {}
|
|
278
|
+
self._lock = threading.Lock()
|
|
279
|
+
self.stopping = threading.Event()
|
|
280
|
+
|
|
281
|
+
def handle(self, request: dict) -> dict:
|
|
282
|
+
op = request.get("op")
|
|
283
|
+
if op == "ping":
|
|
284
|
+
with self._lock:
|
|
285
|
+
return {"ok": True, "namespace": self.namespace, "pid": os.getpid(), "count": len(self._secrets)}
|
|
286
|
+
if op == "stop":
|
|
287
|
+
self.stopping.set()
|
|
288
|
+
return {"ok": True}
|
|
289
|
+
key = request.get("key")
|
|
290
|
+
if op in ("get", "set", "delete") and (not isinstance(key, str) or not _SAFE.fullmatch(key)):
|
|
291
|
+
return {"ok": False, "error": "invalid_key"}
|
|
292
|
+
if op == "get":
|
|
293
|
+
with self._lock:
|
|
294
|
+
return {"ok": True, "value": self._secrets.get(key)}
|
|
295
|
+
if op == "set":
|
|
296
|
+
value = request.get("value")
|
|
297
|
+
if not isinstance(value, str):
|
|
298
|
+
return {"ok": False, "error": "invalid_value"}
|
|
299
|
+
with self._lock:
|
|
300
|
+
self._secrets[key] = value
|
|
301
|
+
return {"ok": True}
|
|
302
|
+
if op == "delete":
|
|
303
|
+
with self._lock:
|
|
304
|
+
self._secrets.pop(key, None)
|
|
305
|
+
return {"ok": True}
|
|
306
|
+
return {"ok": False, "error": "unknown_operation"}
|
|
307
|
+
|
|
308
|
+
def serve_connection(self, conn: socket.socket) -> None:
|
|
309
|
+
try:
|
|
310
|
+
conn.settimeout(_IO_TIMEOUT)
|
|
311
|
+
if peer_uid(conn) != os.geteuid():
|
|
312
|
+
return # a different user gets no answer at all
|
|
313
|
+
send_message(conn, self.handle(read_message(conn)))
|
|
314
|
+
except (OSError, ValueError, TypeError):
|
|
315
|
+
pass # a broken or hostile client only loses its own connection
|
|
316
|
+
finally:
|
|
317
|
+
conn.close()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _alive(path: Path, namespace: str) -> bool:
|
|
321
|
+
try:
|
|
322
|
+
AgentClient(path, namespace).request({"op": "ping"})
|
|
323
|
+
return True
|
|
324
|
+
except AuthError:
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def serve(
|
|
329
|
+
namespace: str,
|
|
330
|
+
*,
|
|
331
|
+
state_dir=None,
|
|
332
|
+
socket_path=None,
|
|
333
|
+
ready: threading.Event | None = None,
|
|
334
|
+
handle_signals: bool = False,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Run the agent in the foreground until it is stopped. ``ready`` is set once the socket listens."""
|
|
337
|
+
_require_unix()
|
|
338
|
+
if not _SAFE.fullmatch(namespace or ""):
|
|
339
|
+
raise AuthError("Invalid namespace.")
|
|
340
|
+
path = Path(socket_path) if socket_path is not None else socket_path_for(namespace, state_dir)
|
|
341
|
+
_check_length(path)
|
|
342
|
+
_private_directory(path.parent, create=True)
|
|
343
|
+
if os.path.lexists(path):
|
|
344
|
+
if _alive(path, namespace):
|
|
345
|
+
raise AuthError("A memory agent is already running for this product.")
|
|
346
|
+
info = path.lstat()
|
|
347
|
+
if not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.geteuid():
|
|
348
|
+
raise AuthError("Something that is not a stale agent socket is in the way; remove it and retry.")
|
|
349
|
+
path.unlink()
|
|
350
|
+
_harden_process()
|
|
351
|
+
agent = MemoryAgent(namespace)
|
|
352
|
+
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
353
|
+
previous_umask = os.umask(0o177)
|
|
354
|
+
try:
|
|
355
|
+
server.bind(os.fspath(path))
|
|
356
|
+
finally:
|
|
357
|
+
os.umask(previous_umask)
|
|
358
|
+
os.chmod(path, 0o600)
|
|
359
|
+
server.listen(16)
|
|
360
|
+
server.settimeout(0.2)
|
|
361
|
+
if handle_signals and threading.current_thread() is threading.main_thread():
|
|
362
|
+
for name in ("SIGTERM", "SIGINT", "SIGHUP"):
|
|
363
|
+
if hasattr(signal, name):
|
|
364
|
+
signal.signal(getattr(signal, name), lambda *_: agent.stopping.set())
|
|
365
|
+
if ready is not None:
|
|
366
|
+
ready.set()
|
|
367
|
+
try:
|
|
368
|
+
while not agent.stopping.is_set():
|
|
369
|
+
try:
|
|
370
|
+
conn, _ = server.accept()
|
|
371
|
+
except TimeoutError:
|
|
372
|
+
continue
|
|
373
|
+
except OSError:
|
|
374
|
+
break
|
|
375
|
+
threading.Thread(target=agent.serve_connection, args=(conn,), daemon=True).start()
|
|
376
|
+
finally:
|
|
377
|
+
server.close()
|
|
378
|
+
try:
|
|
379
|
+
path.unlink()
|
|
380
|
+
except FileNotFoundError:
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def start_background(namespace: str, *, state_dir=None, socket_path=None, timeout: float = 10.0) -> None:
|
|
385
|
+
"""Start the agent as a detached process and return once it answers."""
|
|
386
|
+
_require_unix()
|
|
387
|
+
path = Path(socket_path) if socket_path is not None else socket_path_for(namespace, state_dir)
|
|
388
|
+
if _alive(path, namespace):
|
|
389
|
+
return
|
|
390
|
+
command = [sys.executable, "-m", "cli_login.stores.agent", "serve", "--namespace", namespace]
|
|
391
|
+
command += ["--socket", os.fspath(path)]
|
|
392
|
+
subprocess.Popen(
|
|
393
|
+
command,
|
|
394
|
+
stdin=subprocess.DEVNULL,
|
|
395
|
+
stdout=subprocess.DEVNULL,
|
|
396
|
+
stderr=subprocess.DEVNULL,
|
|
397
|
+
start_new_session=True,
|
|
398
|
+
close_fds=True,
|
|
399
|
+
)
|
|
400
|
+
deadline = time.monotonic() + timeout
|
|
401
|
+
while time.monotonic() < deadline:
|
|
402
|
+
if _alive(path, namespace):
|
|
403
|
+
return
|
|
404
|
+
time.sleep(0.05)
|
|
405
|
+
raise AuthError("The memory agent did not start; run the `serve` command in the foreground to see why.")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def status(namespace: str, *, state_dir=None, socket_path=None) -> dict | None:
|
|
409
|
+
"""The running agent's ping answer, or None when no agent is running."""
|
|
410
|
+
path = Path(socket_path) if socket_path is not None else socket_path_for(namespace, state_dir)
|
|
411
|
+
try:
|
|
412
|
+
return AgentClient(path, namespace).ping()
|
|
413
|
+
except StoreUnavailable:
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def stop(namespace: str, *, state_dir=None, socket_path=None, timeout: float = 5.0) -> bool:
|
|
418
|
+
"""Stop the agent. Returns False when none was running. Everything it held is gone."""
|
|
419
|
+
path = Path(socket_path) if socket_path is not None else socket_path_for(namespace, state_dir)
|
|
420
|
+
client = AgentClient(path, namespace)
|
|
421
|
+
try:
|
|
422
|
+
client.ping()
|
|
423
|
+
client.request({"op": "stop"})
|
|
424
|
+
except StoreUnavailable:
|
|
425
|
+
return False
|
|
426
|
+
deadline = time.monotonic() + timeout
|
|
427
|
+
while time.monotonic() < deadline and os.path.lexists(path):
|
|
428
|
+
time.sleep(0.02)
|
|
429
|
+
return True
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def main(argv=None) -> int:
|
|
433
|
+
"""``serve`` / ``status`` / ``stop``; a product mounts this under ``<product> auth agent``."""
|
|
434
|
+
parser = argparse.ArgumentParser(prog="python -m cli_login.stores.agent")
|
|
435
|
+
parser.add_argument("action", choices=["serve", "status", "stop"])
|
|
436
|
+
parser.add_argument("--namespace", required=True, help="the product namespace this agent serves")
|
|
437
|
+
parser.add_argument("--state-dir", help="the product's state directory (default: under ~/.config)")
|
|
438
|
+
parser.add_argument("--socket", help=f"explicit socket path (also {ENV_SOCKET})")
|
|
439
|
+
parser.add_argument("--background", action="store_true", help="serve: detach and return once it answers")
|
|
440
|
+
args = parser.parse_args(argv)
|
|
441
|
+
options = {"state_dir": args.state_dir, "socket_path": args.socket or os.environ.get(ENV_SOCKET)}
|
|
442
|
+
try:
|
|
443
|
+
if args.action == "serve":
|
|
444
|
+
if args.background:
|
|
445
|
+
start_background(args.namespace, **options)
|
|
446
|
+
print("memory agent running")
|
|
447
|
+
return 0
|
|
448
|
+
print(f"memory agent for {args.namespace} listening; stop it with `stop` or a signal", file=sys.stderr)
|
|
449
|
+
serve(args.namespace, handle_signals=True, **options)
|
|
450
|
+
return 0
|
|
451
|
+
if args.action == "status":
|
|
452
|
+
answer = status(args.namespace, **options)
|
|
453
|
+
if answer is None:
|
|
454
|
+
print("memory agent not running")
|
|
455
|
+
return 1
|
|
456
|
+
print(f"memory agent running (pid {answer['pid']}, {answer['count']} stored)")
|
|
457
|
+
return 0
|
|
458
|
+
print("memory agent stopped" if stop(args.namespace, **options) else "memory agent not running")
|
|
459
|
+
return 0
|
|
460
|
+
except AuthError as error:
|
|
461
|
+
print(str(error), file=sys.stderr)
|
|
462
|
+
return 2
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
if __name__ == "__main__":
|
|
466
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The Linux kernel keyring, through raw syscalls with ctypes (no extra dependency).
|
|
2
|
+
|
|
3
|
+
Secrets live in kernel memory, in the user's *persistent* keyring when the kernel offers one, else in the user
|
|
4
|
+
keyring. They last until the machine reboots, or, for the persistent keyring, until its expiry
|
|
5
|
+
(``/proc/sys/kernel/keys/persistent_keyring_expiry``, three days by default, counted from last use). They are
|
|
6
|
+
readable by processes of the same user, as with the memory agent.
|
|
7
|
+
|
|
8
|
+
Docker's default seccomp profile blocks ``add_key`` and ``keyctl``. The store therefore probes first with a
|
|
9
|
+
real write, read and delete, and reports itself unavailable when the kernel refuses.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ctypes
|
|
15
|
+
import errno
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from ..errors import AuthError, StoreUnavailable
|
|
21
|
+
|
|
22
|
+
# (add_key, keyctl) syscall numbers by architecture.
|
|
23
|
+
_SYSCALLS = {
|
|
24
|
+
"x86_64": (248, 250),
|
|
25
|
+
"aarch64": (217, 219),
|
|
26
|
+
"arm64": (217, 219),
|
|
27
|
+
"riscv64": (217, 219),
|
|
28
|
+
}
|
|
29
|
+
KEY_SPEC_SESSION_KEYRING = -3
|
|
30
|
+
KEY_SPEC_USER_KEYRING = -4
|
|
31
|
+
KEYCTL_GET_KEYRING_ID = 0
|
|
32
|
+
KEYCTL_UNLINK = 9
|
|
33
|
+
KEYCTL_SEARCH = 10
|
|
34
|
+
KEYCTL_READ = 11
|
|
35
|
+
KEYCTL_GET_PERSISTENT = 22
|
|
36
|
+
ENOKEY, EKEYEXPIRED, EKEYREVOKED = 126, 127, 128 # Linux values; absent from errno on other systems
|
|
37
|
+
_MISSING = (ENOKEY, EKEYREVOKED, EKEYEXPIRED)
|
|
38
|
+
MAX_PAYLOAD = 32767 # the kernel's limit for a "user" key
|
|
39
|
+
|
|
40
|
+
GUIDANCE_MEMORY = "the kernel keyring is unavailable here (a container's seccomp profile often blocks it)"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class KeyctlError(OSError):
|
|
44
|
+
"""A syscall failed; carries only an errno."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Keyctl:
|
|
48
|
+
"""The real syscalls. Tests substitute an in-memory double with the same methods."""
|
|
49
|
+
|
|
50
|
+
def __init__(self):
|
|
51
|
+
if not sys.platform.startswith("linux") or platform.system() != "Linux":
|
|
52
|
+
raise StoreUnavailable("The kernel keyring exists only on Linux.")
|
|
53
|
+
numbers = _SYSCALLS.get(platform.machine())
|
|
54
|
+
if numbers is None:
|
|
55
|
+
raise StoreUnavailable("The kernel keyring is not supported on this CPU architecture.")
|
|
56
|
+
self._add_key, self._keyctl = numbers
|
|
57
|
+
try:
|
|
58
|
+
self._syscall = ctypes.CDLL(None, use_errno=True).syscall
|
|
59
|
+
except (OSError, AttributeError):
|
|
60
|
+
raise StoreUnavailable("The kernel keyring cannot be reached from Python here.") from None
|
|
61
|
+
self._syscall.restype = ctypes.c_long
|
|
62
|
+
|
|
63
|
+
def _call(self, *args):
|
|
64
|
+
result = self._syscall(*args)
|
|
65
|
+
if result < 0:
|
|
66
|
+
code = ctypes.get_errno()
|
|
67
|
+
raise KeyctlError(code, os.strerror(code))
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _n(value):
|
|
72
|
+
return ctypes.c_long(value)
|
|
73
|
+
|
|
74
|
+
def keyctl(self, command, *numbers):
|
|
75
|
+
padded = list(numbers) + [0] * (4 - len(numbers))
|
|
76
|
+
return self._call(self._n(self._keyctl), self._n(command), *(self._n(x) for x in padded))
|
|
77
|
+
|
|
78
|
+
def user_keyring(self) -> int:
|
|
79
|
+
return self.keyctl(KEYCTL_GET_KEYRING_ID, KEY_SPEC_USER_KEYRING, 1)
|
|
80
|
+
|
|
81
|
+
def persistent_keyring(self) -> int:
|
|
82
|
+
# Link it into the session keyring: only a keyring the process *possesses* may be written to.
|
|
83
|
+
return self.keyctl(KEYCTL_GET_PERSISTENT, -1, KEY_SPEC_SESSION_KEYRING)
|
|
84
|
+
|
|
85
|
+
def add_key(self, ring: int, description: str, payload: bytes) -> int:
|
|
86
|
+
buffer = ctypes.create_string_buffer(payload, len(payload) or 1)
|
|
87
|
+
return self._call(
|
|
88
|
+
self._n(self._add_key),
|
|
89
|
+
ctypes.c_char_p(b"user"),
|
|
90
|
+
ctypes.c_char_p(description.encode("utf-8")),
|
|
91
|
+
ctypes.cast(buffer, ctypes.c_void_p),
|
|
92
|
+
ctypes.c_size_t(len(payload)),
|
|
93
|
+
self._n(ring),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def search(self, ring: int, description: str) -> int:
|
|
97
|
+
return self._call(
|
|
98
|
+
self._n(self._keyctl),
|
|
99
|
+
self._n(KEYCTL_SEARCH),
|
|
100
|
+
self._n(ring),
|
|
101
|
+
ctypes.c_char_p(b"user"),
|
|
102
|
+
ctypes.c_char_p(description.encode("utf-8")),
|
|
103
|
+
self._n(0),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def read(self, key: int) -> bytes:
|
|
107
|
+
size = 512
|
|
108
|
+
while True:
|
|
109
|
+
buffer = ctypes.create_string_buffer(size)
|
|
110
|
+
needed = self._call(
|
|
111
|
+
self._n(self._keyctl),
|
|
112
|
+
self._n(KEYCTL_READ),
|
|
113
|
+
self._n(key),
|
|
114
|
+
ctypes.cast(buffer, ctypes.c_void_p),
|
|
115
|
+
ctypes.c_size_t(size),
|
|
116
|
+
self._n(0),
|
|
117
|
+
)
|
|
118
|
+
if needed <= size:
|
|
119
|
+
return buffer.raw[:needed]
|
|
120
|
+
size = needed
|
|
121
|
+
|
|
122
|
+
def unlink(self, key: int, ring: int) -> None:
|
|
123
|
+
self.keyctl(KEYCTL_UNLINK, key, ring)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class KernelKeyringStore:
|
|
127
|
+
name = "kernel keyring"
|
|
128
|
+
|
|
129
|
+
def __init__(self, namespace: str, *, backend=None):
|
|
130
|
+
if not namespace:
|
|
131
|
+
raise AuthError("A store namespace is required.")
|
|
132
|
+
self.namespace = namespace
|
|
133
|
+
self.backend = backend if backend is not None else Keyctl()
|
|
134
|
+
self._ring = None
|
|
135
|
+
|
|
136
|
+
def _description(self, key: str) -> str:
|
|
137
|
+
return f"cli-login:{self.namespace}:{key}"
|
|
138
|
+
|
|
139
|
+
def _keyring(self) -> int:
|
|
140
|
+
if self._ring is None:
|
|
141
|
+
try:
|
|
142
|
+
self._ring = self.backend.persistent_keyring()
|
|
143
|
+
except OSError:
|
|
144
|
+
self._ring = self.backend.user_keyring() # kernels without persistent keyrings
|
|
145
|
+
return self._ring
|
|
146
|
+
|
|
147
|
+
def probe(self) -> None:
|
|
148
|
+
"""Write, read back and remove a throwaway key; unavailable if the kernel refuses any step."""
|
|
149
|
+
description = f"cli-login:probe:{os.getpid()}"
|
|
150
|
+
try:
|
|
151
|
+
ring = self._keyring()
|
|
152
|
+
key = self.backend.add_key(ring, description, b"probe")
|
|
153
|
+
try:
|
|
154
|
+
ok = self.backend.read(key) == b"probe"
|
|
155
|
+
finally:
|
|
156
|
+
self.backend.unlink(key, ring)
|
|
157
|
+
except OSError as error:
|
|
158
|
+
blocked = error.errno in (errno.EPERM, errno.ENOSYS, errno.EACCES)
|
|
159
|
+
raise StoreUnavailable(
|
|
160
|
+
(GUIDANCE_MEMORY if blocked else "The kernel keyring could not be used")
|
|
161
|
+
+ "; use the memory agent instead."
|
|
162
|
+
) from None
|
|
163
|
+
if not ok:
|
|
164
|
+
raise StoreUnavailable("The kernel keyring did not return what was written; " + "use the memory agent.")
|
|
165
|
+
|
|
166
|
+
def get(self, key):
|
|
167
|
+
try:
|
|
168
|
+
ring = self._keyring()
|
|
169
|
+
try:
|
|
170
|
+
found = self.backend.search(ring, self._description(key))
|
|
171
|
+
except OSError as error:
|
|
172
|
+
if error.errno in _MISSING:
|
|
173
|
+
return None
|
|
174
|
+
raise
|
|
175
|
+
return self.backend.read(found).decode("utf-8")
|
|
176
|
+
except (OSError, UnicodeDecodeError):
|
|
177
|
+
raise AuthError("Unable to read the kernel keyring; start a new login or use the memory agent.") from None
|
|
178
|
+
|
|
179
|
+
def set(self, key, value):
|
|
180
|
+
payload = value.encode("utf-8")
|
|
181
|
+
if len(payload) > MAX_PAYLOAD:
|
|
182
|
+
raise AuthError("The session is too large for the kernel keyring.")
|
|
183
|
+
try:
|
|
184
|
+
# Adding a key with the same type and description replaces its payload in place.
|
|
185
|
+
self.backend.add_key(self._keyring(), self._description(key), payload)
|
|
186
|
+
except OSError:
|
|
187
|
+
raise AuthError(
|
|
188
|
+
"Unable to save the session in the kernel keyring; use the memory agent or retry login."
|
|
189
|
+
) from None
|
|
190
|
+
|
|
191
|
+
def delete(self, key):
|
|
192
|
+
try:
|
|
193
|
+
ring = self._keyring()
|
|
194
|
+
try:
|
|
195
|
+
found = self.backend.search(ring, self._description(key))
|
|
196
|
+
except OSError as error:
|
|
197
|
+
if error.errno in _MISSING:
|
|
198
|
+
return
|
|
199
|
+
raise
|
|
200
|
+
self.backend.unlink(found, ring)
|
|
201
|
+
except OSError:
|
|
202
|
+
raise AuthError("Unable to remove the session from the kernel keyring; retry logout.") from None
|