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

1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5"""Machinery for providing sockets connected to SSH agents.""" 

6 

7from __future__ import annotations 

8 

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 

29 

30from derivepassphrase import _types 

31 

32if TYPE_CHECKING: 

33 from collections.abc import Callable, Mapping 

34 from collections.abc import Set as AbstractSet 

35 from typing import ClassVar 

36 

37 from typing_extensions import ( 

38 Any, 

39 Buffer, 

40 Literal, 

41 Protocol, 

42 Self, 

43 TypeAlias, 

44 TypeVar, 

45 ) 

46 

47 SSHAgentSocketProviderT = TypeVar( 

48 "SSHAgentSocketProviderT", bound=_types.SSHAgentSocketProvider 

49 ) 

50 """A parametrized SSH agent socket provider.""" 

51 

52 kernel32: ctypes.WinDLL # type: ignore[name-defined] 

53 crypt32: ctypes.WinDLL # type: ignore[name-defined] 

54 

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] 

73 

74 class WinErrorProtocol(Protocol): 

75 def __call__(self, err: int | None = None, /) -> OSError: ... 

76 

77 GetLastError: Callable[[], int] 

78 WinError: WinErrorProtocol 

79 

80 

81class _OverlappedDummyStruct(ctypes.Structure): 

82 """The DUMMYSTRUCTNAME structure in the definition of OVERLAPPED.""" 

83 

84 _fields_ = (("Offset", DWORD), ("OffsetHigh", DWORD)) 

85 

86 

87class _OverlappedDummyUnion(ctypes.Union): 

88 """The DUMMYUNIONNAME union in the definition of OVERLAPPED.""" 

89 

90 _fields_ = ( 

91 ("DUMMYSTRUCTNAME", _OverlappedDummyStruct), 

92 ("Pointer", ctypes.c_void_p), 

93 ) 

94 

95 

96class OVERLAPPED(ctypes.Structure): 

97 """The data structure for overlapped (async) I/O on The Annoying OS.""" 

98 

99 _fields_ = ( 

100 ("Internal", ctypes.POINTER(ctypes.c_ulong)), 

101 ("InternalHigh", ctypes.POINTER(ctypes.c_ulong)), 

102 ("DUMMYUNIONNAME", _OverlappedDummyUnion), 

103 ("hEvent", HANDLE), 

104 ) 

105 

106 

107if TYPE_CHECKING: 

108 LPOVERLAPPED: TypeAlias = ctypes._Pointer[OVERLAPPED] # noqa: SLF001 

109else: 

110 LPOVERLAPPED = ctypes.POINTER(OVERLAPPED) 

111 

112__all__ = ("SocketProvider",) 

113 

114 

115class NoSuchProviderError(KeyError): 

116 """No such SSH agent socket provider is known.""" 

117 

118 

119class UnixDomainSocketsNotAvailableError(NotImplementedError): 

120 """This Python installation does not support UNIX domain sockets. 

121 

122 On this installation, the standard library symbol 

123 [`socket.AF_UNIX`][] is not available, necessary to create UNIX 

124 domain sockets. 

125 

126 """ 

127 

128 

129class WindowsNamedPipesNotAvailableError(NotImplementedError): 

130 """This Python installation does not support Windows named pipes. 

131 

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. 

136 

137 """ 

138 

139 

140GENERIC_READ = 0x80000000 

141GENERIC_WRITE = 0x40000000 

142 

143FILE_SHARE_READ = 0x00000001 

144FILE_SHARE_WRITE = 0x00000002 

145FILE_FLAG_OVERLAPPED = 0x40000000 

146 

147OPEN_EXISTING = 0x3 

148 

149INVALID_HANDLE_VALUE = -1 

150 

151CRYPTPROTECTMEMORY_BLOCK_SIZE = 0x10 

152CRYPTPROTECTMEMORY_CROSS_PROCESS = 0x1 

153 

154PIPE_PREFIX = "//./pipe/".replace("/", "\\") 

155 

156ERROR_PIPE_BUSY = 231 

157ERROR_IO_PENDING = 997 

158 

159 

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. 

180 

181try: # pragma: unless the-annoying-os no cover 

182 import ctypes 

183 

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 

191 

192 def GetLastError() -> int: # noqa: N802 # pragma: no cover [failsafe] 

193 raise NotImplementedError 

194 

195 def WinError(err: int | None = None, /) -> OSError: # noqa: N802 # pragma: no cover [failsafe] 

196 raise NotImplementedError 

197 

198 

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 

207 

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 

215 

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] 

219 

220 

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

237 

238 

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 

251 

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) 

270 

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)) 

284 

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)) 

298 

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)) 

304 

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)) 

313 

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)) 

322 

323 

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] 

376 

377 

378class WindowsNamedPipeHandle: 

379 """A Windows named pipe handle. 

380 

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`][]. 

385 

386 """ 

387 

388 _IO_ON_CLOSED_FILE = "I/O operation on closed file." 

389 

390 def __init__( 

391 self, name: str 

392 ) -> None: # pragma: unless the-annoying-os no cover 

393 """Create a named pipe handle. 

394 

395 Args: 

396 name: 

397 The named pipe's name. 

398 

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. 

407 

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

431 

432 def __enter__(self) -> Self: # pragma: unless the-annoying-os no cover 

433 return self 1abcdefqr

434 

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

444 

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

487 

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

524 

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 

530 

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. 

534 

535 Returns: 

536 A new named pipe handle, using OpenSSH's pipe name. 

537 

538 """ 

539 return cls(f"{PIPE_PREFIX}openssh-ssh-agent") 1abc

540 

541 @classmethod 

542 def for_pageant(cls) -> Self: # pragma: unless the-annoying-os no cover 

543 """Construct a named pipe for use with Pageant. 

544 

545 Returns: 

546 A new named pipe handle, using Pageant's pipe name. 

547 

548 """ 

549 return cls(cls.pageant_named_pipe_name()) 1abc

550 

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. 

557 

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. 

565 

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`. 

569 

570 Raises: 

571 NotImplementedError: 

572 `require_cryptprotectmemory` was `True`, but this Python 

573 version cannot call `CryptProtectMemory` due to lack of 

574 system support. 

575 

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

600 

601 

602class _WindowsNamedPipeSocketAddress(enum.Enum): 

603 """Internal enum for the socket address for Windows named pipes. 

604 

605 Attributes: 

606 PAGEANT: The dynamic address for PuTTY/Pageant. 

607 OPENSSH: The static address for OpenSSH-on-Windows. 

608 

609 """ 

610 

611 PAGEANT = "PuTTY/Pageant on The Annoying OS" 

612 """""" 

613 OPENSSH = "OpenSSH on The Annoying OS" 

614 """""" 

615 

616 

617class SocketProvider: 

618 """Static functionality for providing sockets.""" 

619 

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`. 

623 

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). 

630 

631 Returns: 

632 A connected UNIX domain socket. 

633 

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. 

645 

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

659 

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. 

665 

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/`. 

674 

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. 

685 

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

707 

708 @classmethod 

709 def windows_named_pipe_for_pageant(cls) -> WindowsNamedPipeHandle: 

710 """Return a socket wrapper connected to Pageant on The Annoying OS. 

711 

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. 

718 

719 """ 

720 return cls._windows_named_pipe(_WindowsNamedPipeSocketAddress.PAGEANT) 1abc

721 

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. 

725 

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. 

733 

734 """ # noqa: E501 

735 return cls._windows_named_pipe(_WindowsNamedPipeSocketAddress.OPENSSH) 1abc

736 

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`. 

740 

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/`. 

744 

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. 

757 

758 """ 

759 return cls._windows_named_pipe(None) 1asbcdtefiuo

760 

761 registry: ClassVar[ 

762 dict[str, _types.SSHAgentSocketProvider | str | None] 

763 ] = {} 

764 """A dictionary of callables that provide SSH agent sockets. 

765 

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. 

771 

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.) 

782 

783 (Interpretation of "MUST" and "MAY" as per IETF Best Current 

784 Practice #14; see [RFC 2119][] and [RFC 8174][].) 

785 

786 [RFC 2119]: https://www.rfc-editor.org/info/rfc2119 

787 [RFC 8174]: https://www.rfc-editor.org/info/rfc8174 

788 

789 """ 

790 

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). 

798 

799 Attempting to re-register an existing alias, or a name with an 

800 implementation, with a different implementation is an error. 

801 

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. 

809 

810 Returns: 

811 A decorator implementing the above. 

812 

813 """ 

814 

815 def decorator(f: SSHAgentSocketProviderT) -> SSHAgentSocketProviderT: 1alpnm

816 """Register a callable as an SSH agent socket provider. 

817 

818 Attempting to re-register an existing alias, or a name with 

819 an implementation, with a different implementation is an 

820 error. 

821 

822 Args: 

823 f: The callable to decorate/register. 

824 

825 Returns: 

826 The callable. 

827 

828 Raises: 

829 ValueError: 

830 The name or alias is already in use. 

831 

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

848 

849 return decorator 1alpnm

850 

851 @classmethod 

852 def lookup( 

853 cls, provider: _types.SSHAgentSocketProvider | str | None, / 

854 ) -> _types.SSHAgentSocketProvider | None: 

855 """Look up a socket provider entry. 

856 

857 Args: 

858 provider: The provider to look up. 

859 

860 Returns: 

861 The callable indicated by this provider, if it is 

862 implemented, or `None`, if it is merely registered. 

863 

864 Raises: 

865 NoSuchProviderError: 

866 The provider is not registered. 

867 

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

876 

877 @classmethod 

878 def resolve( 

879 cls, provider: _types.SSHAgentSocketProvider | str | None, / 

880 ) -> _types.SSHAgentSocketProvider: 

881 """Resolve a socket provider to a proper callable. 

882 

883 Args: 

884 provider: The provider to resolve. 

885 

886 Returns: 

887 The callable indicated by this provider. 

888 

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. 

895 

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

905 

906 @classmethod 

907 def grouped(cls) -> Mapping[str, AbstractSet[str]]: 

908 """Calculate a mapping of canonical socket provider entries. 

909 

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. 

913 

914 Returns: 

915 A mapping of non-alias entry names to sets of alias entry 

916 names. 

917 

918 Warning: 

919 The results are undefined if the registry has been modified 

920 by any means other than the [`register`][] decorator. 

921 

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

949 

950 key_of_entry = operator.itemgetter(0) 1bc

951 by_value = operator.itemgetter(1) 1bc

952 

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 } 

958 

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 """ 

966 

967 @classmethod 

968 def _find_all_ssh_agent_socket_providers(cls) -> None: 

969 """Find and load all declared SSH agent socket providers. 

970 

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. 

975 

976 Raises: 

977 AssertionError: 

978 The declared SSH agent socket provider was not, in fact, 

979 an SSH agent socket provider. 

980 

981 Alternatively, multiple distributions supplied the same 

982 SSH agent socket provider, but with different 

983 implementations. 

984 

985 """ 

986 import importlib.metadata # noqa: PLC0415 1jk

987 

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

1036 

1037 

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])