authkeys 0.4.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.
authkeys/__init__.py ADDED
@@ -0,0 +1,382 @@
1
+ """authkeys: pluggable ``AuthorizedKeysCommand`` provider for OpenSSH.
2
+
3
+ Resolves a user's authorized SSH keys from one or more configured *sources*
4
+ (local files, an HTTP endpoint, LDAP certificates, ...), with optional TTL
5
+ caching and user/group aliasing. Designed to be driven either from the CLI as an
6
+ ``AuthorizedKeysCommand`` or as a long-running key server (``authkeys serve``).
7
+ """
8
+
9
+ import re
10
+ import threading
11
+ from configparser import SectionProxy
12
+ from dataclasses import dataclass
13
+ from typing import Callable, Dict, Iterable, List, NamedTuple, Optional, Union
14
+
15
+ from duho import logging
16
+
17
+ from . import groupmembers, usermaps, utils
18
+ from .cache import AuthKeysCache, AuthKeysCacheBackend
19
+ from .config import AuthkeysConfig
20
+
21
+ try:
22
+ from importlib.metadata import version as _pkg_version
23
+
24
+ __version__ = _pkg_version("authkeys")
25
+ except Exception: # not installed (bare source checkout)
26
+ __version__ = "0+unknown"
27
+
28
+ LOGGER = logging.getLogger("authkeys")
29
+
30
+
31
+ # A recognized `authorized_keys` key-type token (prefix or exact set), per the
32
+ # OpenSSH manual: ssh-rsa/ssh-dss/ssh-ed25519(-sk)/sk-ssh-ed25519@openssh.com/
33
+ # ecdsa-sha2-*/sk-ecdsa-sha2-*, and their `*-cert-v01@openssh.com` cert variants.
34
+ # Anything else as the first token (or any token containing `=`, i.e. an
35
+ # options list like `command="...",no-pty`) is an options prefix, not a type.
36
+ _KEY_TYPE_RE = re.compile(r"^(ssh-|ecdsa-|sk-)")
37
+
38
+
39
+ def _is_options_prefix(token: str) -> bool:
40
+ return "=" in token or not _KEY_TYPE_RE.match(token)
41
+
42
+
43
+ class AuthorizedKey(NamedTuple):
44
+ type: str
45
+ key: str
46
+ comment: str
47
+ options: str = ""
48
+
49
+ def __repr__(self) -> str:
50
+ base = f"{self.type} {self.key} {self.comment}" if self.comment else f"{self.type} {self.key}"
51
+ if self.options:
52
+ return f"{self.options} {base}"
53
+ return base
54
+
55
+ @classmethod
56
+ def parse(cls, authorized_key: str, comment: "Optional[str]" = None) -> "AuthorizedKey":
57
+ options = ""
58
+ first, _, rest = authorized_key.partition(" ")
59
+ if first and _is_options_prefix(first):
60
+ options = first
61
+ authorized_key = rest
62
+ parts = authorized_key.split(" ", maxsplit=2)
63
+ if len(parts) < 2:
64
+ raise ValueError(authorized_key)
65
+ if len(parts) == 3 and comment is None:
66
+ comment = parts[2]
67
+ return cls(parts[0], parts[1], comment or "", options)
68
+
69
+ @classmethod
70
+ def parse_all(
71
+ cls, keys: "Union[str, Iterable[Union[AuthorizedKey, str]], None]"
72
+ ) -> "Iterable[AuthorizedKey]":
73
+ keys = keys or ""
74
+ if isinstance(keys, str):
75
+ keys = keys.splitlines()
76
+ for key in keys:
77
+ if isinstance(key, str):
78
+ key = key.strip()
79
+ if not key or key.startswith("#"):
80
+ continue
81
+ key = cls.parse(key)
82
+ if key:
83
+ yield key
84
+
85
+
86
+ class AuthkeysSource:
87
+ """Base class for key sources. Subclasses yield raw ``authorized_keys`` lines."""
88
+
89
+ def __init__(self, conf: SectionProxy, globals: dict) -> None:
90
+ pass
91
+
92
+ def authorized_keys(self, username: str) -> Iterable[str]:
93
+ return ()
94
+
95
+
96
+ Usermap = Callable[[str, str, "Source"], str]
97
+ Sanitizer = Callable[[str, str, AuthorizedKey], "Optional[AuthorizedKey]"]
98
+
99
+
100
+ class UserConfig:
101
+ authorized_users: "List[str]"
102
+ authorized_groups: "List[str]"
103
+
104
+ def __init__(self, authorized_users=None, authorized_groups=None) -> None:
105
+ self.authorized_users = authorized_users or []
106
+ self.authorized_groups = authorized_groups or []
107
+
108
+
109
+ def default_sanitize(src: str, uid: str, key: AuthorizedKey) -> AuthorizedKey:
110
+ if not key.comment:
111
+ key = key._replace(comment=f"{uid}(src={src})")
112
+ return key
113
+
114
+
115
+ @dataclass
116
+ class Source:
117
+ cached: bool
118
+ enabled: bool
119
+ backend: "Optional[AuthkeysSource]"
120
+ sanitize: "Optional[Sanitizer]"
121
+ expire: "Optional[int]" # per-source TTL override; None => use [cache] expire
122
+
123
+ @classmethod
124
+ def from_config(
125
+ cls, config: SectionProxy, globals: dict, defaults: "Source"
126
+ ) -> "Source":
127
+ cached = config.getboolean("cached", defaults.cached)
128
+ enabled = config.getboolean("enabled", defaults.enabled)
129
+ backend = config.get("backend")
130
+ sanitize = config.get("sanitize")
131
+ if enabled and backend:
132
+ backend = utils.call(backend, config, globals)
133
+ if isinstance(sanitize, str):
134
+ sanitize = utils.import_module_object(sanitize)
135
+ elif sanitize is None:
136
+ sanitize = defaults.sanitize
137
+ expire = config.get("expire")
138
+ try:
139
+ expire = int(expire) if expire is not None else None
140
+ except (TypeError, ValueError):
141
+ LOGGER.error(f"Invalid source 'expire' {expire!r}; using [cache] expire")
142
+ expire = None
143
+ return cls(
144
+ cached=cached,
145
+ enabled=enabled,
146
+ backend=backend,
147
+ sanitize=sanitize,
148
+ expire=expire,
149
+ )
150
+
151
+
152
+ class AuthKeys:
153
+ def __init__(self) -> None:
154
+ self.globals: dict = {}
155
+ self.sources: "Dict[str, Source]" = {}
156
+ self.usermap: Usermap = usermaps.none
157
+ self.users: "Dict[str, UserConfig]" = {}
158
+ self.cache: "Optional[AuthKeysCache]" = None
159
+ self.use_expired_on_error = False
160
+ self.groupmembers = groupmembers.system_groupmembers
161
+ self.source_defaults = Source(
162
+ cached=True,
163
+ enabled=True,
164
+ backend=None,
165
+ sanitize=default_sanitize,
166
+ expire=None,
167
+ )
168
+ # Guards the per-(uid, source) cache miss -> compute -> store section so
169
+ # the threaded key server (ThreadingHTTPServer) doesn't race on the
170
+ # shared cache backend (esp. the file backend's read/write).
171
+ self._lock = threading.RLock()
172
+
173
+ def load_config(self, config: AuthkeysConfig) -> None:
174
+ for section in config.sections():
175
+ if not section.startswith("source:"):
176
+ continue
177
+ name = section[len("source:"):]
178
+ src_config = config[section]
179
+ LOGGER.info(f"Registering source {name}")
180
+ self.sources[name] = Source.from_config(
181
+ src_config, self.globals, self.source_defaults
182
+ )
183
+
184
+ self.usermap = utils.import_module_object(
185
+ config["globals"].get("usermap") or "authkeys.usermaps.none"
186
+ )
187
+ self.groupmembers = utils.import_module_object(
188
+ config["globals"].get("groupmembers")
189
+ or "authkeys.groupmembers.system_groupmembers"
190
+ )
191
+
192
+ cache_conf = config["cache"]
193
+ cache_backend = utils.import_module_object(
194
+ cache_conf.get("backend") or "authkeys.cache.AuthKeysCacheMemBackend"
195
+ )
196
+ # Parse the TTL independently: a malformed `expire` should fall back to
197
+ # the default, not disable caching entirely (and not mask a genuine
198
+ # backend construction error below).
199
+ try:
200
+ ttl = int(cache_conf.get("expire", 300))
201
+ except (TypeError, ValueError):
202
+ LOGGER.error(
203
+ f"Invalid cache 'expire' value {cache_conf.get('expire')!r}; "
204
+ f"falling back to default TTL 300"
205
+ )
206
+ ttl = 300
207
+ neg_raw = cache_conf.get("negative_expire")
208
+ try:
209
+ negative_ttl = int(neg_raw) if neg_raw is not None else None
210
+ except (TypeError, ValueError):
211
+ LOGGER.error(f"Invalid 'negative_expire' {neg_raw!r}; using expire")
212
+ negative_ttl = None
213
+ try:
214
+ self.cache = AuthKeysCache(
215
+ backend=cache_backend.from_config(cache_conf),
216
+ ttl=ttl,
217
+ negative_ttl=negative_ttl,
218
+ )
219
+ except Exception as e:
220
+ LOGGER.error(f"Could not load cache due to\n{e}")
221
+ self.cache = None
222
+
223
+ self.use_expired_on_error = utils.parse_bool(
224
+ cache_conf.get("expired_on_error")
225
+ )
226
+
227
+ def user_config_path(self, username: str):
228
+ """Return a user's ``~/.ssh/authkeys.conf`` path, or None if unknown.
229
+
230
+ POSIX-only (uses ``pwd``); returns None when the user has no local
231
+ account, so the caller can fall back to resolving the literal username.
232
+ """
233
+ from pathlib import Path
234
+
235
+ from . import config as _config
236
+
237
+ try:
238
+ user = utils.get_user(username)
239
+ except Exception:
240
+ return None
241
+ return Path(user.pw_dir) / _config.USER_CONF_PATH
242
+
243
+ def resolve(
244
+ self, username: str, *, load_delegation: bool = True
245
+ ) -> "List[AuthorizedKey]":
246
+ """Load per-user delegation (if any) and resolve keys.
247
+
248
+ Used by both the CLI and the HTTP server so ``[authorized]``
249
+ user/group delegation behaves identically for each. Only the shared
250
+ ``self.users`` mutation in ``load_user_config`` is done under the lock;
251
+ the resolution (which may fetch from a slow upstream) runs OUTSIDE it so
252
+ one hung source can't stall concurrent requests. Returns a
253
+ fully-materialized list.
254
+ """
255
+ if load_delegation:
256
+ path = self.user_config_path(username)
257
+ try:
258
+ user_conf = AuthkeysConfig.from_config(path) if path else None
259
+ if user_conf is not None:
260
+ with self._lock:
261
+ self.load_user_config(username, user_conf)
262
+ except Exception as e:
263
+ LOGGER.error(
264
+ f"Could not load delegation config for {username}: {e}"
265
+ )
266
+ return list(self.authorized_keys(username))
267
+
268
+ def load_user_config(self, username: str, parser: AuthkeysConfig) -> None:
269
+ config = UserConfig()
270
+ authorized = parser["authorized"] if parser.has_section("authorized") else None
271
+ groups = authorized.getlist("groups", []) if authorized else []
272
+ if not authorized or "users" not in authorized:
273
+ config.authorized_users = [username]
274
+ else:
275
+ config.authorized_users = authorized.getlist("users", [])
276
+ config.authorized_groups = list(groups)
277
+ LOGGER.info(f"Authorized Users: {config.authorized_users}")
278
+ LOGGER.info(f"Authorized Groups: {groups}")
279
+ for groupname in groups:
280
+ try:
281
+ members = self.groupmembers(groupname, self.globals)
282
+ except Exception as e:
283
+ LOGGER.error(f"Could not resolve group '{groupname}': {e}")
284
+ continue
285
+ LOGGER.info(f"{groupname}: {members}")
286
+ for usr in members:
287
+ if usr not in config.authorized_users:
288
+ config.authorized_users.append(usr)
289
+
290
+ self.users[username] = config
291
+
292
+ def _parse_source_lines(
293
+ self, src_name: str, uid: str, raw_lines: "Iterable[str]", sanitize
294
+ ) -> "List[AuthorizedKey]":
295
+ """Parse raw source lines into keys, skipping (not aborting on) bad ones.
296
+
297
+ A single malformed line must not discard the whole source's keys, so each
298
+ line is parsed defensively -- a ``ValueError`` on one line is logged and
299
+ skipped and parsing continues.
300
+ """
301
+ keys: "List[AuthorizedKey]" = []
302
+ for raw in raw_lines:
303
+ for line in (raw.splitlines() if isinstance(raw, str) else [raw]):
304
+ try:
305
+ parsed = list(AuthorizedKey.parse_all(line))
306
+ except ValueError as e:
307
+ LOGGER.warning(
308
+ f"Skipping malformed key line from {src_name} for {uid}: {e}"
309
+ )
310
+ continue
311
+ for key in parsed:
312
+ if sanitize:
313
+ key = sanitize(src_name, uid, key)
314
+ if not key:
315
+ continue
316
+ keys.append(key)
317
+ return keys
318
+
319
+ def _resolve_source_user(
320
+ self, src_name: str, src: "Source", usr: str, uid: str
321
+ ) -> "List[AuthorizedKey]":
322
+ """Resolve one source for one user, with the cache guarded by ``self._lock``.
323
+
324
+ The lock guards only the cache read and write -- NOT the upstream fetch --
325
+ so one slow/hung source cannot stall other users' logins (esp. concurrent
326
+ ``serve`` requests). Two threads may fetch a cold key concurrently; that is
327
+ correctness-safe (last write wins). Returns a fully-materialized list; the
328
+ caller yields outside any lock.
329
+ """
330
+ cache_key = (uid, src_name)
331
+ src_ttl = getattr(src, "expire", None)
332
+ if self.cache and src.cached:
333
+ with self._lock:
334
+ cached = self.cache.get(cache_key, ttl=src_ttl)
335
+ if cached is not None:
336
+ LOGGER.info(f"Using cached source: {src_name} for {usr} -> {uid}")
337
+ return list(AuthorizedKey.parse_all(cached))
338
+
339
+ try:
340
+ LOGGER.info(f"Using source: {src_name} for {usr} -> {uid}")
341
+ keys = self._parse_source_lines(
342
+ src_name, uid, src.backend.authorized_keys(uid), src.sanitize
343
+ )
344
+ if self.cache and src.cached:
345
+ with self._lock:
346
+ self.cache[cache_key] = "\n".join(str(k) for k in keys)
347
+ return keys
348
+ except Exception as e:
349
+ LOGGER.error(
350
+ f"Error while loading keys for {usr} from {src_name}\n{e}"
351
+ )
352
+ if self.use_expired_on_error and self.cache:
353
+ with self._lock:
354
+ expired = self.cache.get(cache_key, include_expired=True)
355
+ if expired:
356
+ LOGGER.warning(
357
+ f"Falling back to expired keys for {src_name}: "
358
+ f"{usr} -> {uid}"
359
+ )
360
+ return list(AuthorizedKey.parse_all(expired))
361
+ return []
362
+
363
+ def authorized_keys(self, username: str) -> "Iterable[AuthorizedKey]":
364
+ usr_config = self.users.get(username) or UserConfig([username])
365
+ # Dedup on (options, type, key) -- NOT the whole tuple -- so the same
366
+ # public key coming from two sources (with different per-source
367
+ # comments) is emitted once, keeping the first comment seen. An
368
+ # option-bearing key (e.g. `command="...",no-pty ssh-rsa AAAA`) is
369
+ # kept distinct from the bare form of the same key, since the options
370
+ # materially change what the key is allowed to do.
371
+ seen: "set" = set()
372
+
373
+ for src_name, src in self.sources.items():
374
+ if not src.enabled or src.backend is None:
375
+ continue
376
+ for usr in usr_config.authorized_users:
377
+ uid = self.usermap(usr, src_name, src)
378
+ for key in self._resolve_source_user(src_name, src, usr, uid):
379
+ identity = (key.options, key.type, key.key)
380
+ if identity not in seen:
381
+ seen.add(identity)
382
+ yield key
authkeys/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """``python -m authkeys`` entry point.
2
+
3
+ If no subcommand is given, ``resolve`` is assumed, so ``authkeys <user>``
4
+ behaves like ``authkeys resolve <user>`` — matching the console script.
5
+ """
6
+
7
+ from .cli import run
8
+
9
+ if __name__ == "__main__":
10
+ raise SystemExit(run())
authkeys/cache.py ADDED
@@ -0,0 +1,211 @@
1
+ """TTL cache for resolved authorized keys.
2
+
3
+ Entries are keyed by ``(uid, source_name)`` and store the serialized keys plus a
4
+ timestamp. :class:`AuthKeysCache` applies the TTL; backends only persist and
5
+ retrieve ``(value, timestamp)`` pairs.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from time import time
10
+ from typing import Iterable, Optional, Tuple
11
+
12
+ # (uid, source_name)
13
+ CacheKey = Tuple[str, str]
14
+ # (serialized_keys, stored_at_epoch)
15
+ CacheEntry = Tuple[str, float]
16
+
17
+
18
+ class AuthKeysCacheBackend:
19
+ """Persist ``(value, timestamp)`` pairs keyed by :data:`CacheKey`.
20
+
21
+ Enumeration/deletion (``keys``/``__delitem__``/``sweep``) are optional; a
22
+ backend that can't support them (e.g. an in-memory one across processes)
23
+ raises ``NotImplementedError`` so the ``authkeys cache`` subcommand can report
24
+ the limitation gracefully.
25
+ """
26
+
27
+ @classmethod
28
+ def from_config(cls, config) -> "AuthKeysCacheBackend":
29
+ return cls()
30
+
31
+ def __getitem__(self, key: CacheKey) -> "Optional[CacheEntry]": # pragma: no cover
32
+ raise NotImplementedError
33
+
34
+ def __setitem__(self, key: CacheKey, value: str) -> None: # pragma: no cover
35
+ raise NotImplementedError
36
+
37
+ def keys(self) -> "Iterable[CacheKey]": # pragma: no cover
38
+ raise NotImplementedError
39
+
40
+ def __delitem__(self, key: CacheKey) -> None: # pragma: no cover
41
+ raise NotImplementedError
42
+
43
+ def sweep(
44
+ self, *, max_age: "Optional[float]" = None, max_entries: "Optional[int]" = None
45
+ ) -> int:
46
+ """Evict expired/excess entries; return the number removed."""
47
+ removed = 0
48
+ entries = [(k, self[k]) for k in self.keys()]
49
+ entries = [(k, e) for k, e in entries if e]
50
+ if max_age is not None:
51
+ cutoff = time() - max_age
52
+ for k, e in list(entries):
53
+ if e[1] < cutoff:
54
+ del self[k]
55
+ removed += 1
56
+ entries.remove((k, e))
57
+ if max_entries is not None and len(entries) > max_entries:
58
+ # Drop the oldest first.
59
+ entries.sort(key=lambda ke: ke[1][1])
60
+ for k, _e in entries[: len(entries) - max_entries]:
61
+ del self[k]
62
+ removed += 1
63
+ return removed
64
+
65
+
66
+ class AuthKeysCacheMemBackend(AuthKeysCacheBackend):
67
+ def __init__(self) -> None:
68
+ self.db: "dict[CacheKey, CacheEntry]" = {}
69
+
70
+ def __getitem__(self, key: CacheKey) -> "Optional[CacheEntry]":
71
+ return self.db.get(key)
72
+
73
+ def __setitem__(self, key: CacheKey, value: str) -> None:
74
+ self.db[key] = (value, time())
75
+
76
+ def keys(self) -> "Iterable[CacheKey]":
77
+ return list(self.db.keys())
78
+
79
+ def __delitem__(self, key: CacheKey) -> None:
80
+ self.db.pop(key, None)
81
+
82
+
83
+ class AuthKeysCacheFileBackend(AuthKeysCacheBackend):
84
+ """Cache keys under ``<path>/<source>/<uid>``; mtime is the timestamp."""
85
+
86
+ def __init__(self, path: "str | Path") -> None:
87
+ self.path = Path(path)
88
+
89
+ @classmethod
90
+ def from_config(cls, config) -> "AuthKeysCacheFileBackend":
91
+ for path in config.getlist("path", []):
92
+ path = Path(path)
93
+ if path.exists():
94
+ return cls(path)
95
+ raise FileNotFoundError(
96
+ "No existing cache 'path' configured for the file cache backend"
97
+ )
98
+
99
+ def cached_entry_path(self, key: CacheKey) -> "Optional[Path]":
100
+ uid, src = key
101
+ if not (self.path and self.path.exists()):
102
+ return None
103
+ src_cache = self.path / src
104
+ # Cache entries reveal which principals a host trusts -> keep them private.
105
+ src_cache.mkdir(exist_ok=True)
106
+ try:
107
+ src_cache.chmod(0o700)
108
+ except OSError:
109
+ pass
110
+ return src_cache / uid
111
+
112
+ def __getitem__(self, key: CacheKey) -> "Optional[CacheEntry]":
113
+ cached = self.cached_entry_path(key)
114
+ if cached and cached.exists():
115
+ return cached.read_text(), cached.stat().st_mtime
116
+ return None
117
+
118
+ def __setitem__(self, key: CacheKey, value: str) -> None:
119
+ cached = self.cached_entry_path(key)
120
+ if not cached:
121
+ return
122
+ # Write atomically (temp file in the same dir + os.replace) so a concurrent
123
+ # reader -- including a separate sshd-spawned process, which the in-process
124
+ # lock does not cover -- never sees a truncated entry.
125
+ import os
126
+ import tempfile
127
+
128
+ fd, tmp = tempfile.mkstemp(dir=str(cached.parent), prefix=".tmp-")
129
+ try:
130
+ os.write(fd, value.encode("utf-8"))
131
+ os.fsync(fd)
132
+ finally:
133
+ os.close(fd)
134
+ try:
135
+ os.chmod(tmp, 0o600)
136
+ except OSError:
137
+ pass
138
+ os.replace(tmp, str(cached))
139
+
140
+ def keys(self) -> "Iterable[CacheKey]":
141
+ if not (self.path and self.path.exists()):
142
+ return []
143
+ found = []
144
+ for src_dir in self.path.iterdir():
145
+ if src_dir.is_dir():
146
+ for entry in src_dir.iterdir():
147
+ if entry.is_file() and not entry.name.startswith(".tmp-"):
148
+ found.append((entry.name, src_dir.name))
149
+ return found
150
+
151
+ def __delitem__(self, key: CacheKey) -> None:
152
+ cached = self.cached_entry_path(key)
153
+ if cached and cached.exists():
154
+ cached.unlink()
155
+
156
+
157
+ # Marker distinguishing a cached "this (uid, source) genuinely has no keys"
158
+ # (negative hit) from a cached key set. It must never be a valid key line.
159
+ _NEGATIVE_MARKER = "\x00authkeys:empty"
160
+
161
+
162
+ class AuthKeysCache:
163
+ """TTL cache with a distinct (shorter) TTL for negative (empty) results.
164
+
165
+ ``ttl`` is the positive TTL. ``negative_ttl`` (defaults to ``ttl``) bounds a
166
+ cached "no keys" result so a newly-added key appears sooner. An *errored*
167
+ resolution is never stored here -- only genuine results -- so
168
+ ``expired_on_error`` never resurrects an outage as "empty".
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ backend: AuthKeysCacheBackend,
174
+ ttl: int = 1,
175
+ negative_ttl: "Optional[int]" = None,
176
+ ) -> None:
177
+ self.ttl = ttl
178
+ self.negative_ttl = ttl if negative_ttl is None else negative_ttl
179
+ self.backend = backend
180
+
181
+ def _fresh(self, stored_at: float, ttl: int) -> bool:
182
+ return (time() - ttl) <= stored_at
183
+
184
+ def get(
185
+ self,
186
+ key: CacheKey,
187
+ *,
188
+ include_expired: bool = False,
189
+ ttl: "Optional[int]" = None,
190
+ ) -> "Optional[str]":
191
+ result = self.backend[key]
192
+ if not result:
193
+ return None
194
+ value, stored_at = result
195
+ is_negative = value == _NEGATIVE_MARKER
196
+ effective = self.negative_ttl if is_negative else (
197
+ self.ttl if ttl is None else ttl
198
+ )
199
+ if include_expired or self._fresh(stored_at, effective):
200
+ return "" if is_negative else value
201
+ return None
202
+
203
+ def set(self, key: CacheKey, value: str, *, negative: bool = False) -> None:
204
+ self.backend[key] = _NEGATIVE_MARKER if negative else value
205
+
206
+ def __getitem__(self, key: CacheKey) -> "Optional[str]":
207
+ return self.get(key, include_expired=False)
208
+
209
+ def __setitem__(self, key: CacheKey, value: str) -> None:
210
+ # Empty string => negative (known-empty) entry with the negative TTL.
211
+ self.set(key, value, negative=(value == ""))