Support the "all signatures are deterministic" feature of some SSH agents
Marco Ricci

Marco Ricci commited on 2024-11-13 21:18:07
Zeige 3 geänderte Dateien mit 74 Einfügungen und 10 Löschungen.


Pageant issues deterministic signatures for all key types.  Support
detecting whether we are running under Pageant (it supports the
`list-extended@putty.projects.tartarus.org` extension request).  When
determining whether a key is suitable for use with the `vault`
subsystem, also return success if we are running under Pageant.

Detecting Pageant requires that we issue an SSH agent extension request,
so also define the necessary enum constants.
... ...
@@ -569,6 +569,11 @@ class SSH_AGENTC(enum.Enum):  # noqa: N801
569 569
             Remove an (SSH2) identity.
570 570
         ADD_ID_CONSTRAINED:
571 571
             Add an (SSH2) identity, including key constraints.
572
+        EXTENSION:
573
+            Issue a named request that isn't part of the core agent
574
+            protocol.  Expecting [`SSH_AGENT.EXTENSION_RESPONSE`][] or
575
+            [`SSH_AGENT.EXTENSION_FAILURE`][] if the named request is
576
+            supported, [`SSH_AGENT.FAILURE`][] otherwise.
572 577
 
573 578
     """
574 579
 
... ...
@@ -582,6 +587,8 @@ class SSH_AGENTC(enum.Enum):  # noqa: N801
582 587
     """"""
583 588
     ADD_ID_CONSTRAINED: int = 25
584 589
     """"""
590
+    EXTENSION: int = 27
591
+    """"""
585 592
 
586 593
 
587 594
 class SSH_AGENT(enum.Enum):  # noqa: N801
... ...
@@ -596,6 +603,10 @@ class SSH_AGENT(enum.Enum):  # noqa: N801
596 603
             Successful answer to [`SSH_AGENTC.REQUEST_IDENTITIES`][].
597 604
         SIGN_RESPONSE:
598 605
             Successful answer to [`SSH_AGENTC.SIGN_REQUEST`][].
606
+        EXTENSION_FAILURE:
607
+            Unsuccessful answer to [`SSH_AGENTC.EXTENSION`][].
608
+        EXTENSION_RESPONSE:
609
+            Successful answer to [`SSH_AGENTC.EXTENSION`][].
599 610
 
600 611
     """
601 612
 
... ...
@@ -607,3 +618,7 @@ class SSH_AGENT(enum.Enum):  # noqa: N801
607 618
     """"""
608 619
     SIGN_RESPONSE: int = 14
609 620
     """"""
621
+    EXTENSION_FAILURE: int = 28
622
+    """"""
623
+    EXTENSION_RESPONSE: int = 29
624
+    """"""
... ...
@@ -338,6 +338,25 @@ class SSHAgentClient:
338 338
             msg = f'invalid connection hint: {conn!r}'
339 339
             raise TypeError(msg)  # noqa: DOC501
340 340
 
341
+    def has_deterministic_signatures(self) -> bool:
342
+        """Check whether the agent returns deterministic signatures.
343
+
344
+        Returns:
345
+            True if a known agent was detected where signatures are
346
+            deterministic for all SSH key types, false otherwise.
347
+
348
+        Note: Known agents with deterministic signatures
349
+            | agent           | detected via                                                  |
350
+            |:----------------|:--------------------------------------------------------------|
351
+            | Pageant (PuTTY) | `list-extended@putty.projects.tartarus.org` extension request |
352
+
353
+        """  # noqa: E501
354
+        returncode, _payload = self.request(
355
+            _types.SSH_AGENTC.EXTENSION,
356
+            self.string(b'list-extended@putty.projects.tartarus.org'),
357
+        )
358
+        return returncode == _types.SSH_AGENT.SUCCESS.value
359
+
341 360
     @overload
342 361
     def request(  # pragma: no cover
343 362
         self,
... ...
@@ -18,6 +18,7 @@ from typing_extensions import TypeAlias, assert_type
18 18
 from derivepassphrase import sequin, ssh_agent
19 19
 
20 20
 if TYPE_CHECKING:
21
+    import socket
21 22
     from collections.abc import Callable
22 23
 
23 24
 __author__ = 'Marco Ricci <software@the13thletter.info>'
... ...
@@ -449,15 +450,20 @@ class Vault:
449 450
     def _is_suitable_ssh_key(key: bytes | bytearray, /) -> bool:
450 451
         """Check whether the key is suitable for passphrase derivation.
451 452
 
452
-        Currently, this only checks whether signatures with this key
453
-        type are deterministic.
453
+        Currently, this only statically checks whether signatures with
454
+        this key type are guaranteed to be deterministic.
454 455
 
455 456
         Args:
456 457
             key: SSH public key to check.
457 458
 
458 459
         Returns:
459
-            True if and only if the key is suitable for use in deriving
460
-            a passphrase deterministically.
460
+            True if and only if the key is guaranteed suitable for use in
461
+            deriving a passphrase deterministically.
462
+
463
+        Note:
464
+            Some SSH agents additionally guarantee that all signatures
465
+            are deterministic and thus *all* keys are suitable; see
466
+            [`ssh_agent.SSHAgentClient.has_deterministic_signatures`][].
461 467
 
462 468
         """
463 469
         TestFunc: TypeAlias = 'Callable[[bytes | bytearray], bool]'
... ...
@@ -472,21 +478,42 @@ class Vault:
472 478
         return any(v(key) for v in deterministic_signature_types.values())
473 479
 
474 480
     @classmethod
475
-    def phrase_from_key(cls, key: bytes | bytearray, /) -> bytes:
481
+    def phrase_from_key(
482
+        cls,
483
+        key: bytes | bytearray,
484
+        /,
485
+        *,
486
+        conn: ssh_agent.SSHAgentClient | socket.socket | None = None,
487
+    ) -> bytes:
476 488
         """Obtain the master passphrase from a configured SSH key.
477 489
 
478 490
         vault allows the usage of certain SSH keys to derive a master
479 491
         passphrase, by signing the vault UUID with the SSH key.  The key
480
-        type must ensure that signatures are deterministic.
492
+        type or the SSH agent must ensure that signatures are
493
+        deterministic.
481 494
 
482 495
         Args:
483
-            key: The (public) SSH key to use for signing.
496
+            key:
497
+                The (public) SSH key to use for signing.
498
+            conn:
499
+                An optional connection hint to the SSH agent.  See
500
+                [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][].
484 501
 
485 502
         Returns:
486 503
             The signature of the vault UUID under this key, unframed but
487 504
             encoded in base64.
488 505
 
489 506
         Raises:
507
+            KeyError:
508
+                `conn` was `None`, and the `SSH_AUTH_SOCK` environment
509
+                variable was not found.
510
+            NotImplementedError:
511
+                `conn` was `None`, and this Python does not support
512
+                [`socket.AF_UNIX`][], so the SSH agent client cannot be
513
+                automatically set up.
514
+            OSError:
515
+                `conn` was a socket or `None`, and there was an error
516
+                setting up a socket connection to the agent.
490 517
             ValueError:
491 518
                 The SSH key is principally unsuitable for this use case.
492 519
                 Usually this means that the signature is not
... ...
@@ -516,13 +543,16 @@ class Vault:
516 543
             True
517 544
 
518 545
         """
519
-        if not cls._is_suitable_ssh_key(key):
546
+        with ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn) as client:
547
+            if not (
548
+                client.has_deterministic_signatures()
549
+                or cls._is_suitable_ssh_key(key)
550
+            ):
520 551
                 msg = (
521 552
                     'unsuitable SSH key: bad key, or '
522
-                'signature not deterministic'
553
+                    'signature not deterministic under this agent'
523 554
                 )
524 555
                 raise ValueError(msg)
525
-        with ssh_agent.SSHAgentClient.ensure_agent_subcontext() as client:
526 556
             raw_sig = client.sign(key, cls._UUID)
527 557
         _keytype, trailer = ssh_agent.SSHAgentClient.unstring_prefix(raw_sig)
528 558
         signature_blob = ssh_agent.SSHAgentClient.unstring(trailer)
529 559