Skip to content

Commit 7bc86f3

Browse files
authored
Support SSL pinning (#14)
1 parent 5de25a6 commit 7bc86f3

6 files changed

Lines changed: 316 additions & 24 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,25 @@ async with NanoKVMClient(
105105
await client.authenticate("username", "password")
106106
```
107107

108-
#### Option 2: Use custom CA certificate (recommended)
108+
#### Option 2: Certificate pinning (recommended for self-signed)
109+
110+
NanoKVM devices generate self-signed certificates for `localhost` with no CA to verify against. Certificate pinning verifies the server's certificate fingerprint directly instead of relying on CA-based trust.
111+
112+
```python
113+
from nanokvm.utils import async_fetch_remote_fingerprint
114+
115+
# First, fetch the fingerprint (trust-on-first-use)
116+
fingerprint = await async_fetch_remote_fingerprint("https://kvm.local/api/")
117+
118+
# Then connect with the pinned fingerprint
119+
async with NanoKVMClient(
120+
"https://kvm.local/api/",
121+
ssl_fingerprint=fingerprint,
122+
) as client:
123+
await client.authenticate("username", "password")
124+
```
125+
126+
#### Option 3: Use custom CA certificate
109127

110128
```python
111129
async with NanoKVMClient(

nanokvm/client.py

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
BodyPartReader,
1717
ClientResponse,
1818
ClientSession,
19+
Fingerprint,
1920
MultipartReader,
20-
TCPConnector,
2121
hdrs,
2222
)
2323
from PIL import Image
@@ -123,40 +123,51 @@ def __init__(
123123
*,
124124
token: str | None = None,
125125
request_timeout: int = 10,
126+
session: ClientSession | None = None,
126127
verify_ssl: bool = True,
127128
ssl_ca_cert: str | None = None,
129+
ssl_fingerprint: str | None = None,
128130
use_password_obfuscation: bool | None = None,
129131
) -> None:
130132
"""
131133
Initialize the NanoKVM client.
132134
133135
Args:
134136
url: Base URL of the NanoKVM API (e.g., "https://kvm.local/api/")
137+
session: aiohttp ClientSession to use for requests.
135138
token: Optional pre-existing authentication token
136139
request_timeout: Request timeout in seconds (default: 10)
137140
verify_ssl: Enable SSL certificate verification (default: True).
138141
Set to False to disable verification for self-signed certificates.
139142
ssl_ca_cert: Path to custom CA certificate bundle file for SSL verification.
140143
Useful for self-signed certificates or private CAs.
144+
ssl_fingerprint: SHA-256 fingerprint of the server's TLS certificate
145+
as a hex string. When set, the client will verify the server's
146+
certificate fingerprint instead of performing CA-based verification.
147+
Use `async_fetch_remote_fingerprint()` to retrieve this value.
141148
use_password_obfuscation: Control password obfuscation mode (default: None).
142149
None = auto-detect (try obfuscated first, fall back to plain text).
143150
True = always use obfuscated passwords (older NanoKVM versions).
144151
False = always use plain text passwords (newer HTTPS-enabled versions).
145152
"""
146153
self.url = yarl.URL(url)
147-
self._session: ClientSession | None = None
154+
self._session: ClientSession | None = session
155+
self._external_session_provided = session is not None
148156
self._token = token
149157
self._request_timeout = request_timeout
150158
self._ws: aiohttp.ClientWebSocketResponse | None = None
151159
self._verify_ssl = verify_ssl
152160
self._ssl_ca_cert = ssl_ca_cert
161+
self._ssl_fingerprint = ssl_fingerprint
153162
self._use_password_obfuscation = use_password_obfuscation
163+
self._ssl_config: ssl.SSLContext | Fingerprint | bool | None = None
154164

155-
def _create_ssl_context(self) -> ssl.SSLContext | bool:
165+
def _create_ssl_context(self) -> ssl.SSLContext | Fingerprint | bool:
156166
"""
157167
Create and configure SSL context based on initialization parameters.
158168
159169
Returns:
170+
Fingerprint: Certificate fingerprint pinning (when ssl_fingerprint set)
160171
ssl.SSLContext: Configured SSL context for custom certificates
161172
True: Use default SSL verification (aiohttp default)
162173
False: Disable SSL verification
@@ -166,6 +177,10 @@ def _create_ssl_context(self) -> ssl.SSLContext | bool:
166177
ssl.SSLError: If the CA certificate is invalid.
167178
"""
168179

180+
if self._ssl_fingerprint:
181+
_LOGGER.debug("Using certificate fingerprint pinning")
182+
return Fingerprint(bytes.fromhex(self._ssl_fingerprint.replace(":", "")))
183+
169184
if not self._verify_ssl:
170185
_LOGGER.warning(
171186
"SSL verification is disabled. This is insecure and should only be "
@@ -188,16 +203,10 @@ def token(self) -> str | None:
188203

189204
async def __aenter__(self) -> NanoKVMClient:
190205
"""Async context manager entry."""
206+
if self._session is None and not self._external_session_provided:
207+
self._session = ClientSession()
191208

192-
ssl_config = await asyncio.to_thread(self._create_ssl_context)
193-
connector = TCPConnector(ssl=ssl_config)
194-
self._session = ClientSession(connector=connector)
195-
196-
_LOGGER.debug(
197-
"Created client session with SSL verification: %s",
198-
"disabled" if ssl_config is False else "enabled",
199-
)
200-
209+
self._ssl_config = await asyncio.to_thread(self._create_ssl_context)
201210
return self
202211

203212
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
@@ -206,8 +215,9 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
206215
if self._ws is not None and not self._ws.closed:
207216
await self._ws.close()
208217
self._ws = None
218+
209219
# Close HTTP session
210-
if self._session is not None:
220+
if self._session is not None and not self._external_session_provided:
211221
await self._session.close()
212222
self._session = None
213223

@@ -221,16 +231,15 @@ async def _request(
221231
**kwargs: Any,
222232
) -> AsyncIterator[ClientResponse]:
223233
"""Make an API request."""
224-
assert self._session is not None, (
225-
"Client session not initialized. "
226-
"Use as context manager: 'async with NanoKVMClient(url) as client:'"
227-
)
228234
cookies = {}
229235
if authenticate:
230236
if not self._token:
231237
raise NanoKVMNotAuthenticatedError("Client is not authenticated")
232238
cookies["nano-kvm-token"] = self._token
233239

240+
assert self._session is not None
241+
assert self._ssl_config is not None
242+
234243
async with self._session.request(
235244
method,
236245
self.url / path.lstrip("/"),
@@ -240,6 +249,7 @@ async def _request(
240249
cookies=cookies,
241250
timeout=aiohttp.ClientTimeout(total=self._request_timeout),
242251
raise_for_status=True,
252+
ssl=self._ssl_config,
243253
**kwargs,
244254
) as response:
245255
yield response
@@ -278,7 +288,7 @@ async def _api_request_json(
278288
async with self._request(
279289
method,
280290
path,
281-
json=(data.dict() if data is not None else None),
291+
json=(data.model_dump() if data is not None else None),
282292
**kwargs,
283293
) as response:
284294
try:
@@ -795,21 +805,20 @@ async def set_mouse_jiggler_state(
795805
async def _get_ws(self) -> aiohttp.ClientWebSocketResponse:
796806
"""Get or create WebSocket connection for mouse events."""
797807
if self._ws is None or self._ws.closed:
798-
assert self._session is not None, (
799-
"Client session not initialized. "
800-
"Use as context manager: 'async with NanoKVMClient(url) as client:'"
801-
)
802-
803808
if not self._token:
804809
raise NanoKVMNotAuthenticatedError("Client is not authenticated")
805810

806811
# WebSocket URL uses ws:// or wss:// scheme
807812
scheme = "ws" if self.url.scheme == "http" else "wss"
808813
ws_url = self.url.with_scheme(scheme) / "ws"
809814

815+
assert self._session is not None
816+
assert self._ssl_config is not None
817+
810818
self._ws = await self._session.ws_connect(
811819
str(ws_url),
812820
headers={"Cookie": f"nano-kvm-token={self._token}"},
821+
ssl=self._ssl_config,
813822
)
814823
return self._ws
815824

nanokvm/utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import asyncio
12
import base64
23
import hashlib
34
import os
5+
import ssl
46
import urllib.parse
57

68
from cryptography.hazmat.primitives import padding
@@ -52,3 +54,34 @@ def obfuscate_password(password: str) -> str:
5254
)
5355

5456
return urllib.parse.quote(base64.b64encode(password_enc).decode("utf-8"), safe="")
57+
58+
59+
async def async_fetch_remote_fingerprint(
60+
url: str, *, timeout: float | None = 10.0
61+
) -> str:
62+
"""Retrieve the SHA-256 fingerprint of the remote server's TLS certificate.
63+
64+
Connects to the server with verification disabled to grab the raw certificate,
65+
then returns its SHA-256 hash as an uppercase hex string.
66+
67+
This is useful for establishing an initial trust-on-first-use pin with
68+
`NanoKVMClient(url, ssl_fingerprint=...)`.
69+
"""
70+
parsed_url = urllib.parse.urlparse(url)
71+
hostname = parsed_url.hostname
72+
port = parsed_url.port or 443
73+
74+
ssl_ctx = await asyncio.to_thread(ssl.create_default_context)
75+
ssl_ctx.check_hostname = False
76+
ssl_ctx.verify_mode = ssl.CERT_NONE
77+
78+
async with asyncio.timeout(timeout):
79+
_, writer = await asyncio.open_connection(hostname, port, ssl=ssl_ctx)
80+
81+
try:
82+
ssl_obj = writer.get_extra_info("ssl_object")
83+
der_cert = ssl_obj.getpeercert(binary_form=True)
84+
return hashlib.sha256(der_cert).hexdigest().upper()
85+
finally:
86+
writer.close()
87+
await writer.wait_closed()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ exclude = ["tests", "tests.*"]
2929
testing = [
3030
"tomli",
3131
"coverage[toml]",
32+
"cryptography",
3233
"pytest",
3334
"pytest-xdist",
3435
"pytest-asyncio",

0 commit comments

Comments
 (0)