clavem 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.
- clavem-0.1.0/.github/workflows/publish.yml +94 -0
- clavem-0.1.0/.gitignore +224 -0
- clavem-0.1.0/LICENSE +21 -0
- clavem-0.1.0/PKG-INFO +139 -0
- clavem-0.1.0/README.md +122 -0
- clavem-0.1.0/clavem/__init__.py +0 -0
- clavem-0.1.0/clavem/channel.py +59 -0
- clavem-0.1.0/clavem/py.typed +0 -0
- clavem-0.1.0/clavem/tls.py +51 -0
- clavem-0.1.0/clavem/transport.py +94 -0
- clavem-0.1.0/pyproject.toml +48 -0
- clavem-0.1.0/tests/conftest.py +56 -0
- clavem-0.1.0/tests/data/README.md +4 -0
- clavem-0.1.0/tests/data/server-cert.pem +12 -0
- clavem-0.1.0/tests/data/server-key.pem +5 -0
- clavem-0.1.0/tests/test_nodelay.py +27 -0
- clavem-0.1.0/tests/test_psk.py +72 -0
- clavem-0.1.0/uv.lock +122 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
name: Python 3.13 / ${{ matrix.os }}
|
|
11
|
+
runs-on: ${{ matrix.os }}
|
|
12
|
+
timeout-minutes: 10
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
strategy:
|
|
16
|
+
fail-fast: false
|
|
17
|
+
matrix:
|
|
18
|
+
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
19
|
+
steps:
|
|
20
|
+
- name: Check out repository
|
|
21
|
+
uses: actions/checkout@v4
|
|
22
|
+
- name: Install uv
|
|
23
|
+
uses: astral-sh/setup-uv@v8.2.0
|
|
24
|
+
- name: Set up Python
|
|
25
|
+
uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.13"
|
|
28
|
+
- name: Install locked dependencies
|
|
29
|
+
run: uv sync --frozen
|
|
30
|
+
- name: Run transport regressions
|
|
31
|
+
run: uv run --frozen pytest -q
|
|
32
|
+
|
|
33
|
+
build:
|
|
34
|
+
name: Build package
|
|
35
|
+
needs: test
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
|
|
38
|
+
permissions:
|
|
39
|
+
contents: read
|
|
40
|
+
|
|
41
|
+
steps:
|
|
42
|
+
- name: Check out repository
|
|
43
|
+
uses: actions/checkout@v4
|
|
44
|
+
|
|
45
|
+
- name: Install uv
|
|
46
|
+
uses: astral-sh/setup-uv@v8.2.0
|
|
47
|
+
|
|
48
|
+
- name: Set up Python
|
|
49
|
+
uses: actions/setup-python@v5
|
|
50
|
+
with:
|
|
51
|
+
python-version: "3.13"
|
|
52
|
+
|
|
53
|
+
- name: Build package
|
|
54
|
+
run: uv build --no-sources
|
|
55
|
+
|
|
56
|
+
- name: Check wheel import
|
|
57
|
+
run: uv run --with dist/*.whl --no-project -- python -I -c "from clavem.transport import connect, serve; import clavem; print(clavem.__file__)"
|
|
58
|
+
|
|
59
|
+
- name: Upload package distributions
|
|
60
|
+
uses: actions/upload-artifact@v4
|
|
61
|
+
with:
|
|
62
|
+
name: python-package-distributions
|
|
63
|
+
path: dist/
|
|
64
|
+
|
|
65
|
+
publish:
|
|
66
|
+
name: Publish package
|
|
67
|
+
needs: build
|
|
68
|
+
runs-on: ubuntu-latest
|
|
69
|
+
environment:
|
|
70
|
+
name: pypi
|
|
71
|
+
url: https://pypi.org/project/clavem/
|
|
72
|
+
|
|
73
|
+
permissions:
|
|
74
|
+
contents: write
|
|
75
|
+
id-token: write
|
|
76
|
+
|
|
77
|
+
steps:
|
|
78
|
+
- name: Download package distributions
|
|
79
|
+
uses: actions/download-artifact@v4
|
|
80
|
+
with:
|
|
81
|
+
name: python-package-distributions
|
|
82
|
+
path: dist/
|
|
83
|
+
|
|
84
|
+
- name: Publish package
|
|
85
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
86
|
+
|
|
87
|
+
- name: Create GitHub Release
|
|
88
|
+
run: >-
|
|
89
|
+
gh release create "$GITHUB_REF_NAME"
|
|
90
|
+
dist/*.whl dist/*.tar.gz
|
|
91
|
+
--repo "$GITHUB_REPOSITORY"
|
|
92
|
+
--verify-tag --generate-notes
|
|
93
|
+
env:
|
|
94
|
+
GH_TOKEN: ${{ github.token }}
|
clavem-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
*.lcov
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
# Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# uv
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# uv.lock
|
|
103
|
+
|
|
104
|
+
# poetry
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
106
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
107
|
+
# commonly ignored for libraries.
|
|
108
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
109
|
+
# poetry.lock
|
|
110
|
+
# poetry.toml
|
|
111
|
+
|
|
112
|
+
# pdm
|
|
113
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
114
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
115
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
116
|
+
# pdm.lock
|
|
117
|
+
# pdm.toml
|
|
118
|
+
.pdm-python
|
|
119
|
+
.pdm-build/
|
|
120
|
+
|
|
121
|
+
# pixi
|
|
122
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
123
|
+
# pixi.lock
|
|
124
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
125
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
126
|
+
.pixi/*
|
|
127
|
+
!.pixi/config.toml
|
|
128
|
+
|
|
129
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
130
|
+
__pypackages__/
|
|
131
|
+
|
|
132
|
+
# Celery stuff
|
|
133
|
+
celerybeat-schedule*
|
|
134
|
+
celerybeat.pid
|
|
135
|
+
|
|
136
|
+
# Redis
|
|
137
|
+
*.rdb
|
|
138
|
+
*.aof
|
|
139
|
+
*.pid
|
|
140
|
+
|
|
141
|
+
# RabbitMQ
|
|
142
|
+
mnesia/
|
|
143
|
+
rabbitmq/
|
|
144
|
+
rabbitmq-data/
|
|
145
|
+
|
|
146
|
+
# ActiveMQ
|
|
147
|
+
activemq-data/
|
|
148
|
+
|
|
149
|
+
# SageMath parsed files
|
|
150
|
+
*.sage.py
|
|
151
|
+
|
|
152
|
+
# Environments
|
|
153
|
+
.env
|
|
154
|
+
.envrc
|
|
155
|
+
.venv
|
|
156
|
+
env/
|
|
157
|
+
venv/
|
|
158
|
+
ENV/
|
|
159
|
+
env.bak/
|
|
160
|
+
venv.bak/
|
|
161
|
+
|
|
162
|
+
# Spyder project settings
|
|
163
|
+
.spyderproject
|
|
164
|
+
.spyproject
|
|
165
|
+
|
|
166
|
+
# Rope project settings
|
|
167
|
+
.ropeproject
|
|
168
|
+
|
|
169
|
+
# mkdocs/Zensical documentation
|
|
170
|
+
/site
|
|
171
|
+
|
|
172
|
+
# mypy
|
|
173
|
+
.mypy_cache/
|
|
174
|
+
.dmypy.json
|
|
175
|
+
dmypy.json
|
|
176
|
+
|
|
177
|
+
# Pyre type checker
|
|
178
|
+
.pyre/
|
|
179
|
+
|
|
180
|
+
# pytype static type analyzer
|
|
181
|
+
.pytype/
|
|
182
|
+
|
|
183
|
+
# Cython debug symbols
|
|
184
|
+
cython_debug/
|
|
185
|
+
|
|
186
|
+
# PyCharm
|
|
187
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
188
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
189
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
190
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
191
|
+
# .idea/
|
|
192
|
+
|
|
193
|
+
# Abstra
|
|
194
|
+
# Abstra is an AI-powered process automation framework.
|
|
195
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
196
|
+
# Learn more at https://abstra.io/docs
|
|
197
|
+
.abstra/
|
|
198
|
+
|
|
199
|
+
# Visual Studio Code
|
|
200
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
|
|
201
|
+
# can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
202
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer, you
|
|
203
|
+
# could uncomment the following to ignore the entire vscode folder
|
|
204
|
+
# .vscode/
|
|
205
|
+
# Temporary file for partial code execution
|
|
206
|
+
tempCodeRunnerFile.py
|
|
207
|
+
|
|
208
|
+
# Ruff stuff:
|
|
209
|
+
.ruff_cache/
|
|
210
|
+
|
|
211
|
+
# PyPI configuration file
|
|
212
|
+
.pypirc
|
|
213
|
+
|
|
214
|
+
# Marimo
|
|
215
|
+
marimo/_static/
|
|
216
|
+
marimo/_lsp/
|
|
217
|
+
__marimo__/
|
|
218
|
+
|
|
219
|
+
# Streamlit
|
|
220
|
+
.streamlit/secrets.toml
|
|
221
|
+
|
|
222
|
+
# Local pre-shared keys
|
|
223
|
+
*.psk
|
|
224
|
+
psk.bin
|
clavem-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cham
|
|
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.
|
clavem-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: clavem
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small async TLS-PSK message channels.
|
|
5
|
+
Project-URL: Repository, https://github.com/Chamkang6093/clavem
|
|
6
|
+
Author: Cham
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: asyncio,messaging,psk,tls
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.13
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# clavem
|
|
19
|
+
|
|
20
|
+
Small async TLS-PSK message channels. Python 3.13+, no third-party runtime dependencies.
|
|
21
|
+
|
|
22
|
+
Clavem authenticates peers with a shared random key and carries length-prefixed byte
|
|
23
|
+
messages over one TLS 1.3 connection. It does not reconnect, schedule heartbeats,
|
|
24
|
+
serialize application data, or configure logging.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install clavem
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For an unpublished checkout, use `uv sync` and `uv run` instead. Both endpoints
|
|
33
|
+
need a Python/OpenSSL build with PSK support:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -c "import ssl; print(ssl.OPENSSL_VERSION, ssl.HAS_PSK)"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`HAS_PSK` must be `True`. Check this on the exact interpreter used for deployment
|
|
40
|
+
and on the interpreter bundled into a desktop executable.
|
|
41
|
+
|
|
42
|
+
## Connect and exchange messages
|
|
43
|
+
|
|
44
|
+
Create one 32-byte random key and securely provision the same bytes on both peers.
|
|
45
|
+
Use a different key for unrelated services. The identity is public, not a password.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
import asyncio
|
|
50
|
+
|
|
51
|
+
from clavem.transport import connect
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def main() -> None:
|
|
55
|
+
key = Path("psk.bin").read_bytes()
|
|
56
|
+
async with await connect("127.0.0.1", 9443, identity="home", key=key) as channel:
|
|
57
|
+
await channel.send(b"hello")
|
|
58
|
+
async with asyncio.timeout(5):
|
|
59
|
+
reply = await channel.receive()
|
|
60
|
+
print(reply)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
asyncio.run(main())
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
One task receives messages; multiple tasks may send, with complete frames serialized
|
|
67
|
+
by the channel. TCP_NODELAY is enabled on both peers: the client uses asyncio's
|
|
68
|
+
default, and the server explicitly enables it on accepted TLS sockets. Messages
|
|
69
|
+
can be empty and are limited to 65,536 bytes. The wire format is a four-byte
|
|
70
|
+
unsigned network-order length followed by that many bytes.
|
|
71
|
+
|
|
72
|
+
## Serve
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
import asyncio
|
|
77
|
+
|
|
78
|
+
from clavem.channel import Channel
|
|
79
|
+
from clavem.transport import serve
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def echo(channel: Channel) -> None:
|
|
83
|
+
try:
|
|
84
|
+
while True:
|
|
85
|
+
async with asyncio.timeout(30):
|
|
86
|
+
message = await channel.receive()
|
|
87
|
+
await channel.send(message)
|
|
88
|
+
except (asyncio.IncompleteReadError, ConnectionError, TimeoutError):
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def main() -> None:
|
|
93
|
+
key = Path("psk.bin").read_bytes()
|
|
94
|
+
async with serve(echo, "127.0.0.1", 9443, keys={"home": key}) as server:
|
|
95
|
+
await server.serve_forever()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
asyncio.run(main())
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`serve()` owns accepted channels and handler tasks. Returning from a handler closes
|
|
102
|
+
its channel. Leaving the server context closes the listener and cancels and awaits
|
|
103
|
+
remaining handlers. Handle expected peer disconnects in your handler; unexpected
|
|
104
|
+
handler exceptions stop the server and propagate through its task group.
|
|
105
|
+
|
|
106
|
+
## Contract
|
|
107
|
+
|
|
108
|
+
| API | Behavior |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `connect(..., timeout=5)` | Bounds TCP connection and TLS authentication together. Returns a `Channel`. |
|
|
111
|
+
| `serve(..., handshake_timeout=5)` | Async context manager yielding an `asyncio.Server`. |
|
|
112
|
+
| `Channel.send(bytes)` | Checks the size before writing and applies stream backpressure. |
|
|
113
|
+
| `Channel.receive()` | Reads a complete frame; EOF raises `asyncio.IncompleteReadError`. |
|
|
114
|
+
| `Channel.close()` | Closes the stream; supported by `async with channel`. |
|
|
115
|
+
| `client_context()` / `server_context()` | TLS contexts for applications that need lower-level asyncio integration. |
|
|
116
|
+
|
|
117
|
+
An oversized incoming frame raises `clavem.channel.MessageTooLarge` and closes
|
|
118
|
+
the stream. An oversized outgoing message raises the same error before writing.
|
|
119
|
+
Network, TLS, and timeout exceptions retain their standard-library types. After
|
|
120
|
+
cancelling or timing out an in-progress receive or send, close the channel rather
|
|
121
|
+
than trying to reuse a potentially partial exchange.
|
|
122
|
+
|
|
123
|
+
TLS contexts require external PSK authentication. The client loads no trusted CAs,
|
|
124
|
+
the server loads no certificate and issues no session tickets, and the transport
|
|
125
|
+
checks that PSK authentication was negotiated. There is no plaintext fallback.
|
|
126
|
+
Clavem sets neither application idle timeouts nor connection-count limits; the
|
|
127
|
+
hosting application owns those policies.
|
|
128
|
+
|
|
129
|
+
## Development and release
|
|
130
|
+
|
|
131
|
+
Use `uv sync` for the environment and committed `uv.lock`. Run `uv run pytest` for real
|
|
132
|
+
loopback TLS regressions: normal PSK exchange, rejection of untrusted and trusted
|
|
133
|
+
certificate fallback, and TCP_NODELAY checks on both peers. The publish workflow
|
|
134
|
+
runs these checks before building distributions.
|
|
135
|
+
|
|
136
|
+
Versions live in `pyproject.toml` and `uv.lock`. Release with a matching `vX.Y.Z`
|
|
137
|
+
tag after configuring the repository's `pypi` environment and the PyPI Trusted
|
|
138
|
+
Publisher. The workflow builds distributions, verifies an isolated wheel import,
|
|
139
|
+
publishes to PyPI, and attaches distributions to a GitHub Release.
|
clavem-0.1.0/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# clavem
|
|
2
|
+
|
|
3
|
+
Small async TLS-PSK message channels. Python 3.13+, no third-party runtime dependencies.
|
|
4
|
+
|
|
5
|
+
Clavem authenticates peers with a shared random key and carries length-prefixed byte
|
|
6
|
+
messages over one TLS 1.3 connection. It does not reconnect, schedule heartbeats,
|
|
7
|
+
serialize application data, or configure logging.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install clavem
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For an unpublished checkout, use `uv sync` and `uv run` instead. Both endpoints
|
|
16
|
+
need a Python/OpenSSL build with PSK support:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -c "import ssl; print(ssl.OPENSSL_VERSION, ssl.HAS_PSK)"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`HAS_PSK` must be `True`. Check this on the exact interpreter used for deployment
|
|
23
|
+
and on the interpreter bundled into a desktop executable.
|
|
24
|
+
|
|
25
|
+
## Connect and exchange messages
|
|
26
|
+
|
|
27
|
+
Create one 32-byte random key and securely provision the same bytes on both peers.
|
|
28
|
+
Use a different key for unrelated services. The identity is public, not a password.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
import asyncio
|
|
33
|
+
|
|
34
|
+
from clavem.transport import connect
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main() -> None:
|
|
38
|
+
key = Path("psk.bin").read_bytes()
|
|
39
|
+
async with await connect("127.0.0.1", 9443, identity="home", key=key) as channel:
|
|
40
|
+
await channel.send(b"hello")
|
|
41
|
+
async with asyncio.timeout(5):
|
|
42
|
+
reply = await channel.receive()
|
|
43
|
+
print(reply)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
asyncio.run(main())
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
One task receives messages; multiple tasks may send, with complete frames serialized
|
|
50
|
+
by the channel. TCP_NODELAY is enabled on both peers: the client uses asyncio's
|
|
51
|
+
default, and the server explicitly enables it on accepted TLS sockets. Messages
|
|
52
|
+
can be empty and are limited to 65,536 bytes. The wire format is a four-byte
|
|
53
|
+
unsigned network-order length followed by that many bytes.
|
|
54
|
+
|
|
55
|
+
## Serve
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
import asyncio
|
|
60
|
+
|
|
61
|
+
from clavem.channel import Channel
|
|
62
|
+
from clavem.transport import serve
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def echo(channel: Channel) -> None:
|
|
66
|
+
try:
|
|
67
|
+
while True:
|
|
68
|
+
async with asyncio.timeout(30):
|
|
69
|
+
message = await channel.receive()
|
|
70
|
+
await channel.send(message)
|
|
71
|
+
except (asyncio.IncompleteReadError, ConnectionError, TimeoutError):
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def main() -> None:
|
|
76
|
+
key = Path("psk.bin").read_bytes()
|
|
77
|
+
async with serve(echo, "127.0.0.1", 9443, keys={"home": key}) as server:
|
|
78
|
+
await server.serve_forever()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
asyncio.run(main())
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`serve()` owns accepted channels and handler tasks. Returning from a handler closes
|
|
85
|
+
its channel. Leaving the server context closes the listener and cancels and awaits
|
|
86
|
+
remaining handlers. Handle expected peer disconnects in your handler; unexpected
|
|
87
|
+
handler exceptions stop the server and propagate through its task group.
|
|
88
|
+
|
|
89
|
+
## Contract
|
|
90
|
+
|
|
91
|
+
| API | Behavior |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `connect(..., timeout=5)` | Bounds TCP connection and TLS authentication together. Returns a `Channel`. |
|
|
94
|
+
| `serve(..., handshake_timeout=5)` | Async context manager yielding an `asyncio.Server`. |
|
|
95
|
+
| `Channel.send(bytes)` | Checks the size before writing and applies stream backpressure. |
|
|
96
|
+
| `Channel.receive()` | Reads a complete frame; EOF raises `asyncio.IncompleteReadError`. |
|
|
97
|
+
| `Channel.close()` | Closes the stream; supported by `async with channel`. |
|
|
98
|
+
| `client_context()` / `server_context()` | TLS contexts for applications that need lower-level asyncio integration. |
|
|
99
|
+
|
|
100
|
+
An oversized incoming frame raises `clavem.channel.MessageTooLarge` and closes
|
|
101
|
+
the stream. An oversized outgoing message raises the same error before writing.
|
|
102
|
+
Network, TLS, and timeout exceptions retain their standard-library types. After
|
|
103
|
+
cancelling or timing out an in-progress receive or send, close the channel rather
|
|
104
|
+
than trying to reuse a potentially partial exchange.
|
|
105
|
+
|
|
106
|
+
TLS contexts require external PSK authentication. The client loads no trusted CAs,
|
|
107
|
+
the server loads no certificate and issues no session tickets, and the transport
|
|
108
|
+
checks that PSK authentication was negotiated. There is no plaintext fallback.
|
|
109
|
+
Clavem sets neither application idle timeouts nor connection-count limits; the
|
|
110
|
+
hosting application owns those policies.
|
|
111
|
+
|
|
112
|
+
## Development and release
|
|
113
|
+
|
|
114
|
+
Use `uv sync` for the environment and committed `uv.lock`. Run `uv run pytest` for real
|
|
115
|
+
loopback TLS regressions: normal PSK exchange, rejection of untrusted and trusted
|
|
116
|
+
certificate fallback, and TCP_NODELAY checks on both peers. The publish workflow
|
|
117
|
+
runs these checks before building distributions.
|
|
118
|
+
|
|
119
|
+
Versions live in `pyproject.toml` and `uv.lock`. Release with a matching `vX.Y.Z`
|
|
120
|
+
tag after configuring the repository's `pypi` environment and the PyPI Trusted
|
|
121
|
+
Publisher. The workflow builds distributions, verifies an isolated wheel import,
|
|
122
|
+
publishes to PyPI, and attaches distributions to a GitHub Release.
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ssl
|
|
5
|
+
import struct
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
|
|
8
|
+
MAX_MESSAGE = 65_536
|
|
9
|
+
_HEADER = struct.Struct("!I")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MessageTooLarge(ValueError):
|
|
13
|
+
"""A message exceeds the channel's 64 KiB limit."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Channel:
|
|
17
|
+
"""One reader and serialized writers on a length-prefixed byte stream."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
20
|
+
self._reader: asyncio.StreamReader = reader
|
|
21
|
+
self._writer: asyncio.StreamWriter = writer
|
|
22
|
+
self._send_lock: asyncio.Lock = asyncio.Lock()
|
|
23
|
+
|
|
24
|
+
async def send(self, message: bytes) -> None:
|
|
25
|
+
"""Send one message; concurrent senders cannot interleave frames."""
|
|
26
|
+
if len(message) > MAX_MESSAGE:
|
|
27
|
+
raise MessageTooLarge("message exceeds 64 KiB")
|
|
28
|
+
async with self._send_lock:
|
|
29
|
+
self._writer.write(_HEADER.pack(len(message)) + message)
|
|
30
|
+
await self._writer.drain()
|
|
31
|
+
|
|
32
|
+
async def receive(self) -> bytes:
|
|
33
|
+
"""Read one message. Only one task may receive at a time."""
|
|
34
|
+
header = await self._reader.readexactly(_HEADER.size)
|
|
35
|
+
(length,) = _HEADER.unpack(header)
|
|
36
|
+
if length > MAX_MESSAGE:
|
|
37
|
+
self._writer.close()
|
|
38
|
+
raise MessageTooLarge("peer message exceeds 64 KiB; channel closed")
|
|
39
|
+
return await self._reader.readexactly(length)
|
|
40
|
+
|
|
41
|
+
async def close(self) -> None:
|
|
42
|
+
"""Close the stream, bounded by its configured TLS shutdown timeout."""
|
|
43
|
+
self._writer.close()
|
|
44
|
+
try:
|
|
45
|
+
await self._writer.wait_closed()
|
|
46
|
+
except (ConnectionError, ssl.SSLError, TimeoutError):
|
|
47
|
+
# A disconnected peer cannot acknowledge TLS shutdown.
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self) -> Channel:
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
async def __aexit__(
|
|
54
|
+
self,
|
|
55
|
+
exc_type: type[BaseException] | None,
|
|
56
|
+
exc: BaseException | None,
|
|
57
|
+
traceback: TracebackType | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
await self.close()
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ssl
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _validate(identity: str, key: bytes) -> None:
|
|
8
|
+
if not identity or len(identity.encode("utf-8")) > 255 or "\0" in identity:
|
|
9
|
+
raise ValueError("identity must contain 1-255 UTF-8 bytes without NUL")
|
|
10
|
+
if len(key) != 32:
|
|
11
|
+
raise ValueError("key must contain 32 randomly generated bytes")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _context(protocol: int) -> ssl.SSLContext:
|
|
15
|
+
if not ssl.HAS_PSK:
|
|
16
|
+
raise RuntimeError("this Python/OpenSSL build does not support TLS-PSK")
|
|
17
|
+
context = ssl.SSLContext(protocol)
|
|
18
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
19
|
+
context.maximum_version = ssl.TLSVersion.TLSv1_3
|
|
20
|
+
return context
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def client_context(identity: str, key: bytes) -> ssl.SSLContext:
|
|
24
|
+
"""Authenticate with an external PSK; do not trust certificate fallback."""
|
|
25
|
+
_validate(identity, key)
|
|
26
|
+
context = _context(ssl.PROTOCOL_TLS_CLIENT)
|
|
27
|
+
context.check_hostname = False
|
|
28
|
+
# A successful external-PSK handshake needs no CA. Certificate fallback
|
|
29
|
+
# must still fail verification rather than authenticate an unrelated peer.
|
|
30
|
+
context.verify_mode = ssl.CERT_REQUIRED
|
|
31
|
+
context.set_psk_client_callback(lambda _hint: (identity, key))
|
|
32
|
+
return context
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def server_context(keys: Mapping[str, bytes]) -> ssl.SSLContext:
|
|
36
|
+
"""Accept only the supplied identities and their external PSKs."""
|
|
37
|
+
if not keys:
|
|
38
|
+
raise ValueError("at least one identity is required")
|
|
39
|
+
allowed = dict(keys)
|
|
40
|
+
for identity, key in allowed.items():
|
|
41
|
+
_validate(identity, key)
|
|
42
|
+
context = _context(ssl.PROTOCOL_TLS_SERVER)
|
|
43
|
+
context.num_tickets = 0
|
|
44
|
+
|
|
45
|
+
def lookup(identity: str | None) -> bytes:
|
|
46
|
+
if identity not in allowed:
|
|
47
|
+
return b"" # Decline unknown PSKs; no certificate fallback is configured.
|
|
48
|
+
return allowed[identity]
|
|
49
|
+
|
|
50
|
+
context.set_psk_server_callback(lookup)
|
|
51
|
+
return context
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
import ssl
|
|
6
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
|
|
9
|
+
from clavem.channel import MAX_MESSAGE, Channel
|
|
10
|
+
from clavem.tls import client_context, server_context
|
|
11
|
+
|
|
12
|
+
Handler = Callable[[Channel], Awaitable[None]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def connect(
|
|
16
|
+
host: str,
|
|
17
|
+
port: int,
|
|
18
|
+
*,
|
|
19
|
+
identity: str,
|
|
20
|
+
key: bytes,
|
|
21
|
+
timeout: float = 5, # noqa: ASYNC109 - also bounds the TLS handshake.
|
|
22
|
+
) -> Channel:
|
|
23
|
+
"""Establish a TLS 1.3 external-PSK channel within timeout seconds."""
|
|
24
|
+
if timeout <= 0:
|
|
25
|
+
raise ValueError("timeout must be positive")
|
|
26
|
+
context = client_context(identity, key)
|
|
27
|
+
async with asyncio.timeout(timeout):
|
|
28
|
+
reader, writer = await asyncio.open_connection(
|
|
29
|
+
host,
|
|
30
|
+
port,
|
|
31
|
+
ssl=context,
|
|
32
|
+
server_hostname="",
|
|
33
|
+
ssl_handshake_timeout=timeout,
|
|
34
|
+
ssl_shutdown_timeout=2,
|
|
35
|
+
limit=MAX_MESSAGE + 4,
|
|
36
|
+
)
|
|
37
|
+
channel = Channel(reader, writer)
|
|
38
|
+
if not writer.get_extra_info("ssl_object").session_reused:
|
|
39
|
+
await channel.close()
|
|
40
|
+
raise ssl.SSLError("peer did not authenticate with an external PSK")
|
|
41
|
+
return channel
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@asynccontextmanager
|
|
45
|
+
async def serve(
|
|
46
|
+
handler: Handler,
|
|
47
|
+
host: str,
|
|
48
|
+
port: int,
|
|
49
|
+
*,
|
|
50
|
+
keys: Mapping[str, bytes],
|
|
51
|
+
handshake_timeout: float = 5,
|
|
52
|
+
) -> AsyncIterator[asyncio.Server]:
|
|
53
|
+
"""Own a listener and its handlers; cancel handlers when leaving the context.
|
|
54
|
+
|
|
55
|
+
A handler owns one authenticated channel for its lifetime. Unexpected
|
|
56
|
+
handler errors propagate through the task group and stop the listener.
|
|
57
|
+
Applications should handle expected disconnects in their handler.
|
|
58
|
+
"""
|
|
59
|
+
if handshake_timeout <= 0:
|
|
60
|
+
raise ValueError("handshake_timeout must be positive")
|
|
61
|
+
context = server_context(keys)
|
|
62
|
+
tasks: set[asyncio.Task[None]] = set()
|
|
63
|
+
|
|
64
|
+
async def run(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
65
|
+
async with Channel(reader, writer) as channel:
|
|
66
|
+
if not writer.get_extra_info("ssl_object").session_reused:
|
|
67
|
+
raise ssl.SSLError("peer did not authenticate with an external PSK")
|
|
68
|
+
# Accepted sockets do not consistently inherit asyncio's TCP_NODELAY default.
|
|
69
|
+
writer.get_extra_info("socket").setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
70
|
+
await handler(channel)
|
|
71
|
+
|
|
72
|
+
async with asyncio.TaskGroup() as group:
|
|
73
|
+
|
|
74
|
+
def accept(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
75
|
+
task = group.create_task(run(reader, writer))
|
|
76
|
+
tasks.add(task)
|
|
77
|
+
task.add_done_callback(tasks.discard)
|
|
78
|
+
|
|
79
|
+
server = await asyncio.start_server(
|
|
80
|
+
accept,
|
|
81
|
+
host,
|
|
82
|
+
port,
|
|
83
|
+
ssl=context,
|
|
84
|
+
ssl_handshake_timeout=handshake_timeout,
|
|
85
|
+
ssl_shutdown_timeout=2,
|
|
86
|
+
limit=MAX_MESSAGE + 4,
|
|
87
|
+
)
|
|
88
|
+
try:
|
|
89
|
+
yield server
|
|
90
|
+
finally:
|
|
91
|
+
server.close()
|
|
92
|
+
for task in tasks:
|
|
93
|
+
task.cancel()
|
|
94
|
+
await server.wait_closed()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[tool.hatch.build.targets.wheel]
|
|
6
|
+
packages = ["clavem"]
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "clavem"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "Small async TLS-PSK message channels."
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.13"
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
authors = [{ name = "Cham" }]
|
|
17
|
+
keywords = ["asyncio", "tls", "psk", "messaging"]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/Chamkang6093/clavem"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=8.3", "pytest-asyncio>=1.0", "ruff>=0.11"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
asyncio_mode = "auto"
|
|
36
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
target-version = "py313"
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["E", "F", "I", "UP", "B", "ASYNC", "PTH", "RUF"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.format]
|
|
46
|
+
docstring-code-format = true
|
|
47
|
+
quote-style = "double"
|
|
48
|
+
line-ending = "lf"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import secrets
|
|
5
|
+
import ssl
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import pytest_asyncio
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="session", autouse=True)
|
|
14
|
+
def require_psk() -> None:
|
|
15
|
+
assert ssl.HAS_PSK, "transport regressions require a Python/OpenSSL build with PSK"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def key() -> bytes:
|
|
20
|
+
return secrets.token_bytes(32)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def certificate() -> Path:
|
|
25
|
+
return Path(__file__).parent / "data" / "server-cert.pem"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest_asyncio.fixture
|
|
29
|
+
async def certificate_server(certificate: Path) -> AsyncIterator[tuple[int, list[bool]]]:
|
|
30
|
+
"""A real TLS server that declines PSK and offers a test-only certificate."""
|
|
31
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
32
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_3
|
|
33
|
+
context.maximum_version = ssl.TLSVersion.TLSv1_3
|
|
34
|
+
context.load_cert_chain(certificate, certificate.with_name("server-key.pem"))
|
|
35
|
+
context.set_psk_server_callback(lambda _identity: b"")
|
|
36
|
+
context.num_tickets = 0
|
|
37
|
+
accepted: list[bool] = []
|
|
38
|
+
|
|
39
|
+
def accept(_reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
40
|
+
accepted.append(writer.get_extra_info("ssl_object").session_reused)
|
|
41
|
+
writer.close()
|
|
42
|
+
|
|
43
|
+
server = await asyncio.start_server(
|
|
44
|
+
accept,
|
|
45
|
+
"127.0.0.1",
|
|
46
|
+
0,
|
|
47
|
+
ssl=context,
|
|
48
|
+
ssl_handshake_timeout=2,
|
|
49
|
+
ssl_shutdown_timeout=1,
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
yield server.sockets[0].getsockname()[1], accepted
|
|
53
|
+
finally:
|
|
54
|
+
server.close()
|
|
55
|
+
server.close_clients()
|
|
56
|
+
await server.wait_closed()
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
The certificate and private key in this directory are public, test-only fixtures
|
|
2
|
+
for a loopback server. They are not deployment credentials and must never be used
|
|
3
|
+
outside tests. The client trusts this certificate only in the explicit certificate
|
|
4
|
+
fallback regression; the system trust store is never modified.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIBsjCCAVegAwIBAgIUdZbNcJpxwDLIu8sRDJvdBlTy318wCgYIKoZIzj0EAwIw
|
|
3
|
+
HjEcMBoGA1UEAwwTY2xhdmVtLXRlc3QuaW52YWxpZDAeFw0yNjExMTExMDE2Mzda
|
|
4
|
+
Fw0zNjExMDgxMDE2MzdaMB4xHDAaBgNVBAMME2NsYXZlbS10ZXN0LmludmFsaWQw
|
|
5
|
+
WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASECx4F3R54DKWC3Mung5Ldkki2dUVK
|
|
6
|
+
O4u+swzHE4mthGPGEJxbWIJgdAFQrtajS5dZQtmUQ8uHkXqFdloZX3YXo3MwcTAd
|
|
7
|
+
BgNVHQ4EFgQUU24EwuFL7SGJYVU1IxhKRXb7rcowHwYDVR0jBBgwFoAUU24EwuFL
|
|
8
|
+
7SGJYVU1IxhKRXb7rcowDwYDVR0TAQH/BAUwAwEB/zAeBgNVHREEFzAVghNjbGF2
|
|
9
|
+
ZW0tdGVzdC5pbnZhbGlkMAoGCCqGSM49BAMCA0kAMEYCIQCGi7jSR0VNUyYyoAFB
|
|
10
|
+
8aTdM11WPjNb/btQkrOnimB2mgIhAMSE6eZeoyVKRuSUYf0lt0Syiyj7c7RA0jvH
|
|
11
|
+
ypg4hR3Q
|
|
12
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
from clavem.channel import Channel
|
|
7
|
+
from clavem.transport import connect, serve
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def test_tcp_nodelay_is_enabled_on_both_peers(key: bytes) -> None:
|
|
11
|
+
server_option: asyncio.Future[int] = asyncio.get_running_loop().create_future()
|
|
12
|
+
|
|
13
|
+
async def inspect(channel: Channel) -> None:
|
|
14
|
+
connection = channel._writer.get_extra_info("socket")
|
|
15
|
+
server_option.set_result(connection.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY))
|
|
16
|
+
# Keep the channel open until the server context cancels this handler.
|
|
17
|
+
# Avoid an unrelated EOF masking a failed socket-option assertion.
|
|
18
|
+
await asyncio.Event().wait()
|
|
19
|
+
|
|
20
|
+
async with asyncio.timeout(5):
|
|
21
|
+
async with serve(inspect, "127.0.0.1", 0, keys={"home": key}) as server:
|
|
22
|
+
port = server.sockets[0].getsockname()[1]
|
|
23
|
+
async with await connect("127.0.0.1", port, identity="home", key=key) as channel:
|
|
24
|
+
connection = channel._writer.get_extra_info("socket")
|
|
25
|
+
# Enabled is nonzero; Darwin exposes the flag bit rather than 1.
|
|
26
|
+
assert connection.getsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY) != 0
|
|
27
|
+
assert await server_option != 0
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ssl
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from clavem import transport
|
|
10
|
+
from clavem.channel import Channel
|
|
11
|
+
from clavem.tls import client_context
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def test_psk_channel_exchanges_messages(key: bytes) -> None:
|
|
15
|
+
async def echo(channel: Channel) -> None:
|
|
16
|
+
await channel.send(await channel.receive())
|
|
17
|
+
|
|
18
|
+
async with asyncio.timeout(5):
|
|
19
|
+
async with transport.serve(echo, "127.0.0.1", 0, keys={"home": key}) as server:
|
|
20
|
+
port = server.sockets[0].getsockname()[1]
|
|
21
|
+
async with await transport.connect(
|
|
22
|
+
"127.0.0.1", port, identity="home", key=key
|
|
23
|
+
) as channel:
|
|
24
|
+
await channel.send(b"authenticated")
|
|
25
|
+
assert await channel.receive() == b"authenticated"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def test_rejects_untrusted_certificate_fallback(
|
|
29
|
+
certificate_server: tuple[int, list[bool]], key: bytes
|
|
30
|
+
) -> None:
|
|
31
|
+
port, _accepted = certificate_server
|
|
32
|
+
async with asyncio.timeout(5):
|
|
33
|
+
with pytest.raises(ssl.SSLCertVerificationError):
|
|
34
|
+
async with await transport.connect("127.0.0.1", port, identity="home", key=key):
|
|
35
|
+
pytest.fail("untrusted certificate fallback was accepted")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def test_rejects_trusted_certificate_fallback(
|
|
39
|
+
certificate_server: tuple[int, list[bool]],
|
|
40
|
+
certificate: Path,
|
|
41
|
+
key: bytes,
|
|
42
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
43
|
+
) -> None:
|
|
44
|
+
port, accepted = certificate_server
|
|
45
|
+
|
|
46
|
+
def trusted_context(identity: str, secret: bytes) -> ssl.SSLContext:
|
|
47
|
+
context = client_context(identity, secret)
|
|
48
|
+
context.load_verify_locations(certificate)
|
|
49
|
+
return context
|
|
50
|
+
|
|
51
|
+
# First establish that the real certificate fallback succeeds when trusted.
|
|
52
|
+
# This rules out an unrelated TLS/certificate failure as the test's cause.
|
|
53
|
+
async with asyncio.timeout(5):
|
|
54
|
+
reader, writer = await asyncio.open_connection(
|
|
55
|
+
"127.0.0.1",
|
|
56
|
+
port,
|
|
57
|
+
ssl=trusted_context("home", key),
|
|
58
|
+
server_hostname="",
|
|
59
|
+
ssl_shutdown_timeout=1,
|
|
60
|
+
)
|
|
61
|
+
try:
|
|
62
|
+
assert not writer.get_extra_info("ssl_object").session_reused
|
|
63
|
+
assert await reader.read() == b""
|
|
64
|
+
finally:
|
|
65
|
+
writer.close()
|
|
66
|
+
await writer.wait_closed()
|
|
67
|
+
assert accepted == [False]
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr(transport, "client_context", trusted_context)
|
|
70
|
+
with pytest.raises(ssl.SSLError, match="external PSK"):
|
|
71
|
+
async with await transport.connect("127.0.0.1", port, identity="home", key=key):
|
|
72
|
+
pytest.fail("certificate-only authentication was accepted")
|
clavem-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.13"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "clavem"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
source = { editable = "." }
|
|
9
|
+
|
|
10
|
+
[package.dev-dependencies]
|
|
11
|
+
dev = [
|
|
12
|
+
{ name = "pytest" },
|
|
13
|
+
{ name = "pytest-asyncio" },
|
|
14
|
+
{ name = "ruff" },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[package.metadata]
|
|
18
|
+
|
|
19
|
+
[package.metadata.requires-dev]
|
|
20
|
+
dev = [
|
|
21
|
+
{ name = "pytest", specifier = ">=8.3" },
|
|
22
|
+
{ name = "pytest-asyncio", specifier = ">=1.0" },
|
|
23
|
+
{ name = "ruff", specifier = ">=0.11" },
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[package]]
|
|
27
|
+
name = "colorama"
|
|
28
|
+
version = "0.4.6"
|
|
29
|
+
source = { registry = "https://pypi.org/simple" }
|
|
30
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
31
|
+
wheels = [
|
|
32
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[[package]]
|
|
36
|
+
name = "iniconfig"
|
|
37
|
+
version = "2.3.1"
|
|
38
|
+
source = { registry = "https://pypi.org/simple" }
|
|
39
|
+
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
|
40
|
+
wheels = [
|
|
41
|
+
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[[package]]
|
|
45
|
+
name = "packaging"
|
|
46
|
+
version = "26.3"
|
|
47
|
+
source = { registry = "https://pypi.org/simple" }
|
|
48
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
|
49
|
+
wheels = [
|
|
50
|
+
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
[[package]]
|
|
54
|
+
name = "pluggy"
|
|
55
|
+
version = "1.7.0"
|
|
56
|
+
source = { registry = "https://pypi.org/simple" }
|
|
57
|
+
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
|
|
58
|
+
wheels = [
|
|
59
|
+
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[[package]]
|
|
63
|
+
name = "pygments"
|
|
64
|
+
version = "2.21.0"
|
|
65
|
+
source = { registry = "https://pypi.org/simple" }
|
|
66
|
+
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
|
67
|
+
wheels = [
|
|
68
|
+
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
[[package]]
|
|
72
|
+
name = "pytest"
|
|
73
|
+
version = "9.2.0"
|
|
74
|
+
source = { registry = "https://pypi.org/simple" }
|
|
75
|
+
dependencies = [
|
|
76
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
77
|
+
{ name = "iniconfig" },
|
|
78
|
+
{ name = "packaging" },
|
|
79
|
+
{ name = "pluggy" },
|
|
80
|
+
{ name = "pygments" },
|
|
81
|
+
]
|
|
82
|
+
sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
|
|
83
|
+
wheels = [
|
|
84
|
+
{ url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[[package]]
|
|
88
|
+
name = "pytest-asyncio"
|
|
89
|
+
version = "1.4.1.post1"
|
|
90
|
+
source = { registry = "https://pypi.org/simple" }
|
|
91
|
+
dependencies = [
|
|
92
|
+
{ name = "pytest" },
|
|
93
|
+
]
|
|
94
|
+
sdist = { url = "https://files.pythonhosted.org/packages/73/7f/c60a4338420b33453d4f814ac0c930abd399f162b9bdd21af4b9e363037f/pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2", size = 145360, upload-time = "2026-11-01T18:26:03.732Z" }
|
|
95
|
+
wheels = [
|
|
96
|
+
{ url = "https://files.pythonhosted.org/packages/e3/de/adbc7578959cfab07e5e7c05cf6d54224a51e7667ea2c07458b6bc571e75/pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165", size = 17140, upload-time = "2026-11-01T18:26:02.168Z" },
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
[[package]]
|
|
100
|
+
name = "ruff"
|
|
101
|
+
version = "0.17.4"
|
|
102
|
+
source = { registry = "https://pypi.org/simple" }
|
|
103
|
+
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/3d154514b5bad104f7e1c13169c2d09aa9959fccdc06eb47610ccdfa83ed/ruff-0.17.4.tar.gz", hash = "sha256:f32d178b50e826e00f6c6c1be3f56333068acb9b67fe167143527fa9b81e890d", size = 5357750, upload-time = "2026-11-05T18:52:17.638Z" }
|
|
104
|
+
wheels = [
|
|
105
|
+
{ url = "https://files.pythonhosted.org/packages/e0/61/c83c78bbb6108f6c6270a106cf12b54175f4f42da088b9bb880f18e31289/ruff-0.17.4-py3-none-linux_armv6l.whl", hash = "sha256:2b08c4ff98dc234f4a3871a3105353b922fa875ea1073ca31001eb8c06bb4d89", size = 10252195, upload-time = "2026-11-05T18:51:36.594Z" },
|
|
106
|
+
{ url = "https://files.pythonhosted.org/packages/0a/12/dd158708378866e8b53e145a465dbc51a7cccb9fa197a74d67eaedde7234/ruff-0.17.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:13e781c3c5f663e7fba737eaaeb70dd943dd55171daf316afb25cd5268cfe0c7", size = 10266044, upload-time = "2026-11-05T18:51:39.502Z" },
|
|
107
|
+
{ url = "https://files.pythonhosted.org/packages/28/32/89a94a8eb11fd505a381ae6f04e190d038d9958695209dc1786ce5cfcda3/ruff-0.17.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ed2f416349944f908f9574215994602242b0836844eba48eec61473033afca0", size = 9432227, upload-time = "2026-11-05T18:51:41.968Z" },
|
|
108
|
+
{ url = "https://files.pythonhosted.org/packages/58/de/a392dc38b215798ae72e639d228b7f6c02d826a95d1c0ad7c4c0ee49ef5b/ruff-0.17.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ea6e0017c785f7282092b11e46338e4ebed1b9e575ebf2e55a8a1eda968241a", size = 10136900, upload-time = "2026-11-05T18:51:44.562Z" },
|
|
109
|
+
{ url = "https://files.pythonhosted.org/packages/b7/8f/a880f60f42005f6ffdf58f77af34c7ee327000b5607f48a2f6a69d8b242c/ruff-0.17.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e061dcc0933c7031a616ac8d66ac540309b2aa21ab558101a8f53e9ac7a1185", size = 10051245, upload-time = "2026-11-05T18:51:46.7Z" },
|
|
110
|
+
{ url = "https://files.pythonhosted.org/packages/09/55/98844264108af7d5ec27ca3a9cd5a989094ca657a14599c4b69a537a0e9a/ruff-0.17.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e95a1515161138be3cbd1b06c06e739d8feb38ade35564556999fffc3686e", size = 10704341, upload-time = "2026-11-05T18:51:49.228Z" },
|
|
111
|
+
{ url = "https://files.pythonhosted.org/packages/28/a2/5edf97659c3ac7c73f978003bd314936f671037fdac6b978e9de3e4b2b65/ruff-0.17.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f28cf0dd37cb172fa5a8d2e92e6ada3a9dbd0609f094ed24039929eef0b7524", size = 11394465, upload-time = "2026-11-05T18:51:51.534Z" },
|
|
112
|
+
{ url = "https://files.pythonhosted.org/packages/f2/8f/51371ae856b2bef4d15a044a574695fbc0f6ae63aafa42a2a22428070fb6/ruff-0.17.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b21c771b296888e8a35c6f48555f4b44e4483f61675d6ed51810a5203469ce6", size = 10988262, upload-time = "2026-11-05T18:51:53.91Z" },
|
|
113
|
+
{ url = "https://files.pythonhosted.org/packages/af/1b/62874f14c7873f27c7887d2b1ea41de990e14ba9518801ffe8eacb5d51a6/ruff-0.17.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cd025509b2e15721b426fc70fd946d71f43b3db3b08e946dfec2d696d911690", size = 10572829, upload-time = "2026-11-05T18:51:56.129Z" },
|
|
114
|
+
{ url = "https://files.pythonhosted.org/packages/98/30/860b9dae67432b16b88558a9cb2d3194825fa62838f8b9cc49f5d9022095/ruff-0.17.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:49b77f7b3921669466882a4881168801bdb06ec2cd83fb936049016cf5fdece1", size = 10617545, upload-time = "2026-11-05T18:51:58.172Z" },
|
|
115
|
+
{ url = "https://files.pythonhosted.org/packages/0f/08/781a4b41a1c83fd6e30fea610d6502a6f94e49cf94693fd37bdcbc6bfe11/ruff-0.17.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3b84eb74f260d1c2dbd236fd9d4c011d4fdd0a52bdf8aea57a10e3a1b71fca5", size = 10111955, upload-time = "2026-11-05T18:52:00.59Z" },
|
|
116
|
+
{ url = "https://files.pythonhosted.org/packages/3c/e9/94b1c7b677120f30839254518924f73a8157e9bcfa200edcd5796fecdb99/ruff-0.17.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:20e43d7a08e712e33067ba887e6574546c79bd4831596ce8f16803ac7ff9ad90", size = 10058355, upload-time = "2026-11-05T18:52:03.61Z" },
|
|
117
|
+
{ url = "https://files.pythonhosted.org/packages/27/5f/a825910b9f309336dc33cb8c0774126662c1d2f765699b3e0a1bd538d01f/ruff-0.17.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0d1f4848fd2ec0499be4046db9b7dfcc952666885f00233cc7033515282556a8", size = 10415932, upload-time = "2026-11-05T18:52:06.264Z" },
|
|
118
|
+
{ url = "https://files.pythonhosted.org/packages/8b/b8/0796468423413ea4df1a6e2442ae26cf49b8f7dda0eecf246e0d5d0dde54/ruff-0.17.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6007e7f3a0e55b0fbdb947af69cf24c21b4928df6be8ef3e767c69b5ccccf496", size = 10826033, upload-time = "2026-11-05T18:52:08.675Z" },
|
|
119
|
+
{ url = "https://files.pythonhosted.org/packages/b9/ab/d8e407669ffe5014429baf780c2df47e001968a6e4c17f615e2e7b13a9ba/ruff-0.17.4-py3-none-win32.whl", hash = "sha256:3971a78cba76298b284c8321e6d40d93045ed348bec5c369d85099efc97fb7cf", size = 9931993, upload-time = "2026-11-05T18:52:11.169Z" },
|
|
120
|
+
{ url = "https://files.pythonhosted.org/packages/bd/15/42af8a72d8946e3c9efd2bbf6641e462b6832783b0d043d63ef5b280ba7f/ruff-0.17.4-py3-none-win_amd64.whl", hash = "sha256:67c5a894fc054cf41f75f7afa24815de5a74329d97634126c2a1015740ff4e8c", size = 10292922, upload-time = "2026-11-05T18:52:13.436Z" },
|
|
121
|
+
{ url = "https://files.pythonhosted.org/packages/68/9e/f1dbffcfb8ff25dadbc21f87a8b73c3848abe4abada9d4b08ccef8bec40c/ruff-0.17.4-py3-none-win_arm64.whl", hash = "sha256:181ae38a9a8fb94925bf7d0794327b48d7fd7f4b50cfd84c0e999cc09ef8d131", size = 9889396, upload-time = "2026-11-05T18:52:15.549Z" },
|
|
122
|
+
]
|