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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.