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 |