crabcrop-url 0.1.0__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.
- crabcrop_url-0.1.0/PKG-INFO +75 -0
- crabcrop_url-0.1.0/README.md +59 -0
- crabcrop_url-0.1.0/pyproject.toml +26 -0
- crabcrop_url-0.1.0/setup.cfg +4 -0
- crabcrop_url-0.1.0/src/crabcrop_url/__init__.py +212 -0
- crabcrop_url-0.1.0/src/crabcrop_url.egg-info/PKG-INFO +75 -0
- crabcrop_url-0.1.0/src/crabcrop_url.egg-info/SOURCES.txt +8 -0
- crabcrop_url-0.1.0/src/crabcrop_url.egg-info/dependency_links.txt +1 -0
- crabcrop_url-0.1.0/src/crabcrop_url.egg-info/top_level.txt +1 -0
- crabcrop_url-0.1.0/tests/test_vectors.py +188 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crabcrop-url
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build and sign crabcrop image URLs
|
|
5
|
+
Author: Maxwell Dayvson Da Silva
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://crabcrop.org
|
|
8
|
+
Project-URL: Repository, https://github.com/dayvson/crabcrop
|
|
9
|
+
Keywords: image,url,signing,hmac,cdn,crabcrop
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# crabcrop_url
|
|
18
|
+
|
|
19
|
+
Build and sign [crabcrop](https://github.com/dayvson/crabcrop) URLs from Python. Standard library only; the signing scheme is specified in [`spec/url-signing.md`](../../spec/url-signing.md) and this package runs the canonical [`spec/test-vectors.json`](../../spec/test-vectors.json) as its test suite, so it cannot drift from the server.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from crabcrop_url import build_url, build_srcset, purge_group_signature, purge_signature, sign, upload_signature, verify
|
|
25
|
+
|
|
26
|
+
# A signed URL. The operation string is passed verbatim and may
|
|
27
|
+
# contain several segments and preset: references.
|
|
28
|
+
url = build_url(
|
|
29
|
+
"https://images.example.com",
|
|
30
|
+
"your-secret",
|
|
31
|
+
"crop:1200x630:smart/filters:format(webp)",
|
|
32
|
+
"https://example.com/photo.jpg",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# An expiring URL (unix seconds), emitted as a leading exp: segment.
|
|
36
|
+
expiring = build_url(
|
|
37
|
+
"https://images.example.com",
|
|
38
|
+
"your-secret",
|
|
39
|
+
"300x200",
|
|
40
|
+
"https://example.com/photo.jpg",
|
|
41
|
+
expires=1893456000,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# A responsive srcset: one {width}x0 URL per width.
|
|
45
|
+
srcset = build_srcset(
|
|
46
|
+
"https://images.example.com",
|
|
47
|
+
"your-secret",
|
|
48
|
+
"https://example.com/photo.jpg",
|
|
49
|
+
[400, 800, 1200],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# The raw signature for a remainder, if you assemble URLs yourself.
|
|
53
|
+
signature = sign("your-secret", "300x200/https://example.com/photo.jpg")
|
|
54
|
+
|
|
55
|
+
# Verify a signature in constant time (malformed tags return False).
|
|
56
|
+
ok = verify("your-secret", signature, "300x200/https://example.com/photo.jpg")
|
|
57
|
+
|
|
58
|
+
# Purge signatures for the server's /purge and /purge-group endpoints
|
|
59
|
+
# are domain-separated under a derived subkey; the helpers sign the
|
|
60
|
+
# right strings so callers never assemble them by hand.
|
|
61
|
+
purge = purge_signature("your-secret", "300x200/https://example.com/photo.jpg")
|
|
62
|
+
group = purge_group_signature("your-secret", "thumb")
|
|
63
|
+
|
|
64
|
+
# Upload signatures for the server's PUT /upload/<signature>/<name>
|
|
65
|
+
# endpoint live in a third domain, under their own derived subkey.
|
|
66
|
+
upload = upload_signature("your-secret", "heroes/photo.jpg")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Pass `None` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode. The secret may be `str` or `bytes`.
|
|
70
|
+
|
|
71
|
+
## Tests
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 -m unittest discover -s tests
|
|
75
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# crabcrop_url
|
|
2
|
+
|
|
3
|
+
Build and sign [crabcrop](https://github.com/dayvson/crabcrop) URLs from Python. Standard library only; the signing scheme is specified in [`spec/url-signing.md`](../../spec/url-signing.md) and this package runs the canonical [`spec/test-vectors.json`](../../spec/test-vectors.json) as its test suite, so it cannot drift from the server.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from crabcrop_url import build_url, build_srcset, purge_group_signature, purge_signature, sign, upload_signature, verify
|
|
9
|
+
|
|
10
|
+
# A signed URL. The operation string is passed verbatim and may
|
|
11
|
+
# contain several segments and preset: references.
|
|
12
|
+
url = build_url(
|
|
13
|
+
"https://images.example.com",
|
|
14
|
+
"your-secret",
|
|
15
|
+
"crop:1200x630:smart/filters:format(webp)",
|
|
16
|
+
"https://example.com/photo.jpg",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# An expiring URL (unix seconds), emitted as a leading exp: segment.
|
|
20
|
+
expiring = build_url(
|
|
21
|
+
"https://images.example.com",
|
|
22
|
+
"your-secret",
|
|
23
|
+
"300x200",
|
|
24
|
+
"https://example.com/photo.jpg",
|
|
25
|
+
expires=1893456000,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# A responsive srcset: one {width}x0 URL per width.
|
|
29
|
+
srcset = build_srcset(
|
|
30
|
+
"https://images.example.com",
|
|
31
|
+
"your-secret",
|
|
32
|
+
"https://example.com/photo.jpg",
|
|
33
|
+
[400, 800, 1200],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# The raw signature for a remainder, if you assemble URLs yourself.
|
|
37
|
+
signature = sign("your-secret", "300x200/https://example.com/photo.jpg")
|
|
38
|
+
|
|
39
|
+
# Verify a signature in constant time (malformed tags return False).
|
|
40
|
+
ok = verify("your-secret", signature, "300x200/https://example.com/photo.jpg")
|
|
41
|
+
|
|
42
|
+
# Purge signatures for the server's /purge and /purge-group endpoints
|
|
43
|
+
# are domain-separated under a derived subkey; the helpers sign the
|
|
44
|
+
# right strings so callers never assemble them by hand.
|
|
45
|
+
purge = purge_signature("your-secret", "300x200/https://example.com/photo.jpg")
|
|
46
|
+
group = purge_group_signature("your-secret", "thumb")
|
|
47
|
+
|
|
48
|
+
# Upload signatures for the server's PUT /upload/<signature>/<name>
|
|
49
|
+
# endpoint live in a third domain, under their own derived subkey.
|
|
50
|
+
upload = upload_signature("your-secret", "heroes/photo.jpg")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Pass `None` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode. The secret may be `str` or `bytes`.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python3 -m unittest discover -s tests
|
|
59
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crabcrop-url"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Build and sign crabcrop image URLs"
|
|
9
|
+
license = { text = "MIT" }
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Maxwell Dayvson Da Silva" }]
|
|
13
|
+
keywords = ["image", "url", "signing", "hmac", "cdn", "crabcrop"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Multimedia :: Graphics",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://crabcrop.org"
|
|
23
|
+
Repository = "https://github.com/dayvson/crabcrop"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Build and sign crabcrop URLs, per ``spec/url-signing.md``
|
|
2
|
+
in the crabcrop repository. Standard library only.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
from typing import Optional, Sequence, Union
|
|
10
|
+
|
|
11
|
+
Secret = Union[str, bytes, None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _key(secret: Union[str, bytes]) -> bytes:
|
|
15
|
+
return secret.encode("utf-8") if isinstance(secret, str) else secret
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_KEY_ID_CHARS = frozenset(
|
|
19
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_valid_key_id(key_id: str) -> bool:
|
|
24
|
+
"""Whether ``key_id`` is a well-formed key ID: 1 to 32
|
|
25
|
+
characters of ``[A-Za-z0-9_-]``."""
|
|
26
|
+
return (
|
|
27
|
+
isinstance(key_id, str)
|
|
28
|
+
and 1 <= len(key_id) <= 32
|
|
29
|
+
and all(c in _KEY_ID_CHARS for c in key_id)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sign_with_key_id(
|
|
34
|
+
secret: Union[str, bytes], key_id: str, remainder: str
|
|
35
|
+
) -> str:
|
|
36
|
+
"""The signature segment for a keyed signature:
|
|
37
|
+
``<key-id>:`` followed by the hex tag ``sign`` computes.
|
|
38
|
+
Servers verify a keyed segment against the named key
|
|
39
|
+
only, which is what makes secret rotation possible
|
|
40
|
+
without invalidating every outstanding URL."""
|
|
41
|
+
if not is_valid_key_id(key_id):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"invalid key ID {key_id!r}: 1 to 32 characters of [A-Za-z0-9_-]"
|
|
44
|
+
)
|
|
45
|
+
return f"{key_id}:{sign(secret, remainder)}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sign(secret: Union[str, bytes], remainder: str) -> str:
|
|
49
|
+
"""Compute the signature segment for a remainder: the
|
|
50
|
+
lowercase hex HMAC-SHA256 of ``remainder`` under ``secret``.
|
|
51
|
+
The remainder is everything after the signature segment in
|
|
52
|
+
the URL, exactly as it will appear."""
|
|
53
|
+
return hmac.new(_key(secret), remainder.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def purge_key(secret: Union[str, bytes]) -> bytes:
|
|
57
|
+
"""The purge subkey: the raw bytes of the HMAC-SHA256
|
|
58
|
+
of the domain label ``crabcrop/purge-key`` under the
|
|
59
|
+
secret. Purge signatures live under this derived key,
|
|
60
|
+
so an image signature can never be replayed as one."""
|
|
61
|
+
return bytes.fromhex(sign(secret, "crabcrop/purge-key"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def purge_signature(secret: Union[str, bytes], target: str) -> str:
|
|
65
|
+
"""The signature segment for
|
|
66
|
+
``POST /purge/<signature>/<path>``: HMAC-SHA256 of the
|
|
67
|
+
target path under the purge subkey."""
|
|
68
|
+
key = purge_key(secret)
|
|
69
|
+
return hmac.new(key, target.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def purge_group_signature(secret: Union[str, bytes], group: str) -> str:
|
|
73
|
+
"""The signature segment for
|
|
74
|
+
``POST /purge-group/<signature>/<group>``: a purge
|
|
75
|
+
signature over the literal string ``purge-group/<group>``,
|
|
76
|
+
which appears nowhere in the request URL. The helper
|
|
77
|
+
exists so nobody has to know the signed string."""
|
|
78
|
+
return purge_signature(secret, f"purge-group/{group}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def upload_key(secret: Union[str, bytes]) -> bytes:
|
|
82
|
+
"""The upload subkey: the raw bytes of the HMAC-SHA256
|
|
83
|
+
of the domain label ``crabcrop/upload-key`` under the
|
|
84
|
+
secret. Upload signatures live under this derived key,
|
|
85
|
+
so neither an image signature nor a purge signature can
|
|
86
|
+
be replayed as one."""
|
|
87
|
+
return bytes.fromhex(sign(secret, "crabcrop/upload-key"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def upload_signature(secret: Union[str, bytes], name: str) -> str:
|
|
91
|
+
"""The signature segment for
|
|
92
|
+
``PUT /upload/<signature>/<name>``: HMAC-SHA256 of the
|
|
93
|
+
object name under the upload subkey."""
|
|
94
|
+
key = upload_key(secret)
|
|
95
|
+
return hmac.new(key, name.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def verify(secret: Union[str, bytes], signature: str, remainder: str) -> bool:
|
|
99
|
+
"""Check a signature segment against a remainder, with
|
|
100
|
+
the Rust implementation's semantics: the expected tag
|
|
101
|
+
is recomputed and compared in constant time
|
|
102
|
+
(``hmac.compare_digest``), so a non-canonical tag (an
|
|
103
|
+
uppercase one, say) fails like any other mismatch, and
|
|
104
|
+
a malformed one returns False instead of raising."""
|
|
105
|
+
if not isinstance(signature, str) or not signature.isascii():
|
|
106
|
+
return False
|
|
107
|
+
# A keyed segment (`<key-id>:<tag>`) verifies against the
|
|
108
|
+
# one secret given here: the ID is routing metadata, and
|
|
109
|
+
# this function's question is whether this secret made
|
|
110
|
+
# the tag. A malformed ID makes the segment malformed.
|
|
111
|
+
if ":" in signature:
|
|
112
|
+
key_id, _, tag = signature.partition(":")
|
|
113
|
+
if not is_valid_key_id(key_id):
|
|
114
|
+
return False
|
|
115
|
+
signature = tag
|
|
116
|
+
return hmac.compare_digest(sign(secret, remainder), signature)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _validate_url_text(text: str, what: str) -> None:
|
|
120
|
+
"""Fail loudly on inputs that can never verify (audit
|
|
121
|
+
P-4): the remainder travels as a raw URL path, so ``?`` /
|
|
122
|
+
``#`` would truncate it and whitespace, control, and
|
|
123
|
+
non-ASCII characters cannot be sent as-is."""
|
|
124
|
+
for c in text:
|
|
125
|
+
if c in "?#" or not c.isascii() or ord(c) <= 0x20 or ord(c) == 0x7F:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"{what} contains a character that cannot appear in a crabcrop URL: {c!r}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validate_expires(expires: int) -> None:
|
|
132
|
+
"""The server accepts only an unsigned 64-bit integer
|
|
133
|
+
(audit P-5)."""
|
|
134
|
+
if (
|
|
135
|
+
isinstance(expires, bool)
|
|
136
|
+
or not isinstance(expires, int)
|
|
137
|
+
or expires < 0
|
|
138
|
+
or expires > 2**64 - 1
|
|
139
|
+
):
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"expires must be a non-negative integer of unix seconds, got {expires!r}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def build_url(
|
|
146
|
+
base: str,
|
|
147
|
+
secret: Secret,
|
|
148
|
+
operations: str,
|
|
149
|
+
source: str,
|
|
150
|
+
expires: Optional[int] = None,
|
|
151
|
+
key_id: Optional[str] = None,
|
|
152
|
+
) -> str:
|
|
153
|
+
"""Assemble a signed (or ``unsafe``) crabcrop URL.
|
|
154
|
+
``operations`` is the operation string verbatim (several
|
|
155
|
+
``/``-separated segments and ``preset:`` references
|
|
156
|
+
included). ``expires`` is a unix-seconds expiry, emitted as
|
|
157
|
+
a leading ``exp:`` segment. A ``None`` secret produces the
|
|
158
|
+
literal ``unsafe`` signature segment, for servers running
|
|
159
|
+
with unsafe or presets-only configurations."""
|
|
160
|
+
remainder = ""
|
|
161
|
+
if expires is not None:
|
|
162
|
+
_validate_expires(expires)
|
|
163
|
+
remainder += f"exp:{expires}/"
|
|
164
|
+
_validate_url_text(operations, "operations")
|
|
165
|
+
_validate_url_text(source, "source")
|
|
166
|
+
if operations:
|
|
167
|
+
remainder += f"{operations}/"
|
|
168
|
+
remainder += source
|
|
169
|
+
if secret is None:
|
|
170
|
+
if key_id is not None:
|
|
171
|
+
raise ValueError("key_id requires a secret: an unsafe URL has no key to name")
|
|
172
|
+
signature = "unsafe"
|
|
173
|
+
elif key_id is not None:
|
|
174
|
+
signature = sign_with_key_id(secret, key_id, remainder)
|
|
175
|
+
else:
|
|
176
|
+
signature = sign(secret, remainder)
|
|
177
|
+
return f"{base.rstrip('/')}/{signature}/{remainder}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _validate_width(width: int) -> None:
|
|
181
|
+
"""A srcset width becomes a ``{width}x0`` size segment,
|
|
182
|
+
so it must satisfy the grammar's dimension rules: a
|
|
183
|
+
positive integer within the server cap. Anything else
|
|
184
|
+
mints a URL the server rejects (or worse, ``-300``
|
|
185
|
+
reads as a flip), so it raises here, at build time."""
|
|
186
|
+
if (
|
|
187
|
+
isinstance(width, bool)
|
|
188
|
+
or not isinstance(width, int)
|
|
189
|
+
or width < 1
|
|
190
|
+
or width > 16383
|
|
191
|
+
):
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"srcset width must be a positive integer no larger than 16383, got {width!r}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def build_srcset(
|
|
198
|
+
base: str,
|
|
199
|
+
secret: Secret,
|
|
200
|
+
source: str,
|
|
201
|
+
widths: Sequence[int],
|
|
202
|
+
expires: Optional[int] = None,
|
|
203
|
+
) -> str:
|
|
204
|
+
"""Build an ``srcset`` attribute value: one ``{width}x0``
|
|
205
|
+
URL per width, entries formatted ``{url} {width}w`` and
|
|
206
|
+
joined by ``, ``. An expiry, when given, applies to every
|
|
207
|
+
entry."""
|
|
208
|
+
for w in widths:
|
|
209
|
+
_validate_width(w)
|
|
210
|
+
return ", ".join(
|
|
211
|
+
f"{build_url(base, secret, f'{w}x0', source, expires)} {w}w" for w in widths
|
|
212
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: crabcrop-url
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Build and sign crabcrop image URLs
|
|
5
|
+
Author: Maxwell Dayvson Da Silva
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://crabcrop.org
|
|
8
|
+
Project-URL: Repository, https://github.com/dayvson/crabcrop
|
|
9
|
+
Keywords: image,url,signing,hmac,cdn,crabcrop
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# crabcrop_url
|
|
18
|
+
|
|
19
|
+
Build and sign [crabcrop](https://github.com/dayvson/crabcrop) URLs from Python. Standard library only; the signing scheme is specified in [`spec/url-signing.md`](../../spec/url-signing.md) and this package runs the canonical [`spec/test-vectors.json`](../../spec/test-vectors.json) as its test suite, so it cannot drift from the server.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from crabcrop_url import build_url, build_srcset, purge_group_signature, purge_signature, sign, upload_signature, verify
|
|
25
|
+
|
|
26
|
+
# A signed URL. The operation string is passed verbatim and may
|
|
27
|
+
# contain several segments and preset: references.
|
|
28
|
+
url = build_url(
|
|
29
|
+
"https://images.example.com",
|
|
30
|
+
"your-secret",
|
|
31
|
+
"crop:1200x630:smart/filters:format(webp)",
|
|
32
|
+
"https://example.com/photo.jpg",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# An expiring URL (unix seconds), emitted as a leading exp: segment.
|
|
36
|
+
expiring = build_url(
|
|
37
|
+
"https://images.example.com",
|
|
38
|
+
"your-secret",
|
|
39
|
+
"300x200",
|
|
40
|
+
"https://example.com/photo.jpg",
|
|
41
|
+
expires=1893456000,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# A responsive srcset: one {width}x0 URL per width.
|
|
45
|
+
srcset = build_srcset(
|
|
46
|
+
"https://images.example.com",
|
|
47
|
+
"your-secret",
|
|
48
|
+
"https://example.com/photo.jpg",
|
|
49
|
+
[400, 800, 1200],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# The raw signature for a remainder, if you assemble URLs yourself.
|
|
53
|
+
signature = sign("your-secret", "300x200/https://example.com/photo.jpg")
|
|
54
|
+
|
|
55
|
+
# Verify a signature in constant time (malformed tags return False).
|
|
56
|
+
ok = verify("your-secret", signature, "300x200/https://example.com/photo.jpg")
|
|
57
|
+
|
|
58
|
+
# Purge signatures for the server's /purge and /purge-group endpoints
|
|
59
|
+
# are domain-separated under a derived subkey; the helpers sign the
|
|
60
|
+
# right strings so callers never assemble them by hand.
|
|
61
|
+
purge = purge_signature("your-secret", "300x200/https://example.com/photo.jpg")
|
|
62
|
+
group = purge_group_signature("your-secret", "thumb")
|
|
63
|
+
|
|
64
|
+
# Upload signatures for the server's PUT /upload/<signature>/<name>
|
|
65
|
+
# endpoint live in a third domain, under their own derived subkey.
|
|
66
|
+
upload = upload_signature("your-secret", "heroes/photo.jpg")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Pass `None` as the secret to produce the literal `unsafe` signature segment, for servers running with `CRABCROP_ALLOW_UNSAFE` or in presets-only mode. The secret may be `str` or `bytes`.
|
|
70
|
+
|
|
71
|
+
## Tests
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python3 -m unittest discover -s tests
|
|
75
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
crabcrop_url
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The package's signing suite is the canonical vector file
|
|
2
|
+
from the spec directory, shared by every implementation."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
10
|
+
|
|
11
|
+
from crabcrop_url import ( # noqa: E402
|
|
12
|
+
build_srcset,
|
|
13
|
+
build_url,
|
|
14
|
+
purge_group_signature,
|
|
15
|
+
purge_signature,
|
|
16
|
+
sign,
|
|
17
|
+
upload_signature,
|
|
18
|
+
sign_with_key_id,
|
|
19
|
+
verify,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
VECTORS = json.loads(
|
|
23
|
+
(Path(__file__).resolve().parents[3] / "spec" / "test-vectors.json").read_text()
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestVectors(unittest.TestCase):
|
|
28
|
+
def test_all_vectors(self):
|
|
29
|
+
self.assertGreaterEqual(len(VECTORS), 5)
|
|
30
|
+
for v in VECTORS:
|
|
31
|
+
with self.subTest(description=v["description"]):
|
|
32
|
+
self.assertEqual(sign(v["secret"], v["remainder"]), v["signature"])
|
|
33
|
+
|
|
34
|
+
def test_verify_accepts_the_vectors_and_rejects_tampering(self):
|
|
35
|
+
for v in VECTORS:
|
|
36
|
+
with self.subTest(description=v["description"]):
|
|
37
|
+
self.assertTrue(verify(v["secret"], v["signature"], v["remainder"]))
|
|
38
|
+
self.assertFalse(verify(v["secret"], v["signature"], v["remainder"] + "x"))
|
|
39
|
+
self.assertFalse(verify("wrong-secret", v["signature"], v["remainder"]))
|
|
40
|
+
# A non-canonical tag fails: the server
|
|
41
|
+
# accepts only the lowercase spelling.
|
|
42
|
+
self.assertFalse(
|
|
43
|
+
verify(v["secret"], v["signature"].upper(), v["remainder"])
|
|
44
|
+
)
|
|
45
|
+
self.assertFalse(verify("s", "not-a-signature", "300x200/https://e.com/a.jpg"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TestPurgeSignatures(unittest.TestCase):
|
|
49
|
+
def test_published_vectors(self):
|
|
50
|
+
# The same pinned literals the Rust crate and the
|
|
51
|
+
# TypeScript SDK assert.
|
|
52
|
+
self.assertEqual(
|
|
53
|
+
purge_signature("test-secret", "300x200/https://example.com/a.jpg"),
|
|
54
|
+
"9b58a43a984e47cbde3ec08333290acad06c48a5e9b85b99124ae725218e90e8",
|
|
55
|
+
)
|
|
56
|
+
self.assertEqual(
|
|
57
|
+
purge_group_signature("test-secret", "thumb"),
|
|
58
|
+
"2f779d54c25154a7528fb334973dd2c92e08b2575ee1359a7d426421b083b5d6",
|
|
59
|
+
)
|
|
60
|
+
self.assertEqual(
|
|
61
|
+
purge_group_signature("test-secret", "thumb"),
|
|
62
|
+
purge_signature("test-secret", "purge-group/thumb"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestUploadSignatures(unittest.TestCase):
|
|
67
|
+
def test_published_vectors(self):
|
|
68
|
+
# The same pinned literals the Rust crate and the
|
|
69
|
+
# TypeScript SDK assert.
|
|
70
|
+
self.assertEqual(
|
|
71
|
+
upload_signature("test-secret", "heroes/cat.jpg"),
|
|
72
|
+
"81d87b588203fdad32f5431c0a893bf7ddad8188f5cca123a1b057a439b34b84",
|
|
73
|
+
)
|
|
74
|
+
self.assertNotEqual(
|
|
75
|
+
upload_signature("test-secret", "heroes/cat.jpg"),
|
|
76
|
+
purge_signature("test-secret", "heroes/cat.jpg"),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestBuilders(unittest.TestCase):
|
|
81
|
+
def test_spec_worked_example(self):
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
build_url(
|
|
84
|
+
"https://images.example.com/",
|
|
85
|
+
"your-secret",
|
|
86
|
+
"300x200",
|
|
87
|
+
"https://example.com/photo.jpg",
|
|
88
|
+
),
|
|
89
|
+
"https://images.example.com/"
|
|
90
|
+
"3c1f6c180116661877bd4c85fd201a178087d74c1cc188cc584e819d901308c6"
|
|
91
|
+
"/300x200/https://example.com/photo.jpg",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def test_expiry_and_null_secret(self):
|
|
95
|
+
self.assertEqual(
|
|
96
|
+
build_url(
|
|
97
|
+
"https://img.example.com",
|
|
98
|
+
None,
|
|
99
|
+
"preset:thumb",
|
|
100
|
+
"s3://media/a.jpg",
|
|
101
|
+
expires=1893456000,
|
|
102
|
+
),
|
|
103
|
+
"https://img.example.com/unsafe/exp:1893456000/preset:thumb/s3://media/a.jpg",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_srcset(self):
|
|
107
|
+
self.assertEqual(
|
|
108
|
+
build_srcset(
|
|
109
|
+
"https://img.example.com", None, "https://example.com/a.jpg", [320, 640]
|
|
110
|
+
),
|
|
111
|
+
"https://img.example.com/unsafe/320x0/https://example.com/a.jpg 320w, "
|
|
112
|
+
"https://img.example.com/unsafe/640x0/https://example.com/a.jpg 640w",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def test_srcset_rejects_widths_the_grammar_cannot_express(self):
|
|
116
|
+
# 320.5 is not an integer, -300 is the flip spelling,
|
|
117
|
+
# 0 is not a size, and 16384 is past the server cap:
|
|
118
|
+
# each would mint a URL the server rejects.
|
|
119
|
+
for bad in (320.5, -300, 0, 16384, True):
|
|
120
|
+
with self.assertRaises(ValueError):
|
|
121
|
+
build_srcset(
|
|
122
|
+
"https://img.example.com", None, "https://example.com/a.jpg", [bad]
|
|
123
|
+
)
|
|
124
|
+
build_srcset(
|
|
125
|
+
"https://img.example.com", None, "https://example.com/a.jpg", [1, 16383]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_builders_reject_unbuildable_urls(self):
|
|
130
|
+
# Audit P-4 and P-5: these can never verify, so the
|
|
131
|
+
# builders raise instead of minting dead URLs.
|
|
132
|
+
for source in (
|
|
133
|
+
"https://example.com/a.jpg?w=1",
|
|
134
|
+
"https://example.com/a.jpg#frag",
|
|
135
|
+
"https://example.com/my photo.jpg",
|
|
136
|
+
"https://example.com/café.jpg",
|
|
137
|
+
):
|
|
138
|
+
with self.assertRaises(ValueError, msg=source):
|
|
139
|
+
build_url("https://img.example.com", b"k", "300x200", source)
|
|
140
|
+
for bad_expires in (-1, 1.5, True, 2**64):
|
|
141
|
+
with self.assertRaises(ValueError, msg=repr(bad_expires)):
|
|
142
|
+
build_url(
|
|
143
|
+
"https://img.example.com",
|
|
144
|
+
b"k",
|
|
145
|
+
"300x200",
|
|
146
|
+
"https://example.com/a.jpg",
|
|
147
|
+
expires=bad_expires,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class TestKeyIds(unittest.TestCase):
|
|
152
|
+
def test_sign_with_key_id_stamps_the_segment(self):
|
|
153
|
+
remainder = "300x200/example.com/i.jpg"
|
|
154
|
+
self.assertEqual(
|
|
155
|
+
sign_with_key_id("test-secret", "k2", remainder),
|
|
156
|
+
f"k2:{sign('test-secret', remainder)}",
|
|
157
|
+
)
|
|
158
|
+
self.assertEqual(
|
|
159
|
+
build_url(
|
|
160
|
+
"https://img.example.com",
|
|
161
|
+
"test-secret",
|
|
162
|
+
"300x200",
|
|
163
|
+
"example.com/i.jpg",
|
|
164
|
+
key_id="k2",
|
|
165
|
+
),
|
|
166
|
+
f"https://img.example.com/k2:{sign('test-secret', remainder)}/300x200/example.com/i.jpg",
|
|
167
|
+
)
|
|
168
|
+
with self.assertRaises(ValueError):
|
|
169
|
+
sign_with_key_id("test-secret", "bad id", remainder)
|
|
170
|
+
with self.assertRaises(ValueError):
|
|
171
|
+
build_url(
|
|
172
|
+
"https://img.example.com",
|
|
173
|
+
None,
|
|
174
|
+
"300x200",
|
|
175
|
+
"example.com/i.jpg",
|
|
176
|
+
key_id="k2",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def test_verify_accepts_a_keyed_segment_for_its_secret(self):
|
|
180
|
+
remainder = "300x200/example.com/i.jpg"
|
|
181
|
+
segment = sign_with_key_id("old-secret", "k-old", remainder)
|
|
182
|
+
self.assertTrue(verify("old-secret", segment, remainder))
|
|
183
|
+
self.assertFalse(verify("new-secret", segment, remainder))
|
|
184
|
+
self.assertFalse(verify("old-secret", "bad id:" + sign("old-secret", remainder), remainder))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
unittest.main()
|