personaltunnel-python-client 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.
@@ -0,0 +1,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(uv init:*)",
5
+ "Bash(uv add:*)",
6
+ "Bash(uv run:*)"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ personaltunnel-*.md
12
+ snuggly-*.md
13
+ .DS_Store
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: personaltunnel-python-client
3
+ Version: 0.1.0
4
+ Summary: Python asyncio client library for the personaltunnel Go server
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: bip-utils>=2.9.0
7
+ Requires-Dist: cffi<2.0
8
+ Requires-Dist: cryptography>=42.0.0
9
+ Requires-Dist: httpx>=0.27.0
@@ -0,0 +1,29 @@
1
+ """
2
+ personaltunnel_client — asyncio Python client library for the personaltunnel Go server.
3
+
4
+ Quick start::
5
+
6
+ import asyncio
7
+ from personaltunnel_client import PersonalTunnelClient, generate_keypair
8
+
9
+ # Generate a keypair (one-time setup — share your public key with the server operator)
10
+ priv_hex, pub_hex = generate_keypair()
11
+
12
+ async def main():
13
+ async with PersonalTunnelClient(
14
+ server_addr="vps.example.com",
15
+ control_port=7000,
16
+ local_addr="localhost:8000",
17
+ client_priv_key=priv_hex,
18
+ server_pub_key="<server_pub_hex>",
19
+ ):
20
+ print("Tunnel active. Press Ctrl+C to stop.")
21
+ await asyncio.Event().wait() # run forever
22
+
23
+ asyncio.run(main())
24
+ """
25
+
26
+ from .client import PersonalTunnelClient
27
+ from .crypto import generate_keypair
28
+
29
+ __all__ = ["PersonalTunnelClient", "generate_keypair"]
@@ -0,0 +1,234 @@
1
+ """
2
+ PersonalTunnelClient — asyncio client that connects to the personaltunnel
3
+ Go server, authenticates via the Nostr handshake, and forwards public HTTP
4
+ requests to a local service via yamux streams.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import inspect
11
+ import logging
12
+ from typing import Awaitable, Callable, Optional
13
+
14
+ from .crypto import EncConn, HandshakeError, client_handshake
15
+ from .forward import handle_stream
16
+ from .yamux import YamuxSession
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class PersonalTunnelClient:
22
+ """
23
+ Asyncio client library for the personaltunnel Go server.
24
+
25
+ Connects to the server's control port, performs Nostr mutual-auth,
26
+ multiplexes incoming HTTP requests over a yamux session, and forwards
27
+ each request to a local service. Reconnects automatically with
28
+ exponential backoff on connection loss.
29
+
30
+ Usage::
31
+
32
+ # Async context manager (tunnel runs as a background task)
33
+ async with PersonalTunnelClient(
34
+ server_addr="vps.example.com",
35
+ control_port=7000,
36
+ local_addr="localhost:8000",
37
+ client_priv_key="<hex>",
38
+ server_pub_key="<hex>",
39
+ ) as client:
40
+ await asyncio.sleep(3600)
41
+
42
+ # Explicit background task
43
+ client = PersonalTunnelClient(...)
44
+ task = await client.start()
45
+ await asyncio.sleep(3600)
46
+ await client.stop()
47
+
48
+ # Blocking run (never returns until stop() is called elsewhere)
49
+ await client.run()
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ server_addr: str,
55
+ control_port: int,
56
+ local_addr: str,
57
+ client_priv_key: str,
58
+ server_pub_key: str,
59
+ reconnect_interval: float = 2.0,
60
+ on_connect: Optional[Callable[[], Awaitable[None] | None]] = None,
61
+ on_disconnect: Optional[
62
+ Callable[[Optional[Exception]], Awaitable[None] | None]
63
+ ] = None,
64
+ ) -> None:
65
+ """
66
+ Args:
67
+ server_addr: Hostname or IP of the VPS running the Go server.
68
+ control_port: Control port of the Go server (typically 7000).
69
+ local_addr: Local service to forward traffic to
70
+ (e.g. "localhost:8000").
71
+ client_priv_key: Hex-encoded 32-byte secp256k1 private key.
72
+ server_pub_key: Hex-encoded 32-byte x-only secp256k1 public key
73
+ of the server (from ``generate_keypair()``).
74
+ reconnect_interval: Base reconnect delay in seconds (doubles on
75
+ each failure, capped at 60 s).
76
+ on_connect: Optional async or sync callback invoked each
77
+ time the tunnel connects successfully.
78
+ on_disconnect: Optional async or sync callback invoked when
79
+ the tunnel disconnects. Receives the exception
80
+ that caused the disconnect (or None on clean close).
81
+ """
82
+ self.server_addr = server_addr
83
+ self.control_port = control_port
84
+ self.local_addr = local_addr
85
+ self.reconnect_interval = reconnect_interval
86
+ self.on_connect = on_connect
87
+ self.on_disconnect = on_disconnect
88
+
89
+ # Pre-decode keys so errors surface at construction time.
90
+ self._client_priv_bytes: bytes = bytes.fromhex(client_priv_key)
91
+ self._server_pub_x: bytes = bytes.fromhex(server_pub_key)
92
+
93
+ if len(self._client_priv_bytes) != 32:
94
+ raise ValueError("client_priv_key must be a 32-byte (64 hex char) key")
95
+ if len(self._server_pub_x) != 32:
96
+ raise ValueError("server_pub_key must be a 32-byte (64 hex char) x-only key")
97
+
98
+ self._stop_event: asyncio.Event | None = None
99
+ self._task: asyncio.Task | None = None
100
+ self._active_session: YamuxSession | None = None
101
+
102
+ # ── Public API ───────────────────────────────────────────────────────────
103
+
104
+ async def start(self) -> asyncio.Task:
105
+ """
106
+ Start the tunnel in a background asyncio Task.
107
+
108
+ Returns the running Task so callers can await it or cancel it if needed.
109
+ """
110
+ self._stop_event = asyncio.Event()
111
+ self._task = asyncio.create_task(
112
+ self._run_loop(), name="personaltunnel-client"
113
+ )
114
+ return self._task
115
+
116
+ async def stop(self) -> None:
117
+ """Signal the client to stop and wait for the background task to finish."""
118
+ if self._stop_event:
119
+ self._stop_event.set()
120
+ if self._active_session:
121
+ await self._active_session.close()
122
+ if self._task and not self._task.done():
123
+ try:
124
+ await asyncio.wait_for(self._task, timeout=5.0)
125
+ except (asyncio.TimeoutError, asyncio.CancelledError):
126
+ self._task.cancel()
127
+
128
+ async def run(self) -> None:
129
+ """
130
+ Run the tunnel reconnect loop until stop() is called.
131
+
132
+ Useful as a top-level coroutine::
133
+
134
+ client = PersonalTunnelClient(...)
135
+ await client.run()
136
+ """
137
+ self._stop_event = asyncio.Event()
138
+ await self._run_loop()
139
+
140
+ async def __aenter__(self) -> "PersonalTunnelClient":
141
+ await self.start()
142
+ return self
143
+
144
+ async def __aexit__(self, *_args: object) -> None:
145
+ await self.stop()
146
+
147
+ # ── Internal ─────────────────────────────────────────────────────────────
148
+
149
+ async def _run_loop(self) -> None:
150
+ """Reconnect loop with exponential backoff."""
151
+ assert self._stop_event is not None
152
+
153
+ backoff = self.reconnect_interval
154
+
155
+ while not self._stop_event.is_set():
156
+ exc: Exception | None = None
157
+ try:
158
+ await self._connect_once()
159
+ backoff = self.reconnect_interval
160
+ except asyncio.CancelledError:
161
+ return
162
+ except (HandshakeError, ValueError) as e:
163
+ logger.error("tunnel: auth error: %s", e)
164
+ exc = e
165
+ except Exception as e:
166
+ logger.warning("tunnel: disconnected (%r), retry in %.1fs", e, backoff)
167
+ exc = e
168
+ else:
169
+ logger.info("tunnel: session closed cleanly")
170
+
171
+ if self.on_disconnect:
172
+ await _maybe_await(self.on_disconnect(exc))
173
+
174
+ # Wait for backoff duration (wake immediately if stop() is called).
175
+ try:
176
+ await asyncio.wait_for(
177
+ asyncio.shield(self._stop_event.wait()),
178
+ timeout=backoff,
179
+ )
180
+ return # stop was requested
181
+ except asyncio.TimeoutError:
182
+ pass
183
+
184
+ if exc is not None:
185
+ backoff = min(backoff * 2, 60.0)
186
+
187
+ async def _connect_once(self) -> None:
188
+ """Open one connection to the server and serve streams until closed."""
189
+ addr = f"{self.server_addr}:{self.control_port}"
190
+ logger.info("tunnel: connecting to %s", addr)
191
+
192
+ reader, writer = await asyncio.wait_for(
193
+ asyncio.open_connection(self.server_addr, self.control_port),
194
+ timeout=10.0,
195
+ )
196
+
197
+ try:
198
+ enc_conn: EncConn = await client_handshake(
199
+ reader,
200
+ writer,
201
+ self._client_priv_bytes,
202
+ self._server_pub_x,
203
+ )
204
+
205
+ session = YamuxSession(enc_conn)
206
+ self._active_session = session
207
+ await session.start()
208
+
209
+ logger.info("tunnel: connected and authenticated to %s", addr)
210
+ if self.on_connect:
211
+ await _maybe_await(self.on_connect())
212
+
213
+ while not session.closed:
214
+ stream = await session.accept()
215
+ if stream is None:
216
+ break
217
+ asyncio.create_task(
218
+ handle_stream(stream, self.local_addr),
219
+ name=f"stream-{stream._stream_id}",
220
+ )
221
+
222
+ finally:
223
+ self._active_session = None
224
+ writer.close()
225
+ try:
226
+ await writer.wait_closed()
227
+ except Exception:
228
+ pass
229
+
230
+
231
+ async def _maybe_await(result: object) -> None:
232
+ """Await *result* if it is a coroutine, otherwise discard it."""
233
+ if inspect.isawaitable(result):
234
+ await result # type: ignore[arg-type]
@@ -0,0 +1,229 @@
1
+ """
2
+ Nostr mutual-auth handshake and ChaCha20-Poly1305 encrypted connection.
3
+
4
+ Wire protocol (client side):
5
+ Step 1 receive: nonce_s (32) + server_pub_x_only (32)
6
+ Step 1 send: nonce_c (32) + client_pub_x_only (32)
7
+ Step 2 send: schnorr_sig(SHA256(nonce_s)) (64 bytes)
8
+ Step 2 receive: schnorr_sig(SHA256(nonce_c)) (64 bytes)
9
+ Step 3 receive: status byte (0x00 = OK, 0x01 = rejected)
10
+ Step 4: ECDH + HKDF → 32-byte ChaCha20-Poly1305 key
11
+
12
+ Crypto dependencies:
13
+ - coincurve (via bip-utils): secp256k1 Schnorr BIP340 sign/verify, ECDH
14
+ - cryptography: ChaCha20-Poly1305 AEAD, HKDF-SHA256
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import hashlib
21
+ import os
22
+ import struct
23
+
24
+ import coincurve
25
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
26
+ from cryptography.hazmat.primitives.hashes import SHA256
27
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
28
+
29
+ _MAX_MESSAGE_SIZE = 1 << 20 # 1 MiB, matches Go's maxMessageSize
30
+ _HKDF_INFO = b"personaltunnel-v1"
31
+ _STATUS_OK = 0x00
32
+
33
+
34
+ class HandshakeError(Exception):
35
+ """Raised when the Nostr mutual-auth handshake fails."""
36
+
37
+
38
+ class EncConn:
39
+ """
40
+ Async ChaCha20-Poly1305 encrypted connection.
41
+
42
+ Wire format per message:
43
+ 4 bytes big-endian uint32: length of ciphertext (incl. 16-byte AEAD tag)
44
+ N bytes ChaCha20-Poly1305 ciphertext
45
+
46
+ Nonces are 12-byte little-endian encodings of a uint64 counter (separate
47
+ send/recv sequences starting at 0), matching Go's encconn.go seqNonce().
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ reader: asyncio.StreamReader,
53
+ writer: asyncio.StreamWriter,
54
+ key: bytes,
55
+ ) -> None:
56
+ self._reader = reader
57
+ self._writer = writer
58
+ self._aead = ChaCha20Poly1305(key)
59
+ self._send_lock = asyncio.Lock()
60
+ self._send_seq: int = 0
61
+ self._recv_seq: int = 0
62
+ self._recv_buf = bytearray()
63
+
64
+ @staticmethod
65
+ def _seq_nonce(seq: int) -> bytes:
66
+ """12-byte little-endian nonce from a uint64 counter (4 zero pad bytes)."""
67
+ return struct.pack("<Q", seq) + b"\x00\x00\x00\x00"
68
+
69
+ async def send_msg(self, plaintext: bytes) -> None:
70
+ """Encrypt plaintext and write as a single length-prefixed message."""
71
+ async with self._send_lock:
72
+ nonce = self._seq_nonce(self._send_seq)
73
+ self._send_seq += 1
74
+ ciphertext = self._aead.encrypt(nonce, plaintext, None)
75
+ if len(ciphertext) > _MAX_MESSAGE_SIZE:
76
+ raise ValueError(f"message too large: {len(ciphertext)} bytes")
77
+ self._writer.write(struct.pack(">I", len(ciphertext)) + ciphertext)
78
+ await self._writer.drain()
79
+
80
+ async def recv_msg(self) -> bytes:
81
+ """Read and decrypt the next length-prefixed message."""
82
+ length_bytes = await self._reader.readexactly(4)
83
+ msg_len = struct.unpack(">I", length_bytes)[0]
84
+ if msg_len == 0 or msg_len > _MAX_MESSAGE_SIZE:
85
+ raise ValueError(f"invalid message length: {msg_len}")
86
+ ciphertext = await self._reader.readexactly(msg_len)
87
+ nonce = self._seq_nonce(self._recv_seq)
88
+ self._recv_seq += 1
89
+ try:
90
+ return self._aead.decrypt(nonce, ciphertext, None)
91
+ except Exception as exc:
92
+ raise ValueError(f"decryption failed: {exc}") from exc
93
+
94
+ async def read(self, n: int) -> bytes:
95
+ """
96
+ Read exactly n bytes, decrypting new messages as needed.
97
+ Excess plaintext from a decrypted message is retained in an internal
98
+ buffer for subsequent reads, matching Go's encConn.Read() behaviour.
99
+ """
100
+ while len(self._recv_buf) < n:
101
+ chunk = await self.recv_msg()
102
+ self._recv_buf.extend(chunk)
103
+ data = bytes(self._recv_buf[:n])
104
+ del self._recv_buf[:n]
105
+ return data
106
+
107
+ async def write(self, data: bytes) -> None:
108
+ """Encrypt and send data (one encrypted message per call)."""
109
+ await self.send_msg(data)
110
+
111
+ def close(self) -> None:
112
+ """Close the underlying transport."""
113
+ self._writer.close()
114
+
115
+
116
+ async def client_handshake(
117
+ reader: asyncio.StreamReader,
118
+ writer: asyncio.StreamWriter,
119
+ client_priv_bytes: bytes,
120
+ expected_server_pub_x: bytes,
121
+ ) -> EncConn:
122
+ """
123
+ Perform the Nostr mutual-auth handshake as the client side.
124
+
125
+ Args:
126
+ reader: asyncio StreamReader connected to the server control port.
127
+ writer: asyncio StreamWriter connected to the server control port.
128
+ client_priv_bytes: 32-byte raw secp256k1 private key.
129
+ expected_server_pub_x: 32-byte x-only Nostr pubkey of the server.
130
+
131
+ Returns:
132
+ An EncConn ready to be used as a yamux transport.
133
+
134
+ Raises:
135
+ HandshakeError: On pubkey mismatch, invalid signature, or server rejection.
136
+ """
137
+ # ── Step 1 receive: nonce_s + server_pub_x_only ──────────────────────────
138
+ data = await reader.readexactly(64)
139
+ nonce_s: bytes = data[:32]
140
+ server_pub_x_only: bytes = data[32:]
141
+
142
+ if server_pub_x_only != expected_server_pub_x:
143
+ raise HandshakeError(
144
+ f"server pubkey mismatch: got {server_pub_x_only.hex()}, "
145
+ f"expected {expected_server_pub_x.hex()}"
146
+ )
147
+
148
+ # ── Step 1 send: nonce_c + client_pub_x_only ─────────────────────────────
149
+ nonce_c: bytes = os.urandom(32)
150
+ client_priv = coincurve.PrivateKey(secret=client_priv_bytes)
151
+ # x-only pubkey: 32-byte x-coordinate (BIP340 even-Y key)
152
+ client_pub_x_only: bytes = client_priv.public_key_xonly.format()
153
+ writer.write(nonce_c + client_pub_x_only)
154
+ await writer.drain()
155
+
156
+ # ── Step 2 send: schnorr_sig(SHA256(nonce_s)) ────────────────────────────
157
+ nonce_s_hash = hashlib.sha256(nonce_s).digest()
158
+ sig_c: bytes = client_priv.sign_schnorr(nonce_s_hash)
159
+ writer.write(sig_c)
160
+ await writer.drain()
161
+
162
+ # ── Step 2 receive: server signature over nonce_c ────────────────────────
163
+ sig_s = await reader.readexactly(64)
164
+ nonce_c_hash = hashlib.sha256(nonce_c).digest()
165
+ server_xonly_pub = coincurve.PublicKeyXOnly(server_pub_x_only)
166
+ if not server_xonly_pub.verify(sig_s, nonce_c_hash):
167
+ raise HandshakeError("server signature verification failed")
168
+
169
+ # ── Step 3 receive: status byte ──────────────────────────────────────────
170
+ status = await reader.readexactly(1)
171
+ if status[0] != _STATUS_OK:
172
+ raise HandshakeError(
173
+ f"server rejected connection (status {status[0]:#04x})"
174
+ )
175
+
176
+ # ── Step 4: ECDH + HKDF → encryption key ─────────────────────────────────
177
+ shared_x = _ecdh_x(client_priv_bytes, server_pub_x_only)
178
+ key = _derive_key(shared_x, salt=nonce_s + nonce_c)
179
+
180
+ return EncConn(reader, writer, key)
181
+
182
+
183
+ def _ecdh_x(priv_bytes: bytes, peer_pub_x_only: bytes) -> bytes:
184
+ """
185
+ Compute the x-coordinate of the ECDH shared point.
186
+
187
+ Peer's x-only key is converted to a compressed secp256k1 point using the
188
+ BIP340 even-Y convention (0x02 prefix) before scalar multiplication.
189
+
190
+ Equivalent to Go:
191
+ btcec.ScalarMultNonConst(&privKeyScalar, peerPoint, peerPoint)
192
+ sharedX := peerPoint.X.Bytes()
193
+ """
194
+ # BIP340 x-only → even-Y compressed point (0x02 prefix)
195
+ peer_compressed = b"\x02" + peer_pub_x_only
196
+ peer_pub = coincurve.PublicKey(peer_compressed)
197
+ # multiply returns a new PublicKey; extract x-coordinate from compressed form
198
+ shared_pub = peer_pub.multiply(priv_bytes)
199
+ return shared_pub.format(compressed=True)[1:] # 32-byte x-coordinate
200
+
201
+
202
+ def _derive_key(ikm: bytes, salt: bytes) -> bytes:
203
+ """
204
+ HKDF-SHA256 with info="personaltunnel-v1", output 32 bytes.
205
+
206
+ Equivalent to Go:
207
+ hkdf.New(sha256.New, sharedX, salt, []byte("personaltunnel-v1"))
208
+ """
209
+ hkdf = HKDF(
210
+ algorithm=SHA256(),
211
+ length=32,
212
+ salt=salt,
213
+ info=_HKDF_INFO,
214
+ )
215
+ return hkdf.derive(ikm)
216
+
217
+
218
+ def generate_keypair() -> tuple[str, str]:
219
+ """
220
+ Generate a new secp256k1 keypair suitable for use with personaltunnel.
221
+
222
+ Returns:
223
+ (privkey_hex, pubkey_hex) where pubkey_hex is the 32-byte x-only
224
+ (Nostr/BIP340) public key, both hex-encoded.
225
+ """
226
+ priv = coincurve.PrivateKey()
227
+ priv_hex = priv.secret.hex()
228
+ pub_hex = priv.public_key_xonly.format().hex()
229
+ return priv_hex, pub_hex