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.
- personaltunnel_python_client-0.1.0/.claude/settings.local.json +9 -0
- personaltunnel_python_client-0.1.0/.gitignore +13 -0
- personaltunnel_python_client-0.1.0/.python-version +1 -0
- personaltunnel_python_client-0.1.0/PKG-INFO +9 -0
- personaltunnel_python_client-0.1.0/personaltunnel_client/__init__.py +29 -0
- personaltunnel_python_client-0.1.0/personaltunnel_client/client.py +234 -0
- personaltunnel_python_client-0.1.0/personaltunnel_client/crypto.py +229 -0
- personaltunnel_python_client-0.1.0/personaltunnel_client/forward.py +183 -0
- personaltunnel_python_client-0.1.0/personaltunnel_client/yamux.py +319 -0
- personaltunnel_python_client-0.1.0/pyproject.toml +18 -0
- personaltunnel_python_client-0.1.0/requirements.txt +4 -0
- personaltunnel_python_client-0.1.0/uv.lock +741 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -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
|