caplinks 0.0.2__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.
caplinks/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """Capability links: an unguessable token, an optional password gate, expiry, attempt limits.
2
+
3
+ The parts every app needs to hand someone a link to one thing, with no account: the
4
+ token is the permission, a password may gate it, it expires, it can be revoked, and
5
+ password attempts are limited. Standard library only; no web framework. What a link
6
+ reaches (its scope) and how it is served stay with the app.
7
+
8
+ >>> links = CapabilityLinks() # records in a dict; pass any MutableMapping to persist
9
+ >>> rec = links.create({"doc": "report-1"}, password="open sesame", expires="7d")
10
+ >>> state, stored = links.status(rec["token"])
11
+ >>> state, links.check_password(stored, "open sesame")
12
+ ('ok', True)
13
+ >>> cookie = links.unlock_value(stored)
14
+ >>> links.unlocked(stored, cookie, max_age_s=3600)
15
+ True
16
+ """
17
+
18
+ from caplinks.expiry import is_expired, now_utc, parse_expires
19
+ from caplinks.limits import RateLimiter, SlidingWindowLimiter
20
+ from caplinks.links import (
21
+ DFLT_EXPIRES,
22
+ MIN_PASSWORD_LEN,
23
+ RECORD_FIELDS,
24
+ CapabilityLinks,
25
+ check_token,
26
+ new_token,
27
+ public_record,
28
+ )
29
+ from caplinks.passwords import PasswordHasher, ScryptHasher
30
+
31
+ __all__ = [
32
+ "CapabilityLinks",
33
+ "DFLT_EXPIRES",
34
+ "MIN_PASSWORD_LEN",
35
+ "PasswordHasher",
36
+ "RECORD_FIELDS",
37
+ "RateLimiter",
38
+ "ScryptHasher",
39
+ "SlidingWindowLimiter",
40
+ "check_token",
41
+ "is_expired",
42
+ "new_token",
43
+ "now_utc",
44
+ "parse_expires",
45
+ "public_record",
46
+ ]
caplinks/expiry.py ADDED
@@ -0,0 +1,107 @@
1
+ """When a link stops working: reading an expiry the way a person writes one.
2
+
3
+ An expiry is stored as an ISO UTC timestamp (or ``None`` for never). :func:`parse_expires`
4
+ reads what a person types (``7d``, ``2026-11-01``, an ISO timestamp, ``never``);
5
+ :func:`is_expired` checks a record against the clock and fails closed on a value it
6
+ cannot read.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from datetime import datetime, timedelta, timezone
13
+
14
+ #: duration suffixes accepted by :func:`parse_expires` (``30m``, ``12h``, ``7d``, ``2w``)
15
+ DURATION_UNITS = {"m": "minutes", "h": "hours", "d": "days", "w": "weeks"}
16
+ _DURATION_RE = re.compile(r"^(\d+)\s*([mhdw])$", re.I)
17
+ _DATE_RE = re.compile(r"\d{4}-\d{2}-\d{2}")
18
+ #: words that mean "no expiry"
19
+ NEVER = frozenset({"never", "none", "no", ""})
20
+
21
+
22
+ def now_utc() -> datetime:
23
+ """The current time, UTC, second precision (the clock every default reads)."""
24
+ return datetime.now(timezone.utc).replace(microsecond=0)
25
+
26
+
27
+ def _as_utc(when: datetime) -> datetime:
28
+ when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
29
+ return when.astimezone(timezone.utc).replace(microsecond=0)
30
+
31
+
32
+ def parse_expires(value, *, now: datetime | None = None) -> str | None:
33
+ """An expiry as an ISO UTC timestamp, or ``None`` for never.
34
+
35
+ ``None``, ``""`` and ``"never"`` mean never. Accepts a duration (``30m``, ``12h``,
36
+ ``7d``, ``2w``), a date (``2026-11-01``: the END of that day, UTC, so the link works
37
+ through the day named), an ISO timestamp (a naive one is UTC), or a ``datetime``.
38
+ A moment already past is refused.
39
+
40
+ >>> t0 = datetime(2026, 1, 1, tzinfo=timezone.utc)
41
+ >>> parse_expires("2d", now=t0)
42
+ '2026-01-03T00:00:00+00:00'
43
+ >>> parse_expires("2026-01-05", now=t0)
44
+ '2026-01-05T23:59:59+00:00'
45
+ >>> parse_expires("never") is None
46
+ True
47
+ """
48
+ now = now or now_utc()
49
+ if value is None or (isinstance(value, str) and value.strip().lower() in NEVER):
50
+ return None
51
+ if isinstance(value, datetime):
52
+ when = value
53
+ else:
54
+ s = str(value).strip()
55
+ m = _DURATION_RE.match(s)
56
+ if m:
57
+ n, unit = int(m.group(1)), DURATION_UNITS[m.group(2).lower()]
58
+ try:
59
+ when = now + timedelta(**{unit: n})
60
+ except OverflowError:
61
+ raise ValueError(f"expiry {value!r} is too far away") from None
62
+ elif _DATE_RE.fullmatch(s):
63
+ when = datetime.fromisoformat(s).replace(
64
+ hour=23, minute=59, second=59, tzinfo=timezone.utc
65
+ )
66
+ else:
67
+ try:
68
+ when = datetime.fromisoformat(s.replace("Z", "+00:00"))
69
+ except ValueError:
70
+ raise ValueError(
71
+ f"not an expiry: {value!r}; give a duration (7d, 12h), a date "
72
+ "(2026-11-01) or an ISO timestamp"
73
+ ) from None
74
+ try:
75
+ when = _as_utc(when)
76
+ except OverflowError:
77
+ raise ValueError(f"expiry {value!r} is too far away") from None
78
+ if when <= now:
79
+ raise ValueError(f"expiry {when.isoformat()} is already past")
80
+ return when.isoformat()
81
+
82
+
83
+ def expires_at(record: dict) -> datetime | None:
84
+ """The record's expiry as a UTC ``datetime``, ``None`` for never.
85
+
86
+ Raises ``ValueError`` on a value that is not a timestamp.
87
+ """
88
+ exp = record.get("expires")
89
+ if exp is None:
90
+ return None
91
+ try:
92
+ return _as_utc(datetime.fromisoformat(exp))
93
+ except (TypeError, OverflowError):
94
+ raise ValueError(f"not a timestamp: {exp!r}") from None
95
+
96
+
97
+ def is_expired(record: dict, *, now: datetime | None = None) -> bool:
98
+ """True when the record has an expiry and it is reached (a bad one counts as reached).
99
+
100
+ >>> is_expired({"expires": None}), is_expired({"expires": "garbage"})
101
+ (False, True)
102
+ """
103
+ try:
104
+ when = expires_at(record)
105
+ except ValueError:
106
+ return True # fail closed
107
+ return when is not None and (now or now_utc()) >= when
caplinks/limits.py ADDED
@@ -0,0 +1,74 @@
1
+ """Limiting password attempts: the ``allow(key) -> bool`` protocol and an in-memory default.
2
+
3
+ A host decides what to count (one client on one link, every wrong password a link
4
+ takes, ...) and keys the limiter accordingly. The protocol is the shape of
5
+ ``heed.security``'s limiters, so its ``RedisRateLimiter`` (one count shared by every
6
+ worker) fits wherever a :class:`RateLimiter` is asked for.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from collections import OrderedDict, deque
14
+ from typing import Callable, Protocol, runtime_checkable
15
+
16
+ #: attempts one key may make in each window, by default
17
+ DFLT_MAX_PER_WINDOW = 10
18
+ DFLT_WINDOW_S = 300.0
19
+ #: keys tracked at most, so a flood of addresses cannot exhaust memory
20
+ DFLT_MAX_KEYS = 50_000
21
+
22
+
23
+ @runtime_checkable
24
+ class RateLimiter(Protocol):
25
+ """Decide whether one more attempt keyed by ``key`` is allowed now."""
26
+
27
+ def allow(self, key: str) -> bool: ...
28
+
29
+
30
+ class SlidingWindowLimiter:
31
+ """At most ``max_per_window`` hits per key per window; in memory, per process.
32
+
33
+ Each worker counts alone (two workers allow twice the attempts); pass a shared
34
+ limiter when that matters. The least recently seen keys are dropped past
35
+ ``max_keys``.
36
+
37
+ >>> t = [0.0]
38
+ >>> lim = SlidingWindowLimiter(max_per_window=2, window_s=10, clock=lambda: t[0])
39
+ >>> [lim.allow("a"), lim.allow("a"), lim.allow("a"), lim.allow("b")]
40
+ [True, True, False, True]
41
+ >>> t[0] = 10.0; lim.allow("a")
42
+ True
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ max_per_window: int = DFLT_MAX_PER_WINDOW,
49
+ window_s: float = DFLT_WINDOW_S,
50
+ max_keys: int = DFLT_MAX_KEYS,
51
+ clock: Callable[[], float] = time.monotonic,
52
+ ):
53
+ if max_per_window < 1 or max_keys < 1:
54
+ raise ValueError("max_per_window and max_keys must be at least 1")
55
+ self.max_per_window = max_per_window
56
+ self.window_s = window_s
57
+ self.max_keys = max_keys
58
+ self._clock = clock
59
+ self._hits: OrderedDict[str, deque] = OrderedDict()
60
+ self._lock = threading.Lock() # web hosts call limiters from a threadpool
61
+
62
+ def allow(self, key: str) -> bool:
63
+ with self._lock:
64
+ now = self._clock()
65
+ hits = self._hits.setdefault(key, deque())
66
+ self._hits.move_to_end(key)
67
+ while len(self._hits) > self.max_keys:
68
+ self._hits.popitem(last=False)
69
+ while hits and hits[0] <= now - self.window_s:
70
+ hits.popleft()
71
+ if len(hits) >= self.max_per_window:
72
+ return False
73
+ hits.append(now)
74
+ return True
caplinks/links.py ADDED
@@ -0,0 +1,244 @@
1
+ """Capability links: a record behind an unguessable token, with an optional password gate.
2
+
3
+ Knowing the token is the permission. A record is a plain dict, stored in any
4
+ ``MutableMapping`` keyed by token; the generic fields are :data:`RECORD_FIELDS`, and a
5
+ host puts what the link reaches (its scope) in fields of its own beside them::
6
+
7
+ {"token": "<22 url-safe chars, 128 random bits>",
8
+ "created": "<iso>", "expires": "<iso>" | None,
9
+ "password": "scrypt$..." | None, # a hash; the password is never stored
10
+ "revoked": "<iso>" | None, # a tombstone: a revoked link is missing
11
+ ...the host's fields...}
12
+
13
+ A correct password earns an unlock value (a cookie, for a web host): an HMAC keyed by
14
+ the stored hash, so a new password, or a revoke, which clears the hash, ends every open
15
+ session at once.
16
+
17
+ The store contract: a store that cannot vouch for a record (unreadable, damaged, or
18
+ missing the host's own fields) raises ``KeyError``, ``ValueError`` or ``OSError`` when
19
+ it is read, and the link is then missing. A record whose generic fields have the wrong
20
+ types is missing too: every failure closes the link.
21
+
22
+ >>> links = CapabilityLinks()
23
+ >>> rec = links.create({"what": "report-1"}, password="open sesame", expires="7d")
24
+ >>> links.status(rec["token"])[0], rec["protected"], "password" in rec
25
+ ('ok', True, False)
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import hmac
32
+ import re
33
+ import secrets
34
+ from collections.abc import Callable, Mapping, MutableMapping
35
+ from datetime import datetime
36
+
37
+ from caplinks.expiry import expires_at, is_expired, now_utc, parse_expires
38
+ from caplinks.passwords import PasswordHasher, ScryptHasher
39
+
40
+ TOKEN_BYTES = 16 # 128 bits
41
+ TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{20,64}$")
42
+ #: the fields every record has; a host's own fields may not reuse these names
43
+ RECORD_FIELDS = ("token", "created", "expires", "password", "revoked")
44
+ #: fields the owner's view of a record adds (:func:`public_record`, ``list``)
45
+ _VIEW_FIELDS = ("protected", "active")
46
+ #: a link lasts this long unless told otherwise (``"never"`` for no expiry)
47
+ DFLT_EXPIRES = "7d"
48
+ #: the shortest link password accepted (a gate tried from anywhere must not be guessable)
49
+ MIN_PASSWORD_LEN = 8
50
+ #: an unlock value issued this far in the future still counts (clock skew between workers)
51
+ _UNLOCK_SKEW_S = 60
52
+
53
+
54
+ def new_token() -> str:
55
+ """A fresh link token: 128 random bits, url-safe."""
56
+ return secrets.token_urlsafe(TOKEN_BYTES)
57
+
58
+
59
+ def check_token(token: str) -> str:
60
+ """Return ``token``; raise ``ValueError`` on anything that could not be one.
61
+
62
+ >>> check_token("../../etc/passwd")
63
+ Traceback (most recent call last):
64
+ ...
65
+ ValueError: not a link token
66
+ """
67
+ if not isinstance(token, str) or not TOKEN_RE.match(token):
68
+ raise ValueError("not a link token")
69
+ return token
70
+
71
+
72
+ def public_record(record: dict) -> dict:
73
+ """A record without its password hash; ``protected`` says whether it has one."""
74
+ out = {k: v for k, v in record.items() if k != "password"}
75
+ out["protected"] = bool(record.get("password"))
76
+ return out
77
+
78
+
79
+ class CapabilityLinks:
80
+ """Create, read, list and revoke capability links, and gate them on a password.
81
+
82
+ ``store`` maps token to record (a ``dict`` by default, in process); ``hasher``
83
+ hashes link passwords (stdlib scrypt by default); ``clock`` returns the current UTC
84
+ ``datetime`` (tests pin it); ``min_password_len`` is the shortest password accepted.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ store: MutableMapping[str, dict] | None = None,
90
+ *,
91
+ hasher: PasswordHasher | None = None,
92
+ clock: Callable[[], datetime] = now_utc,
93
+ min_password_len: int = MIN_PASSWORD_LEN,
94
+ ):
95
+ self.store = {} if store is None else store
96
+ self.hasher = hasher or ScryptHasher()
97
+ self.clock = clock
98
+ self.min_password_len = min_password_len
99
+
100
+ # -- create / read / list / revoke --
101
+
102
+ def create(
103
+ self,
104
+ fields: Mapping | None = None,
105
+ *,
106
+ password: str | None = None,
107
+ expires=DFLT_EXPIRES,
108
+ ) -> dict:
109
+ """Make a link carrying the host's ``fields``; return its record without the hash.
110
+
111
+ ``expires`` is anything :func:`caplinks.expiry.parse_expires` reads, 7 days by
112
+ default (``"never"`` for none); ``password`` gates the link.
113
+ """
114
+ fields = dict(fields or {})
115
+ clash = sorted(set(fields) & {*RECORD_FIELDS, *_VIEW_FIELDS})
116
+ if clash:
117
+ 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
+ )
123
+ now = self.clock()
124
+ record = {
125
+ "token": new_token(),
126
+ "created": now.isoformat(),
127
+ **fields,
128
+ "expires": parse_expires(expires, now=now),
129
+ "password": self.hasher.hash(password) if password else None,
130
+ "revoked": None,
131
+ }
132
+ self.store[record["token"]] = record
133
+ return public_record(record)
134
+
135
+ def record(self, token: str) -> dict | None:
136
+ """A copy of the stored record (hash included), revoked or not; ``None`` if none."""
137
+ try:
138
+ rec = self.store[check_token(token)]
139
+ except (KeyError, ValueError, OSError):
140
+ return None
141
+ if not _well_formed(rec, token):
142
+ return None # damaged, or filed under another name: grants nothing
143
+ return dict(rec)
144
+
145
+ def status(self, token: str) -> tuple[str, dict | None]:
146
+ """``("ok" | "expired" | "missing", record)``; a revoked link is ``missing``."""
147
+ rec = self.record(token)
148
+ if rec is None or rec.get("revoked"):
149
+ return "missing", None
150
+ if is_expired(rec, now=self.clock()):
151
+ return "expired", rec
152
+ return "ok", rec
153
+
154
+ def list(self, *, include_inactive: bool = False) -> list[dict]:
155
+ """Every live link, newest first, without hashes; ``include_inactive`` adds
156
+ revoked and expired ones.
157
+
158
+ Each record gains ``active`` (neither revoked nor expired).
159
+ """
160
+ now = self.clock()
161
+ out = []
162
+ for token in list(self.store):
163
+ rec = self.record(token)
164
+ if rec is None:
165
+ continue
166
+ rec = public_record(rec)
167
+ rec["active"] = not rec.get("revoked") and not is_expired(rec, now=now)
168
+ if include_inactive or rec["active"]:
169
+ out.append(rec)
170
+ return sorted(out, key=lambda r: r["created"], reverse=True)
171
+
172
+ def revoke(self, token: str) -> dict:
173
+ """End a link now (idempotent). The record stays, as a tombstone."""
174
+ rec = self.record(token)
175
+ if rec is None:
176
+ raise KeyError(token)
177
+ if not rec.get("revoked"):
178
+ rec = {**rec, "revoked": self.clock().isoformat(), "password": None}
179
+ self.store[token] = rec # no hash left: nothing to guess, no session lives
180
+ return public_record(rec)
181
+
182
+ # -- the password gate --
183
+
184
+ def check_password(self, record: dict, password: str) -> bool:
185
+ """Whether ``password`` opens ``record`` (constant time inside the hasher)."""
186
+ hashed = record.get("password")
187
+ return bool(hashed) and self.hasher.verify(hashed, password)
188
+
189
+ def unlock_value(self, record: dict) -> str:
190
+ """What a correct password earns, to send back on later requests: ``<epoch>.<mac>``."""
191
+ issued = int(self.clock().timestamp())
192
+ return f"{issued}.{_unlock_mac(record, issued)}"
193
+
194
+ def unlocked(self, record: dict, value: str | None, *, max_age_s: int) -> bool:
195
+ """Whether ``value`` is a live unlock of ``record`` (no password: always).
196
+
197
+ A revoked record is never unlocked, whatever the caller checked before.
198
+ """
199
+ if record.get("revoked"):
200
+ return False
201
+ if not record.get("password"):
202
+ return True
203
+ if not value or "." not in value:
204
+ return False
205
+ issued_s, _, mac = value.partition(".")
206
+ try:
207
+ issued = int(issued_s)
208
+ except ValueError:
209
+ return False
210
+ now = int(self.clock().timestamp())
211
+ if not (now - max_age_s <= issued <= now + _UNLOCK_SKEW_S):
212
+ return False
213
+ expected = _unlock_mac(record, issued)
214
+ return hmac.compare_digest(mac.encode("utf-8"), expected.encode("ascii"))
215
+
216
+ def unlock_max_age(self, record: dict, *, max_age_s: int) -> int:
217
+ """How long an unlock should last, in seconds: ``max_age_s``, never past expiry."""
218
+ try:
219
+ ends = expires_at(record)
220
+ except ValueError:
221
+ return 1
222
+ if ends is None:
223
+ return max_age_s
224
+ left = int((ends - self.clock()).total_seconds())
225
+ return max(1, min(max_age_s, left))
226
+
227
+
228
+ def _well_formed(rec, token: str) -> bool:
229
+ """The generic fields have the types every method relies on."""
230
+ optional_str = lambda v: v is None or isinstance(v, str) # noqa: E731
231
+ return (
232
+ isinstance(rec, dict)
233
+ and rec.get("token") == token
234
+ and isinstance(rec.get("created"), str)
235
+ and all(optional_str(rec.get(k)) for k in ("expires", "password", "revoked"))
236
+ )
237
+
238
+
239
+ def _unlock_mac(record: dict, issued: int) -> str:
240
+ # Keyed by the password hash: it holds a random salt and never leaves the server, and
241
+ # a new password (or a revoke, which clears it) invalidates every unlock at once.
242
+ key = (record.get("password") or "").encode("utf-8")
243
+ msg = f"{record['token']}:{issued}".encode("ascii")
244
+ return hmac.new(key, msg, hashlib.sha256).hexdigest()
caplinks/passwords.py ADDED
@@ -0,0 +1,74 @@
1
+ """Password hashing for a link's password gate: a protocol and a stdlib default.
2
+
3
+ A link password is a gate, not an account: it is hashed once when the link is made and
4
+ checked on each unlock. :class:`ScryptHasher` needs nothing beyond the standard library;
5
+ anything with the same two methods (an argon2id adapter, say) fits the ``hasher`` seam
6
+ of :class:`caplinks.CapabilityLinks`.
7
+
8
+ >>> h = ScryptHasher(n=2**4) # tiny cost, for the example only
9
+ >>> stored = h.hash("correct horse")
10
+ >>> stored.startswith("scrypt$"), h.verify(stored, "correct horse"), h.verify(stored, "x")
11
+ (True, True, False)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import hashlib
18
+ import hmac
19
+ import secrets
20
+ from dataclasses import dataclass
21
+ from typing import Protocol, runtime_checkable
22
+
23
+
24
+ @runtime_checkable
25
+ class PasswordHasher(Protocol):
26
+ """What a link needs to gate on a password: ``verify`` returns a bool, never raises."""
27
+
28
+ def hash(self, password: str) -> str: ...
29
+
30
+ def verify(self, hashed: str, password: str) -> bool: ...
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ScryptHasher:
35
+ """Stdlib ``hashlib.scrypt``, stored as ``scrypt$n$r$p$salt$hash`` (base64).
36
+
37
+ The parameters travel with the hash, so raising ``n`` later still verifies old
38
+ hashes. ``n=2**14, r=8`` costs ~16 MB and tens of ms per check, which a host's
39
+ attempt limit (:mod:`caplinks.limits`) bounds.
40
+ """
41
+
42
+ n: int = 2**14
43
+ r: int = 8
44
+ p: int = 1
45
+ salt_bytes: int = 16
46
+ dklen: int = 32
47
+
48
+ def _derive(self, password: str, salt: bytes, n: int, r: int, p: int) -> bytes:
49
+ return hashlib.scrypt(
50
+ password.encode("utf-8"),
51
+ salt=salt,
52
+ n=n,
53
+ r=r,
54
+ p=p,
55
+ dklen=self.dklen,
56
+ maxmem=2 * 128 * r * n + 2**20,
57
+ )
58
+
59
+ def hash(self, password: str) -> str:
60
+ salt = secrets.token_bytes(self.salt_bytes)
61
+ dk = self._derive(password, salt, self.n, self.r, self.p)
62
+ b64 = lambda b: base64.b64encode(b).decode("ascii") # noqa: E731
63
+ return f"scrypt${self.n}${self.r}${self.p}${b64(salt)}${b64(dk)}"
64
+
65
+ def verify(self, hashed: str, password: str) -> bool:
66
+ try:
67
+ scheme, n, r, p, salt, dk = hashed.split("$")
68
+ if scheme != "scrypt":
69
+ return False
70
+ expected = base64.b64decode(dk)
71
+ got = self._derive(password, base64.b64decode(salt), int(n), int(r), int(p))
72
+ except (ValueError, TypeError, AttributeError):
73
+ return False
74
+ return hmac.compare_digest(got, expected)
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.5
2
+ Name: caplinks
3
+ Version: 0.0.2
4
+ Summary: Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only.
5
+ Project-URL: Homepage, https://github.com/i2mint/caplinks
6
+ Project-URL: Repository, https://github.com/i2mint/caplinks
7
+ Project-URL: Documentation, https://i2mint.github.io/caplinks
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: capability,expiry,password,rate limit,scrypt,share link,token
12
+ Requires-Python: >=3.10
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
15
+ Requires-Dist: pytest>=7.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
17
+ Provides-Extra: docs
18
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
19
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # caplinks
23
+
24
+ Capability links: hand someone a link to one thing, with no account. The token is the permission; a password may gate it; it expires; it can be revoked; password attempts are limited. Standard library only, no web framework.
25
+
26
+ ```
27
+ pip install caplinks
28
+ ```
29
+
30
+ ```python
31
+ from caplinks import CapabilityLinks
32
+
33
+ links = CapabilityLinks() # records in a dict; pass any MutableMapping to persist them
34
+ rec = links.create({"doc": "report-1"}, password="open sesame", expires="7d")
35
+ url = f"https://example.com/s/{rec['token']}/"
36
+
37
+ state, stored = links.status(rec["token"]) # "ok", "expired" or "missing"
38
+ links.check_password(stored, "open sesame") # True
39
+ cookie = links.unlock_value(stored) # what a correct password earns
40
+ links.unlocked(stored, cookie, max_age_s=3600) # True, until the password changes or the link is revoked
41
+ links.revoke(rec["token"]) # now "missing"
42
+ ```
43
+
44
+ ## What is in it
45
+
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
+ - `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
48
+ - `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
+ - `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.
50
+
51
+ The unlock value is an HMAC keyed by the stored password hash, so changing the password or revoking the link (which clears the hash) ends every open session at once.
52
+
53
+ ## What stays with your app
54
+
55
+ What a link reaches (its scope), how it is served (routes, cookie name, headers), and the attempt policy. `annals` (share links) is the first host.
56
+
57
+ ## Seams
58
+
59
+ | keyword | default | for |
60
+ |---|---|---|
61
+ | `store` | `dict` | persistence: a dol store, a file-backed mapping, S3 |
62
+ | `hasher` | `ScryptHasher()` | argon2id or another scheme |
63
+ | `clock` | `now_utc` | tests that pin the time |
64
+ | `min_password_len` | 8 | a stricter or laxer gate |
65
+
66
+ A store that cannot vouch for a record raises `KeyError`, `ValueError` or `OSError` when it is read, and the link is then missing; so is a record whose generic fields have the wrong types. Every failure closes the link.
@@ -0,0 +1,9 @@
1
+ caplinks/__init__.py,sha256=mii9AgQoelgp_cYVPRl7rpLHUqc1nkfiefGx2Vuy6LM,1443
2
+ caplinks/expiry.py,sha256=Pw_NSb9hQ6Kwm71_HMMkjL8J_HEhAS_tjb_mDFoPUv0,3986
3
+ caplinks/limits.py,sha256=xAJwC-le0Zt9B-DIMeWXMlrH1xJWTpv7p68O0VU83fc,2639
4
+ caplinks/links.py,sha256=gj96knz0Wc-rk-QN6BeffSY-0cIbHm6FZMGLndS7dQU,9827
5
+ caplinks/passwords.py,sha256=nSbfIQsMotj2SkEic_-suNf4DDRg1A3z0PMuW8WzF2E,2556
6
+ caplinks-0.0.2.dist-info/METADATA,sha256=8K3gft_Gh6FTFH_ugh-qcBdQlXVjDc6L3S0n9uQ6jRA,3508
7
+ caplinks-0.0.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ caplinks-0.0.2.dist-info/licenses/LICENSE,sha256=ekE13yCE9fWWEJfP1NPnUJabZ_Wwrc6coKJV2ZZx27w,1068
9
+ caplinks-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.