safe-api-keys 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.
- safe_api_keys/__init__.py +52 -0
- safe_api_keys/_logic.py +414 -0
- safe_api_keys/_util.py +65 -0
- safe_api_keys/amanager.py +281 -0
- safe_api_keys/audit.py +98 -0
- safe_api_keys/cache.py +75 -0
- safe_api_keys/cli.py +271 -0
- safe_api_keys/contrib/__init__.py +1 -0
- safe_api_keys/contrib/_common.py +66 -0
- safe_api_keys/contrib/django/__init__.py +29 -0
- safe_api_keys/contrib/django/admin.py +131 -0
- safe_api_keys/contrib/django/apps.py +14 -0
- safe_api_keys/contrib/django/conf.py +214 -0
- safe_api_keys/contrib/django/decorators.py +90 -0
- safe_api_keys/contrib/django/drf.py +130 -0
- safe_api_keys/contrib/django/management/__init__.py +0 -0
- safe_api_keys/contrib/django/management/commands/__init__.py +0 -0
- safe_api_keys/contrib/django/management/commands/apikey.py +28 -0
- safe_api_keys/contrib/django/middleware.py +33 -0
- safe_api_keys/contrib/django/migrations/0001_initial.py +39 -0
- safe_api_keys/contrib/django/migrations/__init__.py +0 -0
- safe_api_keys/contrib/django/models.py +63 -0
- safe_api_keys/contrib/fastapi.py +165 -0
- safe_api_keys/contrib/flask.py +133 -0
- safe_api_keys/contrib/starlette.py +111 -0
- safe_api_keys/env.py +56 -0
- safe_api_keys/exceptions.py +139 -0
- safe_api_keys/extract.py +78 -0
- safe_api_keys/format.py +150 -0
- safe_api_keys/hashing.py +186 -0
- safe_api_keys/http.py +114 -0
- safe_api_keys/manager.py +283 -0
- safe_api_keys/models.py +180 -0
- safe_api_keys/policy.py +112 -0
- safe_api_keys/py.typed +0 -0
- safe_api_keys/scopes.py +42 -0
- safe_api_keys/stores/__init__.py +84 -0
- safe_api_keys/stores/base.py +147 -0
- safe_api_keys/stores/django.py +135 -0
- safe_api_keys/stores/memory.py +79 -0
- safe_api_keys/stores/redis.py +303 -0
- safe_api_keys/stores/sqlalchemy.py +329 -0
- safe_api_keys/stores/sqlite.py +149 -0
- safe_api_keys-0.1.0.dist-info/METADATA +1252 -0
- safe_api_keys-0.1.0.dist-info/RECORD +49 -0
- safe_api_keys-0.1.0.dist-info/WHEEL +5 -0
- safe_api_keys-0.1.0.dist-info/entry_points.txt +2 -0
- safe_api_keys-0.1.0.dist-info/licenses/LICENSE +21 -0
- safe_api_keys-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""safe-api-keys: framework-agnostic API key lifecycle management.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from safe_api_keys import KeyManager
|
|
6
|
+
from safe_api_keys.stores import MemoryStore
|
|
7
|
+
|
|
8
|
+
km = KeyManager(MemoryStore(), prefix="sk_test", pepper=b"change-me-32-bytes-of-randomness!")
|
|
9
|
+
issued = km.issue("user-1", scopes=["orders:read"])
|
|
10
|
+
record = km.verify(issued.raw_key, scopes=["orders:read"])
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .amanager import AsyncKeyManager, AsyncKeyVerifier
|
|
14
|
+
from .audit import AuditEvent, AuditSink, CallbackAuditSink, LoggingAuditSink, NullAuditSink
|
|
15
|
+
from .cache import VerifyCache
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
APIKeyError,
|
|
18
|
+
ConfigurationError,
|
|
19
|
+
ExpiredKey,
|
|
20
|
+
InsufficientScope,
|
|
21
|
+
IPNotAllowed,
|
|
22
|
+
MalformedKey,
|
|
23
|
+
MissingDependency,
|
|
24
|
+
MissingKey,
|
|
25
|
+
NotSupported,
|
|
26
|
+
PolicyViolation,
|
|
27
|
+
RevokedKey,
|
|
28
|
+
SafeAPIKeysError,
|
|
29
|
+
StoreError,
|
|
30
|
+
UnknownKey,
|
|
31
|
+
)
|
|
32
|
+
from .extract import ExtractConfig, extract_key
|
|
33
|
+
from .format import KeyFormat, mask_key, parse_key
|
|
34
|
+
from .hashing import DUMMY_HASH, Argon2Hasher, Hasher, HmacSha256Hasher, Sha256Hasher
|
|
35
|
+
from .manager import KeyManager, KeyVerifier
|
|
36
|
+
from .models import IssuedKey, KeyRecord, ParsedKey, VerifyResult
|
|
37
|
+
from .policy import KeyPolicy
|
|
38
|
+
from .scopes import has_scope, missing_scopes
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"KeyManager", "KeyVerifier", "AsyncKeyManager", "AsyncKeyVerifier",
|
|
44
|
+
"KeyRecord", "IssuedKey", "ParsedKey", "VerifyResult", "KeyFormat", "KeyPolicy", "VerifyCache",
|
|
45
|
+
"Hasher", "HmacSha256Hasher", "Sha256Hasher", "Argon2Hasher", "DUMMY_HASH",
|
|
46
|
+
"AuditEvent", "AuditSink", "NullAuditSink", "LoggingAuditSink", "CallbackAuditSink",
|
|
47
|
+
"ExtractConfig", "extract_key", "parse_key", "mask_key", "has_scope", "missing_scopes",
|
|
48
|
+
"SafeAPIKeysError", "APIKeyError", "MissingKey", "MalformedKey", "UnknownKey", "RevokedKey", "ExpiredKey",
|
|
49
|
+
"InsufficientScope", "IPNotAllowed", "StoreError", "PolicyViolation", "ConfigurationError",
|
|
50
|
+
"MissingDependency", "NotSupported",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
safe_api_keys/_logic.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Pure decision logic shared by the sync and async managers (§2.4).
|
|
2
|
+
|
|
3
|
+
Every rule (verification order, policy, rotation maths, IP matching) lives here
|
|
4
|
+
exactly once. The managers only add I/O around these functions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
import logging
|
|
11
|
+
import warnings
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from typing import Any, Callable, Iterable, Mapping, Optional, Sequence, Tuple, Union
|
|
14
|
+
|
|
15
|
+
from ._util import ensure_aware, optional_aware, utcnow
|
|
16
|
+
from .audit import AuditEvent, AuditSink, NullAuditSink, safe_emit
|
|
17
|
+
from .cache import VerifyCache
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
APIKeyError,
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
ExpiredKey,
|
|
22
|
+
InsufficientScope,
|
|
23
|
+
IPNotAllowed,
|
|
24
|
+
MalformedKey,
|
|
25
|
+
RevokedKey,
|
|
26
|
+
SafeAPIKeysError,
|
|
27
|
+
StoreError,
|
|
28
|
+
UnknownKey,
|
|
29
|
+
)
|
|
30
|
+
from .format import MAX_RAW_KEY_LENGTH, KeyFormat, generate_key, parse_key, validate_prefix
|
|
31
|
+
from .hashing import Hasher, HasherRegistry
|
|
32
|
+
from .models import IssuedKey, KeyRecord, ParsedKey
|
|
33
|
+
from .policy import KeyPolicy, validate_metadata
|
|
34
|
+
from .scopes import missing_scopes, normalize_scopes, scope_covers
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"IPNetwork",
|
|
38
|
+
"normalize_ip",
|
|
39
|
+
"normalize_allowlist",
|
|
40
|
+
"ip_allowed",
|
|
41
|
+
"prepare_raw",
|
|
42
|
+
"check_secret",
|
|
43
|
+
"decide",
|
|
44
|
+
"check_scopes",
|
|
45
|
+
"check_ip",
|
|
46
|
+
"should_touch",
|
|
47
|
+
"build_new_key",
|
|
48
|
+
"plan_revoke",
|
|
49
|
+
"plan_rotation",
|
|
50
|
+
"key_id_from",
|
|
51
|
+
"KEY_ID_RETRIES",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger("safe_api_keys")
|
|
55
|
+
IPNetwork = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]
|
|
56
|
+
IPAddr = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
|
|
57
|
+
KEY_ID_RETRIES = 3
|
|
58
|
+
Clock = Callable[[], datetime]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------- IP
|
|
62
|
+
def normalize_ip(value: str) -> IPAddr:
|
|
63
|
+
"""Parse an address; IPv4-mapped IPv6 (``::ffff:1.2.3.4``) becomes IPv4 (§7.3)."""
|
|
64
|
+
addr = ipaddress.ip_address(str(value).strip().split("%", 1)[0])
|
|
65
|
+
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
|
66
|
+
return addr.ipv4_mapped
|
|
67
|
+
return addr
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_network(value: str) -> IPNetwork:
|
|
71
|
+
net = ipaddress.ip_network(str(value).strip(), strict=False)
|
|
72
|
+
if isinstance(net, ipaddress.IPv6Network) and net.network_address.ipv4_mapped is not None \
|
|
73
|
+
and net.prefixlen >= 96:
|
|
74
|
+
return ipaddress.ip_network(f"{net.network_address.ipv4_mapped}/{net.prefixlen - 96}")
|
|
75
|
+
return net
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_allowlist(entries: Optional[Iterable[str]]) -> Tuple[str, ...]:
|
|
79
|
+
if entries is None:
|
|
80
|
+
return ()
|
|
81
|
+
if isinstance(entries, str):
|
|
82
|
+
entries = [entries]
|
|
83
|
+
out = []
|
|
84
|
+
for e in entries:
|
|
85
|
+
try:
|
|
86
|
+
out.append(str(_parse_network(e)))
|
|
87
|
+
except ValueError:
|
|
88
|
+
raise ValueError(f"invalid IP/CIDR in ip_allowlist: {e!r}") from None
|
|
89
|
+
return tuple(sorted(set(out)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ip_allowed(allowlist: Iterable[str], client_ip: str) -> bool:
|
|
93
|
+
try:
|
|
94
|
+
addr = normalize_ip(client_ip)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return False
|
|
97
|
+
for entry in allowlist:
|
|
98
|
+
try:
|
|
99
|
+
net = _parse_network(entry)
|
|
100
|
+
except ValueError:
|
|
101
|
+
continue
|
|
102
|
+
if addr.version == net.version and addr in net:
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------- verify steps
|
|
108
|
+
def prepare_raw(raw_key: Any, key_format: KeyFormat) -> ParsedKey:
|
|
109
|
+
"""Steps 1-3: length/ASCII, parse + checksum, prefix binding. Never touches the store."""
|
|
110
|
+
if not isinstance(raw_key, str):
|
|
111
|
+
raise MalformedKey(reason="malformed")
|
|
112
|
+
raw = raw_key.strip()
|
|
113
|
+
if len(raw) > MAX_RAW_KEY_LENGTH or not raw.isascii():
|
|
114
|
+
raise MalformedKey(reason="malformed")
|
|
115
|
+
parsed = parse_key(raw, key_format)
|
|
116
|
+
if parsed.prefix != key_format.prefix:
|
|
117
|
+
raise MalformedKey(reason="prefix", key_id=parsed.key_id)
|
|
118
|
+
return parsed
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def check_secret(registry: HasherRegistry, parsed: ParsedKey, record: Optional[KeyRecord]) -> KeyRecord:
|
|
122
|
+
"""Steps 5-6. Unknown ids burn a dummy hash so timing matches a wrong secret (§4.3)."""
|
|
123
|
+
if record is None:
|
|
124
|
+
registry.dummy_verify()
|
|
125
|
+
raise UnknownKey(reason="unknown", key_id=parsed.key_id)
|
|
126
|
+
hasher = registry.get(record.hash_alg)
|
|
127
|
+
if hasher is None:
|
|
128
|
+
registry.dummy_verify()
|
|
129
|
+
raise UnknownKey(reason="pepper_version_missing", key_id=parsed.key_id)
|
|
130
|
+
if not hasher.verify(parsed.body, record.hash) or record.prefix != parsed.prefix:
|
|
131
|
+
raise UnknownKey(reason="bad_secret", key_id=parsed.key_id)
|
|
132
|
+
return record
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check_scopes(record: KeyRecord, scopes: Optional[Sequence[str]], any_scopes: Optional[Sequence[str]]) -> None:
|
|
136
|
+
required = tuple(scopes or ())
|
|
137
|
+
missing = missing_scopes(record.scopes, required)
|
|
138
|
+
any_req = tuple(any_scopes or ())
|
|
139
|
+
if any_req and not any(scope_covers(g, r) for g in record.scopes for r in any_req):
|
|
140
|
+
missing = missing + [r for r in any_req if r not in missing]
|
|
141
|
+
if missing:
|
|
142
|
+
raise InsufficientScope(required=list(required) + [r for r in any_req if r not in required],
|
|
143
|
+
missing=missing, key_id=record.key_id)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_ip(record: KeyRecord, client_ip: Optional[str]) -> None:
|
|
147
|
+
if not record.ip_allowlist:
|
|
148
|
+
return
|
|
149
|
+
if not client_ip:
|
|
150
|
+
raise IPNotAllowed(reason="no_client_ip", key_id=record.key_id)
|
|
151
|
+
if not ip_allowed(record.ip_allowlist, client_ip):
|
|
152
|
+
raise IPNotAllowed(reason="ip", key_id=record.key_id)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decide(record: KeyRecord, now: datetime, *, scopes: Optional[Sequence[str]] = None,
|
|
156
|
+
any_scopes: Optional[Sequence[str]] = None, client_ip: Optional[str] = None) -> None:
|
|
157
|
+
"""Steps 7-10 in fixed order: revoked -> expired -> scope -> ip (§5.2)."""
|
|
158
|
+
now = ensure_aware(now, "now")
|
|
159
|
+
if record.revoked_at is not None:
|
|
160
|
+
raise RevokedKey(key_id=record.key_id)
|
|
161
|
+
if record.expires_at is not None and record.expires_at <= now:
|
|
162
|
+
raise ExpiredKey(key_id=record.key_id)
|
|
163
|
+
check_scopes(record, scopes, any_scopes)
|
|
164
|
+
check_ip(record, client_ip)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def should_touch(record: KeyRecord, now: datetime, interval: Optional[float]) -> bool:
|
|
168
|
+
if interval is None:
|
|
169
|
+
return False
|
|
170
|
+
if record.last_used_at is None:
|
|
171
|
+
return True
|
|
172
|
+
return (now - record.last_used_at).total_seconds() >= interval
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def key_id_from(value: str, key_format: KeyFormat) -> str:
|
|
176
|
+
"""Accept either a key_id or a raw key (key ids never contain ``_``)."""
|
|
177
|
+
value = (value or "").strip()
|
|
178
|
+
if "_" in value:
|
|
179
|
+
return prepare_raw(value, key_format).key_id
|
|
180
|
+
if not value:
|
|
181
|
+
raise UnknownKey(reason="unknown")
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------- issue / rotate
|
|
186
|
+
def _resolve_expiry(now: datetime, expires_at: Optional[datetime],
|
|
187
|
+
expires_in: Optional[timedelta]) -> Optional[datetime]:
|
|
188
|
+
if expires_at is not None and expires_in is not None:
|
|
189
|
+
raise ValueError("expires_at and expires_in are mutually exclusive")
|
|
190
|
+
if expires_in is not None:
|
|
191
|
+
if not isinstance(expires_in, timedelta):
|
|
192
|
+
raise TypeError("expires_in must be a timedelta")
|
|
193
|
+
if expires_in <= timedelta(0):
|
|
194
|
+
raise ValueError("expires_in must be positive")
|
|
195
|
+
return now + expires_in
|
|
196
|
+
expires_at = optional_aware(expires_at, "expires_at")
|
|
197
|
+
if expires_at is not None and expires_at <= now:
|
|
198
|
+
raise ValueError("expires_at must be in the future")
|
|
199
|
+
return expires_at
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _validate_text(value: Any, name: str, *, required: bool) -> str:
|
|
203
|
+
if value is None:
|
|
204
|
+
value = ""
|
|
205
|
+
if not isinstance(value, str):
|
|
206
|
+
raise ValueError(f"{name} must be a string")
|
|
207
|
+
if required and not value.strip():
|
|
208
|
+
raise ValueError(f"{name} is required")
|
|
209
|
+
if len(value) > 255:
|
|
210
|
+
raise ValueError(f"{name} must be at most 255 characters")
|
|
211
|
+
return value
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def build_new_key(
|
|
215
|
+
*,
|
|
216
|
+
key_format: KeyFormat,
|
|
217
|
+
hasher: Hasher,
|
|
218
|
+
policy: KeyPolicy,
|
|
219
|
+
now: datetime,
|
|
220
|
+
owner: str,
|
|
221
|
+
scopes: Iterable[str] = (),
|
|
222
|
+
expires_at: Optional[datetime] = None,
|
|
223
|
+
expires_in: Optional[timedelta] = None,
|
|
224
|
+
name: str = "",
|
|
225
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
226
|
+
ip_allowlist: Iterable[str] = (),
|
|
227
|
+
key_id: Optional[str] = None,
|
|
228
|
+
rotated_from: Optional[str] = None,
|
|
229
|
+
) -> IssuedKey:
|
|
230
|
+
"""Validate + apply policy + generate + hash. Pure: does not persist."""
|
|
231
|
+
owner = _validate_text(owner, "owner", required=True)
|
|
232
|
+
name = _validate_text(name, "name", required=False)
|
|
233
|
+
norm_scopes = normalize_scopes(scopes)
|
|
234
|
+
policy.check_scopes(norm_scopes)
|
|
235
|
+
exp = policy.resolve_expiry(now, _resolve_expiry(now, expires_at, expires_in))
|
|
236
|
+
meta = validate_metadata(metadata, policy.max_metadata_bytes)
|
|
237
|
+
allow = normalize_allowlist(ip_allowlist)
|
|
238
|
+
raw, parsed = generate_key(key_format, key_id)
|
|
239
|
+
record = KeyRecord(
|
|
240
|
+
key_id=parsed.key_id, prefix=key_format.prefix, hash=hasher.hash(parsed.body), hash_alg=hasher.alg_id,
|
|
241
|
+
secret_last4=parsed.secret[-4:], owner=owner, created_at=now, name=name, scopes=norm_scopes,
|
|
242
|
+
expires_at=exp, ip_allowlist=allow, metadata=meta, rotated_from=rotated_from,
|
|
243
|
+
)
|
|
244
|
+
return IssuedKey(raw_key=raw, record=record)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def plan_revoke(record: KeyRecord, now: datetime, reason: Optional[str]) -> KeyRecord:
|
|
248
|
+
if reason is not None:
|
|
249
|
+
reason = _validate_text(reason, "reason", required=False) or None
|
|
250
|
+
return record.replace(revoked_at=now, revoke_reason=reason)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def plan_rotation(old: KeyRecord, new_key_id: str, now: datetime, grace: timedelta) -> KeyRecord:
|
|
254
|
+
"""Old key: ``expires_at = min(original, now + grace)``; ``grace == 0`` revokes immediately."""
|
|
255
|
+
if not isinstance(grace, timedelta) or grace < timedelta(0):
|
|
256
|
+
raise ValueError("grace must be a non-negative timedelta")
|
|
257
|
+
if grace == timedelta(0):
|
|
258
|
+
return old.replace(rotated_to=new_key_id, revoked_at=now, revoke_reason="rotated")
|
|
259
|
+
cutoff = now + grace
|
|
260
|
+
exp = cutoff if old.expires_at is None else min(old.expires_at, cutoff)
|
|
261
|
+
return old.replace(rotated_to=new_key_id, expires_at=exp)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------- shared manager core
|
|
265
|
+
class _Core:
|
|
266
|
+
"""Configuration, validation and audit plumbing shared by every manager/verifier."""
|
|
267
|
+
|
|
268
|
+
_is_manager = False
|
|
269
|
+
|
|
270
|
+
def __init__(
|
|
271
|
+
self,
|
|
272
|
+
store: Any,
|
|
273
|
+
prefix: str,
|
|
274
|
+
*,
|
|
275
|
+
peppers: Optional[Mapping[str, bytes]] = None,
|
|
276
|
+
current_pepper: Optional[str] = None,
|
|
277
|
+
pepper: Optional[bytes] = None,
|
|
278
|
+
hasher: Optional[Hasher] = None,
|
|
279
|
+
key_format: Optional[KeyFormat] = None,
|
|
280
|
+
policy: Optional[KeyPolicy] = None,
|
|
281
|
+
touch_interval: Optional[float] = 60.0,
|
|
282
|
+
cache: Optional[VerifyCache] = None,
|
|
283
|
+
audit: Optional[AuditSink] = None,
|
|
284
|
+
clock: Optional[Clock] = None,
|
|
285
|
+
reveal_state: bool = True,
|
|
286
|
+
audit_success: bool = True,
|
|
287
|
+
require_pepper: Optional[bool] = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
if store is None:
|
|
290
|
+
raise ConfigurationError("store is required")
|
|
291
|
+
validate_prefix(prefix)
|
|
292
|
+
if key_format is None:
|
|
293
|
+
key_format = KeyFormat(prefix)
|
|
294
|
+
elif key_format.prefix != prefix:
|
|
295
|
+
raise ConfigurationError("key_format.prefix must equal prefix")
|
|
296
|
+
if touch_interval is not None and touch_interval < 0:
|
|
297
|
+
raise ConfigurationError("touch_interval must be >= 0 or None")
|
|
298
|
+
self.policy = policy or KeyPolicy()
|
|
299
|
+
self.registry, self.has_pepper = HasherRegistry.build(
|
|
300
|
+
peppers=peppers, current_pepper=current_pepper, pepper=pepper, hasher=hasher)
|
|
301
|
+
if require_pepper is None:
|
|
302
|
+
require_pepper = self.policy.require_pepper
|
|
303
|
+
if not self.has_pepper and hasher is None:
|
|
304
|
+
if require_pepper:
|
|
305
|
+
raise ConfigurationError(
|
|
306
|
+
"a pepper is required (pass pepper=/peppers= or set SAFE_API_KEYS_PEPPER); "
|
|
307
|
+
"to allow unpeppered SHA-256 explicitly use KeyPolicy(require_pepper=False)")
|
|
308
|
+
warnings.warn("safe_api_keys: no pepper configured; falling back to unpeppered SHA-256. "
|
|
309
|
+
"Configure a pepper for production.", UserWarning, stacklevel=3)
|
|
310
|
+
self.store = store
|
|
311
|
+
self.prefix = prefix
|
|
312
|
+
self.key_format = key_format
|
|
313
|
+
self.touch_interval = touch_interval
|
|
314
|
+
self.cache = cache
|
|
315
|
+
self.audit: AuditSink = audit if audit is not None else NullAuditSink()
|
|
316
|
+
self._clock = clock or utcnow
|
|
317
|
+
self.reveal_state = reveal_state
|
|
318
|
+
self.audit_success = audit_success
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def from_env(cls, store: Any, prefix: str, **overrides: Any) -> Any:
|
|
322
|
+
"""Build with peppers from ``SAFE_API_KEYS_PEPPER(S)`` / ``SAFE_API_KEYS_CURRENT_PEPPER``."""
|
|
323
|
+
from .env import from_env
|
|
324
|
+
|
|
325
|
+
return from_env(cls, store, prefix, **overrides)
|
|
326
|
+
|
|
327
|
+
# -- helpers --------------------------------------------------------------
|
|
328
|
+
def now(self) -> datetime:
|
|
329
|
+
return ensure_aware(self._clock(), "clock()")
|
|
330
|
+
|
|
331
|
+
def _event(self, type_: str, **kw: Any) -> None:
|
|
332
|
+
if "at" not in kw:
|
|
333
|
+
kw["at"] = self.now()
|
|
334
|
+
safe_emit(self.audit, AuditEvent(type=type_, **kw))
|
|
335
|
+
|
|
336
|
+
def _require(self, name: str) -> Callable[..., Any]:
|
|
337
|
+
from .exceptions import NotSupported
|
|
338
|
+
|
|
339
|
+
fn = getattr(self.store, name, None)
|
|
340
|
+
if fn is None or not callable(fn):
|
|
341
|
+
raise NotSupported(f"{type(self.store).__name__} does not support {name}()")
|
|
342
|
+
return fn # type: ignore[no-any-return]
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _wrap_store_error(exc: BaseException) -> StoreError:
|
|
346
|
+
if isinstance(exc, StoreError):
|
|
347
|
+
return exc
|
|
348
|
+
err = StoreError(f"store operation failed: {type(exc).__name__}")
|
|
349
|
+
err.__cause__ = exc
|
|
350
|
+
return err
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def _is_passthrough(exc: BaseException) -> bool:
|
|
354
|
+
return isinstance(exc, SafeAPIKeysError) and not isinstance(exc, StoreError)
|
|
355
|
+
|
|
356
|
+
def parse(self, raw_key: str) -> ParsedKey:
|
|
357
|
+
return prepare_raw(raw_key, self.key_format)
|
|
358
|
+
|
|
359
|
+
def mask(self, raw_key: str) -> str:
|
|
360
|
+
return self.parse(raw_key).masked
|
|
361
|
+
|
|
362
|
+
def _reject(self, exc: APIKeyError, parsed: Optional[ParsedKey], record: Optional[KeyRecord],
|
|
363
|
+
scopes: Optional[Sequence[str]], any_scopes: Optional[Sequence[str]],
|
|
364
|
+
client_ip: Optional[str]) -> APIKeyError:
|
|
365
|
+
"""Audit a rejection and map revealing errors when ``reveal_state=False``."""
|
|
366
|
+
req = tuple(scopes or ()) + tuple(any_scopes or ())
|
|
367
|
+
self._event("key.rejected", key_id=exc.key_id or (parsed.key_id if parsed else None),
|
|
368
|
+
owner=record.owner if record else None, reason=exc.reason,
|
|
369
|
+
scopes_required=req or None, client_ip=client_ip)
|
|
370
|
+
if not self.reveal_state and isinstance(exc, (RevokedKey, ExpiredKey)):
|
|
371
|
+
hidden = UnknownKey(reason=exc.reason, key_id=exc.key_id)
|
|
372
|
+
hidden.__suppress_context__ = True
|
|
373
|
+
return hidden
|
|
374
|
+
return exc
|
|
375
|
+
|
|
376
|
+
def _verified(self, record: KeyRecord, scopes: Optional[Sequence[str]],
|
|
377
|
+
any_scopes: Optional[Sequence[str]], client_ip: Optional[str]) -> None:
|
|
378
|
+
if not self.audit_success:
|
|
379
|
+
return
|
|
380
|
+
extra = {"rotated_to": record.rotated_to} if record.rotated_to else {}
|
|
381
|
+
req = tuple(scopes or ()) + tuple(any_scopes or ())
|
|
382
|
+
self._event("key.verified", key_id=record.key_id, owner=record.owner,
|
|
383
|
+
scopes_required=req or None, client_ip=client_ip, extra=extra)
|
|
384
|
+
|
|
385
|
+
def authorize(self, record: KeyRecord, *, scopes: Optional[Sequence[str]] = None,
|
|
386
|
+
any_scopes: Optional[Sequence[str]] = None, client_ip: Optional[str] = None) -> KeyRecord:
|
|
387
|
+
"""Scope check for an already-verified record (e.g. set by middleware). No I/O."""
|
|
388
|
+
try:
|
|
389
|
+
check_scopes(record, scopes, any_scopes)
|
|
390
|
+
except APIKeyError as exc:
|
|
391
|
+
raise self._reject(exc, None, record, scopes, any_scopes, client_ip) from None
|
|
392
|
+
return record
|
|
393
|
+
|
|
394
|
+
def _touched(self, record: KeyRecord, now: datetime) -> KeyRecord:
|
|
395
|
+
updated = record.replace(last_used_at=now, use_count=record.use_count + 1)
|
|
396
|
+
if self.cache is not None:
|
|
397
|
+
self.cache.update(updated)
|
|
398
|
+
return updated
|
|
399
|
+
|
|
400
|
+
def _touch_failed(self, record: KeyRecord, exc: BaseException) -> None:
|
|
401
|
+
logger.warning("touch failed for key_id=%s: %s", record.key_id, type(exc).__name__)
|
|
402
|
+
self._event("key.touch_failed", key_id=record.key_id, owner=record.owner,
|
|
403
|
+
reason=type(exc).__name__)
|
|
404
|
+
|
|
405
|
+
def _invalidate(self, key_id: str) -> None:
|
|
406
|
+
if self.cache is not None:
|
|
407
|
+
self.cache.invalidate(key_id)
|
|
408
|
+
|
|
409
|
+
def _issue_check_policy_count(self, active: int) -> None:
|
|
410
|
+
self.policy.check_active_count(active)
|
|
411
|
+
|
|
412
|
+
def __repr__(self) -> str:
|
|
413
|
+
return (f"{type(self).__name__}(prefix={self.prefix!r}, store={type(self.store).__name__}, "
|
|
414
|
+
f"hash_alg={self.registry.current.alg_id!r})")
|
safe_api_keys/_util.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Small shared helpers (time, durations). Zero dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from email.utils import format_datetime
|
|
8
|
+
from typing import Optional, Union
|
|
9
|
+
|
|
10
|
+
UTC = timezone.utc
|
|
11
|
+
|
|
12
|
+
_DURATION_RE = re.compile(r"^\s*(\d+)\s*([smhdw]?)\s*$")
|
|
13
|
+
_UNITS = {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def utcnow() -> datetime:
|
|
17
|
+
return datetime.now(UTC)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def ensure_aware(value: datetime, name: str = "datetime") -> datetime:
|
|
21
|
+
"""Reject naive datetimes (§2.5) and normalise to UTC."""
|
|
22
|
+
if not isinstance(value, datetime):
|
|
23
|
+
raise TypeError(f"{name} must be a datetime, got {type(value).__name__}")
|
|
24
|
+
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
|
|
25
|
+
raise ValueError(f"{name} must be timezone-aware (UTC); got naive datetime")
|
|
26
|
+
return value.astimezone(UTC)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def optional_aware(value: Optional[datetime], name: str) -> Optional[datetime]:
|
|
30
|
+
return None if value is None else ensure_aware(value, name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_duration(value: Union[str, int, float, timedelta]) -> timedelta:
|
|
34
|
+
"""Parse ``"90d"``, ``"24h"``, ``"30m"``, ``"45s"``, ``"2w"`` or a number of seconds."""
|
|
35
|
+
if isinstance(value, timedelta):
|
|
36
|
+
return value
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
raise ValueError(f"invalid duration: {value!r}")
|
|
39
|
+
if isinstance(value, (int, float)):
|
|
40
|
+
return timedelta(seconds=value)
|
|
41
|
+
m = _DURATION_RE.match(str(value))
|
|
42
|
+
if not m:
|
|
43
|
+
raise ValueError(f"invalid duration: {value!r} (expected e.g. 90d, 24h, 30m, 45s)")
|
|
44
|
+
return timedelta(seconds=int(m.group(1)) * _UNITS[m.group(2)])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_datetime(value: str) -> datetime:
|
|
48
|
+
"""Parse an ISO-8601 timestamp; ``Z`` suffix accepted; must carry a timezone."""
|
|
49
|
+
text = value.strip()
|
|
50
|
+
if text.endswith(("Z", "z")):
|
|
51
|
+
text = text[:-1] + "+00:00"
|
|
52
|
+
return ensure_aware(datetime.fromisoformat(text), "timestamp")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def to_iso(value: datetime) -> str:
|
|
56
|
+
"""Fixed-width UTC ISO-8601 (lexicographically sortable): ``YYYY-MM-DDTHH:MM:SS.ffffff+00:00``."""
|
|
57
|
+
return ensure_aware(value).strftime("%Y-%m-%dT%H:%M:%S.%f+00:00")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def from_iso(value: str) -> datetime:
|
|
61
|
+
return parse_datetime(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def rfc1123(value: datetime) -> str:
|
|
65
|
+
return format_datetime(ensure_aware(value), usegmt=True)
|