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 +46 -0
- caplinks/expiry.py +107 -0
- caplinks/limits.py +74 -0
- caplinks/links.py +244 -0
- caplinks/passwords.py +74 -0
- caplinks-0.0.2.dist-info/METADATA +66 -0
- caplinks-0.0.2.dist-info/RECORD +9 -0
- caplinks-0.0.2.dist-info/WHEEL +4 -0
- caplinks-0.0.2.dist-info/licenses/LICENSE +21 -0
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,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.
|