Introduce SSH agent socket providers
Marco Ricci

Marco Ricci commited on 2025-08-02 13:12:43
Zeige 7 geänderte Dateien mit 731 Einfügungen und 181 Löschungen.


Introduce a new class of objects, SSH agent socket providers, that
provide sockets connected to SSH agents to the SSH agent client.  In
turn, the clients now depend on the socket providers for establishing
the connection to the agent.  Upon construction, the client queries an
implicit or explicit list of such provider names (from a local, in-code
registry) and uses the first provider that successfully returns
a connected socket.  Providers may be, uh, provided by
`derivepassphrase` itself, or by third-party developers, and they may
depend, e.g., on a certain operating system or Python installation, or
certain installed third-party software.

`derivepassphrase` includes three standard providers: `posix`,
`the_annoying_os` and `native`.  The `posix` provider attempts to
connect via UNIX domain sockets and the `SSH_AUTH_SOCK` environment
variable, as `derivepassphrase` supported it before this commit.  The
`the_annoying_os` provider, currently a stub, is intended to eventually
support connecting via Windows named pipes.  The `native` provider is an
alias to whichever of `posix` and `the_annoying_os` is more appropriate
to this `derivepassphrase` installation.  `derivepassphrase` further
registers and reserves several provider names related to testing, as
well as several aliases for the aforementioned three standard providers.

Third-party socket providers are possible through the
`derivepassphrase.ssh_agent_socket_providers` Python package entry
point, which expects a `SSHAgentSocketProviderEntry` object.  See the
corresponding documentation in the `_types` module.

The intent of the socket provider system is to decouple the SSH agent
client code from the establishing of the connection to the agent.  The
client code can then be (mostly) operating system- and agent-agnostic,
and in turn, the socket provider code can be as operating system- and
third party software-specific as necessary.  Third-party developers can
also add socket providers that cannot or should not be included
with `derivepassphrase` because they are niche, or require additional
software, or have a different level of stability or development cycle
length than `derivepassphrase` has.  Additionally, the decoupled
system can be more easily tested, by mocking the other side of the
connection (which also extends to third-party socket providers).
Several socket provider names are already reserved for the test suite,
as mentioned above.

This new functionality necessitates some large-ish code changes.

The new submodule `derivepassphrase.ssh_agent.socketprovider` contains
all socket provider-related functionality and data, including the
provider registry.  The `derivepassphrase._types` submodule gains a new
type for the socket providers, and for the registry metadata used by the
`derivepassphrase.ssh_agent_socket_providers` entry point.  The SSH
agent client naturally changes its call signature, thus necessitating
typing updates with all callers, direct or indirect.

Somewhat non-obviously, establishing a socket connection is now a search
operation, which can yield multiple unrelated errors.  We thus make use
of PEP 654 Exception Groups, and require new compatibility libraries to
handle this in a cross-Python manner.

Exception groups also make the error handling syntactically more
complex, more so if we're using a compatibility library that restricts
us to old (pre-3.11) syntax: it requires us to write our error handlers
as separate functions, instead of inline code blocks, thus folding much
of the contents of a function `f` into inner functions, or into separate
functions outside of `f`.  Therefore, we use this opportunity to
streamline our error handling and reporting when calling into SSH agent
code.  Specifically, in the `cli_helpers` module, when interactively
selecting an SSH key (`select_ssh_key`) and when converting a key to
a master passphrase (`key_to_phrase`), we now accept and pass through
connection hints to the SSH agent client constructor (now also in
`key_to_phrase`), and handle errors and emit error messages already
within the function (now also in `select_ssh_key`).  In turn, both
`select_ssh_key` and `key_to_phrase` now require error and warning
callbacks to do useful error handling.
... ...
@@ -38,7 +38,11 @@ dependencies = [
38 38
     "typing_extensions",
39 39
     # We read configuration files in JSON and TOML format.  The latter
40 40
     # is unavailable in the Python standard library until Python 3.11.
41
-    'tomli; python_version < "3.11"'
41
+    'tomli; python_version < "3.11"',
42
+    # We use PEP 654 Exception Groups.  Because of compatibility with
43
+    # Python < 3.11, we cannot use the except* syntax, but rather use
44
+    # a compatibility library.
45
+    "exceptiongroup",
42 46
 ]
43 47
 
44 48
 [project.optional-dependencies]
... ...
@@ -30,18 +30,20 @@ from typing import TYPE_CHECKING, cast
30 30
 
31 31
 import click
32 32
 import click.shell_completion
33
+import exceptiongroup
33 34
 from typing_extensions import Any
34 35
 
35 36
 from derivepassphrase import _types, ssh_agent, vault
36 37
 from derivepassphrase._internals import cli_messages as _msg
38
+from derivepassphrase.ssh_agent import socketprovider
37 39
 
38 40
 if sys.version_info >= (3, 11):
39 41
     import tomllib
40 42
 else:
41 43
     import tomli as tomllib
44
+    from exceptiongroup import BaseExceptionGroup
42 45
 
43 46
 if TYPE_CHECKING:
44
-    import socket
45 47
     import types
46 48
     from collections.abc import (
47 49
         Iterator,
... ...
@@ -557,7 +559,11 @@ def load_user_config() -> dict[str, Any]:
557 559
 
558 560
 
559 561
 def get_suitable_ssh_keys(
560
-    conn: ssh_agent.SSHAgentClient | socket.socket | None = None, /
562
+    conn: ssh_agent.SSHAgentClient
563
+    | _types.SSHAgentSocket
564
+    | Sequence[str]
565
+    | None = None,
566
+    /,
561 567
 ) -> Iterator[_types.SSHKeyCommentPair]:
562 568
     """Yield all SSH keys suitable for passphrase derivation.
563 569
 
... ...
@@ -574,16 +580,26 @@ def get_suitable_ssh_keys(
574 580
         derivation.
575 581
 
576 582
     Raises:
583
+        derivepassphrase.ssh_agent.socketprovider.NoSuchProviderError:
584
+            As per
585
+            [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
586
+            Only applicable if agent auto-discovery is used.
577 587
         KeyError:
578
-            `conn` was `None`, and the `SSH_AUTH_SOCK` environment
579
-            variable was not found.
588
+            As per
589
+            [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
590
+            Only applicable if agent auto-discovery is used.
580 591
         NotImplementedError:
581
-            `conn` was `None`, and this Python does not support
582
-            [`socket.AF_UNIX`][], so the SSH agent client cannot be
583
-            automatically set up.
592
+            As per
593
+            [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient],
594
+            including the mulitple raise as an exception group.  Only
595
+            applicable if agent auto-discovery is used.
584 596
         OSError:
585
-            `conn` was a socket or `None`, and there was an error
586
-            setting up a socket connection to the agent.
597
+            If the connection hint was a socket, then there was an error
598
+            setting up the socket connection to the agent.
599
+
600
+            Otherwise, as per
601
+            [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
602
+            Only applicable if agent auto-discovery is used.
587 603
         LookupError:
588 604
             No keys usable for passphrase derivation are loaded into the
589 605
             SSH agent.
... ...
@@ -695,17 +711,132 @@ def prompt_for_selection(
695 711
     return 0
696 712
 
697 713
 
714
+def handle_keyerror(
715
+    error_callback: Callable[..., NoReturn],
716
+    warning_callback: Callable[..., None],
717
+) -> Callable[[BaseExceptionGroup], NoReturn]:
718
+    """Generate a handler for KeyError in try-except*.
719
+
720
+    Returns a function emitting a standard user-facing message.
721
+
722
+    """  # noqa: DOC201
723
+    del warning_callback
724
+
725
+    def handle_keyerror(_excgroup: BaseExceptionGroup) -> NoReturn:
726
+        error_callback(
727
+            _msg.TranslatedString(_msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND)
728
+        )
729
+
730
+    return handle_keyerror
731
+
732
+
733
+def handle_notimplementederror(
734
+    error_callback: Callable[..., NoReturn],
735
+    warning_callback: Callable[..., None],
736
+) -> Callable[[BaseExceptionGroup], NoReturn]:
737
+    """Generate a handler for NotImplementedError in try-except*.
738
+
739
+    Returns a function emitting a standard user-facing message.
740
+
741
+    """  # noqa: DOC201
742
+
743
+    def handle_notimplementederror(excgroup: BaseExceptionGroup) -> NoReturn:
744
+        if excgroup.subgroup(socketprovider.AfUnixNotAvailableError):
745
+            warning_callback(
746
+                _msg.TranslatedString(_msg.WarnMsgTemplate.NO_AF_UNIX)
747
+            )
748
+        if excgroup.subgroup(
749
+            socketprovider.TheAnnoyingOsNamedPipesNotAvailableError
750
+        ):
751
+            warning_callback(
752
+                _msg.TranslatedString(
753
+                    _msg.WarnMsgTemplate.NO_ANNOYING_OS_NAMED_PIPES
754
+                )
755
+            )
756
+        error_callback(
757
+            _msg.TranslatedString(_msg.ErrMsgTemplate.NO_AGENT_SUPPORT)
758
+        )
759
+
760
+    return handle_notimplementederror
761
+
762
+
763
+def handle_oserror(
764
+    error_callback: Callable[..., NoReturn],
765
+    warning_callback: Callable[..., None],
766
+) -> Callable[[BaseExceptionGroup], NoReturn]:
767
+    """Generate a handler for OSError in try-except*.
768
+
769
+    Returns a function emitting a standard user-facing message.
770
+
771
+    """  # noqa: DOC201
772
+    del warning_callback
773
+
774
+    def handle_oserror(excgroup: BaseExceptionGroup) -> NoReturn:
775
+        for exc in excgroup.exceptions:
776
+            assert isinstance(exc, OSError)
777
+            error_callback(
778
+                _msg.TranslatedString(
779
+                    _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT,
780
+                    error=exc.strerror,
781
+                    filename=exc.filename,
782
+                ).maybe_without_filename()
783
+            )
784
+        raise AssertionError()
785
+
786
+    return handle_oserror
787
+
788
+
789
+def handle_runtimeerror(
790
+    error_callback: Callable[..., NoReturn],
791
+    warning_callback: Callable[..., None],
792
+) -> Callable[[BaseExceptionGroup], NoReturn]:
793
+    """Generate a handler for RuntimeError in try-except*.
794
+
795
+    Returns a function emitting a standard user-facing message.
796
+
797
+    """  # noqa: DOC201
798
+    del warning_callback
799
+
800
+    def handle_runtimeerror(excgroup: BaseExceptionGroup) -> NoReturn:
801
+        for exc in excgroup.exceptions:
802
+            error_callback(
803
+                _msg.TranslatedString(
804
+                    _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT
805
+                ),
806
+                exc_info=exc,
807
+            )
808
+        raise AssertionError()
809
+
810
+    return handle_runtimeerror
811
+
812
+
813
+def default_error_callback(
814
+    message: Any,  # noqa: ANN401
815
+    /,
816
+    *_args: Any,  # noqa: ANN401
817
+    **_kwargs: Any,  # noqa: ANN401
818
+) -> NoReturn:  # pragma: no cover [failsafe]
819
+    """Calls [`sys.exit`][] on its first argument, ignoring the rest."""
820
+    sys.exit(message)
821
+
822
+
698 823
 def select_ssh_key(
699
-    conn: ssh_agent.SSHAgentClient | socket.socket | None = None,
824
+    conn: ssh_agent.SSHAgentClient
825
+    | _types.SSHAgentSocket
826
+    | Sequence[str]
827
+    | None = None,
700 828
     /,
701 829
     *,
702 830
     ctx: click.Context | None = None,
831
+    error_callback: Callable[..., NoReturn] = default_error_callback,
832
+    warning_callback: Callable[..., None] = lambda *_args: None,
703 833
 ) -> bytes | bytearray:
704 834
     """Interactively select an SSH key for passphrase derivation.
705 835
 
706 836
     Suitable SSH keys are queried from the running SSH agent (see
707 837
     [`ssh_agent.SSHAgentClient.list_keys`][]), then the user is prompted
708
-    interactively (see [`click.prompt`][]) for a selection.
838
+    interactively (see [`click.prompt`][]) for a selection.  If any
839
+    error occurs, the user receives an appropriate error message.
709 840
 
710 841
     Args:
711 842
         conn:
... ...
@@ -714,32 +845,52 @@ def select_ssh_key(
714 845
         ctx:
715 846
             An `click` context, queried for output device properties and
716 847
             color preferences when issuing the prompt.
848
+        error_callback:
849
+            A callback function for an error message, if any.  The
850
+            callback function is responsible for aborting this function
851
+            call after acknowledging, formatting and/or forwarding the
852
+            error message; it would typically call [`sys.exit`][] or
853
+            raise an exception of its own, based on the provided error
854
+            message.
855
+        warning_callback:
856
+            A callback function for a warning message, if any.  The
857
+            callback function is responsible for formatting the warning
858
+            message and dispatching it into the warning system, if so
859
+            desired.
717 860
 
718 861
     Returns:
719 862
         The selected SSH key.
720 863
 
721
-    Raises:
722
-        KeyError:
723
-            `conn` was `None`, and the `SSH_AUTH_SOCK` environment
724
-            variable was not found.
725
-        NotImplementedError:
726
-            `conn` was `None`, and this Python does not support
727
-            [`socket.AF_UNIX`][], so the SSH agent client cannot be
728
-            automatically set up.
729
-        OSError:
730
-            `conn` was a socket or `None`, and there was an error
731
-            setting up a socket connection to the agent.
732
-        IndexError:
733
-            The user made an invalid or empty selection, or requested an
734
-            abort.
735
-        LookupError:
736
-            No keys usable for passphrase derivation are loaded into the
737
-            SSH agent.
738
-        RuntimeError:
739
-            There was an error communicating with the SSH agent.
740
-        SSHAgentFailedError:
741
-            The agent failed to supply a list of loaded keys.
742 864
     """
865
+
866
+    def handle_lookuperror(_excgroup: BaseExceptionGroup) -> NoReturn:
867
+        error_callback(
868
+            _msg.TranslatedString(
869
+                _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS,
870
+                PROG_NAME=PROG_NAME,
871
+            )
872
+        )
873
+
874
+    def handle_sshagentfailederror(excgroup: BaseExceptionGroup) -> NoReturn:
875
+        for exc in excgroup.exceptions:
876
+            error_callback(
877
+                _msg.TranslatedString(
878
+                    _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS
879
+                ),
880
+                exc_info=exc,
881
+            )
882
+        raise AssertionError()
883
+
884
+    with exceptiongroup.catch({
885
+        KeyError: handle_keyerror(error_callback, warning_callback),
886
+        LookupError: handle_lookuperror,
887
+        NotImplementedError: handle_notimplementederror(
888
+            error_callback, warning_callback
889
+        ),
890
+        OSError: handle_oserror(error_callback, warning_callback),
891
+        ssh_agent.SSHAgentFailedError: handle_sshagentfailederror,
892
+        RuntimeError: handle_runtimeerror(error_callback, warning_callback),
893
+    }):
743 894
         suitable_keys = list(get_suitable_ssh_keys(conn))
744 895
     key_listing: list[str] = []
745 896
     unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix
... ...
@@ -754,12 +905,19 @@ def select_ssh_key(
754 905
         )
755 906
         comment_str = comment.decode('UTF-8', errors='replace')
756 907
         key_listing.append(f'{keytype} {key_extract}  {comment_str}')
908
+    try:
757 909
         choice = prompt_for_selection(
758 910
             key_listing,
759 911
             heading='Suitable SSH keys:',
760 912
             single_choice_prompt='Use this key?',
761 913
             ctx=ctx,
762 914
         )
915
+    except IndexError:
916
+        error_callback(
917
+            _msg.TranslatedString(
918
+                _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION
919
+            )
920
+        )
763 921
     return suitable_keys[choice].key
764 922
 
765 923
 
... ...
@@ -906,21 +1064,16 @@ def check_for_misleading_passphrase(
906 1064
             )
907 1065
 
908 1066
 
909
-def default_error_callback(
910
-    message: Any,  # noqa: ANN401
911
-    /,
912
-    *_args: Any,  # noqa: ANN401
913
-    **_kwargs: Any,  # noqa: ANN401
914
-) -> NoReturn:  # pragma: no cover
915
-    """Calls [`sys.exit`][] on its first argument, ignoring the rest."""
916
-    sys.exit(message)
917
-
918
-
919 1067
 def key_to_phrase(
920 1068
     key: str | Buffer,
921 1069
     /,
922 1070
     *,
923 1071
     error_callback: Callable[..., NoReturn] = default_error_callback,
1072
+    warning_callback: Callable[..., None] = lambda *_args: None,
1073
+    conn: ssh_agent.SSHAgentClient
1074
+    | _types.SSHAgentSocket
1075
+    | Sequence[str]
1076
+    | None = None,
924 1077
 ) -> bytes:
925 1078
     """Return the equivalent master passphrase, or abort.
926 1079
 
... ...
@@ -931,8 +1084,15 @@ def key_to_phrase(
931 1084
 
932 1085
     """
933 1086
     key = base64.standard_b64decode(key)
934
-    try:
935
-        with ssh_agent.SSHAgentClient.ensure_agent_subcontext() as client:
1087
+    with exceptiongroup.catch({  # noqa: SIM117
1088
+        KeyError: handle_keyerror(error_callback, warning_callback),
1089
+        NotImplementedError: handle_notimplementederror(
1090
+            error_callback, warning_callback
1091
+        ),
1092
+        OSError: handle_oserror(error_callback, warning_callback),
1093
+        RuntimeError: handle_runtimeerror(error_callback, warning_callback),
1094
+    }):
1095
+        with ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn) as client:
936 1096
             try:
937 1097
                 return vault.Vault.phrase_from_key(key, conn=client)
938 1098
             except ssh_agent.SSHAgentFailedError as exc:
... ...
@@ -957,27 +1117,6 @@ def key_to_phrase(
957 1117
                     ),
958 1118
                     exc_info=exc,
959 1119
                 )
960
-    except KeyError:
961
-        error_callback(
962
-            _msg.TranslatedString(_msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND)
963
-        )
964
-    except NotImplementedError:
965
-        error_callback(
966
-            _msg.TranslatedString(_msg.ErrMsgTemplate.NO_AGENT_SUPPORT)
967
-        )
968
-    except OSError as exc:
969
-        error_callback(
970
-            _msg.TranslatedString(
971
-                _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT,
972
-                error=exc.strerror,
973
-                filename=exc.filename,
974
-            ).maybe_without_filename()
975
-        )
976
-    except RuntimeError as exc:
977
-        error_callback(
978
-            _msg.TranslatedString(_msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT),
979
-            exc_info=exc,
980
-        )
981 1120
 
982 1121
 
983 1122
 def print_config_as_sh_script(
... ...
@@ -11,20 +11,22 @@ import json
11 11
 import math
12 12
 import string
13 13
 import warnings
14
-from typing import TYPE_CHECKING, Generic, TypeVar, cast
14
+from typing import TYPE_CHECKING, Generic, Protocol, TypeVar, cast
15 15
 
16 16
 from typing_extensions import (
17 17
     Buffer,
18 18
     NamedTuple,
19 19
     NotRequired,
20
+    TypeAlias,
20 21
     TypedDict,
21 22
     deprecated,
22 23
     get_overloads,
23 24
     overload,
25
+    runtime_checkable,
24 26
 )
25 27
 
26 28
 if TYPE_CHECKING:
27
-    from collections.abc import Iterator, Sequence
29
+    from collections.abc import Callable, Iterator, Sequence
28 30
     from typing import Literal
29 31
 
30 32
     from typing_extensions import (
... ...
@@ -878,3 +880,63 @@ class Subcommand(str, enum.Enum):
878 880
 
879 881
     __str__ = str.__str__
880 882
     __format__ = str.__format__  # type: ignore[assignment]
883
+
884
+
885
+@runtime_checkable
886
+class SSHAgentSocket(Protocol):
887
+    """An abstract networking socket connected to an SSH agent.
888
+
889
+    The abstract socket supports the [`sendall`][socket.socket.sendall]
890
+    and a [`recv`][socket.socket.recv] operation, with the same
891
+    signatures and semantics as for "real" sockets.  The abstract socket
892
+    also supports use as a context manager, for automatically closing
893
+    the socket upon exiting the context.
894
+
895
+    """
896
+
897
+    def __enter__(self) -> Any: ...  # noqa: ANN401
898
+
899
+    # mypy/typeshed has a *very* lax annotation of
900
+    # socket.socket.__exit__, which we need to be compatible with.
901
+    # *sigh*
902
+    def __exit__(
903
+        self,
904
+        *args: object,
905
+    ) -> bool | None: ...
906
+
907
+    def sendall(self, data: Buffer, flags: int = 0, /) -> None: ...
908
+
909
+    def recv(self, data: int, flags: int = 0, /) -> bytes: ...
910
+
911
+
912
+SSHAgentSocketProvider: TypeAlias = 'Callable[[], SSHAgentSocket]'
913
+"""A callable that provides an SSH agent socket."""
914
+
915
+
916
+class SSHAgentSocketProviderEntry(NamedTuple):
917
+    """Registry information for the table of SSH agent socket providers.
918
+
919
+    Third-party developers can register new socket providers for
920
+    auto-discovery by setting up an entry point named
921
+    [`derivepassphrase.ssh_agent_socket_providers`][derivepassphrase.ssh_agent.socketprovider.SocketProvider.ENTRY_POINT_GROUP_NAME],
922
+    referencing an instance of this class.  Upon startup of the `vault`
923
+    subsystem, `derivepassphrase` will then add appropriate entries to
924
+    the registry.
925
+
926
+    Attributes:
927
+        provider: The callable that provides the socket.
928
+        key: The table key which this entry is registered as.
929
+        aliases: Other keys that shall point to this entry's key.
930
+
931
+    Note:
932
+        The socket provider registry table uses the key as the key, and
933
+        the provider as the value. It does not store this info object
934
+        directly.
935
+
936
+    """
937
+
938
+    provider: SSHAgentSocketProvider
939
+    """"""
940
+    key: str
941
+    """"""
942
+    aliases: tuple[str, ...]
... ...
@@ -29,7 +29,7 @@ from typing_extensions import (
29 29
     Any,
30 30
 )
31 31
 
32
-from derivepassphrase import _internals, _types, exporter, ssh_agent, vault
32
+from derivepassphrase import _internals, _types, exporter, vault
33 33
 from derivepassphrase._internals import cli_helpers, cli_machinery
34 34
 from derivepassphrase._internals import cli_messages as _msg
35 35
 
... ...
@@ -1049,7 +1049,7 @@ class _VaultContext:  # noqa: PLR0904
1049 1049
                 ).maybe_without_filename(),
1050 1050
             )
1051 1051
 
1052
-    def run_subop_query_phrase_or_key_change(  # noqa: C901,PLR0912
1052
+    def run_subop_query_phrase_or_key_change(
1053 1053
         self,
1054 1054
         *,
1055 1055
         empty_service_permitted: bool,
... ...
@@ -1130,55 +1130,13 @@ class _VaultContext:  # noqa: PLR0904
1130 1130
             )
1131 1131
             raise click.UsageError(str(err_msg))
1132 1132
         if use_key:
1133
-            try:
1134 1133
             settings.maps[0]['key'] = base64.standard_b64encode(
1135
-                    cli_helpers.select_ssh_key(ctx=self.ctx)
1136
-                ).decode('ASCII')
1137
-            except IndexError:
1138
-                self.err(
1139
-                    _msg.TranslatedString(
1140
-                        _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION
1141
-                    ),
1142
-                )
1143
-            except KeyError:
1144
-                self.err(
1145
-                    _msg.TranslatedString(
1146
-                        _msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND
1147
-                    ),
1148
-                )
1149
-            except LookupError:
1150
-                self.err(
1151
-                    _msg.TranslatedString(
1152
-                        _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS,
1153
-                        PROG_NAME=PROG_NAME,
1154
-                    )
1155
-                )
1156
-            except NotImplementedError:
1157
-                self.err(
1158
-                    _msg.TranslatedString(_msg.ErrMsgTemplate.NO_AGENT_SUPPORT)
1159
-                )
1160
-            except OSError as exc:
1161
-                self.err(
1162
-                    _msg.TranslatedString(
1163
-                        _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT,
1164
-                        error=exc.strerror,
1165
-                        filename=exc.filename,
1166
-                    ).maybe_without_filename(),
1167
-                )
1168
-            except ssh_agent.SSHAgentFailedError as exc:
1169
-                self.err(
1170
-                    _msg.TranslatedString(
1171
-                        _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS
1172
-                    ),
1173
-                    exc_info=exc,
1174
-                )
1175
-            except RuntimeError as exc:
1176
-                self.err(
1177
-                    _msg.TranslatedString(
1178
-                        _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT
1179
-                    ),
1180
-                    exc_info=exc,
1134
+                cli_helpers.select_ssh_key(
1135
+                    ctx=self.ctx,
1136
+                    error_callback=self.err,
1137
+                    warning_callback=self.warning,
1181 1138
                 )
1139
+            ).decode('ASCII')
1182 1140
         elif use_phrase:
1183 1141
             maybe_phrase = cli_helpers.prompt_for_passphrase()
1184 1142
             if not maybe_phrase:
... ...
@@ -1414,7 +1372,9 @@ class _VaultContext:  # noqa: PLR0904
1414 1372
         # a key is given.  Finally, if nothing is set, error out.
1415 1373
         if use_key:
1416 1374
             phrase = cli_helpers.key_to_phrase(
1417
-                cast('str', overrides['key']), error_callback=self.err
1375
+                cast('str', overrides['key']),
1376
+                error_callback=self.err,
1377
+                warning_callback=self.warning,
1418 1378
             )
1419 1379
         elif use_phrase:
1420 1380
             phrase = cast('str', overrides['phrase'])
... ...
@@ -8,16 +8,19 @@ from __future__ import annotations
8 8
 
9 9
 import collections
10 10
 import contextlib
11
-import os
12
-import socket
13
-from typing import TYPE_CHECKING, overload
11
+import sys
12
+from collections.abc import Sequence
13
+from typing import TYPE_CHECKING, ClassVar, overload
14 14
 
15 15
 from typing_extensions import Never, Self, assert_type
16 16
 
17 17
 from derivepassphrase import _types
18 18
 
19
+if sys.version_info < (3, 11):
20
+    from exceptiongroup import ExceptionGroup
21
+
19 22
 if TYPE_CHECKING:
20
-    from collections.abc import Iterable, Iterator, Sequence
23
+    from collections.abc import Iterable, Iterator
21 24
     from collections.abc import Set as AbstractSet
22 25
     from types import TracebackType
23 26
 
... ...
@@ -29,8 +32,6 @@ __all__ = ('SSHAgentClient',)
29 32
 # a 4-byte/32-bit unsigned integer at the beginning.
30 33
 HEAD_LEN = 4
31 34
 
32
-_socket = socket
33
-
34 35
 
35 36
 class TrailingDataError(RuntimeError):
36 37
     """The result contained trailing data."""
... ...
@@ -78,60 +79,92 @@ class SSHAgentClient:
78 79
 
79 80
     """
80 81
 
81
-    _connection: socket.socket
82
+    _connection: _types.SSHAgentSocket
83
+    SOCKET_PROVIDERS: ClassVar = ['native']
82 84
 
83
-    def __init__(
84
-        self, /, *, socket: socket.socket | None = None, timeout: int = 125
85
+    def __init__(  # noqa: C901, PLR0912
86
+        self,
87
+        /,
88
+        *,
89
+        socket: _types.SSHAgentSocket | Sequence[str] | None = None,
85 90
     ) -> None:
86 91
         """Initialize the client.
87 92
 
88 93
         Args:
89 94
             socket:
90
-                An optional socket, already connected to the SSH agent.
91
-                If not given, we query the `SSH_AUTH_SOCK` environment
92
-                variable to auto-discover the correct socket address.
93
-
94
-                [We currently only support connecting via UNIX domain
95
-                sockets][issue13], and only on platforms with support
96
-                for [`socket.AF_UNIX`][AF_UNIX].
97
-
98
-                [issue13]: https://github.com/the-13th-letter/derivepassphrase/issues/13
99
-                [AF_UNIX]: https://docs.python.org/3/library/socket.html#socket.AF_UNIX
100
-            timeout:
101
-                A connection timeout for the SSH agent.  Only used if
102
-                the socket is not yet connected.  The default value
103
-                gives ample time for agent connections forwarded via
104
-                SSH on high-latency networks (e.g. Tor).
95
+                An optional socket-like object, already connected to the
96
+                SSH agent, or a list of names of socket providers to try.
97
+                If not given, we query platform-specific default
98
+                addresses, if possible.
105 99
 
106 100
         Raises:
101
+            derivepassphrase.ssh_agent.socketprovider.NoSuchProviderError:
102
+                The list of socket provider names contained an invalid
103
+                entry.
107 104
             KeyError:
108
-                The `SSH_AUTH_SOCK` environment variable was not found.
105
+                An expected configuration entry or an expected environment
106
+                variable is missing.
109 107
             NotImplementedError:
110
-                This Python version does not support UNIX domain
111
-                sockets, necessary to automatically connect to a running
112
-                SSH agent via the `SSH_AUTH_SOCK` environment variable.
108
+                The named socket provider is not functional or not
109
+                applicable to this `derivepassphrase` installation, e.g.
110
+                because it is generally not implemented yet, or it requires
111
+                a specific operating system, or specific system
112
+                functionality that is not provided by all Python versions,
113
+                or external packages that are unavailable on this
114
+                installation.
115
+
116
+                This error may be raised multiple times, as an exception
117
+                group.
113 118
             OSError:
114 119
                 There was an error setting up a socket connection to the
115 120
                 agent.
116 121
 
117
-        """
118
-        if socket is not None:
122
+        """  # noqa: DOC501
123
+        import socket as _socket  # noqa: PLC0415
124
+
125
+        from derivepassphrase.ssh_agent import socketprovider  # noqa: PLC0415
126
+
127
+        if isinstance(socket, _socket.socket):
119 128
             self._connection = socket
120 129
             # Test whether the socket is connected.
121 130
             self._connection.getpeername()
131
+        elif isinstance(socket, str):
132
+            self._connection = socketprovider.SocketProvider.resolve(socket)()
133
+        elif socket is None or isinstance(socket, Sequence):
134
+            if not socket:
135
+                socket = self.SOCKET_PROVIDERS
136
+            assert isinstance(socket, Sequence)  # for the type checker
137
+            excs: list[NotImplementedError] = []
138
+            providers: list[_types.SSHAgentSocketProvider] = []
139
+            for candidate in socket:
140
+                try:
141
+                    provider = socketprovider.SocketProvider.resolve(candidate)
142
+                except NotImplementedError as exc:
143
+                    excs.append(exc)
144
+                    continue
145
+                else:
146
+                    providers.append(provider)
147
+            for provider in providers:
148
+                try:
149
+                    self._connection = provider()
150
+                except NotImplementedError as exc:
151
+                    excs.append(exc)
152
+                    continue
122 153
                 else:
123
-            if not hasattr(_socket, 'AF_UNIX'):
124
-                msg = (
125
-                    'This Python version does not support UNIX domain sockets'
154
+                    break
155
+            else:
156
+                msg = 'No supported SSH agent socket provider found.'
157
+                raise (
158
+                    ExceptionGroup(msg, excs)
159
+                    if excs
160
+                    else NotImplementedError(msg)
126 161
                 )
127
-                raise NotImplementedError(msg)
128
-            self._connection = _socket.socket(family=_socket.AF_UNIX)
129
-            if 'SSH_AUTH_SOCK' not in os.environ:
130
-                msg = 'SSH_AUTH_SOCK environment variable'
131
-                raise KeyError(msg)
132
-            ssh_auth_sock = os.environ['SSH_AUTH_SOCK']
133
-            self._connection.settimeout(timeout)
134
-            self._connection.connect(ssh_auth_sock)
162
+        elif isinstance(socket, _types.SSHAgentSocket):
163
+            self._connection = socket
164
+        else:  # pragma: no cover [failsafe]
165
+            assert_type(socket, Never)
166
+            msg = f'invalid socket object: {socket!r}'
167
+            raise TypeError(msg)
135 168
 
136 169
     def __enter__(self) -> Self:
137 170
         """Close socket connection upon context manager completion.
... ...
@@ -289,7 +322,10 @@ class SSHAgentClient:
289 322
     @contextlib.contextmanager
290 323
     def ensure_agent_subcontext(
291 324
         cls,
292
-        conn: SSHAgentClient | socket.socket | None = None,
325
+        conn: SSHAgentClient
326
+        | _types.SSHAgentSocket
327
+        | Sequence[str]
328
+        | None = None,
293 329
     ) -> Iterator[SSHAgentClient]:
294 330
         """Return an SSH agent client subcontext.
295 331
 
... ...
@@ -307,8 +343,9 @@ class SSHAgentClient:
307 343
                 exiting the context, the client is destroyed and the
308 344
                 socket is closed.
309 345
 
310
-                If `None`, construct a client using agent
311
-                auto-discovery, then enter a context within this
346
+                If `None`, or a list of names of socket providers, then
347
+                construct a client according to those connection hints
348
+                ("auto-discovery"), and enter a context within this
312 349
                 client's scope.  After exiting the context, both the
313 350
                 client and its socket are destroyed.
314 351
 
... ...
@@ -316,16 +353,15 @@ class SSHAgentClient:
316 353
             When entering this context, return the SSH agent client.
317 354
 
318 355
         Raises:
356
+            derivepassphrase.ssh_agent.socketprovider.NoSuchProviderError:
357
+                As per [`__init__`][SSHAgentClient].
319 358
             KeyError:
320
-                `conn` was `None`, and the `SSH_AUTH_SOCK` environment
321
-                variable was not found.
359
+                As per [`__init__`][SSHAgentClient], including the
360
+                multiple raise as an exception group.
322 361
             NotImplementedError:
323
-                `conn` was `None`, and this Python does not support
324
-                [`socket.AF_UNIX`][], so the SSH agent client cannot be
325
-                automatically set up.
362
+                As per [`__init__`][SSHAgentClient].
326 363
             OSError:
327
-                `conn` was a socket or `None`, and there was an error
328
-                setting up a socket connection to the agent.
364
+                As per [`__init__`][SSHAgentClient].
329 365
 
330 366
         """  # noqa: DOC501
331 367
         # TODO(the-13th-letter): Rewrite using structural pattern matching.
... ...
@@ -333,7 +369,10 @@ class SSHAgentClient:
333 369
         if isinstance(conn, SSHAgentClient):
334 370
             with contextlib.nullcontext():
335 371
                 yield conn
336
-        elif isinstance(conn, socket.socket) or conn is None:
372
+        elif (
373
+            isinstance(conn, (_types.SSHAgentSocket, str, Sequence))
374
+            or conn is None
375
+        ):
337 376
             with SSHAgentClient(socket=conn) as client:
338 377
                 yield client
339 378
         else:  # pragma: no cover [failsafe]
... ...
@@ -0,0 +1,334 @@
1
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: Zlib
4
+
5
+"""Machinery for providing sockets connected to SSH agents."""
6
+
7
+from __future__ import annotations
8
+
9
+import collections
10
+import ctypes
11
+import os
12
+import socket
13
+from typing import TYPE_CHECKING, ClassVar, cast
14
+
15
+if TYPE_CHECKING:
16
+    from collections.abc import Callable
17
+
18
+    from derivepassphrase import _types
19
+
20
+__all__ = ('SocketProvider',)
21
+
22
+
23
+class NoSuchProviderError(KeyError):
24
+    """No such SSH agent socket provider is known."""
25
+
26
+
27
+class AfUnixNotAvailableError(NotImplementedError):
28
+    """This Python installation does not support socket.AF_UNIX."""
29
+
30
+
31
+class TheAnnoyingOsNamedPipesNotAvailableError(NotImplementedError):
32
+    """This Python installation does not support Windows named pipes."""
33
+
34
+
35
+class SocketProvider:
36
+    """Static functionality for providing sockets."""
37
+
38
+    @staticmethod
39
+    def unix_domain_ssh_auth_sock(*, timeout: int = 125) -> socket.socket:
40
+        """Return a UNIX domain socket connected to `SSH_AUTH_SOCK`.
41
+
42
+        Args:
43
+            timeout:
44
+                A connection timeout for the SSH agent.  Only used for
45
+                "true" sockets, and only if the socket is not yet connected.
46
+                The default value gives ample time for agent connections
47
+                forwarded via SSH on high-latency networks (e.g. Tor).
48
+
49
+        Returns:
50
+            A connected UNIX domain socket.
51
+
52
+        Raises:
53
+            KeyError:
54
+                The `SSH_AUTH_SOCK` environment variable was not found.
55
+            AfUnixNotAvailableError:
56
+                [This Python version does not support UNIX domain
57
+                sockets][AF_UNIX], necessary to automatically connect to
58
+                a running SSH agent via the `SSH_AUTH_SOCK` environment
59
+                variable.
60
+
61
+                [AF_UNIX]: https://docs.python.org/3/library/socket.html#socket.AF_UNIX
62
+            OSError:
63
+                There was an error setting up a socket connection to the
64
+                agent.
65
+
66
+        """
67
+        if not hasattr(socket, 'AF_UNIX'):
68
+            msg = 'This Python version does not support UNIX domain sockets.'
69
+            raise AfUnixNotAvailableError(msg)
70
+        else:  # noqa: RET506  # pragma: unless posix no cover
71
+            sock = socket.socket(family=socket.AF_UNIX)
72
+            if 'SSH_AUTH_SOCK' not in os.environ:
73
+                msg = 'SSH_AUTH_SOCK environment variable'
74
+                raise KeyError(msg)
75
+            ssh_auth_sock = os.environ['SSH_AUTH_SOCK']
76
+            sock.settimeout(timeout)
77
+            sock.connect(ssh_auth_sock)
78
+            return sock
79
+
80
+    @staticmethod
81
+    def the_annoying_os_named_pipes() -> _types.SSHAgentSocket:
82
+        """Return a socket connected to Pageant/OpenSSH on The Annoying OS.
83
+
84
+        This may be a write-through socket if the underlying connection
85
+        to Pageant or OpenSSH does not use an actual network socket to
86
+        communicate.
87
+
88
+        Raises:
89
+            TheAnnoyingOsNamedPipesNotAvailableError:
90
+                This functionality is not implemented yet.
91
+
92
+        Warning: Not implemented yet
93
+            This functionality is not implemented yet.  Specifically,
94
+            [we do not yet support any of the communication mechanisms
95
+            used by the leading SSH agent
96
+            implementations.][windows-ssh-agent-support]
97
+
98
+            [windows-ssh-agent-support]: https://the13thletter.info/derivepassphrase/0.x/wishlist/windows-ssh-agent-support/
99
+
100
+        """
101
+        if not hasattr(ctypes, 'WinDLL'):
102
+            msg = 'This Python version does not support Windows named pipes.'
103
+            raise TheAnnoyingOsNamedPipesNotAvailableError(msg)
104
+        else:  # noqa: RET506  # pragma: unless the-annoying-os no cover
105
+            msg = (
106
+                'Communicating with Pageant or OpenSSH on Windows '
107
+                'is not implemented yet.'
108
+            )
109
+            raise NotImplementedError(msg)
110
+
111
+    registry: ClassVar[
112
+        dict[str, _types.SSHAgentSocketProvider | str | None]
113
+    ] = {}
114
+    """A dictionary of callables that provide SSH agent sockets.
115
+
116
+    Each entry in the dictionary points either to a callable, a string,
117
+    or `None`: if a callable, then that callable returns a socket; if
118
+    a string, then this entry is an alias for that other entry; if
119
+    `None`, then the entry name is merely registered, but no
120
+    implementation is available.
121
+
122
+    If a callable is not applicable to this platform, Python
123
+    installation or `derivepassphrase` installation, then it MUST raise
124
+    [`NotImplementedError`][].  Conversely, if the callable returns
125
+    a value, or raises any other kind of exception, then the caller MAY
126
+    assume that this platform, Python installation and
127
+    `derivepassphrase` installation are sufficient for the callable to
128
+    be able to return a working socket on this platform.  (The latter
129
+    may still be dependent on further, external circumstances, such as
130
+    required configuration settings, or environment variables, or
131
+    sufficient system resources, etc.)
132
+
133
+    (Interpretation of "MUST" and "MAY" as per IETF Best Current
134
+    Practice #14; see [RFC 2119][] and [RFC 8174][].)
135
+
136
+    [RFC 2119]: https://www.rfc-editor.org/info/rfc2119
137
+    [RFC 8174]: https://www.rfc-editor.org/info/rfc8174
138
+
139
+    """
140
+
141
+    @classmethod
142
+    def register(
143
+        cls,
144
+        name: str,
145
+        *aliases: str,
146
+    ) -> Callable[
147
+        [_types.SSHAgentSocketProvider], _types.SSHAgentSocketProvider
148
+    ]:
149
+        """Register a callable as an SSH agent socket provider (decorator).
150
+
151
+        Attempting to re-register an existing alias, or a name with an
152
+        implementation, with a different implementation is an error.
153
+
154
+        Args:
155
+            name:
156
+                The principal name under which to register the passed
157
+                callable.
158
+            aliases:
159
+                Alternate names to register as aliases for the principal
160
+                name.
161
+
162
+        Returns:
163
+            A decorator implementing the above.
164
+
165
+        """
166
+
167
+        def decorator(
168
+            f: _types.SSHAgentSocketProvider,
169
+        ) -> _types.SSHAgentSocketProvider:
170
+            """Register a callable as an SSH agent socket provider.
171
+
172
+            Attempting to re-register an existing alias, or a name with
173
+            an implementation, with a different implementation is an
174
+            error.
175
+
176
+            Args:
177
+                f: The callable to decorate/register.
178
+
179
+            Returns:
180
+                The callable.
181
+
182
+            Raises:
183
+                ValueError:
184
+                    The name or alias is already in use.
185
+
186
+            """
187
+            for alias in [name, *aliases]:
188
+                try:
189
+                    existing = cls.resolve(alias)
190
+                except (NoSuchProviderError, NotImplementedError):
191
+                    cls.registry[alias] = f if alias == name else name
192
+                else:
193
+                    if existing != f:
194
+                        msg = (
195
+                            f'The SSH agent socket provider {alias!r} '
196
+                            f'is already registered.'
197
+                        )
198
+                        raise ValueError(msg)
199
+            return f
200
+
201
+        return decorator
202
+
203
+    @classmethod
204
+    def resolve(
205
+        cls, provider: Callable[[], _types.SSHAgentSocket] | str | None, /
206
+    ) -> Callable[[], _types.SSHAgentSocket]:
207
+        """Resolve a socket provider to a proper callable.
208
+
209
+        Args:
210
+            provider: The provider to resolve.
211
+
212
+        Returns:
213
+            The callable indicated by this provider.
214
+
215
+        Raises:
216
+            NoSuchProviderError:
217
+                The provider is not registered.
218
+            NotImplementedError:
219
+                The provider is registered, but is not functional or not
220
+                applicable to this `derivepassphrase` installation.
221
+
222
+        """
223
+        ret = provider
224
+        while isinstance(ret, str):
225
+            try:
226
+                ret = cls.registry[ret]
227
+            except KeyError as exc:
228
+                raise NoSuchProviderError(ret) from exc
229
+        if ret is None:
230
+            msg = (
231
+                f'The {ret!r} socket provider is not functional on or '
232
+                'not applicable to this derivepassphrase installation.'
233
+            )
234
+            raise NotImplementedError(msg)
235
+        return ret
236
+
237
+    ENTRY_POINT_GROUP_NAME = 'derivepassphrase.ssh_agent_socket_providers'
238
+    """
239
+    The group name under which [entry
240
+    points][importlib.metadata.entry_points] for the SSH agent socket
241
+    provider registry should be recorded.  Each target of such an entry
242
+    point should be a [`_types.SSHAgentSocketProviderEntry`][] object.
243
+    """
244
+
245
+    @classmethod
246
+    def _find_all_ssh_agent_socket_providers(cls) -> None:
247
+        """Find and load all declared SSH agent socket providers.
248
+
249
+        Load all [entry points][importlib.metadata.entry_points] in the
250
+        `derivepassphrase.ssh_agent_socket_providers` group as providers,
251
+        then register them.  The target of each entry point should be
252
+        a [`_types.SSHAgentSocketProviderEntry`][] object.
253
+
254
+        Raises:
255
+            AssertionError:
256
+                The declared SSH agent socket provider was not, in fact,
257
+                an SSH agent socket provider.
258
+
259
+                Alternatively, multiple distributions supplied the same
260
+                SSH agent socket provider, but with different
261
+                implementations.
262
+
263
+        """
264
+        import importlib.metadata  # noqa: PLC0415
265
+
266
+        origins: dict[str, str | None] = {}
267
+        entries = collections.ChainMap({}, cls.registry)
268
+        for entry_point in importlib.metadata.entry_points(
269
+            group=cls.ENTRY_POINT_GROUP_NAME
270
+        ):
271
+            provider_entry = cast(
272
+                '_types.SSHAgentSocketProviderEntry', entry_point.load()
273
+            )
274
+            key = provider_entry.key
275
+            value = entry_point.value
276
+            dist = (
277
+                entry_point.dist.name  # type: ignore[union-attr]
278
+                if getattr(entry_point, 'dist', None) is not None
279
+                else None
280
+            )
281
+            origin = origins.get(key, 'derivepassphrase')
282
+            if not callable(provider_entry.provider):
283
+                msg = (
284
+                    f'Not an SSHAgentSocketProvider: '
285
+                    f'{dist = }, {cls.ENTRY_POINT_GROUP_NAME = }, '
286
+                    f'{value = }, {provider_entry = }'
287
+                )
288
+                raise AssertionError(msg)  # noqa: TRY004
289
+            if key in entries:
290
+                if entries[key] != provider_entry.provider:
291
+                    msg = (
292
+                        f'Name clash in SSH agent socket providers '
293
+                        f'for entry {key!r}, both by {dist!r} '
294
+                        f'and by {origin!r}'
295
+                    )
296
+                    raise AssertionError(msg)
297
+            else:
298
+                entries[key] = provider_entry.provider
299
+                origins[key] = dist
300
+            for alias in provider_entry.aliases:
301
+                alias_origin = origins.get(alias, 'derivepassphrase')
302
+                if alias in entries:
303
+                    if entries[alias] != key:
304
+                        msg = (
305
+                            f'Name clash in SSH agent socket providers '
306
+                            f'for entry {alias!r}, both by {dist!r} '
307
+                            f'and by {alias_origin!r}'
308
+                        )
309
+                        raise AssertionError(msg)
310
+                else:
311
+                    entries[alias] = key
312
+                    origins[key] = dist
313
+        cls.registry.update(entries.maps[0])
314
+
315
+
316
+SocketProvider.registry.update({
317
+    'posix': SocketProvider.unix_domain_ssh_auth_sock,
318
+    'the_annoying_os': SocketProvider.the_annoying_os_named_pipes,
319
+    # known instances
320
+    'stub_agent': None,
321
+    'stub_with_address': None,
322
+    'stub_with_address_and_deterministic_dsa': None,
323
+    # aliases
324
+    'native': 'the_annoying_os' if os.name == 'nt' else 'posix',
325
+    'unix_domain': 'posix',
326
+    'ssh_auth_sock': 'posix',
327
+    'the_annoying_os_named_pipe': 'the_annoying_os',
328
+    'pageant_on_the_annoying_os': 'the_annoying_os',
329
+    'openssh_on_the_annoying_os': 'the_annoying_os',
330
+    'windows': 'the_annoying_os',
331
+    'windows_named_pipe': 'the_annoying_os',
332
+    'pageant_on_windows': 'the_annoying_os',
333
+    'openssh_on_windows': 'the_annoying_os',
334
+})
... ...
@@ -16,11 +16,10 @@ from typing import TYPE_CHECKING, Final
16 16
 
17 17
 from typing_extensions import TypeAlias, assert_type
18 18
 
19
-from derivepassphrase import sequin, ssh_agent
19
+from derivepassphrase import _types, sequin, ssh_agent
20 20
 
21 21
 if TYPE_CHECKING:
22
-    import socket
23
-    from collections.abc import Callable
22
+    from collections.abc import Callable, Sequence
24 23
 
25 24
     from typing_extensions import Buffer
26 25
 
... ...
@@ -520,7 +519,10 @@ class Vault:
520 519
         key: Buffer,
521 520
         /,
522 521
         *,
523
-        conn: ssh_agent.SSHAgentClient | socket.socket | None = None,
522
+        conn: ssh_agent.SSHAgentClient
523
+        | _types.SSHAgentSocket
524
+        | Sequence[str]
525
+        | None = None,
524 526
     ) -> bytes:
525 527
         """Obtain the master passphrase from a configured SSH key.
526 528
 
... ...
@@ -541,16 +543,26 @@ class Vault:
541 543
             unframed but encoded in base64.
542 544
 
543 545
         Raises:
546
+            derivepassphrase.ssh_agent.socketprovider.NoSuchProviderError:
547
+                As per
548
+                [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
549
+                Only applicable if agent auto-discovery is used.
544 550
             KeyError:
545
-                `conn` was `None`, and the `SSH_AUTH_SOCK` environment
546
-                variable was not found.
551
+                As per
552
+                [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
553
+                Only applicable if agent auto-discovery is used.
547 554
             NotImplementedError:
548
-                `conn` was `None`, and this Python does not support
549
-                [`socket.AF_UNIX`][], so the SSH agent client cannot be
550
-                automatically set up.
555
+                As per
556
+                [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient],
557
+                including the mulitple raise as an exception group.
558
+                Only applicable if agent auto-discovery is used.
551 559
             OSError:
552
-                `conn` was a socket or `None`, and there was an error
553
-                setting up a socket connection to the agent.
560
+                If the connection hint was a socket, then there was an
561
+                error setting up the socket connection to the agent.
562
+
563
+                Otherwise, as per
564
+                [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
565
+                Only applicable if agent auto-discovery is used.
554 566
             ValueError:
555 567
                 The SSH key is principally unsuitable for this use case.
556 568
                 Usually this means that the signature is not
557 569