clavem 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.
- clavem/__init__.py +0 -0
- clavem/channel.py +59 -0
- clavem/py.typed +0 -0
- clavem/tls.py +51 -0
- clavem/transport.py +94 -0
- clavem-0.1.0.dist-info/METADATA +139 -0
- clavem-0.1.0.dist-info/RECORD +9 -0
- clavem-0.1.0.dist-info/WHEEL +4 -0
- clavem-0.1.0.dist-info/licenses/LICENSE +21 -0
clavem/__init__.py
ADDED
|
File without changes
|
clavem/channel.py
ADDED
|
@@ -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()
|
clavem/py.typed
ADDED
|
File without changes
|
clavem/tls.py
ADDED
|
@@ -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
|
clavem/transport.py
ADDED
|
@@ -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,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.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
clavem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
clavem/channel.py,sha256=ZvDG9b-p2PMlMxj2WohOC-m2MgreTsjltA8uurys_yg,2002
|
|
3
|
+
clavem/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
clavem/tls.py,sha256=ar8telR9WCfZUqyfjuB_I7pc5uomSmt8JPG3mtjWs1Q,1914
|
|
5
|
+
clavem/transport.py,sha256=Omp_VRtj7Nub6YfRhrcr8kW68hLP-3UfpJvtPeCWWFY,3176
|
|
6
|
+
clavem-0.1.0.dist-info/METADATA,sha256=g8XOweb2O0JJyJeUWD56Gx8AZlJMmPYx3g2ZZnkDamg,5201
|
|
7
|
+
clavem-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
clavem-0.1.0.dist-info/licenses/LICENSE,sha256=R2JKtKOoqKohwk31ygpA8Udqk6nZjU-sWPhtwYxj0M8,1061
|
|
9
|
+
clavem-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|