samarkand 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
samarkand/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """samarkand: the licence, webhook and threat-intelligence wire formats shared by a family of products.
2
+ Pure logic only: each product keeps its own storage, routes, UI and rules, and passes in
3
+ what's its own (its id, public keys, event names and header prefix)."""
4
+
5
+ __version__ = "0.1.0"
samarkand/_http.py ADDED
@@ -0,0 +1,78 @@
1
+ """One HTTP request, two ways: the standard library (sync) and httpx (async, the optional extra).
2
+
3
+ Redirects are never followed unless asked: a webhook, a TAXII server or a MISP instance is the
4
+ address an admin registered, not wherever it points next. TLS is verified unless the caller turns it
5
+ off for that one request.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ssl
11
+ import urllib.error
12
+ import urllib.request
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, field
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Response:
19
+ """What came back: the status, the body and the headers (names lower-cased)."""
20
+
21
+ status: int
22
+ body: bytes = b""
23
+ headers: Mapping[str, str] = field(default_factory=dict)
24
+
25
+
26
+ class _NoRedirects(urllib.request.HTTPRedirectHandler):
27
+ def redirect_request(self, *args: object, **kwargs: object) -> None:
28
+ return None # the 3xx comes back to the caller as the answer
29
+
30
+
31
+ def request(
32
+ method: str,
33
+ url: str,
34
+ *,
35
+ body: bytes | None = None,
36
+ headers: Mapping[str, str] | None = None,
37
+ timeout: float = 30.0,
38
+ verify: bool = True,
39
+ follow_redirects: bool = False,
40
+ ) -> Response:
41
+ """Send one request with the standard library. Any status is a Response; a failure to reach the
42
+ server raises OSError (urllib.error.URLError is one)."""
43
+ context = ssl.create_default_context()
44
+ if not verify:
45
+ context.check_hostname = False
46
+ context.verify_mode = ssl.CERT_NONE
47
+ handlers: list[urllib.request.BaseHandler] = [urllib.request.HTTPSHandler(context=context)]
48
+ if not follow_redirects:
49
+ handlers.append(_NoRedirects())
50
+ opener = urllib.request.build_opener(*handlers)
51
+ req = urllib.request.Request(url, data=body, headers=dict(headers or {}), method=method) # noqa: S310
52
+ try:
53
+ with opener.open(req, timeout=timeout) as answer:
54
+ return Response(answer.status, answer.read(), {k.lower(): v for k, v in answer.headers.items()})
55
+ except urllib.error.HTTPError as answer:
56
+ found = answer.headers.items() if answer.headers else []
57
+ return Response(answer.code, answer.read() or b"", {k.lower(): v for k, v in found})
58
+
59
+
60
+ async def request_async(
61
+ method: str,
62
+ url: str,
63
+ *,
64
+ body: bytes | None = None,
65
+ headers: Mapping[str, str] | None = None,
66
+ timeout: float = 30.0,
67
+ verify: bool = True,
68
+ follow_redirects: bool = False,
69
+ ) -> Response:
70
+ """The same request with httpx (`pip install samarkand[async]`). A failure to reach the server
71
+ raises httpx.HTTPError."""
72
+ try:
73
+ import httpx
74
+ except ImportError as exc: # pragma: no cover - only without the extra
75
+ raise RuntimeError("The async sender needs httpx: install samarkand[async].") from exc
76
+ async with httpx.AsyncClient(timeout=timeout, verify=verify, follow_redirects=follow_redirects) as client:
77
+ answer = await client.request(method, url, content=body, headers=dict(headers or {}))
78
+ return Response(answer.status_code, answer.content, {k.lower(): v for k, v in answer.headers.items()})
samarkand/licence.py ADDED
@@ -0,0 +1,350 @@
1
+ """The licence line shared by every product, checked offline against public keys the product supplies.
2
+
3
+ A licence is one line, `<product>-licence-1.<payload>.<signature>`:
4
+
5
+ - `payload` is base64url, without padding, of canonical JSON (sorted keys, compact separators);
6
+ - `signature` is Ed25519, base64url without padding, over the ASCII of `<product>-licence-1.<payload>`,
7
+ so the product and the format version are signed too and a line for one product can't be passed
8
+ off as another's.
9
+
10
+ Payload fields: `licence_id`, `product`, `licensee`, `edition` (`pro` or `services`), `seats` (a
11
+ number, or null for unlimited), `issued`, `expires` (a date, or null for perpetual), and optionally
12
+ `email`, `features` (extra feature ids, or `*` for all) and `not_before`. Dates are `YYYY-MM-DD`.
13
+
14
+ A licence runs from the start of its `not_before` day (UTC) to the end of its `expires` day, then
15
+ carries on for GRACE with a warning, then the installation is Free again. Before `not_before` it is
16
+ Free, shown as pending. A lapsed licence never deactivates anyone: that rule, the seat count and where
17
+ the licence is kept are each product's own.
18
+
19
+ There is no signing here. Licences are issued by each product's own admin script with the product's
20
+ private key, which never leaves the licensor. `head_of` gives the exact text an issuer signs and
21
+ `line_of` puts the line together, so an issuer can use the same encoding without this package ever
22
+ holding a private key.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import binascii
29
+ import json
30
+ import logging
31
+ from collections.abc import Iterable
32
+ from dataclasses import dataclass, field
33
+ from datetime import date, datetime, time, timedelta, timezone
34
+ from enum import Enum
35
+ from typing import Any
36
+
37
+ from cryptography.exceptions import InvalidSignature
38
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
39
+
40
+ log = logging.getLogger(__name__)
41
+
42
+ FORMAT_VERSION = 1
43
+ SUFFIX = f"-licence-{FORMAT_VERSION}"
44
+ GRACE = timedelta(days=30)
45
+
46
+
47
+ class Edition(str, Enum):
48
+ """The three editions. Code asks `Standing.has(feature)`, never the edition's name."""
49
+
50
+ FREE = "free"
51
+ PRO = "pro"
52
+ SERVICES = "services"
53
+
54
+
55
+ # Feature ids: short, kebab-case, stable, and the same in every product.
56
+ REPORT_BRANDING = "report-branding" # one brand for the installation's reports
57
+ BRAND_LIBRARY = "brand-library" # a brand per client, chosen for each piece of work
58
+ AUTOMATION = "automation" # signed webhooks, checked when one is sent
59
+ MANAGED_SERVICE = "managed-service" # a right in the commercial terms, not a switch in the code
60
+ FEATURES = (REPORT_BRANDING, BRAND_LIBRARY, AUTOMATION, MANAGED_SERVICE)
61
+ WILDCARD = "*"
62
+ EDITION_FEATURES: dict[Edition, frozenset[str]] = {
63
+ Edition.FREE: frozenset(),
64
+ Edition.PRO: frozenset({REPORT_BRANDING, AUTOMATION}),
65
+ Edition.SERVICES: frozenset({REPORT_BRANDING, BRAND_LIBRARY, AUTOMATION, MANAGED_SERVICE}),
66
+ }
67
+
68
+ # Why a licence was refused, for code to act on; the message is for the admin.
69
+ NOT_A_LICENCE = "not-a-licence"
70
+ OTHER_PRODUCT = "other-product"
71
+ DAMAGED = "damaged"
72
+ BAD_SIGNATURE = "bad-signature"
73
+ UNREADABLE = "unreadable" # signed, but fields this version doesn't understand: a newer format
74
+ GRANTS_NOTHING = "grants-nothing"
75
+ LAPSED = "lapsed"
76
+
77
+ # A Standing's state
78
+ FREE = "free"
79
+ PENDING = "pending"
80
+ ACTIVE = "active"
81
+ IN_GRACE = "grace"
82
+ LAPSED_STATE = "lapsed"
83
+
84
+
85
+ class LicenceProblem(ValueError):
86
+ """A licence that can't be used, with a reason an admin can act on (the message) and one code can
87
+ (`code`, one of the module's reason constants)."""
88
+
89
+ def __init__(self, message: str, code: str) -> None:
90
+ super().__init__(message)
91
+ self.code = code
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class Licence:
96
+ """What a licence says, once its signature has checked out."""
97
+
98
+ licence_id: str
99
+ licensee: str
100
+ edition: Edition
101
+ seats: int | None # None: unlimited
102
+ issued: date
103
+ expires: date | None # None: perpetual
104
+ email: str = ""
105
+ features: tuple[str, ...] = () # beyond the edition's own
106
+ not_before: date | None = None
107
+ product: str = ""
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class Standing:
112
+ """What the installation may do now."""
113
+
114
+ edition: Edition
115
+ seats: int | None # None: unlimited
116
+ state: str # "free" (no licence), "pending", "active", "grace" or "lapsed"
117
+ licence: Licence | None = None
118
+ features: frozenset[str] = field(default_factory=frozenset)
119
+
120
+ def has(self, feature: str) -> bool:
121
+ """Whether the installation may use `feature` now."""
122
+ return feature in self.features or WILDCARD in self.features
123
+
124
+ @property
125
+ def custom_branding(self) -> bool:
126
+ """Reports may carry the installation's own brand."""
127
+ return self.has(REPORT_BRANDING)
128
+
129
+ @property
130
+ def brand_library(self) -> bool:
131
+ """A brand may be kept per client."""
132
+ return self.has(BRAND_LIBRARY)
133
+
134
+ @property
135
+ def organisation(self) -> str:
136
+ """The licensee, while the licence is in force (active or in grace), else ""."""
137
+ return self.licence.licensee if self.licence and self.state in (ACTIVE, IN_GRACE) else ""
138
+
139
+
140
+ def format_of(product: str) -> str:
141
+ """The line's prefix for a product: "acme" gives "acme-licence-1"."""
142
+ return f"{product}{SUFFIX}"
143
+
144
+
145
+ def _b64(data: bytes) -> str:
146
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
147
+
148
+
149
+ def _unb64(text: str) -> bytes:
150
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
151
+
152
+
153
+ def canonical(payload: dict[str, Any]) -> str:
154
+ """The JSON that is encoded and signed: sorted keys, compact separators."""
155
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
156
+
157
+
158
+ def payload_of(licence: Licence) -> dict[str, Any]:
159
+ """The signed fields. Optional ones are left out rather than sent empty."""
160
+ out: dict[str, Any] = {
161
+ "licence_id": licence.licence_id,
162
+ "product": licence.product,
163
+ "licensee": licence.licensee,
164
+ "edition": licence.edition.value,
165
+ "seats": licence.seats,
166
+ "issued": licence.issued.isoformat(),
167
+ "expires": licence.expires.isoformat() if licence.expires else None,
168
+ }
169
+ if licence.email:
170
+ out["email"] = licence.email
171
+ if licence.features:
172
+ out["features"] = sorted(licence.features)
173
+ if licence.not_before:
174
+ out["not_before"] = licence.not_before.isoformat()
175
+ return out
176
+
177
+
178
+ def head_of(licence: Licence) -> str:
179
+ """`<product>-licence-1.<payload>`: the text an issuer signs (its ASCII bytes) with the product's
180
+ private key. For the licensor's admin script only; no product needs it."""
181
+ if not licence.product:
182
+ raise ValueError("A licence names its product.")
183
+ return f"{format_of(licence.product)}.{_b64(canonical(payload_of(licence)).encode())}"
184
+
185
+
186
+ def line_of(head: str, signature: bytes) -> str:
187
+ """The licence line from its head and the Ed25519 signature over it."""
188
+ return f"{head}.{_b64(signature)}"
189
+
190
+
191
+ def _article(name: str) -> str:
192
+ return "an" if name[:1].lower() in "aeiou" else "a"
193
+
194
+
195
+ def load_keys(public_keys: Iterable[str]) -> tuple[Ed25519PublicKey, ...]:
196
+ """Raw Ed25519 public keys given as hex. A key that isn't one is a mistake in the product, so it
197
+ raises ValueError rather than refusing a customer's licence."""
198
+ keys = []
199
+ for key in public_keys:
200
+ try:
201
+ keys.append(Ed25519PublicKey.from_public_bytes(bytes.fromhex(key)))
202
+ except ValueError as exc:
203
+ raise ValueError(f"Not a raw Ed25519 public key in hex: {key!r}") from exc
204
+ return tuple(keys)
205
+
206
+
207
+ def _day(value: Any) -> date | None:
208
+ return date.fromisoformat(value) if value else None
209
+
210
+
211
+ def parse(
212
+ text: str,
213
+ *,
214
+ product: str,
215
+ public_keys: Iterable[str] | Iterable[Ed25519PublicKey],
216
+ name: str | None = None,
217
+ ) -> Licence:
218
+ """A licence for `product`, once its signature checks out against one of `public_keys` (a tuple,
219
+ so a key can be rotated by adding the new one). `name` is the product's name as people read it,
220
+ for the messages; it defaults to the id with a capital. Raises LicenceProblem."""
221
+ shown = name or product.capitalize()
222
+ expected = format_of(product)
223
+ keys = tuple(k if isinstance(k, Ed25519PublicKey) else load_keys((k,))[0] for k in public_keys)
224
+ parts = (text or "").strip().split(".")
225
+ if len(parts) != 3 or not parts[0].endswith(SUFFIX):
226
+ raise LicenceProblem(
227
+ f"That isn't {_article(shown)} {shown} licence. Paste the whole line you were sent.",
228
+ NOT_A_LICENCE,
229
+ )
230
+ if parts[0] != expected:
231
+ other = parts[0][: -len(SUFFIX)]
232
+ raise LicenceProblem(f"That's a licence for {other}, not {shown}.", OTHER_PRODUCT)
233
+ head = f"{parts[0]}.{parts[1]}"
234
+ try:
235
+ signature = _unb64(parts[2])
236
+ payload = json.loads(_unb64(parts[1]))
237
+ except (binascii.Error, ValueError) as exc:
238
+ raise LicenceProblem("That licence is damaged. Paste the whole line you were sent.", DAMAGED) from exc
239
+ for key in keys:
240
+ try:
241
+ key.verify(signature, head.encode())
242
+ break
243
+ except InvalidSignature:
244
+ continue
245
+ else:
246
+ raise LicenceProblem(f"That licence wasn't issued for {shown}, or has been altered.", BAD_SIGNATURE)
247
+ try:
248
+ licence = Licence(
249
+ licence_id=str(payload["licence_id"]),
250
+ product=str(payload["product"]),
251
+ licensee=str(payload["licensee"]),
252
+ email=str(payload.get("email", "")),
253
+ edition=Edition(payload["edition"]),
254
+ seats=None if payload["seats"] is None else int(payload["seats"]),
255
+ features=tuple(str(f) for f in payload.get("features", [])),
256
+ issued=date.fromisoformat(payload["issued"]),
257
+ not_before=_day(payload.get("not_before")),
258
+ expires=_day(payload["expires"]),
259
+ )
260
+ except (KeyError, ValueError, TypeError, AttributeError) as exc:
261
+ raise LicenceProblem(
262
+ f"That licence is from a newer version of {shown}. Update, then try again.", UNREADABLE
263
+ ) from exc
264
+ if licence.product != product:
265
+ raise LicenceProblem(f"That's a licence for {licence.product}, not {shown}.", OTHER_PRODUCT)
266
+ if licence.edition is Edition.FREE or (licence.seats is not None and licence.seats < 1):
267
+ raise LicenceProblem("That licence grants nothing beyond the free edition.", GRANTS_NOTHING)
268
+ return licence
269
+
270
+
271
+ def _midnight(day: date) -> datetime:
272
+ return datetime.combine(day, time.min, timezone.utc)
273
+
274
+
275
+ def standing(licence: Licence | None, now: datetime | None = None, *, free_seats: int | None) -> Standing:
276
+ """The edition a licence (or none) gives at `now` (UTC; a naive time is taken as UTC).
277
+ `free_seats` is the product's own free number."""
278
+ free = Standing(Edition.FREE, free_seats, FREE)
279
+ if licence is None:
280
+ return free
281
+ now = now or datetime.now(timezone.utc)
282
+ if now.tzinfo is None:
283
+ now = now.replace(tzinfo=timezone.utc)
284
+ if licence.not_before and now < _midnight(licence.not_before):
285
+ return Standing(Edition.FREE, free_seats, PENDING, licence)
286
+ granted = EDITION_FEATURES[licence.edition] | frozenset(licence.features)
287
+ paid = Standing(licence.edition, licence.seats, ACTIVE, licence, granted)
288
+ if licence.expires is None:
289
+ return paid
290
+ ends = _midnight(licence.expires + timedelta(days=1))
291
+ if now < ends:
292
+ return paid
293
+ if now < ends + GRACE:
294
+ return Standing(licence.edition, licence.seats, IN_GRACE, licence, granted)
295
+ return Standing(Edition.FREE, free_seats, LAPSED_STATE, licence)
296
+
297
+
298
+ def grace_ends(licence: Licence) -> date | None:
299
+ """The last day of grace, or None for a perpetual licence."""
300
+ return licence.expires + GRACE if licence.expires else None
301
+
302
+
303
+ def check_installable(licence: Licence, now: datetime | None = None) -> Licence:
304
+ """`licence`, if it may be installed now: one already past its grace is refused (LicenceProblem,
305
+ LAPSED). A pending licence may be installed; it takes effect on its first day."""
306
+ if standing(licence, now, free_seats=None).state == LAPSED_STATE:
307
+ raise LicenceProblem(f"That licence ran out on {licence.expires}.", LAPSED)
308
+ return licence
309
+
310
+
311
+ @dataclass(frozen=True)
312
+ class Rules:
313
+ """One product's licence rules, bound once: its id, its trusted public keys (hex), its free seat
314
+ number and its name as people read it.
315
+
316
+ RULES = Rules("acme", ("cf9e…",), free_seats=4, name="Acme")
317
+ RULES.parse(line); RULES.standing(licence)
318
+ """
319
+
320
+ product: str
321
+ public_keys: tuple[str, ...]
322
+ free_seats: int | None
323
+ name: str = ""
324
+ _keys: tuple[Ed25519PublicKey, ...] = field(init=False, repr=False, compare=False)
325
+
326
+ def __post_init__(self) -> None:
327
+ if not self.public_keys:
328
+ log.warning("No public keys for %s: every licence will be refused.", self.product)
329
+ object.__setattr__(self, "_keys", load_keys(self.public_keys))
330
+
331
+ def parse(self, text: str) -> Licence:
332
+ """See `parse`."""
333
+ return parse(text, product=self.product, public_keys=self._keys, name=self.name or None)
334
+
335
+ def standing(self, licence: Licence | None, now: datetime | None = None) -> Standing:
336
+ """See `standing`."""
337
+ return standing(licence, now, free_seats=self.free_seats)
338
+
339
+ def current(self, text: str | None, now: datetime | None = None) -> Standing:
340
+ """The standing from a stored line, or none. A stored line that no longer verifies (say, after
341
+ a key was withdrawn) counts as no licence, logged, so the installation carries on as Free."""
342
+ if not text:
343
+ return self.standing(None, now)
344
+ try:
345
+ return self.standing(self.parse(text), now)
346
+ except LicenceProblem as problem:
347
+ log.warning(
348
+ "The installed %s licence doesn't verify (%s): treating it as none.", self.product, problem
349
+ )
350
+ return self.standing(None, now)