Marco Ricci commited on 2026-09-14 16:33:20
Zeige 3 geänderte Dateien mit 1235 Einfügungen und 4 Löschungen.
| ... | ... |
@@ -19,11 +19,29 @@ class SSH_AGENTC(int, enum.Enum): # noqa: N801 |
| 19 | 19 |
SIGN_REQUEST (int): |
| 20 | 20 |
Sign data. Expecting [`SSH_AGENT.SIGN_RESPONSE`][]. |
| 21 | 21 |
ADD_IDENTITY (int): |
| 22 |
- Add an (SSH2) identity. |
|
| 22 |
+ Add an (SSH2) identity to the agent. |
|
| 23 | 23 |
REMOVE_IDENTITY (int): |
| 24 |
- Remove an (SSH2) identity. |
|
| 24 |
+ Remove an (SSH2) identity from the agent. |
|
| 25 |
+ REMOVE_ALL_IDENTITIES (int): |
|
| 26 |
+ Remove all identities held by the agent. |
|
| 27 |
+ ADD_SMARTCARD_KEY (int): |
|
| 28 |
+ Add an (SSH2) identity on a hardware token to the agent's |
|
| 29 |
+ list of keys. (The key does not leave the hardware token.) |
|
| 30 |
+ REMOVE_SMARTCARD_KEY (int): |
|
| 31 |
+ Remove an (SSH2) identity on a hardware token from the |
|
| 32 |
+ agent's list of keys. (The key is not removed from the |
|
| 33 |
+ hardware token.) |
|
| 34 |
+ LOCK (int): |
|
| 35 |
+ Lock the agent with a passphrase, preventing adding and |
|
| 36 |
+ signing operations until unlocked. |
|
| 37 |
+ UNLOCK (int): |
|
| 38 |
+ Attempt to unlock the agent with the given passphrase. |
|
| 25 | 39 |
ADD_ID_CONSTRAINED (int): |
| 26 | 40 |
Add an (SSH2) identity, including key constraints. |
| 41 |
+ ADD_SMARTCARD_KEY_CONSTRAINED (int): |
|
| 42 |
+ Add an (SSH2) identity on a hardware token, including key |
|
| 43 |
+ constraints, to the agent's list of keys. (The key does not |
|
| 44 |
+ leave the hardware token.) |
|
| 27 | 45 |
EXTENSION (int): |
| 28 | 46 |
Issue a named request that isn't part of the core agent |
| 29 | 47 |
protocol. Expecting [`SSH_AGENT.EXTENSION_RESPONSE`][] or |
| ... | ... |
@@ -40,8 +58,20 @@ class SSH_AGENTC(int, enum.Enum): # noqa: N801 |
| 40 | 58 |
"""""" |
| 41 | 59 |
REMOVE_IDENTITY = 18 |
| 42 | 60 |
"""""" |
| 61 |
+ REMOVE_ALL_IDENTITIES = 19 |
|
| 62 |
+ """""" |
|
| 63 |
+ ADD_SMARTCARD_KEY = 20 |
|
| 64 |
+ """""" |
|
| 65 |
+ REMOVE_SMARTCARD_KEY = 21 |
|
| 66 |
+ """""" |
|
| 67 |
+ LOCK = 22 |
|
| 68 |
+ """""" |
|
| 69 |
+ UNLOCK = 23 |
|
| 70 |
+ """""" |
|
| 43 | 71 |
ADD_ID_CONSTRAINED = 25 |
| 44 | 72 |
"""""" |
| 73 |
+ ADD_SMARTCARD_KEY_CONSTRAINED = 26 |
|
| 74 |
+ """""" |
|
| 45 | 75 |
EXTENSION = 27 |
| 46 | 76 |
"""""" |
| 47 | 77 |
|
| ... | ... |
@@ -0,0 +1,807 @@ |
| 1 |
+# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info> |
|
| 2 |
+# |
|
| 3 |
+# SPDX-License-Identifier: Zlib |
|
| 4 |
+ |
|
| 5 |
+"""Events from the SSH agent protocol. |
|
| 6 |
+ |
|
| 7 |
+As per the Sans-I/O paradigm, the SSH agent protocol stack translates |
|
| 8 |
+protocol traffic into a bidirectional stream of events. |
|
| 9 |
+ |
|
| 10 |
+""" |
|
| 11 |
+ |
|
| 12 |
+from __future__ import annotations |
|
| 13 |
+ |
|
| 14 |
+import abc |
|
| 15 |
+import dataclasses |
|
| 16 |
+import enum |
|
| 17 |
+from typing import TYPE_CHECKING, Union |
|
| 18 |
+ |
|
| 19 |
+from fakesshagent import _types |
|
| 20 |
+ |
|
| 21 |
+if TYPE_CHECKING: |
|
| 22 |
+ from collections.abc import Set as AbstractSet |
|
| 23 |
+ from typing import ClassVar |
|
| 24 |
+ |
|
| 25 |
+ from typing_extensions import Literal |
|
| 26 |
+ |
|
| 27 |
+EVENT = dataclasses.dataclass(frozen=True) |
|
| 28 |
+ |
|
| 29 |
+ |
|
| 30 |
+@EVENT |
|
| 31 |
+class AbstractServerEvent(abc.ABC): |
|
| 32 |
+ """Base class for events the protocol stack handles internally. |
|
| 33 |
+ |
|
| 34 |
+ Includes parsed client requests, server responses and automatic |
|
| 35 |
+ server actions alike. |
|
| 36 |
+ |
|
| 37 |
+ """ |
|
| 38 |
+ |
|
| 39 |
+ @property |
|
| 40 |
+ @classmethod |
|
| 41 |
+ @abc.abstractmethod |
|
| 42 |
+ def _tag(cls) -> str: |
|
| 43 |
+ raise NotImplementedError |
|
| 44 |
+ |
|
| 45 |
+ |
|
| 46 |
+@EVENT |
|
| 47 |
+class AbstractClientRequest(AbstractServerEvent): |
|
| 48 |
+ """Base class for all SSH agent protocol requests. |
|
| 49 |
+ |
|
| 50 |
+ These are the internal representation the protocol stack parses the |
|
| 51 |
+ network input into, to be further handled within the protocol stack. |
|
| 52 |
+ Agent implementations normally do not come into contact with these. |
|
| 53 |
+ |
|
| 54 |
+ """ |
|
| 55 |
+ |
|
| 56 |
+ _code: ClassVar[int] |
|
| 57 |
+ |
|
| 58 |
+ |
|
| 59 |
+@EVENT |
|
| 60 |
+class AbstractServerResponse(AbstractServerEvent): |
|
| 61 |
+ """Base class for immediate protocol responses.""" |
|
| 62 |
+ |
|
| 63 |
+ _code: ClassVar[int] |
|
| 64 |
+ |
|
| 65 |
+ |
|
| 66 |
+@EVENT |
|
| 67 |
+class AbstractServerAction(AbstractServerEvent): |
|
| 68 |
+ """An automatic action that the protocol stack took on the SSH agent's behalf.""" # noqa: E501 |
|
| 69 |
+ |
|
| 70 |
+ |
|
| 71 |
+@EVENT |
|
| 72 |
+class AbstractProtocolStackFeedback(abc.ABC): |
|
| 73 |
+ """Base class for protocol stack feedback to the agent implementation. |
|
| 74 |
+ |
|
| 75 |
+ Some of the feedback is purely informational. Other feedback |
|
| 76 |
+ constitutes (remote) procedure calls on the agent implementation, in |
|
| 77 |
+ the form of [agent actions][AgentAction]. |
|
| 78 |
+ |
|
| 79 |
+ """ |
|
| 80 |
+ |
|
| 81 |
+ client_initiated: bool |
|
| 82 |
+ |
|
| 83 |
+ @property |
|
| 84 |
+ @classmethod |
|
| 85 |
+ @abc.abstractmethod |
|
| 86 |
+ def _tag(cls) -> str: |
|
| 87 |
+ raise NotImplementedError |
|
| 88 |
+ |
|
| 89 |
+ |
|
| 90 |
+@EVENT |
|
| 91 |
+class AbstractAgentAction(AbstractProtocolStackFeedback): |
|
| 92 |
+ """Base class for actions to be taken by the SSH agent implementation. |
|
| 93 |
+ |
|
| 94 |
+ These are events the protocol stack expects the SSH agent |
|
| 95 |
+ implementation (built on top of the protocol stack) to adequately |
|
| 96 |
+ react to, by taking the respective action. |
|
| 97 |
+ |
|
| 98 |
+ Most actions can only be initiated by the agent client, i.e., they |
|
| 99 |
+ are a direct result of the protocol messages received from the |
|
| 100 |
+ client (and deserialised and interpreted by the protocol stack). A |
|
| 101 |
+ few select actions are sometimes or always initiated by the protocol |
|
| 102 |
+ stack instead, usually because of the stack's state management. |
|
| 103 |
+ |
|
| 104 |
+ """ |
|
| 105 |
+ |
|
| 106 |
+ client_initiated: bool |
|
| 107 |
+ |
|
| 108 |
+ |
|
| 109 |
+@EVENT |
|
| 110 |
+class AbstractAgentEvent(abc.ABC): |
|
| 111 |
+ """Base class for events the agent passes to the protocol stack. |
|
| 112 |
+ |
|
| 113 |
+ This includes [input from the client][ClientInput], |
|
| 114 |
+ [responses to agent actions][AgentReaction] and [other input |
|
| 115 |
+ from the agent][AgentInput]. |
|
| 116 |
+ |
|
| 117 |
+ """ |
|
| 118 |
+ |
|
| 119 |
+ @property |
|
| 120 |
+ @classmethod |
|
| 121 |
+ @abc.abstractmethod |
|
| 122 |
+ def _tag(cls) -> str: |
|
| 123 |
+ raise NotImplementedError |
|
| 124 |
+ |
|
| 125 |
+ |
|
| 126 |
+@EVENT |
|
| 127 |
+class AbstractClientInput(AbstractAgentEvent): |
|
| 128 |
+ """Base class for client-initiated input to the protocol stack.""" |
|
| 129 |
+ |
|
| 130 |
+ |
|
| 131 |
+@EVENT |
|
| 132 |
+class AbstractAgentReaction(AbstractAgentEvent): |
|
| 133 |
+ """Base class for events in response to agent actions. |
|
| 134 |
+ |
|
| 135 |
+ These communicate the results of [agent actions][AgentAction] which |
|
| 136 |
+ the protocol stack instructed the agent implementation to take. |
|
| 137 |
+ |
|
| 138 |
+ """ |
|
| 139 |
+ |
|
| 140 |
+ |
|
| 141 |
+@EVENT |
|
| 142 |
+class AbstractAgentInput(AbstractAgentEvent): |
|
| 143 |
+ """Base class for agent-initiated input to the protocol stack.""" |
|
| 144 |
+ |
|
| 145 |
+ |
|
| 146 |
+class SignRequestFlag(enum.IntFlag): |
|
| 147 |
+ """Flags for an `SSH_AGENTC_SIGN_REQUEST` request.""" |
|
| 148 |
+ |
|
| 149 |
+ RSA_SHA2_256 = 0x00000002 |
|
| 150 |
+ RSA_SHA2_512 = 0x00000004 |
|
| 151 |
+ |
|
| 152 |
+ |
|
| 153 |
+@EVENT |
|
| 154 |
+class AbstractKeyConstraint(abc.ABC): |
|
| 155 |
+ """Base class for all constraints on keys in the SSH agent protocol.""" |
|
| 156 |
+ |
|
| 157 |
+ _code: ClassVar[int] |
|
| 158 |
+ |
|
| 159 |
+ @property |
|
| 160 |
+ @classmethod |
|
| 161 |
+ @abc.abstractmethod |
|
| 162 |
+ def _tag(cls) -> str: |
|
| 163 |
+ raise NotImplementedError |
|
| 164 |
+ |
|
| 165 |
+ |
|
| 166 |
+@EVENT |
|
| 167 |
+class TimeoutConstraint(AbstractKeyConstraint): |
|
| 168 |
+ """The key is purged from the agent after a certain amount of time.""" |
|
| 169 |
+ |
|
| 170 |
+ _code: ClassVar[int] = 1 |
|
| 171 |
+ # pyrefly: ignore [bad-override] |
|
| 172 |
+ _tag: ClassVar[Literal["TimeoutConstraint"]] = "TimeoutConstraint" |
|
| 173 |
+ |
|
| 174 |
+ deadline: int |
|
| 175 |
+ |
|
| 176 |
+ |
|
| 177 |
+@EVENT |
|
| 178 |
+class ConfirmUseConstraint(AbstractKeyConstraint): |
|
| 179 |
+ """Use of the key must be confirmed by the user.""" |
|
| 180 |
+ |
|
| 181 |
+ _code: ClassVar[int] = 2 |
|
| 182 |
+ # pyrefly: ignore [bad-override] |
|
| 183 |
+ _tag: ClassVar[Literal["ConfirmUseConstraint"]] = "ConfirmUseConstraint" |
|
| 184 |
+ |
|
| 185 |
+ |
|
| 186 |
+@EVENT |
|
| 187 |
+class ExtensionConstraint(AbstractKeyConstraint): |
|
| 188 |
+ """A named extension key constraint.""" |
|
| 189 |
+ |
|
| 190 |
+ _code: ClassVar[int] = 255 |
|
| 191 |
+ # pyrefly: ignore [bad-override] |
|
| 192 |
+ _tag: ClassVar[Literal["ExtensionConstraint"]] = "ExtensionConstraint" |
|
| 193 |
+ |
|
| 194 |
+ extension_name: str |
|
| 195 |
+ payload: bytes |
|
| 196 |
+ |
|
| 197 |
+ |
|
| 198 |
+@EVENT |
|
| 199 |
+class BytesFromClient(AbstractClientInput): |
|
| 200 |
+ """The client sent some bytes to the agent. |
|
| 201 |
+ |
|
| 202 |
+ The protocol stack is tasked with parsing and interpreting those |
|
| 203 |
+ bytes, if possible. The bytes need not form a single protocol |
|
| 204 |
+ message, or even a complete one. |
|
| 205 |
+ |
|
| 206 |
+ """ |
|
| 207 |
+ |
|
| 208 |
+ payload: bytes |
|
| 209 |
+ |
|
| 210 |
+ |
|
| 211 |
+@EVENT |
|
| 212 |
+class BytesToClient(AbstractAgentAction): |
|
| 213 |
+ """The agent shall send some bytes to the client.""" |
|
| 214 |
+ |
|
| 215 |
+ # pyrefly: ignore [bad-override] |
|
| 216 |
+ _tag: ClassVar[Literal["BytesToClient"]] = "BytesToClient" |
|
| 217 |
+ |
|
| 218 |
+ payload: bytes |
|
| 219 |
+ |
|
| 220 |
+ |
|
| 221 |
+@EVENT |
|
| 222 |
+class BaseAddIdentityRequest(AbstractClientRequest): |
|
| 223 |
+ """Base class for `SSH_AGENTC_ADD_ID*` requests.""" |
|
| 224 |
+ |
|
| 225 |
+ |
|
| 226 |
+@EVENT |
|
| 227 |
+class AddIdentityUnconstrainedRequest(BaseAddIdentityRequest): |
|
| 228 |
+ """An `SSH_AGENTC_ADD_IDENTITY` request.""" |
|
| 229 |
+ |
|
| 230 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.ADD_IDENTITY |
|
| 231 |
+ # pyrefly: ignore [bad-override] |
|
| 232 |
+ _tag: ClassVar[Literal["AddIdentityUnconstrainedRequest"]] = ( |
|
| 233 |
+ "AddIdentityUnconstrainedRequest" |
|
| 234 |
+ ) |
|
| 235 |
+ |
|
| 236 |
+ key_type: str |
|
| 237 |
+ key_material: bytes |
|
| 238 |
+ comment: str | bytes |
|
| 239 |
+ |
|
| 240 |
+ |
|
| 241 |
+@EVENT |
|
| 242 |
+class AddIdentityConstrainedRequest(BaseAddIdentityRequest): |
|
| 243 |
+ """An `SSH_AGENTC_ADD_ID_CONSTRAINED` request.""" |
|
| 244 |
+ |
|
| 245 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.ADD_ID_CONSTRAINED |
|
| 246 |
+ # pyrefly: ignore [bad-override] |
|
| 247 |
+ _tag: ClassVar[Literal["AddIdentityConstrainedRequest"]] = ( |
|
| 248 |
+ "AddIdentityConstrainedRequest" |
|
| 249 |
+ ) |
|
| 250 |
+ |
|
| 251 |
+ key_type: str |
|
| 252 |
+ key_material: bytes |
|
| 253 |
+ comment: str | bytes |
|
| 254 |
+ constraints: AbstractSet[AbstractKeyConstraint] |
|
| 255 |
+ |
|
| 256 |
+ |
|
| 257 |
+@EVENT |
|
| 258 |
+class AddSmartcardKeyUnconstrainedRequest(BaseAddIdentityRequest): |
|
| 259 |
+ """An `SSH_AGENTC_ADD_SMARTCARD_KEY` request.""" |
|
| 260 |
+ |
|
| 261 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.ADD_SMARTCARD_KEY |
|
| 262 |
+ # pyrefly: ignore [bad-override] |
|
| 263 |
+ _tag: ClassVar[Literal["AddSmartcardKeyUnconstrainedRequest"]] = ( |
|
| 264 |
+ "AddSmartcardKeyUnconstrainedRequest" |
|
| 265 |
+ ) |
|
| 266 |
+ |
|
| 267 |
+ token_id: bytes |
|
| 268 |
+ pin: bytes |
|
| 269 |
+ |
|
| 270 |
+ |
|
| 271 |
+@EVENT |
|
| 272 |
+class AddSmartcardKeyConstrainedRequest(BaseAddIdentityRequest): |
|
| 273 |
+ """An `SSH_AGENTC_ADD_SMARTCARD_KEY_CONSTRAINED` request.""" |
|
| 274 |
+ |
|
| 275 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.ADD_SMARTCARD_KEY_CONSTRAINED |
|
| 276 |
+ # pyrefly: ignore [bad-override] |
|
| 277 |
+ _tag: ClassVar[Literal["AddSmartcardKeyConstrainedRequest"]] = ( |
|
| 278 |
+ "AddSmartcardKeyConstrainedRequest" |
|
| 279 |
+ ) |
|
| 280 |
+ |
|
| 281 |
+ token_id: bytes |
|
| 282 |
+ pin: bytes |
|
| 283 |
+ constraints: AbstractSet[AbstractKeyConstraint] |
|
| 284 |
+ |
|
| 285 |
+ |
|
| 286 |
+@EVENT |
|
| 287 |
+class BaseRemoveKeyMaterialRequest(AbstractClientRequest): |
|
| 288 |
+ """Base class for `SSH_AGENTC_REMOVE_*` requests.""" |
|
| 289 |
+ |
|
| 290 |
+ |
|
| 291 |
+@EVENT |
|
| 292 |
+class RemoveIdentityRequest(BaseRemoveKeyMaterialRequest): |
|
| 293 |
+ """An `SSH_AGENTC_REMOVE_IDENTITY` request.""" |
|
| 294 |
+ |
|
| 295 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.REMOVE_IDENTITY |
|
| 296 |
+ # pyrefly: ignore [bad-override] |
|
| 297 |
+ _tag: ClassVar[Literal["RemoveIdentityRequest"]] = "RemoveIdentityRequest" |
|
| 298 |
+ |
|
| 299 |
+ key_blob: bytes |
|
| 300 |
+ |
|
| 301 |
+ |
|
| 302 |
+@EVENT |
|
| 303 |
+class RemoveAllIdentitiesRequest(BaseRemoveKeyMaterialRequest): |
|
| 304 |
+ """An `SSH_AGENTC_REMOVE_ALL_IDENTITIES` request.""" |
|
| 305 |
+ |
|
| 306 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.REMOVE_ALL_IDENTITIES |
|
| 307 |
+ # pyrefly: ignore [bad-override] |
|
| 308 |
+ _tag: ClassVar[Literal["RemoveAllIdentitiesRequest"]] = ( |
|
| 309 |
+ "RemoveAllIdentitiesRequest" |
|
| 310 |
+ ) |
|
| 311 |
+ |
|
| 312 |
+ |
|
| 313 |
+@EVENT |
|
| 314 |
+class RemoveSmartcardKeyRequest(BaseRemoveKeyMaterialRequest): |
|
| 315 |
+ """An `SSH_AGENTC_REMOVE_SMARTCARD_KEY` request.""" |
|
| 316 |
+ |
|
| 317 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.REMOVE_SMARTCARD_KEY |
|
| 318 |
+ # pyrefly: ignore [bad-override] |
|
| 319 |
+ _tag: ClassVar[Literal["RemoveSmartcardKeyRequest"]] = ( |
|
| 320 |
+ "RemoveSmartcardKeyRequest" |
|
| 321 |
+ ) |
|
| 322 |
+ |
|
| 323 |
+ token_id: bytes |
|
| 324 |
+ pin: bytes |
|
| 325 |
+ |
|
| 326 |
+ |
|
| 327 |
+@EVENT |
|
| 328 |
+class ListIdentitiesRequest(AbstractClientRequest): |
|
| 329 |
+ """An `SSH_AGENTC_REQUEST_IDENTITIES` request.""" |
|
| 330 |
+ |
|
| 331 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.REQUEST_IDENTITIES |
|
| 332 |
+ # pyrefly: ignore [bad-override] |
|
| 333 |
+ _tag: ClassVar[Literal["ListIdentitiesRequest"]] = "ListIdentitiesRequest" |
|
| 334 |
+ |
|
| 335 |
+ |
|
| 336 |
+@EVENT |
|
| 337 |
+class SignRequest(AbstractClientRequest): |
|
| 338 |
+ """An `SSH_AGENTC_SIGN_REQUEST` request.""" |
|
| 339 |
+ |
|
| 340 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.SIGN_REQUEST |
|
| 341 |
+ # pyrefly: ignore [bad-override] |
|
| 342 |
+ _tag: ClassVar[Literal["SignRequest"]] = "SignRequest" |
|
| 343 |
+ |
|
| 344 |
+ key_blob: bytes |
|
| 345 |
+ payload: bytes |
|
| 346 |
+ flags: SignRequestFlag |
|
| 347 |
+ |
|
| 348 |
+ |
|
| 349 |
+@EVENT |
|
| 350 |
+class LockRequest(AbstractClientRequest): |
|
| 351 |
+ """An `SSH_AGENTC_LOCK` request.""" |
|
| 352 |
+ |
|
| 353 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.LOCK |
|
| 354 |
+ # pyrefly: ignore [bad-override] |
|
| 355 |
+ _tag: ClassVar[Literal["LockRequest"]] = "LockRequest" |
|
| 356 |
+ |
|
| 357 |
+ passphrase: bytes |
|
| 358 |
+ |
|
| 359 |
+ |
|
| 360 |
+@EVENT |
|
| 361 |
+class UnlockRequest(AbstractClientRequest): |
|
| 362 |
+ """An `SSH_AGENTC_UNLOCK` request.""" |
|
| 363 |
+ |
|
| 364 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.UNLOCK |
|
| 365 |
+ # pyrefly: ignore [bad-override] |
|
| 366 |
+ _tag: ClassVar[Literal["UnlockRequest"]] = "UnlockRequest" |
|
| 367 |
+ |
|
| 368 |
+ passphrase: bytes |
|
| 369 |
+ |
|
| 370 |
+ |
|
| 371 |
+@EVENT |
|
| 372 |
+class AbstractExtensionRequest(AbstractClientRequest): |
|
| 373 |
+ """Base class for known and unknown `SSH_AGENTC_EXTENSION` requests.""" |
|
| 374 |
+ |
|
| 375 |
+ _code: ClassVar[int] = _types.SSH_AGENTC.EXTENSION |
|
| 376 |
+ |
|
| 377 |
+ |
|
| 378 |
+@EVENT |
|
| 379 |
+class QueryExtensionRequest(AbstractClientRequest): |
|
| 380 |
+ """A "query" `SSH_AGENTC_EXTENSION` request.""" |
|
| 381 |
+ |
|
| 382 |
+ # pyrefly: ignore [bad-override] |
|
| 383 |
+ _tag: ClassVar[Literal["QueryExtensionRequest"]] = "QueryExtensionRequest" |
|
| 384 |
+ |
|
| 385 |
+ |
|
| 386 |
+@EVENT |
|
| 387 |
+class UnrecognizedExtensionRequest(AbstractExtensionRequest): |
|
| 388 |
+ """An `SSH_AGENTC_EXTENSION` request of an unrecognized type.""" |
|
| 389 |
+ |
|
| 390 |
+ # pyrefly: ignore [bad-override] |
|
| 391 |
+ _tag: ClassVar[Literal["UnrecognizedExtensionRequest"]] = ( |
|
| 392 |
+ "UnrecognizedExtensionRequest" |
|
| 393 |
+ ) |
|
| 394 |
+ |
|
| 395 |
+ extension_type: str |
|
| 396 |
+ extension_payload: bytes |
|
| 397 |
+ |
|
| 398 |
+ |
|
| 399 |
+@EVENT |
|
| 400 |
+class SuccessResponse(AbstractServerResponse): |
|
| 401 |
+ """An `SSH_AGENT_SUCCESS` response.""" |
|
| 402 |
+ |
|
| 403 |
+ _code: ClassVar[int] = _types.SSH_AGENT.SUCCESS |
|
| 404 |
+ # pyrefly: ignore [bad-override] |
|
| 405 |
+ _tag: ClassVar[Literal["SuccessResponse"]] = "SuccessResponse" |
|
| 406 |
+ |
|
| 407 |
+ |
|
| 408 |
+@EVENT |
|
| 409 |
+class FailureResponse(AbstractServerResponse): |
|
| 410 |
+ """An `SSH_AGENT_FAILURE` response.""" |
|
| 411 |
+ |
|
| 412 |
+ _code: ClassVar[int] = _types.SSH_AGENT.FAILURE |
|
| 413 |
+ # pyrefly: ignore [bad-override] |
|
| 414 |
+ _tag: ClassVar[Literal["FailureResponse"]] = "FailureResponse" |
|
| 415 |
+ |
|
| 416 |
+ |
|
| 417 |
+@EVENT |
|
| 418 |
+class SignResponse(AbstractServerResponse): |
|
| 419 |
+ """An `SSH_AGENT_SIGN_RESPONSE` response.""" |
|
| 420 |
+ |
|
| 421 |
+ _code: ClassVar[int] = _types.SSH_AGENT.SIGN_RESPONSE |
|
| 422 |
+ # pyrefly: ignore [bad-override] |
|
| 423 |
+ _tag: ClassVar[Literal["SignResponse"]] = "SignResponse" |
|
| 424 |
+ |
|
| 425 |
+ signature: bytes |
|
| 426 |
+ |
|
| 427 |
+ |
|
| 428 |
+@EVENT |
|
| 429 |
+class IdentitiesResponse(AbstractServerResponse): |
|
| 430 |
+ """An `SSH_AGENT_IDENTITIES_ANSWER` response.""" |
|
| 431 |
+ |
|
| 432 |
+ _code: ClassVar[int] = _types.SSH_AGENT.IDENTITIES_ANSWER |
|
| 433 |
+ # pyrefly: ignore [bad-override] |
|
| 434 |
+ _tag: ClassVar[Literal["IdentitiesResponse"]] = "IdentitiesResponse" |
|
| 435 |
+ |
|
| 436 |
+ identities: tuple[tuple[bytes, str], ...] |
|
| 437 |
+ |
|
| 438 |
+ |
|
| 439 |
+@EVENT |
|
| 440 |
+class AbstractExtensionResponse(AbstractServerResponse): |
|
| 441 |
+ """Base class for responses to `SSH_AGENTC_EXTENSION` requests.""" |
|
| 442 |
+ |
|
| 443 |
+ |
|
| 444 |
+@EVENT |
|
| 445 |
+class AbstractUnmanagedExtensionResponse(AbstractExtensionResponse): |
|
| 446 |
+ """A response to an unmanaged type of `SSH_AGENTC_EXTENSION` request.""" |
|
| 447 |
+ |
|
| 448 |
+ extension_type: str |
|
| 449 |
+ |
|
| 450 |
+ |
|
| 451 |
+@EVENT |
|
| 452 |
+class UnmanagedExtensionSuccessResponse(AbstractUnmanagedExtensionResponse): |
|
| 453 |
+ """A successful response to an `SSH_AGENTC_EXTENSION` request. |
|
| 454 |
+ |
|
| 455 |
+ May be an `SSH_AGENT_SUCCESS` or an `SSH_AGENT_EXTENSION_RESPONSE` |
|
| 456 |
+ response. |
|
| 457 |
+ |
|
| 458 |
+ Not used for extension requests that the protocol stack knows how |
|
| 459 |
+ to handle itself. |
|
| 460 |
+ |
|
| 461 |
+ """ |
|
| 462 |
+ |
|
| 463 |
+ _code: ClassVar[ |
|
| 464 |
+ Literal[_types.SSH_AGENT.SUCCESS, _types.SSH_AGENT.EXTENSION_RESPONSE] |
|
| 465 |
+ ] |
|
| 466 |
+ # pyrefly: ignore [bad-override] |
|
| 467 |
+ _tag: ClassVar[Literal["UnmanagedExtensionSuccessResponse"]] = ( |
|
| 468 |
+ "UnmanagedExtensionSuccessResponse" |
|
| 469 |
+ ) |
|
| 470 |
+ |
|
| 471 |
+ response_data: bytes |
|
| 472 |
+ |
|
| 473 |
+ |
|
| 474 |
+@EVENT |
|
| 475 |
+class UnmanagedExtensionFailureResponse(AbstractUnmanagedExtensionResponse): |
|
| 476 |
+ """An `SSH_AGENT_EXTENSION_FAILURE` response. |
|
| 477 |
+ |
|
| 478 |
+ Not used for extension requests that the protocol stack knows how to |
|
| 479 |
+ handle itself. |
|
| 480 |
+ |
|
| 481 |
+ """ |
|
| 482 |
+ |
|
| 483 |
+ _code: ClassVar[int] = _types.SSH_AGENT.EXTENSION_FAILURE |
|
| 484 |
+ # pyrefly: ignore [bad-override] |
|
| 485 |
+ _tag: ClassVar[Literal["UnmanagedExtensionFailureResponse"]] = ( |
|
| 486 |
+ "UnmanagedExtensionFailureResponse" |
|
| 487 |
+ ) |
|
| 488 |
+ |
|
| 489 |
+ |
|
| 490 |
+@EVENT |
|
| 491 |
+class QueryExtensionResponse(AbstractExtensionResponse): |
|
| 492 |
+ """A successful response to `SSH_AGENTC_EXTENSION` "query" requests.""" |
|
| 493 |
+ |
|
| 494 |
+ _code: ClassVar[ |
|
| 495 |
+ Literal[_types.SSH_AGENT.SUCCESS, _types.SSH_AGENT.EXTENSION_RESPONSE] |
|
| 496 |
+ ] |
|
| 497 |
+ # pyrefly: ignore [bad-override] |
|
| 498 |
+ _tag: ClassVar[Literal["QueryExtensionResponse"]] = ( |
|
| 499 |
+ "QueryExtensionResponse" |
|
| 500 |
+ ) |
|
| 501 |
+ |
|
| 502 |
+ supported_extensions: tuple[str, ...] |
|
| 503 |
+ extension_type: str = "query" |
|
| 504 |
+ |
|
| 505 |
+ |
|
| 506 |
+@EVENT |
|
| 507 |
+class ServerLocked(AbstractServerAction): |
|
| 508 |
+ """The protocol stack locked the agent against further private key use.""" |
|
| 509 |
+ |
|
| 510 |
+ # pyrefly: ignore [bad-override] |
|
| 511 |
+ _tag: ClassVar[Literal["ServerLocked"]] = "ServerLocked" |
|
| 512 |
+ |
|
| 513 |
+ passphrase: str |
|
| 514 |
+ |
|
| 515 |
+ |
|
| 516 |
+@EVENT |
|
| 517 |
+class ServerUnlocked(AbstractServerAction): |
|
| 518 |
+ """The protocol stack unlocked the agent for further private key use.""" |
|
| 519 |
+ |
|
| 520 |
+ # pyrefly: ignore [bad-override] |
|
| 521 |
+ _tag: ClassVar[Literal["ServerUnlocked"]] = "ServerUnlocked" |
|
| 522 |
+ |
|
| 523 |
+ |
|
| 524 |
+@EVENT |
|
| 525 |
+class UnfulfillableRejectedRequest(AbstractServerAction): |
|
| 526 |
+ """The request was automatically rejected because it is unfulfillable. |
|
| 527 |
+ |
|
| 528 |
+ The request requires something that the SSH agent does not have, or |
|
| 529 |
+ that does not match the agent's state. |
|
| 530 |
+ |
|
| 531 |
+ """ |
|
| 532 |
+ |
|
| 533 |
+ # pyrefly: ignore [bad-override] |
|
| 534 |
+ _tag: ClassVar[Literal["UnfulfillableRejectedRequest"]] = ( |
|
| 535 |
+ "UnfulfillableRejectedRequest" |
|
| 536 |
+ ) |
|
| 537 |
+ |
|
| 538 |
+ request: AbstractClientRequest |
|
| 539 |
+ |
|
| 540 |
+ |
|
| 541 |
+@EVENT |
|
| 542 |
+class MissingKeyRejectedRequest(AbstractServerAction): |
|
| 543 |
+ """The request was automatically rejected due to missing key material.""" |
|
| 544 |
+ |
|
| 545 |
+ # pyrefly: ignore [bad-override] |
|
| 546 |
+ _tag: ClassVar[Literal["MissingKeyRejectedRequest"]] = ( |
|
| 547 |
+ "MissingKeyRejectedRequest" |
|
| 548 |
+ ) |
|
| 549 |
+ |
|
| 550 |
+ request: SignRequest | BaseRemoveKeyMaterialRequest |
|
| 551 |
+ |
|
| 552 |
+ |
|
| 553 |
+@EVENT |
|
| 554 |
+class LockedAgentRejectedRequest(AbstractServerAction): |
|
| 555 |
+ """The request was automatically rejected because the agent is locked.""" |
|
| 556 |
+ |
|
| 557 |
+ # pyrefly: ignore [bad-override] |
|
| 558 |
+ _tag: ClassVar[Literal["LockedAgentRejectedRequest"]] = ( |
|
| 559 |
+ "LockedAgentRejectedRequest" |
|
| 560 |
+ ) |
|
| 561 |
+ |
|
| 562 |
+ |
|
| 563 |
+@EVENT |
|
| 564 |
+class StoreKey(AbstractAgentAction): |
|
| 565 |
+ """The agent shall store the given key in its keystore. |
|
| 566 |
+ |
|
| 567 |
+ The agent implementation shall send a [`KeyAdded`][] event if it |
|
| 568 |
+ successfully stored the key material, or a [`UserActionFailed`][] |
|
| 569 |
+ event if it could not or refused to store the key material. |
|
| 570 |
+ |
|
| 571 |
+ This action can only be initiated by the client. |
|
| 572 |
+ |
|
| 573 |
+ The [`TimeoutConstraint`][] and the [`ConfirmUseConstraint`][], if |
|
| 574 |
+ present, are informative; they are enforced by the protocol stack |
|
| 575 |
+ automatically. All other constraints must be handled by the agent |
|
| 576 |
+ implementation. |
|
| 577 |
+ |
|
| 578 |
+ """ |
|
| 579 |
+ |
|
| 580 |
+ # pyrefly: ignore [bad-override] |
|
| 581 |
+ _tag: ClassVar[Literal["StoreKey"]] = "StoreKey" |
|
| 582 |
+ |
|
| 583 |
+ key_type: str |
|
| 584 |
+ key_material: bytes |
|
| 585 |
+ comment: str | bytes |
|
| 586 |
+ constraints: AbstractSet[AbstractKeyConstraint] |
|
| 587 |
+ client_initiated: Literal[True] = dataclasses.field( |
|
| 588 |
+ default=True, init=False |
|
| 589 |
+ ) |
|
| 590 |
+ |
|
| 591 |
+ |
|
| 592 |
+@EVENT |
|
| 593 |
+class LoadSmartcardKeys(AbstractAgentAction): |
|
| 594 |
+ """The agent shall store the keys from the named hardware token in its keystore. |
|
| 595 |
+ |
|
| 596 |
+ The agent implementation shall attempt to locate and access the |
|
| 597 |
+ named hardware token, using the given PIN if necessary, and load all |
|
| 598 |
+ supported keys from it, with the given constraints. The agent |
|
| 599 |
+ implementation shall send a [`SmartcardKeysAdded`][] event with the |
|
| 600 |
+ loaded keys' public keys to the protocol stack if any keys were |
|
| 601 |
+ successfully loaded from the hardware token, or |
|
| 602 |
+ a [`UserActionFailed`][] event otherwise. |
|
| 603 |
+ |
|
| 604 |
+ This action can only be initiated by the client. |
|
| 605 |
+ |
|
| 606 |
+ The [`TimeoutConstraint`][] and the [`ConfirmUseConstraint`][], if |
|
| 607 |
+ present, are informative; they are enforced by the protocol stack |
|
| 608 |
+ automatically. All other constraints must be handled by the agent |
|
| 609 |
+ implementation. |
|
| 610 |
+ |
|
| 611 |
+ """ # noqa: E501 |
|
| 612 |
+ |
|
| 613 |
+ # pyrefly: ignore [bad-override] |
|
| 614 |
+ _tag: ClassVar[Literal["LoadSmartcardKeys"]] = "LoadSmartcardKeys" |
|
| 615 |
+ |
|
| 616 |
+ token_id: str |
|
| 617 |
+ pin: str |
|
| 618 |
+ constraints: AbstractSet[AbstractKeyConstraint] |
|
| 619 |
+ client_initiated: Literal[True] = dataclasses.field( |
|
| 620 |
+ default=True, init=False |
|
| 621 |
+ ) |
|
| 622 |
+ |
|
| 623 |
+ |
|
| 624 |
+@EVENT |
|
| 625 |
+class PurgeKey(AbstractAgentAction): |
|
| 626 |
+ """The agent shall purge the given key from its keystore. |
|
| 627 |
+ |
|
| 628 |
+ This action may be initiated by the client or by the protocol stack. |
|
| 629 |
+ The key may be a smartcard key. |
|
| 630 |
+ |
|
| 631 |
+ """ |
|
| 632 |
+ |
|
| 633 |
+ # pyrefly: ignore [bad-override] |
|
| 634 |
+ _tag: ClassVar[Literal["PurgeKey"]] = "PurgeKey" |
|
| 635 |
+ |
|
| 636 |
+ public_key: bytes |
|
| 637 |
+ token_id: str | None |
|
| 638 |
+ |
|
| 639 |
+ |
|
| 640 |
+@EVENT |
|
| 641 |
+class PerformSignature(AbstractAgentAction): |
|
| 642 |
+ """The agent shall sign the given data with the given key. |
|
| 643 |
+ |
|
| 644 |
+ This action can only be initiated by the client. |
|
| 645 |
+ |
|
| 646 |
+ The key constraints list contains only those constraints not already |
|
| 647 |
+ handled by the protocol stack. |
|
| 648 |
+ |
|
| 649 |
+ """ |
|
| 650 |
+ |
|
| 651 |
+ # pyrefly: ignore [bad-override] |
|
| 652 |
+ _tag: ClassVar[Literal["PerformSignature"]] = "PerformSignature" |
|
| 653 |
+ |
|
| 654 |
+ public_key: bytes |
|
| 655 |
+ payload: bytes |
|
| 656 |
+ signature_flags: SignRequestFlag |
|
| 657 |
+ constraints: AbstractSet[AbstractKeyConstraint] |
|
| 658 |
+ client_initiated: Literal[True] = dataclasses.field( |
|
| 659 |
+ default=True, init=False |
|
| 660 |
+ ) |
|
| 661 |
+ |
|
| 662 |
+ |
|
| 663 |
+@EVENT |
|
| 664 |
+class ConfirmKeyUse(AbstractAgentAction): |
|
| 665 |
+ """The agent shall prompt the user for confirmation to use the specified key. |
|
| 666 |
+ |
|
| 667 |
+ The agent implementation shall present an interactive dialog to the |
|
| 668 |
+ user, asking for permission to use the specified key. (For |
|
| 669 |
+ compatibility with other implementations, the message should refer |
|
| 670 |
+ to the key using its base64-encoded SHA256 fingerprint and its |
|
| 671 |
+ comment.) The agent implementation shall send |
|
| 672 |
+ a [`KeyUseConfirmed`][] event if permission was granted, and |
|
| 673 |
+ a [`UserActionFailed`][] event if not. |
|
| 674 |
+ |
|
| 675 |
+ """ # noqa: E501 |
|
| 676 |
+ |
|
| 677 |
+ # pyrefly: ignore [bad-override] |
|
| 678 |
+ _tag: ClassVar[Literal["ConfirmKeyUse"]] = "ConfirmKeyUse" |
|
| 679 |
+ |
|
| 680 |
+ public_key: bytes |
|
| 681 |
+ client_initiated: Literal[False] = dataclasses.field( |
|
| 682 |
+ default=False, init=False |
|
| 683 |
+ ) |
|
| 684 |
+ |
|
| 685 |
+ |
|
| 686 |
+@EVENT |
|
| 687 |
+class AgentActionFailed(AbstractAgentReaction): |
|
| 688 |
+ """The agent implementation failed to fulfill the protocol stack's request.""" # noqa: E501 |
|
| 689 |
+ |
|
| 690 |
+ # pyrefly: ignore [bad-override] |
|
| 691 |
+ _tag: ClassVar[Literal["AgentActionFailed"]] = "AgentActionFailed" |
|
| 692 |
+ |
|
| 693 |
+ |
|
| 694 |
+@EVENT |
|
| 695 |
+class KeyUseConfirmed(AbstractAgentReaction): |
|
| 696 |
+ """The agent implementation received permission to use a specific key.""" |
|
| 697 |
+ |
|
| 698 |
+ # pyrefly: ignore [bad-override] |
|
| 699 |
+ _tag: ClassVar[Literal["KeyUseConfirmed"]] = "KeyUseConfirmed" |
|
| 700 |
+ |
|
| 701 |
+ public_key: bytes |
|
| 702 |
+ |
|
| 703 |
+ |
|
| 704 |
+@EVENT |
|
| 705 |
+class KeyAdded(AbstractAgentReaction): |
|
| 706 |
+ """The agent implementation added this key.""" |
|
| 707 |
+ |
|
| 708 |
+ # pyrefly: ignore [bad-override] |
|
| 709 |
+ _tag: ClassVar[Literal["KeyAdded"]] = "KeyAdded" |
|
| 710 |
+ |
|
| 711 |
+ public_key: bytes |
|
| 712 |
+ comment: str | bytes |
|
| 713 |
+ |
|
| 714 |
+ |
|
| 715 |
+@EVENT |
|
| 716 |
+class SmartcardKeysAdded(AbstractAgentReaction): |
|
| 717 |
+ """The agent implementation added these keys from a hardware token.""" |
|
| 718 |
+ |
|
| 719 |
+ # pyrefly: ignore [bad-override] |
|
| 720 |
+ _tag: ClassVar[Literal["SmartcardKeysAdded"]] = "SmartcardKeysAdded" |
|
| 721 |
+ |
|
| 722 |
+ token_id: str |
|
| 723 |
+ public_keys_and_comments: tuple[tuple[bytes, str | bytes], ...] |
|
| 724 |
+ |
|
| 725 |
+ |
|
| 726 |
+@EVENT |
|
| 727 |
+class TimeProgressed(AbstractAgentInput): |
|
| 728 |
+ """The agent implementation signals the passage of time.""" |
|
| 729 |
+ |
|
| 730 |
+ # pyrefly: ignore [bad-override] |
|
| 731 |
+ _tag: ClassVar[Literal["TimeProgressed"]] = "TimeProgressed" |
|
| 732 |
+ |
|
| 733 |
+ timestamp: int | float |
|
| 734 |
+ |
|
| 735 |
+ |
|
| 736 |
+@EVENT |
|
| 737 |
+class NextTimedEventAt(AbstractProtocolStackFeedback): |
|
| 738 |
+ """The protocol stack reports the time of the next timed event.""" |
|
| 739 |
+ |
|
| 740 |
+ # pyrefly: ignore [bad-override] |
|
| 741 |
+ _tag: ClassVar[Literal["NextTimedEventAt"]] = "NextTimedEventAt" |
|
| 742 |
+ |
|
| 743 |
+ timestamp: int | float |
|
| 744 |
+ client_initiated: Literal[False] = dataclasses.field( |
|
| 745 |
+ default=False, init=False |
|
| 746 |
+ ) |
|
| 747 |
+ |
|
| 748 |
+ |
|
| 749 |
+ServerAction = Union[ |
|
| 750 |
+ LockedAgentRejectedRequest, |
|
| 751 |
+ MissingKeyRejectedRequest, |
|
| 752 |
+ UnfulfillableRejectedRequest, |
|
| 753 |
+ ServerUnlocked, |
|
| 754 |
+ ServerLocked, |
|
| 755 |
+] |
|
| 756 |
+UnmanagedExtensionResponse = Union[ |
|
| 757 |
+ UnmanagedExtensionFailureResponse, UnmanagedExtensionSuccessResponse |
|
| 758 |
+] |
|
| 759 |
+ExtensionResponse = Union[QueryExtensionResponse, UnmanagedExtensionResponse] |
|
| 760 |
+ServerResponse = Union[ |
|
| 761 |
+ ExtensionResponse, |
|
| 762 |
+ IdentitiesResponse, |
|
| 763 |
+ SignResponse, |
|
| 764 |
+ FailureResponse, |
|
| 765 |
+ SuccessResponse, |
|
| 766 |
+] |
|
| 767 |
+ExtensionRequest = Union[UnrecognizedExtensionRequest] |
|
| 768 |
+RemoveKeyMaterialRequest = Union[ |
|
| 769 |
+ RemoveIdentityRequest, |
|
| 770 |
+ RemoveAllIdentitiesRequest, |
|
| 771 |
+ RemoveSmartcardKeyRequest, |
|
| 772 |
+] |
|
| 773 |
+AddIdentityRequest = Union[ |
|
| 774 |
+ AddIdentityUnconstrainedRequest, |
|
| 775 |
+ AddIdentityConstrainedRequest, |
|
| 776 |
+ AddSmartcardKeyUnconstrainedRequest, |
|
| 777 |
+ AddSmartcardKeyConstrainedRequest, |
|
| 778 |
+] |
|
| 779 |
+ClientRequest = Union[ |
|
| 780 |
+ QueryExtensionRequest, |
|
| 781 |
+ ExtensionRequest, |
|
| 782 |
+ UnlockRequest, |
|
| 783 |
+ LockRequest, |
|
| 784 |
+ SignRequest, |
|
| 785 |
+ ListIdentitiesRequest, |
|
| 786 |
+ RemoveKeyMaterialRequest, |
|
| 787 |
+ AddIdentityRequest, |
|
| 788 |
+] |
|
| 789 |
+ServerEvent = Union[ServerAction, ServerResponse, ClientRequest] |
|
| 790 |
+AgentAction = Union[ |
|
| 791 |
+ ConfirmKeyUse, |
|
| 792 |
+ PerformSignature, |
|
| 793 |
+ PurgeKey, |
|
| 794 |
+ LoadSmartcardKeys, |
|
| 795 |
+ StoreKey, |
|
| 796 |
+ BytesToClient, |
|
| 797 |
+] |
|
| 798 |
+ProtocolStackFeedback = Union[NextTimedEventAt, AgentAction] |
|
| 799 |
+AgentInput = Union[TimeProgressed] |
|
| 800 |
+AgentReaction = Union[ |
|
| 801 |
+ SmartcardKeysAdded, KeyAdded, KeyUseConfirmed, AgentActionFailed |
|
| 802 |
+] |
|
| 803 |
+ClientInput = Union[BytesFromClient] |
|
| 804 |
+AgentEvent = Union[AgentInput, AgentReaction, ClientInput] |
|
| 805 |
+KeyConstraint = Union[ |
|
| 806 |
+ ExtensionConstraint, ConfirmUseConstraint, TimeoutConstraint |
|
| 807 |
+] |
| ... | ... |
@@ -6,19 +6,35 @@ |
| 6 | 6 |
|
| 7 | 7 |
from __future__ import annotations |
| 8 | 8 |
|
| 9 |
+import collections |
|
| 10 |
+import enum |
|
| 9 | 11 |
import errno |
| 10 | 12 |
import os |
| 13 |
+import sched |
|
| 11 | 14 |
import struct |
| 15 |
+import time |
|
| 12 | 16 |
from typing import TYPE_CHECKING, cast |
| 13 | 17 |
|
| 18 |
+from typing_extensions import ( |
|
| 19 |
+ Literal, |
|
| 20 |
+ NamedTuple, |
|
| 21 |
+ TypeAlias, |
|
| 22 |
+ assert_never, |
|
| 23 |
+ overload, |
|
| 24 |
+) |
|
| 25 |
+ |
|
| 14 | 26 |
import derivepassphrase_sshagentsocketprovider as d_sasp |
| 15 |
-from fakesshagent import _types, data |
|
| 27 |
+from fakesshagent import _types, data, events |
|
| 16 | 28 |
|
| 17 | 29 |
if TYPE_CHECKING: |
| 18 |
- from collections.abc import Generator, Iterable |
|
| 30 |
+ import types |
|
| 31 |
+ from collections.abc import Callable, Generator, Iterable |
|
| 32 |
+ from collections.abc import Set as AbstractSet |
|
| 19 | 33 |
|
| 20 | 34 |
from typing_extensions import Buffer, Self |
| 21 | 35 |
|
| 36 |
+ Timefunc: TypeAlias = Callable[[], int | float] |
|
| 37 |
+ |
|
| 22 | 38 |
__all__ = ("StubbedSSHAgentSocket",)
|
| 23 | 39 |
|
| 24 | 40 |
VAULT_UUID = b"e87eb0f4-34cb-46b9-93ad-766c5ab063e7" |
| ... | ... |
@@ -73,6 +89,384 @@ def unstring_prefix(buf: Buffer, /) -> tuple[bytes, bytes]: |
| 73 | 89 |
return head, tail |
| 74 | 90 |
|
| 75 | 91 |
|
| 92 |
+class InvalidStateError(ValueError): |
|
| 93 |
+ """The operation cannot continue because the system is not in the necessary state.""" # noqa: E501 |
|
| 94 |
+ |
|
| 95 |
+ |
|
| 96 |
+class FrozenClockScheduler: |
|
| 97 |
+ """A scheduler that works with fixed timestamps.""" |
|
| 98 |
+ |
|
| 99 |
+ BLOCKING_CALL = "Blocking call in non-blocking scheduler?!" |
|
| 100 |
+ NON_MONOTONIC_TIMESTAMPS = "non-monotonic timestamps" |
|
| 101 |
+ |
|
| 102 |
+ def __init__(self, last_timestamp: float = float("-inf"), /) -> None:
|
|
| 103 |
+ """Initialize the frozen clock scheduler. |
|
| 104 |
+ |
|
| 105 |
+ Args: |
|
| 106 |
+ last_timestamp: |
|
| 107 |
+ The initial timestamp to use. |
|
| 108 |
+ |
|
| 109 |
+ """ |
|
| 110 |
+ self.last_timestamp: float = last_timestamp |
|
| 111 |
+ """ |
|
| 112 |
+ The last timestamp value seen. Used to ensure monotonic |
|
| 113 |
+ progression of the timestamp. |
|
| 114 |
+ """ |
|
| 115 |
+ self.scheduler = sched.scheduler( |
|
| 116 |
+ timefunc=lambda: self.last_timestamp, delayfunc=self._do_not_call |
|
| 117 |
+ ) |
|
| 118 |
+ """The underlying scheduler.""" |
|
| 119 |
+ |
|
| 120 |
+ def run(self, timestamp: float, /) -> float | None: |
|
| 121 |
+ """Run the events scheduled at or before the timestamp. |
|
| 122 |
+ |
|
| 123 |
+ Args: |
|
| 124 |
+ timestamp: |
|
| 125 |
+ The timestamp to move forward to. Must not be smaller |
|
| 126 |
+ than the last timestamp, i.e., the clock must advance |
|
| 127 |
+ weakly monotonically. |
|
| 128 |
+ |
|
| 129 |
+ Returns: |
|
| 130 |
+ The distance from the given timestamp to the next scheduled |
|
| 131 |
+ event not handled in this call, if any. |
|
| 132 |
+ |
|
| 133 |
+ Raises: |
|
| 134 |
+ ValueError: |
|
| 135 |
+ The clock did not advance weakly monotonically. |
|
| 136 |
+ |
|
| 137 |
+ """ |
|
| 138 |
+ if timestamp < self.last_timestamp: # pragma: no cover [failsafe] |
|
| 139 |
+ raise ValueError(self.NON_MONOTONIC_TIMESTAMPS) |
|
| 140 |
+ self.last_timestamp = timestamp |
|
| 141 |
+ return self.scheduler.run(blocking=False) |
|
| 142 |
+ |
|
| 143 |
+ def _do_not_call( |
|
| 144 |
+ self, _ignored: float, / |
|
| 145 |
+ ) -> None: # pragma: no cover [failsafe] |
|
| 146 |
+ # Always raise an error. |
|
| 147 |
+ raise AssertionError(self.BLOCKING_CALL) |
|
| 148 |
+ |
|
| 149 |
+ |
|
| 150 |
+class SSHAgentProtocolStackState(str, enum.Enum): |
|
| 151 |
+ pass |
|
| 152 |
+ |
|
| 153 |
+ |
|
| 154 |
+class SSHAgentProtocolStack: |
|
| 155 |
+ """A Sans-I/O protocol stack for the SSH agent protocol. |
|
| 156 |
+ |
|
| 157 |
+ The protocol stack handles serialisation and deserialisation of |
|
| 158 |
+ protocol messages, and management of agent state. The stack |
|
| 159 |
+ translates these messages and state changes into <dfn>events</dfn>, |
|
| 160 |
+ such as [automatic server responses][events.ServerAction] and [agent |
|
| 161 |
+ actions][events.AgentAction]. The agent implementation (the "user" |
|
| 162 |
+ of this protocol stack) then interacts with the protocol stack in |
|
| 163 |
+ terms of these events, in a request-response manner. I/O from a |
|
| 164 |
+ client connection is represented by a [`BytesFromClient` agent input |
|
| 165 |
+ event][events.BytesFromClient], I/O to a client connection by a |
|
| 166 |
+ [`BytesToClient` agent action event][events.BytesToClient]; other |
|
| 167 |
+ [protocol stack feedback][events.ProtocolStackFeedback]/[agent |
|
| 168 |
+ actions][events.AgentAction], [reactions][events.AgentReactions] and |
|
| 169 |
+ [input][events.AgentInput] are also possible. |
|
| 170 |
+ |
|
| 171 |
+ The protocol stack itself contains no cryptographic code. In |
|
| 172 |
+ particular, **the protocol stack cannot fulfill signature requests |
|
| 173 |
+ and cannot securely store key material**. The agent implementation |
|
| 174 |
+ is responsible for both of these functionalities, by physically |
|
| 175 |
+ reacting to the appropriate agent actions and passing the correct |
|
| 176 |
+ agent reactions back. |
|
| 177 |
+ |
|
| 178 |
+ The protocol stack also manages key constraints automatically, |
|
| 179 |
+ including key timeouts, by signaling appropriate agent actions to |
|
| 180 |
+ the agent implementation. However, the protocol stack does not |
|
| 181 |
+ block, sleep or otherwise interface with the system clock and thread |
|
| 182 |
+ or process suspension functionality. Therefore, **the protocol |
|
| 183 |
+ stack cannot inform the agent implementation of key expirations in a |
|
| 184 |
+ timely manner unless the agent implementation regularly updates the |
|
| 185 |
+ protocol stack's knowledge of the current time**, at whatever |
|
| 186 |
+ frequency the implementation deems sufficient. |
|
| 187 |
+ |
|
| 188 |
+ The protocol stack uses <dfn>handles</dfn> to represent the I/O |
|
| 189 |
+ channels to different clients. See the [`Handle`][] class for more |
|
| 190 |
+ information. |
|
| 191 |
+ |
|
| 192 |
+ """ |
|
| 193 |
+ |
|
| 194 |
+ class Handle: |
|
| 195 |
+ """A handle for an SSH agent protocol stack. |
|
| 196 |
+ |
|
| 197 |
+ A handle represents an endpoint for feeding agent events into |
|
| 198 |
+ the protocol stack, and receiving agent actions from it. I/O to |
|
| 199 |
+ and from a client connection is mediated by the agent |
|
| 200 |
+ implementation, and expressed by the [`BytesFromClient` agent |
|
| 201 |
+ input event][events.BytesFromClient] and the [`BytesToClient` |
|
| 202 |
+ agent action][events.BytesToClient]. |
|
| 203 |
+ |
|
| 204 |
+ Agent implementations SHOULD create separate handles for each |
|
| 205 |
+ distinct client network connection they do I/O with. All |
|
| 206 |
+ handles from the same protocol stack share the same state, |
|
| 207 |
+ except for those endpoints. This includes the keystore in the |
|
| 208 |
+ agent implementation: key management actions are global to the |
|
| 209 |
+ protocol stack, may be triggered without client interaction, and |
|
| 210 |
+ thus may be returned as part of the next "list of events" |
|
| 211 |
+ response to the agent implementation (whatever handle that turns |
|
| 212 |
+ out to belong to). |
|
| 213 |
+ |
|
| 214 |
+ Handles are resources, and SHOULD be used as context managers to |
|
| 215 |
+ avoid lost messages. When entered and exited, the handle |
|
| 216 |
+ ensures that all input and output channels are empty. Once |
|
| 217 |
+ exited, a handle cannot be meaningfully interacted with anymore. |
|
| 218 |
+ |
|
| 219 |
+ """ |
|
| 220 |
+ |
|
| 221 |
+ _ALREADY_CLOSED = "the handle is already closed" |
|
| 222 |
+ """A common error message.""" |
|
| 223 |
+ _QUEUES_NOT_EMPTY = "the queues are not empty" |
|
| 224 |
+ """A common error message.""" |
|
| 225 |
+ |
|
| 226 |
+ def __init__(self, protocol_stack: SSHAgentProtocolStack, /) -> None: |
|
| 227 |
+ """Initialize a handle. |
|
| 228 |
+ |
|
| 229 |
+ Args: |
|
| 230 |
+ protocol_stack: |
|
| 231 |
+ The SSH agent protocol stack this handle belongs to. |
|
| 232 |
+ |
|
| 233 |
+ """ |
|
| 234 |
+ self.protocol_stack = protocol_stack |
|
| 235 |
+ """The protocol stack this handle belongs to.""" |
|
| 236 |
+ self.bytes_queue = bytearray() |
|
| 237 |
+ """ |
|
| 238 |
+ The queue of bytes from the client, containing |
|
| 239 |
+ not-yet-processed as well as partial requests. It is |
|
| 240 |
+ [fed][feed] from [`BytesFromClient` agent input |
|
| 241 |
+ events][events.BytesFromClient]. |
|
| 242 |
+ """ |
|
| 243 |
+ self.closed = False |
|
| 244 |
+ """True if and only if the handle is closed.""" |
|
| 245 |
+ |
|
| 246 |
+ def __enter__(self, /) -> Self: |
|
| 247 |
+ """Enter the handle as a new context. |
|
| 248 |
+ |
|
| 249 |
+ As part of this, check that the handle is not yet closed, |
|
| 250 |
+ and that the bytes queue from the client is empty. |
|
| 251 |
+ |
|
| 252 |
+ Returns: |
|
| 253 |
+ The handle. |
|
| 254 |
+ |
|
| 255 |
+ Raises: |
|
| 256 |
+ InvalidStateError: |
|
| 257 |
+ The handle is closed, or the bytes queue is not |
|
| 258 |
+ empty. |
|
| 259 |
+ |
|
| 260 |
+ """ |
|
| 261 |
+ if self.closed: |
|
| 262 |
+ raise InvalidStateError(self._ALREADY_CLOSED) |
|
| 263 |
+ if self.bytes_queue: |
|
| 264 |
+ raise InvalidStateError(self._QUEUES_NOT_EMPTY) |
|
| 265 |
+ return self |
|
| 266 |
+ |
|
| 267 |
+ def __exit__( |
|
| 268 |
+ self, |
|
| 269 |
+ _exc_type: type[BaseException] | None, |
|
| 270 |
+ _exc_value: BaseException | None, |
|
| 271 |
+ _exc_tb: types.TracebackType | None, |
|
| 272 |
+ /, |
|
| 273 |
+ ) -> bool: |
|
| 274 |
+ """Close the handle, and check that the byte queue is empty. |
|
| 275 |
+ |
|
| 276 |
+ Returns: |
|
| 277 |
+ False. This context manager does not swallow any |
|
| 278 |
+ exceptions. |
|
| 279 |
+ |
|
| 280 |
+ Raises: |
|
| 281 |
+ InvalidStateError: |
|
| 282 |
+ The bytes queue is not empty. |
|
| 283 |
+ |
|
| 284 |
+ """ |
|
| 285 |
+ self.closed = True |
|
| 286 |
+ if self.bytes_queue: |
|
| 287 |
+ raise InvalidStateError(self._QUEUES_NOT_EMPTY) |
|
| 288 |
+ return False |
|
| 289 |
+ |
|
| 290 |
+ @overload |
|
| 291 |
+ def feed( |
|
| 292 |
+ self, |
|
| 293 |
+ event: events.AgentInput, |
|
| 294 |
+ /, |
|
| 295 |
+ *, |
|
| 296 |
+ automatic_events: bool = False, |
|
| 297 |
+ ) -> tuple[events.ProtocolStackFeedback, ...]: ... |
|
| 298 |
+ |
|
| 299 |
+ @overload |
|
| 300 |
+ def feed( |
|
| 301 |
+ self, |
|
| 302 |
+ event: events.AgentEvent, |
|
| 303 |
+ /, |
|
| 304 |
+ *, |
|
| 305 |
+ automatic_events: Literal[False] = False, |
|
| 306 |
+ ) -> tuple[events.ProtocolStackFeedback, ...]: ... |
|
| 307 |
+ |
|
| 308 |
+ @overload |
|
| 309 |
+ def feed( |
|
| 310 |
+ self, |
|
| 311 |
+ event: events.AgentEvent, |
|
| 312 |
+ /, |
|
| 313 |
+ *, |
|
| 314 |
+ automatic_events: Literal[True] = True, |
|
| 315 |
+ ) -> tuple[ |
|
| 316 |
+ events.ProtocolStackFeedback | events.ServerEvent, |
|
| 317 |
+ ..., |
|
| 318 |
+ ]: ... |
|
| 319 |
+ |
|
| 320 |
+ def feed( |
|
| 321 |
+ self, |
|
| 322 |
+ event: events.AgentEvent, |
|
| 323 |
+ /, |
|
| 324 |
+ *, |
|
| 325 |
+ automatic_events: bool = False, |
|
| 326 |
+ ) -> tuple[ |
|
| 327 |
+ events.ProtocolStackFeedback | events.ServerEvent, |
|
| 328 |
+ ..., |
|
| 329 |
+ ]: |
|
| 330 |
+ """Feed agent events to the handle. |
|
| 331 |
+ |
|
| 332 |
+ Receive agent actions in return. If requested, also receive |
|
| 333 |
+ server events. |
|
| 334 |
+ |
|
| 335 |
+ Args: |
|
| 336 |
+ event: |
|
| 337 |
+ The agent event to process in the protocol stack. |
|
| 338 |
+ |
|
| 339 |
+ Client input is represented by a [`BytesFromClient` |
|
| 340 |
+ agent input event][events.BytesFromClient]. |
|
| 341 |
+ automatic_events: |
|
| 342 |
+ If true, then also receive automatic server events: |
|
| 343 |
+ the [parsed client requests][events.ClientRequest], |
|
| 344 |
+ the [server responses][events.ServerResponse], and |
|
| 345 |
+ the [automatic server actions][events.ServerAction]. |
|
| 346 |
+ |
|
| 347 |
+ Returns: |
|
| 348 |
+ A list of agent actions to perform, as a result of the |
|
| 349 |
+ processed agent event. May include a [`BytesToClient` |
|
| 350 |
+ agent action][events.BytesToClient], indicating which |
|
| 351 |
+ bytes to send to the client. If `automatic_events` is |
|
| 352 |
+ true, and the processed agent event is an [agent |
|
| 353 |
+ reaction][events.AgentReaction], then the list may also |
|
| 354 |
+ contain parsed client requests, server responses and |
|
| 355 |
+ automatic server actions. |
|
| 356 |
+ |
|
| 357 |
+ """ |
|
| 358 |
+ raise NotImplementedError |
|
| 359 |
+ |
|
| 360 |
+ def _process_event( |
|
| 361 |
+ self, request: events.ClientRequest, / |
|
| 362 |
+ ) -> tuple[events.ProtocolStackFeedback | events.ServerEvent, ...]: |
|
| 363 |
+ raise NotImplementedError |
|
| 364 |
+ |
|
| 365 |
+ class _KeyInfo(NamedTuple): |
|
| 366 |
+ """Information for key material managed by the agent. |
|
| 367 |
+ |
|
| 368 |
+ Attributes: |
|
| 369 |
+ constraints: |
|
| 370 |
+ The set of [key constraints][events.KeyConstraint] this |
|
| 371 |
+ key is governed by. |
|
| 372 |
+ token_id: |
|
| 373 |
+ The (opaque) identifier of the smartcard token the key |
|
| 374 |
+ is located on, if any. Otherwise, `None`. |
|
| 375 |
+ expiry_event: |
|
| 376 |
+ The current [scheduler event][sched.scheduler.queue] for |
|
| 377 |
+ the key's expiry, if the key expires. Otherwise, |
|
| 378 |
+ `None`. |
|
| 379 |
+ |
|
| 380 |
+ """ |
|
| 381 |
+ |
|
| 382 |
+ constraints: AbstractSet[events.KeyConstraint] |
|
| 383 |
+ """""" |
|
| 384 |
+ token_id: str | None |
|
| 385 |
+ """""" |
|
| 386 |
+ expiry_event: sched.Event | None |
|
| 387 |
+ """""" |
|
| 388 |
+ |
|
| 389 |
+ def __init__( |
|
| 390 |
+ self, /, *, timefunc: Timefunc | None = time.monotonic |
|
| 391 |
+ ) -> None: |
|
| 392 |
+ """Initialise the protocol stack. |
|
| 393 |
+ |
|
| 394 |
+ Args: |
|
| 395 |
+ timefunc: |
|
| 396 |
+ An optional zero-argument callable that returns the |
|
| 397 |
+ "current time", so that handles can automatically |
|
| 398 |
+ generate [`TimeProgressed` |
|
| 399 |
+ events][events.TimeProgressed] to inform the protocol |
|
| 400 |
+ stack of the passage of time. Defaults to |
|
| 401 |
+ [`time.monotonic`][]. If explicitly set to `None`, then |
|
| 402 |
+ no `TimeProgressed` events are automatically generated, |
|
| 403 |
+ and the agent implementation needs to update the |
|
| 404 |
+ protocol stack's clock itself, lest it remain static. |
|
| 405 |
+ |
|
| 406 |
+ """ |
|
| 407 |
+ self.timefunc = timefunc |
|
| 408 |
+ """ |
|
| 409 |
+ An optional zero-argument callable that returns the "current |
|
| 410 |
+ time". If given, then handles automatically generate |
|
| 411 |
+ [`TimeProgressed` events][events.TimeProgressed] to inform the |
|
| 412 |
+ protocol stack of the passage of time at the beginning of each |
|
| 413 |
+ [event cycle][feed]. The protocol stack's clock remains static |
|
| 414 |
+ between updates. |
|
| 415 |
+ """ |
|
| 416 |
+ self.scheduler = FrozenClockScheduler( |
|
| 417 |
+ timefunc() if timefunc is not None else float("-inf")
|
|
| 418 |
+ ) |
|
| 419 |
+ """ |
|
| 420 |
+ An event scheduler for keeping track of key expiry events. Only |
|
| 421 |
+ ever run in non-blocking mode, and with a static clock set to |
|
| 422 |
+ the current value of [timestamp][]. |
|
| 423 |
+ """ |
|
| 424 |
+ self.recently_expired: collections.deque[bytes] = collections.deque() |
|
| 425 |
+ """ |
|
| 426 |
+ A queue of recently expired keys that have not yet been signaled |
|
| 427 |
+ to the agent implementation. Filled by the [scheduler][], and |
|
| 428 |
+ drained by [polling][poll]. |
|
| 429 |
+ """ |
|
| 430 |
+ self.known_keys: dict[bytes, SSHAgentProtocolStack._KeyInfo] = {}
|
|
| 431 |
+ """ |
|
| 432 |
+ A dict of public keys to [key |
|
| 433 |
+ info][SSHAgentProtocolStack._KeyInfo]. |
|
| 434 |
+ """ |
|
| 435 |
+ |
|
| 436 |
+ def handle(self) -> Handle: |
|
| 437 |
+ """Return a newly constructed handle.""" |
|
| 438 |
+ return self.Handle(self) |
|
| 439 |
+ |
|
| 440 |
+ def poll( |
|
| 441 |
+ self, agent_input: events.AgentInput, / |
|
| 442 |
+ ) -> tuple[events.ProtocolStackFeedback, ...]: |
|
| 443 |
+ if isinstance(agent_input, events.TimeProgressed): |
|
| 444 |
+ next_timeout_delta = self.scheduler.run(agent_input.timestamp) |
|
| 445 |
+ expired_keys = self.recently_expired |
|
| 446 |
+ self.recently_expired = collections.deque() |
|
| 447 |
+ result: collections.deque[events.ProtocolStackFeedback] = ( |
|
| 448 |
+ collections.deque() |
|
| 449 |
+ ) |
|
| 450 |
+ for key in expired_keys: |
|
| 451 |
+ try: |
|
| 452 |
+ key_info = self.known_keys.pop(key) |
|
| 453 |
+ except KeyError as e: |
|
| 454 |
+ msg = f"Invalid known_keys state, missing {key!r}"
|
|
| 455 |
+ raise AssertionError(msg) from e |
|
| 456 |
+ result.append( |
|
| 457 |
+ events.PurgeKey( |
|
| 458 |
+ public_key=key, |
|
| 459 |
+ token_id=key_info.token_id, |
|
| 460 |
+ client_initiated=False, |
|
| 461 |
+ ) |
|
| 462 |
+ ) |
|
| 463 |
+ if next_timeout_delta is not None: |
|
| 464 |
+ next_timeout = agent_input.timestamp + next_timeout_delta |
|
| 465 |
+ result.appendleft(events.NextTimedEventAt(next_timeout)) |
|
| 466 |
+ return tuple(result) |
|
| 467 |
+ assert_never(agent_input) |
|
| 468 |
+ |
|
| 469 |
+ |
|
| 76 | 470 |
class StubbedSSHAgentSocket: |
| 77 | 471 |
"""A stubbed SSH agent presenting an [`_types.SSHAgentSocket`][]. |
| 78 | 472 |
|
| 79 | 473 |