caplinks 0.0.2__tar.gz → 0.0.4__tar.gz
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.
- {caplinks-0.0.2 → caplinks-0.0.4}/PKG-INFO +4 -1
- {caplinks-0.0.2 → caplinks-0.0.4}/README.md +3 -0
- {caplinks-0.0.2 → caplinks-0.0.4}/caplinks/__init__.py +7 -0
- caplinks-0.0.4/caplinks/guard.py +87 -0
- {caplinks-0.0.2 → caplinks-0.0.4}/caplinks/links.py +53 -8
- caplinks-0.0.4/caplinks/records.py +158 -0
- {caplinks-0.0.2 → caplinks-0.0.4}/pyproject.toml +1 -1
- {caplinks-0.0.2 → caplinks-0.0.4}/tests/test_caplinks.py +220 -0
- {caplinks-0.0.2 → caplinks-0.0.4}/.gitignore +0 -0
- {caplinks-0.0.2 → caplinks-0.0.4}/LICENSE +0 -0
- {caplinks-0.0.2 → caplinks-0.0.4}/caplinks/expiry.py +0 -0
- {caplinks-0.0.2 → caplinks-0.0.4}/caplinks/limits.py +0 -0
- {caplinks-0.0.2 → caplinks-0.0.4}/caplinks/passwords.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: caplinks
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only.
|
|
5
5
|
Project-URL: Homepage, https://github.com/i2mint/caplinks
|
|
6
6
|
Project-URL: Repository, https://github.com/i2mint/caplinks
|
|
@@ -44,6 +44,9 @@ links.revoke(rec["token"]) # now "missing"
|
|
|
44
44
|
## What is in it
|
|
45
45
|
|
|
46
46
|
- `CapabilityLinks`: create, read, list and revoke links, and gate them on a password. Records are plain dicts in any `MutableMapping` keyed by token: the generic fields are `token`, `created`, `expires`, `password` (a hash) and `revoked`; your own fields (what the link reaches) sit beside them.
|
|
47
|
+
- Gating an id you already have: `create(..., token=existing_id)` keeps the id; `set_password(token, pw_or_None)` and `set_expires(token, when)` change a live link later (a new password ends every open unlock).
|
|
48
|
+
- `AttemptGuard`: the per-link lock-out. `allow(link, client)` before checking a password, `failed(link)` after a wrong one; one client is limited on its own, and a link that takes too many wrong passwords from everyone is locked for a while.
|
|
49
|
+
- `JsonRecords(texts)` and `FileTexts(folder)`: a ready `store` for `CapabilityLinks`, records as `<token>.json` in a text mapping (a local folder: atomic, mode 0600). Pass `valid=` to check your own fields and `deletable=False` to keep revoked links as tombstones.
|
|
47
50
|
- `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
|
|
48
51
|
- `parse_expires` reads what people write (`30m`, `12h`, `7d`, `2w`, `2026-11-01` meaning the end of that day UTC, an ISO timestamp, `never`) and refuses the past; `is_expired` fails closed.
|
|
49
52
|
- `SlidingWindowLimiter` behind the `RateLimiter` protocol (`allow(key) -> bool`): count password attempts per client, per link, or both. It is per process; a shared limiter of the same shape (Redis, say) fits where several workers serve the links.
|
|
@@ -23,6 +23,9 @@ links.revoke(rec["token"]) # now "missing"
|
|
|
23
23
|
## What is in it
|
|
24
24
|
|
|
25
25
|
- `CapabilityLinks`: create, read, list and revoke links, and gate them on a password. Records are plain dicts in any `MutableMapping` keyed by token: the generic fields are `token`, `created`, `expires`, `password` (a hash) and `revoked`; your own fields (what the link reaches) sit beside them.
|
|
26
|
+
- Gating an id you already have: `create(..., token=existing_id)` keeps the id; `set_password(token, pw_or_None)` and `set_expires(token, when)` change a live link later (a new password ends every open unlock).
|
|
27
|
+
- `AttemptGuard`: the per-link lock-out. `allow(link, client)` before checking a password, `failed(link)` after a wrong one; one client is limited on its own, and a link that takes too many wrong passwords from everyone is locked for a while.
|
|
28
|
+
- `JsonRecords(texts)` and `FileTexts(folder)`: a ready `store` for `CapabilityLinks`, records as `<token>.json` in a text mapping (a local folder: atomic, mode 0600). Pass `valid=` to check your own fields and `deletable=False` to keep revoked links as tombstones.
|
|
26
29
|
- `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
|
|
27
30
|
- `parse_expires` reads what people write (`30m`, `12h`, `7d`, `2w`, `2026-11-01` meaning the end of that day UTC, an ISO timestamp, `never`) and refuses the past; `is_expired` fails closed.
|
|
28
31
|
- `SlidingWindowLimiter` behind the `RateLimiter` protocol (`allow(key) -> bool`): count password attempts per client, per link, or both. It is per process; a shared limiter of the same shape (Redis, say) fits where several workers serve the links.
|
|
@@ -16,6 +16,7 @@ True
|
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
18
|
from caplinks.expiry import is_expired, now_utc, parse_expires
|
|
19
|
+
from caplinks.guard import DFLT_MAX_ATTEMPTS, DFLT_MAX_LINK_FAILURES, AttemptGuard
|
|
19
20
|
from caplinks.limits import RateLimiter, SlidingWindowLimiter
|
|
20
21
|
from caplinks.links import (
|
|
21
22
|
DFLT_EXPIRES,
|
|
@@ -27,8 +28,14 @@ from caplinks.links import (
|
|
|
27
28
|
public_record,
|
|
28
29
|
)
|
|
29
30
|
from caplinks.passwords import PasswordHasher, ScryptHasher
|
|
31
|
+
from caplinks.records import FileTexts, JsonRecords
|
|
30
32
|
|
|
31
33
|
__all__ = [
|
|
34
|
+
"AttemptGuard",
|
|
35
|
+
"DFLT_MAX_ATTEMPTS",
|
|
36
|
+
"DFLT_MAX_LINK_FAILURES",
|
|
37
|
+
"FileTexts",
|
|
38
|
+
"JsonRecords",
|
|
32
39
|
"CapabilityLinks",
|
|
33
40
|
"DFLT_EXPIRES",
|
|
34
41
|
"MIN_PASSWORD_LEN",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The per-link lock-out: limit password tries, and lock a link that takes too many.
|
|
2
|
+
|
|
3
|
+
Two counts, because they defend against different things. Tries of one client on one
|
|
4
|
+
link are limited so that a guesser locks out only itself; the wrong passwords a link
|
|
5
|
+
takes from *everyone* are limited too, and past that the link's unlock is closed for a
|
|
6
|
+
while (a distributed guess). A host calls :meth:`AttemptGuard.allow` before it checks a
|
|
7
|
+
password and :meth:`AttemptGuard.failed` after a wrong one.
|
|
8
|
+
|
|
9
|
+
>>> t = [0.0]
|
|
10
|
+
>>> guard = AttemptGuard(
|
|
11
|
+
... client_limiter=SlidingWindowLimiter(max_per_window=5, clock=lambda: t[0]),
|
|
12
|
+
... link_limiter=SlidingWindowLimiter(max_per_window=1, clock=lambda: t[0]),
|
|
13
|
+
... lock_s=60, clock=lambda: t[0])
|
|
14
|
+
>>> guard.allow("link", "1.2.3.4")
|
|
15
|
+
True
|
|
16
|
+
>>> guard.failed("link"); guard.failed("link") # the second one locks the link
|
|
17
|
+
>>> guard.allow("link", "5.6.7.8")
|
|
18
|
+
False
|
|
19
|
+
>>> t[0] = 61.0; guard.allow("link", "5.6.7.8")
|
|
20
|
+
True
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
from caplinks.limits import (
|
|
30
|
+
DFLT_WINDOW_S,
|
|
31
|
+
RateLimiter,
|
|
32
|
+
SlidingWindowLimiter,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
#: tries one client gets on one link per window
|
|
36
|
+
DFLT_MAX_ATTEMPTS = 10
|
|
37
|
+
#: wrong passwords one link takes from everyone per window before it locks
|
|
38
|
+
DFLT_MAX_LINK_FAILURES = 100
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AttemptGuard:
|
|
42
|
+
"""Decide whether a password try may be made, and lock a link that is being guessed.
|
|
43
|
+
|
|
44
|
+
``client_limiter`` counts every try of one client on one link; ``link_limiter``
|
|
45
|
+
counts the wrong passwords a link takes from everyone and, past its limit, locks the
|
|
46
|
+
link for ``lock_s`` seconds. Both are anything with ``allow(key) -> bool`` (a Redis
|
|
47
|
+
one shares the count between workers); the lock itself is per process. ``clock``
|
|
48
|
+
returns a monotonic time in seconds (tests pin it; the default limiters use it too).
|
|
49
|
+
Keep ``lock_s`` at least the limiter's window, or the first failure after a lock ends
|
|
50
|
+
locks the link again at once.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
client_limiter: RateLimiter | None = None,
|
|
57
|
+
link_limiter: RateLimiter | None = None,
|
|
58
|
+
lock_s: float = DFLT_WINDOW_S,
|
|
59
|
+
clock: Callable[[], float] = time.monotonic,
|
|
60
|
+
):
|
|
61
|
+
self.client_limiter = client_limiter or SlidingWindowLimiter(
|
|
62
|
+
max_per_window=DFLT_MAX_ATTEMPTS, window_s=DFLT_WINDOW_S, clock=clock
|
|
63
|
+
)
|
|
64
|
+
self.link_limiter = link_limiter or SlidingWindowLimiter(
|
|
65
|
+
max_per_window=DFLT_MAX_LINK_FAILURES, window_s=DFLT_WINDOW_S, clock=clock
|
|
66
|
+
)
|
|
67
|
+
self.lock_s = lock_s
|
|
68
|
+
self._clock = clock
|
|
69
|
+
self._locked: dict[str, float] = {} # link -> when its lock ends
|
|
70
|
+
self._lock = threading.Lock()
|
|
71
|
+
|
|
72
|
+
def allow(self, link: str, client: str) -> bool:
|
|
73
|
+
"""Whether ``client`` may try a password on ``link`` now (counts the try)."""
|
|
74
|
+
with self._lock:
|
|
75
|
+
ends = self._locked.get(link, 0.0)
|
|
76
|
+
if ends > self._clock():
|
|
77
|
+
return False
|
|
78
|
+
if ends:
|
|
79
|
+
del self._locked[link] # expired: do not keep it
|
|
80
|
+
return self.client_limiter.allow(f"{link}:{client}")
|
|
81
|
+
|
|
82
|
+
def failed(self, link: str) -> None:
|
|
83
|
+
"""Record a wrong password on ``link``; lock the link if it takes too many."""
|
|
84
|
+
if self.link_limiter.allow(link):
|
|
85
|
+
return
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._locked[link] = self._clock() + self.lock_s
|
|
@@ -105,28 +105,32 @@ class CapabilityLinks:
|
|
|
105
105
|
*,
|
|
106
106
|
password: str | None = None,
|
|
107
107
|
expires=DFLT_EXPIRES,
|
|
108
|
+
token: str | None = None,
|
|
108
109
|
) -> dict:
|
|
109
110
|
"""Make a link carrying the host's ``fields``; return its record without the hash.
|
|
110
111
|
|
|
111
112
|
``expires`` is anything :func:`caplinks.expiry.parse_expires` reads, 7 days by
|
|
112
|
-
default (``"never"`` for none); ``password`` gates the link.
|
|
113
|
+
default (``"never"`` for none); ``password`` gates the link. ``token`` gates an
|
|
114
|
+
id that already exists (a host whose links must not change): it must look like
|
|
115
|
+
a token (:func:`check_token`) and be unused; leave it out for a fresh one. The
|
|
116
|
+
caller then owns the id's randomness: use 128 random bits, as :func:`new_token`.
|
|
113
117
|
"""
|
|
114
118
|
fields = dict(fields or {})
|
|
119
|
+
if token is not None:
|
|
120
|
+
check_token(token)
|
|
121
|
+
if token in self.store: # even a damaged record is not free to overwrite
|
|
122
|
+
raise ValueError("a link with this token already exists")
|
|
115
123
|
clash = sorted(set(fields) & {*RECORD_FIELDS, *_VIEW_FIELDS})
|
|
116
124
|
if clash:
|
|
117
125
|
raise ValueError(f"fields {clash} are the link's own; name yours otherwise")
|
|
118
|
-
|
|
119
|
-
raise ValueError(
|
|
120
|
-
f"a link password needs at least {self.min_password_len} characters "
|
|
121
|
-
"(anyone can try it); omit it for no password"
|
|
122
|
-
)
|
|
126
|
+
hashed = self._hash_password(password)
|
|
123
127
|
now = self.clock()
|
|
124
128
|
record = {
|
|
125
|
-
"token": new_token(),
|
|
129
|
+
"token": token or new_token(),
|
|
126
130
|
"created": now.isoformat(),
|
|
127
131
|
**fields,
|
|
128
132
|
"expires": parse_expires(expires, now=now),
|
|
129
|
-
"password":
|
|
133
|
+
"password": hashed,
|
|
130
134
|
"revoked": None,
|
|
131
135
|
}
|
|
132
136
|
self.store[record["token"]] = record
|
|
@@ -169,6 +173,47 @@ class CapabilityLinks:
|
|
|
169
173
|
out.append(rec)
|
|
170
174
|
return sorted(out, key=lambda r: r["created"], reverse=True)
|
|
171
175
|
|
|
176
|
+
def set_password(self, token: str, password: str | None) -> dict:
|
|
177
|
+
"""Give an unrevoked link a new password, or ``None`` to open it.
|
|
178
|
+
|
|
179
|
+
A new password ends every open unlock at once. A revoked link stays revoked
|
|
180
|
+
(``KeyError``).
|
|
181
|
+
"""
|
|
182
|
+
return self._update(token, password=self._hash_password(password))
|
|
183
|
+
|
|
184
|
+
def set_expires(self, token: str, expires) -> dict:
|
|
185
|
+
"""Move an unrevoked link's expiry (anything :func:`parse_expires` reads;
|
|
186
|
+
``"never"`` for none). An expired link may be given a later one; a revoked link
|
|
187
|
+
stays revoked (``KeyError``).
|
|
188
|
+
"""
|
|
189
|
+
return self._update(token, expires=parse_expires(expires, now=self.clock()))
|
|
190
|
+
|
|
191
|
+
def _update(self, token: str, **changes) -> dict:
|
|
192
|
+
# The store has no compare-and-set: the record is read just before the write (the
|
|
193
|
+
# slow hashing is done by the caller) so a revoke that landed meanwhile is not
|
|
194
|
+
# undone. Hosts with several workers writing the
|
|
195
|
+
# same link still need to serialise writes per token.
|
|
196
|
+
rec = self._unrevoked(token)
|
|
197
|
+
if rec is None:
|
|
198
|
+
raise KeyError(token)
|
|
199
|
+
rec.update(changes)
|
|
200
|
+
self.store[token] = rec
|
|
201
|
+
return public_record(rec)
|
|
202
|
+
|
|
203
|
+
def _unrevoked(self, token: str) -> dict | None:
|
|
204
|
+
rec = self.record(token)
|
|
205
|
+
return None if rec is None or rec.get("revoked") else rec
|
|
206
|
+
|
|
207
|
+
def _hash_password(self, password: str | None) -> str | None:
|
|
208
|
+
if password is None:
|
|
209
|
+
return None
|
|
210
|
+
if len(password) < self.min_password_len:
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f"a link password needs at least {self.min_password_len} characters "
|
|
213
|
+
"(anyone can try it); omit it for no password"
|
|
214
|
+
)
|
|
215
|
+
return self.hasher.hash(password)
|
|
216
|
+
|
|
172
217
|
def revoke(self, token: str) -> dict:
|
|
173
218
|
"""End a link now (idempotent). The record stays, as a tombstone."""
|
|
174
219
|
rec = self.record(token)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Link records as JSON text in a store of text: a mapping from token to dict.
|
|
2
|
+
|
|
3
|
+
:class:`CapabilityLinks` keeps its records in any ``MutableMapping`` of token to dict.
|
|
4
|
+
:class:`JsonRecords` makes one from a mapping of *text* (``<token>.json`` to the JSON of
|
|
5
|
+
the record), and :class:`FileTexts` is that text mapping over a local folder, written
|
|
6
|
+
atomically and readable by the owner only (a record holds a password hash). A host whose
|
|
7
|
+
text lives elsewhere (ssh, S3) passes its own mapping of text.
|
|
8
|
+
|
|
9
|
+
>>> import tempfile
|
|
10
|
+
>>> from caplinks import CapabilityLinks
|
|
11
|
+
>>> links = CapabilityLinks(JsonRecords(FileTexts(tempfile.mkdtemp())))
|
|
12
|
+
>>> token = links.create({"doc": "d1"})["token"]
|
|
13
|
+
>>> links.status(token)[1]["doc"]
|
|
14
|
+
'd1'
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import tempfile
|
|
22
|
+
from collections.abc import Callable, Iterator, MutableMapping
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from caplinks.links import TOKEN_RE, check_token
|
|
26
|
+
|
|
27
|
+
SUFFIX = ".json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JsonRecords(MutableMapping):
|
|
31
|
+
"""``{token: dict}`` over a mapping of text, ``<token>.json`` to its JSON.
|
|
32
|
+
|
|
33
|
+
A record that cannot be read, is not a JSON object, or fails ``valid`` raises
|
|
34
|
+
``KeyError``: the link is then missing (every failure closes the link). ``valid``
|
|
35
|
+
takes the host's own check of its fields. ``deletable=False`` refuses ``del``: a
|
|
36
|
+
host that keeps a revoked link as a tombstone says so. ``indent`` pretty-prints.
|
|
37
|
+
|
|
38
|
+
The contract of ``texts``: reading a missing name raises ``KeyError``.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
texts: MutableMapping[str, str],
|
|
44
|
+
*,
|
|
45
|
+
valid: Callable[[dict], bool] | None = None,
|
|
46
|
+
deletable: bool = True,
|
|
47
|
+
indent: int | None = None,
|
|
48
|
+
):
|
|
49
|
+
self.texts = texts
|
|
50
|
+
self.valid = valid
|
|
51
|
+
self.deletable = deletable
|
|
52
|
+
self.indent = indent
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _name(token: str) -> str:
|
|
56
|
+
try:
|
|
57
|
+
return check_token(token) + SUFFIX
|
|
58
|
+
except ValueError:
|
|
59
|
+
raise KeyError(token) from None
|
|
60
|
+
|
|
61
|
+
def __getitem__(self, token: str) -> dict:
|
|
62
|
+
try:
|
|
63
|
+
rec = json.loads(self.texts[self._name(token)])
|
|
64
|
+
except (ValueError, KeyError, OSError, RuntimeError, TypeError):
|
|
65
|
+
raise KeyError(token) from None
|
|
66
|
+
if not isinstance(rec, dict) or (self.valid and not self.valid(rec)):
|
|
67
|
+
raise KeyError(token)
|
|
68
|
+
return rec
|
|
69
|
+
|
|
70
|
+
def __setitem__(self, token: str, record: dict) -> None:
|
|
71
|
+
self.texts[self._name(token)] = json.dumps(record, indent=self.indent)
|
|
72
|
+
|
|
73
|
+
def __delitem__(self, token: str) -> None:
|
|
74
|
+
if not self.deletable:
|
|
75
|
+
raise TypeError("link records are never deleted; revoke the link instead")
|
|
76
|
+
del self.texts[self._name(token)]
|
|
77
|
+
|
|
78
|
+
def __iter__(self) -> Iterator[str]:
|
|
79
|
+
for name in self.texts:
|
|
80
|
+
token = name[: -len(SUFFIX)]
|
|
81
|
+
if name.endswith(SUFFIX) and TOKEN_RE.match(token):
|
|
82
|
+
yield token
|
|
83
|
+
|
|
84
|
+
def __len__(self) -> int:
|
|
85
|
+
return sum(1 for _ in self)
|
|
86
|
+
|
|
87
|
+
def __contains__(self, token) -> bool:
|
|
88
|
+
try:
|
|
89
|
+
return self._name(token) in self.texts
|
|
90
|
+
except (KeyError, TypeError):
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FileTexts(MutableMapping):
|
|
95
|
+
"""``{name: text}`` over the files of one local folder (created on first write).
|
|
96
|
+
|
|
97
|
+
Names are plain file names (no path separator, no leading dot, no NUL); a name whose
|
|
98
|
+
file is a symlink out of the folder is missing. The folder is private (mode 0700:
|
|
99
|
+
the names are link tokens) and a write goes to a hidden temporary file (mode 0600)
|
|
100
|
+
that replaces the target, so a reader never sees half a record.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, folder: str | os.PathLike):
|
|
104
|
+
self.folder = Path(folder).expanduser()
|
|
105
|
+
|
|
106
|
+
def _path(self, name: str) -> Path:
|
|
107
|
+
if (
|
|
108
|
+
not isinstance(name, str)
|
|
109
|
+
or not name
|
|
110
|
+
or name.startswith(".")
|
|
111
|
+
or any(c in name for c in "/\\\0")
|
|
112
|
+
):
|
|
113
|
+
raise KeyError(name)
|
|
114
|
+
path = self.folder / name
|
|
115
|
+
if path.is_symlink():
|
|
116
|
+
raise KeyError(name)
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
def __getitem__(self, name: str) -> str:
|
|
120
|
+
try:
|
|
121
|
+
return self._path(name).read_text(encoding="utf-8")
|
|
122
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
123
|
+
raise KeyError(name) from None
|
|
124
|
+
|
|
125
|
+
def __setitem__(self, name: str, text: str) -> None:
|
|
126
|
+
path = self._path(name)
|
|
127
|
+
self.folder.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
128
|
+
fd, tmp = tempfile.mkstemp(dir=self.folder, prefix=".", suffix=".tmp") # 0600
|
|
129
|
+
try:
|
|
130
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
131
|
+
f.write(text)
|
|
132
|
+
f.flush()
|
|
133
|
+
os.fsync(f.fileno())
|
|
134
|
+
os.replace(tmp, path)
|
|
135
|
+
except BaseException:
|
|
136
|
+
Path(tmp).unlink(missing_ok=True)
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
def __delitem__(self, name: str) -> None:
|
|
140
|
+
try:
|
|
141
|
+
self._path(name).unlink()
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
raise KeyError(name) from None
|
|
144
|
+
|
|
145
|
+
def __iter__(self) -> Iterator[str]:
|
|
146
|
+
if self.folder.is_dir():
|
|
147
|
+
for p in self.folder.iterdir():
|
|
148
|
+
if p.is_file() and not p.is_symlink() and not p.name.startswith("."):
|
|
149
|
+
yield p.name
|
|
150
|
+
|
|
151
|
+
def __len__(self) -> int:
|
|
152
|
+
return sum(1 for _ in self)
|
|
153
|
+
|
|
154
|
+
def __contains__(self, name) -> bool:
|
|
155
|
+
try:
|
|
156
|
+
return self._path(name).is_file()
|
|
157
|
+
except (KeyError, TypeError):
|
|
158
|
+
return False
|
|
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
|
|
6
6
|
|
|
7
7
|
[project]
|
|
8
8
|
name = "caplinks"
|
|
9
|
-
version = "0.0.
|
|
9
|
+
version = "0.0.4"
|
|
10
10
|
description = "Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only."
|
|
11
11
|
readme = "README.md"
|
|
12
12
|
requires-python = ">=3.10"
|
|
@@ -266,3 +266,223 @@ def test_parse_expires_far_future_is_a_value_error(value):
|
|
|
266
266
|
def test_limiter_refuses_settings_that_disable_it(kw):
|
|
267
267
|
with pytest.raises(ValueError):
|
|
268
268
|
SlidingWindowLimiter(**kw)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_create_gates_an_existing_id():
|
|
272
|
+
links = CapabilityLinks()
|
|
273
|
+
tid = "A" * 22
|
|
274
|
+
rec = links.create({"x": 1}, password=PW, expires="never", token=tid)
|
|
275
|
+
assert rec["token"] == tid and rec["protected"]
|
|
276
|
+
assert links.status(tid)[0] == "ok"
|
|
277
|
+
with pytest.raises(ValueError, match="already exists"):
|
|
278
|
+
links.create(token=tid)
|
|
279
|
+
with pytest.raises(ValueError, match="not a link token"):
|
|
280
|
+
links.create(token="../x")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_set_password_ends_open_unlocks():
|
|
284
|
+
links = CapabilityLinks()
|
|
285
|
+
tok = links.create(password=PW)["token"]
|
|
286
|
+
cookie = links.unlock_value(links.record(tok))
|
|
287
|
+
assert links.unlocked(links.record(tok), cookie, max_age_s=3600)
|
|
288
|
+
assert links.set_password(tok, "another pass")["protected"]
|
|
289
|
+
assert not links.unlocked(links.record(tok), cookie, max_age_s=3600)
|
|
290
|
+
assert not links.set_password(tok, None)["protected"]
|
|
291
|
+
with pytest.raises(ValueError):
|
|
292
|
+
links.set_password(tok, "short")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def test_set_expires_revives_an_expired_link_but_not_a_revoked_one():
|
|
296
|
+
clock = Clock()
|
|
297
|
+
links = CapabilityLinks(clock=clock)
|
|
298
|
+
tok = links.create(expires="1d")["token"]
|
|
299
|
+
clock.t = T0 + timedelta(days=2)
|
|
300
|
+
assert links.status(tok)[0] == "expired"
|
|
301
|
+
links.set_expires(tok, "3d")
|
|
302
|
+
assert links.status(tok)[0] == "ok"
|
|
303
|
+
assert links.set_expires(tok, "never")["expires"] is None
|
|
304
|
+
links.revoke(tok)
|
|
305
|
+
for call in (lambda: links.set_expires(tok, "3d"), lambda: links.set_password(tok, PW)):
|
|
306
|
+
with pytest.raises(KeyError):
|
|
307
|
+
call()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_set_password_new_one_opens_old_one_does_not():
|
|
311
|
+
links = CapabilityLinks(hasher=ScryptHasher(n=2**4))
|
|
312
|
+
tok = links.create(password=PW)["token"]
|
|
313
|
+
links.set_password(tok, "another pass")
|
|
314
|
+
rec = links.record(tok)
|
|
315
|
+
assert links.check_password(rec, "another pass") and not links.check_password(rec, PW)
|
|
316
|
+
links.set_password(tok, None)
|
|
317
|
+
assert links.unlocked(links.record(tok), None, max_age_s=60)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_setters_refuse_the_past_and_the_missing():
|
|
321
|
+
links = CapabilityLinks()
|
|
322
|
+
tok = links.create()["token"]
|
|
323
|
+
with pytest.raises(ValueError, match="past"):
|
|
324
|
+
links.set_expires(tok, "2000-01-01")
|
|
325
|
+
for call in (lambda: links.set_expires("B" * 22, "1d"), lambda: links.set_password("B" * 22, PW)):
|
|
326
|
+
with pytest.raises(KeyError):
|
|
327
|
+
call()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def test_a_revoked_id_cannot_be_gated_again():
|
|
331
|
+
links = CapabilityLinks()
|
|
332
|
+
tid = "C" * 22
|
|
333
|
+
links.create(token=tid)
|
|
334
|
+
links.revoke(tid)
|
|
335
|
+
with pytest.raises(ValueError, match="already exists"):
|
|
336
|
+
links.create(token=tid)
|
|
337
|
+
assert links.status(tid)[0] == "missing"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_create_token_does_not_overwrite_a_damaged_record():
|
|
341
|
+
store = {"D" * 22: {"junk": 1}}
|
|
342
|
+
links = CapabilityLinks(store)
|
|
343
|
+
with pytest.raises(ValueError, match="already exists"):
|
|
344
|
+
links.create(token="D" * 22)
|
|
345
|
+
assert store["D" * 22] == {"junk": 1}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# -- AttemptGuard, JsonRecords, FileTexts --
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_guard_limits_one_client_without_locking_the_link():
|
|
353
|
+
from caplinks import AttemptGuard
|
|
354
|
+
|
|
355
|
+
guard = AttemptGuard(client_limiter=SlidingWindowLimiter(max_per_window=2))
|
|
356
|
+
assert [guard.allow("L", "a") for _ in range(3)] == [True, True, False]
|
|
357
|
+
assert guard.allow("L", "b") # another client is not affected
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_guard_locks_a_link_taking_many_failures_then_reopens():
|
|
361
|
+
from caplinks import AttemptGuard
|
|
362
|
+
|
|
363
|
+
t = [0.0]
|
|
364
|
+
guard = AttemptGuard(
|
|
365
|
+
link_limiter=SlidingWindowLimiter(max_per_window=2, clock=lambda: t[0]),
|
|
366
|
+
lock_s=60,
|
|
367
|
+
clock=lambda: t[0],
|
|
368
|
+
)
|
|
369
|
+
for _ in range(2):
|
|
370
|
+
guard.failed("L")
|
|
371
|
+
assert guard.allow("L", "a") # at the limit, not past it
|
|
372
|
+
guard.failed("L")
|
|
373
|
+
assert not guard.allow("L", "a") and not guard.allow("L", "b")
|
|
374
|
+
assert guard.allow("other", "a")
|
|
375
|
+
t[0] = 60.0
|
|
376
|
+
assert guard.allow("L", "a")
|
|
377
|
+
assert "L" not in guard._locked # pruned
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_json_records_roundtrip_and_fail_closed(tmp_path):
|
|
381
|
+
from caplinks import FileTexts, JsonRecords
|
|
382
|
+
|
|
383
|
+
recs = JsonRecords(FileTexts(tmp_path / "r"), valid=lambda r: "scope" in r)
|
|
384
|
+
links = CapabilityLinks(recs)
|
|
385
|
+
tok = links.create({"scope": 1}, password=PW)["token"]
|
|
386
|
+
assert links.status(tok)[0] == "ok" and tok in recs and list(recs) == [tok]
|
|
387
|
+
(tmp_path / "r" / f"{tok}.json").write_text("{not json")
|
|
388
|
+
assert links.status(tok)[0] == "missing" and tok in recs
|
|
389
|
+
(tmp_path / "r" / f"{tok}.json").write_text('{"token": "%s"}' % tok)
|
|
390
|
+
assert links.status(tok)[0] == "missing" # fails the host's check
|
|
391
|
+
assert "../x" not in recs
|
|
392
|
+
with pytest.raises(KeyError):
|
|
393
|
+
recs["../x"]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_json_records_can_refuse_deletes(tmp_path):
|
|
397
|
+
from caplinks import FileTexts, JsonRecords
|
|
398
|
+
|
|
399
|
+
recs = JsonRecords(FileTexts(tmp_path), deletable=False)
|
|
400
|
+
tok = CapabilityLinks(recs).create()["token"]
|
|
401
|
+
with pytest.raises(TypeError):
|
|
402
|
+
del recs[tok]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def test_file_texts_are_private_atomic_and_confined(tmp_path):
|
|
406
|
+
import os
|
|
407
|
+
|
|
408
|
+
from caplinks import FileTexts
|
|
409
|
+
|
|
410
|
+
texts = FileTexts(tmp_path / "t")
|
|
411
|
+
texts["a.json"] = "one"
|
|
412
|
+
texts["a.json"] = "two"
|
|
413
|
+
assert texts["a.json"] == "two" and list(texts) == ["a.json"]
|
|
414
|
+
assert os.stat(tmp_path / "t" / "a.json").st_mode & 0o777 == 0o600
|
|
415
|
+
assert os.stat(tmp_path / "t").st_mode & 0o777 == 0o700
|
|
416
|
+
assert sorted(p.name for p in (tmp_path / "t").iterdir()) == ["a.json"] # no temp
|
|
417
|
+
for bad in ("../a", ".hidden", "x/y", "", "a\0b"):
|
|
418
|
+
assert bad not in texts
|
|
419
|
+
with pytest.raises(KeyError):
|
|
420
|
+
texts[bad]
|
|
421
|
+
del texts["a.json"]
|
|
422
|
+
with pytest.raises(KeyError):
|
|
423
|
+
del texts["a.json"]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def test_a_failed_write_leaves_the_old_text_and_no_temp_file(tmp_path):
|
|
427
|
+
from caplinks import FileTexts
|
|
428
|
+
|
|
429
|
+
texts = FileTexts(tmp_path)
|
|
430
|
+
texts["a.json"] = "old"
|
|
431
|
+
with pytest.raises(TypeError):
|
|
432
|
+
texts["a.json"] = 5 # not text: the write fails midway
|
|
433
|
+
assert texts["a.json"] == "old"
|
|
434
|
+
assert [p.name for p in tmp_path.iterdir()] == ["a.json"]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_stray_files_and_symlinks_do_not_break_listing_or_escape(tmp_path):
|
|
438
|
+
from caplinks import FileTexts, JsonRecords
|
|
439
|
+
|
|
440
|
+
outside = tmp_path / "secret.json"
|
|
441
|
+
outside.write_text("{}")
|
|
442
|
+
folder = tmp_path / "r"
|
|
443
|
+
recs = JsonRecords(FileTexts(folder))
|
|
444
|
+
tok = CapabilityLinks(recs).create()["token"]
|
|
445
|
+
(folder / ".DS_Store").write_text("x")
|
|
446
|
+
(folder / "README.json").write_text("{}")
|
|
447
|
+
(folder / ("S" * 22 + ".json")).symlink_to(outside)
|
|
448
|
+
assert list(recs) == [tok]
|
|
449
|
+
assert set(dict(recs.items())) == {tok}
|
|
450
|
+
assert "S" * 22 not in recs
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_texts_returning_none_is_a_missing_link(tmp_path):
|
|
454
|
+
from caplinks import JsonRecords
|
|
455
|
+
|
|
456
|
+
class Nones(dict):
|
|
457
|
+
def __getitem__(self, k):
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
recs = JsonRecords(Nones())
|
|
461
|
+
with pytest.raises(KeyError):
|
|
462
|
+
recs["N" * 22]
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def test_guard_default_limiters_follow_the_pinned_clock():
|
|
466
|
+
from caplinks import AttemptGuard
|
|
467
|
+
|
|
468
|
+
t = [0.0]
|
|
469
|
+
guard = AttemptGuard(clock=lambda: t[0])
|
|
470
|
+
assert all(guard.allow("L", "a") for _ in range(10)) and not guard.allow("L", "a")
|
|
471
|
+
t[0] = 301.0
|
|
472
|
+
assert guard.allow("L", "a")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_guard_is_safe_under_threads():
|
|
476
|
+
import threading
|
|
477
|
+
|
|
478
|
+
from caplinks import AttemptGuard
|
|
479
|
+
|
|
480
|
+
guard = AttemptGuard(client_limiter=SlidingWindowLimiter(max_per_window=50))
|
|
481
|
+
results = []
|
|
482
|
+
threads = [
|
|
483
|
+
threading.Thread(target=lambda: results.append(guard.allow("L", "a")))
|
|
484
|
+
for _ in range(200)
|
|
485
|
+
]
|
|
486
|
+
[th.start() for th in threads]
|
|
487
|
+
[th.join() for th in threads]
|
|
488
|
+
assert sum(results) == 50
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|