scalio-connect 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.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: scalio-connect
3
+ Version: 0.1.0
4
+ Summary: Signed calls between the Scalio apps and Compass (format v2)
5
+ License: Proprietary
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: cryptography>=42
9
+ Provides-Extra: web
10
+ Requires-Dist: pymongo>=4.6; extra == "web"
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == "dev"
13
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
14
+ Requires-Dist: pymongo>=4.6; extra == "dev"
15
+ Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
16
+ Requires-Dist: typing_extensions>=4.9; extra == "dev"
17
+ Requires-Dist: ruff>=0.6; extra == "dev"
18
+
19
+ # scalio-connect
20
+
21
+ Signed calls between the Scalio apps and Compass. One format ("v2", header `Scalio-Signature`) for every
22
+ direction:
23
+
24
+ - Compass -> app: control key K3 on `/api/provision/*` (replaces the old `X-Provision-Control-Signature`)
25
+ - app -> Compass: instance key on the support intake `/api/support/v1/*` (planned)
26
+
27
+ Contract: scalio-docs `interfaces/signed-request-v2.md`. Decision: scalio-docs ADR 0020.
28
+
29
+ ## What is signed
30
+
31
+ ```
32
+ Scalio-Signature: v=2,kid=k3,ts=1793131200,nonce=vector-nonce-0000000001,sig=<base64 Ed25519>
33
+
34
+ scalio-sig-v2
35
+ <kid>
36
+ <METHOD>
37
+ <audience = receiver origin, e.g. https://acme.eu.scalionexus.com>
38
+ <path and query as sent, e.g. /api/provision/admin>
39
+ <ts>
40
+ <nonce>
41
+ <sha256 of the raw body, hex>
42
+ ```
43
+
44
+ Lines joined by `\n`, no line break at the end. Time window +-300 s, each (kid, nonce) once.
45
+ Because receiver and path are signed, a captured request does not work on another instance or another
46
+ endpoint (security review H9).
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ from scalio_connect import sign, verify, SignatureError
52
+ from scalio_connect.nonces import use_nonce
53
+
54
+ # sender
55
+ headers = sign(private_key, "k3", "POST", "https://acme.eu.scalionexus.com/api/provision/admin", body)
56
+
57
+ # receiver (raw body bytes, path with query as received)
58
+ try:
59
+ v = verify(request.headers["Scalio-Signature"], request.method, path_with_query, body,
60
+ audiences=["https://acme.eu.scalionexus.com"], keys_for=lambda kid: k3_keys if kid == "k3" else [])
61
+ except SignatureError:
62
+ raise HTTPException(401)
63
+ if not await use_nonce(db, v.kid, v.nonce):
64
+ raise HTTPException(401)
65
+ ```
66
+
67
+ `audiences` are the receiver's own public origins from its configuration. Do not take them from the
68
+ `Host` header.
69
+
70
+ ## Development
71
+
72
+ ```
73
+ pip install -e ".[dev]"
74
+ ruff check . && pytest -q
75
+ ```
76
+
77
+ `tests/vector.json` is the shared test vector (throwaway seed). Compass and every app test against the same file.
78
+ Release: bump `pyproject.toml`, CHANGELOG line, merge, then "Publish" (cockpit or Actions) with the version, no `v`.
@@ -0,0 +1,60 @@
1
+ # scalio-connect
2
+
3
+ Signed calls between the Scalio apps and Compass. One format ("v2", header `Scalio-Signature`) for every
4
+ direction:
5
+
6
+ - Compass -> app: control key K3 on `/api/provision/*` (replaces the old `X-Provision-Control-Signature`)
7
+ - app -> Compass: instance key on the support intake `/api/support/v1/*` (planned)
8
+
9
+ Contract: scalio-docs `interfaces/signed-request-v2.md`. Decision: scalio-docs ADR 0020.
10
+
11
+ ## What is signed
12
+
13
+ ```
14
+ Scalio-Signature: v=2,kid=k3,ts=1793131200,nonce=vector-nonce-0000000001,sig=<base64 Ed25519>
15
+
16
+ scalio-sig-v2
17
+ <kid>
18
+ <METHOD>
19
+ <audience = receiver origin, e.g. https://acme.eu.scalionexus.com>
20
+ <path and query as sent, e.g. /api/provision/admin>
21
+ <ts>
22
+ <nonce>
23
+ <sha256 of the raw body, hex>
24
+ ```
25
+
26
+ Lines joined by `\n`, no line break at the end. Time window +-300 s, each (kid, nonce) once.
27
+ Because receiver and path are signed, a captured request does not work on another instance or another
28
+ endpoint (security review H9).
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ from scalio_connect import sign, verify, SignatureError
34
+ from scalio_connect.nonces import use_nonce
35
+
36
+ # sender
37
+ headers = sign(private_key, "k3", "POST", "https://acme.eu.scalionexus.com/api/provision/admin", body)
38
+
39
+ # receiver (raw body bytes, path with query as received)
40
+ try:
41
+ v = verify(request.headers["Scalio-Signature"], request.method, path_with_query, body,
42
+ audiences=["https://acme.eu.scalionexus.com"], keys_for=lambda kid: k3_keys if kid == "k3" else [])
43
+ except SignatureError:
44
+ raise HTTPException(401)
45
+ if not await use_nonce(db, v.kid, v.nonce):
46
+ raise HTTPException(401)
47
+ ```
48
+
49
+ `audiences` are the receiver's own public origins from its configuration. Do not take them from the
50
+ `Host` header.
51
+
52
+ ## Development
53
+
54
+ ```
55
+ pip install -e ".[dev]"
56
+ ruff check . && pytest -q
57
+ ```
58
+
59
+ `tests/vector.json` is the shared test vector (throwaway seed). Compass and every app test against the same file.
60
+ Release: bump `pyproject.toml`, CHANGELOG line, merge, then "Publish" (cockpit or Actions) with the version, no `v`.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scalio-connect"
7
+ version = "0.1.0"
8
+ description = "Signed calls between the Scalio apps and Compass (format v2)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Proprietary" }
12
+ dependencies = ["cryptography>=42"]
13
+
14
+ [project.optional-dependencies]
15
+ web = ["pymongo>=4.6"]
16
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "pymongo>=4.6", "mongomock-motor>=0.0.29", "typing_extensions>=4.9", "ruff>=0.6"]
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.pytest.ini_options]
22
+ asyncio_mode = "auto"
23
+ testpaths = ["tests"]
24
+
25
+ [tool.ruff]
26
+ line-length = 120
27
+ target-version = "py311"
28
+
29
+ [tool.ruff.lint]
30
+ select = ["E", "F", "W", "I", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ """Signed calls between the Scalio apps and Compass. See README.md."""
2
+ from .signing import (
3
+ HEADER,
4
+ SKEW_SECONDS,
5
+ SignatureError,
6
+ Verified,
7
+ canonical,
8
+ load_private_key,
9
+ origin,
10
+ parse_header,
11
+ public_key_b64,
12
+ sign,
13
+ verify,
14
+ )
15
+
16
+ __all__ = ["HEADER", "SKEW_SECONDS", "SignatureError", "Verified", "canonical", "load_private_key", "origin",
17
+ "parse_header", "public_key_b64", "sign", "verify"]
18
+ __version__ = "0.1.0"
@@ -0,0 +1,26 @@
1
+ """Single use of nonces, stored in MongoDB (Motor or mongomock-motor).
2
+
3
+ Each (kid, nonce) pair is accepted once. Entries expire after NONCE_TTL_SECONDS, which is longer than the
4
+ time window, so a nonce cannot come back after it was deleted.
5
+ """
6
+ from datetime import datetime, timezone
7
+
8
+ from .signing import NONCE_TTL_SECONDS
9
+
10
+ COLLECTION = "scalio_sig_nonces"
11
+
12
+
13
+ async def ensure_indexes(db):
14
+ await db[COLLECTION].create_index([("kid", 1), ("nonce", 1)], unique=True)
15
+ await db[COLLECTION].create_index("used_at", expireAfterSeconds=NONCE_TTL_SECONDS)
16
+
17
+
18
+ async def use_nonce(db, kid: str, nonce: str) -> bool:
19
+ """True the first time this nonce is seen for this kid, False on a replay."""
20
+ from pymongo.errors import DuplicateKeyError
21
+ await ensure_indexes(db)
22
+ try:
23
+ await db[COLLECTION].insert_one({"kid": kid, "nonce": nonce, "used_at": datetime.now(timezone.utc)})
24
+ except DuplicateKeyError:
25
+ return False
26
+ return True
@@ -0,0 +1,179 @@
1
+ """Signed requests, format v2 ("Scalio-Signature").
2
+
3
+ One format for every signed call between Scalio services: Compass -> app (K3), app -> Compass (instance key),
4
+ later Compass -> engine. Contract: scalio-docs interfaces/signed-request-v2.md.
5
+
6
+ The signature covers key id, method, receiver origin, path with query, time, nonce and a hash of the body. A
7
+ signed request therefore only works once, only at the receiver it was made for and only on that endpoint.
8
+
9
+ Scalio-Signature: v=2,kid=k3,ts=1793131200,nonce=4f0c...,sig=<base64 Ed25519>
10
+
11
+ Signed text (UTF-8, lines joined by "\\n", no line break at the end):
12
+
13
+ scalio-sig-v2
14
+ <kid>
15
+ <METHOD>
16
+ <audience> receiver origin, e.g. https://acme.eu.scalionexus.com
17
+ <path> path and query exactly as sent, e.g. /api/provision/admin
18
+ <ts>
19
+ <nonce>
20
+ <sha256 of the raw body, lower-case hex>
21
+ """
22
+ import base64
23
+ import binascii
24
+ import hashlib
25
+ import re
26
+ import secrets
27
+ import time
28
+ from dataclasses import dataclass
29
+ from typing import Callable, Iterable
30
+ from urllib.parse import urlsplit
31
+
32
+ from cryptography.exceptions import InvalidSignature
33
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
34
+
35
+ HEADER = "Scalio-Signature"
36
+ VERSION = "2"
37
+ PREFIX = "scalio-sig-v2"
38
+ SKEW_SECONDS = 300
39
+ NONCE_TTL_SECONDS = 2 * SKEW_SECONDS + 60
40
+
41
+ _KID = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$")
42
+ _NONCE = re.compile(r"^[A-Za-z0-9_-]{16,128}$")
43
+ _METHOD = re.compile(r"^[A-Z]{3,7}$")
44
+
45
+
46
+ class SignatureError(Exception):
47
+ """The request is not signed correctly. The text is for the log, not for the caller."""
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Verified:
52
+ kid: str
53
+ nonce: str
54
+ ts: int
55
+
56
+
57
+ def b64d(s: str) -> bytes:
58
+ s = (s or "").strip()
59
+ return base64.b64decode(s + "=" * (-len(s) % 4), validate=True)
60
+
61
+
62
+ def b64e(b: bytes) -> str:
63
+ return base64.b64encode(b).decode()
64
+
65
+
66
+ def origin(url: str) -> str:
67
+ """Normalised origin "scheme://host[:port]" (lower case, default ports dropped), "" if not a http(s) URL."""
68
+ parts = urlsplit((url or "").strip())
69
+ scheme = parts.scheme.lower()
70
+ if scheme not in ("http", "https") or not parts.hostname or parts.username or parts.password:
71
+ return ""
72
+ host = parts.hostname.lower()
73
+ if ":" in host:
74
+ host = f"[{host}]"
75
+ try:
76
+ port = parts.port
77
+ except ValueError:
78
+ return ""
79
+ if port and not ((scheme == "https" and port == 443) or (scheme == "http" and port == 80)):
80
+ host = f"{host}:{port}"
81
+ return f"{scheme}://{host}"
82
+
83
+
84
+ def body_hash(body: bytes | None) -> str:
85
+ return hashlib.sha256(body or b"").hexdigest()
86
+
87
+
88
+ def canonical(kid: str, method: str, audience: str, path: str, ts: int | str, nonce: str, body: bytes | None) -> bytes:
89
+ """The exact bytes that are signed."""
90
+ aud = origin(audience)
91
+ if not aud:
92
+ raise SignatureError("audience is not an http(s) origin")
93
+ if not _KID.match(kid or ""):
94
+ raise SignatureError("invalid kid")
95
+ if not _NONCE.match(nonce or ""):
96
+ raise SignatureError("invalid nonce")
97
+ m = (method or "").upper()
98
+ if not _METHOD.match(m):
99
+ raise SignatureError("invalid method")
100
+ if not path or not path.startswith("/") or "\n" in path:
101
+ raise SignatureError("invalid path")
102
+ lines = [PREFIX, kid, m, aud, path, str(int(ts)), nonce, body_hash(body)]
103
+ return "\n".join(lines).encode("utf-8")
104
+
105
+
106
+ def new_nonce() -> str:
107
+ return secrets.token_urlsafe(24)
108
+
109
+
110
+ def load_private_key(seed_b64: str) -> Ed25519PrivateKey:
111
+ return Ed25519PrivateKey.from_private_bytes(b64d(seed_b64))
112
+
113
+
114
+ def public_key_b64(key: Ed25519PrivateKey) -> str:
115
+ from cryptography.hazmat.primitives import serialization
116
+ raw = key.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
117
+ return b64e(raw)
118
+
119
+
120
+ def sign(key: Ed25519PrivateKey, kid: str, method: str, url: str, body: bytes | None = None,
121
+ ts: int | None = None, nonce: str | None = None) -> dict:
122
+ """Headers to add to the request to `url` (full URL with path and query, as it is sent)."""
123
+ parts = urlsplit(url)
124
+ path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
125
+ ts = int(time.time()) if ts is None else int(ts)
126
+ nonce = nonce or new_nonce()
127
+ sig = key.sign(canonical(kid, method, url, path, ts, nonce, body))
128
+ return {HEADER: f"v={VERSION},kid={kid},ts={ts},nonce={nonce},sig={b64e(sig)}"}
129
+
130
+
131
+ def parse_header(value: str) -> dict:
132
+ fields = {}
133
+ for part in (value or "").split(","):
134
+ k, sep, v = part.strip().partition("=")
135
+ if not sep or k in fields:
136
+ raise SignatureError("malformed signature header")
137
+ fields[k] = v.strip()
138
+ if fields.get("v") != VERSION:
139
+ raise SignatureError("unsupported signature version")
140
+ for k in ("kid", "ts", "nonce", "sig"):
141
+ if not fields.get(k):
142
+ raise SignatureError(f"signature header without {k}")
143
+ return fields
144
+
145
+
146
+ def verify(header: str, method: str, path: str, body: bytes | None, audiences: Iterable[str],
147
+ keys_for: Callable[[str], Iterable[str]], now: float | None = None) -> Verified:
148
+ """Check a received request. Raises SignatureError, else returns kid, nonce and time.
149
+
150
+ audiences: the receiver's own public origins; the signed audience must be one of them.
151
+ keys_for(kid): base64 public keys allowed for that kid (empty = unknown kid).
152
+ The caller must still claim the nonce once (see nonces.use_nonce) before acting.
153
+ """
154
+ f = parse_header(header)
155
+ try:
156
+ ts = int(f["ts"])
157
+ except ValueError as e:
158
+ raise SignatureError("invalid timestamp") from e
159
+ if abs((time.time() if now is None else now) - ts) > SKEW_SECONDS:
160
+ raise SignatureError("timestamp outside the allowed window")
161
+ allowed = {origin(a) for a in audiences} - {""}
162
+ if not allowed:
163
+ raise SignatureError("receiver has no audience configured")
164
+ try:
165
+ sig = b64d(f["sig"])
166
+ except (binascii.Error, ValueError) as e:
167
+ raise SignatureError("signature is not base64") from e
168
+ keys = [k for k in (keys_for(f["kid"]) or []) if k]
169
+ if not keys:
170
+ raise SignatureError("unknown kid")
171
+ for aud in sorted(allowed):
172
+ msg = canonical(f["kid"], method, aud, path, ts, f["nonce"], body)
173
+ for k in keys:
174
+ try:
175
+ Ed25519PublicKey.from_public_bytes(b64d(k)).verify(sig, msg)
176
+ return Verified(kid=f["kid"], nonce=f["nonce"], ts=ts)
177
+ except (InvalidSignature, ValueError, binascii.Error):
178
+ continue
179
+ raise SignatureError("signature does not match")
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: scalio-connect
3
+ Version: 0.1.0
4
+ Summary: Signed calls between the Scalio apps and Compass (format v2)
5
+ License: Proprietary
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: cryptography>=42
9
+ Provides-Extra: web
10
+ Requires-Dist: pymongo>=4.6; extra == "web"
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == "dev"
13
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
14
+ Requires-Dist: pymongo>=4.6; extra == "dev"
15
+ Requires-Dist: mongomock-motor>=0.0.29; extra == "dev"
16
+ Requires-Dist: typing_extensions>=4.9; extra == "dev"
17
+ Requires-Dist: ruff>=0.6; extra == "dev"
18
+
19
+ # scalio-connect
20
+
21
+ Signed calls between the Scalio apps and Compass. One format ("v2", header `Scalio-Signature`) for every
22
+ direction:
23
+
24
+ - Compass -> app: control key K3 on `/api/provision/*` (replaces the old `X-Provision-Control-Signature`)
25
+ - app -> Compass: instance key on the support intake `/api/support/v1/*` (planned)
26
+
27
+ Contract: scalio-docs `interfaces/signed-request-v2.md`. Decision: scalio-docs ADR 0020.
28
+
29
+ ## What is signed
30
+
31
+ ```
32
+ Scalio-Signature: v=2,kid=k3,ts=1793131200,nonce=vector-nonce-0000000001,sig=<base64 Ed25519>
33
+
34
+ scalio-sig-v2
35
+ <kid>
36
+ <METHOD>
37
+ <audience = receiver origin, e.g. https://acme.eu.scalionexus.com>
38
+ <path and query as sent, e.g. /api/provision/admin>
39
+ <ts>
40
+ <nonce>
41
+ <sha256 of the raw body, hex>
42
+ ```
43
+
44
+ Lines joined by `\n`, no line break at the end. Time window +-300 s, each (kid, nonce) once.
45
+ Because receiver and path are signed, a captured request does not work on another instance or another
46
+ endpoint (security review H9).
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ from scalio_connect import sign, verify, SignatureError
52
+ from scalio_connect.nonces import use_nonce
53
+
54
+ # sender
55
+ headers = sign(private_key, "k3", "POST", "https://acme.eu.scalionexus.com/api/provision/admin", body)
56
+
57
+ # receiver (raw body bytes, path with query as received)
58
+ try:
59
+ v = verify(request.headers["Scalio-Signature"], request.method, path_with_query, body,
60
+ audiences=["https://acme.eu.scalionexus.com"], keys_for=lambda kid: k3_keys if kid == "k3" else [])
61
+ except SignatureError:
62
+ raise HTTPException(401)
63
+ if not await use_nonce(db, v.kid, v.nonce):
64
+ raise HTTPException(401)
65
+ ```
66
+
67
+ `audiences` are the receiver's own public origins from its configuration. Do not take them from the
68
+ `Host` header.
69
+
70
+ ## Development
71
+
72
+ ```
73
+ pip install -e ".[dev]"
74
+ ruff check . && pytest -q
75
+ ```
76
+
77
+ `tests/vector.json` is the shared test vector (throwaway seed). Compass and every app test against the same file.
78
+ Release: bump `pyproject.toml`, CHANGELOG line, merge, then "Publish" (cockpit or Actions) with the version, no `v`.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/scalio_connect/__init__.py
4
+ src/scalio_connect/nonces.py
5
+ src/scalio_connect/signing.py
6
+ src/scalio_connect.egg-info/PKG-INFO
7
+ src/scalio_connect.egg-info/SOURCES.txt
8
+ src/scalio_connect.egg-info/dependency_links.txt
9
+ src/scalio_connect.egg-info/requires.txt
10
+ src/scalio_connect.egg-info/top_level.txt
11
+ tests/test_nonces.py
12
+ tests/test_signing.py
@@ -0,0 +1,12 @@
1
+ cryptography>=42
2
+
3
+ [dev]
4
+ pytest>=8
5
+ pytest-asyncio>=0.23
6
+ pymongo>=4.6
7
+ mongomock-motor>=0.0.29
8
+ typing_extensions>=4.9
9
+ ruff>=0.6
10
+
11
+ [web]
12
+ pymongo>=4.6
@@ -0,0 +1 @@
1
+ scalio_connect
@@ -0,0 +1,10 @@
1
+ from mongomock_motor import AsyncMongoMockClient
2
+
3
+ from scalio_connect.nonces import use_nonce
4
+
5
+
6
+ async def test_nonce_only_once_per_kid():
7
+ db = AsyncMongoMockClient()["t"]
8
+ assert await use_nonce(db, "k3", "n" * 16) is True
9
+ assert await use_nonce(db, "k3", "n" * 16) is False
10
+ assert await use_nonce(db, "inst-1", "n" * 16) is True
@@ -0,0 +1,169 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
6
+
7
+ from scalio_connect import (
8
+ HEADER,
9
+ SignatureError,
10
+ canonical,
11
+ load_private_key,
12
+ origin,
13
+ parse_header,
14
+ public_key_b64,
15
+ sign,
16
+ verify,
17
+ )
18
+
19
+ VECTOR = json.loads((Path(__file__).parent / "vector.json").read_text())
20
+ NOW = 1793131200
21
+ URL = "https://acme.eu.scalionexus.com/api/provision/admin"
22
+ BODY = b'{"email":"admin@acme.example"}'
23
+
24
+
25
+ @pytest.fixture
26
+ def key():
27
+ return Ed25519PrivateKey.generate()
28
+
29
+
30
+ def keys_for(key, kid="k3"):
31
+ return lambda k: [public_key_b64(key)] if k == kid else []
32
+
33
+
34
+ def _verify(header, key, method="POST", path="/api/provision/admin", body=BODY,
35
+ audiences=("https://acme.eu.scalionexus.com",), now=NOW, kid="k3"):
36
+ return verify(header, method, path, body, audiences, keys_for(key, kid), now=now)
37
+
38
+
39
+ # Shared vector: Compass and every app must produce and accept exactly this.
40
+
41
+ def test_vector_canonical_and_header():
42
+ k = load_private_key(VECTOR["seed"])
43
+ assert public_key_b64(k) == VECTOR["public_key"]
44
+ body = VECTOR["body"].encode()
45
+ msg = canonical(VECTOR["kid"], VECTOR["method"], VECTOR["url"], VECTOR["path"], VECTOR["ts"], VECTOR["nonce"], body)
46
+ assert msg.decode() == VECTOR["canonical"]
47
+ h = sign(k, VECTOR["kid"], VECTOR["method"], VECTOR["url"], body, ts=VECTOR["ts"], nonce=VECTOR["nonce"])
48
+ assert h[HEADER] == VECTOR["header"]
49
+
50
+
51
+ def test_vector_verifies():
52
+ v = verify(VECTOR["header"], VECTOR["method"], VECTOR["path"], VECTOR["body"].encode(), [VECTOR["url"]],
53
+ lambda kid: [VECTOR["public_key"]], now=VECTOR["ts"])
54
+ assert (v.kid, v.nonce, v.ts) == ("k3", VECTOR["nonce"], VECTOR["ts"])
55
+
56
+
57
+ # Normal use
58
+
59
+ def test_roundtrip(key):
60
+ h = sign(key, "k3", "POST", URL, BODY, ts=NOW)[HEADER]
61
+ v = _verify(h, key)
62
+ assert v.kid == "k3" and len(v.nonce) >= 16
63
+
64
+
65
+ def test_query_is_signed(key):
66
+ h = sign(key, "k3", "GET", "https://compass.example/api/support/v1/tickets?mine=1", None, ts=NOW)[HEADER]
67
+ verify(h, "GET", "/api/support/v1/tickets?mine=1", b"", ["https://compass.example"], keys_for(key), now=NOW)
68
+ with pytest.raises(SignatureError):
69
+ verify(h, "GET", "/api/support/v1/tickets?mine=0", b"", ["https://compass.example"], keys_for(key), now=NOW)
70
+
71
+
72
+ def test_rotation_accepts_any_listed_key(key):
73
+ old = Ed25519PrivateKey.generate()
74
+ h = sign(key, "k3", "POST", URL, BODY, ts=NOW)[HEADER]
75
+ v = verify(h, "POST", "/api/provision/admin", BODY, [URL], lambda k: [public_key_b64(old), public_key_b64(key)],
76
+ now=NOW)
77
+ assert v.kid == "k3"
78
+
79
+
80
+ def test_receiver_with_several_origins(key):
81
+ h = sign(key, "k3", "POST", "https://custom.acme.example/api/provision/admin", BODY, ts=NOW)[HEADER]
82
+ _verify(h, key, audiences=("https://acme.eu.scalionexus.com", "https://custom.acme.example"))
83
+
84
+
85
+ # What H9 is about: a captured request must not work anywhere else.
86
+
87
+ def test_replay_to_other_instance_rejected(key):
88
+ h = sign(key, "k3", "POST", URL, BODY, ts=NOW)[HEADER]
89
+ with pytest.raises(SignatureError):
90
+ _verify(h, key, audiences=("https://other.eu.scalionexus.com",))
91
+
92
+
93
+ def test_replay_to_other_endpoint_rejected(key):
94
+ h = sign(key, "k3", "POST", "https://acme.eu.scalionexus.com/api/provision/suspend", b"{}", ts=NOW)[HEADER]
95
+ with pytest.raises(SignatureError):
96
+ _verify(h, key, path="/api/provision/resume", body=b"{}")
97
+
98
+
99
+ def test_other_method_rejected(key):
100
+ h = sign(key, "k3", "POST", URL, BODY, ts=NOW)[HEADER]
101
+ with pytest.raises(SignatureError):
102
+ _verify(h, key, method="PUT")
103
+
104
+
105
+ def test_changed_body_rejected(key):
106
+ h = sign(key, "k3", "POST", URL, BODY, ts=NOW)[HEADER]
107
+ with pytest.raises(SignatureError):
108
+ _verify(h, key, body=b'{"email":"evil@example.com"}')
109
+
110
+
111
+ def test_other_kid_rejected(key):
112
+ h = sign(key, "inst-1", "POST", URL, BODY, ts=NOW)[HEADER]
113
+ with pytest.raises(SignatureError):
114
+ _verify(h, key, kid="inst-2")
115
+ # the kid is part of the signed text: swapping it in the header breaks the signature
116
+ swapped = h.replace("kid=inst-1", "kid=inst-2")
117
+ with pytest.raises(SignatureError):
118
+ verify(swapped, "POST", "/api/provision/admin", BODY, [URL], lambda k: [public_key_b64(key)], now=NOW)
119
+
120
+
121
+ def test_wrong_key_rejected(key):
122
+ h = sign(Ed25519PrivateKey.generate(), "k3", "POST", URL, BODY, ts=NOW)[HEADER]
123
+ with pytest.raises(SignatureError):
124
+ _verify(h, key)
125
+
126
+
127
+ @pytest.mark.parametrize("delta", [-301, 301])
128
+ def test_time_window(key, delta):
129
+ h = sign(key, "k3", "POST", URL, BODY, ts=NOW)[HEADER]
130
+ _verify(h, key, now=NOW + delta - (1 if delta > 0 else -1))
131
+ with pytest.raises(SignatureError):
132
+ _verify(h, key, now=NOW + delta)
133
+
134
+
135
+ def test_receiver_without_audience_refuses(key):
136
+ h = sign(key, "k3", "POST", URL, BODY, ts=NOW)[HEADER]
137
+ with pytest.raises(SignatureError):
138
+ _verify(h, key, audiences=("", "not a url"))
139
+
140
+
141
+ # Header and input checks
142
+
143
+ @pytest.mark.parametrize("header", [
144
+ "", "v=1,kid=k3,ts=1,nonce=aaaaaaaaaaaaaaaa,sig=AA==", "v=2,kid=k3,ts=1,nonce=aaaaaaaaaaaaaaaa",
145
+ "v=2,v=2,kid=k3,ts=1,nonce=aaaaaaaaaaaaaaaa,sig=AA==", "garbage",
146
+ ])
147
+ def test_bad_headers(header):
148
+ with pytest.raises(SignatureError):
149
+ parse_header(header)
150
+
151
+
152
+ def test_bad_inputs_to_canonical():
153
+ for args in [("k 3", "POST", URL, "/x", NOW, "a" * 16), ("k3", "POST", URL, "/x", NOW, "short"),
154
+ ("k3", "POST", "ftp://x", "/x", NOW, "a" * 16), ("k3", "POST", URL, "x", NOW, "a" * 16),
155
+ ("k3", "POST", URL, "/x\n/y", NOW, "a" * 16), ("k3", "P0ST", URL, "/x", NOW, "a" * 16)]:
156
+ with pytest.raises(SignatureError):
157
+ canonical(*args, b"")
158
+
159
+
160
+ @pytest.mark.parametrize("url,expected", [
161
+ ("https://Acme.Example/path?q=1", "https://acme.example"),
162
+ ("https://acme.example:443/", "https://acme.example"),
163
+ ("http://localhost:8001", "http://localhost:8001"),
164
+ ("https://user:pw@acme.example", ""),
165
+ ("javascript:alert(1)", ""),
166
+ ("https://[::1]:8443/x", "https://[::1]:8443"),
167
+ ])
168
+ def test_origin(url, expected):
169
+ assert origin(url) == expected