Coverage for src / derivepassphrase / ssh_agent / socketprovider.py: 100.000%
272 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5"""Machinery for providing sockets connected to SSH agents."""
7from __future__ import annotations
9import collections
10import ctypes
11import enum
12import errno
13import graphlib
14import hashlib
15import itertools
16import operator
17import os
18import socket
19from ctypes.wintypes import ( # type: ignore[attr-defined]
20 BOOL,
21 DWORD,
22 HANDLE,
23 LPCVOID,
24 LPCWSTR,
25 LPDWORD,
26 LPVOID,
27)
28from typing import TYPE_CHECKING, cast
30from derivepassphrase import _types
32if TYPE_CHECKING:
33 from collections.abc import Callable, Mapping
34 from collections.abc import Set as AbstractSet
35 from typing import ClassVar
37 from typing_extensions import (
38 Any,
39 Buffer,
40 Literal,
41 Protocol,
42 Self,
43 TypeAlias,
44 TypeVar,
45 )
47 SSHAgentSocketProviderT = TypeVar(
48 "SSHAgentSocketProviderT", bound=_types.SSHAgentSocketProvider
49 )
50 """A parametrized SSH agent socket provider."""
52 kernel32: ctypes.WinDLL # type: ignore[name-defined]
53 crypt32: ctypes.WinDLL # type: ignore[name-defined]
55 CryptProtectMemory: Callable[[LPVOID, DWORD, DWORD], BOOL]
56 CreateFile: Callable[
57 [
58 LPCWSTR,
59 DWORD,
60 DWORD,
61 None,
62 DWORD,
63 DWORD,
64 HANDLE | None,
65 ],
66 HANDLE,
67 ]
68 ReadFile: Callable[[HANDLE, LPCVOID, DWORD, LPDWORD, LPOVERLAPPED], BOOL]
69 WriteFile: Callable[[HANDLE, LPCVOID, DWORD, LPDWORD, LPOVERLAPPED], BOOL]
70 CloseHandle: Callable[[HANDLE], BOOL]
71 GetOverlappedResult: Callable[[HANDLE, LPOVERLAPPED, LPDWORD, BOOL], BOOL]
72 CreateEvent: Callable[[None, BOOL, BOOL, None], HANDLE]
74 class WinErrorProtocol(Protocol):
75 def __call__(self, err: int | None = None, /) -> OSError: ...
77 GetLastError: Callable[[], int]
78 WinError: WinErrorProtocol
81class _OverlappedDummyStruct(ctypes.Structure):
82 """The DUMMYSTRUCTNAME structure in the definition of OVERLAPPED."""
84 _fields_ = (("Offset", DWORD), ("OffsetHigh", DWORD))
87class _OverlappedDummyUnion(ctypes.Union):
88 """The DUMMYUNIONNAME union in the definition of OVERLAPPED."""
90 _fields_ = (
91 ("DUMMYSTRUCTNAME", _OverlappedDummyStruct),
92 ("Pointer", ctypes.c_void_p),
93 )
96class OVERLAPPED(ctypes.Structure):
97 """The data structure for overlapped (async) I/O on The Annoying OS."""
99 _fields_ = (
100 ("Internal", ctypes.POINTER(ctypes.c_ulong)),
101 ("InternalHigh", ctypes.POINTER(ctypes.c_ulong)),
102 ("DUMMYUNIONNAME", _OverlappedDummyUnion),
103 ("hEvent", HANDLE),
104 )
107if TYPE_CHECKING:
108 LPOVERLAPPED: TypeAlias = ctypes._Pointer[OVERLAPPED] # noqa: SLF001
109else:
110 LPOVERLAPPED = ctypes.POINTER(OVERLAPPED)
112__all__ = ("SocketProvider",)
115class NoSuchProviderError(KeyError):
116 """No such SSH agent socket provider is known."""
119class UnixDomainSocketsNotAvailableError(NotImplementedError):
120 """This Python installation does not support UNIX domain sockets.
122 On this installation, the standard library symbol
123 [`socket.AF_UNIX`][] is not available, necessary to create UNIX
124 domain sockets.
126 """
129class WindowsNamedPipesNotAvailableError(NotImplementedError):
130 """This Python installation does not support Windows named pipes.
132 On this installation, the standard library symbol
133 [`ctypes.WinDLL`][] is not available, necessary to interface with
134 the Annoying Operating System's standard library to create Windows
135 named pipes.
137 """
140GENERIC_READ = 0x80000000
141GENERIC_WRITE = 0x40000000
143FILE_SHARE_READ = 0x00000001
144FILE_SHARE_WRITE = 0x00000002
145FILE_FLAG_OVERLAPPED = 0x40000000
147OPEN_EXISTING = 0x3
149INVALID_HANDLE_VALUE = -1
151CRYPTPROTECTMEMORY_BLOCK_SIZE = 0x10
152CRYPTPROTECTMEMORY_CROSS_PROCESS = 0x1
154PIPE_PREFIX = "//./pipe/".replace("/", "\\")
156ERROR_PIPE_BUSY = 231
157ERROR_IO_PENDING = 997
160# The type checker and the standard library use different
161# (finer-grained?) types and type definitions on The Annoying Operating
162# System to annotate things derived from ctypes.WinDLL. Furthermore, on
163# other OSes, ctypes.WinDLL is missing entirely. So the type checker
164# attempts to validate our code against *very* different type
165# hierarchies, depending on what OS it is running on, and so we require
166# a lot of type checking exclusions to keep the types reasonably narrow.
167#
168# Additionally, the official documentation for kernel32.dll and
169# crypt32.dll (and The Annoying OS in general) uses CamelCase for both
170# function names and function parameters. This clashes with the
171# standard Python naming conventions, so we need linting exclusions for
172# each such name.
173#
174# Finally, since this code is mostly platform-specific, we use coverage
175# pragmas for the respective branches to avoid surprises about missing
176# coverage.
177#
178# All these factors necessitate *a lot* of "type: ignore", "noqa:" and
179# "pragma: ... no cover" comments below.
181try: # pragma: unless the-annoying-os no cover
182 import ctypes
184 GetLastError = ctypes.GetLastError # type: ignore[attr-defined]
185 WinError = ctypes.WinError # type: ignore[attr-defined]
186except (
187 AttributeError,
188 FileNotFoundError,
189 ImportError,
190): # pragma: unless posix no cover
192 def GetLastError() -> int: # noqa: N802 # pragma: no cover [failsafe]
193 raise NotImplementedError
195 def WinError(err: int | None = None, /) -> OSError: # noqa: N802 # pragma: no cover [failsafe]
196 raise NotImplementedError
199try: # pragma: unless the-annoying-os no cover
200 crypt32 = ctypes.WinDLL("crypt32.dll") # type: ignore[attr-defined]
201 CryptProtectMemory = crypt32.CryptProtectMemory # type: ignore[attr-defined]
202except (
203 AttributeError,
204 FileNotFoundError,
205 ImportError,
206): # pragma: unless posix no cover
208 def CryptProtectMemory( # noqa: N802
209 pDataIn: LPVOID, # noqa: N803
210 cbDataIn: DWORD, # noqa: N803
211 dwFlags: DWORD, # noqa: N803
212 ) -> BOOL: # pragma: no cover [external]
213 del pDataIn, cbDataIn, dwFlags
214 raise NotImplementedError
216else: # pragma: unless the-annoying-os no cover
217 CryptProtectMemory.argtypes = [LPVOID, DWORD, DWORD] # type: ignore[attr-defined]
218 CryptProtectMemory.restype = BOOL # type: ignore[attr-defined]
221def _errcheck(
222 result: int,
223 func: Callable,
224 arguments: Any, # noqa: ANN401
225) -> int: # pragma: no cover [external]
226 del func 1abcdefgih
227 del arguments 1abcdefgih
228 # Even if they have the same value, ctypes pointers don't
229 # compare equal to each other. We need to compare the values
230 # explicitly, keeping the ranges in mind as well.
231 result_value = HANDLE(result).value 1abcdefgih
232 invalid_value = HANDLE(INVALID_HANDLE_VALUE).value 1abcdefgih
233 if result_value == invalid_value: 1abcdefgih
234 raise WinError() 1abci
235 assert result_value is not None # for the type checker 1abcdefgh
236 return result_value 1abcdefgh
239try: # pragma: unless the-annoying-os no cover
240 kernel32 = ctypes.WinDLL("Kernel32.dll") # type: ignore[attr-defined]
241 CreateFile = kernel32.CreateFileW # type: ignore[attr-defined]
242 ReadFile = kernel32.ReadFile # type: ignore[attr-defined]
243 WriteFile = kernel32.WriteFile # type: ignore[attr-defined]
244 CloseHandle = kernel32.CloseHandle # type: ignore[attr-defined]
245 GetOverlappedResult = kernel32.GetOverlappedResult # type: ignore[attr-defined]
246 CreateEvent = kernel32.CreateEventW # type: ignore[attr-defined]
247except (
248 AttributeError,
249 FileNotFoundError,
250): # pragma: unless posix no cover
252 def CreateFile( # noqa: N802, PLR0913, PLR0917
253 lpFilename: LPCWSTR, # noqa: N803
254 dwDesiredAccess: DWORD, # noqa: N803
255 dwShareMode: DWORD, # noqa: N803
256 lpSecurityAttributes: None, # noqa: N803
257 dwCreationDisposition: DWORD, # noqa: N803
258 dwFlagsAndAttributes: DWORD, # noqa: N803
259 hTemplateFile: HANDLE | None, # noqa: N803
260 ) -> HANDLE: # pragma: no cover [external]
261 del lpFilename
262 del dwDesiredAccess
263 del dwShareMode
264 del lpSecurityAttributes
265 del dwCreationDisposition
266 del dwFlagsAndAttributes
267 del hTemplateFile
268 msg = "This Python version does not support Windows named pipes."
269 raise WindowsNamedPipesNotAvailableError(msg)
271 def ReadFile( # noqa: N802
272 hFile: HANDLE, # noqa: N803
273 lpBuffer: LPVOID, # noqa: N803
274 nNumberOfBytesToRead: DWORD, # noqa: N803
275 lpNumberOfBytesRead: LPDWORD, # noqa: N803
276 lpOverlapped: LPOVERLAPPED, # noqa: N803
277 ) -> BOOL: # pragma: no cover [external]
278 del hFile
279 del lpBuffer
280 del nNumberOfBytesToRead
281 del lpNumberOfBytesRead
282 del lpOverlapped
283 raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP))
285 def WriteFile( # noqa: N802
286 hFile: HANDLE, # noqa: N803
287 lpBuffer: LPVOID, # noqa: N803
288 nNumberOfBytesToWrite: DWORD, # noqa: N803
289 lpNumberOfBytesWritten: LPDWORD, # noqa: N803
290 lpOverlapped: LPOVERLAPPED, # noqa: N803
291 ) -> BOOL: # pragma: no cover [external]
292 del hFile
293 del lpBuffer
294 del nNumberOfBytesToWrite
295 del lpNumberOfBytesWritten
296 del lpOverlapped
297 raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP))
299 def CloseHandle( # noqa: N802
300 hHandle: HANDLE, # noqa: N803
301 ) -> BOOL: # pragma: no cover [external]
302 del hHandle
303 raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP))
305 def GetOverlappedResult( # noqa: N802
306 hFile: HANDLE, # noqa: N803
307 lpOverlapped: LPOVERLAPPED, # noqa: N803
308 lpNumberOfBytesTransferred: LPDWORD, # noqa: N803
309 bWait: BOOL, # noqa: N803
310 ) -> BOOL: # pragma: no cover [external]
311 del hFile, lpOverlapped, lpNumberOfBytesTransferred, bWait
312 raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP))
314 def CreateEvent( # noqa: N802
315 lpEventAttributes: None, # noqa: N803
316 bManualReset: BOOL, # noqa: N803
317 bInitialState: BOOL, # noqa: N803
318 lpName: None, # noqa: N803
319 ) -> HANDLE: # pragma: no cover [external]
320 del lpEventAttributes, bManualReset, bInitialState, lpName
321 raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP))
324else: # pragma: unless the-annoying-os no cover
325 CreateFile.argtypes = [ # type: ignore[attr-defined]
326 LPCWSTR,
327 DWORD,
328 DWORD,
329 # Actually, LPSECURITY_ATTRIBUTES, but we always pass
330 # None/NULL.
331 ctypes.c_void_p,
332 DWORD,
333 DWORD,
334 # We always pass None/NULL.
335 HANDLE,
336 ]
337 CreateEvent.argtypes = [ # type: ignore[attr-defined]
338 # Actually, LPSECURITY_ATTRIBUTES, but we always pass
339 # None/NULL.
340 ctypes.c_void_p,
341 BOOL,
342 BOOL,
343 # We always pass None/NULL.
344 LPCWSTR,
345 ]
346 CreateFile.restype = HANDLE # type: ignore[attr-defined]
347 CreateEvent.restype = HANDLE # type: ignore[attr-defined]
348 CreateFile.errcheck = _errcheck # type: ignore[assignment, attr-defined]
349 CreateEvent.errcheck = _errcheck # type: ignore[assignment, attr-defined]
350 ReadFile.argtypes = [ # type: ignore[attr-defined]
351 HANDLE,
352 LPVOID,
353 DWORD,
354 LPDWORD,
355 LPOVERLAPPED,
356 ]
357 WriteFile.argtypes = [ # type: ignore[attr-defined]
358 HANDLE,
359 LPVOID,
360 DWORD,
361 LPDWORD,
362 LPOVERLAPPED,
363 ]
364 CloseHandle.argtypes = [HANDLE] # type: ignore[attr-defined]
365 GetOverlappedResult.argtypes = [ # type: ignore[attr-defined]
366 HANDLE,
367 LPOVERLAPPED,
368 LPDWORD,
369 # We always pass True.
370 BOOL,
371 ]
372 ReadFile.restype = BOOL # type: ignore[attr-defined]
373 WriteFile.restype = BOOL # type: ignore[attr-defined]
374 CloseHandle.restype = BOOL # type: ignore[attr-defined]
375 GetOverlappedResult.restype = BOOL # type: ignore[attr-defined]
378class WindowsNamedPipeHandle:
379 """A Windows named pipe handle.
381 This handle implements the [`SSHAgentSocket`][_types.SSHAgentSocket]
382 interface. It is only constructable if the Python installation can
383 successfully call into the Annoying OS `kernel32.dll` library via
384 [`ctypes`][].
386 """
388 _IO_ON_CLOSED_FILE = "I/O operation on closed file."
390 def __init__(
391 self, name: str
392 ) -> None: # pragma: unless the-annoying-os no cover
393 """Create a named pipe handle.
395 Args:
396 name:
397 The named pipe's name.
399 Raises:
400 OSError:
401 The system failed to open the named pipe.
402 ValueError:
403 The named pipe's name does not designate a named pipe.
404 WindowsNamedPipesNotAvailableError:
405 This Python version does not support Windows named
406 pipes.
408 """
409 if not name.replace("/", "\\").startswith(PIPE_PREFIX): 1abcdefiqPr
410 msg = f"Invalid named pipe name: {name!r}" 1P
411 raise ValueError(msg) 1P
412 self.handle: HANDLE | None = None 1abcdefiqr
413 while self.handle is None: 1abcdefiqr
414 try: 1abcdefiqr
415 self.handle = CreateFile( 1abcdefiqr
416 ctypes.c_wchar_p(name),
417 ctypes.c_ulong(GENERIC_READ | GENERIC_WRITE),
418 ctypes.c_ulong(FILE_SHARE_READ | FILE_SHARE_WRITE),
419 None,
420 ctypes.c_ulong(OPEN_EXISTING),
421 ctypes.c_ulong(FILE_FLAG_OVERLAPPED),
422 None,
423 )
424 except BlockingIOError: # pragma: no cover [external] 1abci
425 continue # retry
426 except OSError as exc: # pragma: no cover [external] 1abci
427 if getattr(exc, "winerror", None) == ERROR_PIPE_BUSY: 1abci
428 continue # retry
429 raise 1abci
430 self._name = name 1abcdefqr
432 def __enter__(self) -> Self: # pragma: unless the-annoying-os no cover
433 return self 1abcdefqr
435 def __exit__(
436 self, *args: object
437 ) -> Literal[False]: # pragma: unless the-annoying-os no cover
438 try: 1abcdefqr
439 if self.handle is not None: 1abcdefqr
440 CloseHandle(self.handle) 1abcdefqr
441 return False 1abcdefqr
442 finally:
443 self.handle = None 1abcdefqr
445 def recv(
446 self, data: int, flags: int = 0, /
447 ) -> bytes: # pragma: unless the-annoying-os no cover
448 if self.handle is None: 1adghqr
449 raise ValueError(self._IO_ON_CLOSED_FILE) 1qr
450 del flags 1adgh
451 result = bytearray() 1adgh
452 read_count = DWORD(0) 1adgh
453 buffer = (ctypes.c_char * 65536)() 1adgh
454 while data > 0: 1adgh
455 block_size = min(max(0, data), 65536) 1adgh
456 wait_event = CreateEvent(None, BOOL(True), BOOL(False), None) # noqa: FBT003 1adgh
457 try: 1adgh
458 overlapped_struct = OVERLAPPED(hEvent=wait_event) 1adgh
459 success = ReadFile( 1adgh
460 self.handle,
461 ctypes.cast(ctypes.byref(buffer), ctypes.c_void_p),
462 DWORD(block_size),
463 ctypes.cast(ctypes.byref(read_count), LPDWORD),
464 ctypes.cast(ctypes.byref(overlapped_struct), LPOVERLAPPED),
465 )
466 if ( 1adgh
467 not success and GetLastError() == ERROR_IO_PENDING
468 ): # pragma: no cover [external]
469 success = GetOverlappedResult( 1adgh
470 self.handle,
471 ctypes.cast(
472 ctypes.byref(overlapped_struct), LPOVERLAPPED
473 ),
474 ctypes.cast(ctypes.byref(read_count), LPDWORD),
475 BOOL(True), # noqa: FBT003
476 )
477 if ( 1adgh
478 not success or read_count.value == 0
479 ): # pragma: no cover [external]
480 raise WinError()
481 finally:
482 CloseHandle(wait_event) 1adgh
483 result.extend(buffer.raw[:block_size]) 1adgh
484 data -= read_count.value 1adgh
485 read_count.value = 0 1adgh
486 return bytes(result) 1adgh
488 def sendall(
489 self, data: Buffer, flags: int = 0, /
490 ) -> None: # pragma: unless the-annoying-os no cover
491 if self.handle is None: 1adghqr
492 raise ValueError(self._IO_ON_CLOSED_FILE) 1qr
493 del flags 1adgh
494 data = bytes(data) 1adgh
495 databuf = (ctypes.c_char * len(data))() 1adgh
496 for i, x in enumerate(data): 1adgh
497 databuf[i] = ctypes.c_char(x) 1adgh
498 write_count = DWORD(0) 1adgh
499 wait_event = CreateEvent(None, BOOL(True), BOOL(False), None) # noqa: FBT003 1adgh
500 try: 1adgh
501 overlapped_struct = OVERLAPPED(hEvent=wait_event) 1adgh
502 success = WriteFile( 1adgh
503 self.handle,
504 ctypes.cast(ctypes.byref(databuf), ctypes.c_void_p),
505 DWORD(len(data)),
506 ctypes.cast(ctypes.byref(write_count), LPDWORD),
507 ctypes.cast(ctypes.byref(overlapped_struct), LPOVERLAPPED),
508 )
509 if ( 1adgh
510 not success and GetLastError() == ERROR_IO_PENDING
511 ): # pragma: no cover [external]
512 success = GetOverlappedResult(
513 self.handle,
514 ctypes.cast(ctypes.byref(overlapped_struct), LPOVERLAPPED),
515 ctypes.cast(ctypes.byref(write_count), LPDWORD),
516 BOOL(True), # noqa: FBT003
517 )
518 if ( 1adgh
519 not success or write_count.value == 0
520 ): # pragma: no cover [external]
521 raise WinError()
522 finally:
523 CloseHandle(wait_event) 1adgh
525 def named_pipe_name(
526 self,
527 ) -> str: # pragma: unless the-annoying-os no cover
528 """Return the named pipe name this socket is connected to."""
529 return self._name
531 @classmethod
532 def for_openssh(cls) -> Self: # pragma: unless the-annoying-os no cover
533 """Construct a named pipe for use with OpenSSH on The Annoying OS.
535 Returns:
536 A new named pipe handle, using OpenSSH's pipe name.
538 """
539 return cls(f"{PIPE_PREFIX}openssh-ssh-agent") 1abc
541 @classmethod
542 def for_pageant(cls) -> Self: # pragma: unless the-annoying-os no cover
543 """Construct a named pipe for use with Pageant.
545 Returns:
546 A new named pipe handle, using Pageant's pipe name.
548 """
549 return cls(cls.pageant_named_pipe_name()) 1abc
551 @staticmethod
552 def pageant_named_pipe_name(
553 *,
554 require_cryptprotectmemory: bool = False,
555 ) -> str: # pragma: unless the-annoying-os no cover
556 """Return the pipe name that Pageant on The Annoying OS would use.
558 Args:
559 require_cryptprotectmemory:
560 Pageant normally attempts to use the
561 `CryptProtectMemory` system function from the
562 `crypt32.dll` library on The Annoying OS, ignoring any
563 errors. This in turn influences the resulting named
564 pipe's filename.
566 If true, and if we fail to call `CryptProtectMemory`, we
567 abort; otherwise we ignore any errors from calling or
568 failing to call `CryptProtectMemory`.
570 Raises:
571 NotImplementedError:
572 `require_cryptprotectmemory` was `True`, but this Python
573 version cannot call `CryptProtectMemory` due to lack of
574 system support.
576 """
577 realname = b"Pageant\x00" 1abc
578 num_blocks = ( 1abc
579 len(realname) + CRYPTPROTECTMEMORY_BLOCK_SIZE - 1
580 ) // CRYPTPROTECTMEMORY_BLOCK_SIZE
581 assert num_blocks == 1 1abc
582 buf_decl = ctypes.c_char * (CRYPTPROTECTMEMORY_BLOCK_SIZE * num_blocks) 1abc
583 buf = buf_decl() 1abc
584 for i, x in enumerate(realname): 1abc
585 buf[i] = ctypes.c_char(x) 1abc
586 try: 1abc
587 CryptProtectMemory( 1abc
588 ctypes.cast(ctypes.byref(buf), ctypes.c_void_p),
589 DWORD(len(buf.raw)),
590 DWORD(CRYPTPROTECTMEMORY_CROSS_PROCESS),
591 )
592 except NotImplementedError: # pragma: no cover [external]
593 if require_cryptprotectmemory:
594 raise
595 buf_as_ssh_string = bytearray() 1abc
596 buf_as_ssh_string.extend(int.to_bytes(len(buf.raw), 4, "big")) 1abc
597 buf_as_ssh_string.extend(buf.raw) 1abc
598 digest = hashlib.sha256(buf_as_ssh_string).hexdigest() 1abc
599 return f"{PIPE_PREFIX}pageant.{os.getlogin()}.{digest}" 1abc
602class _WindowsNamedPipeSocketAddress(enum.Enum):
603 """Internal enum for the socket address for Windows named pipes.
605 Attributes:
606 PAGEANT: The dynamic address for PuTTY/Pageant.
607 OPENSSH: The static address for OpenSSH-on-Windows.
609 """
611 PAGEANT = "PuTTY/Pageant on The Annoying OS"
612 """"""
613 OPENSSH = "OpenSSH on The Annoying OS"
614 """"""
617class SocketProvider:
618 """Static functionality for providing sockets."""
620 @staticmethod
621 def unix_domain_ssh_auth_sock(*, timeout: int = 125) -> socket.socket:
622 """Return a UNIX domain socket connected to `SSH_AUTH_SOCK`.
624 Args:
625 timeout:
626 A connection timeout for the SSH agent. Only used for
627 "true" sockets, and only if the socket is not yet connected.
628 The default value gives ample time for agent connections
629 forwarded via SSH on high-latency networks (e.g. Tor).
631 Returns:
632 A connected UNIX domain socket.
634 Raises:
635 KeyError:
636 The `SSH_AUTH_SOCK` environment variable was not found.
637 UnixDomainSocketsNotAvailableError:
638 This Python version does not support UNIX domain
639 sockets, necessary to automatically connect to
640 a running SSH agent via the `SSH_AUTH_SOCK` environment
641 variable.
642 OSError:
643 There was an error setting up a socket connection to the
644 agent.
646 """
647 if not hasattr(socket, "AF_UNIX"): 1asbcdtefiuv
648 msg = "This Python version does not support UNIX domain sockets." 1sbctu
649 raise UnixDomainSocketsNotAvailableError(msg) 1sbctu
650 else: # noqa: RET506 # pragma: unless posix no cover
651 sock = socket.socket(family=socket.AF_UNIX) 1abcdefiv
652 if "SSH_AUTH_SOCK" not in os.environ: 1abcdefiv
653 msg = "SSH_AUTH_SOCK environment variable" 1v
654 raise KeyError(msg) 1v
655 ssh_auth_sock = os.environ["SSH_AUTH_SOCK"] 1abcdefi
656 sock.settimeout(timeout) 1abcdefi
657 sock.connect(ssh_auth_sock) 1abcdefi
658 return sock 1abcdef
660 @staticmethod
661 def _windows_named_pipe(
662 pipe_name: _WindowsNamedPipeSocketAddress | str | None,
663 ) -> WindowsNamedPipeHandle:
664 r"""Return a socket wrapper around a Windows named pipe.
666 Args:
667 pipe_name:
668 The named pipe's name, or a well-known named pipe socket
669 address, or `None` to look up the true name in
670 `SSH_AUTH_SOCK`. In the latter case, the `SSH_AUTH_SOCK`
671 environment variable is assumed to contain a valid named
672 pipe name, i.e., a path starting with `\\.\pipe\` or
673 `//./pipe/`.
675 Raises:
676 KeyError:
677 The `SSH_AUTH_SOCK` environment variable was not found.
678 OSError:
679 There was an error setting up a connection to the agent.
680 ValueError:
681 The path clearly does not name a valid named pipe name.
682 WindowsNamedPipesNotAvailableError:
683 This Python version does not support Windows named
684 pipes.
686 """
687 # We must raise WindowsNamedPipesNotAvailableError in preference
688 # to other errors, so we must duplicate the system support check
689 # here (even though WindowsNamedPipeHandle would raise as well).
690 if not hasattr(ctypes, "WinDLL"): 1asbcdtefiuo
691 msg = "This Python version does not support Windows named pipes." 1sbctu
692 raise WindowsNamedPipesNotAvailableError(msg) 1sbctu
693 else: # noqa: RET506 # pragma: unless the-annoying-os no cover
694 # TODO(the-13th-letter): Rewrite using structural pattern
695 # matching (3 cases).
696 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
697 if pipe_name == _WindowsNamedPipeSocketAddress.PAGEANT: 1abcdefio
698 return WindowsNamedPipeHandle.for_pageant() 1abc
699 if pipe_name == _WindowsNamedPipeSocketAddress.OPENSSH: 1abcdefio
700 return WindowsNamedPipeHandle.for_openssh() 1abc
701 if pipe_name is None: # pragma: no branch 1abcdefio
702 pipe_name = os.environ["SSH_AUTH_SOCK"] 1abcdefio
703 if not pipe_name.replace("/", "\\").startswith(PIPE_PREFIX): 1defio
704 msg = f"Invalid named pipe name: {pipe_name!r}" 1o
705 raise ValueError(msg) 1o
706 return WindowsNamedPipeHandle(pipe_name) 1defi
708 @classmethod
709 def windows_named_pipe_for_pageant(cls) -> WindowsNamedPipeHandle:
710 """Return a socket wrapper connected to Pageant on The Annoying OS.
712 Raises:
713 OSError:
714 There was an error setting up a connection to Pageant.
715 WindowsNamedPipesNotAvailableError:
716 This Python version does not support Windows named
717 pipes.
719 """
720 return cls._windows_named_pipe(_WindowsNamedPipeSocketAddress.PAGEANT) 1abc
722 @classmethod
723 def windows_named_pipe_for_openssh(cls) -> WindowsNamedPipeHandle:
724 """Return a socket wrapper connected to the OpenSSH agent on The Annoying OS.
726 Raises:
727 OSError:
728 There was an error setting up a connection to the OpenSSH
729 agent.
730 WindowsNamedPipesNotAvailableError:
731 This Python version does not support Windows named
732 pipes.
734 """ # noqa: E501
735 return cls._windows_named_pipe(_WindowsNamedPipeSocketAddress.OPENSSH) 1abc
737 @classmethod
738 def windows_named_pipe_ssh_auth_sock(cls) -> WindowsNamedPipeHandle:
739 r"""Return a socket wrapper connected to the agent in `SSH_AUTH_SOCK`.
741 The `SSH_AUTH_SOCK` environment variable is assumed to contain
742 a valid named pipe name, i.e., a path starting with `\\.\pipe\`
743 or `//./pipe/`.
745 Raises:
746 KeyError:
747 The `SSH_AUTH_SOCK` environment variable was not found.
748 ValueError:
749 The path in `SSH_AUTH_SOCK` clearly does not name a valid
750 named pipe name.
751 OSError:
752 There was an error setting up a connection to the OpenSSH
753 agent.
754 WindowsNamedPipesNotAvailableError:
755 This Python version does not support Windows named
756 pipes.
758 """
759 return cls._windows_named_pipe(None) 1asbcdtefiuo
761 registry: ClassVar[
762 dict[str, _types.SSHAgentSocketProvider | str | None]
763 ] = {}
764 """A dictionary of callables that provide SSH agent sockets.
766 Each entry in the dictionary points either to a callable, a string,
767 or `None`: if a callable, then that callable returns a socket; if
768 a string, then this entry is an alias for that other entry; if
769 `None`, then the entry name is merely registered, but no
770 implementation is available.
772 If a callable is not applicable to this platform, Python
773 installation or `derivepassphrase` installation, then it MUST raise
774 [`NotImplementedError`][]. Conversely, if the callable returns
775 a value, or raises any other kind of exception, then the caller MAY
776 assume that this platform, Python installation and
777 `derivepassphrase` installation are sufficient for the callable to
778 be able to return a working socket on this platform. (The latter
779 may still be dependent on further, external circumstances, such as
780 required configuration settings, or environment variables, or
781 sufficient system resources, etc.)
783 (Interpretation of "MUST" and "MAY" as per IETF Best Current
784 Practice #14; see [RFC 2119][] and [RFC 8174][].)
786 [RFC 2119]: https://www.rfc-editor.org/info/rfc2119
787 [RFC 8174]: https://www.rfc-editor.org/info/rfc8174
789 """
791 @classmethod
792 def register(
793 cls,
794 name: str,
795 *aliases: str,
796 ) -> Callable[[SSHAgentSocketProviderT], SSHAgentSocketProviderT]:
797 """Register a callable as an SSH agent socket provider (decorator).
799 Attempting to re-register an existing alias, or a name with an
800 implementation, with a different implementation is an error.
802 Args:
803 name:
804 The principal name under which to register the passed
805 callable.
806 aliases:
807 Alternate names to register as aliases for the principal
808 name.
810 Returns:
811 A decorator implementing the above.
813 """
815 def decorator(f: SSHAgentSocketProviderT) -> SSHAgentSocketProviderT: 1alpnm
816 """Register a callable as an SSH agent socket provider.
818 Attempting to re-register an existing alias, or a name with
819 an implementation, with a different implementation is an
820 error.
822 Args:
823 f: The callable to decorate/register.
825 Returns:
826 The callable.
828 Raises:
829 ValueError:
830 The name or alias is already in use.
832 """
833 for alias in [name, *aliases]: 1alpnm
834 try: 1alpnm
835 existing = cls.lookup(alias) 1alpnm
836 except (NoSuchProviderError, NotImplementedError): 1lnm
837 cls.registry[alias] = f if alias == name else name 1lnm
838 else:
839 if existing is None: 1alpm
840 cls.registry[alias] = f if alias == name else name
841 elif existing != f: 1lpm
842 msg = ( 1p
843 f"The SSH agent socket provider {alias!r} "
844 f"is already registered."
845 )
846 raise ValueError(msg) 1p
847 return f 1alnm
849 return decorator 1alpnm
851 @classmethod
852 def lookup(
853 cls, provider: _types.SSHAgentSocketProvider | str | None, /
854 ) -> _types.SSHAgentSocketProvider | None:
855 """Look up a socket provider entry.
857 Args:
858 provider: The provider to look up.
860 Returns:
861 The callable indicated by this provider, if it is
862 implemented, or `None`, if it is merely registered.
864 Raises:
865 NoSuchProviderError:
866 The provider is not registered.
868 """
869 ret = provider 1alsbcdtefAOBCDEFGHIiuzvjpnmwxJKLyMoN
870 while isinstance(ret, str): 1alsbcdtefAOBCDEFGHIiuzvjpnmwxJKLyMoN
871 try: 1alsbcdtefAOBCDEFGHIiuzvjpnmwxJKLyMoN
872 ret = cls.registry[ret] 1alsbcdtefAOBCDEFGHIiuzvjpnmwxJKLyMoN
873 except KeyError as exc: 1lOnmx
874 raise NoSuchProviderError(ret) from exc 1lOnmx
875 return ret 1alsbcdtefABCDEFGHIiuzvjpnmwxJKLyMoN
877 @classmethod
878 def resolve(
879 cls, provider: _types.SSHAgentSocketProvider | str | None, /
880 ) -> _types.SSHAgentSocketProvider:
881 """Resolve a socket provider to a proper callable.
883 Args:
884 provider: The provider to resolve.
886 Returns:
887 The callable indicated by this provider.
889 Raises:
890 NoSuchProviderError:
891 The provider is not registered.
892 NotImplementedError:
893 The provider is registered, but is not functional or not
894 applicable to this `derivepassphrase` installation.
896 """
897 ret = cls.lookup(provider) 1alsbcdtefAOBCDEFGHIiuzvjpnmwxJKLyMoN
898 if ret is None: 1alsbcdtefABCDEFGHIiuzvjpnmwxJKLyMoN
899 msg = ( 1zwy
900 f"The {ret!r} socket provider is not functional on or "
901 "not applicable to this derivepassphrase installation."
902 )
903 raise NotImplementedError(msg) 1zwy
904 return ret 1alsbcdtefABCDEFGHIiuvjpnmwxJKLyMoN
906 @classmethod
907 def grouped(cls) -> Mapping[str, AbstractSet[str]]:
908 """Calculate a mapping of canonical socket provider entries.
910 Specifically, determine the non-alias entries in the socket
911 provider registry, and map each such non-alias entry to its set
912 of aliases.
914 Returns:
915 A mapping of non-alias entry names to sets of alias entry
916 names.
918 Warning:
919 The results are undefined if the registry has been modified
920 by any means other than the [`register`][] decorator.
922 """
923 known_socket_provider_values = frozenset({ 1bc
924 v for v in cls.registry.values() if not isinstance(v, str)
925 })
926 canonical: dict[str, str] = {} 1bc
927 sorter: graphlib.TopologicalSorter[ 1bc
928 _types.SSHAgentSocketProvider | str | None
929 ] = graphlib.TopologicalSorter()
930 k: _types.SSHAgentSocketProvider | str | None
931 v: _types.SSHAgentSocketProvider | str | None
932 for k, v in cls.registry.items(): 1bc
933 sorter.add(k, v) 1bc
934 sorter.prepare() 1bc
935 while sorter: 1bc
936 entries = sorter.get_ready() 1bc
937 for k in entries: 1bc
938 if not isinstance(k, str): 1bc
939 assert k in known_socket_provider_values 1bc
940 sorter.done(k) 1bc
941 continue 1bc
942 v = cls.registry[k] 1bc
943 canonical[k] = ( 1bc
944 canonical[v] # alias
945 if isinstance(v, str)
946 else k # actual entry
947 )
948 sorter.done(k) 1bc
950 key_of_entry = operator.itemgetter(0) 1bc
951 by_value = operator.itemgetter(1) 1bc
953 sorted_entries = sorted(canonical.items(), key=by_value) 1bc
954 return { 1bc
955 k: frozenset(map(key_of_entry, v))
956 for k, v in itertools.groupby(sorted_entries, key=by_value)
957 }
959 ENTRY_POINT_GROUP_NAME = "derivepassphrase.ssh_agent_socket_providers"
960 """
961 The group name under which [entry
962 points][importlib.metadata.entry_points] for the SSH agent socket
963 provider registry should be recorded. Each target of such an entry
964 point should be a [`_types.SSHAgentSocketProviderEntry`][] object.
965 """
967 @classmethod
968 def _find_all_ssh_agent_socket_providers(cls) -> None:
969 """Find and load all declared SSH agent socket providers.
971 Load all [entry points][importlib.metadata.entry_points] in the
972 `derivepassphrase.ssh_agent_socket_providers` group as providers,
973 then register them. The target of each entry point should be
974 a [`_types.SSHAgentSocketProviderEntry`][] object.
976 Raises:
977 AssertionError:
978 The declared SSH agent socket provider was not, in fact,
979 an SSH agent socket provider.
981 Alternatively, multiple distributions supplied the same
982 SSH agent socket provider, but with different
983 implementations.
985 """
986 import importlib.metadata # noqa: PLC0415 1jk
988 origins: dict[str, str | None] = {} 1jk
989 entries = collections.ChainMap({}, cls.registry) 1jk
990 for entry_point in importlib.metadata.entry_points( 1jk
991 group=cls.ENTRY_POINT_GROUP_NAME
992 ):
993 provider_entry = cast( 1jk
994 "_types.SSHAgentSocketProviderEntry", entry_point.load()
995 )
996 key = provider_entry.key 1jk
997 value = entry_point.value 1jk
998 dist = ( 1jk
999 entry_point.dist.name # type: ignore[union-attr]
1000 if getattr(entry_point, "dist", None) is not None
1001 else "<unknown>"
1002 )
1003 origin = origins.get(key, "derivepassphrase") 1jk
1004 if not callable(provider_entry.provider): 1jk
1005 msg = ( 1k
1006 f"Not an SSHAgentSocketProvider: "
1007 f"{dist = }, {cls.ENTRY_POINT_GROUP_NAME = }, "
1008 f"{value = }, {provider_entry = }"
1009 )
1010 raise AssertionError(msg) # noqa: TRY004 1k
1011 if key in entries: 1jk
1012 if entries[key] != provider_entry.provider: 1jk
1013 msg = ( 1k
1014 f"Name clash in SSH agent socket providers "
1015 f"for entry {key!r}, both by {dist!r} "
1016 f"and by {origin!r}"
1017 )
1018 raise AssertionError(msg) 1k
1019 else:
1020 entries[key] = provider_entry.provider 1j
1021 origins[key] = dist 1j
1022 for alias in provider_entry.aliases: 1jk
1023 alias_origin = origins.get(alias, "derivepassphrase") 1jk
1024 if alias in entries: 1jk
1025 if entries[alias] != key: 1k
1026 msg = ( 1k
1027 f"Name clash in SSH agent socket providers "
1028 f"for entry {alias!r}, both by {dist!r} "
1029 f"and by {alias_origin!r}"
1030 )
1031 raise AssertionError(msg) 1k
1032 else:
1033 entries[alias] = key 1j
1034 origins[key] = dist 1j
1035 cls.registry.update(entries.maps[0]) 1j
1038SocketProvider.registry.update([
1039 (
1040 _types.BuiltinSSHAgentSocketProvider.SSH_AUTH_SOCK_ON_POSIX,
1041 SocketProvider.unix_domain_ssh_auth_sock,
1042 ),
1043 (
1044 _types.BuiltinSSHAgentSocketProvider.PAGEANT_ON_WINDOWS,
1045 SocketProvider.windows_named_pipe_for_pageant,
1046 ),
1047 (
1048 _types.BuiltinSSHAgentSocketProvider.OPENSSH_ON_WINDOWS,
1049 SocketProvider.windows_named_pipe_for_openssh,
1050 ),
1051 (
1052 _types.BuiltinSSHAgentSocketProvider.SSH_AUTH_SOCK_ON_WINDOWS,
1053 SocketProvider.windows_named_pipe_ssh_auth_sock,
1054 ),
1055 # known instances
1056 (_types.BuiltinSSHAgentSocketProvider.STUB_AGENT, None),
1057 (_types.BuiltinSSHAgentSocketProvider.STUB_AGENT_WITH_ADDRESS, None),
1058 (
1059 _types.BuiltinSSHAgentSocketProvider.STUB_AGENT_WITH_ADDRESS_AND_DETERMINISTIC_DSA,
1060 None,
1061 ),
1062 # aliases
1063 (
1064 _types.BuiltinSSHAgentSocketProvider.SSH_AUTH_SOCK,
1065 _types.BuiltinSSHAgentSocketProvider.SSH_AUTH_SOCK_ON_POSIX,
1066 ),
1067 (
1068 _types.BuiltinSSHAgentSocketProvider.UNIX_DOMAIN,
1069 _types.BuiltinSSHAgentSocketProvider.SSH_AUTH_SOCK_ON_POSIX,
1070 ),
1071 (
1072 _types.BuiltinSSHAgentSocketProvider.POSIX,
1073 _types.BuiltinSSHAgentSocketProvider.UNIX_DOMAIN,
1074 ),
1075 (
1076 _types.BuiltinSSHAgentSocketProvider.WINDOWS_NAMED_PIPE,
1077 _types.BuiltinSSHAgentSocketProvider.SSH_AUTH_SOCK_ON_WINDOWS,
1078 ),
1079 (
1080 _types.BuiltinSSHAgentSocketProvider.WINDOWS,
1081 _types.BuiltinSSHAgentSocketProvider.WINDOWS_NAMED_PIPE,
1082 ),
1083 (
1084 _types.BuiltinSSHAgentSocketProvider.NATIVE,
1085 _types.BuiltinSSHAgentSocketProvider.WINDOWS
1086 if os.name == "nt"
1087 else _types.BuiltinSSHAgentSocketProvider.POSIX,
1088 ),
1089])