Marco Ricci commited on 2025-12-14 17:21:00
Zeige 2 geänderte Dateien mit 509 Einfügungen und 43 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 Windows named pipes. We also bind the functions necessary to compute the pipe name for PuTTY/Pageant, and provide convenience functions to connect to PuTTY/Pageant and to OpenSSH, the de facto main SSH implementations on The Annoying OS. One major design question remains: how to discover the (named pipe address for) the SSH agent to use? Tempting options are using `SSH_AUTH_SOCK` again (whether literally or with special notation), and listing the preferred agent addresses in the user configuration file. For certain "well-known" addresses such as PuTTY/Pageant or OpenSSH, we provide specific SSH agent socket provider registry entries that attempt to connect to the respective agent. Other applications could easily register a custom provider that respects their configuration if the final socket (address) depends on external configuration. It is thus not clear to me if the system needs to be more flexible than it currently is, e.g., if the SSH agent socket provider needs to accept arguments that further configure the address or the connection options to the socket or named pipe. The formal name for this facility is "Windows named pipe", and this is the name other developers will expect. Thus, any mention of The Annoying OS in the context of Windows named pipes will strictly be called "Windows named pipe", not "The Annoying OS named pipe" or similar. (Some pre-existing names were adapted accordingly.) Similarly, the other facility is called "UNIX domain socket", not "AF_UNIX" or "AfUnix" or somesuch. This commit contains no specific test code for this functionality; we leave this to follow-up commits. For ease of integration with the existing test suite, *in this commit* we attempt to use `SSH_AUTH_SOCK` directly as the named pipe's name (which will fail unless specifically prepared). 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.
| ... | ... |
@@ -741,12 +741,14 @@ def handle_notimplementederror( |
| 741 | 741 |
""" # noqa: DOC201 |
| 742 | 742 |
|
| 743 | 743 |
def handle_notimplementederror(excgroup: BaseExceptionGroup) -> NoReturn: |
| 744 |
- if excgroup.subgroup(socketprovider.AfUnixNotAvailableError): |
|
| 744 |
+ if excgroup.subgroup( |
|
| 745 |
+ socketprovider.UnixDomainSocketsNotAvailableError |
|
| 746 |
+ ): |
|
| 745 | 747 |
warning_callback( |
| 746 | 748 |
_msg.TranslatedString(_msg.WarnMsgTemplate.NO_AF_UNIX) |
| 747 | 749 |
) |
| 748 | 750 |
if excgroup.subgroup( |
| 749 |
- socketprovider.TheAnnoyingOsNamedPipesNotAvailableError |
|
| 751 |
+ socketprovider.WindowsNamedPipesNotAvailableError |
|
| 750 | 752 |
): |
| 751 | 753 |
warning_callback( |
| 752 | 754 |
_msg.TranslatedString( |
| ... | ... |
@@ -8,15 +8,50 @@ from __future__ import annotations |
| 8 | 8 |
|
| 9 | 9 |
import collections |
| 10 | 10 |
import ctypes |
| 11 |
+import enum |
|
| 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, Literal, 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 |
|
| ... | ... |
@@ -24,12 +59,384 @@ class NoSuchProviderError(KeyError): |
| 24 | 59 |
"""No such SSH agent socket provider is known.""" |
| 25 | 60 |
|
| 26 | 61 |
|
| 27 |
-class AfUnixNotAvailableError(NotImplementedError): |
|
| 28 |
- """This Python installation does not support socket.AF_UNIX.""" |
|
| 62 |
+class UnixDomainSocketsNotAvailableError(NotImplementedError): |
|
| 63 |
+ """This Python installation does not support UNIX domain sockets. |
|
| 64 |
+ |
|
| 65 |
+ On this installation, the standard library symbol |
|
| 66 |
+ [`socket.AF_UNIX`][] is not available, necessary to create UNIX |
|
| 67 |
+ domain sockets. |
|
| 68 |
+ |
|
| 69 |
+ """ |
|
| 70 |
+ |
|
| 71 |
+ |
|
| 72 |
+class WindowsNamedPipesNotAvailableError(NotImplementedError): |
|
| 73 |
+ """This Python installation does not support Windows named pipes. |
|
| 74 |
+ |
|
| 75 |
+ On this installation, the standard library symbol |
|
| 76 |
+ [`ctypes.WinDLL`][] is not available, necessary to interface with |
|
| 77 |
+ the Annoying Operating System's standard library to create Windows |
|
| 78 |
+ named pipes. |
|
| 79 |
+ |
|
| 80 |
+ """ |
|
| 81 |
+ |
|
| 82 |
+ |
|
| 83 |
+GENERIC_READ = 0x80000000 |
|
| 84 |
+GENERIC_WRITE = 0x40000000 |
|
| 85 |
+ |
|
| 86 |
+FILE_SHARE_READ = 0x00000001 |
|
| 87 |
+FILE_SHARE_WRITE = 0x00000002 |
|
| 88 |
+ |
|
| 89 |
+OPEN_EXISTING = 0x3 |
|
| 90 |
+ |
|
| 91 |
+INVALID_HANDLE_VALUE = -1 |
|
| 92 |
+ |
|
| 93 |
+CRYPTPROTECTMEMORY_BLOCK_SIZE = 0x10 |
|
| 94 |
+CRYPTPROTECTMEMORY_CROSS_PROCESS = 0x1 |
|
| 95 |
+ |
|
| 96 |
+PIPE_PREFIX = "//./pipe/".replace("/", "\\")
|
|
| 97 |
+ |
|
| 98 |
+ |
|
| 99 |
+# The type checker and the standard library use different |
|
| 100 |
+# (finer-grained?) types and type definitions on The Annoying Operating |
|
| 101 |
+# System to annotate things derived from ctypes.WinDLL. Furthermore, on |
|
| 102 |
+# other OSes, ctypes.WinDLL is missing entirely. So the type checker |
|
| 103 |
+# attempts to validate our code against *very* different type |
|
| 104 |
+# hierarchies, depending on what OS it is running on, and so we require |
|
| 105 |
+# a lot of type checking exclusions to keep the types reasonably narrow. |
|
| 106 |
+# |
|
| 107 |
+# Additionally, the official documentation for kernel32.dll and |
|
| 108 |
+# crypt32.dll (and The Annoying OS in general) uses CamelCase for both |
|
| 109 |
+# function names and function parameters. This clashes with the |
|
| 110 |
+# standard Python naming conventions, so we need linting exclusions for |
|
| 111 |
+# each such name. |
|
| 112 |
+# |
|
| 113 |
+# Finally, since this code is mostly platform-specific, we use coverage |
|
| 114 |
+# pragmas for the respective branches to avoid surprises about missing |
|
| 115 |
+# coverage. |
|
| 116 |
+# |
|
| 117 |
+# All these factors necessitate *a lot* of "type: ignore", "noqa:" and |
|
| 118 |
+# "pragma: ... no cover" comments below. |
|
| 119 |
+ |
|
| 120 |
+try: # pragma: unless the-annoying-os no cover |
|
| 121 |
+ import ctypes |
|
| 122 |
+ |
|
| 123 |
+ crypt32 = ctypes.WinDLL("crypt32.dll") # type: ignore[attr-defined]
|
|
| 124 |
+ CryptProtectMemory = crypt32.CryptProtectMemory # type: ignore[attr-defined] |
|
| 125 |
+except ( |
|
| 126 |
+ AttributeError, |
|
| 127 |
+ FileNotFoundError, |
|
| 128 |
+ ImportError, |
|
| 129 |
+): # pragma unless posix no cover |
|
| 130 |
+ |
|
| 131 |
+ def CryptProtectMemory( # noqa: N802 |
|
| 132 |
+ pDataIn: LPVOID, # noqa: N803 |
|
| 133 |
+ cbDataIn: DWORD, # noqa: N803 |
|
| 134 |
+ dwFlags: DWORD, # noqa: N803 |
|
| 135 |
+ ) -> BOOL: # pragma: no cover [external] |
|
| 136 |
+ raise NotImplementedError |
|
| 137 |
+ |
|
| 138 |
+else: # pragma: unless the-annoying-os no cover |
|
| 139 |
+ CryptProtectMemory.argtypes = [LPVOID, DWORD, DWORD] # type: ignore[attr-defined] |
|
| 140 |
+ CryptProtectMemory.restype = BOOL # type: ignore[attr-defined] |
|
| 141 |
+ |
|
| 142 |
+ |
|
| 143 |
+def _errcheck( |
|
| 144 |
+ result: int, |
|
| 145 |
+ func: Callable, |
|
| 146 |
+ arguments: Any, # noqa: ANN401 |
|
| 147 |
+) -> int: # pragma: no cover [external] |
|
| 148 |
+ del func |
|
| 149 |
+ del arguments |
|
| 150 |
+ # Even if they have the same value, ctypes pointers don't |
|
| 151 |
+ # compare equal to each other. We need to compare the values |
|
| 152 |
+ # explicitly, keeping the ranges in mind as well. |
|
| 153 |
+ result_value = HANDLE(result).value |
|
| 154 |
+ invalid_value = HANDLE(INVALID_HANDLE_VALUE).value |
|
| 155 |
+ if result_value == invalid_value: |
|
| 156 |
+ raise ctypes.WinError() # type: ignore[attr-defined] |
|
| 157 |
+ assert result_value is not None # for the type checker |
|
| 158 |
+ return result_value |
|
| 159 |
+ |
|
| 160 |
+ |
|
| 161 |
+try: # pragma: unless the-annoying-os no cover |
|
| 162 |
+ kernel32 = ctypes.WinDLL("Kernel32.dll") # type: ignore[attr-defined]
|
|
| 163 |
+ CreateFile = kernel32.CreateFileW # type: ignore[attr-defined] |
|
| 164 |
+ ReadFile = kernel32.ReadFile # type: ignore[attr-defined] |
|
| 165 |
+ WriteFile = kernel32.WriteFile # type: ignore[attr-defined] |
|
| 166 |
+ CloseHandle = kernel32.CloseHandle # type: ignore[attr-defined] |
|
| 167 |
+except ( |
|
| 168 |
+ AttributeError, |
|
| 169 |
+ FileNotFoundError, |
|
| 170 |
+): # pragma: unless posix no cover |
|
| 171 |
+ |
|
| 172 |
+ def CreateFile( # noqa: N802, PLR0913, PLR0917 |
|
| 173 |
+ lpFilename: LPCWSTR, # noqa: N803 |
|
| 174 |
+ dwDesiredAccess: DWORD, # noqa: N803 |
|
| 175 |
+ dwShareMode: DWORD, # noqa: N803 |
|
| 176 |
+ lpSecurityAttributes: None, # noqa: N803 |
|
| 177 |
+ dwCreationDisposition: DWORD, # noqa: N803 |
|
| 178 |
+ dwFlagsAndAttributes: DWORD, # noqa: N803 |
|
| 179 |
+ hTemplateFile: HANDLE | None, # noqa: N803 |
|
| 180 |
+ ) -> HANDLE: # pragma: no cover [external] |
|
| 181 |
+ del ( |
|
| 182 |
+ lpFilename, |
|
| 183 |
+ dwDesiredAccess, |
|
| 184 |
+ dwShareMode, |
|
| 185 |
+ lpSecurityAttributes, |
|
| 186 |
+ dwCreationDisposition, |
|
| 187 |
+ dwFlagsAndAttributes, |
|
| 188 |
+ hTemplateFile, |
|
| 189 |
+ ) |
|
| 190 |
+ msg = "This Python version does not support Windows named pipes." |
|
| 191 |
+ raise WindowsNamedPipesNotAvailableError(msg) |
|
| 192 |
+ |
|
| 193 |
+ def ReadFile( # noqa: N802 |
|
| 194 |
+ hFile: HANDLE, # noqa: N803 |
|
| 195 |
+ lpBuffer: LPVOID, # noqa: N803 |
|
| 196 |
+ nNumberOfBytesToRead: DWORD, # noqa: N803 |
|
| 197 |
+ lpNumberOfBytesRead: LPDWORD, # noqa: N803 |
|
| 198 |
+ lpOverlapped: None, # noqa: N803 |
|
| 199 |
+ ) -> BOOL: # pragma: no cover [external] |
|
| 200 |
+ del ( |
|
| 201 |
+ hFile, |
|
| 202 |
+ lpBuffer, |
|
| 203 |
+ nNumberOfBytesToRead, |
|
| 204 |
+ lpNumberOfBytesRead, |
|
| 205 |
+ lpOverlapped, |
|
| 206 |
+ ) |
|
| 207 |
+ raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP)) |
|
| 208 |
+ |
|
| 209 |
+ def WriteFile( # noqa: N802 |
|
| 210 |
+ hFile: HANDLE, # noqa: N803 |
|
| 211 |
+ lpBuffer: LPVOID, # noqa: N803 |
|
| 212 |
+ nNumberOfBytesToWrite: DWORD, # noqa: N803 |
|
| 213 |
+ lpNumberOfBytesWritten: LPDWORD, # noqa: N803 |
|
| 214 |
+ lpOverlapped: None, # noqa: N803 |
|
| 215 |
+ ) -> BOOL: # pragma: no cover [external] |
|
| 216 |
+ del ( |
|
| 217 |
+ hFile, |
|
| 218 |
+ lpBuffer, |
|
| 219 |
+ nNumberOfBytesToWrite, |
|
| 220 |
+ lpNumberOfBytesWritten, |
|
| 221 |
+ lpOverlapped, |
|
| 222 |
+ ) |
|
| 223 |
+ raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP)) |
|
| 224 |
+ |
|
| 225 |
+else: # pragma: unless the-annoying-os no cover |
|
| 226 |
+ CreateFile.argtypes = [ # type: ignore[attr-defined] |
|
| 227 |
+ LPCWSTR, |
|
| 228 |
+ DWORD, |
|
| 229 |
+ DWORD, |
|
| 230 |
+ # Actually, LPSECURITY_ATTRIBUTES, but we always pass |
|
| 231 |
+ # None/NULL. |
|
| 232 |
+ ctypes.c_void_p, |
|
| 233 |
+ DWORD, |
|
| 234 |
+ DWORD, |
|
| 235 |
+ # We always pass None/NULL. |
|
| 236 |
+ HANDLE, |
|
| 237 |
+ ] |
|
| 238 |
+ CreateFile.restype = HANDLE # type: ignore[attr-defined] |
|
| 239 |
+ CreateFile.errcheck = _errcheck # type: ignore[assignment, attr-defined] |
|
| 240 |
+ ReadFile.argtypes = [ # type: ignore[attr-defined] |
|
| 241 |
+ HANDLE, |
|
| 242 |
+ LPVOID, |
|
| 243 |
+ DWORD, |
|
| 244 |
+ LPDWORD, |
|
| 245 |
+ # Actually, LPOVERLAPPED, but we always pass None/NULL. |
|
| 246 |
+ ctypes.c_void_p, |
|
| 247 |
+ ] |
|
| 248 |
+ WriteFile.argtypes = [ # type: ignore[attr-defined] |
|
| 249 |
+ HANDLE, |
|
| 250 |
+ LPVOID, |
|
| 251 |
+ DWORD, |
|
| 252 |
+ LPDWORD, |
|
| 253 |
+ # Actually, LPOVERLAPPED, but we always pass None/NULL. |
|
| 254 |
+ ctypes.c_void_p, |
|
| 255 |
+ ] |
|
| 256 |
+ CloseHandle.argtypes = [HANDLE] # type: ignore[attr-defined] |
|
| 257 |
+ ReadFile.restype = BOOL # type: ignore[attr-defined] |
|
| 258 |
+ WriteFile.restype = BOOL # type: ignore[attr-defined] |
|
| 259 |
+ CloseHandle.restype = BOOL # type: ignore[attr-defined] |
|
| 260 |
+ |
|
| 261 |
+ |
|
| 262 |
+class WindowsNamedPipeHandle: |
|
| 263 |
+ """A Windows named pipe handle. |
|
| 264 |
+ |
|
| 265 |
+ This handle implements the [`SSHAgentSocket`][_types.SSHAgentSocket] |
|
| 266 |
+ interface. It is only constructable if the Python installation can |
|
| 267 |
+ successfully call into the Annoying OS `kernel32.dll` library via |
|
| 268 |
+ [`ctypes`][]. |
|
| 269 |
+ |
|
| 270 |
+ """ |
|
| 271 |
+ |
|
| 272 |
+ _IO_ON_CLOSED_FILE = "I/O operation on closed file." |
|
| 273 |
+ |
|
| 274 |
+ def __init__(self, name: str) -> None: |
|
| 275 |
+ """Create a named pipe handle. |
|
| 276 |
+ |
|
| 277 |
+ Args: |
|
| 278 |
+ name: |
|
| 279 |
+ The named pipe's name. |
|
| 280 |
+ |
|
| 281 |
+ Raises: |
|
| 282 |
+ OSError: |
|
| 283 |
+ The system failed to open the named pipe. |
|
| 284 |
+ ValueError: |
|
| 285 |
+ The named pipe's name does not designate a named pipe. |
|
| 286 |
+ WindowsNamedPipesNotAvailableError: |
|
| 287 |
+ This Python version does not support Windows named |
|
| 288 |
+ pipes. |
|
| 289 |
+ |
|
| 290 |
+ """ |
|
| 291 |
+ if not name.replace("/", "\\").startswith(PIPE_PREFIX):
|
|
| 292 |
+ raise ValueError(errno.EINVAL, os.strerror(errno.EINVAL)) |
|
| 293 |
+ self.handle: HANDLE | None = CreateFile( |
|
| 294 |
+ ctypes.c_wchar_p(name), |
|
| 295 |
+ ctypes.c_ulong(GENERIC_READ | GENERIC_WRITE), |
|
| 296 |
+ ctypes.c_ulong(FILE_SHARE_READ | FILE_SHARE_WRITE), |
|
| 297 |
+ None, |
|
| 298 |
+ ctypes.c_ulong(OPEN_EXISTING), |
|
| 299 |
+ ctypes.c_ulong(0), |
|
| 300 |
+ None, |
|
| 301 |
+ ) |
|
| 302 |
+ |
|
| 303 |
+ def __enter__(self) -> Self: |
|
| 304 |
+ return self |
|
| 305 |
+ |
|
| 306 |
+ def __exit__(self, *args: object) -> Literal[False]: |
|
| 307 |
+ try: |
|
| 308 |
+ if self.handle is not None: |
|
| 309 |
+ CloseHandle(self.handle) |
|
| 310 |
+ return False |
|
| 311 |
+ finally: |
|
| 312 |
+ self.handle = None |
|
| 313 |
+ |
|
| 314 |
+ def recv(self, data: int, flags: int = 0, /) -> bytes: |
|
| 315 |
+ if self.handle is None: |
|
| 316 |
+ raise ValueError(self._IO_ON_CLOSED_FILE) |
|
| 317 |
+ del flags |
|
| 318 |
+ result = bytearray() |
|
| 319 |
+ read_count = DWORD(0) |
|
| 320 |
+ buffer = (ctypes.c_char * 65536)() |
|
| 321 |
+ while data > 0: |
|
| 322 |
+ block_size = min(max(0, data), 65536) |
|
| 323 |
+ success = ReadFile( |
|
| 324 |
+ self.handle, |
|
| 325 |
+ ctypes.cast(ctypes.byref(buffer), ctypes.c_void_p), |
|
| 326 |
+ DWORD(block_size), |
|
| 327 |
+ ctypes.cast(ctypes.byref(read_count), LPDWORD), |
|
| 328 |
+ None, |
|
| 329 |
+ ) |
|
| 330 |
+ if ( |
|
| 331 |
+ not success or read_count.value == 0 |
|
| 332 |
+ ): # pragma: no cover [external] |
|
| 333 |
+ raise ctypes.WinError() # type: ignore[attr-defined] |
|
| 334 |
+ result.extend(buffer.raw[:block_size]) |
|
| 335 |
+ data -= read_count.value |
|
| 336 |
+ read_count.value = 0 |
|
| 337 |
+ return bytes(result) |
|
| 338 |
+ |
|
| 339 |
+ def sendall(self, data: Buffer, flags: int = 0, /) -> None: |
|
| 340 |
+ if self.handle is None: |
|
| 341 |
+ raise ValueError(self._IO_ON_CLOSED_FILE) |
|
| 342 |
+ del flags |
|
| 343 |
+ data = bytes(data) |
|
| 344 |
+ databuf = (ctypes.c_char * len(data))() |
|
| 345 |
+ for i, x in enumerate(data): |
|
| 346 |
+ databuf[i] = ctypes.c_char(x) |
|
| 347 |
+ write_count = DWORD(0) |
|
| 348 |
+ WriteFile( |
|
| 349 |
+ self.handle, |
|
| 350 |
+ ctypes.cast(ctypes.byref(databuf), ctypes.c_void_p), |
|
| 351 |
+ DWORD(len(data)), |
|
| 352 |
+ ctypes.cast(ctypes.byref(write_count), LPDWORD), |
|
| 353 |
+ None, |
|
| 354 |
+ ) |
|
| 355 |
+ |
|
| 356 |
+ @classmethod |
|
| 357 |
+ def for_openssh(cls) -> Self: |
|
| 358 |
+ """Construct a named pipe for use with OpenSSH on The Annoying OS. |
|
| 359 |
+ |
|
| 360 |
+ Returns: |
|
| 361 |
+ A new named pipe handle, using OpenSSH's pipe name. |
|
| 362 |
+ |
|
| 363 |
+ """ |
|
| 364 |
+ return cls(f"{PIPE_PREFIX}openssh-ssh-agent")
|
|
| 365 |
+ |
|
| 366 |
+ @classmethod |
|
| 367 |
+ def for_pageant(cls) -> Self: |
|
| 368 |
+ """Construct a named pipe for use with Pageant. |
|
| 369 |
+ |
|
| 370 |
+ Returns: |
|
| 371 |
+ A new named pipe handle, using Pageant's pipe name. |
|
| 372 |
+ |
|
| 373 |
+ """ |
|
| 374 |
+ return cls(cls.pageant_named_pipe_name()) |
|
| 375 |
+ |
|
| 376 |
+ @staticmethod |
|
| 377 |
+ def pageant_named_pipe_name( |
|
| 378 |
+ *, |
|
| 379 |
+ require_cryptprotectmemory: bool = False, |
|
| 380 |
+ ) -> str: |
|
| 381 |
+ """Return the pipe name that Pageant on The Annoying OS would use. |
|
| 382 |
+ |
|
| 383 |
+ Args: |
|
| 384 |
+ require_cryptprotectmemory: |
|
| 385 |
+ Pageant normally attempts to use the |
|
| 386 |
+ `CryptProtectMemory` system function from the |
|
| 387 |
+ `crypt32.dll` library on The Annoying OS, ignoring any |
|
| 388 |
+ errors. This in turn influences the resulting named |
|
| 389 |
+ pipe's filename. |
|
| 29 | 390 |
|
| 391 |
+ If true, and if we fail to call `CryptProtectMemory`, we |
|
| 392 |
+ abort; otherwise we ignore any errors from calling or |
|
| 393 |
+ failing to call `CryptProtectMemory`. |
|
| 394 |
+ |
|
| 395 |
+ Raises: |
|
| 396 |
+ NotImplementedError: |
|
| 397 |
+ `require_cryptprotectmemory` was `True`, but this Python |
|
| 398 |
+ version cannot call `CryptProtectMemory` due to lack of |
|
| 399 |
+ system support. |
|
| 400 |
+ |
|
| 401 |
+ """ |
|
| 402 |
+ realname = b"Pageant\x00" |
|
| 403 |
+ num_blocks = ( |
|
| 404 |
+ len(realname) + CRYPTPROTECTMEMORY_BLOCK_SIZE - 1 |
|
| 405 |
+ ) // CRYPTPROTECTMEMORY_BLOCK_SIZE |
|
| 406 |
+ assert num_blocks == 1 |
|
| 407 |
+ buf_decl = ctypes.c_char * (CRYPTPROTECTMEMORY_BLOCK_SIZE * num_blocks) |
|
| 408 |
+ buf = buf_decl() |
|
| 409 |
+ for i, x in enumerate(realname): |
|
| 410 |
+ buf[i] = ctypes.c_char(x) |
|
| 411 |
+ try: |
|
| 412 |
+ CryptProtectMemory( |
|
| 413 |
+ ctypes.cast(ctypes.byref(buf), ctypes.c_void_p), |
|
| 414 |
+ DWORD(len(buf.raw)), |
|
| 415 |
+ DWORD(CRYPTPROTECTMEMORY_CROSS_PROCESS), |
|
| 416 |
+ ) |
|
| 417 |
+ except NotImplementedError: # pragma: no cover [external] |
|
| 418 |
+ if require_cryptprotectmemory: |
|
| 419 |
+ raise |
|
| 420 |
+ buf_as_ssh_string = bytearray() |
|
| 421 |
+ buf_as_ssh_string.extend(int.to_bytes(len(buf.raw), 4, "big")) |
|
| 422 |
+ buf_as_ssh_string.extend(buf.raw) |
|
| 423 |
+ digest = hashlib.sha256(buf_as_ssh_string).hexdigest() |
|
| 424 |
+ return f"{PIPE_PREFIX}pageant.{os.getlogin()}.{digest}"
|
|
| 30 | 425 |
|
| 31 |
-class TheAnnoyingOsNamedPipesNotAvailableError(NotImplementedError): |
|
| 32 |
- """This Python installation does not support Windows named pipes.""" |
|
| 426 |
+ |
|
| 427 |
+class _WindowsNamedPipeSocketAddress(enum.Enum): |
|
| 428 |
+ """Internal enum for the socket address for Windows named pipes. |
|
| 429 |
+ |
|
| 430 |
+ Attributes: |
|
| 431 |
+ PAGEANT: The dynamic address for PuTTY/Pageant. |
|
| 432 |
+ OPENSSH: The static address for OpenSSH-on-Windows. |
|
| 433 |
+ |
|
| 434 |
+ """ |
|
| 435 |
+ |
|
| 436 |
+ PAGEANT = "PuTTY/Pageant on The Annoying OS" |
|
| 437 |
+ """""" |
|
| 438 |
+ OPENSSH = "OpenSSH on The Annoying OS" |
|
| 439 |
+ """""" |
|
| 33 | 440 |
|
| 34 | 441 |
|
| 35 | 442 |
class SocketProvider: |
| ... | ... |
@@ -52,13 +459,11 @@ class SocketProvider: |
| 52 | 459 |
Raises: |
| 53 | 460 |
KeyError: |
| 54 | 461 |
The `SSH_AUTH_SOCK` environment variable was not found. |
| 55 |
- AfUnixNotAvailableError: |
|
| 56 |
- [This Python version does not support UNIX domain |
|
| 57 |
- sockets][AF_UNIX], necessary to automatically connect to |
|
| 462 |
+ UnixDomainSocketsNotAvailableError: |
|
| 463 |
+ This Python version does not support UNIX domain |
|
| 464 |
+ sockets, necessary to automatically connect to |
|
| 58 | 465 |
a running SSH agent via the `SSH_AUTH_SOCK` environment |
| 59 | 466 |
variable. |
| 60 |
- |
|
| 61 |
- [AF_UNIX]: https://docs.python.org/3/library/socket.html#socket.AF_UNIX |
|
| 62 | 467 |
OSError: |
| 63 | 468 |
There was an error setting up a socket connection to the |
| 64 | 469 |
agent. |
| ... | ... |
@@ -66,7 +471,7 @@ class SocketProvider: |
| 66 | 471 |
""" |
| 67 | 472 |
if not hasattr(socket, "AF_UNIX"): |
| 68 | 473 |
msg = "This Python version does not support UNIX domain sockets." |
| 69 |
- raise AfUnixNotAvailableError(msg) |
|
| 474 |
+ raise UnixDomainSocketsNotAvailableError(msg) |
|
| 70 | 475 |
else: # noqa: RET506 # pragma: unless posix no cover |
| 71 | 476 |
sock = socket.socket(family=socket.AF_UNIX) |
| 72 | 477 |
if "SSH_AUTH_SOCK" not in os.environ: |
| ... | ... |
@@ -78,35 +483,92 @@ class SocketProvider: |
| 78 | 483 |
return sock |
| 79 | 484 |
|
| 80 | 485 |
@staticmethod |
| 81 |
- def the_annoying_os_named_pipes() -> _types.SSHAgentSocket: |
|
| 82 |
- """Return a socket connected to Pageant/OpenSSH on The Annoying OS. |
|
| 83 |
- |
|
| 84 |
- This may be a write-through socket if the underlying connection |
|
| 85 |
- to Pageant or OpenSSH does not use an actual network socket to |
|
| 86 |
- communicate. |
|
| 486 |
+ def _the_annoying_os_named_pipe( |
|
| 487 |
+ pipe_name: _WindowsNamedPipeSocketAddress | str, |
|
| 488 |
+ ) -> _types.SSHAgentSocket: |
|
| 489 |
+ """Return a socket wrapper around a Windows named pipe. |
|
| 87 | 490 |
|
| 88 | 491 |
Raises: |
| 89 |
- TheAnnoyingOsNamedPipesNotAvailableError: |
|
| 90 |
- This functionality is not implemented yet. |
|
| 91 |
- |
|
| 92 |
- Warning: Not implemented yet |
|
| 93 |
- This functionality is not implemented yet. Specifically, |
|
| 94 |
- [we do not yet support any of the communication mechanisms |
|
| 95 |
- used by the leading SSH agent |
|
| 96 |
- implementations.][windows-ssh-agent-support] |
|
| 97 |
- |
|
| 98 |
- [windows-ssh-agent-support]: https://the13thletter.info/derivepassphrase/0.x/wishlist/windows-ssh-agent-support/ |
|
| 492 |
+ OSError: |
|
| 493 |
+ There was an error setting up a connection to the agent. |
|
| 494 |
+ WindowsNamedPipesNotAvailableError: |
|
| 495 |
+ This Python version does not support Windows named |
|
| 496 |
+ pipes. |
|
| 99 | 497 |
|
| 100 | 498 |
""" |
| 101 | 499 |
if not hasattr(ctypes, "WinDLL"): |
| 102 | 500 |
msg = "This Python version does not support Windows named pipes." |
| 103 |
- raise TheAnnoyingOsNamedPipesNotAvailableError(msg) |
|
| 501 |
+ raise WindowsNamedPipesNotAvailableError(msg) |
|
| 104 | 502 |
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." |
|
| 503 |
+ # TODO(the-13th-letter): Rewrite using structural pattern |
|
| 504 |
+ # matching. |
|
| 505 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
| 506 |
+ if pipe_name == _WindowsNamedPipeSocketAddress.PAGEANT: |
|
| 507 |
+ return WindowsNamedPipeHandle.for_pageant() |
|
| 508 |
+ if pipe_name == _WindowsNamedPipeSocketAddress.OPENSSH: |
|
| 509 |
+ return WindowsNamedPipeHandle.for_openssh() |
|
| 510 |
+ return WindowsNamedPipeHandle(pipe_name) |
|
| 511 |
+ |
|
| 512 |
+ @classmethod |
|
| 513 |
+ def the_annoying_os_named_pipe_for_pageant(cls) -> _types.SSHAgentSocket: |
|
| 514 |
+ """Return a socket wrapper connected to Pageant on The Annoying OS. |
|
| 515 |
+ |
|
| 516 |
+ Raises: |
|
| 517 |
+ OSError: |
|
| 518 |
+ There was an error setting up a connection to Pageant. |
|
| 519 |
+ WindowsNamedPipesNotAvailableError: |
|
| 520 |
+ This Python version does not support Windows named |
|
| 521 |
+ pipes. |
|
| 522 |
+ |
|
| 523 |
+ """ |
|
| 524 |
+ return cls._the_annoying_os_named_pipe( |
|
| 525 |
+ _WindowsNamedPipeSocketAddress.PAGEANT |
|
| 108 | 526 |
) |
| 109 |
- raise NotImplementedError(msg) |
|
| 527 |
+ |
|
| 528 |
+ @classmethod |
|
| 529 |
+ def the_annoying_os_named_pipe_for_openssh(cls) -> _types.SSHAgentSocket: |
|
| 530 |
+ """Return a socket wrapper connected to the OpenSSH agent on The Annoying OS. |
|
| 531 |
+ |
|
| 532 |
+ Raises: |
|
| 533 |
+ OSError: |
|
| 534 |
+ There was an error setting up a connection to the OpenSSH |
|
| 535 |
+ agent. |
|
| 536 |
+ WindowsNamedPipesNotAvailableError: |
|
| 537 |
+ This Python version does not support Windows named |
|
| 538 |
+ pipes. |
|
| 539 |
+ |
|
| 540 |
+ """ # noqa: E501 |
|
| 541 |
+ return cls._the_annoying_os_named_pipe( |
|
| 542 |
+ _WindowsNamedPipeSocketAddress.OPENSSH |
|
| 543 |
+ ) |
|
| 544 |
+ |
|
| 545 |
+ @classmethod |
|
| 546 |
+ def the_annoying_os_named_pipe_ssh_auth_sock(cls) -> _types.SSHAgentSocket: |
|
| 547 |
+ r"""Return a socket wrapper connected to the agent in `SSH_AUTH_SOCK`. |
|
| 548 |
+ |
|
| 549 |
+ The `SSH_AUTH_SOCK` environment variable is assumed to contain |
|
| 550 |
+ a valid named pipe name, i.e., a path starting with `\\.\pipe\` |
|
| 551 |
+ or `//./pipe/`. |
|
| 552 |
+ |
|
| 553 |
+ Raises: |
|
| 554 |
+ KeyError: |
|
| 555 |
+ The `SSH_AUTH_SOCK` environment variable was not found. |
|
| 556 |
+ ValueError: |
|
| 557 |
+ The path in `SSH_AUTH_SOCK` clearly does not name a valid |
|
| 558 |
+ named pipe name. |
|
| 559 |
+ OSError: |
|
| 560 |
+ There was an error setting up a connection to the OpenSSH |
|
| 561 |
+ agent. |
|
| 562 |
+ WindowsNamedPipesNotAvailableError: |
|
| 563 |
+ This Python version does not support Windows named |
|
| 564 |
+ pipes. |
|
| 565 |
+ |
|
| 566 |
+ """ |
|
| 567 |
+ address = os.environ["SSH_AUTH_SOCK"] |
|
| 568 |
+ if not address.replace("/", "\\").startswith(PIPE_PREFIX):
|
|
| 569 |
+ msg = f"Invalid named pipe name: {address!r}"
|
|
| 570 |
+ raise ValueError(msg) |
|
| 571 |
+ return cls._the_annoying_os_named_pipe(address) |
|
| 110 | 572 |
|
| 111 | 573 |
registry: ClassVar[ |
| 112 | 574 |
dict[str, _types.SSHAgentSocketProvider | str | None] |
| ... | ... |
@@ -337,21 +799,23 @@ class SocketProvider: |
| 337 | 799 |
|
| 338 | 800 |
|
| 339 | 801 |
SocketProvider.registry.update({
|
| 340 |
- "posix": SocketProvider.unix_domain_ssh_auth_sock, |
|
| 341 |
- "the_annoying_os": SocketProvider.the_annoying_os_named_pipes, |
|
| 802 |
+ "ssh_auth_sock_on_posix": SocketProvider.unix_domain_ssh_auth_sock, |
|
| 803 |
+ "pageant_on_the_annoying_os": SocketProvider.the_annoying_os_named_pipe_for_pageant, # noqa: E501 |
|
| 804 |
+ "openssh_on_the_annoying_os": SocketProvider.the_annoying_os_named_pipe_for_openssh, # noqa: E501 |
|
| 805 |
+ "ssh_auth_sock_on_the_annoying_os": SocketProvider.the_annoying_os_named_pipe_ssh_auth_sock, # noqa: E501 |
|
| 342 | 806 |
# known instances |
| 343 | 807 |
"stub_agent": None, |
| 344 | 808 |
"stub_with_address": None, |
| 345 | 809 |
"stub_with_address_and_deterministic_dsa": None, |
| 346 | 810 |
# aliases |
| 347 |
- "native": "the_annoying_os" if os.name == "nt" else "posix", |
|
| 348 |
- "unix_domain": "posix", |
|
| 349 |
- "ssh_auth_sock": "posix", |
|
| 811 |
+ "posix": "ssh_auth_sock_on_posix", |
|
| 812 |
+ "ssh_auth_sock": "ssh_auth_sock_on_posix", |
|
| 813 |
+ "unix_domain": "ssh_auth_sock_on_posix", |
|
| 814 |
+ "the_annoying_os": "pageant_on_the_annoying_os", |
|
| 350 | 815 |
"the_annoying_os_named_pipe": "the_annoying_os", |
| 351 |
- "pageant_on_the_annoying_os": "the_annoying_os", |
|
| 352 |
- "openssh_on_the_annoying_os": "the_annoying_os", |
|
| 353 | 816 |
"windows": "the_annoying_os", |
| 354 | 817 |
"windows_named_pipe": "the_annoying_os", |
| 355 |
- "pageant_on_windows": "the_annoying_os", |
|
| 356 |
- "openssh_on_windows": "the_annoying_os", |
|
| 818 |
+ "pageant_on_windows": "pageant_on_the_annoying_os", |
|
| 819 |
+ "openssh_on_windows": "openssh_on_the_annoying_os", |
|
| 820 |
+ "native": "the_annoying_os" if os.name == "nt" else "posix", |
|
| 357 | 821 |
}) |
| 358 | 822 |