Marco Ricci commited on 2024-07-28 21:32:08
              Zeige 15 geänderte Dateien mit 612 Einfügungen und 619 Löschungen.
            
These modules were already strongly coupled to `derivepassphrase`, and prone to namespace collisions (particularly `ssh_agent_client`). Furthermore, maintaining the author and version information in three top-level modules is error-prone. A direct inclusion of `sequin` and `ssh_agent_client` revealed a circular import, so the bulk of `derivepassphrase` was delegated to a new submodule `derivepassphrase.vault`. This also yields a cleaner import graph. Also, because the shorter name makes more sense in this context, rename `derivepassphrase.ssh_agent_client` to `derivepassphrase.ssh_agent`. The two `types` submodules are not yet consolidated.
| ... | ... | 
                      @@ -63,7 +63,7 @@ exclude = [  | 
                  
| 63 | 63 | 
                        ]  | 
                    
| 64 | 64 | 
                         | 
                    
| 65 | 65 | 
                        [tool.hatch.build.targets.wheel]  | 
                    
| 66 | 
                        -packages = ['src/derivepassphrase', 'src/sequin', 'src/ssh_agent_client']  | 
                    |
| 66 | 
                        +packages = ['src/derivepassphrase']  | 
                    |
| 67 | 67 | 
                         | 
                    
| 68 | 68 | 
                        [tool.hatch.envs.hatch-test]  | 
                    
| 69 | 69 | 
                        default-args = ['src', 'tests']  | 
                    
| ... | ... | 
                      @@ -99,7 +99,7 @@ check = "mypy --install-types --non-interactive {args:src/derivepassphrase tests
                     | 
                  
| 99 | 99 | 
                        directory = "html/coverage"  | 
                    
| 100 | 100 | 
                         | 
                    
| 101 | 101 | 
                        [tool.coverage.run]  | 
                    
| 102 | 
                        -source_pkgs = ["derivepassphrase", "sequin", "ssh_agent_client", "tests"]  | 
                    |
| 102 | 
                        +source_pkgs = ["derivepassphrase", "tests"]  | 
                    |
| 103 | 103 | 
                        branch = true  | 
                    
| 104 | 104 | 
                        parallel = true  | 
                    
| 105 | 105 | 
                        omit = [  | 
                    
| ... | ... | 
                      @@ -4,537 +4,5 @@  | 
                  
| 4 | 4 | 
                         | 
                    
| 5 | 5 | 
                        """Work-alike of vault(1) – a deterministic, stateless password manager""" # noqa: RUF002  | 
                    
| 6 | 6 | 
                         | 
                    
| 7 | 
                        -from __future__ import annotations  | 
                    |
| 8 | 
                        -  | 
                    |
| 9 | 
                        -import base64  | 
                    |
| 10 | 
                        -import collections  | 
                    |
| 11 | 
                        -import hashlib  | 
                    |
| 12 | 
                        -import math  | 
                    |
| 13 | 
                        -import unicodedata  | 
                    |
| 14 | 
                        -from collections.abc import Callable  | 
                    |
| 15 | 
                        -from typing import TypeAlias  | 
                    |
| 16 | 
                        -  | 
                    |
| 17 | 
                        -from typing_extensions import assert_type  | 
                    |
| 18 | 
                        -  | 
                    |
| 19 | 
                        -import sequin  | 
                    |
| 20 | 
                        -import ssh_agent_client  | 
                    |
| 21 | 
                        -  | 
                    |
| 22 | 7 | 
                        __author__ = 'Marco Ricci <m@the13thletter.info>'  | 
                    
| 23 | 8 | 
                        __version__ = '0.1.3'  | 
                    
| 24 | 
                        -  | 
                    |
| 25 | 
                        -  | 
                    |
| 26 | 
                        -class AmbiguousByteRepresentationError(ValueError):  | 
                    |
| 27 | 
                        - """The object has an ambiguous byte representation."""  | 
                    |
| 28 | 
                        -  | 
                    |
| 29 | 
                        - def __init__(self) -> None:  | 
                    |
| 30 | 
                        -        super().__init__('text string has ambiguous byte representation')
                       | 
                    |
| 31 | 
                        -  | 
                    |
| 32 | 
                        -  | 
                    |
| 33 | 
                        -_CHARSETS = collections.OrderedDict([  | 
                    |
| 34 | 
                        -    ('lower', b'abcdefghijklmnopqrstuvwxyz'),
                       | 
                    |
| 35 | 
                        -    ('upper', b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),
                       | 
                    |
| 36 | 
                        -    ('alpha', b''),  # Placeholder.
                       | 
                    |
| 37 | 
                        -    ('number', b'0123456789'),
                       | 
                    |
| 38 | 
                        -    ('alphanum', b''),  # Placeholder.
                       | 
                    |
| 39 | 
                        -    ('space', b' '),
                       | 
                    |
| 40 | 
                        -    ('dash', b'-_'),
                       | 
                    |
| 41 | 
                        -    ('symbol', b'!"#$%&\'()*+,./:;<=>?@[\\]^{|}~-_'),
                       | 
                    |
| 42 | 
                        -    ('all', b''),  # Placeholder.
                       | 
                    |
| 43 | 
                        -])  | 
                    |
| 44 | 
                        -_CHARSETS['alpha'] = _CHARSETS['lower'] + _CHARSETS['upper']  | 
                    |
| 45 | 
                        -_CHARSETS['alphanum'] = _CHARSETS['alpha'] + _CHARSETS['number']  | 
                    |
| 46 | 
                        -_CHARSETS['all'] = (  | 
                    |
| 47 | 
                        - _CHARSETS['alphanum'] + _CHARSETS['space'] + _CHARSETS['symbol']  | 
                    |
| 48 | 
                        -)  | 
                    |
| 49 | 
                        -  | 
                    |
| 50 | 
                        -  | 
                    |
| 51 | 
                        -class Vault:  | 
                    |
| 52 | 
                        - """A work-alike of James Coglan's vault.  | 
                    |
| 53 | 
                        -  | 
                    |
| 54 | 
                        - Store settings for generating (actually: deriving) passphrases for  | 
                    |
| 55 | 
                        - named services, with various constraints, given only a master  | 
                    |
| 56 | 
                        - passphrase. Also, actually generate the passphrase. The derivation  | 
                    |
| 57 | 
                        - is deterministic and non-secret; only the master passphrase need be  | 
                    |
| 58 | 
                        - kept secret. The implementation is compatible with [vault][].  | 
                    |
| 59 | 
                        -  | 
                    |
| 60 | 
                        - [James Coglan explains the passphrase derivation algorithm in great  | 
                    |
| 61 | 
                        - detail][ALGORITHM] in his blog post on said topic: A principally  | 
                    |
| 62 | 
                        - infinite bit stream is obtained by running a key-derivation function  | 
                    |
| 63 | 
                        - on the master passphrase and the service name, then this bit stream  | 
                    |
| 64 | 
                        - is fed into a [Sequin][sequin.Sequin] to generate random numbers in  | 
                    |
| 65 | 
                        - the correct range, and finally these random numbers select  | 
                    |
| 66 | 
                        - passphrase characters until the desired length is reached.  | 
                    |
| 67 | 
                        -  | 
                    |
| 68 | 
                        - [vault]: https://getvau.lt  | 
                    |
| 69 | 
                        - [ALGORITHM]: https://blog.jcoglan.com/2012/07/16/designing-vaults-generator-algorithm/  | 
                    |
| 70 | 
                        -  | 
                    |
| 71 | 
                        - """  | 
                    |
| 72 | 
                        -  | 
                    |
| 73 | 
                        - _UUID = b'e87eb0f4-34cb-46b9-93ad-766c5ab063e7'  | 
                    |
| 74 | 
                        - """A tag used by vault in the bit stream generation."""  | 
                    |
| 75 | 
                        - _CHARSETS = _CHARSETS  | 
                    |
| 76 | 
                        - """  | 
                    |
| 77 | 
                        - Known character sets from which to draw passphrase characters.  | 
                    |
| 78 | 
                        - Relies on a certain, fixed order for their definition and their  | 
                    |
| 79 | 
                        - contents.  | 
                    |
| 80 | 
                        -  | 
                    |
| 81 | 
                        - """  | 
                    |
| 82 | 
                        -  | 
                    |
| 83 | 
                        - def __init__(  | 
                    |
| 84 | 
                        - self,  | 
                    |
| 85 | 
                        - *,  | 
                    |
| 86 | 
                        - phrase: bytes | bytearray | str = b'',  | 
                    |
| 87 | 
                        - length: int = 20,  | 
                    |
| 88 | 
                        - repeat: int = 0,  | 
                    |
| 89 | 
                        - lower: int | None = None,  | 
                    |
| 90 | 
                        - upper: int | None = None,  | 
                    |
| 91 | 
                        - number: int | None = None,  | 
                    |
| 92 | 
                        - space: int | None = None,  | 
                    |
| 93 | 
                        - dash: int | None = None,  | 
                    |
| 94 | 
                        - symbol: int | None = None,  | 
                    |
| 95 | 
                        - ) -> None:  | 
                    |
| 96 | 
                        - """Initialize the Vault object.  | 
                    |
| 97 | 
                        -  | 
                    |
| 98 | 
                        - Args:  | 
                    |
| 99 | 
                        - phrase:  | 
                    |
| 100 | 
                        - The master passphrase from which to derive the service  | 
                    |
| 101 | 
                        - passphrases. If a text string, then the byte  | 
                    |
| 102 | 
                        - representation must be unique.  | 
                    |
| 103 | 
                        - length:  | 
                    |
| 104 | 
                        - Desired passphrase length.  | 
                    |
| 105 | 
                        - repeat:  | 
                    |
| 106 | 
                        - The maximum number of immediate character repetitions  | 
                    |
| 107 | 
                        - allowed in the passphrase. Disabled if set to 0.  | 
                    |
| 108 | 
                        - lower:  | 
                    |
| 109 | 
                        - Optional constraint on ASCII lowercase characters. If  | 
                    |
| 110 | 
                        - positive, include this many lowercase characters  | 
                    |
| 111 | 
                        - somewhere in the passphrase. If 0, avoid lowercase  | 
                    |
| 112 | 
                        - characters altogether.  | 
                    |
| 113 | 
                        - upper:  | 
                    |
| 114 | 
                        - Same as `lower`, but for ASCII uppercase characters.  | 
                    |
| 115 | 
                        - number:  | 
                    |
| 116 | 
                        - Same as `lower`, but for ASCII digits.  | 
                    |
| 117 | 
                        - space:  | 
                    |
| 118 | 
                        - Same as `lower`, but for the space character.  | 
                    |
| 119 | 
                        - dash:  | 
                    |
| 120 | 
                        - Same as `lower`, but for the hyphen-minus and underscore  | 
                    |
| 121 | 
                        - characters.  | 
                    |
| 122 | 
                        - symbol:  | 
                    |
| 123 | 
                        - Same as `lower`, but for all other hitherto unlisted  | 
                    |
| 124 | 
                        - ASCII printable characters (except backquote).  | 
                    |
| 125 | 
                        -  | 
                    |
| 126 | 
                        - Raises:  | 
                    |
| 127 | 
                        - AmbiguousByteRepresentationError:  | 
                    |
| 128 | 
                        - The phrase is a text string with differing NFC- and  | 
                    |
| 129 | 
                        - NFD-normalized UTF-8 byte representations.  | 
                    |
| 130 | 
                        -  | 
                    |
| 131 | 
                        - """  | 
                    |
| 132 | 
                        - self._phrase = self._get_binary_string(phrase)  | 
                    |
| 133 | 
                        - self._length = length  | 
                    |
| 134 | 
                        - self._repeat = repeat  | 
                    |
| 135 | 
                        - self._allowed = bytearray(self._CHARSETS['all'])  | 
                    |
| 136 | 
                        - self._required: list[bytes] = []  | 
                    |
| 137 | 
                        -  | 
                    |
| 138 | 
                        - def subtract_or_require(  | 
                    |
| 139 | 
                        - count: int | None, characters: bytes | bytearray  | 
                    |
| 140 | 
                        - ) -> None:  | 
                    |
| 141 | 
                        - if not isinstance(count, int):  | 
                    |
| 142 | 
                        - return  | 
                    |
| 143 | 
                        - if count <= 0:  | 
                    |
| 144 | 
                        - self._allowed = self._subtract(characters, self._allowed)  | 
                    |
| 145 | 
                        - else:  | 
                    |
| 146 | 
                        - for _ in range(count):  | 
                    |
| 147 | 
                        - self._required.append(characters)  | 
                    |
| 148 | 
                        -  | 
                    |
| 149 | 
                        - subtract_or_require(lower, self._CHARSETS['lower'])  | 
                    |
| 150 | 
                        - subtract_or_require(upper, self._CHARSETS['upper'])  | 
                    |
| 151 | 
                        - subtract_or_require(number, self._CHARSETS['number'])  | 
                    |
| 152 | 
                        - subtract_or_require(space, self._CHARSETS['space'])  | 
                    |
| 153 | 
                        - subtract_or_require(dash, self._CHARSETS['dash'])  | 
                    |
| 154 | 
                        - subtract_or_require(symbol, self._CHARSETS['symbol'])  | 
                    |
| 155 | 
                        - if len(self._required) > self._length:  | 
                    |
| 156 | 
                        - msg = 'requested passphrase length too short'  | 
                    |
| 157 | 
                        - raise ValueError(msg)  | 
                    |
| 158 | 
                        - if not self._allowed:  | 
                    |
| 159 | 
                        - msg = 'no allowed characters left'  | 
                    |
| 160 | 
                        - raise ValueError(msg)  | 
                    |
| 161 | 
                        - for _ in range(len(self._required), self._length):  | 
                    |
| 162 | 
                        - self._required.append(bytes(self._allowed))  | 
                    |
| 163 | 
                        -  | 
                    |
| 164 | 
                        - def _entropy(self) -> float:  | 
                    |
| 165 | 
                        - """Estimate the passphrase entropy, given the current settings.  | 
                    |
| 166 | 
                        -  | 
                    |
| 167 | 
                        - The entropy is the base 2 logarithm of the amount of  | 
                    |
| 168 | 
                        - possibilities. We operate directly on the logarithms, and use  | 
                    |
| 169 | 
                        - sorting and [`math.fsum`][] to keep high accuracy.  | 
                    |
| 170 | 
                        -  | 
                    |
| 171 | 
                        - Note:  | 
                    |
| 172 | 
                        - We actually overestimate the entropy here because of poor  | 
                    |
| 173 | 
                        - handling of character repetitions. In the extreme, assuming  | 
                    |
| 174 | 
                        - that only one character were allowed, then because there is  | 
                    |
| 175 | 
                        - only one possible string of each given length, the entropy  | 
                    |
| 176 | 
                        - of that string `s` is always be zero. However, we calculate  | 
                    |
| 177 | 
                        - the entropy as `math.log2(math.factorial(len(s)))`, i.e. we  | 
                    |
| 178 | 
                        - assume the characters at the respective string position are  | 
                    |
| 179 | 
                        - distinguishable from each other.  | 
                    |
| 180 | 
                        -  | 
                    |
| 181 | 
                        - Returns:  | 
                    |
| 182 | 
                        - A valid (and somewhat close) upper bound to the entropy.  | 
                    |
| 183 | 
                        -  | 
                    |
| 184 | 
                        - """  | 
                    |
| 185 | 
                        - factors: list[int] = []  | 
                    |
| 186 | 
                        - if not self._required or any(not x for x in self._required):  | 
                    |
| 187 | 
                        -            return float('-inf')
                       | 
                    |
| 188 | 
                        - for i, charset in enumerate(self._required):  | 
                    |
| 189 | 
                        - factors.extend([i + 1, len(charset)])  | 
                    |
| 190 | 
                        - factors.sort()  | 
                    |
| 191 | 
                        - return math.fsum(math.log2(f) for f in factors)  | 
                    |
| 192 | 
                        -  | 
                    |
| 193 | 
                        - def _estimate_sufficient_hash_length(  | 
                    |
| 194 | 
                        - self,  | 
                    |
| 195 | 
                        - safety_factor: float = 2.0,  | 
                    |
| 196 | 
                        - ) -> int:  | 
                    |
| 197 | 
                        - """Estimate the sufficient hash length, given the current settings.  | 
                    |
| 198 | 
                        -  | 
                    |
| 199 | 
                        - Using the entropy (via `_entropy`) and a safety factor, give an  | 
                    |
| 200 | 
                        - initial estimate of the length to use for `create_hash` such  | 
                    |
| 201 | 
                        - that using a `Sequin` with this hash will not exhaust it during  | 
                    |
| 202 | 
                        - passphrase generation.  | 
                    |
| 203 | 
                        -  | 
                    |
| 204 | 
                        - Args:  | 
                    |
| 205 | 
                        - safety_factor: The safety factor. Must be at least 1.  | 
                    |
| 206 | 
                        -  | 
                    |
| 207 | 
                        - Returns:  | 
                    |
| 208 | 
                        - The estimated sufficient hash length.  | 
                    |
| 209 | 
                        -  | 
                    |
| 210 | 
                        - Warning:  | 
                    |
| 211 | 
                        - This is a heuristic, not an exact computation; it may  | 
                    |
| 212 | 
                        - underestimate the true necessary hash length. It is  | 
                    |
| 213 | 
                        - intended as a starting point for searching for a sufficient  | 
                    |
| 214 | 
                        - hash length, usually by doubling the hash length each time  | 
                    |
| 215 | 
                        - it does not yet prove so.  | 
                    |
| 216 | 
                        -  | 
                    |
| 217 | 
                        - """  | 
                    |
| 218 | 
                        - try:  | 
                    |
| 219 | 
                        - safety_factor = float(safety_factor)  | 
                    |
| 220 | 
                        - except TypeError as e:  | 
                    |
| 221 | 
                        -            msg = f'invalid safety factor: not a float: {safety_factor!r}'
                       | 
                    |
| 222 | 
                        - raise TypeError(msg) from e  | 
                    |
| 223 | 
                        - if not math.isfinite(safety_factor) or safety_factor < 1.0:  | 
                    |
| 224 | 
                        -            msg = f'invalid safety factor {safety_factor!r}'
                       | 
                    |
| 225 | 
                        - raise ValueError(msg)  | 
                    |
| 226 | 
                        - # Ensure the bound is strictly positive.  | 
                    |
| 227 | 
                        - entropy_bound = max(1, self._entropy())  | 
                    |
| 228 | 
                        - return int(math.ceil(safety_factor * entropy_bound / 8))  | 
                    |
| 229 | 
                        -  | 
                    |
| 230 | 
                        - @staticmethod  | 
                    |
| 231 | 
                        - def _get_binary_string(s: bytes | bytearray | str, /) -> bytes:  | 
                    |
| 232 | 
                        - """Convert the input string to a read-only, binary string.  | 
                    |
| 233 | 
                        -  | 
                    |
| 234 | 
                        - If it is a text string, then test for an unambiguous UTF-8  | 
                    |
| 235 | 
                        - representation, otherwise abort. (That is, check whether the  | 
                    |
| 236 | 
                        - NFC and NFD forms of the string coincide.)  | 
                    |
| 237 | 
                        -  | 
                    |
| 238 | 
                        - Args:  | 
                    |
| 239 | 
                        - s: The string to (check and) convert.  | 
                    |
| 240 | 
                        -  | 
                    |
| 241 | 
                        - Returns:  | 
                    |
| 242 | 
                        - A read-only, binary copy of the string.  | 
                    |
| 243 | 
                        -  | 
                    |
| 244 | 
                        - Raises:  | 
                    |
| 245 | 
                        - AmbiguousByteRepresentationError:  | 
                    |
| 246 | 
                        - The text string has differing NFC- and NFD-normalized  | 
                    |
| 247 | 
                        - UTF-8 byte representations.  | 
                    |
| 248 | 
                        -  | 
                    |
| 249 | 
                        - """  | 
                    |
| 250 | 
                        - if isinstance(s, str):  | 
                    |
| 251 | 
                        - norm = unicodedata.normalize  | 
                    |
| 252 | 
                        -            if norm('NFC', s) != norm('NFD', s):
                       | 
                    |
| 253 | 
                        - raise AmbiguousByteRepresentationError  | 
                    |
| 254 | 
                        -            return s.encode('UTF-8')
                       | 
                    |
| 255 | 
                        - return bytes(s)  | 
                    |
| 256 | 
                        -  | 
                    |
| 257 | 
                        - @classmethod  | 
                    |
| 258 | 
                        - def create_hash(  | 
                    |
| 259 | 
                        - cls,  | 
                    |
| 260 | 
                        - phrase: bytes | bytearray | str,  | 
                    |
| 261 | 
                        - service: bytes | bytearray,  | 
                    |
| 262 | 
                        - *,  | 
                    |
| 263 | 
                        - length: int = 32,  | 
                    |
| 264 | 
                        - ) -> bytes:  | 
                    |
| 265 | 
                        - r"""Create a pseudorandom byte stream from phrase and service.  | 
                    |
| 266 | 
                        -  | 
                    |
| 267 | 
                        - Create a pseudorandom byte stream from `phrase` and `service` by  | 
                    |
| 268 | 
                        - feeding them into the key-derivation function PBKDF2  | 
                    |
| 269 | 
                        - (8 iterations, using SHA-1).  | 
                    |
| 270 | 
                        -  | 
                    |
| 271 | 
                        - Args:  | 
                    |
| 272 | 
                        - phrase:  | 
                    |
| 273 | 
                        - A master passphrase, or sometimes an SSH signature.  | 
                    |
| 274 | 
                        - Used as the key for PBKDF2, the underlying cryptographic  | 
                    |
| 275 | 
                        - primitive.  | 
                    |
| 276 | 
                        -  | 
                    |
| 277 | 
                        - If a text string, then the byte representation must be  | 
                    |
| 278 | 
                        - unique.  | 
                    |
| 279 | 
                        - service:  | 
                    |
| 280 | 
                        - A vault service name. Will be suffixed with  | 
                    |
| 281 | 
                        - `Vault._UUID`, and then used as the salt value for  | 
                    |
| 282 | 
                        - PBKDF2.  | 
                    |
| 283 | 
                        - length:  | 
                    |
| 284 | 
                        - The length of the byte stream to generate.  | 
                    |
| 285 | 
                        -  | 
                    |
| 286 | 
                        - Returns:  | 
                    |
| 287 | 
                        - A pseudorandom byte string of length `length`.  | 
                    |
| 288 | 
                        -  | 
                    |
| 289 | 
                        - Raises:  | 
                    |
| 290 | 
                        - AmbiguousByteRepresentationError:  | 
                    |
| 291 | 
                        - The phrase is a text string with differing NFC- and  | 
                    |
| 292 | 
                        - NFD-normalized UTF-8 byte representations.  | 
                    |
| 293 | 
                        -  | 
                    |
| 294 | 
                        - Note:  | 
                    |
| 295 | 
                        - Shorter values returned from this method (with the same key  | 
                    |
| 296 | 
                        - and message) are prefixes of longer values returned from  | 
                    |
| 297 | 
                        - this method. (This property is inherited from the  | 
                    |
| 298 | 
                        - underlying PBKDF2 function.) It is thus safe (if slow) to  | 
                    |
| 299 | 
                        - call this method with the same input with ever-increasing  | 
                    |
| 300 | 
                        - target lengths.  | 
                    |
| 301 | 
                        -  | 
                    |
| 302 | 
                        - Examples:  | 
                    |
| 303 | 
                        - >>> # See also Vault.phrase_from_key examples.  | 
                    |
| 304 | 
                        -            >>> phrase = bytes.fromhex('''
                       | 
                    |
| 305 | 
                        - ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39  | 
                    |
| 306 | 
                        - ... 00 00 00 40  | 
                    |
| 307 | 
                        - ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86  | 
                    |
| 308 | 
                        - ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd  | 
                    |
| 309 | 
                        - ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c  | 
                    |
| 310 | 
                        - ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02  | 
                    |
| 311 | 
                        - ... ''')  | 
                    |
| 312 | 
                        - >>> Vault.create_hash(phrase, b'some_service', length=4)  | 
                    |
| 313 | 
                        - b'M\xb1<S'  | 
                    |
| 314 | 
                        - >>> Vault.create_hash(phrase, b'some_service', length=16)  | 
                    |
| 315 | 
                        - b'M\xb1<S\x827E\xd1M\xaf\xf8~\xc8n\x10\xcc'  | 
                    |
| 316 | 
                        - >>> Vault.create_hash(phrase, b'NOSUCHSERVICE', length=16)  | 
                    |
| 317 | 
                        - b'\x1c\xc3\x9c\xd9\xb6\x1a\x99CS\x07\xc41\xf4\x85#s'  | 
                    |
| 318 | 
                        -  | 
                    |
| 319 | 
                        - """  | 
                    |
| 320 | 
                        - phrase = cls._get_binary_string(phrase)  | 
                    |
| 321 | 
                        - assert not isinstance(phrase, str)  | 
                    |
| 322 | 
                        - salt = bytes(service) + cls._UUID  | 
                    |
| 323 | 
                        - return hashlib.pbkdf2_hmac(  | 
                    |
| 324 | 
                        - hash_name='sha1',  | 
                    |
| 325 | 
                        - password=phrase,  | 
                    |
| 326 | 
                        - salt=salt,  | 
                    |
| 327 | 
                        - iterations=8,  | 
                    |
| 328 | 
                        - dklen=length,  | 
                    |
| 329 | 
                        - )  | 
                    |
| 330 | 
                        -  | 
                    |
| 331 | 
                        - def generate(  | 
                    |
| 332 | 
                        - self,  | 
                    |
| 333 | 
                        - service_name: str | bytes | bytearray,  | 
                    |
| 334 | 
                        - /,  | 
                    |
| 335 | 
                        - *,  | 
                    |
| 336 | 
                        - phrase: bytes | bytearray | str = b'',  | 
                    |
| 337 | 
                        - ) -> bytes:  | 
                    |
| 338 | 
                        - r"""Generate a service passphrase.  | 
                    |
| 339 | 
                        -  | 
                    |
| 340 | 
                        - Args:  | 
                    |
| 341 | 
                        - service_name:  | 
                    |
| 342 | 
                        - The service name.  | 
                    |
| 343 | 
                        - phrase:  | 
                    |
| 344 | 
                        - If given, override the passphrase given during  | 
                    |
| 345 | 
                        - construction.  | 
                    |
| 346 | 
                        -  | 
                    |
| 347 | 
                        - If a text string, then the byte representation must be  | 
                    |
| 348 | 
                        - unique.  | 
                    |
| 349 | 
                        -  | 
                    |
| 350 | 
                        - Returns:  | 
                    |
| 351 | 
                        - The service passphrase.  | 
                    |
| 352 | 
                        -  | 
                    |
| 353 | 
                        - Raises:  | 
                    |
| 354 | 
                        - AmbiguousByteRepresentationError:  | 
                    |
| 355 | 
                        - The phrase is a text string with differing NFC- and  | 
                    |
| 356 | 
                        - NFD-normalized UTF-8 byte representations.  | 
                    |
| 357 | 
                        -  | 
                    |
| 358 | 
                        - Examples:  | 
                    |
| 359 | 
                        - >>> phrase = b'She cells C shells bye the sea shoars'  | 
                    |
| 360 | 
                        - >>> # Using default options in constructor.  | 
                    |
| 361 | 
                        - >>> Vault(phrase=phrase).generate(b'google')  | 
                    |
| 362 | 
                        -            b': 4TVH#5:aZl8LueOT\\{'
                       | 
                    |
| 363 | 
                        - >>> # Also possible:  | 
                    |
| 364 | 
                        - >>> Vault().generate(b'google', phrase=phrase)  | 
                    |
| 365 | 
                        -            b': 4TVH#5:aZl8LueOT\\{'
                       | 
                    |
| 366 | 
                        -  | 
                    |
| 367 | 
                        - """  | 
                    |
| 368 | 
                        - hash_length = self._estimate_sufficient_hash_length()  | 
                    |
| 369 | 
                        - assert hash_length >= 1  | 
                    |
| 370 | 
                        - # Ensure the phrase is a bytes object. Needed later for safe  | 
                    |
| 371 | 
                        - # concatenation.  | 
                    |
| 372 | 
                        - if isinstance(service_name, str):  | 
                    |
| 373 | 
                        -            service_name = service_name.encode('utf-8')
                       | 
                    |
| 374 | 
                        - elif not isinstance(service_name, bytes):  | 
                    |
| 375 | 
                        - service_name = bytes(service_name)  | 
                    |
| 376 | 
                        - assert_type(service_name, bytes)  | 
                    |
| 377 | 
                        - if not phrase:  | 
                    |
| 378 | 
                        - phrase = self._phrase  | 
                    |
| 379 | 
                        - phrase = self._get_binary_string(phrase)  | 
                    |
| 380 | 
                        - # Repeat the passphrase generation with ever-increasing hash  | 
                    |
| 381 | 
                        - # lengths, until the passphrase can be formed without exhausting  | 
                    |
| 382 | 
                        - # the sequin. See the guarantee in the create_hash method for  | 
                    |
| 383 | 
                        - # why this works.  | 
                    |
| 384 | 
                        - while True:  | 
                    |
| 385 | 
                        - try:  | 
                    |
| 386 | 
                        - required = self._required[:]  | 
                    |
| 387 | 
                        - seq = sequin.Sequin(  | 
                    |
| 388 | 
                        - self.create_hash(  | 
                    |
| 389 | 
                        - phrase=phrase, service=service_name, length=hash_length  | 
                    |
| 390 | 
                        - )  | 
                    |
| 391 | 
                        - )  | 
                    |
| 392 | 
                        - result = bytearray()  | 
                    |
| 393 | 
                        - while len(result) < self._length:  | 
                    |
| 394 | 
                        - pos = seq.generate(len(required))  | 
                    |
| 395 | 
                        - charset = required.pop(pos)  | 
                    |
| 396 | 
                        - # Determine if an unlucky choice right now might  | 
                    |
| 397 | 
                        - # violate the restriction on repeated characters.  | 
                    |
| 398 | 
                        - # That is, check if the current partial passphrase  | 
                    |
| 399 | 
                        - # ends with r - 1 copies of the same character  | 
                    |
| 400 | 
                        - # (where r is the repeat limit that must not be  | 
                    |
| 401 | 
                        - # reached), and if so, remove this same character  | 
                    |
| 402 | 
                        - # from the current character's allowed set.  | 
                    |
| 403 | 
                        - if self._repeat and result:  | 
                    |
| 404 | 
                        - bad_suffix = bytes(result[-1:]) * (self._repeat - 1)  | 
                    |
| 405 | 
                        - if result.endswith(bad_suffix):  | 
                    |
| 406 | 
                        - charset = self._subtract(  | 
                    |
| 407 | 
                        - bytes(result[-1:]), charset  | 
                    |
| 408 | 
                        - )  | 
                    |
| 409 | 
                        - pos = seq.generate(len(charset))  | 
                    |
| 410 | 
                        - result.extend(charset[pos : pos + 1])  | 
                    |
| 411 | 
                        - except sequin.SequinExhaustedError:  | 
                    |
| 412 | 
                        - hash_length *= 2  | 
                    |
| 413 | 
                        - else:  | 
                    |
| 414 | 
                        - return bytes(result)  | 
                    |
| 415 | 
                        -  | 
                    |
| 416 | 
                        - @staticmethod  | 
                    |
| 417 | 
                        - def _is_suitable_ssh_key(key: bytes | bytearray, /) -> bool:  | 
                    |
| 418 | 
                        - """Check whether the key is suitable for passphrase derivation.  | 
                    |
| 419 | 
                        -  | 
                    |
| 420 | 
                        - Currently, this only checks whether signatures with this key  | 
                    |
| 421 | 
                        - type are deterministic.  | 
                    |
| 422 | 
                        -  | 
                    |
| 423 | 
                        - Args:  | 
                    |
| 424 | 
                        - key: SSH public key to check.  | 
                    |
| 425 | 
                        -  | 
                    |
| 426 | 
                        - Returns:  | 
                    |
| 427 | 
                        - True if and only if the key is suitable for use in deriving  | 
                    |
| 428 | 
                        - a passphrase deterministically.  | 
                    |
| 429 | 
                        -  | 
                    |
| 430 | 
                        - """  | 
                    |
| 431 | 
                        - TestFunc: TypeAlias = Callable[[bytes | bytearray], bool]  | 
                    |
| 432 | 
                        - deterministic_signature_types: dict[str, TestFunc]  | 
                    |
| 433 | 
                        -        deterministic_signature_types = {
                       | 
                    |
| 434 | 
                        - 'ssh-ed25519': lambda k: k.startswith(  | 
                    |
| 435 | 
                        - b'\x00\x00\x00\x0bssh-ed25519'  | 
                    |
| 436 | 
                        - ),  | 
                    |
| 437 | 
                        - 'ssh-ed448': lambda k: k.startswith(b'\x00\x00\x00\x09ssh-ed448'),  | 
                    |
| 438 | 
                        - 'ssh-rsa': lambda k: k.startswith(b'\x00\x00\x00\x07ssh-rsa'),  | 
                    |
| 439 | 
                        - }  | 
                    |
| 440 | 
                        - return any(v(key) for v in deterministic_signature_types.values())  | 
                    |
| 441 | 
                        -  | 
                    |
| 442 | 
                        - @classmethod  | 
                    |
| 443 | 
                        - def phrase_from_key(cls, key: bytes | bytearray, /) -> bytes:  | 
                    |
| 444 | 
                        - """Obtain the master passphrase from a configured SSH key.  | 
                    |
| 445 | 
                        -  | 
                    |
| 446 | 
                        - vault allows the usage of certain SSH keys to derive a master  | 
                    |
| 447 | 
                        - passphrase, by signing the vault UUID with the SSH key. The key  | 
                    |
| 448 | 
                        - type must ensure that signatures are deterministic.  | 
                    |
| 449 | 
                        -  | 
                    |
| 450 | 
                        - Args:  | 
                    |
| 451 | 
                        - key: The (public) SSH key to use for signing.  | 
                    |
| 452 | 
                        -  | 
                    |
| 453 | 
                        - Returns:  | 
                    |
| 454 | 
                        - The signature of the vault UUID under this key, unframed but  | 
                    |
| 455 | 
                        - encoded in base64.  | 
                    |
| 456 | 
                        -  | 
                    |
| 457 | 
                        - Raises:  | 
                    |
| 458 | 
                        - ValueError:  | 
                    |
| 459 | 
                        - The SSH key is principally unsuitable for this use case.  | 
                    |
| 460 | 
                        - Usually this means that the signature is not  | 
                    |
| 461 | 
                        - deterministic.  | 
                    |
| 462 | 
                        -  | 
                    |
| 463 | 
                        - Examples:  | 
                    |
| 464 | 
                        - >>> import base64  | 
                    |
| 465 | 
                        - >>> # Actual Ed25519 test public key.  | 
                    |
| 466 | 
                        -            >>> public_key = bytes.fromhex('''
                       | 
                    |
| 467 | 
                        - ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39  | 
                    |
| 468 | 
                        - ... 00 00 00 20  | 
                    |
| 469 | 
                        - ... 81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1  | 
                    |
| 470 | 
                        - ... 30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76  | 
                    |
| 471 | 
                        - ... ''')  | 
                    |
| 472 | 
                        -            >>> expected_sig_raw = bytes.fromhex('''
                       | 
                    |
| 473 | 
                        - ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39  | 
                    |
| 474 | 
                        - ... 00 00 00 40  | 
                    |
| 475 | 
                        - ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86  | 
                    |
| 476 | 
                        - ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd  | 
                    |
| 477 | 
                        - ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c  | 
                    |
| 478 | 
                        - ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02  | 
                    |
| 479 | 
                        - ... ''')  | 
                    |
| 480 | 
                        - >>> # Raw Ed25519 signatures are 64 bytes long.  | 
                    |
| 481 | 
                        - >>> signature_blob = expected_sig_raw[-64:]  | 
                    |
| 482 | 
                        - >>> phrase = base64.standard_b64encode(signature_blob)  | 
                    |
| 483 | 
                        - >>> Vault.phrase_from_key(phrase) == expected # doctest:+SKIP  | 
                    |
| 484 | 
                        - True  | 
                    |
| 485 | 
                        -  | 
                    |
| 486 | 
                        - """  | 
                    |
| 487 | 
                        - if not cls._is_suitable_ssh_key(key):  | 
                    |
| 488 | 
                        - msg = (  | 
                    |
| 489 | 
                        - 'unsuitable SSH key: bad key, or '  | 
                    |
| 490 | 
                        - 'signature not deterministic'  | 
                    |
| 491 | 
                        - )  | 
                    |
| 492 | 
                        - raise ValueError(msg)  | 
                    |
| 493 | 
                        - with ssh_agent_client.SSHAgentClient() as client:  | 
                    |
| 494 | 
                        - raw_sig = client.sign(key, cls._UUID)  | 
                    |
| 495 | 
                        - _keytype, trailer = client.unstring_prefix(raw_sig)  | 
                    |
| 496 | 
                        - signature_blob = client.unstring(trailer)  | 
                    |
| 497 | 
                        - return bytes(base64.standard_b64encode(signature_blob))  | 
                    |
| 498 | 
                        -  | 
                    |
| 499 | 
                        - @staticmethod  | 
                    |
| 500 | 
                        - def _subtract(  | 
                    |
| 501 | 
                        - charset: bytes | bytearray,  | 
                    |
| 502 | 
                        - allowed: bytes | bytearray,  | 
                    |
| 503 | 
                        - ) -> bytearray:  | 
                    |
| 504 | 
                        - """Remove the characters in charset from allowed.  | 
                    |
| 505 | 
                        -  | 
                    |
| 506 | 
                        - This preserves the relative order of characters in `allowed`.  | 
                    |
| 507 | 
                        -  | 
                    |
| 508 | 
                        - Args:  | 
                    |
| 509 | 
                        - charset:  | 
                    |
| 510 | 
                        - Characters to remove. Must not contain duplicate  | 
                    |
| 511 | 
                        - characters.  | 
                    |
| 512 | 
                        - allowed:  | 
                    |
| 513 | 
                        - Character set to remove the other characters from. Must  | 
                    |
| 514 | 
                        - not contain duplicate characters.  | 
                    |
| 515 | 
                        -  | 
                    |
| 516 | 
                        - Returns:  | 
                    |
| 517 | 
                        - The pruned "allowed" character set.  | 
                    |
| 518 | 
                        -  | 
                    |
| 519 | 
                        - Raises:  | 
                    |
| 520 | 
                        - ValueError:  | 
                    |
| 521 | 
                        - `allowed` or `charset` contained duplicate characters.  | 
                    |
| 522 | 
                        -  | 
                    |
| 523 | 
                        - """  | 
                    |
| 524 | 
                        - allowed = (  | 
                    |
| 525 | 
                        - allowed if isinstance(allowed, bytearray) else bytearray(allowed)  | 
                    |
| 526 | 
                        - )  | 
                    |
| 527 | 
                        - assert_type(allowed, bytearray)  | 
                    |
| 528 | 
                        - msg_dup_characters = 'duplicate characters in set'  | 
                    |
| 529 | 
                        - if len(frozenset(allowed)) != len(allowed):  | 
                    |
| 530 | 
                        - raise ValueError(msg_dup_characters)  | 
                    |
| 531 | 
                        - if len(frozenset(charset)) != len(charset):  | 
                    |
| 532 | 
                        - raise ValueError(msg_dup_characters)  | 
                    |
| 533 | 
                        - for c in charset:  | 
                    |
| 534 | 
                        - try:  | 
                    |
| 535 | 
                        - pos = allowed.index(c)  | 
                    |
| 536 | 
                        - except ValueError:  | 
                    |
| 537 | 
                        - pass  | 
                    |
| 538 | 
                        - else:  | 
                    |
| 539 | 
                        - allowed[pos : pos + 1] = []  | 
                    |
| 540 | 
                        - return allowed  | 
                    
| ... | ... | 
                      @@ -27,8 +27,7 @@ from typing_extensions import (  | 
                  
| 27 | 27 | 
                        )  | 
                    
| 28 | 28 | 
                         | 
                    
| 29 | 29 | 
                        import derivepassphrase as dpp  | 
                    
| 30 | 
                        -import ssh_agent_client  | 
                    |
| 31 | 
                        -import ssh_agent_client.types  | 
                    |
| 30 | 
                        +from derivepassphrase import ssh_agent, vault  | 
                    |
| 32 | 31 | 
                        from derivepassphrase import types as dpp_types  | 
                    
| 33 | 32 | 
                         | 
                    
| 34 | 33 | 
                        if TYPE_CHECKING:  | 
                    
| ... | ... | 
                      @@ -128,12 +127,12 @@ def _save_config(config: dpp_types.VaultConfig, /) -> None:  | 
                  
| 128 | 127 | 
                         | 
                    
| 129 | 128 | 
                         | 
                    
| 130 | 129 | 
                        def _get_suitable_ssh_keys(  | 
                    
| 131 | 
                        - conn: ssh_agent_client.SSHAgentClient | socket.socket | None = None, /  | 
                    |
| 132 | 
                        -) -> Iterator[ssh_agent_client.types.KeyCommentPair]:  | 
                    |
| 130 | 
                        + conn: ssh_agent.SSHAgentClient | socket.socket | None = None, /  | 
                    |
| 131 | 
                        +) -> Iterator[ssh_agent.types.KeyCommentPair]:  | 
                    |
| 133 | 132 | 
                        """Yield all SSH keys suitable for passphrase derivation.  | 
                    
| 134 | 133 | 
                         | 
                    
| 135 | 134 | 
                        Suitable SSH keys are queried from the running SSH agent (see  | 
                    
| 136 | 
                        - [`ssh_agent_client.SSHAgentClient.list_keys`][]).  | 
                    |
| 135 | 
                        + [`ssh_agent.SSHAgentClient.list_keys`][]).  | 
                    |
| 137 | 136 | 
                         | 
                    
| 138 | 137 | 
                        Args:  | 
                    
| 139 | 138 | 
                        conn:  | 
                    
| ... | ... | 
                      @@ -165,14 +164,14 @@ def _get_suitable_ssh_keys(  | 
                  
| 165 | 164 | 
                        There was an error communicating with the SSH agent.  | 
                    
| 166 | 165 | 
                         | 
                    
| 167 | 166 | 
                        """  | 
                    
| 168 | 
                        - client: ssh_agent_client.SSHAgentClient  | 
                    |
| 167 | 
                        + client: ssh_agent.SSHAgentClient  | 
                    |
| 169 | 168 | 
                        client_context: contextlib.AbstractContextManager[Any]  | 
                    
| 170 | 169 | 
                        match conn:  | 
                    
| 171 | 
                        - case ssh_agent_client.SSHAgentClient():  | 
                    |
| 170 | 
                        + case ssh_agent.SSHAgentClient():  | 
                    |
| 172 | 171 | 
                        client = conn  | 
                    
| 173 | 172 | 
                        client_context = contextlib.nullcontext()  | 
                    
| 174 | 173 | 
                        case socket.socket() | None:  | 
                    
| 175 | 
                        - client = ssh_agent_client.SSHAgentClient(socket=conn)  | 
                    |
| 174 | 
                        + client = ssh_agent.SSHAgentClient(socket=conn)  | 
                    |
| 176 | 175 | 
                        client_context = client  | 
                    
| 177 | 176 | 
                        case _: # pragma: no cover  | 
                    
| 178 | 177 | 
                        assert_never(conn)  | 
                    
| ... | ... | 
                      @@ -186,7 +185,7 @@ def _get_suitable_ssh_keys(  | 
                  
| 186 | 185 | 
                        suitable_keys = copy.copy(all_key_comment_pairs)  | 
                    
| 187 | 186 | 
                        for pair in all_key_comment_pairs:  | 
                    
| 188 | 187 | 
                        key, _comment = pair  | 
                    
| 189 | 
                        - if dpp.Vault._is_suitable_ssh_key(key): # noqa: SLF001  | 
                    |
| 188 | 
                        + if vault.Vault._is_suitable_ssh_key(key): # noqa: SLF001  | 
                    |
| 190 | 189 | 
                        yield pair  | 
                    
| 191 | 190 | 
                        if not suitable_keys: # pragma: no cover  | 
                    
| 192 | 191 | 
                        raise IndexError(_NO_USABLE_KEYS)  | 
                    
| ... | ... | 
                      @@ -262,12 +261,12 @@ def _prompt_for_selection(  | 
                  
| 262 | 261 | 
                         | 
                    
| 263 | 262 | 
                         | 
                    
| 264 | 263 | 
                        def _select_ssh_key(  | 
                    
| 265 | 
                        - conn: ssh_agent_client.SSHAgentClient | socket.socket | None = None, /  | 
                    |
| 264 | 
                        + conn: ssh_agent.SSHAgentClient | socket.socket | None = None, /  | 
                    |
| 266 | 265 | 
                        ) -> bytes | bytearray:  | 
                    
| 267 | 266 | 
                        """Interactively select an SSH key for passphrase derivation.  | 
                    
| 268 | 267 | 
                         | 
                    
| 269 | 268 | 
                        Suitable SSH keys are queried from the running SSH agent (see  | 
                    
| 270 | 
                        - [`ssh_agent_client.SSHAgentClient.list_keys`][]), then the user is  | 
                    |
| 269 | 
                        + [`ssh_agent.SSHAgentClient.list_keys`][]), then the user is  | 
                    |
| 271 | 270 | 
                        prompted interactively (see [`click.prompt`][]) for a selection.  | 
                    
| 272 | 271 | 
                         | 
                    
| 273 | 272 | 
                        Args:  | 
                    
| ... | ... | 
                      @@ -302,7 +301,7 @@ def _select_ssh_key(  | 
                  
| 302 | 301 | 
                        """  | 
                    
| 303 | 302 | 
                        suitable_keys = list(_get_suitable_ssh_keys(conn))  | 
                    
| 304 | 303 | 
                        key_listing: list[str] = []  | 
                    
| 305 | 
                        - unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix  | 
                    |
| 304 | 
                        + unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix  | 
                    |
| 306 | 305 | 
                        for key, comment in suitable_keys:  | 
                    
| 307 | 306 | 
                                 keytype = unstring_prefix(key)[0].decode('ASCII')
                       | 
                    
| 308 | 307 | 
                                 key_str = base64.standard_b64encode(key).decode('ASCII')
                       | 
                    
| ... | ... | 
                      @@ -1055,7 +1054,7 @@ def derivepassphrase(  | 
                  
| 1055 | 1054 | 
                        def key_to_phrase(  | 
                    
| 1056 | 1055 | 
                        key: str | bytes | bytearray,  | 
                    
| 1057 | 1056 | 
                        ) -> bytes | bytearray:  | 
                    
| 1058 | 
                        - return dpp.Vault.phrase_from_key(  | 
                    |
| 1057 | 
                        + return vault.Vault.phrase_from_key(  | 
                    |
| 1059 | 1058 | 
                        base64.standard_b64decode(key)  | 
                    
| 1060 | 1059 | 
                        )  | 
                    
| 1061 | 1060 | 
                         | 
                    
| ... | ... | 
                      @@ -1065,7 +1064,7 @@ def derivepassphrase(  | 
                  
| 1065 | 1064 | 
                        # service-specific (use that one). Otherwise, if only one of  | 
                    
| 1066 | 1065 | 
                        # key and phrase is set in the config, use that one. In all  | 
                    
| 1067 | 1066 | 
                        # these above cases, set the phrase via  | 
                    
| 1068 | 
                        - # derivepassphrase.Vault.phrase_from_key if a key is  | 
                    |
| 1067 | 
                        + # derivepassphrase.vault.Vault.phrase_from_key if a key is  | 
                    |
| 1069 | 1068 | 
                        # given. Finally, if nothing is set, error out.  | 
                    
| 1070 | 1069 | 
                        if use_key or use_phrase:  | 
                    
| 1071 | 1070 | 
                        kwargs['phrase'] = key_to_phrase(key) if use_key else phrase  | 
                    
| ... | ... | 
                      @@ -1083,8 +1082,7 @@ def derivepassphrase(  | 
                  
| 1083 | 1082 | 
                        )  | 
                    
| 1084 | 1083 | 
                        raise click.UsageError(msg)  | 
                    
| 1085 | 1084 | 
                                     kwargs.pop('key', '')
                       | 
                    
| 1086 | 
                        - vault = dpp.Vault(**kwargs)  | 
                    |
| 1087 | 
                        - result = vault.generate(service)  | 
                    |
| 1085 | 
                        + result = vault.Vault(**kwargs).generate(service)  | 
                    |
| 1088 | 1086 | 
                                     click.echo(result.decode('ASCII'))
                       | 
                    
| 1089 | 1087 | 
                         | 
                    
| 1090 | 1088 | 
                         | 
                    
| ... | ... | 
                      @@ -14,8 +14,8 @@ deterministic, stateless password manager that recomputes passwords  | 
                  
| 14 | 14 | 
                        instead of storing them), and this reimplementation is used for  | 
                    
| 15 | 15 | 
                        a similar purpose.  | 
                    
| 16 | 16 | 
                         | 
                    
| 17 | 
                        -The main API is the [`Sequin`] [sequin.Sequin] class, which is  | 
                    |
| 18 | 
                        -thoroughly documented.  | 
                    |
| 17 | 
                        +The main API is the [`Sequin`] [derivepassphrase.sequin.Sequin] class,  | 
                    |
| 18 | 
                        +which is thoroughly documented.  | 
                    |
| 19 | 19 | 
                         | 
                    
| 20 | 20 | 
                        """  | 
                    
| 21 | 21 | 
                         | 
                    
| ... | ... | 
                      @@ -33,7 +33,6 @@ if TYPE_CHECKING:  | 
                  
| 33 | 33 | 
                         | 
                    
| 34 | 34 | 
                         __all__ = ('Sequin', 'SequinExhaustedError')
                       | 
                    
| 35 | 35 | 
                        __author__ = 'Marco Ricci <m@the13thletter.info>'  | 
                    
| 36 | 
                        -__version__ = '0.1.3'  | 
                    |
| 37 | 36 | 
                         | 
                    
| 38 | 37 | 
                         | 
                    
| 39 | 38 | 
                        class Sequin:  | 
                    
| ... | ... | 
                      @@ -14,7 +14,7 @@ from typing import TYPE_CHECKING  | 
                  
| 14 | 14 | 
                         | 
                    
| 15 | 15 | 
                        from typing_extensions import Self  | 
                    
| 16 | 16 | 
                         | 
                    
| 17 | 
                        -from ssh_agent_client import types  | 
                    |
| 17 | 
                        +from derivepassphrase.ssh_agent import types  | 
                    |
| 18 | 18 | 
                         | 
                    
| 19 | 19 | 
                        if TYPE_CHECKING:  | 
                    
| 20 | 20 | 
                        from collections.abc import Sequence  | 
                    
| ... | ... | 
                      @@ -22,7 +22,6 @@ if TYPE_CHECKING:  | 
                  
| 22 | 22 | 
                         | 
                    
| 23 | 23 | 
                         __all__ = ('SSHAgentClient',)
                       | 
                    
| 24 | 24 | 
                        __author__ = 'Marco Ricci <m@the13thletter.info>'  | 
                    
| 25 | 
                        -__version__ = '0.1.3'  | 
                    |
| 26 | 25 | 
                         | 
                    
| 27 | 26 | 
                        # In SSH bytestrings, the "length" of the byte string is stored as  | 
                    
| 28 | 27 | 
                        # a 4-byte/32-bit unsigned integer at the beginning.  | 
                    
| ... | ... | 
                      @@ -0,0 +1,538 @@  | 
                  
| 1 | 
                        +# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>  | 
                    |
| 2 | 
                        +#  | 
                    |
| 3 | 
                        +# SPDX-License-Identifier: MIT  | 
                    |
| 4 | 
                        +  | 
                    |
| 5 | 
                        +"""Work-alike of vault(1) – a deterministic, stateless password manager""" # noqa: RUF002  | 
                    |
| 6 | 
                        +  | 
                    |
| 7 | 
                        +from __future__ import annotations  | 
                    |
| 8 | 
                        +  | 
                    |
| 9 | 
                        +import base64  | 
                    |
| 10 | 
                        +import collections  | 
                    |
| 11 | 
                        +import hashlib  | 
                    |
| 12 | 
                        +import math  | 
                    |
| 13 | 
                        +import unicodedata  | 
                    |
| 14 | 
                        +from collections.abc import Callable  | 
                    |
| 15 | 
                        +from typing import TypeAlias  | 
                    |
| 16 | 
                        +  | 
                    |
| 17 | 
                        +from typing_extensions import assert_type  | 
                    |
| 18 | 
                        +  | 
                    |
| 19 | 
                        +from derivepassphrase import sequin, ssh_agent  | 
                    |
| 20 | 
                        +  | 
                    |
| 21 | 
                        +__author__ = 'Marco Ricci <m@the13thletter.info>'  | 
                    |
| 22 | 
                        +  | 
                    |
| 23 | 
                        +  | 
                    |
| 24 | 
                        +class AmbiguousByteRepresentationError(ValueError):  | 
                    |
| 25 | 
                        + """The object has an ambiguous byte representation."""  | 
                    |
| 26 | 
                        +  | 
                    |
| 27 | 
                        + def __init__(self) -> None:  | 
                    |
| 28 | 
                        +        super().__init__('text string has ambiguous byte representation')
                       | 
                    |
| 29 | 
                        +  | 
                    |
| 30 | 
                        +  | 
                    |
| 31 | 
                        +_CHARSETS = collections.OrderedDict([  | 
                    |
| 32 | 
                        +    ('lower', b'abcdefghijklmnopqrstuvwxyz'),
                       | 
                    |
| 33 | 
                        +    ('upper', b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),
                       | 
                    |
| 34 | 
                        +    ('alpha', b''),  # Placeholder.
                       | 
                    |
| 35 | 
                        +    ('number', b'0123456789'),
                       | 
                    |
| 36 | 
                        +    ('alphanum', b''),  # Placeholder.
                       | 
                    |
| 37 | 
                        +    ('space', b' '),
                       | 
                    |
| 38 | 
                        +    ('dash', b'-_'),
                       | 
                    |
| 39 | 
                        +    ('symbol', b'!"#$%&\'()*+,./:;<=>?@[\\]^{|}~-_'),
                       | 
                    |
| 40 | 
                        +    ('all', b''),  # Placeholder.
                       | 
                    |
| 41 | 
                        +])  | 
                    |
| 42 | 
                        +_CHARSETS['alpha'] = _CHARSETS['lower'] + _CHARSETS['upper']  | 
                    |
| 43 | 
                        +_CHARSETS['alphanum'] = _CHARSETS['alpha'] + _CHARSETS['number']  | 
                    |
| 44 | 
                        +_CHARSETS['all'] = (  | 
                    |
| 45 | 
                        + _CHARSETS['alphanum'] + _CHARSETS['space'] + _CHARSETS['symbol']  | 
                    |
| 46 | 
                        +)  | 
                    |
| 47 | 
                        +  | 
                    |
| 48 | 
                        +  | 
                    |
| 49 | 
                        +class Vault:  | 
                    |
| 50 | 
                        + """A work-alike of James Coglan's vault.  | 
                    |
| 51 | 
                        +  | 
                    |
| 52 | 
                        + Store settings for generating (actually: deriving) passphrases for  | 
                    |
| 53 | 
                        + named services, with various constraints, given only a master  | 
                    |
| 54 | 
                        + passphrase. Also, actually generate the passphrase. The derivation  | 
                    |
| 55 | 
                        + is deterministic and non-secret; only the master passphrase need be  | 
                    |
| 56 | 
                        + kept secret. The implementation is compatible with [vault][].  | 
                    |
| 57 | 
                        +  | 
                    |
| 58 | 
                        + [James Coglan explains the passphrase derivation algorithm in great  | 
                    |
| 59 | 
                        + detail][ALGORITHM] in his blog post on said topic: A principally  | 
                    |
| 60 | 
                        + infinite bit stream is obtained by running a key-derivation function  | 
                    |
| 61 | 
                        + on the master passphrase and the service name, then this bit stream  | 
                    |
| 62 | 
                        + is fed into a [Sequin][sequin.Sequin] to generate random numbers in  | 
                    |
| 63 | 
                        + the correct range, and finally these random numbers select  | 
                    |
| 64 | 
                        + passphrase characters until the desired length is reached.  | 
                    |
| 65 | 
                        +  | 
                    |
| 66 | 
                        + [vault]: https://getvau.lt  | 
                    |
| 67 | 
                        + [ALGORITHM]: https://blog.jcoglan.com/2012/07/16/designing-vaults-generator-algorithm/  | 
                    |
| 68 | 
                        +  | 
                    |
| 69 | 
                        + """  | 
                    |
| 70 | 
                        +  | 
                    |
| 71 | 
                        + _UUID = b'e87eb0f4-34cb-46b9-93ad-766c5ab063e7'  | 
                    |
| 72 | 
                        + """A tag used by vault in the bit stream generation."""  | 
                    |
| 73 | 
                        + _CHARSETS = _CHARSETS  | 
                    |
| 74 | 
                        + """  | 
                    |
| 75 | 
                        + Known character sets from which to draw passphrase characters.  | 
                    |
| 76 | 
                        + Relies on a certain, fixed order for their definition and their  | 
                    |
| 77 | 
                        + contents.  | 
                    |
| 78 | 
                        +  | 
                    |
| 79 | 
                        + """  | 
                    |
| 80 | 
                        +  | 
                    |
| 81 | 
                        + def __init__(  | 
                    |
| 82 | 
                        + self,  | 
                    |
| 83 | 
                        + *,  | 
                    |
| 84 | 
                        + phrase: bytes | bytearray | str = b'',  | 
                    |
| 85 | 
                        + length: int = 20,  | 
                    |
| 86 | 
                        + repeat: int = 0,  | 
                    |
| 87 | 
                        + lower: int | None = None,  | 
                    |
| 88 | 
                        + upper: int | None = None,  | 
                    |
| 89 | 
                        + number: int | None = None,  | 
                    |
| 90 | 
                        + space: int | None = None,  | 
                    |
| 91 | 
                        + dash: int | None = None,  | 
                    |
| 92 | 
                        + symbol: int | None = None,  | 
                    |
| 93 | 
                        + ) -> None:  | 
                    |
| 94 | 
                        + """Initialize the Vault object.  | 
                    |
| 95 | 
                        +  | 
                    |
| 96 | 
                        + Args:  | 
                    |
| 97 | 
                        + phrase:  | 
                    |
| 98 | 
                        + The master passphrase from which to derive the service  | 
                    |
| 99 | 
                        + passphrases. If a text string, then the byte  | 
                    |
| 100 | 
                        + representation must be unique.  | 
                    |
| 101 | 
                        + length:  | 
                    |
| 102 | 
                        + Desired passphrase length.  | 
                    |
| 103 | 
                        + repeat:  | 
                    |
| 104 | 
                        + The maximum number of immediate character repetitions  | 
                    |
| 105 | 
                        + allowed in the passphrase. Disabled if set to 0.  | 
                    |
| 106 | 
                        + lower:  | 
                    |
| 107 | 
                        + Optional constraint on ASCII lowercase characters. If  | 
                    |
| 108 | 
                        + positive, include this many lowercase characters  | 
                    |
| 109 | 
                        + somewhere in the passphrase. If 0, avoid lowercase  | 
                    |
| 110 | 
                        + characters altogether.  | 
                    |
| 111 | 
                        + upper:  | 
                    |
| 112 | 
                        + Same as `lower`, but for ASCII uppercase characters.  | 
                    |
| 113 | 
                        + number:  | 
                    |
| 114 | 
                        + Same as `lower`, but for ASCII digits.  | 
                    |
| 115 | 
                        + space:  | 
                    |
| 116 | 
                        + Same as `lower`, but for the space character.  | 
                    |
| 117 | 
                        + dash:  | 
                    |
| 118 | 
                        + Same as `lower`, but for the hyphen-minus and underscore  | 
                    |
| 119 | 
                        + characters.  | 
                    |
| 120 | 
                        + symbol:  | 
                    |
| 121 | 
                        + Same as `lower`, but for all other hitherto unlisted  | 
                    |
| 122 | 
                        + ASCII printable characters (except backquote).  | 
                    |
| 123 | 
                        +  | 
                    |
| 124 | 
                        + Raises:  | 
                    |
| 125 | 
                        + AmbiguousByteRepresentationError:  | 
                    |
| 126 | 
                        + The phrase is a text string with differing NFC- and  | 
                    |
| 127 | 
                        + NFD-normalized UTF-8 byte representations.  | 
                    |
| 128 | 
                        +  | 
                    |
| 129 | 
                        + """  | 
                    |
| 130 | 
                        + self._phrase = self._get_binary_string(phrase)  | 
                    |
| 131 | 
                        + self._length = length  | 
                    |
| 132 | 
                        + self._repeat = repeat  | 
                    |
| 133 | 
                        + self._allowed = bytearray(self._CHARSETS['all'])  | 
                    |
| 134 | 
                        + self._required: list[bytes] = []  | 
                    |
| 135 | 
                        +  | 
                    |
| 136 | 
                        + def subtract_or_require(  | 
                    |
| 137 | 
                        + count: int | None, characters: bytes | bytearray  | 
                    |
| 138 | 
                        + ) -> None:  | 
                    |
| 139 | 
                        + if not isinstance(count, int):  | 
                    |
| 140 | 
                        + return  | 
                    |
| 141 | 
                        + if count <= 0:  | 
                    |
| 142 | 
                        + self._allowed = self._subtract(characters, self._allowed)  | 
                    |
| 143 | 
                        + else:  | 
                    |
| 144 | 
                        + for _ in range(count):  | 
                    |
| 145 | 
                        + self._required.append(characters)  | 
                    |
| 146 | 
                        +  | 
                    |
| 147 | 
                        + subtract_or_require(lower, self._CHARSETS['lower'])  | 
                    |
| 148 | 
                        + subtract_or_require(upper, self._CHARSETS['upper'])  | 
                    |
| 149 | 
                        + subtract_or_require(number, self._CHARSETS['number'])  | 
                    |
| 150 | 
                        + subtract_or_require(space, self._CHARSETS['space'])  | 
                    |
| 151 | 
                        + subtract_or_require(dash, self._CHARSETS['dash'])  | 
                    |
| 152 | 
                        + subtract_or_require(symbol, self._CHARSETS['symbol'])  | 
                    |
| 153 | 
                        + if len(self._required) > self._length:  | 
                    |
| 154 | 
                        + msg = 'requested passphrase length too short'  | 
                    |
| 155 | 
                        + raise ValueError(msg)  | 
                    |
| 156 | 
                        + if not self._allowed:  | 
                    |
| 157 | 
                        + msg = 'no allowed characters left'  | 
                    |
| 158 | 
                        + raise ValueError(msg)  | 
                    |
| 159 | 
                        + for _ in range(len(self._required), self._length):  | 
                    |
| 160 | 
                        + self._required.append(bytes(self._allowed))  | 
                    |
| 161 | 
                        +  | 
                    |
| 162 | 
                        + def _entropy(self) -> float:  | 
                    |
| 163 | 
                        + """Estimate the passphrase entropy, given the current settings.  | 
                    |
| 164 | 
                        +  | 
                    |
| 165 | 
                        + The entropy is the base 2 logarithm of the amount of  | 
                    |
| 166 | 
                        + possibilities. We operate directly on the logarithms, and use  | 
                    |
| 167 | 
                        + sorting and [`math.fsum`][] to keep high accuracy.  | 
                    |
| 168 | 
                        +  | 
                    |
| 169 | 
                        + Note:  | 
                    |
| 170 | 
                        + We actually overestimate the entropy here because of poor  | 
                    |
| 171 | 
                        + handling of character repetitions. In the extreme, assuming  | 
                    |
| 172 | 
                        + that only one character were allowed, then because there is  | 
                    |
| 173 | 
                        + only one possible string of each given length, the entropy  | 
                    |
| 174 | 
                        + of that string `s` is always be zero. However, we calculate  | 
                    |
| 175 | 
                        + the entropy as `math.log2(math.factorial(len(s)))`, i.e. we  | 
                    |
| 176 | 
                        + assume the characters at the respective string position are  | 
                    |
| 177 | 
                        + distinguishable from each other.  | 
                    |
| 178 | 
                        +  | 
                    |
| 179 | 
                        + Returns:  | 
                    |
| 180 | 
                        + A valid (and somewhat close) upper bound to the entropy.  | 
                    |
| 181 | 
                        +  | 
                    |
| 182 | 
                        + """  | 
                    |
| 183 | 
                        + factors: list[int] = []  | 
                    |
| 184 | 
                        + if not self._required or any(not x for x in self._required):  | 
                    |
| 185 | 
                        +            return float('-inf')
                       | 
                    |
| 186 | 
                        + for i, charset in enumerate(self._required):  | 
                    |
| 187 | 
                        + factors.extend([i + 1, len(charset)])  | 
                    |
| 188 | 
                        + factors.sort()  | 
                    |
| 189 | 
                        + return math.fsum(math.log2(f) for f in factors)  | 
                    |
| 190 | 
                        +  | 
                    |
| 191 | 
                        + def _estimate_sufficient_hash_length(  | 
                    |
| 192 | 
                        + self,  | 
                    |
| 193 | 
                        + safety_factor: float = 2.0,  | 
                    |
| 194 | 
                        + ) -> int:  | 
                    |
| 195 | 
                        + """Estimate the sufficient hash length, given the current settings.  | 
                    |
| 196 | 
                        +  | 
                    |
| 197 | 
                        + Using the entropy (via `_entropy`) and a safety factor, give an  | 
                    |
| 198 | 
                        + initial estimate of the length to use for `create_hash` such  | 
                    |
| 199 | 
                        + that using a `Sequin` with this hash will not exhaust it during  | 
                    |
| 200 | 
                        + passphrase generation.  | 
                    |
| 201 | 
                        +  | 
                    |
| 202 | 
                        + Args:  | 
                    |
| 203 | 
                        + safety_factor: The safety factor. Must be at least 1.  | 
                    |
| 204 | 
                        +  | 
                    |
| 205 | 
                        + Returns:  | 
                    |
| 206 | 
                        + The estimated sufficient hash length.  | 
                    |
| 207 | 
                        +  | 
                    |
| 208 | 
                        + Warning:  | 
                    |
| 209 | 
                        + This is a heuristic, not an exact computation; it may  | 
                    |
| 210 | 
                        + underestimate the true necessary hash length. It is  | 
                    |
| 211 | 
                        + intended as a starting point for searching for a sufficient  | 
                    |
| 212 | 
                        + hash length, usually by doubling the hash length each time  | 
                    |
| 213 | 
                        + it does not yet prove so.  | 
                    |
| 214 | 
                        +  | 
                    |
| 215 | 
                        + """  | 
                    |
| 216 | 
                        + try:  | 
                    |
| 217 | 
                        + safety_factor = float(safety_factor)  | 
                    |
| 218 | 
                        + except TypeError as e:  | 
                    |
| 219 | 
                        +            msg = f'invalid safety factor: not a float: {safety_factor!r}'
                       | 
                    |
| 220 | 
                        + raise TypeError(msg) from e  | 
                    |
| 221 | 
                        + if not math.isfinite(safety_factor) or safety_factor < 1.0:  | 
                    |
| 222 | 
                        +            msg = f'invalid safety factor {safety_factor!r}'
                       | 
                    |
| 223 | 
                        + raise ValueError(msg)  | 
                    |
| 224 | 
                        + # Ensure the bound is strictly positive.  | 
                    |
| 225 | 
                        + entropy_bound = max(1, self._entropy())  | 
                    |
| 226 | 
                        + return int(math.ceil(safety_factor * entropy_bound / 8))  | 
                    |
| 227 | 
                        +  | 
                    |
| 228 | 
                        + @staticmethod  | 
                    |
| 229 | 
                        + def _get_binary_string(s: bytes | bytearray | str, /) -> bytes:  | 
                    |
| 230 | 
                        + """Convert the input string to a read-only, binary string.  | 
                    |
| 231 | 
                        +  | 
                    |
| 232 | 
                        + If it is a text string, then test for an unambiguous UTF-8  | 
                    |
| 233 | 
                        + representation, otherwise abort. (That is, check whether the  | 
                    |
| 234 | 
                        + NFC and NFD forms of the string coincide.)  | 
                    |
| 235 | 
                        +  | 
                    |
| 236 | 
                        + Args:  | 
                    |
| 237 | 
                        + s: The string to (check and) convert.  | 
                    |
| 238 | 
                        +  | 
                    |
| 239 | 
                        + Returns:  | 
                    |
| 240 | 
                        + A read-only, binary copy of the string.  | 
                    |
| 241 | 
                        +  | 
                    |
| 242 | 
                        + Raises:  | 
                    |
| 243 | 
                        + AmbiguousByteRepresentationError:  | 
                    |
| 244 | 
                        + The text string has differing NFC- and NFD-normalized  | 
                    |
| 245 | 
                        + UTF-8 byte representations.  | 
                    |
| 246 | 
                        +  | 
                    |
| 247 | 
                        + """  | 
                    |
| 248 | 
                        + if isinstance(s, str):  | 
                    |
| 249 | 
                        + norm = unicodedata.normalize  | 
                    |
| 250 | 
                        +            if norm('NFC', s) != norm('NFD', s):
                       | 
                    |
| 251 | 
                        + raise AmbiguousByteRepresentationError  | 
                    |
| 252 | 
                        +            return s.encode('UTF-8')
                       | 
                    |
| 253 | 
                        + return bytes(s)  | 
                    |
| 254 | 
                        +  | 
                    |
| 255 | 
                        + @classmethod  | 
                    |
| 256 | 
                        + def create_hash(  | 
                    |
| 257 | 
                        + cls,  | 
                    |
| 258 | 
                        + phrase: bytes | bytearray | str,  | 
                    |
| 259 | 
                        + service: bytes | bytearray,  | 
                    |
| 260 | 
                        + *,  | 
                    |
| 261 | 
                        + length: int = 32,  | 
                    |
| 262 | 
                        + ) -> bytes:  | 
                    |
| 263 | 
                        + r"""Create a pseudorandom byte stream from phrase and service.  | 
                    |
| 264 | 
                        +  | 
                    |
| 265 | 
                        + Create a pseudorandom byte stream from `phrase` and `service` by  | 
                    |
| 266 | 
                        + feeding them into the key-derivation function PBKDF2  | 
                    |
| 267 | 
                        + (8 iterations, using SHA-1).  | 
                    |
| 268 | 
                        +  | 
                    |
| 269 | 
                        + Args:  | 
                    |
| 270 | 
                        + phrase:  | 
                    |
| 271 | 
                        + A master passphrase, or sometimes an SSH signature.  | 
                    |
| 272 | 
                        + Used as the key for PBKDF2, the underlying cryptographic  | 
                    |
| 273 | 
                        + primitive.  | 
                    |
| 274 | 
                        +  | 
                    |
| 275 | 
                        + If a text string, then the byte representation must be  | 
                    |
| 276 | 
                        + unique.  | 
                    |
| 277 | 
                        + service:  | 
                    |
| 278 | 
                        + A vault service name. Will be suffixed with  | 
                    |
| 279 | 
                        + `Vault._UUID`, and then used as the salt value for  | 
                    |
| 280 | 
                        + PBKDF2.  | 
                    |
| 281 | 
                        + length:  | 
                    |
| 282 | 
                        + The length of the byte stream to generate.  | 
                    |
| 283 | 
                        +  | 
                    |
| 284 | 
                        + Returns:  | 
                    |
| 285 | 
                        + A pseudorandom byte string of length `length`.  | 
                    |
| 286 | 
                        +  | 
                    |
| 287 | 
                        + Raises:  | 
                    |
| 288 | 
                        + AmbiguousByteRepresentationError:  | 
                    |
| 289 | 
                        + The phrase is a text string with differing NFC- and  | 
                    |
| 290 | 
                        + NFD-normalized UTF-8 byte representations.  | 
                    |
| 291 | 
                        +  | 
                    |
| 292 | 
                        + Note:  | 
                    |
| 293 | 
                        + Shorter values returned from this method (with the same key  | 
                    |
| 294 | 
                        + and message) are prefixes of longer values returned from  | 
                    |
| 295 | 
                        + this method. (This property is inherited from the  | 
                    |
| 296 | 
                        + underlying PBKDF2 function.) It is thus safe (if slow) to  | 
                    |
| 297 | 
                        + call this method with the same input with ever-increasing  | 
                    |
| 298 | 
                        + target lengths.  | 
                    |
| 299 | 
                        +  | 
                    |
| 300 | 
                        + Examples:  | 
                    |
| 301 | 
                        + >>> # See also Vault.phrase_from_key examples.  | 
                    |
| 302 | 
                        +            >>> phrase = bytes.fromhex('''
                       | 
                    |
| 303 | 
                        + ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39  | 
                    |
| 304 | 
                        + ... 00 00 00 40  | 
                    |
| 305 | 
                        + ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86  | 
                    |
| 306 | 
                        + ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd  | 
                    |
| 307 | 
                        + ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c  | 
                    |
| 308 | 
                        + ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02  | 
                    |
| 309 | 
                        + ... ''')  | 
                    |
| 310 | 
                        + >>> Vault.create_hash(phrase, b'some_service', length=4)  | 
                    |
| 311 | 
                        + b'M\xb1<S'  | 
                    |
| 312 | 
                        + >>> Vault.create_hash(phrase, b'some_service', length=16)  | 
                    |
| 313 | 
                        + b'M\xb1<S\x827E\xd1M\xaf\xf8~\xc8n\x10\xcc'  | 
                    |
| 314 | 
                        + >>> Vault.create_hash(phrase, b'NOSUCHSERVICE', length=16)  | 
                    |
| 315 | 
                        + b'\x1c\xc3\x9c\xd9\xb6\x1a\x99CS\x07\xc41\xf4\x85#s'  | 
                    |
| 316 | 
                        +  | 
                    |
| 317 | 
                        + """  | 
                    |
| 318 | 
                        + phrase = cls._get_binary_string(phrase)  | 
                    |
| 319 | 
                        + assert not isinstance(phrase, str)  | 
                    |
| 320 | 
                        + salt = bytes(service) + cls._UUID  | 
                    |
| 321 | 
                        + return hashlib.pbkdf2_hmac(  | 
                    |
| 322 | 
                        + hash_name='sha1',  | 
                    |
| 323 | 
                        + password=phrase,  | 
                    |
| 324 | 
                        + salt=salt,  | 
                    |
| 325 | 
                        + iterations=8,  | 
                    |
| 326 | 
                        + dklen=length,  | 
                    |
| 327 | 
                        + )  | 
                    |
| 328 | 
                        +  | 
                    |
| 329 | 
                        + def generate(  | 
                    |
| 330 | 
                        + self,  | 
                    |
| 331 | 
                        + service_name: str | bytes | bytearray,  | 
                    |
| 332 | 
                        + /,  | 
                    |
| 333 | 
                        + *,  | 
                    |
| 334 | 
                        + phrase: bytes | bytearray | str = b'',  | 
                    |
| 335 | 
                        + ) -> bytes:  | 
                    |
| 336 | 
                        + r"""Generate a service passphrase.  | 
                    |
| 337 | 
                        +  | 
                    |
| 338 | 
                        + Args:  | 
                    |
| 339 | 
                        + service_name:  | 
                    |
| 340 | 
                        + The service name.  | 
                    |
| 341 | 
                        + phrase:  | 
                    |
| 342 | 
                        + If given, override the passphrase given during  | 
                    |
| 343 | 
                        + construction.  | 
                    |
| 344 | 
                        +  | 
                    |
| 345 | 
                        + If a text string, then the byte representation must be  | 
                    |
| 346 | 
                        + unique.  | 
                    |
| 347 | 
                        +  | 
                    |
| 348 | 
                        + Returns:  | 
                    |
| 349 | 
                        + The service passphrase.  | 
                    |
| 350 | 
                        +  | 
                    |
| 351 | 
                        + Raises:  | 
                    |
| 352 | 
                        + AmbiguousByteRepresentationError:  | 
                    |
| 353 | 
                        + The phrase is a text string with differing NFC- and  | 
                    |
| 354 | 
                        + NFD-normalized UTF-8 byte representations.  | 
                    |
| 355 | 
                        +  | 
                    |
| 356 | 
                        + Examples:  | 
                    |
| 357 | 
                        + >>> phrase = b'She cells C shells bye the sea shoars'  | 
                    |
| 358 | 
                        + >>> # Using default options in constructor.  | 
                    |
| 359 | 
                        + >>> Vault(phrase=phrase).generate(b'google')  | 
                    |
| 360 | 
                        +            b': 4TVH#5:aZl8LueOT\\{'
                       | 
                    |
| 361 | 
                        + >>> # Also possible:  | 
                    |
| 362 | 
                        + >>> Vault().generate(b'google', phrase=phrase)  | 
                    |
| 363 | 
                        +            b': 4TVH#5:aZl8LueOT\\{'
                       | 
                    |
| 364 | 
                        +  | 
                    |
| 365 | 
                        + """  | 
                    |
| 366 | 
                        + hash_length = self._estimate_sufficient_hash_length()  | 
                    |
| 367 | 
                        + assert hash_length >= 1  | 
                    |
| 368 | 
                        + # Ensure the phrase is a bytes object. Needed later for safe  | 
                    |
| 369 | 
                        + # concatenation.  | 
                    |
| 370 | 
                        + if isinstance(service_name, str):  | 
                    |
| 371 | 
                        +            service_name = service_name.encode('utf-8')
                       | 
                    |
| 372 | 
                        + elif not isinstance(service_name, bytes):  | 
                    |
| 373 | 
                        + service_name = bytes(service_name)  | 
                    |
| 374 | 
                        + assert_type(service_name, bytes)  | 
                    |
| 375 | 
                        + if not phrase:  | 
                    |
| 376 | 
                        + phrase = self._phrase  | 
                    |
| 377 | 
                        + phrase = self._get_binary_string(phrase)  | 
                    |
| 378 | 
                        + # Repeat the passphrase generation with ever-increasing hash  | 
                    |
| 379 | 
                        + # lengths, until the passphrase can be formed without exhausting  | 
                    |
| 380 | 
                        + # the sequin. See the guarantee in the create_hash method for  | 
                    |
| 381 | 
                        + # why this works.  | 
                    |
| 382 | 
                        + while True:  | 
                    |
| 383 | 
                        + try:  | 
                    |
| 384 | 
                        + required = self._required[:]  | 
                    |
| 385 | 
                        + seq = sequin.Sequin(  | 
                    |
| 386 | 
                        + self.create_hash(  | 
                    |
| 387 | 
                        + phrase=phrase, service=service_name, length=hash_length  | 
                    |
| 388 | 
                        + )  | 
                    |
| 389 | 
                        + )  | 
                    |
| 390 | 
                        + result = bytearray()  | 
                    |
| 391 | 
                        + while len(result) < self._length:  | 
                    |
| 392 | 
                        + pos = seq.generate(len(required))  | 
                    |
| 393 | 
                        + charset = required.pop(pos)  | 
                    |
| 394 | 
                        + # Determine if an unlucky choice right now might  | 
                    |
| 395 | 
                        + # violate the restriction on repeated characters.  | 
                    |
| 396 | 
                        + # That is, check if the current partial passphrase  | 
                    |
| 397 | 
                        + # ends with r - 1 copies of the same character  | 
                    |
| 398 | 
                        + # (where r is the repeat limit that must not be  | 
                    |
| 399 | 
                        + # reached), and if so, remove this same character  | 
                    |
| 400 | 
                        + # from the current character's allowed set.  | 
                    |
| 401 | 
                        + if self._repeat and result:  | 
                    |
| 402 | 
                        + bad_suffix = bytes(result[-1:]) * (self._repeat - 1)  | 
                    |
| 403 | 
                        + if result.endswith(bad_suffix):  | 
                    |
| 404 | 
                        + charset = self._subtract(  | 
                    |
| 405 | 
                        + bytes(result[-1:]), charset  | 
                    |
| 406 | 
                        + )  | 
                    |
| 407 | 
                        + pos = seq.generate(len(charset))  | 
                    |
| 408 | 
                        + result.extend(charset[pos : pos + 1])  | 
                    |
| 409 | 
                        + except sequin.SequinExhaustedError:  | 
                    |
| 410 | 
                        + hash_length *= 2  | 
                    |
| 411 | 
                        + else:  | 
                    |
| 412 | 
                        + return bytes(result)  | 
                    |
| 413 | 
                        +  | 
                    |
| 414 | 
                        + @staticmethod  | 
                    |
| 415 | 
                        + def _is_suitable_ssh_key(key: bytes | bytearray, /) -> bool:  | 
                    |
| 416 | 
                        + """Check whether the key is suitable for passphrase derivation.  | 
                    |
| 417 | 
                        +  | 
                    |
| 418 | 
                        + Currently, this only checks whether signatures with this key  | 
                    |
| 419 | 
                        + type are deterministic.  | 
                    |
| 420 | 
                        +  | 
                    |
| 421 | 
                        + Args:  | 
                    |
| 422 | 
                        + key: SSH public key to check.  | 
                    |
| 423 | 
                        +  | 
                    |
| 424 | 
                        + Returns:  | 
                    |
| 425 | 
                        + True if and only if the key is suitable for use in deriving  | 
                    |
| 426 | 
                        + a passphrase deterministically.  | 
                    |
| 427 | 
                        +  | 
                    |
| 428 | 
                        + """  | 
                    |
| 429 | 
                        + TestFunc: TypeAlias = Callable[[bytes | bytearray], bool]  | 
                    |
| 430 | 
                        + deterministic_signature_types: dict[str, TestFunc]  | 
                    |
| 431 | 
                        +        deterministic_signature_types = {
                       | 
                    |
| 432 | 
                        + 'ssh-ed25519': lambda k: k.startswith(  | 
                    |
| 433 | 
                        + b'\x00\x00\x00\x0bssh-ed25519'  | 
                    |
| 434 | 
                        + ),  | 
                    |
| 435 | 
                        + 'ssh-ed448': lambda k: k.startswith(b'\x00\x00\x00\x09ssh-ed448'),  | 
                    |
| 436 | 
                        + 'ssh-rsa': lambda k: k.startswith(b'\x00\x00\x00\x07ssh-rsa'),  | 
                    |
| 437 | 
                        + }  | 
                    |
| 438 | 
                        + return any(v(key) for v in deterministic_signature_types.values())  | 
                    |
| 439 | 
                        +  | 
                    |
| 440 | 
                        + @classmethod  | 
                    |
| 441 | 
                        + def phrase_from_key(cls, key: bytes | bytearray, /) -> bytes:  | 
                    |
| 442 | 
                        + """Obtain the master passphrase from a configured SSH key.  | 
                    |
| 443 | 
                        +  | 
                    |
| 444 | 
                        + vault allows the usage of certain SSH keys to derive a master  | 
                    |
| 445 | 
                        + passphrase, by signing the vault UUID with the SSH key. The key  | 
                    |
| 446 | 
                        + type must ensure that signatures are deterministic.  | 
                    |
| 447 | 
                        +  | 
                    |
| 448 | 
                        + Args:  | 
                    |
| 449 | 
                        + key: The (public) SSH key to use for signing.  | 
                    |
| 450 | 
                        +  | 
                    |
| 451 | 
                        + Returns:  | 
                    |
| 452 | 
                        + The signature of the vault UUID under this key, unframed but  | 
                    |
| 453 | 
                        + encoded in base64.  | 
                    |
| 454 | 
                        +  | 
                    |
| 455 | 
                        + Raises:  | 
                    |
| 456 | 
                        + ValueError:  | 
                    |
| 457 | 
                        + The SSH key is principally unsuitable for this use case.  | 
                    |
| 458 | 
                        + Usually this means that the signature is not  | 
                    |
| 459 | 
                        + deterministic.  | 
                    |
| 460 | 
                        +  | 
                    |
| 461 | 
                        + Examples:  | 
                    |
| 462 | 
                        + >>> import base64  | 
                    |
| 463 | 
                        + >>> # Actual Ed25519 test public key.  | 
                    |
| 464 | 
                        +            >>> public_key = bytes.fromhex('''
                       | 
                    |
| 465 | 
                        + ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39  | 
                    |
| 466 | 
                        + ... 00 00 00 20  | 
                    |
| 467 | 
                        + ... 81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1  | 
                    |
| 468 | 
                        + ... 30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76  | 
                    |
| 469 | 
                        + ... ''')  | 
                    |
| 470 | 
                        +            >>> expected_sig_raw = bytes.fromhex('''
                       | 
                    |
| 471 | 
                        + ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39  | 
                    |
| 472 | 
                        + ... 00 00 00 40  | 
                    |
| 473 | 
                        + ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86  | 
                    |
| 474 | 
                        + ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd  | 
                    |
| 475 | 
                        + ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c  | 
                    |
| 476 | 
                        + ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02  | 
                    |
| 477 | 
                        + ... ''')  | 
                    |
| 478 | 
                        + >>> # Raw Ed25519 signatures are 64 bytes long.  | 
                    |
| 479 | 
                        + >>> signature_blob = expected_sig_raw[-64:]  | 
                    |
| 480 | 
                        + >>> phrase = base64.standard_b64encode(signature_blob)  | 
                    |
| 481 | 
                        + >>> Vault.phrase_from_key(phrase) == expected # doctest:+SKIP  | 
                    |
| 482 | 
                        + True  | 
                    |
| 483 | 
                        +  | 
                    |
| 484 | 
                        + """  | 
                    |
| 485 | 
                        + if not cls._is_suitable_ssh_key(key):  | 
                    |
| 486 | 
                        + msg = (  | 
                    |
| 487 | 
                        + 'unsuitable SSH key: bad key, or '  | 
                    |
| 488 | 
                        + 'signature not deterministic'  | 
                    |
| 489 | 
                        + )  | 
                    |
| 490 | 
                        + raise ValueError(msg)  | 
                    |
| 491 | 
                        + with ssh_agent.SSHAgentClient() as client:  | 
                    |
| 492 | 
                        + raw_sig = client.sign(key, cls._UUID)  | 
                    |
| 493 | 
                        + _keytype, trailer = client.unstring_prefix(raw_sig)  | 
                    |
| 494 | 
                        + signature_blob = client.unstring(trailer)  | 
                    |
| 495 | 
                        + return bytes(base64.standard_b64encode(signature_blob))  | 
                    |
| 496 | 
                        +  | 
                    |
| 497 | 
                        + @staticmethod  | 
                    |
| 498 | 
                        + def _subtract(  | 
                    |
| 499 | 
                        + charset: bytes | bytearray,  | 
                    |
| 500 | 
                        + allowed: bytes | bytearray,  | 
                    |
| 501 | 
                        + ) -> bytearray:  | 
                    |
| 502 | 
                        + """Remove the characters in charset from allowed.  | 
                    |
| 503 | 
                        +  | 
                    |
| 504 | 
                        + This preserves the relative order of characters in `allowed`.  | 
                    |
| 505 | 
                        +  | 
                    |
| 506 | 
                        + Args:  | 
                    |
| 507 | 
                        + charset:  | 
                    |
| 508 | 
                        + Characters to remove. Must not contain duplicate  | 
                    |
| 509 | 
                        + characters.  | 
                    |
| 510 | 
                        + allowed:  | 
                    |
| 511 | 
                        + Character set to remove the other characters from. Must  | 
                    |
| 512 | 
                        + not contain duplicate characters.  | 
                    |
| 513 | 
                        +  | 
                    |
| 514 | 
                        + Returns:  | 
                    |
| 515 | 
                        + The pruned "allowed" character set.  | 
                    |
| 516 | 
                        +  | 
                    |
| 517 | 
                        + Raises:  | 
                    |
| 518 | 
                        + ValueError:  | 
                    |
| 519 | 
                        + `allowed` or `charset` contained duplicate characters.  | 
                    |
| 520 | 
                        +  | 
                    |
| 521 | 
                        + """  | 
                    |
| 522 | 
                        + allowed = (  | 
                    |
| 523 | 
                        + allowed if isinstance(allowed, bytearray) else bytearray(allowed)  | 
                    |
| 524 | 
                        + )  | 
                    |
| 525 | 
                        + assert_type(allowed, bytearray)  | 
                    |
| 526 | 
                        + msg_dup_characters = 'duplicate characters in set'  | 
                    |
| 527 | 
                        + if len(frozenset(allowed)) != len(allowed):  | 
                    |
| 528 | 
                        + raise ValueError(msg_dup_characters)  | 
                    |
| 529 | 
                        + if len(frozenset(charset)) != len(charset):  | 
                    |
| 530 | 
                        + raise ValueError(msg_dup_characters)  | 
                    |
| 531 | 
                        + for c in charset:  | 
                    |
| 532 | 
                        + try:  | 
                    |
| 533 | 
                        + pos = allowed.index(c)  | 
                    |
| 534 | 
                        + except ValueError:  | 
                    |
| 535 | 
                        + pass  | 
                    |
| 536 | 
                        + else:  | 
                    |
| 537 | 
                        + allowed[pos : pos + 1] = []  | 
                    |
| 538 | 
                        + return allowed  | 
                    
| ... | ... | 
                      @@ -14,9 +14,9 @@ import pytest  | 
                  
| 14 | 14 | 
                         | 
                    
| 15 | 15 | 
                        import derivepassphrase  | 
                    
| 16 | 16 | 
                        import derivepassphrase.cli  | 
                    
| 17 | 
                        +import derivepassphrase.ssh_agent  | 
                    |
| 18 | 
                        +import derivepassphrase.ssh_agent.types  | 
                    |
| 17 | 19 | 
                        import derivepassphrase.types  | 
                    
| 18 | 
                        -import ssh_agent_client  | 
                    |
| 19 | 
                        -import ssh_agent_client.types  | 
                    |
| 20 | 20 | 
                         | 
                    
| 21 | 21 | 
                        __all__ = ()  | 
                    
| 22 | 22 | 
                         | 
                    
| ... | ... | 
                      @@ -357,9 +357,9 @@ skip_if_no_agent = pytest.mark.skipif(  | 
                  
| 357 | 357 | 
                         | 
                    
| 358 | 358 | 
                        def list_keys(  | 
                    
| 359 | 359 | 
                        self: Any = None,  | 
                    
| 360 | 
                        -) -> list[ssh_agent_client.types.KeyCommentPair]:  | 
                    |
| 360 | 
                        +) -> list[derivepassphrase.ssh_agent.types.KeyCommentPair]:  | 
                    |
| 361 | 361 | 
                        del self # Unused.  | 
                    
| 362 | 
                        - Pair = ssh_agent_client.types.KeyCommentPair # noqa: N806  | 
                    |
| 362 | 
                        + Pair = derivepassphrase.ssh_agent.types.KeyCommentPair # noqa: N806  | 
                    |
| 363 | 363 | 
                        list1 = [  | 
                    
| 364 | 364 | 
                                 Pair(value['public_key_data'], f'{key} test key'.encode('ASCII'))
                       | 
                    
| 365 | 365 | 
                        for key, value in SUPPORTED_KEYS.items()  | 
                    
| ... | ... | 
                      @@ -373,9 +373,9 @@ def list_keys(  | 
                  
| 373 | 373 | 
                         | 
                    
| 374 | 374 | 
                        def list_keys_singleton(  | 
                    
| 375 | 375 | 
                        self: Any = None,  | 
                    
| 376 | 
                        -) -> list[ssh_agent_client.types.KeyCommentPair]:  | 
                    |
| 376 | 
                        +) -> list[derivepassphrase.ssh_agent.types.KeyCommentPair]:  | 
                    |
| 377 | 377 | 
                        del self # Unused.  | 
                    
| 378 | 
                        - Pair = ssh_agent_client.types.KeyCommentPair # noqa: N806  | 
                    |
| 378 | 
                        + Pair = derivepassphrase.ssh_agent.types.KeyCommentPair # noqa: N806  | 
                    |
| 379 | 379 | 
                        list1 = [  | 
                    
| 380 | 380 | 
                                 Pair(value['public_key_data'], f'{key} test key'.encode('ASCII'))
                       | 
                    
| 381 | 381 | 
                        for key, value in SUPPORTED_KEYS.items()  | 
                    
| ... | ... | 
                      @@ -385,11 +385,12 @@ def list_keys_singleton(  | 
                  
| 385 | 385 | 
                         | 
                    
| 386 | 386 | 
                        def suitable_ssh_keys(  | 
                    
| 387 | 387 | 
                        conn: Any,  | 
                    
| 388 | 
                        -) -> Iterator[ssh_agent_client.types.KeyCommentPair]:  | 
                    |
| 388 | 
                        +) -> Iterator[derivepassphrase.ssh_agent.types.KeyCommentPair]:  | 
                    |
| 389 | 389 | 
                        del conn # Unused.  | 
                    
| 390 | 
                        + Pair = derivepassphrase.ssh_agent.types.KeyCommentPair # noqa: N806  | 
                    |
| 390 | 391 | 
                        yield from [  | 
                    
| 391 | 
                        - ssh_agent_client.types.KeyCommentPair(DUMMY_KEY1, b'no comment'),  | 
                    |
| 392 | 
                        - ssh_agent_client.types.KeyCommentPair(DUMMY_KEY2, b'a comment'),  | 
                    |
| 392 | 
                        + Pair(DUMMY_KEY1, b'no comment'),  | 
                    |
| 393 | 
                        + Pair(DUMMY_KEY2, b'a comment'),  | 
                    |
| 393 | 394 | 
                        ]  | 
                    
| 394 | 395 | 
                         | 
                    
| 395 | 396 | 
                         | 
                    
| ... | ... | 
                      @@ -2,7 +2,7 @@  | 
                  
| 2 | 2 | 
                        #  | 
                    
| 3 | 3 | 
                        # SPDX-License-Identifier: MIT  | 
                    
| 4 | 4 | 
                         | 
                    
| 5 | 
                        -"""Test passphrase generation via derivepassphrase.Vault."""  | 
                    |
| 5 | 
                        +"""Test passphrase generation via derivepassphrase.vault.Vault."""  | 
                    |
| 6 | 6 | 
                         | 
                    
| 7 | 7 | 
                        from __future__ import annotations  | 
                    
| 8 | 8 | 
                         | 
                    
| ... | ... | 
                      @@ -13,7 +13,7 @@ import pytest  | 
                  
| 13 | 13 | 
                         | 
                    
| 14 | 14 | 
                        import derivepassphrase  | 
                    
| 15 | 15 | 
                         | 
                    
| 16 | 
                        -Vault = derivepassphrase.Vault  | 
                    |
| 16 | 
                        +Vault = derivepassphrase.vault.Vault  | 
                    |
| 17 | 17 | 
                         | 
                    
| 18 | 18 | 
                         | 
                    
| 19 | 19 | 
                        class TestVault:  | 
                    
| ... | ... | 
                      @@ -218,9 +218,9 @@ class TestVault:  | 
                  
| 218 | 218 | 
                        def test_224_binary_strings(  | 
                    
| 219 | 219 | 
                        self, s: str | bytes | bytearray, raises: bool  | 
                    
| 220 | 220 | 
                        ) -> None:  | 
                    
| 221 | 
                        - binstr = derivepassphrase.Vault._get_binary_string  | 
                    |
| 221 | 
                        + binstr = Vault._get_binary_string  | 
                    |
| 222 | 222 | 
                        AmbiguousByteRepresentationError = ( # noqa: N806  | 
                    
| 223 | 
                        - derivepassphrase.AmbiguousByteRepresentationError  | 
                    |
| 223 | 
                        + derivepassphrase.vault.AmbiguousByteRepresentationError  | 
                    |
| 224 | 224 | 
                        )  | 
                    
| 225 | 225 | 
                        if raises:  | 
                    
| 226 | 226 | 
                        with pytest.raises(AmbiguousByteRepresentationError):  | 
                    
| ... | ... | 
                      @@ -16,9 +16,8 @@ import pytest  | 
                  
| 16 | 16 | 
                        from typing_extensions import NamedTuple  | 
                    
| 17 | 17 | 
                         | 
                    
| 18 | 18 | 
                        import derivepassphrase as dpp  | 
                    
| 19 | 
                        -import ssh_agent_client  | 
                    |
| 20 | 19 | 
                        import tests  | 
                    
| 21 | 
                        -from derivepassphrase import cli  | 
                    |
| 20 | 
                        +from derivepassphrase import cli, ssh_agent  | 
                    |
| 22 | 21 | 
                         | 
                    
| 23 | 22 | 
                        if TYPE_CHECKING:  | 
                    
| 24 | 23 | 
                        from collections.abc import Callable  | 
                    
| ... | ... | 
                      @@ -226,7 +225,7 @@ class TestCLI:  | 
                  
| 226 | 225 | 
                        ) -> None:  | 
                    
| 227 | 226 | 
                        monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)  | 
                    
| 228 | 227 | 
                                 option = f'--{charset_name}'
                       | 
                    
| 229 | 
                        -        charset = dpp.Vault._CHARSETS[charset_name].decode('ascii')
                       | 
                    |
| 228 | 
                        +        charset = dpp.vault.Vault._CHARSETS[charset_name].decode('ascii')
                       | 
                    |
| 230 | 229 | 
                        runner = click.testing.CliRunner(mix_stderr=False)  | 
                    
| 231 | 230 | 
                        with tests.isolated_config(  | 
                    
| 232 | 231 | 
                        monkeypatch=monkeypatch,  | 
                    
| ... | ... | 
                      @@ -316,7 +315,7 @@ class TestCLI:  | 
                  
| 316 | 315 | 
                        monkeypatch=monkeypatch, runner=runner, config=config  | 
                    
| 317 | 316 | 
                        ):  | 
                    
| 318 | 317 | 
                        monkeypatch.setattr(  | 
                    
| 319 | 
                        - dpp.Vault, 'phrase_from_key', tests.phrase_from_key  | 
                    |
| 318 | 
                        + dpp.vault.Vault, 'phrase_from_key', tests.phrase_from_key  | 
                    |
| 320 | 319 | 
                        )  | 
                    
| 321 | 320 | 
                        result = runner.invoke(  | 
                    
| 322 | 321 | 
                        cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False  | 
                    
| ... | ... | 
                      @@ -343,7 +342,7 @@ class TestCLI:  | 
                  
| 343 | 342 | 
                        cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys  | 
                    
| 344 | 343 | 
                        )  | 
                    
| 345 | 344 | 
                        monkeypatch.setattr(  | 
                    
| 346 | 
                        - dpp.Vault, 'phrase_from_key', tests.phrase_from_key  | 
                    |
| 345 | 
                        + dpp.vault.Vault, 'phrase_from_key', tests.phrase_from_key  | 
                    |
| 347 | 346 | 
                        )  | 
                    
| 348 | 347 | 
                        result = runner.invoke(  | 
                    
| 349 | 348 | 
                        cli.derivepassphrase,  | 
                    
| ... | ... | 
                      @@ -403,9 +402,9 @@ class TestCLI:  | 
                  
| 403 | 402 | 
                        raise AssertionError  | 
                    
| 404 | 403 | 
                         | 
                    
| 405 | 404 | 
                        monkeypatch.setattr(  | 
                    
| 406 | 
                        - ssh_agent_client.SSHAgentClient, 'list_keys', tests.list_keys  | 
                    |
| 405 | 
                        + ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys  | 
                    |
| 407 | 406 | 
                        )  | 
                    
| 408 | 
                        - monkeypatch.setattr(ssh_agent_client.SSHAgentClient, 'sign', sign)  | 
                    |
| 407 | 
                        + monkeypatch.setattr(ssh_agent.SSHAgentClient, 'sign', sign)  | 
                    |
| 409 | 408 | 
                        runner = click.testing.CliRunner(mix_stderr=False)  | 
                    
| 410 | 409 | 
                        with tests.isolated_config(  | 
                    
| 411 | 410 | 
                        monkeypatch=monkeypatch, runner=runner, config=config  | 
                    
| ... | ... | 
                      @@ -1304,7 +1303,7 @@ Boo.  | 
                  
| 1304 | 1303 | 
                         | 
                    
| 1305 | 1304 | 
                        def test_204_phrase_from_key_manually(self) -> None:  | 
                    
| 1306 | 1305 | 
                        assert (  | 
                    
| 1307 | 
                        - dpp.Vault(  | 
                    |
| 1306 | 
                        + dpp.vault.Vault(  | 
                    |
| 1308 | 1307 | 
                        phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS  | 
                    
| 1309 | 1308 | 
                        ).generate(DUMMY_SERVICE)  | 
                    
| 1310 | 1309 | 
                        == DUMMY_RESULT_KEY1  | 
                    
| ... | ... | 
                      @@ -1334,12 +1333,12 @@ Boo.  | 
                  
| 1334 | 1333 | 
                        conn_hint: str,  | 
                    
| 1335 | 1334 | 
                        ) -> None:  | 
                    
| 1336 | 1335 | 
                        monkeypatch.setattr(  | 
                    
| 1337 | 
                        - ssh_agent_client.SSHAgentClient, 'list_keys', tests.list_keys  | 
                    |
| 1336 | 
                        + ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys  | 
                    |
| 1338 | 1337 | 
                        )  | 
                    
| 1339 | 
                        - hint: ssh_agent_client.SSHAgentClient | socket.socket | None  | 
                    |
| 1338 | 
                        + hint: ssh_agent.SSHAgentClient | socket.socket | None  | 
                    |
| 1340 | 1339 | 
                        match conn_hint:  | 
                    
| 1341 | 1340 | 
                        case 'client':  | 
                    
| 1342 | 
                        - hint = ssh_agent_client.SSHAgentClient()  | 
                    |
| 1341 | 
                        + hint = ssh_agent.SSHAgentClient()  | 
                    |
| 1343 | 1342 | 
                        case 'socket':  | 
                    
| 1344 | 1343 | 
                        hint = socket.socket(family=socket.AF_UNIX)  | 
                    
| 1345 | 1344 | 
                        hint.connect(os.environ['SSH_AUTH_SOCK'])  | 
                    
| ... | ... | 
                      @@ -18,10 +18,8 @@ import click.testing  | 
                  
| 18 | 18 | 
                        import pytest  | 
                    
| 19 | 19 | 
                        from typing_extensions import Any  | 
                    
| 20 | 20 | 
                         | 
                    
| 21 | 
                        -import derivepassphrase  | 
                    |
| 22 | 
                        -import derivepassphrase.cli  | 
                    |
| 23 | 
                        -import ssh_agent_client  | 
                    |
| 24 | 21 | 
                        import tests  | 
                    
| 22 | 
                        +from derivepassphrase import cli, ssh_agent, vault  | 
                    |
| 25 | 23 | 
                         | 
                    
| 26 | 24 | 
                        if TYPE_CHECKING:  | 
                    
| 27 | 25 | 
                        from collections.abc import Iterator  | 
                    
| ... | ... | 
                      @@ -49,7 +47,7 @@ class TestStaticFunctionality:  | 
                  
| 49 | 47 | 
                        with pytest.raises(  | 
                    
| 50 | 48 | 
                        KeyError, match='SSH_AUTH_SOCK environment variable'  | 
                    
| 51 | 49 | 
                        ):  | 
                    
| 52 | 
                        - ssh_agent_client.SSHAgentClient(socket=sock)  | 
                    |
| 50 | 
                        + ssh_agent.SSHAgentClient(socket=sock)  | 
                    |
| 53 | 51 | 
                         | 
                    
| 54 | 52 | 
                        @pytest.mark.parametrize(  | 
                    
| 55 | 53 | 
                        ['input', 'expected'],  | 
                    
| ... | ... | 
                      @@ -58,7 +56,7 @@ class TestStaticFunctionality:  | 
                  
| 58 | 56 | 
                        ],  | 
                    
| 59 | 57 | 
                        )  | 
                    
| 60 | 58 | 
                        def test_210_uint32(self, input: int, expected: bytes | bytearray) -> None:  | 
                    
| 61 | 
                        - uint32 = ssh_agent_client.SSHAgentClient.uint32  | 
                    |
| 59 | 
                        + uint32 = ssh_agent.SSHAgentClient.uint32  | 
                    |
| 62 | 60 | 
                        assert uint32(input) == expected  | 
                    
| 63 | 61 | 
                         | 
                    
| 64 | 62 | 
                        @pytest.mark.parametrize(  | 
                    
| ... | ... | 
                      @@ -67,7 +65,7 @@ class TestStaticFunctionality:  | 
                  
| 67 | 65 | 
                        (b'ssh-rsa', b'\x00\x00\x00\x07ssh-rsa'),  | 
                    
| 68 | 66 | 
                        (b'ssh-ed25519', b'\x00\x00\x00\x0bssh-ed25519'),  | 
                    
| 69 | 67 | 
                        (  | 
                    
| 70 | 
                        - ssh_agent_client.SSHAgentClient.string(b'ssh-ed25519'),  | 
                    |
| 68 | 
                        + ssh_agent.SSHAgentClient.string(b'ssh-ed25519'),  | 
                    |
| 71 | 69 | 
                        b'\x00\x00\x00\x0f\x00\x00\x00\x0bssh-ed25519',  | 
                    
| 72 | 70 | 
                        ),  | 
                    
| 73 | 71 | 
                        ],  | 
                    
| ... | ... | 
                      @@ -75,7 +73,7 @@ class TestStaticFunctionality:  | 
                  
| 75 | 73 | 
                        def test_211_string(  | 
                    
| 76 | 74 | 
                        self, input: bytes | bytearray, expected: bytes | bytearray  | 
                    
| 77 | 75 | 
                        ) -> None:  | 
                    
| 78 | 
                        - string = ssh_agent_client.SSHAgentClient.string  | 
                    |
| 76 | 
                        + string = ssh_agent.SSHAgentClient.string  | 
                    |
| 79 | 77 | 
                        assert bytes(string(input)) == expected  | 
                    
| 80 | 78 | 
                         | 
                    
| 81 | 79 | 
                        @pytest.mark.parametrize(  | 
                    
| ... | ... | 
                      @@ -83,7 +81,7 @@ class TestStaticFunctionality:  | 
                  
| 83 | 81 | 
                        [  | 
                    
| 84 | 82 | 
                        (b'\x00\x00\x00\x07ssh-rsa', b'ssh-rsa'),  | 
                    
| 85 | 83 | 
                        (  | 
                    
| 86 | 
                        - ssh_agent_client.SSHAgentClient.string(b'ssh-ed25519'),  | 
                    |
| 84 | 
                        + ssh_agent.SSHAgentClient.string(b'ssh-ed25519'),  | 
                    |
| 87 | 85 | 
                        b'ssh-ed25519',  | 
                    
| 88 | 86 | 
                        ),  | 
                    
| 89 | 87 | 
                        ],  | 
                    
| ... | ... | 
                      @@ -91,8 +89,8 @@ class TestStaticFunctionality:  | 
                  
| 91 | 89 | 
                        def test_212_unstring(  | 
                    
| 92 | 90 | 
                        self, input: bytes | bytearray, expected: bytes | bytearray  | 
                    
| 93 | 91 | 
                        ) -> None:  | 
                    
| 94 | 
                        - unstring = ssh_agent_client.SSHAgentClient.unstring  | 
                    |
| 95 | 
                        - unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix  | 
                    |
| 92 | 
                        + unstring = ssh_agent.SSHAgentClient.unstring  | 
                    |
| 93 | 
                        + unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix  | 
                    |
| 96 | 94 | 
                        assert bytes(unstring(input)) == expected  | 
                    
| 97 | 95 | 
                        assert tuple(bytes(x) for x in unstring_prefix(input)) == (  | 
                    
| 98 | 96 | 
                        expected,  | 
                    
| ... | ... | 
                      @@ -109,7 +107,7 @@ class TestStaticFunctionality:  | 
                  
| 109 | 107 | 
                        def test_310_uint32_exceptions(  | 
                    
| 110 | 108 | 
                        self, value: int, exc_type: type[Exception], exc_pattern: str  | 
                    
| 111 | 109 | 
                        ) -> None:  | 
                    
| 112 | 
                        - uint32 = ssh_agent_client.SSHAgentClient.uint32  | 
                    |
| 110 | 
                        + uint32 = ssh_agent.SSHAgentClient.uint32  | 
                    |
| 113 | 111 | 
                        with pytest.raises(exc_type, match=exc_pattern):  | 
                    
| 114 | 112 | 
                        uint32(value)  | 
                    
| 115 | 113 | 
                         | 
                    
| ... | ... | 
                      @@ -122,7 +120,7 @@ class TestStaticFunctionality:  | 
                  
| 122 | 120 | 
                        def test_311_string_exceptions(  | 
                    
| 123 | 121 | 
                        self, input: Any, exc_type: type[Exception], exc_pattern: str  | 
                    
| 124 | 122 | 
                        ) -> None:  | 
                    
| 125 | 
                        - string = ssh_agent_client.SSHAgentClient.string  | 
                    |
| 123 | 
                        + string = ssh_agent.SSHAgentClient.string  | 
                    |
| 126 | 124 | 
                        with pytest.raises(exc_type, match=exc_pattern):  | 
                    
| 127 | 125 | 
                        string(input)  | 
                    
| 128 | 126 | 
                         | 
                    
| ... | ... | 
                      @@ -154,8 +152,8 @@ class TestStaticFunctionality:  | 
                  
| 154 | 152 | 
                        has_trailer: bool,  | 
                    
| 155 | 153 | 
                        parts: tuple[bytes | bytearray, bytes | bytearray] | None,  | 
                    
| 156 | 154 | 
                        ) -> None:  | 
                    
| 157 | 
                        - unstring = ssh_agent_client.SSHAgentClient.unstring  | 
                    |
| 158 | 
                        - unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix  | 
                    |
| 155 | 
                        + unstring = ssh_agent.SSHAgentClient.unstring  | 
                    |
| 156 | 
                        + unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix  | 
                    |
| 159 | 157 | 
                        with pytest.raises(exc_type, match=exc_pattern):  | 
                    
| 160 | 158 | 
                        unstring(input)  | 
                    
| 161 | 159 | 
                        if has_trailer:  | 
                    
| ... | ... | 
                      @@ -189,7 +187,7 @@ class TestAgentInteraction:  | 
                  
| 189 | 187 | 
                        )  | 
                    
| 190 | 188 | 
                        else:  | 
                    
| 191 | 189 | 
                        try:  | 
                    
| 192 | 
                        - client = ssh_agent_client.SSHAgentClient()  | 
                    |
| 190 | 
                        + client = ssh_agent.SSHAgentClient()  | 
                    |
| 193 | 191 | 
                        except OSError: # pragma: no cover  | 
                    
| 194 | 192 | 
                                         pytest.skip('communication error with the SSH agent')
                       | 
                    
| 195 | 193 | 
                        with client:  | 
                    
| ... | ... | 
                      @@ -202,19 +200,15 @@ class TestAgentInteraction:  | 
                  
| 202 | 200 | 
                        if public_key_data not in key_comment_pairs: # pragma: no cover  | 
                    
| 203 | 201 | 
                                         pytest.skip('prerequisite SSH key not loaded')
                       | 
                    
| 204 | 202 | 
                        signature = bytes(  | 
                    
| 205 | 
                        - client.sign(  | 
                    |
| 206 | 
                        - payload=derivepassphrase.Vault._UUID, key=public_key_data  | 
                    |
| 207 | 
                        - )  | 
                    |
| 203 | 
                        + client.sign(payload=vault.Vault._UUID, key=public_key_data)  | 
                    |
| 208 | 204 | 
                        )  | 
                    
| 209 | 205 | 
                        assert signature == expected_signature, 'SSH signature mismatch'  | 
                    
| 210 | 206 | 
                        signature2 = bytes(  | 
                    
| 211 | 
                        - client.sign(  | 
                    |
| 212 | 
                        - payload=derivepassphrase.Vault._UUID, key=public_key_data  | 
                    |
| 213 | 
                        - )  | 
                    |
| 207 | 
                        + client.sign(payload=vault.Vault._UUID, key=public_key_data)  | 
                    |
| 214 | 208 | 
                        )  | 
                    
| 215 | 209 | 
                        assert signature2 == expected_signature, 'SSH signature mismatch'  | 
                    
| 216 | 210 | 
                        assert (  | 
                    
| 217 | 
                        - derivepassphrase.Vault.phrase_from_key(public_key_data)  | 
                    |
| 211 | 
                        + vault.Vault.phrase_from_key(public_key_data)  | 
                    |
| 218 | 212 | 
                        == derived_passphrase  | 
                    
| 219 | 213 | 
                        ), 'SSH signature mismatch'  | 
                    
| 220 | 214 | 
                         | 
                    
| ... | ... | 
                      @@ -240,7 +234,7 @@ class TestAgentInteraction:  | 
                  
| 240 | 234 | 
                        )  | 
                    
| 241 | 235 | 
                        else:  | 
                    
| 242 | 236 | 
                        try:  | 
                    
| 243 | 
                        - client = ssh_agent_client.SSHAgentClient()  | 
                    |
| 237 | 
                        + client = ssh_agent.SSHAgentClient()  | 
                    |
| 244 | 238 | 
                        except OSError: # pragma: no cover  | 
                    
| 245 | 239 | 
                                         pytest.skip('communication error with the SSH agent')
                       | 
                    
| 246 | 240 | 
                        with client:  | 
                    
| ... | ... | 
                      @@ -252,18 +246,14 @@ class TestAgentInteraction:  | 
                  
| 252 | 246 | 
                        if public_key_data not in key_comment_pairs: # pragma: no cover  | 
                    
| 253 | 247 | 
                                         pytest.skip('prerequisite SSH key not loaded')
                       | 
                    
| 254 | 248 | 
                        signature = bytes(  | 
                    
| 255 | 
                        - client.sign(  | 
                    |
| 256 | 
                        - payload=derivepassphrase.Vault._UUID, key=public_key_data  | 
                    |
| 257 | 
                        - )  | 
                    |
| 249 | 
                        + client.sign(payload=vault.Vault._UUID, key=public_key_data)  | 
                    |
| 258 | 250 | 
                        )  | 
                    
| 259 | 251 | 
                        signature2 = bytes(  | 
                    
| 260 | 
                        - client.sign(  | 
                    |
| 261 | 
                        - payload=derivepassphrase.Vault._UUID, key=public_key_data  | 
                    |
| 262 | 
                        - )  | 
                    |
| 252 | 
                        + client.sign(payload=vault.Vault._UUID, key=public_key_data)  | 
                    |
| 263 | 253 | 
                        )  | 
                    
| 264 | 254 | 
                        assert signature != signature2, 'SSH signature repeatable?!'  | 
                    
| 265 | 255 | 
                        with pytest.raises(ValueError, match='unsuitable SSH key'):  | 
                    
| 266 | 
                        - derivepassphrase.Vault.phrase_from_key(public_key_data)  | 
                    |
| 256 | 
                        + vault.Vault.phrase_from_key(public_key_data)  | 
                    |
| 267 | 257 | 
                         | 
                    
| 268 | 258 | 
                        @staticmethod  | 
                    
| 269 | 259 | 
                        def _params() -> Iterator[tuple[bytes, bool]]:  | 
                    
| ... | ... | 
                      @@ -287,7 +277,7 @@ class TestAgentInteraction:  | 
                  
| 287 | 277 | 
                         | 
                    
| 288 | 278 | 
                        if single:  | 
                    
| 289 | 279 | 
                        monkeypatch.setattr(  | 
                    
| 290 | 
                        - ssh_agent_client.SSHAgentClient,  | 
                    |
| 280 | 
                        + ssh_agent.SSHAgentClient,  | 
                    |
| 291 | 281 | 
                        'list_keys',  | 
                    
| 292 | 282 | 
                        tests.list_keys_singleton,  | 
                    
| 293 | 283 | 
                        )  | 
                    
| ... | ... | 
                      @@ -300,7 +290,7 @@ class TestAgentInteraction:  | 
                  
| 300 | 290 | 
                        text = 'Use this key? yes\n'  | 
                    
| 301 | 291 | 
                        else:  | 
                    
| 302 | 292 | 
                        monkeypatch.setattr(  | 
                    
| 303 | 
                        - ssh_agent_client.SSHAgentClient, 'list_keys', tests.list_keys  | 
                    |
| 293 | 
                        + ssh_agent.SSHAgentClient, 'list_keys', tests.list_keys  | 
                    |
| 304 | 294 | 
                        )  | 
                    
| 305 | 295 | 
                        keys = [  | 
                    
| 306 | 296 | 
                        pair.key  | 
                    
| ... | ... | 
                      @@ -314,7 +304,7 @@ class TestAgentInteraction:  | 
                  
| 314 | 304 | 
                         | 
                    
| 315 | 305 | 
                        @click.command()  | 
                    
| 316 | 306 | 
                        def driver() -> None:  | 
                    
| 317 | 
                        - key = derivepassphrase.cli._select_ssh_key()  | 
                    |
| 307 | 
                        + key = cli._select_ssh_key()  | 
                    |
| 318 | 308 | 
                                     click.echo(base64.standard_b64encode(key).decode('ASCII'))
                       | 
                    
| 319 | 309 | 
                         | 
                    
| 320 | 310 | 
                        runner = click.testing.CliRunner(mix_stderr=True)  | 
                    
| ... | ... | 
                      @@ -339,7 +329,7 @@ class TestAgentInteraction:  | 
                  
| 339 | 329 | 
                                 monkeypatch.setenv('SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~')
                       | 
                    
| 340 | 330 | 
                        sock = socket.socket(family=socket.AF_UNIX)  | 
                    
| 341 | 331 | 
                        with pytest.raises(OSError): # noqa: PT011  | 
                    
| 342 | 
                        - ssh_agent_client.SSHAgentClient(socket=sock)  | 
                    |
| 332 | 
                        + ssh_agent.SSHAgentClient(socket=sock)  | 
                    |
| 343 | 333 | 
                         | 
                    
| 344 | 334 | 
                        @pytest.mark.parametrize(  | 
                    
| 345 | 335 | 
                        'response',  | 
                    
| ... | ... | 
                      @@ -351,7 +341,7 @@ class TestAgentInteraction:  | 
                  
| 351 | 341 | 
                        def test_310_truncated_server_response(  | 
                    
| 352 | 342 | 
                        self, monkeypatch: Any, response: bytes  | 
                    
| 353 | 343 | 
                        ) -> None:  | 
                    
| 354 | 
                        - client = ssh_agent_client.SSHAgentClient()  | 
                    |
| 344 | 
                        + client = ssh_agent.SSHAgentClient()  | 
                    |
| 355 | 345 | 
                        response_stream = io.BytesIO(response)  | 
                    
| 356 | 346 | 
                         | 
                    
| 357 | 347 | 
                        class PseudoSocket:  | 
                    
| ... | ... | 
                      @@ -375,7 +365,7 @@ class TestAgentInteraction:  | 
                  
| 375 | 365 | 
                        (  | 
                    
| 376 | 366 | 
                        12,  | 
                    
| 377 | 367 | 
                        b'\x00\x00\x00\x00abc',  | 
                    
| 378 | 
                        - ssh_agent_client.TrailingDataError,  | 
                    |
| 368 | 
                        + ssh_agent.TrailingDataError,  | 
                    |
| 379 | 369 | 
                        'Overlong response',  | 
                    
| 380 | 370 | 
                        ),  | 
                    
| 381 | 371 | 
                        ],  | 
                    
| ... | ... | 
                      @@ -388,7 +378,7 @@ class TestAgentInteraction:  | 
                  
| 388 | 378 | 
                        exc_type: type[Exception],  | 
                    
| 389 | 379 | 
                        exc_pattern: str,  | 
                    
| 390 | 380 | 
                        ) -> None:  | 
                    
| 391 | 
                        - client = ssh_agent_client.SSHAgentClient()  | 
                    |
| 381 | 
                        + client = ssh_agent.SSHAgentClient()  | 
                    |
| 392 | 382 | 
                        monkeypatch.setattr(  | 
                    
| 393 | 383 | 
                        client,  | 
                    
| 394 | 384 | 
                        'request',  | 
                    
| ... | ... | 
                      @@ -426,9 +416,9 @@ class TestAgentInteraction:  | 
                  
| 426 | 416 | 
                        exc_type: type[Exception],  | 
                    
| 427 | 417 | 
                        exc_pattern: str,  | 
                    
| 428 | 418 | 
                        ) -> None:  | 
                    
| 429 | 
                        - client = ssh_agent_client.SSHAgentClient()  | 
                    |
| 419 | 
                        + client = ssh_agent.SSHAgentClient()  | 
                    |
| 430 | 420 | 
                        monkeypatch.setattr(client, 'request', lambda a, b: response) # noqa: ARG005  | 
                    
| 431 | 
                        - KeyCommentPair = ssh_agent_client.types.KeyCommentPair # noqa: N806  | 
                    |
| 421 | 
                        + KeyCommentPair = ssh_agent.types.KeyCommentPair # noqa: N806  | 
                    |
| 432 | 422 | 
                        loaded_keys = [  | 
                    
| 433 | 423 | 
                        KeyCommentPair(v['public_key_data'], b'no comment')  | 
                    
| 434 | 424 | 
                        for v in tests.SUPPORTED_KEYS.values()  |