caplinks 0.0.2__tar.gz → 0.0.3__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.3}/PKG-INFO +2 -1
- {caplinks-0.0.2 → caplinks-0.0.3}/README.md +1 -0
- {caplinks-0.0.2 → caplinks-0.0.3}/caplinks/links.py +53 -8
- {caplinks-0.0.2 → caplinks-0.0.3}/pyproject.toml +1 -1
- {caplinks-0.0.2 → caplinks-0.0.3}/tests/test_caplinks.py +78 -0
- {caplinks-0.0.2 → caplinks-0.0.3}/.gitignore +0 -0
- {caplinks-0.0.2 → caplinks-0.0.3}/LICENSE +0 -0
- {caplinks-0.0.2 → caplinks-0.0.3}/caplinks/__init__.py +0 -0
- {caplinks-0.0.2 → caplinks-0.0.3}/caplinks/expiry.py +0 -0
- {caplinks-0.0.2 → caplinks-0.0.3}/caplinks/limits.py +0 -0
- {caplinks-0.0.2 → caplinks-0.0.3}/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.3
|
|
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,7 @@ 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).
|
|
47
48
|
- `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
|
|
48
49
|
- `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
50
|
- `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,7 @@ 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).
|
|
26
27
|
- `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
|
|
27
28
|
- `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
29
|
- `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.
|
|
@@ -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)
|
|
@@ -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.3"
|
|
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,81 @@ 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
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|