Coverage for src / derivepassphrase / ssh_agent / __init__.py: 100.000%

199 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"""A bare-bones SSH agent client supporting signing and key listing.""" 

6 

7from __future__ import annotations 

8 

9import collections 

10import contextlib 

11import sys 

12from collections.abc import Sequence 

13from typing import TYPE_CHECKING, ClassVar, overload 

14 

15from typing_extensions import Never, Self, assert_type 

16 

17from derivepassphrase import _types 

18 

19if sys.version_info < (3, 11): 

20 from exceptiongroup import ExceptionGroup 

21 

22if TYPE_CHECKING: 

23 from collections.abc import Generator, Iterable 

24 from collections.abc import Set as AbstractSet 

25 from types import TracebackType 

26 

27 from typing_extensions import Buffer 

28 

29__all__ = ("SSHAgentClient",) 

30 

31# In SSH bytestrings, the "length" of the byte string is stored as 

32# a 4-byte/32-bit unsigned integer at the beginning. 

33HEAD_LEN = 4 

34 

35# Needed for handling OpenSSH bug 3967. See 

36# _check_for_extra_success_response_after_query_extension_request. 

37_OPENSSH_AGENT_10_3_QUERY_EXTENSION_RESPONSE_PAYLOAD = bytes.fromhex(""" 

3800 00 00 05 71 75 65 72 79 

3900 00 00 18 73 65 73 73 69 6f 6e 2d 

4062 69 6e 64 40 6f 70 65 6e 73 73 68 2e 63 6f 6d 

41""") 

42 

43 

44def _check_for_extra_empty_success_response_after_query_extension_request( 

45 client: SSHAgentClient, response_data: bytes | bytearray 

46) -> None: 

47 """Check for extra `SUCCESS` responses after a "query" extension request. 

48 

49 OpenSSH's `ssh-agent` version 10.3 erroneously answers a "query" 

50 extension request with two response messages: the expected 

51 [`EXTENSION_RESPONSE`][_types.SSH_AGENT.EXTENSION_RESPONSE] or 

52 [`SUCCESS`][_types.SSH_AGENT.SUCCESS] message, and the unexpected 

53 extra `SUCCESS` message. This causes a misalignment of the request 

54 and response messages if further requests are later issued to the 

55 agent. 

56 

57 ([OpenSSH acknowledges this as Bug 3967][OPENSSH_BZ3967], and 

58 intends to fix this in version 10.4.) 

59 

60 This callback is intended to run immediately after issuing a "query" 

61 extension request to the agent, and after reading its response. If 

62 we detect the aforementioned OpenSSH behavior, then we re-align the 

63 request and response messages queue. Otherwise, we do nothing. In 

64 particular, it is safe to call us (at the aforementioned time) even 

65 on different SSH agents, or on versions of OpenSSH's agent that do 

66 not exhibit this bug. 

67 

68 [OPENSSH_BZ3967]: https://bugzilla.mindrot.org/show_bug.cgi?id=3967 

69 

70 Args: 

71 client: 

72 The client connected to the SSH agent that may or may not 

73 exhibit this OpenSSH behavior. We assume that the client 

74 has just issued a "query" extension request, that the client 

75 has read and parsed the (first) response of the agent, that 

76 the response message was an `EXTENSION_RESPONSE` or 

77 a `SUCCESS` message, and that the response message (except 

78 for the response code) is exactly the contents of the 

79 `response_data` argument. 

80 response_data: 

81 The payload of the response message to a "query" extension 

82 request issued to the SSH agent immediately prior. 

83 

84 """ 

85 if response_data == _OPENSSH_AGENT_10_3_QUERY_EXTENSION_RESPONSE_PAYLOAD: 1dfbig

86 # We cannot blindly read from the agent socket to check 

87 # for a possible extra SUCCESS response: that would 

88 # deadlock if the extra response is missing. Instead, 

89 # we issue a second "query" request, and depending on 

90 # whether we next see the extra empty SUCCESS response or not, 

91 # we know whether to discard an additional two response 

92 # messages or not. 

93 # 

94 # (Principally any request that does *not* ellicit 

95 # a SUCCESS response and which does not modify the 

96 # agent's state would do for this.) 

97 response_code, response_data2 = client.request( 1fbi

98 _types.SSH_AGENTC.REQUEST_IDENTITIES, b"", response_code=None 

99 ) 

100 if response_code == _types.SSH_AGENT.SUCCESS and not response_data2: 1fbi

101 response_code, response_data2 = client._get_response_pair() # noqa: SLF001 1fbi

102 assert response_code == _types.SSH_AGENT.IDENTITIES_ANSWER, ( 1fbi

103 "failed to re-synchronize with agent via REQUEST_IDENTITIES " 

104 'after "query" EXTENSION_REQUEST: want IDENTITIES_ANSWER, ' 

105 f"got {response_code!r}, {response_data2!r}" 

106 ) 

107 

108 

109class TrailingDataError(RuntimeError): 

110 """The result contained trailing data.""" 

111 

112 def __init__( 

113 self, raw: bytes | bytearray = b"", trailing: bytes | bytearray = b"" 

114 ) -> None: 

115 super().__init__("Overlong response from SSH agent") 1coh

116 self.raw = bytes(raw) 1coh

117 self.trailing = bytes(trailing) 1coh

118 

119 def __str__(self) -> str: # pragma: no cover [debug] 

120 if self.raw or self.trailing: 1oh

121 return ( 1h

122 f"Overlong response from SSH agent: " 1h

123 f"raw = {self.raw!r}, trailing = {self.trailing!r}" 1h

124 ) 

125 return super().__str__() 1o

126 

127 def __repr__(self) -> str: # pragma: no cover [debug] 

128 """""" # noqa: D419 

129 return ( 

130 f"{self.__class__.__name__}" 

131 f"(raw={self.raw!r}, trailing={self.trailing!r})" 

132 ) 

133 

134 

135class SSHAgentFailedError(RuntimeError): 

136 """The SSH agent failed to complete the requested operation.""" 

137 

138 def __str__(self) -> str: 

139 # TODO(the-13th-letter): Rewrite using structural pattern matching. 

140 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

141 if self.args == ( # pragma: no branch 1rhkj

142 _types.SSH_AGENT.FAILURE, 

143 b"", 

144 ): 

145 return "The SSH agent failed to complete the request" 1rhj

146 elif self.args[1]: # noqa: RET505 # pragma: no cover [failsafe] 1k

147 code = self.args[0] 1k

148 msg = self.args[1] 1k

149 return f"[Code {code:d}] {msg!r}" 1k

150 else: # pragma: no cover [failsafe] 

151 return repr(self) 

152 

153 def __repr__(self) -> str: # pragma: no cover [debug] 

154 """""" # noqa: D419 

155 return f"{self.__class__.__name__}{self.args!r}" 

156 

157 

158class SSHAgentClient: 

159 """A bare-bones SSH agent client supporting signing and key listing. 

160 

161 The main use case is requesting the agent sign some data, after 

162 checking that the necessary key is already loaded. 

163 

164 The main fleshed out methods are [`list_keys`][] and [`sign`][], 

165 which implement the [`REQUEST_IDENTITIES`] 

166 [_types.SSH_AGENTC.REQUEST_IDENTITIES] and [`SIGN_REQUEST`] 

167 [_types.SSH_AGENTC.SIGN_REQUEST] requests. If you *really* wanted 

168 to, there is enough infrastructure in place to issue other requests 

169 as defined in the protocol---it's merely the wrapper functions and 

170 the protocol numbers table that are missing. 

171 

172 """ 

173 

174 _connection: _types.SSHAgentSocket 

175 SOCKET_PROVIDERS: ClassVar = ("native",) 

176 """ 

177 The default list of SSH agent socket providers. 

178 """ 

179 

180 def __init__( # noqa: C901, PLR0912 

181 self, 

182 /, 

183 *, 

184 socket: _types.SSHAgentSocket | Sequence[str] | None = None, 

185 ) -> None: 

186 """Initialize the client. 

187 

188 Args: 

189 socket: 

190 An optional socket-like object, already connected to the 

191 SSH agent, or a list of names of socket providers to try. 

192 If not given, we query platform-specific default 

193 addresses, if possible. 

194 

195 Raises: 

196 derivepassphrase.ssh_agent.socketprovider.NoSuchProviderError: 

197 The list of socket provider names contained an invalid 

198 entry. 

199 KeyError: 

200 An expected configuration entry or an expected environment 

201 variable is missing. 

202 NotImplementedError: 

203 The named socket provider is not functional or not 

204 applicable to this `derivepassphrase` installation, e.g. 

205 because it is generally not implemented yet, or it requires 

206 a specific operating system, or specific system 

207 functionality that is not provided by all Python versions, 

208 or external packages that are unavailable on this 

209 installation. 

210 

211 This error may be raised multiple times, as an exception 

212 group. 

213 OSError: 

214 There was an error setting up a socket connection to the 

215 agent. 

216 

217 """ # noqa: DOC501 

218 import socket as _socket # noqa: PLC0415 1asqpdcntvCemwxurofAUBLhgkjl

219 

220 from derivepassphrase.ssh_agent import socketprovider # noqa: PLC0415 1asqpdcntvCemwxurofAUBLhgkjl

221 

222 # On POSIX, the predominant type of communication channel is the 

223 # UNIX domain socket, which in Python is represented as 

224 # a `socket.socket` object. This is (as of January 2026) 

225 # unsupported on The Annoying OS: Only in Windows 10 (on some 

226 # later builds at least) did Microsoft ship a partial 

227 # implementation of named UNIX domain sockets in stream mode, 

228 # but because of the limited scope Python does not provide 

229 # bindings to this functionality. (The Annoying OS has other 

230 # predominant communication channels, but these are not 

231 # represented as `socket.socket` objects.) 

232 # 

233 # Thus, for practical purposes, using a `socket.socket` object 

234 # to interface a running SSH agent is not a valid code path on 

235 # The Annoying OS. The code path should therefore be marked as 

236 # such. 

237 # 

238 # https://devblogs.microsoft.com/commandline/af_unix-comes-to-windows/ 

239 # https://github.com/microsoft/WSL/issues/4240 

240 # https://github.com/python/cpython/issues/77589 

241 if isinstance( 1asqpdcntvCemwxurofAUBLhgkjl

242 socket, _socket.socket 

243 ): # pragma: unless posix no cover [external] 

244 self._connection = socket 1dL

245 # Test whether the socket is connected. 

246 self._connection.getpeername() 1dL

247 elif isinstance(socket, str): 1asqpdcntvCemwxurofAUBLhgkjl

248 self._connection = socketprovider.SocketProvider.resolve(socket)() 1U

249 elif socket is None or isinstance(socket, Sequence): 1asqpdcntvCemwxurofABLhgkjl

250 if not socket: 1asqpdcntvCemwxurofABhgkjl

251 socket = self.SOCKET_PROVIDERS 1aspdcntemwxurofAhgkjl

252 assert isinstance(socket, Sequence) # for the type checker 1asqpdcntvCemwxurofABhgkjl

253 excs: list[NotImplementedError] = [] 1asqpdcntvCemwxurofABhgkjl

254 providers: list[_types.SSHAgentSocketProvider] = [] 1asqpdcntvCemwxurofABhgkjl

255 for candidate in socket: 1asqpdcntvCemwxurofABhgkjl

256 try: 1asqpdcntvCemwxurofABhgkjl

257 provider = socketprovider.SocketProvider.resolve(candidate) 1asqpdcntvCemwxurofABhgkjl

258 except NotImplementedError as exc: 1CB

259 excs.append(exc) 1B

260 continue 1B

261 else: 

262 providers.append(provider) 1asqpdcntvemwxurofAhgkjl

263 for provider in providers: 1asqpdcntvemwxurofABhgkjl

264 try: 1asqpdcntvemwxurofAhgkjl

265 self._connection = provider() 1asqpdcntvemwxurofAhgkjl

266 except NotImplementedError as exc: 1asqpcwxA

267 excs.append(exc) 1sqpc

268 continue 1sqpc

269 else: 

270 break 1aqpdcntvemurofhgkjl

271 else: 

272 msg = "No supported SSH agent socket provider found." 1sqpcB

273 raise ( 1sqpcB

274 ExceptionGroup(msg, excs) 

275 if excs 

276 else NotImplementedError(msg) 

277 ) 

278 elif isinstance(socket, _types.SSHAgentSocket): 1aL

279 self._connection = socket 1aL

280 else: # pragma: no cover [failsafe] 

281 assert_type(socket, Never) 

282 msg = f"invalid socket object: {socket!r}" 

283 raise TypeError(msg) 

284 

285 def __enter__(self) -> Self: 

286 """Close socket connection upon context manager completion. 

287 

288 Returns: 

289 Self. 

290 

291 """ 

292 self._connection.__enter__() 1aqpdcntvemurofhgkj

293 return self 1aqpdcntvemurofhgkj

294 

295 def __exit__( 

296 self, 

297 exc_type: type[BaseException] | None, 

298 exc_val: BaseException | None, 

299 exc_tb: TracebackType | None, 

300 ) -> bool: 

301 """Close socket connection upon context manager completion. 

302 

303 Args: 

304 exc_type: An optional exception type. 

305 exc_val: An optional exception value. 

306 exc_tb: An optional exception traceback. 

307 

308 Returns: 

309 True if the exception was handled, false if it should 

310 propagate. 

311 

312 """ 

313 return bool( 1aqpdcntvemurofhgkj

314 self._connection.__exit__(exc_type, exc_val, exc_tb) # type: ignore[func-returns-value] 

315 ) 

316 

317 @staticmethod 

318 def uint32(num: int, /) -> bytes: 

319 r"""Format the number as a `uint32`, as per the agent protocol. 

320 

321 Args: 

322 num: A number. 

323 

324 Returns: 

325 The number in SSH agent wire protocol format, i.e. as 

326 a 32-bit big endian number. 

327 

328 Raises: 

329 OverflowError: 

330 As per [`int.to_bytes`][]. 

331 

332 Examples: 

333 >>> SSHAgentClient.uint32(16777216) 

334 b'\x01\x00\x00\x00' 

335 

336 """ 

337 return int.to_bytes(num, 4, "big", signed=False) 1aMEFGHNDOIdcefbigkjPQylWXYZz

338 

339 @classmethod 

340 def string(cls, payload: Buffer, /) -> bytes: 

341 r"""Format the payload as an SSH string, as per the agent protocol. 

342 

343 Args: 

344 payload: A bytes-like object. 

345 

346 Returns: 

347 The payload, framed in the SSH agent wire protocol format, 

348 as a bytes object. 

349 

350 Examples: 

351 >>> SSHAgentClient.string(b"ssh-rsa") 

352 b'\x00\x00\x00\x07ssh-rsa' 

353 

354 """ # noqa: DOC501 

355 try: 1aMEFGHNDOIdcefbigkjPVQylz

356 payload = memoryview(payload) 1aMEFGHNDOIdcefbigkjPVQylz

357 except TypeError as e: 1V

358 msg = "invalid payload type" 1V

359 raise TypeError(msg) from e 1V

360 ret = bytearray() 1aMEFGHNDOIdcefbigkjPQylz

361 ret.extend(cls.uint32(len(payload))) 1aMEFGHNDOIdcefbigkjPQylz

362 ret.extend(payload) 1aMEFGHNDOIdcefbigkjPQylz

363 return bytes(ret) 1aMEFGHNDOIdcefbigkjPQylz

364 

365 @classmethod 

366 def unstring(cls, bytestring: Buffer, /) -> bytes: 

367 r"""Unpack an SSH string. 

368 

369 Args: 

370 bytestring: A framed bytes-like object. 

371 

372 Returns: 

373 The payload, as a bytes object. 

374 

375 Raises: 

376 ValueError: 

377 The byte string is not an SSH string. 

378 

379 Examples: 

380 >>> SSHAgentClient.unstring(b"\x00\x00\x00\x07ssh-rsa") 

381 b'ssh-rsa' 

382 >>> SSHAgentClient.unstring(SSHAgentClient.string(b"ssh-ed25519")) 

383 b'ssh-ed25519' 

384 

385 """ 

386 bytestring = memoryview(bytestring) 1aembyKJz

387 n = len(bytestring) 1aembyKJz

388 msg = "malformed SSH byte string" 1aembyKJz

389 if n < HEAD_LEN or n != HEAD_LEN + int.from_bytes( 1aembyKJz

390 bytestring[:HEAD_LEN], "big", signed=False 

391 ): 

392 raise ValueError(msg) 1J

393 return bytes(bytestring[HEAD_LEN:]) 1aembyKz

394 

395 @classmethod 

396 def unstring_prefix(cls, bytestring: Buffer, /) -> tuple[bytes, bytes]: 

397 r"""Unpack an SSH string at the beginning of the byte string. 

398 

399 Args: 

400 bytestring: 

401 A bytes-like object, beginning with a framed/SSH byte 

402 string. 

403 

404 Returns: 

405 A 2-tuple `(a, b)`, where `a` is the unframed byte 

406 string/payload at the beginning of input byte string, and 

407 `b` is the remainder of the input byte string. 

408 

409 Raises: 

410 ValueError: 

411 The byte string does not begin with an SSH string. 

412 

413 Examples: 

414 >>> SSHAgentClient.unstring_prefix( 

415 ... b"\x00\x00\x00\x07ssh-rsa____trailing data" 

416 ... ) 

417 (b'ssh-rsa', b'____trailing data') 

418 >>> SSHAgentClient.unstring_prefix( 

419 ... SSHAgentClient.string(b"ssh-ed25519") 

420 ... ) 

421 (b'ssh-ed25519', b'') 

422 

423 """ 

424 bytestring = memoryview(bytestring).toreadonly() 1aEFGHDIdnemRSTfbigyKJz

425 n = len(bytestring) 1aEFGHDIdnemRSTfbigyKJz

426 msg = "malformed SSH byte string" 1aEFGHDIdnemRSTfbigyKJz

427 if n < HEAD_LEN: 1aEFGHDIdnemRSTfbigyKJz

428 raise ValueError(msg) 1DgJ

429 m = int.from_bytes(bytestring[:HEAD_LEN], "big", signed=False) 1aEFGHDIdnemRSTfbigyKJz

430 if m + HEAD_LEN > n: 1aEFGHDIdnemRSTfbigyKJz

431 raise ValueError(msg) 1gJ

432 return ( 1aEFGHDIdnemRSTfbigyKJz

433 bytes(bytestring[HEAD_LEN : m + HEAD_LEN]), 

434 bytes(bytestring[m + HEAD_LEN :]), 

435 ) 

436 

437 @classmethod 

438 @contextlib.contextmanager 

439 def ensure_agent_subcontext( 

440 cls, 

441 conn: SSHAgentClient 

442 | _types.SSHAgentSocket 

443 | Sequence[str] 

444 | None = None, 

445 ) -> Generator[SSHAgentClient, None, None]: 

446 """Return an SSH agent client subcontext. 

447 

448 If necessary, construct an SSH agent client first using the 

449 connection hint. 

450 

451 Args: 

452 conn: 

453 If an existing SSH agent client, then enter a context 

454 within this client's scope. After exiting the context, 

455 the client persists, including its socket. 

456 

457 If a socket, then construct a client using this socket, 

458 then enter a context within this client's scope. After 

459 exiting the context, the client is destroyed and the 

460 socket is closed. 

461 

462 If `None`, or a list of names of socket providers, then 

463 construct a client according to those connection hints 

464 ("auto-discovery"), and enter a context within this 

465 client's scope. After exiting the context, both the 

466 client and its socket are destroyed. 

467 

468 Yields: 

469 When entering this context, return the SSH agent client. 

470 

471 Raises: 

472 derivepassphrase.ssh_agent.socketprovider.NoSuchProviderError: 

473 As per [`__init__`][SSHAgentClient]. 

474 KeyError: 

475 As per [`__init__`][SSHAgentClient], including the 

476 multiple raise as an exception group. 

477 NotImplementedError: 

478 As per [`__init__`][SSHAgentClient]. 

479 OSError: 

480 As per [`__init__`][SSHAgentClient]. 

481 

482 """ # noqa: DOC501 

483 # TODO(the-13th-letter): Rewrite using structural pattern matching. 

484 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

485 if isinstance(conn, SSHAgentClient): 1asqpdcntvCemwxurofbihgj

486 with contextlib.nullcontext(): 1dcembi

487 yield conn 1dcembi

488 elif ( 1asqpdcntvCemwxurofhgj

489 isinstance(conn, (_types.SSHAgentSocket, str, Sequence)) 

490 or conn is None 

491 ): 

492 with SSHAgentClient(socket=conn) as client: 1asqpdcntvCemwxurofhgj

493 yield client 1aqpdcntvemurofhgj

494 else: # pragma: no cover [failsafe] 

495 assert_type(conn, Never) 

496 msg = f"invalid connection hint: {conn!r}" 

497 raise TypeError(msg) 

498 

499 def _agent_is_pageant(self) -> bool: 

500 """Return True if we are connected to Pageant. 

501 

502 Warning: 

503 This is a heuristic, not a verified query or computation. 

504 

505 """ 

506 return ( 1debi

507 b"list-extended@putty.projects.tartarus.org" 

508 in self.query_extensions() 

509 ) 

510 

511 def has_deterministic_dsa_signatures(self) -> bool: 

512 """Check whether the agent returns deterministic DSA signatures. 

513 

514 This includes ECDSA signatures. 

515 

516 Generally, this means that the SSH agent implements [RFC 6979][] 

517 or a similar system. 

518 

519 [RFC 6979]: https://www.rfc-editor.org/rfc/rfc6979 

520 

521 Returns: 

522 True if a known agent was detected where signatures are 

523 deterministic for all DSA key types, false otherwise. 

524 

525 Note: Known agents with deterministic signatures 

526 | agent | detected via | 

527 |:----------------|:--------------------------------------------------------------| 

528 | Pageant (PuTTY) | `list-extended@putty.projects.tartarus.org` extension request | 

529 

530 """ # noqa: E501 

531 known_good_agents = { 1debi

532 "Pageant": self._agent_is_pageant, 

533 } 

534 return any( # pragma: no branch 1debi

535 v() for v in known_good_agents.values() 

536 ) 

537 

538 def _send_request_message( 

539 self, code: int | _types.SSH_AGENTC, payload: Buffer, / 

540 ) -> None: 

541 """Prepare and send a generic request to the SSH agent. 

542 

543 We take care of packing, framing and sending the request. 

544 

545 Args: 

546 code: 

547 See [request][]. 

548 payload: 

549 See [request][]. 

550 

551 """ 

552 payload = memoryview(payload) 1adcebikl

553 request_message = bytearray([code]) 1adcebikl

554 request_message.extend(payload) 1adcebikl

555 self._connection.sendall(self.string(request_message)) 1adcebikl

556 

557 def _get_response_pair(self) -> tuple[int, bytes]: 

558 """Read the response for a generic request to the SSH agent. 

559 

560 Returns: 

561 A 2-tuple consisting of the response code and the payload, 

562 with all wire framing removed. 

563 

564 Raises: 

565 EOFError: 

566 The response from the SSH agent is truncated or missing. 

567 OSError: 

568 There was a communication error with the SSH agent. 

569 

570 """ 

571 chunk = self._connection.recv(HEAD_LEN) 1adcebikl

572 if len(chunk) < HEAD_LEN: 1adcebikl

573 msg = "cannot read response length" 1l

574 raise EOFError(msg) 1l

575 response_length = int.from_bytes(chunk, "big", signed=False) 1adcebikl

576 response = self._connection.recv(response_length) 1adcebikl

577 if len(response) < response_length: 1adcebikl

578 msg = "truncated response from SSH agent" 1l

579 raise EOFError(msg) 1l

580 return response[0], response[1:] 1adcebik

581 

582 @overload 

583 def request( 

584 self, 

585 code: int | _types.SSH_AGENTC, 

586 payload: Buffer, 

587 /, 

588 *, 

589 response_code: None = None, 

590 ) -> tuple[int, bytes]: ... 

591 

592 @overload 

593 def request( 

594 self, 

595 code: int | _types.SSH_AGENTC, 

596 payload: Buffer, 

597 /, 

598 *, 

599 response_code: Iterable[_types.SSH_AGENT | int] = frozenset({ 

600 _types.SSH_AGENT.SUCCESS 

601 }), 

602 ) -> bytes: ... 

603 

604 @overload 

605 def request( 

606 self, 

607 code: int | _types.SSH_AGENTC, 

608 payload: Buffer, 

609 /, 

610 *, 

611 response_code: _types.SSH_AGENT | int = _types.SSH_AGENT.SUCCESS, 

612 ) -> bytes: ... 

613 

614 def request( 

615 self, 

616 code: int | _types.SSH_AGENTC, 

617 payload: Buffer, 

618 /, 

619 *, 

620 response_code: ( 

621 Iterable[_types.SSH_AGENT | int] | _types.SSH_AGENT | int | None 

622 ) = None, 

623 ) -> tuple[int, bytes] | bytes: 

624 """Issue a generic request to the SSH agent. 

625 

626 Args: 

627 code: 

628 The request code. See the SSH agent protocol for 

629 protocol numbers to use here (and which protocol numbers 

630 to expect in a response). 

631 payload: 

632 A bytes-like object containing the payload, or 

633 "contents", of the request. Request-specific. 

634 

635 It is our responsibility to add any necessary wire 

636 framing around the request code and the payload, 

637 not the caller's. 

638 response_code: 

639 An optional response code, or a set of response codes, 

640 that we expect. If given, and the actual response code 

641 does not match, raise an error. 

642 

643 Returns: 

644 A 2-tuple consisting of the response code and the payload, 

645 with all wire framing removed. 

646 

647 If a response code was passed, then only return the payload. 

648 

649 Raises: 

650 EOFError: 

651 The response from the SSH agent is truncated or missing. 

652 OSError: 

653 There was a communication error with the SSH agent. 

654 SSHAgentFailedError: 

655 We expected specific response codes, but did not receive 

656 any of them. 

657 

658 """ 

659 if isinstance(response_code, int): # pragma: no branch 1adcefbihgkjl

660 response_code = frozenset({response_code}) 1acbhkj

661 self._send_request_message(code, payload) 1adcefbihgkjl

662 actual_code, response = self._get_response_pair() 1adcefbihgkjl

663 if not response_code: # pragma: no cover [failsafe] 1adcefbihgkj

664 return actual_code, response 1fbi

665 if actual_code not in response_code: 1adcefbihgkj

666 raise SSHAgentFailedError(actual_code, response) 1aefihkj

667 return response 1adcfbihg

668 

669 def list_keys(self) -> Sequence[_types.SSHKeyCommentPair]: 

670 """Request a list of keys known to the SSH agent. 

671 

672 Returns: 

673 A read-only sequence of key/comment pairs. 

674 

675 Raises: 

676 EOFError: 

677 The response from the SSH agent is truncated or missing. 

678 OSError: 

679 There was a communication error with the SSH agent. 

680 TrailingDataError: 

681 The response from the SSH agent is too long. 

682 SSHAgentFailedError: 

683 The agent failed to complete the request. 

684 

685 """ 

686 response = self.request( 1acbh

687 _types.SSH_AGENTC.REQUEST_IDENTITIES.value, 

688 b"", 

689 response_code=_types.SSH_AGENT.IDENTITIES_ANSWER, 

690 ) 

691 response_stream = collections.deque(response) 1acbh

692 

693 def shift(num: int) -> bytes: 1acbh

694 buf: collections.deque[int] = collections.deque() 1acbh

695 for _ in range(num): 1acbh

696 try: 1acbh

697 val = response_stream.popleft() 1acbh

698 except IndexError: 1h

699 response_stream.extendleft(reversed(buf)) 1h

700 msg = "truncated response from SSH agent" 1h

701 raise EOFError(msg) from None 1h

702 buf.append(val) 1acbh

703 return bytes(buf) 1acbh

704 

705 key_count = int.from_bytes(shift(4), "big") 1acbh

706 keys: collections.deque[_types.SSHKeyCommentPair] 

707 keys = collections.deque() 1acbh

708 for _ in range(key_count): 1acbh

709 key_size = int.from_bytes(shift(4), "big") 1acbh

710 key = shift(key_size) 1acb

711 comment_size = int.from_bytes(shift(4), "big") 1acb

712 comment = shift(comment_size) 1acb

713 # Both `key` and `comment` are not wrapped as SSH strings. 

714 keys.append(_types.SSHKeyCommentPair(key, comment)) 1acb

715 if response_stream: 1acbh

716 raise TrailingDataError( 1h

717 raw=response, trailing=bytes(response_stream) 

718 ) 

719 return keys 1acb

720 

721 def sign( 

722 self, 

723 /, 

724 key: Buffer, 

725 payload: Buffer, 

726 *, 

727 flags: int = 0, 

728 check_if_key_loaded: bool = False, 

729 ) -> bytes: 

730 """Request the SSH agent sign the payload with the key. 

731 

732 Args: 

733 key: 

734 The public SSH key to sign the payload with, in the same 

735 format as returned by, e.g., the [`list_keys`][] method. 

736 The corresponding private key must have previously been 

737 loaded into the agent to successfully issue a signature. 

738 payload: 

739 A byte string of data to sign. 

740 flags: 

741 Optional flags for the signing request. Currently 

742 passed on as-is to the agent. In real-world usage, this 

743 could be used, e.g., to request more modern hash 

744 algorithms when signing with RSA keys. (No such 

745 real-world usage is currently implemented.) 

746 check_if_key_loaded: 

747 If true, check beforehand (via [`list_keys`][]) if the 

748 corresponding key has been loaded into the agent. 

749 

750 Returns: 

751 The binary signature of the payload under the given key. 

752 

753 Raises: 

754 EOFError: 

755 The response from the SSH agent is truncated or missing. 

756 OSError: 

757 There was a communication error with the SSH agent. 

758 TrailingDataError: 

759 The response from the SSH agent is too long. 

760 SSHAgentFailedError: 

761 The agent failed to complete the request. 

762 KeyError: 

763 `check_if_key_loaded` is true, and the `key` was not 

764 loaded into the agent. 

765 

766 """ 

767 key = memoryview(key) 1bj

768 payload = memoryview(payload) 1bj

769 if check_if_key_loaded: 1bj

770 loaded_keys = frozenset({pair.key for pair in self.list_keys()}) 1j

771 if bytes(key) not in loaded_keys: 1j

772 msg = "target SSH key not loaded into agent" 1j

773 raise KeyError(msg) 1j

774 request_data = bytearray(self.string(key)) 1bj

775 request_data.extend(self.string(payload)) 1bj

776 request_data.extend(self.uint32(flags)) 1bj

777 return bytes( 1bj

778 self.unstring( 

779 self.request( 

780 _types.SSH_AGENTC.SIGN_REQUEST, 

781 request_data, 

782 response_code=_types.SSH_AGENT.SIGN_RESPONSE, 

783 ) 

784 ) 

785 ) 

786 

787 def query_extensions(self) -> AbstractSet[bytes]: 

788 """Request a listing of extensions supported by the SSH agent. 

789 

790 Returns: 

791 A read-only set of extension names the SSH agent says it 

792 supports. 

793 

794 Raises: 

795 EOFError: 

796 The response from the SSH agent is truncated or missing. 

797 OSError: 

798 There was a communication error with the SSH agent. 

799 RuntimeError: 

800 The response from the SSH agent is malformed. 

801 

802 Note: 

803 The set of supported extensions is queried via the `query` 

804 extension request. If the agent does not support the query 

805 extension request, or extension requests in general, then an 

806 empty set is returned. This does not however imply that the 

807 agent doesn't support *any* extension request... merely that 

808 it doesn't support extension autodiscovery. 

809 

810 """ 

811 try: 1defbig

812 # The draft-miller-ssh-agent-14 (2024-04-15) version of the 

813 # SSH agent protocol specification introduced the 

814 # SSH_AGENT_EXTENSION_RESPONSE protocol message, and 

815 # suggested the use of this message in the response for 

816 # SSH_AGENTC_EXTENSION requests. Older versions of the spec 

817 # used the SSH_AGENT_SUCCESS response, and even the newer 

818 # spec versions contain wording that allows such usage 

819 # across all SSH_AGENTC_EXTENSION requests. Both response 

820 # codes are used in the wild (`ssh-agent`/OpenSSH 10.3, 

821 # `pageant`/PuTTY 0.83), so both of them need to be 

822 # supported for compatibility reasons. 

823 response_data = self.request( 1defbig

824 _types.SSH_AGENTC.EXTENSION, 

825 self.string(b"query"), 

826 response_code={ 

827 _types.SSH_AGENT.EXTENSION_RESPONSE, 

828 _types.SSH_AGENT.SUCCESS, 

829 }, 

830 ) 

831 _check_for_extra_empty_success_response_after_query_extension_request( 1dfbig

832 self, response_data 

833 ) 

834 except SSHAgentFailedError: 1efi

835 # Cannot query extension support. Assume no extensions. 

836 # This isn't necessarily true, e.g. for OpenSSH's ssh-agent 

837 # 10.2 and older. 

838 return frozenset() 1efi

839 extensions: set[bytes] = set() 1dfbig

840 msg = "Malformed response from SSH agent" 1dfbig

841 msg2 = "Extension response message does not match request" 1dfbig

842 try: 1dfbig

843 query, response_data = self.unstring_prefix(response_data) 1dfbig

844 except ValueError as e: 1g

845 raise RuntimeError(msg) from e 1g

846 if bytes(query) != b"query": 1dfbig

847 raise RuntimeError(msg2) 1g

848 while response_data: 1dfbig

849 try: 1dfbig

850 extension, response_data = self.unstring_prefix(response_data) 1dfbig

851 except ValueError as e: 1g

852 raise RuntimeError(msg) from e 1g

853 else: 

854 extensions.add(bytes(extension)) 1dfbig

855 return frozenset(extensions) 1dfbi