runlight 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.
- runlight-0.1.0/.gitignore +10 -0
- runlight-0.1.0/LICENSE +21 -0
- runlight-0.1.0/PKG-INFO +82 -0
- runlight-0.1.0/README.md +47 -0
- runlight-0.1.0/pyproject.toml +65 -0
- runlight-0.1.0/src/runlight/__init__.py +55 -0
- runlight-0.1.0/src/runlight/_aesgcm.py +202 -0
- runlight-0.1.0/src/runlight/_intl.py +202 -0
- runlight-0.1.0/src/runlight/_js.py +567 -0
- runlight-0.1.0/src/runlight/accounts/__init__.py +35 -0
- runlight-0.1.0/src/runlight/accounts/auth.py +738 -0
- runlight-0.1.0/src/runlight/accounts/crypto.py +152 -0
- runlight-0.1.0/src/runlight/accounts/pages.py +264 -0
- runlight-0.1.0/src/runlight/accounts/web.py +654 -0
- runlight-0.1.0/src/runlight/asgi.py +185 -0
- runlight-0.1.0/src/runlight/assets/build.json +10 -0
- runlight-0.1.0/src/runlight/assets/dashboard.css +1 -0
- runlight-0.1.0/src/runlight/assets/dashboard.js +81 -0
- runlight-0.1.0/src/runlight/assets/locales.json +7 -0
- runlight-0.1.0/src/runlight/assets/picker.js +1 -0
- runlight-0.1.0/src/runlight/assets/tracker.js +1 -0
- runlight-0.1.0/src/runlight/assets/world.json +1 -0
- runlight-0.1.0/src/runlight/assets.py +63 -0
- runlight-0.1.0/src/runlight/assistant.py +447 -0
- runlight-0.1.0/src/runlight/body.py +41 -0
- runlight-0.1.0/src/runlight/brand.py +14 -0
- runlight-0.1.0/src/runlight/connect.py +204 -0
- runlight-0.1.0/src/runlight/core.py +1469 -0
- runlight-0.1.0/src/runlight/data/__init__.py +1 -0
- runlight-0.1.0/src/runlight/data/agents.py +45 -0
- runlight-0.1.0/src/runlight/data/intl.json +1 -0
- runlight-0.1.0/src/runlight/data/sources.py +88 -0
- runlight-0.1.0/src/runlight/db.py +691 -0
- runlight-0.1.0/src/runlight/django/__init__.py +168 -0
- runlight-0.1.0/src/runlight/django/apps.py +11 -0
- runlight-0.1.0/src/runlight/django/management/__init__.py +0 -0
- runlight-0.1.0/src/runlight/django/management/commands/__init__.py +0 -0
- runlight-0.1.0/src/runlight/django/management/commands/runlight_check.py +15 -0
- runlight-0.1.0/src/runlight/env.py +16 -0
- runlight-0.1.0/src/runlight/fastapi.py +30 -0
- runlight-0.1.0/src/runlight/flask.py +48 -0
- runlight-0.1.0/src/runlight/funnels.py +56 -0
- runlight-0.1.0/src/runlight/geo.py +141 -0
- runlight-0.1.0/src/runlight/goals.py +141 -0
- runlight-0.1.0/src/runlight/hash.py +34 -0
- runlight-0.1.0/src/runlight/http/__init__.py +20 -0
- runlight-0.1.0/src/runlight/http/fetcher.py +200 -0
- runlight-0.1.0/src/runlight/http/headers.py +82 -0
- runlight-0.1.0/src/runlight/http/message.py +127 -0
- runlight-0.1.0/src/runlight/http/search_params.py +102 -0
- runlight-0.1.0/src/runlight/http/url.py +360 -0
- runlight-0.1.0/src/runlight/icon.py +177 -0
- runlight-0.1.0/src/runlight/importers/__init__.py +7 -0
- runlight-0.1.0/src/runlight/importers/bitly.py +99 -0
- runlight-0.1.0/src/runlight/importers/csvvisits.py +157 -0
- runlight-0.1.0/src/runlight/importers/dub.py +130 -0
- runlight-0.1.0/src/runlight/importers/http.py +282 -0
- runlight-0.1.0/src/runlight/importers/index.py +65 -0
- runlight-0.1.0/src/runlight/importers/rebrandly.py +51 -0
- runlight-0.1.0/src/runlight/importers/shortio.py +103 -0
- runlight-0.1.0/src/runlight/importers/types.py +49 -0
- runlight-0.1.0/src/runlight/importers/umami.py +146 -0
- runlight-0.1.0/src/runlight/importers/visits.py +400 -0
- runlight-0.1.0/src/runlight/importers/write.py +221 -0
- runlight-0.1.0/src/runlight/journeys.py +138 -0
- runlight-0.1.0/src/runlight/limit.py +46 -0
- runlight-0.1.0/src/runlight/links.py +159 -0
- runlight-0.1.0/src/runlight/mail/__init__.py +21 -0
- runlight-0.1.0/src/runlight/mail/secret.py +60 -0
- runlight-0.1.0/src/runlight/mail/ses.py +116 -0
- runlight-0.1.0/src/runlight/mail/smtp.py +299 -0
- runlight-0.1.0/src/runlight/mail/transports.py +268 -0
- runlight-0.1.0/src/runlight/mcp.py +342 -0
- runlight-0.1.0/src/runlight/messages.py +119 -0
- runlight-0.1.0/src/runlight/mmdb.py +208 -0
- runlight-0.1.0/src/runlight/oauth.py +463 -0
- runlight-0.1.0/src/runlight/payload.py +133 -0
- runlight-0.1.0/src/runlight/py.typed +0 -0
- runlight-0.1.0/src/runlight/query.py +81 -0
- runlight-0.1.0/src/runlight/reports.py +269 -0
- runlight-0.1.0/src/runlight/routes.py +2512 -0
- runlight-0.1.0/src/runlight/safefetch.py +264 -0
- runlight-0.1.0/src/runlight/serve.py +112 -0
- runlight-0.1.0/src/runlight/server/__init__.py +2 -0
- runlight-0.1.0/src/runlight/server/agents.py +504 -0
- runlight-0.1.0/src/runlight/server/cli.py +366 -0
- runlight-0.1.0/src/runlight/server/dbip.py +192 -0
- runlight-0.1.0/src/runlight/server/standalone.py +211 -0
- runlight-0.1.0/src/runlight/sources.py +173 -0
- runlight-0.1.0/src/runlight/store/__init__.py +7 -0
- runlight-0.1.0/src/runlight/store/sql.py +411 -0
- runlight-0.1.0/src/runlight/store/sql_store.py +1638 -0
- runlight-0.1.0/src/runlight/store/stores.py +73 -0
- runlight-0.1.0/src/runlight/time.py +360 -0
- runlight-0.1.0/src/runlight/ua.py +131 -0
- runlight-0.1.0/src/runlight/version.py +17 -0
- runlight-0.1.0/src/runlight/wsgi.py +190 -0
- runlight-0.1.0/src/runlight/zip.py +75 -0
runlight-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jon Phillips
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
runlight-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: runlight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Privacy friendly web analytics that lives inside your Python app. Mount a route, add a script tag, read your stats at /runlight.
|
|
5
|
+
Project-URL: Homepage, https://runlight.sh
|
|
6
|
+
Project-URL: Documentation, https://runlight.sh/docs/python/
|
|
7
|
+
Project-URL: Source, https://github.com/runlightsh/runlight/tree/main/packages/python
|
|
8
|
+
Project-URL: Issues, https://github.com/runlightsh/runlight/issues
|
|
9
|
+
Author: Jon C. Phillips
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: analytics,django,fastapi,flask,privacy,short links,web analytics
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Framework :: Flask
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
|
+
Classifier: Topic :: Internet :: Log Analysis
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Provides-Extra: mysql
|
|
29
|
+
Requires-Dist: pymysql>=1.1; extra == 'mysql'
|
|
30
|
+
Provides-Extra: postgres
|
|
31
|
+
Requires-Dist: psycopg>=3.2; extra == 'postgres'
|
|
32
|
+
Provides-Extra: tzdata
|
|
33
|
+
Requires-Dist: tzdata; extra == 'tzdata'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# runlight
|
|
37
|
+
|
|
38
|
+
Runlight is privacy friendly web analytics that runs inside your own Python app. It counts visitors without cookies and without storing anyone’s IP address. The numbers stay in your database, and the dashboard is served from your domain at `/runlight`.
|
|
39
|
+
|
|
40
|
+
This package is the Python version of [Runlight](https://runlight.sh). It works in Django, Flask, FastAPI, or any WSGI or ASGI app on Python 3.11 or later, and it can also run on its own domain as a standalone server. It answers every request the way the TypeScript library does and uses the same tables, so either one can read the other’s database.
|
|
41
|
+
|
|
42
|
+
## Get started
|
|
43
|
+
|
|
44
|
+
Install it with pip.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install runlight
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Create one instance for your app.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from runlight import Runlight
|
|
54
|
+
from runlight.store import Stores
|
|
55
|
+
|
|
56
|
+
rl = Runlight(
|
|
57
|
+
store=Stores.sqlite("data/runlight.db"),
|
|
58
|
+
site={"name": "example.com", "hostnames": ["example.com"], "timezone": "Europe/London"},
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Put it in front of your app. In Flask that is one line.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import runlight.flask
|
|
66
|
+
|
|
67
|
+
runlight.flask.init_app(app, rl)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Add the script to every page, just before `</head>`.
|
|
71
|
+
|
|
72
|
+
```html
|
|
73
|
+
<script defer src="/runlight/s.js"></script>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Set `RUNLIGHT_TOKEN` to a long random string, then open `/runlight/?token=` followed by that string to sign in.
|
|
77
|
+
|
|
78
|
+
The [Python guide](https://runlight.sh/docs/python/) has the code for Django, FastAPI, and plain WSGI or ASGI apps, the Postgres, MySQL, and MariaDB stores, the scheduled check, and the standalone server.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
Runlight is MIT licensed.
|
runlight-0.1.0/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# runlight
|
|
2
|
+
|
|
3
|
+
Runlight is privacy friendly web analytics that runs inside your own Python app. It counts visitors without cookies and without storing anyone’s IP address. The numbers stay in your database, and the dashboard is served from your domain at `/runlight`.
|
|
4
|
+
|
|
5
|
+
This package is the Python version of [Runlight](https://runlight.sh). It works in Django, Flask, FastAPI, or any WSGI or ASGI app on Python 3.11 or later, and it can also run on its own domain as a standalone server. It answers every request the way the TypeScript library does and uses the same tables, so either one can read the other’s database.
|
|
6
|
+
|
|
7
|
+
## Get started
|
|
8
|
+
|
|
9
|
+
Install it with pip.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install runlight
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Create one instance for your app.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from runlight import Runlight
|
|
19
|
+
from runlight.store import Stores
|
|
20
|
+
|
|
21
|
+
rl = Runlight(
|
|
22
|
+
store=Stores.sqlite("data/runlight.db"),
|
|
23
|
+
site={"name": "example.com", "hostnames": ["example.com"], "timezone": "Europe/London"},
|
|
24
|
+
)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Put it in front of your app. In Flask that is one line.
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import runlight.flask
|
|
31
|
+
|
|
32
|
+
runlight.flask.init_app(app, rl)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Add the script to every page, just before `</head>`.
|
|
36
|
+
|
|
37
|
+
```html
|
|
38
|
+
<script defer src="/runlight/s.js"></script>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Set `RUNLIGHT_TOKEN` to a long random string, then open `/runlight/?token=` followed by that string to sign in.
|
|
42
|
+
|
|
43
|
+
The [Python guide](https://runlight.sh/docs/python/) has the code for Django, FastAPI, and plain WSGI or ASGI apps, the Postgres, MySQL, and MariaDB stores, the scheduled check, and the standalone server.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
Runlight is MIT licensed.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "runlight"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Privacy friendly web analytics that lives inside your Python app. Mount a route, add a script tag, read your stats at /runlight."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
authors = [{ name = "Jon C. Phillips" }]
|
|
14
|
+
keywords = ["analytics", "privacy", "web analytics", "short links", "django", "flask", "fastapi"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Framework :: Django",
|
|
18
|
+
"Framework :: Flask",
|
|
19
|
+
"Framework :: FastAPI",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Topic :: Internet :: Log Analysis",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
# The core has no dependencies: SQLite, HTTP, crypto, and zones come from the standard library.
|
|
32
|
+
dependencies = []
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
# zoneinfo reads the system's zone database; Windows has none, so install this there.
|
|
36
|
+
tzdata = ["tzdata"]
|
|
37
|
+
# Stores.postgres(), on psycopg 3.
|
|
38
|
+
postgres = ["psycopg>=3.2"]
|
|
39
|
+
# Stores.mysql(), for MySQL 8.4 and MariaDB 11.4 and later.
|
|
40
|
+
mysql = ["pymysql>=1.1"]
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
runlight = "runlight.server.cli:main"
|
|
44
|
+
|
|
45
|
+
[project.urls]
|
|
46
|
+
Homepage = "https://runlight.sh"
|
|
47
|
+
Documentation = "https://runlight.sh/docs/python/"
|
|
48
|
+
Source = "https://github.com/runlightsh/runlight/tree/main/packages/python"
|
|
49
|
+
Issues = "https://github.com/runlightsh/runlight/issues"
|
|
50
|
+
|
|
51
|
+
[dependency-groups]
|
|
52
|
+
# psycopg and PyMySQL for the Postgres and MySQL tests (run when RUNLIGHT_TEST_PG
|
|
53
|
+
# or RUNLIGHT_TEST_MYSQL is set), and Django, Flask, FastAPI and httpx for the
|
|
54
|
+
# framework adapters' tests.
|
|
55
|
+
dev = ["pytest>=8", "psycopg[binary]>=3.2", "pymysql>=1.1", "django>=5.2", "flask>=3", "fastapi>=0.115", "httpx>=0.27"]
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.wheel]
|
|
58
|
+
packages = ["src/runlight"]
|
|
59
|
+
|
|
60
|
+
[tool.hatch.build.targets.sdist]
|
|
61
|
+
include = ["src/runlight", "README.md", "LICENSE", "pyproject.toml"]
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
testpaths = ["tests"]
|
|
65
|
+
addopts = "-ra"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Runlight: privacy friendly web analytics that lives inside your Python app.
|
|
2
|
+
|
|
3
|
+
from runlight import Runlight
|
|
4
|
+
from runlight.store import Stores
|
|
5
|
+
|
|
6
|
+
rl = Runlight(store=Stores.sqlite("./data/runlight.db"), site={"name": "example.com", "hostnames": ["example.com"]})
|
|
7
|
+
routes = rl.routes() # routes.handle(request) answers /runlight/*
|
|
8
|
+
# or mount it: runlight.wsgi, runlight.asgi, runlight.django, runlight.flask, runlight.fastapi
|
|
9
|
+
|
|
10
|
+
Then add <script defer src="/runlight/s.js"></script> to your pages.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .http import Headers, Request, Response, Url
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"API_VERSION",
|
|
21
|
+
"LINK_DOMAIN_CHECK",
|
|
22
|
+
"SESSION_IDLE_MS",
|
|
23
|
+
"VERSION",
|
|
24
|
+
"Headers",
|
|
25
|
+
"Request",
|
|
26
|
+
"Response",
|
|
27
|
+
"Runlight",
|
|
28
|
+
"SettingsError",
|
|
29
|
+
"Stores",
|
|
30
|
+
"Url",
|
|
31
|
+
"runlight",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def runlight(options: Any = None, **kwargs: Any) -> Any:
|
|
36
|
+
"""A Runlight, as runlight() in the TypeScript SDK makes one."""
|
|
37
|
+
from .core import Runlight
|
|
38
|
+
|
|
39
|
+
return Runlight(options, **kwargs)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def __getattr__(name: str) -> Any:
|
|
43
|
+
if name in ("Runlight", "SettingsError", "SESSION_IDLE_MS", "LINK_DOMAIN_CHECK"):
|
|
44
|
+
from . import core
|
|
45
|
+
|
|
46
|
+
return getattr(core, name)
|
|
47
|
+
if name == "Stores":
|
|
48
|
+
from .store import Stores
|
|
49
|
+
|
|
50
|
+
return Stores
|
|
51
|
+
if name in ("VERSION", "API_VERSION"):
|
|
52
|
+
from . import version
|
|
53
|
+
|
|
54
|
+
return getattr(version, name)
|
|
55
|
+
raise AttributeError(name)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""AES-GCM, as Web Crypto's AES-GCM seals and opens: the ciphertext with its 16 byte tag at the end.
|
|
2
|
+
|
|
3
|
+
The standard library has no AES, so this uses the `cryptography` package's AESGCM when it is installed, and
|
|
4
|
+
otherwise a plain Python AES and GHASH. Runlight seals short things (two-factor secrets, a mail service's keys,
|
|
5
|
+
an assistant's key), so the plain one is quick enough, and both give the same bytes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
try: # pragma: no cover - depends on what is installed
|
|
11
|
+
from cryptography.exceptions import InvalidTag as _InvalidTag
|
|
12
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM as _AESGCM
|
|
13
|
+
except ImportError: # pragma: no cover
|
|
14
|
+
_AESGCM = None
|
|
15
|
+
_InvalidTag = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidTag(ValueError):
|
|
19
|
+
"""The ciphertext, IV, or key is not the one sealed: Web Crypto rejects with an OperationError then."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def encrypt(key: bytes, iv: bytes, data: bytes, aad: bytes = b"") -> bytes:
|
|
23
|
+
"""The ciphertext followed by its 16 byte tag."""
|
|
24
|
+
_check(key, iv)
|
|
25
|
+
if _AESGCM is not None:
|
|
26
|
+
return _AESGCM(key).encrypt(iv, data, aad or None)
|
|
27
|
+
return _Gcm(key).seal(iv, data, aad)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def decrypt(key: bytes, iv: bytes, data: bytes, aad: bytes = b"") -> bytes:
|
|
31
|
+
"""The plaintext. Raises InvalidTag when the tag does not check out."""
|
|
32
|
+
_check(key, iv)
|
|
33
|
+
if len(data) < 16:
|
|
34
|
+
raise InvalidTag("The data is shorter than its tag")
|
|
35
|
+
if _AESGCM is not None:
|
|
36
|
+
try:
|
|
37
|
+
return _AESGCM(key).decrypt(iv, data, aad or None)
|
|
38
|
+
except _InvalidTag: # type: ignore[misc]
|
|
39
|
+
raise InvalidTag("The tag does not match") from None
|
|
40
|
+
return _Gcm(key).open(iv, data, aad)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _check(key: bytes, iv: bytes) -> None:
|
|
44
|
+
if len(key) not in (16, 24, 32):
|
|
45
|
+
raise ValueError("An AES key is 16, 24, or 32 bytes")
|
|
46
|
+
# Node's Web Crypto refuses an IV under 12 bytes.
|
|
47
|
+
if len(iv) < 12:
|
|
48
|
+
raise ValueError("An AES-GCM IV is at least 12 bytes")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# AES (FIPS 197), with the round function in four tables of 32-bit words.
|
|
52
|
+
|
|
53
|
+
_SBOX: list[int] = []
|
|
54
|
+
_T0: list[int] = []
|
|
55
|
+
_T1: list[int] = []
|
|
56
|
+
_T2: list[int] = []
|
|
57
|
+
_T3: list[int] = []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _tables() -> None:
|
|
61
|
+
def xtime(a: int) -> int:
|
|
62
|
+
a <<= 1
|
|
63
|
+
return a ^ 0x11B if a & 0x100 else a
|
|
64
|
+
|
|
65
|
+
# Powers and logs of 3 in GF(2^8), for the inverses.
|
|
66
|
+
exp = [0] * 256
|
|
67
|
+
log = [0] * 256
|
|
68
|
+
x = 1
|
|
69
|
+
for i in range(255):
|
|
70
|
+
exp[i] = x
|
|
71
|
+
log[x] = i
|
|
72
|
+
x ^= xtime(x)
|
|
73
|
+
for a in range(256):
|
|
74
|
+
inv = 0 if a == 0 else exp[(255 - log[a]) % 255]
|
|
75
|
+
s = inv
|
|
76
|
+
for shift in range(1, 5):
|
|
77
|
+
s ^= ((inv << shift) | (inv >> (8 - shift))) & 0xFF
|
|
78
|
+
_SBOX.append(s ^ 0x63)
|
|
79
|
+
for s in _SBOX:
|
|
80
|
+
s2 = xtime(s)
|
|
81
|
+
s3 = s2 ^ s
|
|
82
|
+
word = (s2 << 24) | (s << 16) | (s << 8) | s3
|
|
83
|
+
_T0.append(word)
|
|
84
|
+
_T1.append(((word >> 8) | (word << 24)) & 0xFFFFFFFF)
|
|
85
|
+
_T2.append(((word >> 16) | (word << 16)) & 0xFFFFFFFF)
|
|
86
|
+
_T3.append(((word >> 24) | (word << 8)) & 0xFFFFFFFF)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_tables()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _expand(key: bytes) -> list[int]:
|
|
93
|
+
nk = len(key) // 4
|
|
94
|
+
rounds = nk + 6
|
|
95
|
+
words = [int.from_bytes(key[i * 4 : i * 4 + 4], "big") for i in range(nk)]
|
|
96
|
+
rcon = 1
|
|
97
|
+
sbox = _SBOX
|
|
98
|
+
for i in range(nk, 4 * (rounds + 1)):
|
|
99
|
+
t = words[i - 1]
|
|
100
|
+
if i % nk == 0:
|
|
101
|
+
t = ((t << 8) | (t >> 24)) & 0xFFFFFFFF
|
|
102
|
+
t = (sbox[t >> 24] << 24) | (sbox[(t >> 16) & 255] << 16) | (sbox[(t >> 8) & 255] << 8) | sbox[t & 255]
|
|
103
|
+
t ^= rcon << 24
|
|
104
|
+
rcon <<= 1
|
|
105
|
+
if rcon & 0x100:
|
|
106
|
+
rcon ^= 0x11B
|
|
107
|
+
elif nk > 6 and i % nk == 4:
|
|
108
|
+
t = (sbox[t >> 24] << 24) | (sbox[(t >> 16) & 255] << 16) | (sbox[(t >> 8) & 255] << 8) | sbox[t & 255]
|
|
109
|
+
words.append(words[i - nk] ^ t)
|
|
110
|
+
return words
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _encrypt_block(w: list[int], block: int) -> int:
|
|
114
|
+
"""One 128-bit block, as an integer, through AES with the expanded key `w`."""
|
|
115
|
+
t0, t1, t2, t3, sbox = _T0, _T1, _T2, _T3, _SBOX
|
|
116
|
+
a = (block >> 96) ^ w[0]
|
|
117
|
+
b = ((block >> 64) & 0xFFFFFFFF) ^ w[1]
|
|
118
|
+
c = ((block >> 32) & 0xFFFFFFFF) ^ w[2]
|
|
119
|
+
d = (block & 0xFFFFFFFF) ^ w[3]
|
|
120
|
+
rounds = len(w) // 4 - 1
|
|
121
|
+
k = 4
|
|
122
|
+
for _ in range(rounds - 1):
|
|
123
|
+
a, b, c, d = (
|
|
124
|
+
t0[a >> 24] ^ t1[(b >> 16) & 255] ^ t2[(c >> 8) & 255] ^ t3[d & 255] ^ w[k],
|
|
125
|
+
t0[b >> 24] ^ t1[(c >> 16) & 255] ^ t2[(d >> 8) & 255] ^ t3[a & 255] ^ w[k + 1],
|
|
126
|
+
t0[c >> 24] ^ t1[(d >> 16) & 255] ^ t2[(a >> 8) & 255] ^ t3[b & 255] ^ w[k + 2],
|
|
127
|
+
t0[d >> 24] ^ t1[(a >> 16) & 255] ^ t2[(b >> 8) & 255] ^ t3[c & 255] ^ w[k + 3],
|
|
128
|
+
)
|
|
129
|
+
k += 4
|
|
130
|
+
|
|
131
|
+
def last(p: int, q: int, r: int, s: int, key: int) -> int:
|
|
132
|
+
return ((sbox[p >> 24] << 24) | (sbox[(q >> 16) & 255] << 16) | (sbox[(r >> 8) & 255] << 8) | sbox[s & 255]) ^ key
|
|
133
|
+
|
|
134
|
+
return (last(a, b, c, d, w[k]) << 96) | (last(b, c, d, a, w[k + 1]) << 64) | (last(c, d, a, b, w[k + 2]) << 32) | last(d, a, b, c, w[k + 3])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# GCM (NIST SP 800-38D).
|
|
138
|
+
|
|
139
|
+
_R = 0xE1 << 120
|
|
140
|
+
_MASK = (1 << 128) - 1
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _Gcm:
|
|
144
|
+
def __init__(self, key: bytes) -> None:
|
|
145
|
+
self.w = _expand(key)
|
|
146
|
+
h = _encrypt_block(self.w, 0)
|
|
147
|
+
# H times x^i for i in 0..127, so a product is the XOR of the rows its bits pick.
|
|
148
|
+
rows = []
|
|
149
|
+
v = h
|
|
150
|
+
for _ in range(128):
|
|
151
|
+
rows.append(v)
|
|
152
|
+
v = (v >> 1) ^ _R if v & 1 else v >> 1
|
|
153
|
+
self.rows = rows
|
|
154
|
+
|
|
155
|
+
def _mul(self, x: int) -> int:
|
|
156
|
+
out = 0
|
|
157
|
+
rows = self.rows
|
|
158
|
+
for i in range(128):
|
|
159
|
+
if x >> (127 - i) & 1:
|
|
160
|
+
out ^= rows[i]
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
def _ghash(self, aad: bytes, data: bytes) -> int:
|
|
164
|
+
y = 0
|
|
165
|
+
for part in (aad, data):
|
|
166
|
+
for at in range(0, len(part), 16):
|
|
167
|
+
y = self._mul(y ^ int.from_bytes(part[at : at + 16].ljust(16, b"\0"), "big"))
|
|
168
|
+
return self._mul(y ^ ((len(aad) * 8) << 64) ^ (len(data) * 8))
|
|
169
|
+
|
|
170
|
+
def _j0(self, iv: bytes) -> int:
|
|
171
|
+
if len(iv) == 12:
|
|
172
|
+
return (int.from_bytes(iv, "big") << 32) | 1
|
|
173
|
+
return self._ghash(b"", iv)
|
|
174
|
+
|
|
175
|
+
def _ctr(self, j0: int, data: bytes) -> bytes:
|
|
176
|
+
out = bytearray()
|
|
177
|
+
counter = j0
|
|
178
|
+
for at in range(0, len(data), 16):
|
|
179
|
+
counter = (counter & ~0xFFFFFFFF) | ((counter + 1) & 0xFFFFFFFF)
|
|
180
|
+
stream = _encrypt_block(self.w, counter).to_bytes(16, "big")
|
|
181
|
+
chunk = data[at : at + 16]
|
|
182
|
+
out += (int.from_bytes(chunk, "big") ^ int.from_bytes(stream[: len(chunk)], "big")).to_bytes(len(chunk), "big")
|
|
183
|
+
return bytes(out)
|
|
184
|
+
|
|
185
|
+
def _tag(self, j0: int, aad: bytes, ciphertext: bytes) -> bytes:
|
|
186
|
+
return ((_encrypt_block(self.w, j0) ^ self._ghash(aad, ciphertext)) & _MASK).to_bytes(16, "big")
|
|
187
|
+
|
|
188
|
+
def seal(self, iv: bytes, data: bytes, aad: bytes) -> bytes:
|
|
189
|
+
j0 = self._j0(iv)
|
|
190
|
+
ciphertext = self._ctr(j0, data)
|
|
191
|
+
return ciphertext + self._tag(j0, aad, ciphertext)
|
|
192
|
+
|
|
193
|
+
def open(self, iv: bytes, data: bytes, aad: bytes) -> bytes:
|
|
194
|
+
ciphertext, tag = data[:-16], data[-16:]
|
|
195
|
+
j0 = self._j0(iv)
|
|
196
|
+
expected = self._tag(j0, aad, ciphertext)
|
|
197
|
+
diff = 0
|
|
198
|
+
for x, y in zip(expected, tag):
|
|
199
|
+
diff |= x ^ y
|
|
200
|
+
if diff:
|
|
201
|
+
raise InvalidTag("The tag does not match")
|
|
202
|
+
return self._ctr(j0, ciphertext)
|