Sketch events for Sans-I/O version
Marco Ricci

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