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.
- scalio_connect-0.1.0/PKG-INFO +78 -0
- scalio_connect-0.1.0/README.md +60 -0
- scalio_connect-0.1.0/pyproject.toml +30 -0
- scalio_connect-0.1.0/setup.cfg +4 -0
- scalio_connect-0.1.0/src/scalio_connect/__init__.py +18 -0
- scalio_connect-0.1.0/src/scalio_connect/nonces.py +26 -0
- scalio_connect-0.1.0/src/scalio_connect/signing.py +179 -0
- scalio_connect-0.1.0/src/scalio_connect.egg-info/PKG-INFO +78 -0
- scalio_connect-0.1.0/src/scalio_connect.egg-info/SOURCES.txt +12 -0
- scalio_connect-0.1.0/src/scalio_connect.egg-info/dependency_links.txt +1 -0
- scalio_connect-0.1.0/src/scalio_connect.egg-info/requires.txt +12 -0
- scalio_connect-0.1.0/src/scalio_connect.egg-info/top_level.txt +1 -0
- scalio_connect-0.1.0/tests/test_nonces.py +10 -0
- scalio_connect-0.1.0/tests/test_signing.py +169 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|