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