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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: caplinks
3
- Version: 0.0.2
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
- if password is not None and len(password) < self.min_password_len:
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": self.hasher.hash(password) if password else None,
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.2"
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