Marco Ricci commited on 2025-12-07 00:14:33
Zeige 1 geänderte Dateien mit 299 Einfügungen und 6 Löschungen.
Using the `ctypes` module, call into The Annoying OS's system libraries and bind the functions necessary to create, read from/write to, and close handles to existing named pipes. We also bind the functions necessary to compute the pipe name for PuTTY/Pageant, the de facto main SSH implementation on The Annoying OS. Since many of the symbols are undefined on other operating systems, and because the `ctypes` library relies on dynamically generated attributes and is thus mostly invisible to static analysis tools, much of the code needs type checking exemptions and extraneous explicit (C) casts at the Python level. Additionally, because our stub functions use the same CamelCase naming as The Annoying OS's official documentation does, much of the code also needs linting exceptions for the naming policy. TODO: error check, type check, and coverage check. Actually test on The Annoying OS.
| ... | ... |
@@ -8,15 +8,50 @@ from __future__ import annotations |
| 8 | 8 |
|
| 9 | 9 |
import collections |
| 10 | 10 |
import ctypes |
| 11 |
+import ctypes.wintypes |
|
| 12 |
+import errno |
|
| 13 |
+import hashlib |
|
| 11 | 14 |
import os |
| 12 | 15 |
import socket |
| 13 |
-from typing import TYPE_CHECKING, ClassVar, cast |
|
| 16 |
+from ctypes.wintypes import ( # type: ignore[attr-defined] |
|
| 17 |
+ BOOL, |
|
| 18 |
+ DWORD, |
|
| 19 |
+ HANDLE, |
|
| 20 |
+ LPCVOID, |
|
| 21 |
+ LPCWSTR, |
|
| 22 |
+ LPDWORD, |
|
| 23 |
+ LPVOID, |
|
| 24 |
+) |
|
| 25 |
+from typing import TYPE_CHECKING, cast |
|
| 14 | 26 |
|
| 15 | 27 |
if TYPE_CHECKING: |
| 16 | 28 |
from collections.abc import Callable |
| 29 |
+ from typing import ClassVar |
|
| 30 |
+ |
|
| 31 |
+ from typing_extensions import Any, Buffer, Self |
|
| 17 | 32 |
|
| 18 | 33 |
from derivepassphrase import _types |
| 19 | 34 |
|
| 35 |
+ kernel32: ctypes.WinDLL # type: ignore[name-defined] |
|
| 36 |
+ crypt32: ctypes.WinDLL # type: ignore[name-defined] |
|
| 37 |
+ |
|
| 38 |
+ CryptProtectMemory: Callable[[LPVOID, DWORD, DWORD], BOOL] |
|
| 39 |
+ CreateFile: Callable[ |
|
| 40 |
+ [ |
|
| 41 |
+ LPCWSTR, |
|
| 42 |
+ DWORD, |
|
| 43 |
+ DWORD, |
|
| 44 |
+ None, |
|
| 45 |
+ DWORD, |
|
| 46 |
+ DWORD, |
|
| 47 |
+ HANDLE | None, |
|
| 48 |
+ ], |
|
| 49 |
+ HANDLE, |
|
| 50 |
+ ] |
|
| 51 |
+ ReadFile: Callable[[HANDLE, LPCVOID, DWORD, LPDWORD, None], BOOL] |
|
| 52 |
+ WriteFile: Callable[[HANDLE, LPCVOID, DWORD, LPDWORD, None], BOOL] |
|
| 53 |
+ CloseHandle: Callable[[HANDLE], BOOL] |
|
| 54 |
+ |
|
| 20 | 55 |
__all__ = ("SocketProvider",)
|
| 21 | 56 |
|
| 22 | 57 |
|
| ... | ... |
@@ -32,6 +67,263 @@ class TheAnnoyingOsNamedPipesNotAvailableError(NotImplementedError): |
| 32 | 67 |
"""This Python installation does not support Windows named pipes.""" |
| 33 | 68 |
|
| 34 | 69 |
|
| 70 |
+GENERIC_READ = 0x80000000 |
|
| 71 |
+GENERIC_WRITE = 0x40000000 |
|
| 72 |
+ |
|
| 73 |
+FILE_SHARE_READ = 0x00000001 |
|
| 74 |
+FILE_SHARE_WRITE = 0x00000002 |
|
| 75 |
+ |
|
| 76 |
+OPEN_EXISTING = 0x3 |
|
| 77 |
+ |
|
| 78 |
+CRYPTPROTECTMEMORY_BLOCK_SIZE = 0x10 |
|
| 79 |
+CRYPTPROTECTMEMORY_CROSS_PROCESS = 0x1 |
|
| 80 |
+ |
|
| 81 |
+ |
|
| 82 |
+try: |
|
| 83 |
+ import ctypes |
|
| 84 |
+ import ctypes.wintypes |
|
| 85 |
+ |
|
| 86 |
+ try: |
|
| 87 |
+ crypt32 = ctypes.WinDLL("crypt32.dll") # type: ignore[attr-defined]
|
|
| 88 |
+ CryptProtectMemory = crypt32.CryptProtectMemory # type: ignore[attr-defined] |
|
| 89 |
+ except (AttributeError, FileNotFoundError): |
|
| 90 |
+ |
|
| 91 |
+ def CryptProtectMemory( # noqa: N802 |
|
| 92 |
+ pDataIn: LPVOID, # noqa: N803 |
|
| 93 |
+ cbDataIn: DWORD, # noqa: N803 |
|
| 94 |
+ dwFlags: DWORD, # noqa: N803 |
|
| 95 |
+ ) -> BOOL: |
|
| 96 |
+ raise NotImplementedError |
|
| 97 |
+ |
|
| 98 |
+ else: |
|
| 99 |
+ CryptProtectMemory.argtypes = [LPVOID, DWORD, DWORD] # type: ignore[attr-defined] |
|
| 100 |
+ CryptProtectMemory.restype = BOOL # type: ignore[attr-defined] |
|
| 101 |
+ |
|
| 102 |
+ def errcheck( |
|
| 103 |
+ result: HANDLE, |
|
| 104 |
+ func: Callable, |
|
| 105 |
+ arguments: Any, # noqa: ANN401 |
|
| 106 |
+ ) -> HANDLE: |
|
| 107 |
+ del func |
|
| 108 |
+ del arguments |
|
| 109 |
+ invalid_handle = ctypes.wintypes.HANDLE(0) |
|
| 110 |
+ if result == invalid_handle: |
|
| 111 |
+ raise ctypes.WinError() # type: ignore[attr-defined] |
|
| 112 |
+ return result |
|
| 113 |
+ |
|
| 114 |
+ try: |
|
| 115 |
+ kernel32 = ctypes.WinDLL("Kernel32.dll") # type: ignore[attr-defined]
|
|
| 116 |
+ CreateFile = kernel32.CreateFileW # type: ignore[attr-defined] |
|
| 117 |
+ ReadFile = kernel32.ReadFileW # type: ignore[attr-defined] |
|
| 118 |
+ WriteFile = kernel32.WriteFileW # type: ignore[attr-defined] |
|
| 119 |
+ CloseHandle = kernel32.CloseHandle # type: ignore[attr-defined] |
|
| 120 |
+ except (AttributeError, FileNotFoundError): |
|
| 121 |
+ |
|
| 122 |
+ def CreateFile( # noqa: N802, PLR0913, PLR0917 |
|
| 123 |
+ lpFilename: LPCWSTR, # noqa: N803 |
|
| 124 |
+ dwDesiredAccess: DWORD, # noqa: N803 |
|
| 125 |
+ dwShareMode: DWORD, # noqa: N803 |
|
| 126 |
+ lpSecurityAttributes: None, # noqa: N803 |
|
| 127 |
+ dwCreationDisposition: DWORD, # noqa: N803 |
|
| 128 |
+ dwFlagsAndAttributes: DWORD, # noqa: N803 |
|
| 129 |
+ hTemplateFile: HANDLE | None, # noqa: N803 |
|
| 130 |
+ ) -> HANDLE: |
|
| 131 |
+ del ( |
|
| 132 |
+ lpFilename, |
|
| 133 |
+ dwDesiredAccess, |
|
| 134 |
+ dwShareMode, |
|
| 135 |
+ lpSecurityAttributes, |
|
| 136 |
+ dwCreationDisposition, |
|
| 137 |
+ dwFlagsAndAttributes, |
|
| 138 |
+ hTemplateFile, |
|
| 139 |
+ ) |
|
| 140 |
+ msg = "This Python version does not support Windows named pipes." |
|
| 141 |
+ raise TheAnnoyingOsNamedPipesNotAvailableError(msg) |
|
| 142 |
+ |
|
| 143 |
+ def ReadFile( # noqa: N802 |
|
| 144 |
+ hFile: HANDLE, # noqa: N803 |
|
| 145 |
+ lpBuffer: LPVOID, # noqa: N803 |
|
| 146 |
+ nNumberOfBytesToRead: DWORD, # noqa: N803 |
|
| 147 |
+ lpNumberOfBytesRead: LPDWORD, # noqa: N803 |
|
| 148 |
+ lpOverlapped: None, # noqa: N803 |
|
| 149 |
+ ) -> BOOL: |
|
| 150 |
+ del ( |
|
| 151 |
+ hFile, |
|
| 152 |
+ lpBuffer, |
|
| 153 |
+ nNumberOfBytesToRead, |
|
| 154 |
+ lpNumberOfBytesRead, |
|
| 155 |
+ lpOverlapped, |
|
| 156 |
+ ) |
|
| 157 |
+ raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP)) |
|
| 158 |
+ |
|
| 159 |
+ def WriteFile( # noqa: N802 |
|
| 160 |
+ hFile: HANDLE, # noqa: N803 |
|
| 161 |
+ lpBuffer: LPVOID, # noqa: N803 |
|
| 162 |
+ nNumberOfBytesToWrite: DWORD, # noqa: N803 |
|
| 163 |
+ lpNumberOfBytesWritten: LPDWORD, # noqa: N803 |
|
| 164 |
+ lpOverlapped: None, # noqa: N803 |
|
| 165 |
+ ) -> BOOL: |
|
| 166 |
+ del ( |
|
| 167 |
+ hFile, |
|
| 168 |
+ lpBuffer, |
|
| 169 |
+ nNumberOfBytesToWrite, |
|
| 170 |
+ lpNumberOfBytesWritten, |
|
| 171 |
+ lpOverlapped, |
|
| 172 |
+ ) |
|
| 173 |
+ raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP)) |
|
| 174 |
+ |
|
| 175 |
+ else: |
|
| 176 |
+ CreateFile.argtypes = [ # type: ignore[attr-defined] |
|
| 177 |
+ LPCWSTR, |
|
| 178 |
+ DWORD, |
|
| 179 |
+ DWORD, |
|
| 180 |
+ None, |
|
| 181 |
+ DWORD, |
|
| 182 |
+ DWORD, |
|
| 183 |
+ HANDLE | None, |
|
| 184 |
+ ] |
|
| 185 |
+ CreateFile.restype = HANDLE # type: ignore[attr-defined] |
|
| 186 |
+ CreateFile.errcheck = errcheck # type: ignore[attr-defined] |
|
| 187 |
+ ReadFile.argtypes = [HANDLE, LPVOID, DWORD, LPDWORD, None] # type: ignore[attr-defined] |
|
| 188 |
+ WriteFile.argtypes = [HANDLE, LPVOID, DWORD, LPDWORD, None] # type: ignore[attr-defined] |
|
| 189 |
+ CloseHandle.argtypes = [HANDLE] # type: ignore[attr-defined] |
|
| 190 |
+ ReadFile.argtypes = [BOOL] # type: ignore[attr-defined] |
|
| 191 |
+ WriteFile.argtypes = [BOOL] # type: ignore[attr-defined] |
|
| 192 |
+ CloseHandle.argtypes = [BOOL] # type: ignore[attr-defined] |
|
| 193 |
+ |
|
| 194 |
+except ImportError: |
|
| 195 |
+ pass |
|
| 196 |
+ |
|
| 197 |
+ |
|
| 198 |
+class TheAnnoyingOsNamedPipeHandle: |
|
| 199 |
+ """A named pipe handle on The Annoying OS (Microsoft Windows). |
|
| 200 |
+ |
|
| 201 |
+ This handle implements the [`SSHAgentSocket`][_types.SSHAgentSocket] |
|
| 202 |
+ interface. It is only constructable if the Python installation can |
|
| 203 |
+ successfully call into The Annoying OS `kernel32.dll` library via |
|
| 204 |
+ [`ctypes`][]. |
|
| 205 |
+ |
|
| 206 |
+ """ |
|
| 207 |
+ |
|
| 208 |
+ def __init__(self, name: str) -> None: |
|
| 209 |
+ self.handle: HANDLE | None = CreateFile( |
|
| 210 |
+ ctypes.c_wchar_p(name), |
|
| 211 |
+ ctypes.c_ulong(GENERIC_READ | GENERIC_WRITE), |
|
| 212 |
+ ctypes.c_ulong(FILE_SHARE_READ | FILE_SHARE_WRITE), |
|
| 213 |
+ None, |
|
| 214 |
+ ctypes.c_ulong(OPEN_EXISTING), |
|
| 215 |
+ ctypes.c_ulong(0), |
|
| 216 |
+ None, |
|
| 217 |
+ ) |
|
| 218 |
+ |
|
| 219 |
+ def __enter__(self) -> Self: |
|
| 220 |
+ return self |
|
| 221 |
+ |
|
| 222 |
+ def __exit__(self, *args: object) -> bool | None: |
|
| 223 |
+ try: |
|
| 224 |
+ if self.handle is not None: |
|
| 225 |
+ return CloseHandle(self.handle).value != 0 |
|
| 226 |
+ return False |
|
| 227 |
+ finally: |
|
| 228 |
+ self.handle = None |
|
| 229 |
+ |
|
| 230 |
+ def recv(self, data: int, flags: int = 0, /) -> bytes: |
|
| 231 |
+ if self.handle is None: |
|
| 232 |
+ raise OSError(errno.EBADF, os.strerror(errno.EBADF)) |
|
| 233 |
+ del flags |
|
| 234 |
+ result = bytearray() |
|
| 235 |
+ read_count = DWORD(0) |
|
| 236 |
+ buffer = (ctypes.c_char * 65536)() |
|
| 237 |
+ while data > 0: |
|
| 238 |
+ block_size = min(max(0, data), 65536) |
|
| 239 |
+ success = ReadFile( |
|
| 240 |
+ self.handle, |
|
| 241 |
+ ctypes.cast(ctypes.byref(buffer), ctypes.c_void_p), |
|
| 242 |
+ DWORD(block_size), |
|
| 243 |
+ ctypes.cast(ctypes.byref(read_count), LPDWORD), |
|
| 244 |
+ None, |
|
| 245 |
+ ) |
|
| 246 |
+ if not success or read_count.value == 0: |
|
| 247 |
+ raise ctypes.WinError() # type: ignore[attr-defined] |
|
| 248 |
+ result.extend(buffer.raw[:block_size]) |
|
| 249 |
+ data -= read_count.value |
|
| 250 |
+ read_count.value = 0 |
|
| 251 |
+ return bytes(result) |
|
| 252 |
+ |
|
| 253 |
+ def sendall(self, data: Buffer, flags: int = 0, /) -> None: |
|
| 254 |
+ if self.handle is None: |
|
| 255 |
+ raise OSError(errno.EBADF, os.strerror(errno.EBADF)) |
|
| 256 |
+ del flags |
|
| 257 |
+ data = bytes(data) |
|
| 258 |
+ databuf = (ctypes.c_char * len(data))(data) |
|
| 259 |
+ write_count = DWORD(0) |
|
| 260 |
+ WriteFile( |
|
| 261 |
+ self.handle, |
|
| 262 |
+ ctypes.cast(ctypes.byref(databuf), ctypes.c_void_p), |
|
| 263 |
+ DWORD(len(data)), |
|
| 264 |
+ ctypes.cast(ctypes.byref(write_count), LPDWORD), |
|
| 265 |
+ None, |
|
| 266 |
+ ) |
|
| 267 |
+ |
|
| 268 |
+ @classmethod |
|
| 269 |
+ def for_pageant(cls) -> Self: |
|
| 270 |
+ """Construct a named pipe for use with Pageant. |
|
| 271 |
+ |
|
| 272 |
+ Returns: |
|
| 273 |
+ A new named pipe handle, using Pageant's pipe name. |
|
| 274 |
+ |
|
| 275 |
+ """ |
|
| 276 |
+ return cls(cls.pageant_named_pipe_name()) |
|
| 277 |
+ |
|
| 278 |
+ @staticmethod |
|
| 279 |
+ def pageant_named_pipe_name( |
|
| 280 |
+ *, |
|
| 281 |
+ require_cryptprotectmemory: bool = False, |
|
| 282 |
+ ) -> str: |
|
| 283 |
+ """Return The Annoying OS named pipe name that Pageant would use. |
|
| 284 |
+ |
|
| 285 |
+ Args: |
|
| 286 |
+ require_cryptprotectmemory: |
|
| 287 |
+ Pageant normally attempts to use the |
|
| 288 |
+ `CryptProtectMemory` system function from the |
|
| 289 |
+ `crypt32.dll` library on The Annoying OS, ignoring any |
|
| 290 |
+ errors. This in turn influences the resulting named |
|
| 291 |
+ pipe's filename. |
|
| 292 |
+ |
|
| 293 |
+ If true, and if we fail to call `CryptProtectMemory`, we |
|
| 294 |
+ abort; otherwise we ignore any errors from calling or |
|
| 295 |
+ failing to call `CryptProtectMemory`. |
|
| 296 |
+ |
|
| 297 |
+ Raises: |
|
| 298 |
+ NotImplementedError: |
|
| 299 |
+ This Python version cannot call `CryptProtectMemory` due |
|
| 300 |
+ to lack of system support. |
|
| 301 |
+ |
|
| 302 |
+ """ |
|
| 303 |
+ realname = "Pageant" |
|
| 304 |
+ c_realname = ctypes.create_string_buffer(realname.encode("UTF-8"))
|
|
| 305 |
+ num_blocks = ( |
|
| 306 |
+ len(c_realname) + CRYPTPROTECTMEMORY_BLOCK_SIZE - 1 |
|
| 307 |
+ ) // CRYPTPROTECTMEMORY_BLOCK_SIZE |
|
| 308 |
+ assert num_blocks == 1 |
|
| 309 |
+ buf_decl = ctypes.c_char * (CRYPTPROTECTMEMORY_BLOCK_SIZE * num_blocks) |
|
| 310 |
+ buf = buf_decl(c_realname.raw) |
|
| 311 |
+ try: |
|
| 312 |
+ CryptProtectMemory( |
|
| 313 |
+ ctypes.cast(ctypes.byref(buf), ctypes.c_void_p), |
|
| 314 |
+ DWORD(len(buf.raw)), |
|
| 315 |
+ DWORD(CRYPTPROTECTMEMORY_CROSS_PROCESS), |
|
| 316 |
+ ) |
|
| 317 |
+ except NotImplementedError: |
|
| 318 |
+ if require_cryptprotectmemory: |
|
| 319 |
+ raise |
|
| 320 |
+ buf_as_ssh_string = bytearray() |
|
| 321 |
+ buf_as_ssh_string.extend(int.to_bytes(len(buf.raw), 4, "big")) |
|
| 322 |
+ buf_as_ssh_string.extend(buf.raw) |
|
| 323 |
+ digest = hashlib.sha256(buf_as_ssh_string).hexdigest() |
|
| 324 |
+ return f"//.pipe/pageant.{os.getlogin()}.{digest}"
|
|
| 325 |
+ |
|
| 326 |
+ |
|
| 35 | 327 |
class SocketProvider: |
| 36 | 328 |
"""Static functionality for providing sockets.""" |
| 37 | 329 |
|
| ... | ... |
@@ -98,15 +390,16 @@ class SocketProvider: |
| 98 | 390 |
[windows-ssh-agent-support]: https://the13thletter.info/derivepassphrase/0.x/wishlist/windows-ssh-agent-support/ |
| 99 | 391 |
|
| 100 | 392 |
""" |
| 393 |
+ try: # pragma: no cover [external] |
|
| 394 |
+ import ctypes # noqa: PLC0415 |
|
| 395 |
+ except ImportError as exc: # pragma: no cover [external] |
|
| 396 |
+ msg = "This Python version does not support Windows named pipes." |
|
| 397 |
+ raise TheAnnoyingOsNamedPipesNotAvailableError(msg) from exc |
|
| 101 | 398 |
if not hasattr(ctypes, "WinDLL"): |
| 102 | 399 |
msg = "This Python version does not support Windows named pipes." |
| 103 | 400 |
raise TheAnnoyingOsNamedPipesNotAvailableError(msg) |
| 104 | 401 |
else: # noqa: RET506 # pragma: unless the-annoying-os no cover |
| 105 |
- msg = ( |
|
| 106 |
- "Communicating with Pageant or OpenSSH on Windows " |
|
| 107 |
- "is not implemented yet." |
|
| 108 |
- ) |
|
| 109 |
- raise NotImplementedError(msg) |
|
| 402 |
+ return TheAnnoyingOsNamedPipeHandle.for_pageant() |
|
| 110 | 403 |
|
| 111 | 404 |
registry: ClassVar[ |
| 112 | 405 |
dict[str, _types.SSHAgentSocketProvider | str | None] |
| 113 | 406 |