caplinks 0.0.2__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/.gitignore +119 -0
- caplinks-0.0.2/LICENSE +21 -0
- caplinks-0.0.2/PKG-INFO +66 -0
- caplinks-0.0.2/README.md +45 -0
- caplinks-0.0.2/caplinks/__init__.py +46 -0
- caplinks-0.0.2/caplinks/expiry.py +107 -0
- caplinks-0.0.2/caplinks/limits.py +74 -0
- caplinks-0.0.2/caplinks/links.py +244 -0
- caplinks-0.0.2/caplinks/passwords.py +74 -0
- caplinks-0.0.2/pyproject.toml +174 -0
- caplinks-0.0.2/tests/test_caplinks.py +268 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
.claude/handoffs/
|
|
2
|
+
.claude/scratch/
|
|
3
|
+
|
|
4
|
+
# Byte-compiled / optimized / DLL files
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*$py.class
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
.DS_Store
|
|
11
|
+
# C extensions
|
|
12
|
+
*.so
|
|
13
|
+
|
|
14
|
+
# TLS certificates
|
|
15
|
+
## Ignore all PEM files anywhere
|
|
16
|
+
*.pem
|
|
17
|
+
## Also ignore any certs directory
|
|
18
|
+
certs/
|
|
19
|
+
|
|
20
|
+
# Distribution / packaging
|
|
21
|
+
.Python
|
|
22
|
+
build/
|
|
23
|
+
develop-eggs/
|
|
24
|
+
dist/
|
|
25
|
+
downloads/
|
|
26
|
+
eggs/
|
|
27
|
+
.eggs/
|
|
28
|
+
lib/
|
|
29
|
+
lib64/
|
|
30
|
+
parts/
|
|
31
|
+
sdist/
|
|
32
|
+
var/
|
|
33
|
+
wheels/
|
|
34
|
+
*.egg-info/
|
|
35
|
+
.installed.cfg
|
|
36
|
+
*.egg
|
|
37
|
+
MANIFEST
|
|
38
|
+
_build
|
|
39
|
+
|
|
40
|
+
# PyInstaller
|
|
41
|
+
# Usually these files are written by a python script from a template
|
|
42
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
43
|
+
*.manifest
|
|
44
|
+
*.spec
|
|
45
|
+
|
|
46
|
+
# Installer logs
|
|
47
|
+
pip-log.txt
|
|
48
|
+
pip-delete-this-directory.txt
|
|
49
|
+
|
|
50
|
+
# Unit test / coverage reports
|
|
51
|
+
htmlcov/
|
|
52
|
+
.tox/
|
|
53
|
+
.coverage
|
|
54
|
+
.coverage.*
|
|
55
|
+
.cache
|
|
56
|
+
nosetests.xml
|
|
57
|
+
coverage.xml
|
|
58
|
+
*.cover
|
|
59
|
+
.hypothesis/
|
|
60
|
+
.pytest_cache/
|
|
61
|
+
|
|
62
|
+
# Translations
|
|
63
|
+
*.mo
|
|
64
|
+
*.pot
|
|
65
|
+
|
|
66
|
+
# Django stuff:
|
|
67
|
+
*.log
|
|
68
|
+
local_settings.py
|
|
69
|
+
db.sqlite3
|
|
70
|
+
|
|
71
|
+
# Flask stuff:
|
|
72
|
+
instance/
|
|
73
|
+
.webassets-cache
|
|
74
|
+
|
|
75
|
+
# Scrapy stuff:
|
|
76
|
+
.scrapy
|
|
77
|
+
|
|
78
|
+
# Sphinx documentation
|
|
79
|
+
docs/_build/
|
|
80
|
+
|
|
81
|
+
# PyBuilder
|
|
82
|
+
target/
|
|
83
|
+
|
|
84
|
+
# Jupyter Notebook
|
|
85
|
+
.ipynb_checkpoints
|
|
86
|
+
|
|
87
|
+
# pyenv
|
|
88
|
+
.python-version
|
|
89
|
+
|
|
90
|
+
# celery beat schedule file
|
|
91
|
+
celerybeat-schedule
|
|
92
|
+
|
|
93
|
+
# SageMath parsed files
|
|
94
|
+
*.sage.py
|
|
95
|
+
|
|
96
|
+
# Environments
|
|
97
|
+
.env
|
|
98
|
+
.venv
|
|
99
|
+
env/
|
|
100
|
+
venv/
|
|
101
|
+
ENV/
|
|
102
|
+
env.bak/
|
|
103
|
+
venv.bak/
|
|
104
|
+
|
|
105
|
+
# Spyder project settings
|
|
106
|
+
.spyderproject
|
|
107
|
+
.spyproject
|
|
108
|
+
|
|
109
|
+
# Rope project settings
|
|
110
|
+
.ropeproject
|
|
111
|
+
|
|
112
|
+
# mkdocs documentation
|
|
113
|
+
/site
|
|
114
|
+
|
|
115
|
+
# mypy
|
|
116
|
+
.mypy_cache/
|
|
117
|
+
|
|
118
|
+
# PyCharm
|
|
119
|
+
.idea
|
caplinks-0.0.2/LICENSE
ADDED
|
@@ -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.
|
caplinks-0.0.2/PKG-INFO
ADDED
|
@@ -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.
|
caplinks-0.0.2/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# caplinks
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install caplinks
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from caplinks import CapabilityLinks
|
|
11
|
+
|
|
12
|
+
links = CapabilityLinks() # records in a dict; pass any MutableMapping to persist them
|
|
13
|
+
rec = links.create({"doc": "report-1"}, password="open sesame", expires="7d")
|
|
14
|
+
url = f"https://example.com/s/{rec['token']}/"
|
|
15
|
+
|
|
16
|
+
state, stored = links.status(rec["token"]) # "ok", "expired" or "missing"
|
|
17
|
+
links.check_password(stored, "open sesame") # True
|
|
18
|
+
cookie = links.unlock_value(stored) # what a correct password earns
|
|
19
|
+
links.unlocked(stored, cookie, max_age_s=3600) # True, until the password changes or the link is revoked
|
|
20
|
+
links.revoke(rec["token"]) # now "missing"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What is in it
|
|
24
|
+
|
|
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
|
+
- `ScryptHasher` (stdlib scrypt) behind the `PasswordHasher` protocol (`hash`, `verify -> bool`): pass `hasher=` for argon2 or anything else.
|
|
27
|
+
- `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
|
+
- `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.
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
## What stays with your app
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
## Seams
|
|
37
|
+
|
|
38
|
+
| keyword | default | for |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `store` | `dict` | persistence: a dol store, a file-backed mapping, S3 |
|
|
41
|
+
| `hasher` | `ScryptHasher()` | argon2id or another scheme |
|
|
42
|
+
| `clock` | `now_utc` | tests that pin the time |
|
|
43
|
+
| `min_password_len` | 8 | a stricter or laxer gate |
|
|
44
|
+
|
|
45
|
+
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,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
|
+
]
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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,174 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = [
|
|
3
|
+
"hatchling",
|
|
4
|
+
]
|
|
5
|
+
build-backend = "hatchling.build"
|
|
6
|
+
|
|
7
|
+
[project]
|
|
8
|
+
name = "caplinks"
|
|
9
|
+
version = "0.0.2"
|
|
10
|
+
description = "Capability links: an unguessable token, an optional password gate, expiry, revocation and attempt limits. Standard library only."
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
license = "MIT"
|
|
14
|
+
keywords = ["capability", "share link", "token", "password", "expiry", "rate limit", "scrypt"]
|
|
15
|
+
authors = [
|
|
16
|
+
{ name = "Thor Whalen" },
|
|
17
|
+
]
|
|
18
|
+
dependencies = []
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/i2mint/caplinks"
|
|
22
|
+
Repository = "https://github.com/i2mint/caplinks"
|
|
23
|
+
Documentation = "https://i2mint.github.io/caplinks"
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=7.0",
|
|
28
|
+
"pytest-cov>=4.0",
|
|
29
|
+
"ruff>=0.1.0",
|
|
30
|
+
]
|
|
31
|
+
docs = [
|
|
32
|
+
"sphinx>=6.0",
|
|
33
|
+
"sphinx-rtd-theme>=1.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build]
|
|
37
|
+
skip-excluded-dirs = true
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["caplinks"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
exclude = [
|
|
44
|
+
".claude",
|
|
45
|
+
".github",
|
|
46
|
+
"scrap",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 88
|
|
51
|
+
target-version = "py310"
|
|
52
|
+
exclude = [
|
|
53
|
+
"**/*.ipynb",
|
|
54
|
+
"**/*.md",
|
|
55
|
+
".git",
|
|
56
|
+
".venv",
|
|
57
|
+
"build",
|
|
58
|
+
"dist",
|
|
59
|
+
"tests",
|
|
60
|
+
"examples",
|
|
61
|
+
"scrap",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = [
|
|
66
|
+
"D100",
|
|
67
|
+
]
|
|
68
|
+
ignore = [
|
|
69
|
+
"D203",
|
|
70
|
+
"E501",
|
|
71
|
+
"B905",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint.pydocstyle]
|
|
75
|
+
convention = "google"
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.per-file-ignores]
|
|
78
|
+
"**/tests/*" = [
|
|
79
|
+
"D",
|
|
80
|
+
]
|
|
81
|
+
"**/examples/*" = [
|
|
82
|
+
"D",
|
|
83
|
+
]
|
|
84
|
+
"**/scrap/*" = [
|
|
85
|
+
"D",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.pytest.ini_options]
|
|
89
|
+
minversion = "6.0"
|
|
90
|
+
testpaths = [
|
|
91
|
+
"caplinks",
|
|
92
|
+
"tests",
|
|
93
|
+
]
|
|
94
|
+
addopts = "--doctest-modules"
|
|
95
|
+
doctest_optionflags = [
|
|
96
|
+
"NORMALIZE_WHITESPACE",
|
|
97
|
+
"ELLIPSIS",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
[tool.wads.ci]
|
|
101
|
+
project_name = ""
|
|
102
|
+
|
|
103
|
+
[tool.wads.ci.install]
|
|
104
|
+
extras = "dev"
|
|
105
|
+
|
|
106
|
+
[tool.wads.ci.commands]
|
|
107
|
+
pre_test = []
|
|
108
|
+
test = []
|
|
109
|
+
post_test = []
|
|
110
|
+
lint = []
|
|
111
|
+
format = []
|
|
112
|
+
|
|
113
|
+
[tool.wads.ci.env]
|
|
114
|
+
required_envvars = []
|
|
115
|
+
test_envvars = []
|
|
116
|
+
extra_envvars = []
|
|
117
|
+
|
|
118
|
+
[tool.wads.ci.env.defaults]
|
|
119
|
+
|
|
120
|
+
[tool.wads.ci.quality.ruff]
|
|
121
|
+
enabled = true
|
|
122
|
+
|
|
123
|
+
[tool.wads.ci.quality.black]
|
|
124
|
+
enabled = false
|
|
125
|
+
|
|
126
|
+
[tool.wads.ci.quality.mypy]
|
|
127
|
+
enabled = false
|
|
128
|
+
|
|
129
|
+
[tool.wads.ci.testing]
|
|
130
|
+
enabled = true
|
|
131
|
+
python_versions = [
|
|
132
|
+
"3.10",
|
|
133
|
+
"3.12",
|
|
134
|
+
]
|
|
135
|
+
pytest_args = [
|
|
136
|
+
"-v",
|
|
137
|
+
"--tb=short",
|
|
138
|
+
]
|
|
139
|
+
coverage_enabled = true
|
|
140
|
+
coverage_threshold = 0
|
|
141
|
+
coverage_report_format = [
|
|
142
|
+
"term",
|
|
143
|
+
"xml",
|
|
144
|
+
]
|
|
145
|
+
exclude_paths = [
|
|
146
|
+
"examples",
|
|
147
|
+
"scrap",
|
|
148
|
+
]
|
|
149
|
+
test_on_windows = false
|
|
150
|
+
|
|
151
|
+
[tool.wads.ci.metrics]
|
|
152
|
+
enabled = true
|
|
153
|
+
config_path = ".github/umpyre-config.yml"
|
|
154
|
+
storage_branch = "code-metrics"
|
|
155
|
+
python_version = "3.11"
|
|
156
|
+
force_run = false
|
|
157
|
+
|
|
158
|
+
[tool.wads.ci.build]
|
|
159
|
+
sdist = true
|
|
160
|
+
wheel = true
|
|
161
|
+
|
|
162
|
+
[tool.wads.ci.publish]
|
|
163
|
+
enabled = true
|
|
164
|
+
skip_ci_marker = "[skip ci]"
|
|
165
|
+
publish_marker = "[publish]"
|
|
166
|
+
|
|
167
|
+
[tool.wads.ci.docs]
|
|
168
|
+
enabled = true
|
|
169
|
+
builder = "epythet"
|
|
170
|
+
ignore_paths = [
|
|
171
|
+
"tests/",
|
|
172
|
+
"scrap/",
|
|
173
|
+
"examples/",
|
|
174
|
+
]
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from caplinks import (
|
|
6
|
+
CapabilityLinks,
|
|
7
|
+
RateLimiter,
|
|
8
|
+
ScryptHasher,
|
|
9
|
+
SlidingWindowLimiter,
|
|
10
|
+
is_expired,
|
|
11
|
+
parse_expires,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
T0 = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
|
|
15
|
+
PW = "correct horse"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Clock:
|
|
19
|
+
def __init__(self, t=T0):
|
|
20
|
+
self.t = t
|
|
21
|
+
|
|
22
|
+
def __call__(self):
|
|
23
|
+
return self.t
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def clock():
|
|
28
|
+
return Clock()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def links(clock):
|
|
33
|
+
return CapabilityLinks(hasher=ScryptHasher(n=2**4), clock=clock)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_smoke_create_open_unlock_revoke():
|
|
37
|
+
"""The one-command path, every seam on its default."""
|
|
38
|
+
links = CapabilityLinks()
|
|
39
|
+
rec = links.create({"doc": "d1"}, password=PW)
|
|
40
|
+
state, stored = links.status(rec["token"])
|
|
41
|
+
assert state == "ok" and stored["doc"] == "d1"
|
|
42
|
+
assert links.check_password(stored, PW)
|
|
43
|
+
cookie = links.unlock_value(stored)
|
|
44
|
+
assert links.unlocked(stored, cookie, max_age_s=60)
|
|
45
|
+
links.revoke(rec["token"])
|
|
46
|
+
assert links.status(rec["token"]) == ("missing", None)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_create_keeps_host_fields_and_never_returns_the_hash(links):
|
|
50
|
+
rec = links.create({"root": {"id": "x"}, "docs": {"x": ["a.md"]}}, password=PW)
|
|
51
|
+
assert rec["root"] == {"id": "x"} and rec["protected"] is True
|
|
52
|
+
assert "password" not in rec
|
|
53
|
+
stored = links.store[rec["token"]]
|
|
54
|
+
assert stored["password"].startswith("scrypt$") and PW not in stored["password"]
|
|
55
|
+
assert stored["expires"] == (T0 + timedelta(days=7)).isoformat()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_create_refuses_reserved_fields_and_short_passwords(links):
|
|
59
|
+
with pytest.raises(ValueError, match="link's own"):
|
|
60
|
+
links.create({"expires": "never"})
|
|
61
|
+
with pytest.raises(ValueError, match="at least 8"):
|
|
62
|
+
links.create(password="short")
|
|
63
|
+
assert links.create(password=None)["protected"] is False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_status_ok_expired_missing(links, clock):
|
|
67
|
+
tok = links.create(expires="1h")["token"]
|
|
68
|
+
assert links.status(tok)[0] == "ok"
|
|
69
|
+
clock.t = T0 + timedelta(hours=1)
|
|
70
|
+
assert links.status(tok)[0] == "expired"
|
|
71
|
+
assert links.status("A" * 22) == ("missing", None)
|
|
72
|
+
assert links.status("../etc") == ("missing", None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_a_record_the_store_cannot_vouch_for_is_missing(clock):
|
|
76
|
+
class Picky(dict):
|
|
77
|
+
def __getitem__(self, k):
|
|
78
|
+
raise ValueError("damaged")
|
|
79
|
+
|
|
80
|
+
links = CapabilityLinks(Picky(), clock=clock)
|
|
81
|
+
links.store["A" * 22] = {"token": "A" * 22}
|
|
82
|
+
assert links.record("A" * 22) is None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_a_record_filed_under_another_token_grants_nothing(links):
|
|
86
|
+
rec = links.create()
|
|
87
|
+
other = "B" * 22
|
|
88
|
+
links.store[other] = dict(links.store[rec["token"]])
|
|
89
|
+
assert links.record(other) is None and links.status(other)[0] == "missing"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_list_newest_first_and_all_adds_inactive(links, clock):
|
|
93
|
+
a = links.create(expires="1h")["token"]
|
|
94
|
+
clock.t = T0 + timedelta(minutes=1)
|
|
95
|
+
b = links.create()["token"]
|
|
96
|
+
c = links.create()["token"]
|
|
97
|
+
links.revoke(c)
|
|
98
|
+
assert [r["token"] for r in links.list()] == [b, a]
|
|
99
|
+
clock.t = T0 + timedelta(hours=2)
|
|
100
|
+
assert [r["token"] for r in links.list()] == [b]
|
|
101
|
+
everything = links.list(include_inactive=True)
|
|
102
|
+
assert {r["token"] for r in everything} == {a, b, c}
|
|
103
|
+
assert all("password" not in r for r in everything)
|
|
104
|
+
assert {r["token"]: r["active"] for r in everything} == {
|
|
105
|
+
a: False,
|
|
106
|
+
b: True,
|
|
107
|
+
c: False,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_revoke_is_idempotent_clears_the_hash_and_raises_on_unknown(links, clock):
|
|
112
|
+
tok = links.create(password=PW)["token"]
|
|
113
|
+
first = links.revoke(tok)
|
|
114
|
+
clock.t = T0 + timedelta(hours=1)
|
|
115
|
+
assert links.revoke(tok)["revoked"] == first["revoked"]
|
|
116
|
+
assert links.store[tok]["password"] is None
|
|
117
|
+
with pytest.raises(KeyError):
|
|
118
|
+
links.revoke("C" * 22)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_unlock_value_lifecycle(links, clock):
|
|
122
|
+
rec = links.store[links.create(password=PW)["token"]]
|
|
123
|
+
value = links.unlock_value(rec)
|
|
124
|
+
assert links.unlocked(rec, value, max_age_s=3600)
|
|
125
|
+
assert not links.unlocked(
|
|
126
|
+
rec, value[:-1] + ("1" if value[-1] == "0" else "0"), max_age_s=3600
|
|
127
|
+
)
|
|
128
|
+
assert not links.unlocked(rec, None, max_age_s=3600)
|
|
129
|
+
assert not links.unlocked(rec, "nonsense", max_age_s=3600)
|
|
130
|
+
clock.t = T0 + timedelta(hours=2)
|
|
131
|
+
assert not links.unlocked(rec, value, max_age_s=3600) # too old
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_a_new_password_ends_every_unlock(links):
|
|
135
|
+
rec = links.store[links.create(password=PW)["token"]]
|
|
136
|
+
value = links.unlock_value(rec)
|
|
137
|
+
rec = {**rec, "password": links.hasher.hash("another one")}
|
|
138
|
+
assert not links.unlocked(rec, value, max_age_s=3600)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_an_open_link_is_always_unlocked(links):
|
|
142
|
+
rec = links.store[links.create()["token"]]
|
|
143
|
+
assert links.unlocked(rec, None, max_age_s=1)
|
|
144
|
+
assert not links.check_password(rec, "anything")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_unlock_max_age_never_outlives_the_link(links, clock):
|
|
148
|
+
rec = links.store[links.create(expires="1h")["token"]]
|
|
149
|
+
assert links.unlock_max_age(rec, max_age_s=7 * 86400) == 3600
|
|
150
|
+
assert links.unlock_max_age(rec, max_age_s=60) == 60
|
|
151
|
+
never = links.store[links.create(expires="never")["token"]]
|
|
152
|
+
assert links.unlock_max_age(never, max_age_s=60) == 60
|
|
153
|
+
clock.t = T0 + timedelta(hours=3)
|
|
154
|
+
assert links.unlock_max_age(rec, max_age_s=60) == 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_hasher_rejects_foreign_or_broken_hashes():
|
|
158
|
+
h = ScryptHasher(n=2**4)
|
|
159
|
+
assert not h.verify("argon2$whatever", PW)
|
|
160
|
+
assert not h.verify("scrypt$not$enough", PW)
|
|
161
|
+
assert not h.verify(None, PW)
|
|
162
|
+
assert h.hash(PW) != h.hash(PW) # salted
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@pytest.mark.parametrize(
|
|
166
|
+
"value, expected",
|
|
167
|
+
[
|
|
168
|
+
("30m", T0 + timedelta(minutes=30)),
|
|
169
|
+
("12H", T0 + timedelta(hours=12)),
|
|
170
|
+
("2w", T0 + timedelta(weeks=2)),
|
|
171
|
+
("2026-01-02", datetime(2026, 1, 2, 23, 59, 59, tzinfo=timezone.utc)),
|
|
172
|
+
("2026-01-02T10:00:00Z", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
|
|
173
|
+
("2026-01-02T10:00:00", datetime(2026, 1, 2, 10, tzinfo=timezone.utc)),
|
|
174
|
+
(
|
|
175
|
+
datetime(2026, 2, 1, tzinfo=timezone.utc),
|
|
176
|
+
datetime(2026, 2, 1, tzinfo=timezone.utc),
|
|
177
|
+
),
|
|
178
|
+
],
|
|
179
|
+
)
|
|
180
|
+
def test_parse_expires_reads_what_people_write(value, expected):
|
|
181
|
+
assert parse_expires(value, now=T0) == expected.isoformat()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.parametrize("value", [None, "", "never", " None "])
|
|
185
|
+
def test_parse_expires_never(value):
|
|
186
|
+
assert parse_expires(value, now=T0) is None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.mark.parametrize("value", ["2025-12-31", "yesterday", "0d"])
|
|
190
|
+
def test_parse_expires_refuses_the_past_and_nonsense(value):
|
|
191
|
+
with pytest.raises(ValueError):
|
|
192
|
+
parse_expires(value, now=T0)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_is_expired_fails_closed():
|
|
196
|
+
assert is_expired({"expires": 12345})
|
|
197
|
+
assert is_expired({"expires": "not a date"})
|
|
198
|
+
assert not is_expired({})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_limiter_bounds_its_keys_and_fits_the_protocol():
|
|
202
|
+
lim = SlidingWindowLimiter(max_per_window=1, max_keys=2, clock=lambda: 0.0)
|
|
203
|
+
assert lim.allow("a") and not lim.allow("a")
|
|
204
|
+
lim.allow("b"), lim.allow("c") # "a" is evicted
|
|
205
|
+
assert lim.allow("a")
|
|
206
|
+
assert isinstance(lim, RateLimiter)
|
|
207
|
+
|
|
208
|
+
class Redisish: # heed.security.RedisRateLimiter's shape
|
|
209
|
+
def allow(self, key):
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
assert isinstance(Redisish(), RateLimiter)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_view_fields_are_reserved_too(links):
|
|
216
|
+
for name in ("protected", "active"):
|
|
217
|
+
with pytest.raises(ValueError, match="link's own"):
|
|
218
|
+
links.create({name: 1})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_a_hostile_unlock_value_is_refused_not_raised(links, clock):
|
|
222
|
+
rec = links.record(links.create(password=PW)["token"])
|
|
223
|
+
now = int(clock().timestamp())
|
|
224
|
+
assert not links.unlocked(rec, f"{now}.é", max_age_s=60)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_a_revoked_record_is_never_unlocked(links):
|
|
228
|
+
tok = links.create(password=PW)["token"]
|
|
229
|
+
links.revoke(tok)
|
|
230
|
+
assert not links.unlocked(links.record(tok), None, max_age_s=60)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@pytest.mark.parametrize(
|
|
234
|
+
"damage",
|
|
235
|
+
[{"password": 123}, {"created": 5}, {"expires": 7}, {"revoked": []}],
|
|
236
|
+
)
|
|
237
|
+
def test_a_record_with_mistyped_fields_is_missing_and_spares_the_list(links, damage):
|
|
238
|
+
good = links.create()["token"]
|
|
239
|
+
bad = links.create()["token"]
|
|
240
|
+
links.store[bad] = {**links.store[bad], **damage}
|
|
241
|
+
assert links.record(bad) is None and links.status(bad)[0] == "missing"
|
|
242
|
+
assert [r["token"] for r in links.list(include_inactive=True)] == [good]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_a_store_raising_oserror_closes_the_link(clock):
|
|
246
|
+
class Flaky(dict):
|
|
247
|
+
def __getitem__(self, k):
|
|
248
|
+
raise OSError("ssh dropped")
|
|
249
|
+
|
|
250
|
+
assert CapabilityLinks(Flaky(), clock=clock).record("A" * 22) is None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_record_is_a_copy(links):
|
|
254
|
+
tok = links.create(password=PW)["token"]
|
|
255
|
+
links.record(tok).pop("password")
|
|
256
|
+
assert links.record(tok)["password"]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@pytest.mark.parametrize("value", ["99999999d", "9999-12-31T23:59:59-05:00"])
|
|
260
|
+
def test_parse_expires_far_future_is_a_value_error(value):
|
|
261
|
+
with pytest.raises(ValueError, match="too far"):
|
|
262
|
+
parse_expires(value, now=T0)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@pytest.mark.parametrize("kw", [{"max_keys": 0}, {"max_per_window": 0}])
|
|
266
|
+
def test_limiter_refuses_settings_that_disable_it(kw):
|
|
267
|
+
with pytest.raises(ValueError):
|
|
268
|
+
SlidingWindowLimiter(**kw)
|