Rename and regroup all test files and test cases
Marco Ricci

Marco Ricci commited on 2024-06-30 16:27:37
Zeige 8 geänderte Dateien mit 1293 Einfügungen und 1152 Löschungen.


Adhere to standard testing best practices: name tests after the module
they test (and move all such tests into the same testing module), group
related tests within a module into classes, and move common testing
functionality and test data into separate modules.  Grouping related
tests into classes also means that tests with common prerequisites (such
as an available SSH agent) can then be skipped in bulk, if necessary.
... ...
@@ -1,3 +1,389 @@
1 1
 # SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2 2
 #
3 3
 # SPDX-License-Identifier: MIT
4
+
5
+from __future__ import annotations
6
+
7
+import base64
8
+from collections.abc import Iterator, Mapping
9
+import contextlib
10
+import json
11
+import os
12
+from typing import Any, TYPE_CHECKING, TypedDict
13
+
14
+import derivepassphrase
15
+import derivepassphrase.cli
16
+import derivepassphrase.types
17
+import ssh_agent_client
18
+import ssh_agent_client.types
19
+import pytest
20
+
21
+__all__ = ()
22
+
23
+if TYPE_CHECKING:
24
+    class SSHTestKey(TypedDict):
25
+        private_key: bytes
26
+        public_key: bytes | str
27
+        public_key_data: bytes
28
+        expected_signature: bytes | None
29
+        derived_passphrase: bytes | str | None
30
+
31
+SUPPORTED_KEYS: Mapping[str, SSHTestKey] = {
32
+    'ed25519': {
33
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
34
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
35
+QyNTUxOQAAACCBeIFoJtYCSF8P/zJIb+TBMIncHGpFBgnpCQ/7whJpdgAAAKDweO7H8Hju
36
+xwAAAAtzc2gtZWQyNTUxOQAAACCBeIFoJtYCSF8P/zJIb+TBMIncHGpFBgnpCQ/7whJpdg
37
+AAAEAbM/A869nkWZbe2tp3Dm/L6gitvmpH/aRZt8sBII3ExYF4gWgm1gJIXw//Mkhv5MEw
38
+idwcakUGCekJD/vCEml2AAAAG3Rlc3Qga2V5IHdpdGhvdXQgcGFzc3BocmFzZQEC
39
+-----END OPENSSH PRIVATE KEY-----
40
+''',
41
+        'public_key': rb'''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIF4gWgm1gJIXw//Mkhv5MEwidwcakUGCekJD/vCEml2 test key without passphrase
42
+''',
43
+        'public_key_data': bytes.fromhex('''
44
+            00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
45
+            00 00 00 20
46
+            81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1
47
+            30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76
48
+'''),
49
+        'expected_signature': bytes.fromhex('''
50
+            00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
51
+            00 00 00 40
52
+            f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86
53
+            66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd
54
+            0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c
55
+            1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02
56
+        '''),
57
+        'derived_passphrase': rb'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg==',
58
+    },
59
+    # Currently only supported by PuTTY (which is deficient in other
60
+    # niceties of the SSH agent and the agent's client).
61
+    'ed448': {
62
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
63
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAASgAAAAlz
64
+c2gtZWQ0NDgAAAA54vZy009Wu8wExjvEb3hqtLz1GO/+d5vmGUbErWQ4AUO9mYLT
65
+zHJHc2m4s+yWzP29Cc3EcxizLG8AAAAA8BdhfCcXYXwnAAAACXNzaC1lZDQ0OAAA
66
+ADni9nLTT1a7zATGO8RveGq0vPUY7/53m+YZRsStZDgBQ72ZgtPMckdzabiz7JbM
67
+/b0JzcRzGLMsbwAAAAByM7GIMRvWJB3YD6SIpAF2uudX4ozZe0X917wPwiBrs373
68
+9TM1n94Nib6hrxGNmCk2iBQDe2KALPgA4vZy009Wu8wExjvEb3hqtLz1GO/+d5vm
69
+GUbErWQ4AUO9mYLTzHJHc2m4s+yWzP29Cc3EcxizLG8AAAAAG3Rlc3Qga2V5IHdp
70
+dGhvdXQgcGFzc3BocmFzZQECAwQFBgcICQ==
71
+-----END OPENSSH PRIVATE KEY-----
72
+''',
73
+        'public_key': rb'''ssh-ed448 AAAACXNzaC1lZDQ0OAAAADni9nLTT1a7zATGO8RveGq0vPUY7/53m+YZRsStZDgBQ72ZgtPMckdzabiz7JbM/b0JzcRzGLMsbwA= test key without passphrase
74
+''',
75
+        'public_key_data': bytes.fromhex('''
76
+            00 00 00 09 73 73 68 2d 65 64 34 34 38
77
+            00 00 00 39
78
+            e2 f6 72 d3 4f 56 bb cc 04 c6 3b c4 6f 78 6a b4
79
+            bc f5 18 ef fe 77 9b e6 19 46 c4 ad 64 38 01 43
80
+            bd 99 82 d3 cc 72 47 73 69 b8 b3 ec 96 cc fd bd
81
+            09 cd c4 73 18 b3 2c 6f 00
82
+        '''),
83
+
84
+        'expected_signature': bytes.fromhex('''
85
+            00 00 00 09 73 73 68 2d 65 64 34 34 38
86
+            00 00 00 72 06 86
87
+            f4 64 a4 a6 ba d9 c3 22 c4 93 49 99 fc 11 de 67
88
+            97 08 f2 d8 b7 3c 2c 13 e7 c5 1c 1e 92 a6 0e d8
89
+            2f 6d 81 03 82 00 e3 72 e4 32 6d 72 d2 6d 32 84
90
+            3f cc a9 1e 57 2c 00 9a b3 99 de 45 da ce 2e d1
91
+            db e5 89 f3 35 be 24 58 90 c6 ca 04 f0 db 88 80
92
+            db bd 77 7c 80 20 7f 3a 48 61 f6 1f ae a9 5e 53
93
+            7b e0 9d 93 1e ea dc eb b5 cd 56 4c ea 8f 08 00
94
+        '''),
95
+        'derived_passphrase': rb'Bob0ZKSmutnDIsSTSZn8Ed5nlwjy2Lc8LBPnxRwekqYO2C9tgQOCAONy5DJtctJtMoQ/zKkeVywAmrOZ3kXazi7R2+WJ8zW+JFiQxsoE8NuIgNu9d3yAIH86SGH2H66pXlN74J2THurc67XNVkzqjwgA',
96
+    },
97
+    'rsa': {
98
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
99
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
100
+NhAAAAAwEAAQAAAYEAsaHu6Xs4cVsuDSNJlMCqoPVgmDgEviI8TfXmHKqX3JkIqI3LsvV7
101
+Ijf8WCdTveEq7CkuZhImtsR52AOEVAoU8mDXDNr+nJ5wUPzf1UIaRjDe0lcXW4SlF01hQs
102
+G4wYDuqxshwelraB/L3e0zhD7fjYHF8IbFsqGlFHWEwOtlfhhfbxJsTGguLm4A8/gdEJD5
103
+2rkqDcZpIXCHtJbCzW9aQpWcs/PDw5ylwl/3dB7jfxyfrGz4O3QrzsqhWEsip97mOmwl6q
104
+CHbq8V8x9zu89D/H+bG5ijqxhijbjcVUW3lZfw/97gy9J6rG31HNar5H8GycLTFwuCFepD
105
+mTEpNgQLKoe8ePIEPq4WHhFUovBdwlrOByUKKqxreyvWt5gkpTARz+9Lt8OjBO3rpqK8sZ
106
+VKH3sE3de2RJM3V9PJdmZSs2b8EFK3PsUGdlMPM9pn1uk4uIItKWBmooOynuD8Ll6aPwuW
107
+AFn3l8nLLyWdrmmEYzHWXiRjQJxy1Bi5AbHMOWiPAAAFkDPkuBkz5LgZAAAAB3NzaC1yc2
108
+EAAAGBALGh7ul7OHFbLg0jSZTAqqD1YJg4BL4iPE315hyql9yZCKiNy7L1eyI3/FgnU73h
109
+KuwpLmYSJrbEedgDhFQKFPJg1wza/pyecFD839VCGkYw3tJXF1uEpRdNYULBuMGA7qsbIc
110
+Hpa2gfy93tM4Q+342BxfCGxbKhpRR1hMDrZX4YX28SbExoLi5uAPP4HRCQ+dq5Kg3GaSFw
111
+h7SWws1vWkKVnLPzw8OcpcJf93Qe438cn6xs+Dt0K87KoVhLIqfe5jpsJeqgh26vFfMfc7
112
+vPQ/x/mxuYo6sYYo243FVFt5WX8P/e4MvSeqxt9RzWq+R/BsnC0xcLghXqQ5kxKTYECyqH
113
+vHjyBD6uFh4RVKLwXcJazgclCiqsa3sr1reYJKUwEc/vS7fDowTt66aivLGVSh97BN3Xtk
114
+STN1fTyXZmUrNm/BBStz7FBnZTDzPaZ9bpOLiCLSlgZqKDsp7g/C5emj8LlgBZ95fJyy8l
115
+na5phGMx1l4kY0CcctQYuQGxzDlojwAAAAMBAAEAAAF/cNVYT+Om4x9+SItcz5bOByGIOj
116
+yWUH8f9rRjnr5ILuwabIDgvFaVG+xM1O1hWADqzMnSEcknHRkTYEsqYPykAtxFvjOFEh70
117
+6qRUJ+fVZkqRGEaI3oWyWKTOhcCIYImtONvb0LOv/HQ2H2AXCoeqjST1qr/xSuljBtcB8u
118
+wxs3EqaO1yU7QoZpDcMX9plH7Rmc9nNfZcgrnktPk2deX2+Y/A5tzdVgG1IeqYp6CBMLNM
119
+uhL0OPdDehgBoDujx+rhkZ1gpo1wcULIM94NL7VSHBPX0Lgh9T+3j1HVP+YnMAvhfOvfct
120
+LlbJ06+TYGRAMuF2LPCAZM/m0FEyAurRgWxAjLXm+4kp2GAJXlw82deDkQ+P8cHNT6s9ZH
121
+R5YSy3lpZ35594ZMOLR8KqVvhgJGF6i9019BiF91SDxjE+sp6dNGfN8W+64tHdDv2a0Mso
122
++8Qjyx7sTpi++EjLU8Iy73/e4B8qbXMyheyA/UUfgMtNKShh6sLlrD9h2Sm9RFTuEAAADA
123
+Jh3u7WfnjhhKZYbAW4TsPNXDMrB0/t7xyAQgFmko7JfESyrJSLg1cO+QMOiDgD7zuQ9RSp
124
+NIKdPsnIna5peh979mVjb2HgnikjyJECmBpLdwZKhX7MnIvgKw5lnQXHboEtWCa1N58l7f
125
+srzwbi9pFUuUp9dShXNffmlUCjDRsVLbK5C6+iaIQyCWFYK8mc6dpNkIoPKf+Xg+EJCIFQ
126
+oITqeu30Gc1+M+fdZc2ghq0b6XLthh/uHEry8b68M5KglMAAAAwQDw1i+IdcvPV/3u/q9O
127
+/kzLpKO3tbT89sc1zhjZsDNjDAGluNr6n38iq/XYRZu7UTL9BG+EgFVfIUV7XsYT5e+BPf
128
+13VS94rzZ7maCsOlULX+VdMO2zBucHIoec9RUlRZrfB21B2W7YGMhbpoa5lN3lKJQ7afHo
129
+dXZUMp0cTFbOmbzJgSzO2/NE7BhVwmvcUzTDJGMMKuxBO6w99YKDKRKm0PNLFDz26rWm9L
130
+dNS2MVfVuPMTpzT26HQG4pFageq9cAAADBALzRBXdZF8kbSBa5MTUBVTTzgKQm1C772gJ8
131
+T01DJEXZsVtOv7mUC1/m/by6Hk4tPyvDBuGj9hHq4N7dPqGutHb1q5n0ADuoQjRW7BXw5Q
132
+vC2EAD91xexdorIA5BgXU+qltBqzzBVzVtF7+jOZOjfzOlaTX9I5I5veyeTaTxZj1XXUzi
133
+btBNdMEJJp7ifucYmoYAAwE7K+VlWagDEK2y8Mte9y9E+N0uO2j+h85sQt/UIb2iE/vhcg
134
+Bgp6142WnSCQAAABt0ZXN0IGtleSB3aXRob3V0IHBhc3NwaHJhc2UB
135
+-----END OPENSSH PRIVATE KEY-----
136
+''',
137
+        'public_key': rb'''ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCxoe7pezhxWy4NI0mUwKqg9WCYOAS+IjxN9eYcqpfcmQiojcuy9XsiN/xYJ1O94SrsKS5mEia2xHnYA4RUChTyYNcM2v6cnnBQ/N/VQhpGMN7SVxdbhKUXTWFCwbjBgO6rGyHB6WtoH8vd7TOEPt+NgcXwhsWyoaUUdYTA62V+GF9vEmxMaC4ubgDz+B0QkPnauSoNxmkhcIe0lsLNb1pClZyz88PDnKXCX/d0HuN/HJ+sbPg7dCvOyqFYSyKn3uY6bCXqoIdurxXzH3O7z0P8f5sbmKOrGGKNuNxVRbeVl/D/3uDL0nqsbfUc1qvkfwbJwtMXC4IV6kOZMSk2BAsqh7x48gQ+rhYeEVSi8F3CWs4HJQoqrGt7K9a3mCSlMBHP70u3w6ME7eumoryxlUofewTd17ZEkzdX08l2ZlKzZvwQUrc+xQZ2Uw8z2mfW6Ti4gi0pYGaig7Ke4PwuXpo/C5YAWfeXycsvJZ2uaYRjMdZeJGNAnHLUGLkBscw5aI8= test key without passphrase
138
+''',
139
+        'public_key_data': bytes.fromhex('''
140
+            00 00 00 07 73 73 68 2d 72 73 61
141
+            00 00 00 03 01 00 01
142
+            00 00 01 81 00
143
+            b1 a1 ee e9 7b 38 71 5b 2e 0d 23 49 94 c0 aa a0
144
+            f5 60 98 38 04 be 22 3c 4d f5 e6 1c aa 97 dc 99
145
+            08 a8 8d cb b2 f5 7b 22 37 fc 58 27 53 bd e1 2a
146
+            ec 29 2e 66 12 26 b6 c4 79 d8 03 84 54 0a 14 f2
147
+            60 d7 0c da fe 9c 9e 70 50 fc df d5 42 1a 46 30
148
+            de d2 57 17 5b 84 a5 17 4d 61 42 c1 b8 c1 80 ee
149
+            ab 1b 21 c1 e9 6b 68 1f cb dd ed 33 84 3e df 8d
150
+            81 c5 f0 86 c5 b2 a1 a5 14 75 84 c0 eb 65 7e 18
151
+            5f 6f 12 6c 4c 68 2e 2e 6e 00 f3 f8 1d 10 90 f9
152
+            da b9 2a 0d c6 69 21 70 87 b4 96 c2 cd 6f 5a 42
153
+            95 9c b3 f3 c3 c3 9c a5 c2 5f f7 74 1e e3 7f 1c
154
+            9f ac 6c f8 3b 74 2b ce ca a1 58 4b 22 a7 de e6
155
+            3a 6c 25 ea a0 87 6e af 15 f3 1f 73 bb cf 43 fc
156
+            7f 9b 1b 98 a3 ab 18 62 8d b8 dc 55 45 b7 95 97
157
+            f0 ff de e0 cb d2 7a ac 6d f5 1c d6 ab e4 7f 06
158
+            c9 c2 d3 17 0b 82 15 ea 43 99 31 29 36 04 0b 2a
159
+            87 bc 78 f2 04 3e ae 16 1e 11 54 a2 f0 5d c2 5a
160
+            ce 07 25 0a 2a ac 6b 7b 2b d6 b7 98 24 a5 30 11
161
+            cf ef 4b b7 c3 a3 04 ed eb a6 a2 bc b1 95 4a 1f
162
+            7b 04 dd d7 b6 44 93 37 57 d3 c9 76 66 52 b3 66
163
+            fc 10 52 b7 3e c5 06 76 53 0f 33 da 67 d6 e9 38
164
+            b8 82 2d 29 60 66 a2 83 b2 9e e0 fc 2e 5e 9a 3f
165
+            0b 96 00 59 f7 97 c9 cb 2f 25 9d ae 69 84 63 31
166
+            d6 5e 24 63 40 9c 72 d4 18 b9 01 b1 cc 39 68 8f
167
+'''),
168
+        'expected_signature': bytes.fromhex('''
169
+            00 00 00 07 73 73 68 2d 72 73 61
170
+            00 00 01 80
171
+            a2 10 7c 2e f6 bb 53 a8 74 2a a1 19 99 ad 81 be
172
+            79 9c ed d6 9d 09 4e 6e c5 18 48 33 90 77 99 68
173
+            f7 9e 03 5a cd 4e 18 eb 89 7d 85 a2 ee ae 4a 92
174
+            f6 6f ce b9 fe 86 7f 2a 6b 31 da 6e 1a fe a2 a5
175
+            88 b8 44 7f a1 76 73 b3 ec 75 b5 d0 a6 b9 15 97
176
+            65 09 13 7d 94 21 d1 fb 5d 0f 8b 23 04 77 c2 c3
177
+            55 22 b1 a0 09 8a f5 38 2a d6 7f 1b 87 29 a0 25
178
+            d3 25 6f cb 64 61 07 98 dc 14 c5 84 f8 92 24 5e
179
+            50 11 6b 49 e5 f0 cc 29 cb 29 a9 19 d8 a7 71 1f
180
+            91 0b 05 b1 01 4b c2 5f 00 a5 b6 21 bf f8 2c 9d
181
+            67 9b 47 3b 0a 49 6b 79 2d fc 1d ec 0c b0 e5 27
182
+            22 d5 a9 f8 d3 c3 f9 df 48 68 e9 fb ef 3c dc 26
183
+            bf cf ea 29 43 01 a6 e3 c5 51 95 f4 66 6d 8a 55
184
+            e2 47 ec e8 30 45 4c ae 47 e7 c9 a4 21 8b 64 ba
185
+            b6 88 f6 21 f8 73 b9 cb 11 a1 78 75 92 c6 5a e5
186
+            64 fe ed 42 d9 95 99 e6 2b 6f 3c 16 3c 28 74 a4
187
+            72 2f 0d 3f 2c 33 67 aa 35 19 8e e7 b5 11 2f b3
188
+            f7 6a c5 02 e2 6f a3 42 e3 62 19 99 03 ea a5 20
189
+            e7 a1 e3 bc c8 06 a3 b5 7c d6 76 5d df 6f 60 46
190
+            83 2a 08 00 d6 d3 d9 a4 c1 41 8c f8 60 56 45 81
191
+            da 3b a2 16 1f 9e 4e 75 83 17 da c3 53 c3 3e 19
192
+            a4 1b bc d2 29 b8 78 61 2b 78 e6 b1 52 b0 d5 ec
193
+            de 69 2c 48 62 d9 fd d1 9b 6b b0 49 db d3 ff 38
194
+            e7 10 d9 2d ce 9f 0d 5e 09 7b 37 d2 7b c3 bf ce
195
+'''),
196
+        'derived_passphrase': rb'ohB8Lva7U6h0KqEZma2Bvnmc7dadCU5uxRhIM5B3mWj3ngNazU4Y64l9haLurkqS9m/Ouf6GfyprMdpuGv6ipYi4RH+hdnOz7HW10Ka5FZdlCRN9lCHR+10PiyMEd8LDVSKxoAmK9Tgq1n8bhymgJdMlb8tkYQeY3BTFhPiSJF5QEWtJ5fDMKcspqRnYp3EfkQsFsQFLwl8ApbYhv/gsnWebRzsKSWt5Lfwd7Ayw5Sci1an408P530ho6fvvPNwmv8/qKUMBpuPFUZX0Zm2KVeJH7OgwRUyuR+fJpCGLZLq2iPYh+HO5yxGheHWSxlrlZP7tQtmVmeYrbzwWPCh0pHIvDT8sM2eqNRmO57URL7P3asUC4m+jQuNiGZkD6qUg56HjvMgGo7V81nZd329gRoMqCADW09mkwUGM+GBWRYHaO6IWH55OdYMX2sNTwz4ZpBu80im4eGEreOaxUrDV7N5pLEhi2f3Rm2uwSdvT/zjnENktzp8NXgl7N9J7w7/O',
197
+    },
198
+}
199
+
200
+UNSUITABLE_KEYS: Mapping[str, SSHTestKey] = {
201
+    'dsa1024': {
202
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
203
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH
204
+NzAAAAgQC7KAZXqBGNVLBQPrcMYAoNW54BhD8aIhe7BDWYzJcsaMt72VKSkguZ8+XR7nRa
205
+0C/ZsBi+uJp0dpxy9ZMTOWX4u5YPMeQcXEdGExZIfimGqSOAsy6fCld2IfJZJZExcCmhe9
206
+Ssjsd3YSAPJRluOXFQc95MZoR5hMwlIDD8QzrE7QAAABUA99nOZOgd7aHMVGoXpUEBcn7H
207
+ossAAACALr2Ag3hxM3rKdxzVUw8fX0VVPXO+3+Kr8hGe0Kc/7NwVaBVL1GQ8fenBuWynpA
208
+UbH0wo3h1wkB/8hX6p+S8cnu5rIBlUuVNwLw/bIYohK98LfqTYK/V+g6KD+8m34wvEiXZm
209
+qywY54n2bksch1Nqvj/tNpLzExSx/XS0kSM1aigAAACAbQNRPcVEuGDrEcf+xg5tgAejPX
210
+BPXr/Jss+Chk64km3mirMYjAWyWYtVcgT+7hOYxtYRin8LyMLqKRmqa0Q5UrvDfChgLhvs
211
+G9YSb/Mpw5qm8PiHSafwhkaz/te3+8hKogqoe7sd+tCF06IpJr5k70ACiNtRGqssNF8Elr
212
+l1efYAAAH4swlfVrMJX1YAAAAHc3NoLWRzcwAAAIEAuygGV6gRjVSwUD63DGAKDVueAYQ/
213
+GiIXuwQ1mMyXLGjLe9lSkpILmfPl0e50WtAv2bAYvriadHaccvWTEzll+LuWDzHkHFxHRh
214
+MWSH4phqkjgLMunwpXdiHyWSWRMXApoXvUrI7Hd2EgDyUZbjlxUHPeTGaEeYTMJSAw/EM6
215
+xO0AAAAVAPfZzmToHe2hzFRqF6VBAXJ+x6LLAAAAgC69gIN4cTN6yncc1VMPH19FVT1zvt
216
+/iq/IRntCnP+zcFWgVS9RkPH3pwblsp6QFGx9MKN4dcJAf/IV+qfkvHJ7uayAZVLlTcC8P
217
+2yGKISvfC36k2Cv1foOig/vJt+MLxIl2ZqssGOeJ9m5LHIdTar4/7TaS8xMUsf10tJEjNW
218
+ooAAAAgG0DUT3FRLhg6xHH/sYObYAHoz1wT16/ybLPgoZOuJJt5oqzGIwFslmLVXIE/u4T
219
+mMbWEYp/C8jC6ikZqmtEOVK7w3woYC4b7BvWEm/zKcOapvD4h0mn8IZGs/7Xt/vISqIKqH
220
+u7HfrQhdOiKSa+ZO9AAojbURqrLDRfBJa5dXn2AAAAFQDJHfenj4EJ9WkehpdJatPBlqCW
221
+0gAAABt0ZXN0IGtleSB3aXRob3V0IHBhc3NwaHJhc2UBAgMEBQYH
222
+-----END OPENSSH PRIVATE KEY-----
223
+''',
224
+        'public_key': rb'''ssh-dss AAAAB3NzaC1kc3MAAACBALsoBleoEY1UsFA+twxgCg1bngGEPxoiF7sENZjMlyxoy3vZUpKSC5nz5dHudFrQL9mwGL64mnR2nHL1kxM5Zfi7lg8x5BxcR0YTFkh+KYapI4CzLp8KV3Yh8lklkTFwKaF71KyOx3dhIA8lGW45cVBz3kxmhHmEzCUgMPxDOsTtAAAAFQD32c5k6B3tocxUahelQQFyfseiywAAAIAuvYCDeHEzesp3HNVTDx9fRVU9c77f4qvyEZ7Qpz/s3BVoFUvUZDx96cG5bKekBRsfTCjeHXCQH/yFfqn5Lxye7msgGVS5U3AvD9shiiEr3wt+pNgr9X6DooP7ybfjC8SJdmarLBjnifZuSxyHU2q+P+02kvMTFLH9dLSRIzVqKAAAAIBtA1E9xUS4YOsRx/7GDm2AB6M9cE9ev8myz4KGTriSbeaKsxiMBbJZi1VyBP7uE5jG1hGKfwvIwuopGaprRDlSu8N8KGAuG+wb1hJv8ynDmqbw+IdJp/CGRrP+17f7yEqiCqh7ux360IXToikmvmTvQAKI21Eaqyw0XwSWuXV59g== test key without passphrase
225
+''',
226
+        'public_key_data': bytes.fromhex('''
227
+            00 00 00 07 73 73 68 2d 64 73 73
228
+            00 00 00 81 00
229
+            bb 28 06 57 a8 11 8d 54 b0 50 3e b7 0c 60 0a 0d
230
+            5b 9e 01 84 3f 1a 22 17 bb 04 35 98 cc 97 2c 68
231
+            cb 7b d9 52 92 92 0b 99 f3 e5 d1 ee 74 5a d0 2f
232
+            d9 b0 18 be b8 9a 74 76 9c 72 f5 93 13 39 65 f8
233
+            bb 96 0f 31 e4 1c 5c 47 46 13 16 48 7e 29 86 a9
234
+            23 80 b3 2e 9f 0a 57 76 21 f2 59 25 91 31 70 29
235
+            a1 7b d4 ac 8e c7 77 61 20 0f 25 19 6e 39 71 50
236
+            73 de 4c 66 84 79 84 cc 25 20 30 fc 43 3a c4 ed
237
+            00 00 00 15 00 f7 d9 ce 64
238
+            e8 1d ed a1 cc 54 6a 17 a5 41 01 72 7e c7 a2 cb
239
+            00 00 00 80
240
+            2e bd 80 83 78 71 33 7a ca 77 1c d5 53 0f 1f 5f
241
+            45 55 3d 73 be df e2 ab f2 11 9e d0 a7 3f ec dc
242
+            15 68 15 4b d4 64 3c 7d e9 c1 b9 6c a7 a4 05 1b
243
+            1f 4c 28 de 1d 70 90 1f fc 85 7e a9 f9 2f 1c 9e
244
+            ee 6b 20 19 54 b9 53 70 2f 0f db 21 8a 21 2b df
245
+            0b 7e a4 d8 2b f5 7e 83 a2 83 fb c9 b7 e3 0b c4
246
+            89 76 66 ab 2c 18 e7 89 f6 6e 4b 1c 87 53 6a be
247
+            3f ed 36 92 f3 13 14 b1 fd 74 b4 91 23 35 6a 28
248
+            00 00 00 80
249
+            6d 03 51 3d c5 44 b8 60 eb 11 c7 fe c6 0e 6d 80
250
+            07 a3 3d 70 4f 5e bf c9 b2 cf 82 86 4e b8 92 6d
251
+            e6 8a b3 18 8c 05 b2 59 8b 55 72 04 fe ee 13 98
252
+            c6 d6 11 8a 7f 0b c8 c2 ea 29 19 aa 6b 44 39 52
253
+            bb c3 7c 28 60 2e 1b ec 1b d6 12 6f f3 29 c3 9a
254
+            a6 f0 f8 87 49 a7 f0 86 46 b3 fe d7 b7 fb c8 4a
255
+            a2 0a a8 7b bb 1d fa d0 85 d3 a2 29 26 be 64 ef
256
+            40 02 88 db 51 1a ab 2c 34 5f 04 96 b9 75 79 f6
257
+'''),
258
+        'expected_signature': None,
259
+        'derived_passphrase': None,
260
+    },
261
+    'ecdsa256': {
262
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
263
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
264
+1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTLbU0zDwsk2Dvp+VYIrsNVf5gWwz2S
265
+3SZ8TbxiQRkpnGSVqyIoHJOJc+NQItAa7xlJ/8Z6gfz57Z3apUkaMJm6AAAAuKeY+YinmP
266
+mIAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMttTTMPCyTYO+n5
267
+Vgiuw1V/mBbDPZLdJnxNvGJBGSmcZJWrIigck4lz41Ai0BrvGUn/xnqB/PntndqlSRowmb
268
+oAAAAhAKIl/3n0pKVIxpZkXTGtii782Qr4yIcvHdpxjO/QsIqKAAAAG3Rlc3Qga2V5IHdp
269
+dGhvdXQgcGFzc3BocmFzZQECAwQ=
270
+-----END OPENSSH PRIVATE KEY-----
271
+''',
272
+        'public_key': rb'''ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMttTTMPCyTYO+n5Vgiuw1V/mBbDPZLdJnxNvGJBGSmcZJWrIigck4lz41Ai0BrvGUn/xnqB/PntndqlSRowmbo= test key without passphrase
273
+''',
274
+        'public_key_data': bytes.fromhex('''
275
+            00 00 00 13 65 63 64 73 61 2d 73 68 61 32 2d 6e
276
+            69 73 74 70 32 35 36
277
+            00 00 00 08 6e 69 73 74 70 32 35 36
278
+            00 00 00 41 04
279
+            cb 6d 4d 33 0f 0b 24 d8 3b e9 f9 56 08 ae c3 55
280
+            7f 98 16 c3 3d 92 dd 26 7c 4d bc 62 41 19 29 9c
281
+            64 95 ab 22 28 1c 93 89 73 e3 50 22 d0 1a ef 19
282
+            49 ff c6 7a 81 fc f9 ed 9d da a5 49 1a 30 99 ba
283
+'''),
284
+        'expected_signature': None,
285
+        'derived_passphrase': None,
286
+    },
287
+    'ecdsa384': {
288
+        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
289
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS
290
+1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQSgkOjkAvq7v5vHuj3KBL4/EAWcn5hZ
291
+DyKcbyV0eBMGFq7hKXQlZqIahLVqeMR0QqmkxNJ2rly2VHcXneq3vZ+9fIsWCOdYk5WP3N
292
+ZPzv911Xn7wbEkC7QndD5zKlm4pBUAAADomhj+IZoY/iEAAAATZWNkc2Etc2hhMi1uaXN0
293
+cDM4NAAAAAhuaXN0cDM4NAAAAGEEoJDo5AL6u7+bx7o9ygS+PxAFnJ+YWQ8inG8ldHgTBh
294
+au4Sl0JWaiGoS1anjEdEKppMTSdq5ctlR3F53qt72fvXyLFgjnWJOVj9zWT87/ddV5+8Gx
295
+JAu0J3Q+cypZuKQVAAAAMQD5sTy8p+B1cn/DhOmXquui1BcxvASqzzevkBlbQoBa73y04B
296
+2OdqVOVRkwZWRROz0AAAAbdGVzdCBrZXkgd2l0aG91dCBwYXNzcGhyYXNlAQIDBA==
297
+-----END OPENSSH PRIVATE KEY-----
298
+''',
299
+        'public_key': rb'''ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBKCQ6OQC+ru/m8e6PcoEvj8QBZyfmFkPIpxvJXR4EwYWruEpdCVmohqEtWp4xHRCqaTE0nauXLZUdxed6re9n718ixYI51iTlY/c1k/O/3XVefvBsSQLtCd0PnMqWbikFQ== test key without passphrase
300
+''',
301
+        'public_key_data': bytes.fromhex('''
302
+            00 00 00 13
303
+            65 63 64 73 61 2d 73 68 61 32 2d 6e 69 73 74 70
304
+            33 38 34
305
+            00 00 00 08 6e 69 73 74 70 33 38 34
306
+            00 00 00 61 04
307
+            a0 90 e8 e4 02 fa bb bf 9b c7 ba 3d ca 04 be 3f
308
+            10 05 9c 9f 98 59 0f 22 9c 6f 25 74 78 13 06 16
309
+            ae e1 29 74 25 66 a2 1a 84 b5 6a 78 c4 74 42 a9
310
+            a4 c4 d2 76 ae 5c b6 54 77 17 9d ea b7 bd 9f bd
311
+            7c 8b 16 08 e7 58 93 95 8f dc d6 4f ce ff 75 d5
312
+            79 fb c1 b1 24 0b b4 27 74 3e 73 2a 59 b8 a4 15
313
+'''),
314
+        'expected_signature': None,
315
+        'derived_passphrase': None,
316
+    },
317
+}
318
+
319
+DUMMY_SERVICE = 'service1'
320
+DUMMY_PASSPHRASE = b'my secret passphrase\n'
321
+DUMMY_KEY1 = SUPPORTED_KEYS['ed25519']['public_key_data']
322
+DUMMY_KEY1_B64 = base64.standard_b64encode(DUMMY_KEY1).decode('ASCII')
323
+DUMMY_KEY2 = SUPPORTED_KEYS['rsa']['public_key_data']
324
+DUMMY_KEY2_B64 = base64.standard_b64encode(DUMMY_KEY2).decode('ASCII')
325
+DUMMY_CONFIG_SETTINGS = {"length": 10, "upper": 1, "lower": 1, "repeat": 5,
326
+                         "number": 1, "space": 1, "dash": 1, "symbol": 1}
327
+DUMMY_RESULT_PASSPHRASE = b'.2V_QJkd o'
328
+DUMMY_RESULT_KEY1 = b'E<b<{ -7iG'
329
+DUMMY_PHRASE_FROM_KEY1_RAW = (
330
+    b'\x00\x00\x00\x0bssh-ed25519'
331
+    b'\x00\x00\x00@\xf0\x98\x19\x80l\x1a\x97\xd5&\x03n'
332
+    b'\xcc\xe3e\x8f\x86f\x07\x13\x19\x13\t!33\xf9\xe46S'
333
+    b'\x1d\xaf\xfd\r\x08\x1f\xec\xf8s\x9b\x8c_U9\x16|ST,'
334
+    b'\x1eR\xbb0\xed\x7f\x89\xe2/iQU\xd8\x9e\xa6\x02'
335
+)
336
+DUMMY_PHRASE_FROM_KEY1 = b'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg=='
337
+
338
+skip_if_no_agent = pytest.mark.skipif(
339
+    not os.environ.get('SSH_AUTH_SOCK'), reason='running SSH agent required')
340
+
341
+def list_keys(
342
+    self: Any = None,
343
+) -> list[ssh_agent_client.types.KeyCommentPair]:
344
+    Pair = ssh_agent_client.types.KeyCommentPair
345
+    list1 = [Pair(value['public_key_data'], f'{key} test key'.encode('ASCII'))
346
+             for key, value in SUPPORTED_KEYS.items()]
347
+    list2 = [Pair(value['public_key_data'], f'{key} test key'.encode('ASCII'))
348
+             for key, value in UNSUITABLE_KEYS.items()]
349
+    return list1 + list2
350
+
351
+def list_keys_singleton(
352
+    self: Any = None,
353
+) -> list[ssh_agent_client.types.KeyCommentPair]:
354
+    Pair = ssh_agent_client.types.KeyCommentPair
355
+    list1 = [Pair(value['public_key_data'], f'{key} test key'.encode('ASCII'))
356
+             for key, value in SUPPORTED_KEYS.items()]
357
+    return list1[:1]
358
+
359
+def suitable_ssh_keys(
360
+    conn: Any
361
+) -> Iterator[ssh_agent_client.types.KeyCommentPair]:
362
+    yield from [
363
+        ssh_agent_client.types.KeyCommentPair(DUMMY_KEY1, b'no comment'),
364
+        ssh_agent_client.types.KeyCommentPair(DUMMY_KEY2, b'a comment'),
365
+    ]
366
+
367
+def phrase_from_key(key: bytes) -> bytes:
368
+    if key == DUMMY_KEY1:  # pragma: no branch
369
+        return DUMMY_PHRASE_FROM_KEY1
370
+    raise KeyError(key)  # pragma: no cover
371
+
372
+@contextlib.contextmanager
373
+def isolated_config(
374
+    monkeypatch: Any, runner: click.testing.CliRunner, config: Any,
375
+):
376
+    prog_name = derivepassphrase.cli.prog_name
377
+    env_name = prog_name.replace(' ', '_').upper() + '_PATH'
378
+    with runner.isolated_filesystem():
379
+        monkeypatch.setenv('HOME', os.getcwd())
380
+        monkeypatch.setenv('USERPROFILE', os.getcwd())
381
+        monkeypatch.delenv(env_name, raising=False)
382
+        os.makedirs(os.path.dirname(derivepassphrase.cli._config_filename()),
383
+                    exist_ok=True)
384
+        with open(derivepassphrase.cli._config_filename(), 'wt') as outfile:
385
+            json.dump(config, outfile)
386
+        yield
387
+
388
+def auto_prompt(*args: Any, **kwargs: Any) -> str:
389
+    return DUMMY_PASSPHRASE.decode('UTF-8')
... ...
@@ -0,0 +1,209 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Test passphrase generation via derivepassphrase.Vault."""
6
+
7
+from __future__ import annotations
8
+
9
+import math
10
+from typing import Any
11
+
12
+import derivepassphrase
13
+import sequin
14
+import pytest
15
+import tests
16
+
17
+Vault = derivepassphrase.Vault
18
+
19
+class TestVault:
20
+
21
+    phrase = b'She cells C shells bye the sea shoars'
22
+    google_phrase = rb': 4TVH#5:aZl8LueOT\{'
23
+    twitter_phrase = rb"[ (HN_N:lI&<ro=)3'g9"
24
+
25
+    @pytest.mark.parametrize(['service', 'expected'], [
26
+        (b'google', google_phrase),
27
+        ('twitter', twitter_phrase),
28
+    ])
29
+    def test_200_basic_configuration(self, service, expected):
30
+        assert Vault(phrase=self.phrase).generate(service) == expected
31
+
32
+    def test_201_phrase_dependence(self):
33
+        assert (
34
+            Vault(phrase=(self.phrase + b'X')).generate('google') ==
35
+            b'n+oIz6sL>K*lTEWYRO%7'
36
+        )
37
+
38
+    def test_202_reproducibility_and_bytes_service_name(self):
39
+        assert (
40
+            Vault(phrase=self.phrase).generate(b'google') ==
41
+            Vault(phrase=self.phrase).generate('google')
42
+        )
43
+
44
+    def test_203_reproducibility_and_bytearray_service_name(self):
45
+        assert (
46
+            Vault(phrase=self.phrase).generate(b'google') ==
47
+            Vault(phrase=self.phrase).generate(bytearray(b'google'))
48
+        )
49
+
50
+    def test_210_nonstandard_length(self):
51
+        assert (
52
+            Vault(phrase=self.phrase, length=4).generate('google')
53
+            == b'xDFu'
54
+        )
55
+
56
+    def test_211_repetition_limit(self):
57
+        assert (
58
+            Vault(phrase=b'', length=24, symbol=0, number=0,
59
+                  repeat=1).generate('asd') ==
60
+            b'IVTDzACftqopUXqDHPkuCIhV'
61
+        )
62
+
63
+    def test_212_without_symbols(self):
64
+        assert (
65
+            Vault(phrase=self.phrase, symbol=0).generate('google') ==
66
+            b'XZ4wRe0bZCazbljCaMqR'
67
+        )
68
+
69
+    def test_213_no_numbers(self):
70
+        assert (
71
+            Vault(phrase=self.phrase, number=0).generate('google') ==
72
+            b'_*$TVH.%^aZl(LUeOT?>'
73
+        )
74
+
75
+    def test_214_no_lowercase_letters(self):
76
+        assert (
77
+            Vault(phrase=self.phrase, lower=0).generate('google') ==
78
+            b':{?)+7~@OA:L]!0E$)(+'
79
+        )
80
+
81
+    def test_215_at_least_5_digits(self):
82
+        assert (
83
+            Vault(phrase=self.phrase, length=8, number=5)
84
+            .generate('songkick') == b'i0908.7['
85
+        )
86
+
87
+    def test_216_lots_of_spaces(self):
88
+        assert (
89
+            Vault(phrase=self.phrase, space=12)
90
+            .generate('songkick') == b' c   6 Bq  % 5fR    '
91
+        )
92
+
93
+    def test_217_all_character_classes(self):
94
+        assert (
95
+            Vault(phrase=self.phrase, lower=2, upper=2, number=1,
96
+                  space=3, dash=2, symbol=1)
97
+            .generate('google') == b': : fv_wqt>a-4w1S  R'
98
+        )
99
+
100
+    def test_218_only_numbers_and_very_high_repetition_limit(self):
101
+        generated = Vault(phrase=b'', length=40, lower=0, upper=0, space=0,
102
+                          dash=0, symbol=0, repeat=4).generate('abcdef')
103
+        forbidden_substrings = {b'0000', b'1111', b'2222', b'3333', b'4444',
104
+                                b'5555', b'6666', b'7777', b'8888', b'9999'}
105
+        for substring in forbidden_substrings:
106
+            assert substring not in generated
107
+
108
+    def test_219_very_limited_character_set(self):
109
+        generated = Vault(phrase=b'', length=24, lower=0, upper=0,
110
+                          space=0, symbol=0).generate('testing')
111
+        assert b'763252593304946694588866' == generated
112
+
113
+    def test_220_character_set_subtraction(self):
114
+        assert Vault._subtract(b'be', b'abcdef') == bytearray(b'acdf')
115
+
116
+    @pytest.mark.parametrize(['length', 'settings', 'entropy'], [
117
+        (20, {}, math.log2(math.factorial(20)) + 20 * math.log2(94)),
118
+        (
119
+            20,
120
+            {'upper': 0, 'number': 0, 'space': 0, 'symbol': 0},
121
+            math.log2(math.factorial(20)) + 20 * math.log2(26)
122
+        ),
123
+        (0, {}, float('-inf')),
124
+        (0, {'lower': 0, 'number': 0, 'space': 0, 'symbol': 0}, float('-inf')),
125
+        (1, {}, math.log2(94)),
126
+        (1, {'upper': 0, 'lower': 0, 'number': 0, 'symbol': 0}, 0.0),
127
+    ])
128
+    def test_221_entropy(
129
+        self, length: int, settings: dict[str, int], entropy: int
130
+    ) -> None:
131
+        v = Vault(length=length, **settings)  # type: ignore[arg-type]
132
+        assert math.isclose(v._entropy(), entropy)
133
+        assert v._estimate_sufficient_hash_length() > 0
134
+        if math.isfinite(entropy) and entropy:
135
+            assert (
136
+                v._estimate_sufficient_hash_length(1.0) ==
137
+                math.ceil(entropy / 8)
138
+            )
139
+        assert v._estimate_sufficient_hash_length(8.0) >= entropy
140
+
141
+    def test_222_hash_length_estimation(self) -> None:
142
+        v = Vault(phrase=self.phrase)
143
+        v2 = Vault(phrase=self.phrase, lower=0, upper=0, number=0,
144
+                   symbol=0, space=1, length=1)
145
+        assert v2._entropy() == 0.0
146
+        assert v2._estimate_sufficient_hash_length() > 0
147
+
148
+    @pytest.mark.parametrize(['service', 'expected'], [
149
+        (b'google', google_phrase),
150
+        ('twitter', twitter_phrase),
151
+    ])
152
+    def test_223_hash_length_expansion(
153
+        self, monkeypatch: Any, service: str | bytes, expected: bytes
154
+    ) -> None:
155
+        v = Vault(phrase=self.phrase)
156
+        monkeypatch.setattr(v,
157
+                            '_estimate_sufficient_hash_length',
158
+                            lambda *args, **kwargs: 1)
159
+        assert v._estimate_sufficient_hash_length() < len(self.phrase)
160
+        assert v.generate(service) == expected
161
+
162
+    @pytest.mark.parametrize(['s', 'raises'], [
163
+        ('ñ', True), ('Düsseldorf', True),
164
+        ('liberté, egalité, fraternité', True), ('ASCII', False),
165
+        ('Düsseldorf'.encode('UTF-8'), False),
166
+        (bytearray([2, 3, 5, 7, 11, 13]), False),
167
+    ])
168
+    def test_224_binary_strings(
169
+        self, s: str | bytes | bytearray, raises: bool
170
+    ) -> None:
171
+        binstr = derivepassphrase.Vault._get_binary_string
172
+        AmbiguousByteRepresentationError = (
173
+            derivepassphrase.AmbiguousByteRepresentationError
174
+        )
175
+        if raises:
176
+            with pytest.raises(AmbiguousByteRepresentationError):
177
+                binstr(s)
178
+        elif isinstance(s, str):
179
+            assert binstr(s) == s.encode('UTF-8')
180
+            assert binstr(binstr(s)) == s.encode('UTF-8')
181
+        else:
182
+            assert binstr(s) == bytes(s)
183
+            assert binstr(binstr(s)) == bytes(s)
184
+
185
+    def test_310_too_many_symbols(self):
186
+        with pytest.raises(ValueError,
187
+                           match='requested passphrase length too short'):
188
+            Vault(phrase=self.phrase, symbol=100)
189
+
190
+    def test_311_no_viable_characters(self):
191
+        with pytest.raises(ValueError,
192
+                           match='no allowed characters left'):
193
+            Vault(phrase=self.phrase, lower=0, upper=0, number=0,
194
+                  space=0, dash=0, symbol=0)
195
+
196
+    def test_320_character_set_subtraction_duplicate(self):
197
+        with pytest.raises(ValueError, match='duplicate characters'):
198
+            Vault._subtract(b'abcdef', b'aabbccddeeff')
199
+        with pytest.raises(ValueError, match='duplicate characters'):
200
+            Vault._subtract(b'aabbccddeeff', b'abcdef')
201
+
202
+    def test_322_hash_length_estimation(self) -> None:
203
+        v = Vault(phrase=self.phrase)
204
+        with pytest.raises(ValueError,
205
+                           match='invalid safety factor'):
206
+            assert v._estimate_sufficient_hash_length(-1.0)
207
+        with pytest.raises(TypeError,
208
+                           match='invalid safety factor: not a float'):
209
+            assert v._estimate_sufficient_hash_length(None)  # type: ignore
... ...
@@ -5,10 +5,9 @@
5 5
 from __future__ import annotations
6 6
 
7 7
 import base64
8
-import contextlib
9
-import errno
10 8
 import json
11 9
 import os
10
+import socket
12 11
 from typing import Any, cast, TYPE_CHECKING, NamedTuple
13 12
 
14 13
 import click.testing
... ...
@@ -16,25 +15,19 @@ import derivepassphrase as dpp
16 15
 import derivepassphrase.cli as cli
17 16
 import ssh_agent_client.types
18 17
 import pytest
18
+import tests
19 19
 
20
-DUMMY_SERVICE = 'service1'
21
-DUMMY_PASSPHRASE = b'my secret passphrase\n'
22
-DUMMY_CONFIG_SETTINGS = {"length": 10, "upper": 1, "lower": 1, "repeat": 5,
23
-                         "number": 1, "space": 1, "dash": 1, "symbol": 1}
24
-DUMMY_RESULT_PASSPHRASE = b'.2V_QJkd o'
25
-DUMMY_RESULT_KEY1 = b'E<b<{ -7iG'
26
-DUMMY_PHRASE_FROM_KEY1_RAW = (
27
-    b'\x00\x00\x00\x0bssh-ed25519'
28
-    b'\x00\x00\x00@\xf0\x98\x19\x80l\x1a\x97\xd5&\x03n'
29
-    b'\xcc\xe3e\x8f\x86f\x07\x13\x19\x13\t!33\xf9\xe46S'
30
-    b'\x1d\xaf\xfd\r\x08\x1f\xec\xf8s\x9b\x8c_U9\x16|ST,'
31
-    b'\x1eR\xbb0\xed\x7f\x89\xe2/iQU\xd8\x9e\xa6\x02'
32
-)
33
-DUMMY_PHRASE_FROM_KEY1 = b'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg=='
20
+DUMMY_SERVICE = tests.DUMMY_SERVICE
21
+DUMMY_PASSPHRASE = tests.DUMMY_PASSPHRASE
22
+DUMMY_CONFIG_SETTINGS = tests.DUMMY_CONFIG_SETTINGS
23
+DUMMY_RESULT_PASSPHRASE = tests.DUMMY_RESULT_PASSPHRASE
24
+DUMMY_RESULT_KEY1 = tests.DUMMY_RESULT_KEY1
25
+DUMMY_PHRASE_FROM_KEY1_RAW = tests.DUMMY_PHRASE_FROM_KEY1_RAW
26
+DUMMY_PHRASE_FROM_KEY1 = tests.DUMMY_PHRASE_FROM_KEY1
34 27
 
35
-# See Ed25519 and RSA test keys in test_key_signing.py
36
-DUMMY_KEY1 = 'AAAAC3NzaC1lZDI1NTE5AAAAIIF4gWgm1gJIXw//Mkhv5MEwidwcakUGCekJD/vCEml2'
37
-DUMMY_KEY2 = 'AAAAB3NzaC1yc2EAAAADAQABAAABgQCxoe7pezhxWy4NI0mUwKqg9WCYOAS+IjxN9eYcqpfcmQiojcuy9XsiN/xYJ1O94SrsKS5mEia2xHnYA4RUChTyYNcM2v6cnnBQ/N/VQhpGMN7SVxdbhKUXTWFCwbjBgO6rGyHB6WtoH8vd7TOEPt+NgcXwhsWyoaUUdYTA62V+GF9vEmxMaC4ubgDz+B0QkPnauSoNxmkhcIe0lsLNb1pClZyz88PDnKXCX/d0HuN/HJ+sbPg7dCvOyqFYSyKn3uY6bCXqoIdurxXzH3O7z0P8f5sbmKOrGGKNuNxVRbeVl/D/3uDL0nqsbfUc1qvkfwbJwtMXC4IV6kOZMSk2BAsqh7x48gQ+rhYeEVSi8F3CWs4HJQoqrGt7K9a3mCSlMBHP70u3w6ME7eumoryxlUofewTd17ZEkzdX08l2ZlKzZvwQUrc+xQZ2Uw8z2mfW6Ti4gi0pYGaig7Ke4PwuXpo/C5YAWfeXycsvJZ2uaYRjMdZeJGNAnHLUGLkBscw5aI8='
28
+DUMMY_KEY1 = tests.DUMMY_KEY1
29
+DUMMY_KEY1_B64 = tests.DUMMY_KEY1_B64
30
+DUMMY_KEY2 = tests.DUMMY_KEY2
38 31
 
39 32
 
40 33
 class IncompatibleConfiguration(NamedTuple):
... ...
@@ -156,151 +149,9 @@ for opt, config in SINGLES.items():
156 149
                           input=config.input,
157 150
                           check_success=config.check_success))
158 151
 
159
-@contextlib.contextmanager
160
-def isolated_config(
161
-    monkeypatch: Any, runner: click.testing.CliRunner, config: Any,
162
-):
163
-    with runner.isolated_filesystem():
164
-        monkeypatch.setenv('HOME', os.getcwd())
165
-        monkeypatch.setenv('USERPROFILE', os.getcwd())
166
-        monkeypatch.delenv(cli.prog_name.replace(' ', '_').upper() + '_PATH',
167
-                           raising=False)
168
-        os.makedirs(os.path.dirname(cli._config_filename()), exist_ok=True)
169
-        with open(cli._config_filename(), 'wt') as outfile:
170
-            json.dump(config, outfile)
171
-        yield
172
-
173
-
174
-def test_100_save_bad_config(monkeypatch: Any) -> None:
175
-    runner = click.testing.CliRunner()
176
-    with isolated_config(monkeypatch=monkeypatch, runner=runner, config={}):
177
-        with pytest.raises(ValueError, match='Invalid vault config'):
178
-            cli._save_config(None)  # type: ignore
179
-
180
-
181
-def test_101_prompt_for_selection_multiple(monkeypatch: Any) -> None:
182
-    @click.command()
183
-    @click.option('--heading', default='Our menu:')
184
-    @click.argument('items', nargs=-1)
185
-    def driver(heading, items):
186
-        # from https://montypython.fandom.com/wiki/Spam#The_menu
187
-        items = items or [
188
-            'Egg and bacon',
189
-            'Egg, sausage and bacon',
190
-            'Egg and spam',
191
-            'Egg, bacon and spam',
192
-            'Egg, bacon, sausage and spam',
193
-            'Spam, bacon, sausage and spam',
194
-            'Spam, egg, spam, spam, bacon and spam',
195
-            'Spam, spam, spam, egg and spam',
196
-            ('Spam, spam, spam, spam, spam, spam, baked beans, '
197
-             'spam, spam, spam and spam'),
198
-            ('Lobster thermidor aux crevettes with a mornay sauce '
199
-             'garnished with truffle paté, brandy '
200
-             'and a fried egg on top and spam'),
201
-        ]
202
-        index = cli._prompt_for_selection(items, heading=heading)
203
-        click.echo('A fine choice: ', nl=False)
204
-        click.echo(items[index])
205
-        click.echo('(Note: Vikings strictly optional.)')
206
-    runner = click.testing.CliRunner(mix_stderr=True)
207
-    result = runner.invoke(driver, [], input='9')
208
-    assert result.exit_code == 0, 'driver program failed'
209
-    assert result.stdout == '''\
210
-Our menu:
211
-[1] Egg and bacon
212
-[2] Egg, sausage and bacon
213
-[3] Egg and spam
214
-[4] Egg, bacon and spam
215
-[5] Egg, bacon, sausage and spam
216
-[6] Spam, bacon, sausage and spam
217
-[7] Spam, egg, spam, spam, bacon and spam
218
-[8] Spam, spam, spam, egg and spam
219
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
220
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
221
-Your selection? (1-10, leave empty to abort): 9
222
-A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
223
-(Note: Vikings strictly optional.)
224
-''', 'driver program produced unexpected output'
225
-    result = runner.invoke(driver, ['--heading='], input='',
226
-                           catch_exceptions=True)
227
-    assert result.exit_code > 0, 'driver program succeeded?!'
228
-    assert result.stdout == '''\
229
-[1] Egg and bacon
230
-[2] Egg, sausage and bacon
231
-[3] Egg and spam
232
-[4] Egg, bacon and spam
233
-[5] Egg, bacon, sausage and spam
234
-[6] Spam, bacon, sausage and spam
235
-[7] Spam, egg, spam, spam, bacon and spam
236
-[8] Spam, spam, spam, egg and spam
237
-[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
238
-[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
239
-Your selection? (1-10, leave empty to abort): \n''', (
240
-        'driver program produced unexpected output'
241
-    )
242
-    assert isinstance(result.exception, IndexError), (
243
-        'driver program did not raise IndexError?!'
244
-    )
245
-
246
-
247
-def test_102_prompt_for_selection_single(monkeypatch: Any) -> None:
248
-    @click.command()
249
-    @click.option('--item', default='baked beans')
250
-    @click.argument('prompt')
251
-    def driver(item, prompt):
252
-        try:
253
-            cli._prompt_for_selection([item], heading='',
254
-                                      single_choice_prompt=prompt)
255
-        except IndexError as e:
256
-            click.echo('Boo.')
257
-            raise e
258
-        else:
259
-            click.echo('Great!')
260
-    runner = click.testing.CliRunner(mix_stderr=True)
261
-    result = runner.invoke(driver, ['Will replace with spam. Confirm, y/n?'],
262
-                           input='y')
263
-    assert result.exit_code == 0, 'driver program failed'
264
-    assert result.stdout == '''\
265
-[1] baked beans
266
-Will replace with spam. Confirm, y/n? y
267
-Great!
268
-''', 'driver program produced unexpected output'
269
-    result = runner.invoke(driver,
270
-                           ['Will replace with spam, okay? ' +
271
-                            '(Please say "y" or "n".)'],
272
-                           input='')
273
-    assert result.exit_code > 0, 'driver program succeeded?!'
274
-    assert result.stdout == '''\
275
-[1] baked beans
276
-Will replace with spam, okay? (Please say "y" or "n".): 
277
-Boo.
278
-''', 'driver program produced unexpected output'
279
-    assert isinstance(result.exception, IndexError), (
280
-        'driver program did not raise IndexError?!'
281
-    )
282
-
283
-
284
-def test_103_prompt_for_passphrase(monkeypatch: Any) -> None:
285
-    monkeypatch.setattr(click, 'prompt',
286
-                        lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw}))
287
-    res = json.loads(cli._prompt_for_passphrase())
288
-    assert 'args' in res and 'kwargs' in res, (
289
-        'missing arguments to passphrase prompt'
290
-    )
291
-    assert res['args'][:1] == ['Passphrase'], (
292
-        'missing arguments to passphrase prompt'
293
-    )
294
-    assert (res['kwargs'].get('default') == ''
295
-            and not res['kwargs'].get('show_default', True)), (
296
-        'missing arguments to passphrase prompt'
297
-    )
298
-    assert res['kwargs'].get('err') and res['kwargs'].get('hide_input'), (
299
-        'missing arguments to passphrase prompt'
300
-    )
301
-
302 152
 
303
-def test_200_help_output():
153
+class TestCLI:
154
+    def test_200_help_output(self):
304 155
         runner = click.testing.CliRunner(mix_stderr=False)
305 156
         result = runner.invoke(cli.derivepassphrase, ['--help'],
306 157
                                catch_exceptions=False)
... ...
@@ -316,10 +167,9 @@ def test_200_help_output():
316 167
                              [('lower',), ('upper',), ('number',), ('space',),
317 168
                               ('dash',), ('symbol',)])
318 169
     def test_201_disable_character_set(
319
-    monkeypatch: Any, charset_name: str
170
+        self, monkeypatch: Any, charset_name: str
320 171
     ) -> None:
321
-    monkeypatch.setattr(cli, '_prompt_for_passphrase',
322
-                        lambda *a, **kw: DUMMY_PASSPHRASE.decode('UTF-8'))
172
+        monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
323 173
         option = f'--{charset_name}'
324 174
         charset = dpp.Vault._CHARSETS[charset_name].decode('ascii')
325 175
         runner = click.testing.CliRunner(mix_stderr=False)
... ...
@@ -338,9 +188,8 @@ def test_201_disable_character_set(
338 188
                 f'{result.stdout!r}'
339 189
             )
340 190
 
341
-def test_202_disable_repetition(monkeypatch: Any) -> None:
342
-    monkeypatch.setattr(cli, '_prompt_for_passphrase',
343
-                        lambda *a, **kw: DUMMY_PASSPHRASE.decode('UTF-8'))
191
+    def test_202_disable_repetition(self, monkeypatch: Any) -> None:
192
+        monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
344 193
         runner = click.testing.CliRunner(mix_stderr=False)
345 194
         result = runner.invoke(cli.derivepassphrase,
346 195
                                ['--repeat', '0', '-p', DUMMY_SERVICE],
... ...
@@ -358,92 +207,47 @@ def test_202_disable_repetition(monkeypatch: Any) -> None:
358 207
                 f'at position {i}: {result.stdout!r}'
359 208
             )
360 209
 
361
-@pytest.mark.parametrize(['command_line', 'config', 'result_config'], [
362
-    (['--delete-globals'],
363
-     {'global': {'phrase': 'abc'}, 'services': {}}, {'services': {}}),
364
-    (['--delete', DUMMY_SERVICE],
365
-     {'global': {'phrase': 'abc'},
366
-      'services': {DUMMY_SERVICE: {'notes': '...'}}},
367
-     {'global': {'phrase': 'abc'}, 'services': {}}),
368
-    (['--clear'],
369
-     {'global': {'phrase': 'abc'},
370
-      'services': {DUMMY_SERVICE: {'notes': '...'}}},
371
-     {'services': {}}),
372
-])
373
-def test_203_repeated_config_deletion(
374
-    monkeypatch: Any, command_line: list[str],
375
-    config: dpp.types.VaultConfig, result_config: dpp.types.VaultConfig,
376
-) -> None:
377
-    runner = click.testing.CliRunner(mix_stderr=False)
378
-    for start_config in [config, result_config]:
379
-        with isolated_config(monkeypatch=monkeypatch, runner=runner,
380
-                             config=start_config):
381
-            result = runner.invoke(cli.derivepassphrase, command_line,
382
-                                   catch_exceptions=False)
383
-            assert (result.exit_code, result.stderr_bytes) == (0, b''), (
384
-                'program exited with failure'
385
-            )
386
-            with open(cli._config_filename(), 'rt') as infile:
387
-                config_readback = json.load(infile)
388
-            assert config_readback == result_config
389
-
390
-def test_204_phrase_from_key_manually() -> None:
391
-    assert (
392
-        dpp.Vault(phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS)
393
-        .generate(DUMMY_SERVICE) == DUMMY_RESULT_KEY1
394
-    )
395
-
396 210
     @pytest.mark.parametrize(['config'], [
397
-    pytest.param({'global': {'key': DUMMY_KEY1},
211
+        pytest.param({'global': {'key': DUMMY_KEY1_B64},
398 212
                       'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}},
399 213
                      id='global'),
400
-    pytest.param({'global': {'phrase': DUMMY_PASSPHRASE.rstrip(b'\n').decode('ASCII')},
401
-                  'services': {DUMMY_SERVICE: {'key': DUMMY_KEY1,
214
+        pytest.param({'global': {'phrase': DUMMY_PASSPHRASE.rstrip(b'\n')
215
+                                           .decode('ASCII')},
216
+                      'services': {DUMMY_SERVICE: {'key': DUMMY_KEY1_B64,
402 217
                                                    **DUMMY_CONFIG_SETTINGS}}},
403 218
                      id='service'),
404 219
     ])
405 220
     def test_204a_key_from_config(
406
-    monkeypatch: Any, config: dpp.types.VaultConfig,
221
+        self, monkeypatch: Any, config: dpp.types.VaultConfig,
407 222
     ) -> None:
408
-    def phrase_from_key(key: bytes) -> bytes:
409
-        if key == base64.standard_b64decode(DUMMY_KEY1):  # pragma: no branch
410
-            return DUMMY_PHRASE_FROM_KEY1
411
-        raise KeyError(key)  # pragma: no cover
412 223
         runner = click.testing.CliRunner(mix_stderr=False)
413
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
224
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
414 225
                                    config=config):
415 226
             monkeypatch.setattr(dpp.Vault, 'phrase_from_key',
416
-                            phrase_from_key)
227
+                                tests.phrase_from_key)
417 228
             result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],
418 229
                                    catch_exceptions=False)
419 230
             assert (result.exit_code, result.stderr_bytes) == (0, b''), (
420 231
                 'program exited with failure'
421 232
             )
422
-        assert result.stdout_bytes.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE, (
233
+            assert (
234
+                result.stdout_bytes.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE
235
+            ), (
423 236
                 'program generated unexpected result (phrase instead of key)'
424 237
             )
425 238
             assert result.stdout_bytes.rstrip(b'\n') == DUMMY_RESULT_KEY1, (
426 239
                 'program generated unexpected result (wrong settings?)'
427 240
             )
428 241
 
429
-def test_204b_key_from_command_line(monkeypatch: Any) -> None:
430
-    KeyCommentPair = ssh_agent_client.types.KeyCommentPair
431
-    key_list = [
432
-        KeyCommentPair(base64.standard_b64decode(DUMMY_KEY1), b'no comment'),
433
-        KeyCommentPair(base64.standard_b64decode(DUMMY_KEY2), b'a comment'),
434
-    ]
435
-    def _suitable_ssh_keys(conn: Any) -> Iterator[KeyCommentPair]:
436
-        yield from key_list
437
-    def phrase_from_key(key: bytes) -> bytes:
438
-        if key == base64.standard_b64decode(DUMMY_KEY1):  # pragma: no branch
439
-            return DUMMY_PHRASE_FROM_KEY1
440
-        raise KeyError(key)  # pragma: no cover
242
+    def test_204b_key_from_command_line(self, monkeypatch: Any) -> None:
441 243
         runner = click.testing.CliRunner(mix_stderr=False)
442
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
443
-                         config={'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS}}):
444
-        monkeypatch.setattr(cli, '_get_suitable_ssh_keys', _suitable_ssh_keys)
244
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
245
+                                   config={'services': {DUMMY_SERVICE:
246
+                                               DUMMY_CONFIG_SETTINGS}}):
247
+            monkeypatch.setattr(cli, '_get_suitable_ssh_keys',
248
+                                tests.suitable_ssh_keys)
445 249
             monkeypatch.setattr(dpp.Vault, 'phrase_from_key',
446
-                            phrase_from_key)
250
+                                tests.phrase_from_key)
447 251
             result = runner.invoke(cli.derivepassphrase,
448 252
                                    ['-k', DUMMY_SERVICE],
449 253
                                    input=b'1\n', catch_exceptions=False)
... ...
@@ -457,15 +261,18 @@ def test_204b_key_from_command_line(monkeypatch: Any) -> None:
457 261
                 'program generated unexpected result (wrong settings?)'
458 262
             )
459 263
 
460
-def test_205_service_phrase_if_key_in_global_config(monkeypatch: Any) -> None:
264
+    def test_205_service_phrase_if_key_in_global_config(
265
+        self, monkeypatch: Any,
266
+    ) -> None:
461 267
         runner = click.testing.CliRunner(mix_stderr=False)
462
-    with isolated_config(
268
+        with tests.isolated_config(
463 269
             monkeypatch=monkeypatch, runner=runner,
464 270
             config={
465
-            'global': {'key': DUMMY_KEY1},
271
+                'global': {'key': DUMMY_KEY1_B64},
466 272
                 'services': {
467 273
                     DUMMY_SERVICE: {
468
-                    'phrase': DUMMY_PASSPHRASE.rstrip(b'\n').decode('ASCII'),
274
+                        'phrase': DUMMY_PASSPHRASE.rstrip(b'\n')
275
+                                  .decode('ASCII'),
469 276
                         **DUMMY_CONFIG_SETTINGS}}}
470 277
         ):
471 278
             result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],
... ...
@@ -484,13 +291,15 @@ def test_205_service_phrase_if_key_in_global_config(monkeypatch: Any) -> None:
484 291
                              [('--lower',), ('--upper',), ('--number',),
485 292
                               ('--space',), ('--dash',), ('--symbol',),
486 293
                               ('--repeat',), ('--length',)])
487
-def test_210_invalid_argument_range(option: str) -> None:
294
+    def test_210_invalid_argument_range(self, option: str) -> None:
488 295
         runner = click.testing.CliRunner(mix_stderr=False)
489 296
         value: str | int
490 297
         for value in '-42', 'invalid':
491 298
             result = runner.invoke(cli.derivepassphrase,
492
-                               [option, cast(str, value), '-p', DUMMY_SERVICE],
493
-                               input=DUMMY_PASSPHRASE, catch_exceptions=False)
299
+                                   [option, cast(str, value), '-p',
300
+                                    DUMMY_SERVICE],
301
+                                   input=DUMMY_PASSPHRASE,
302
+                                   catch_exceptions=False)
494 303
             assert result.exit_code > 0, (
495 304
                 'program unexpectedly succeeded'
496 305
             )
... ...
@@ -501,33 +310,18 @@ def test_210_invalid_argument_range(option: str) -> None:
501 310
                 'program did not print the expected error message'
502 311
             )
503 312
 
504
-@pytest.mark.parametrize(['vfunc', 'input'], [
505
-    (cli._validate_occurrence_constraint, 20),
506
-    (cli._validate_length, 20),
507
-])
508
-def test_210a_validate_constraints_manually(
509
-    vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
510
-    input: int,
511
-) -> None:
512
-    ctx = cli.derivepassphrase.make_context(cli.prog_name, [])
513
-    param = cli.derivepassphrase.params[0]
514
-    assert vfunc(ctx, param, input) == input
515
-
516 313
     @pytest.mark.parametrize(
517 314
         ['options', 'service', 'input', 'check_success'],
518 315
         [(o.options, o.needs_service, o.input, o.check_success)
519 316
          for o in INTERESTING_OPTION_COMBINATIONS if not o.incompatible],
520 317
     )
521 318
     def test_211_service_needed(
522
-    monkeypatch: Any, options: list[str],
319
+        self, monkeypatch: Any, options: list[str],
523 320
         service: bool | None, input: bytes | None, check_success: bool,
524 321
     ) -> None:
525
-    def _prompt1(*args, **kwargs):
526
-        """Needed for --config handling."""
527
-        return DUMMY_PASSPHRASE.decode('UTF-8')
528
-    monkeypatch.setattr(cli, '_prompt_for_passphrase', _prompt1)
322
+        monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
529 323
         runner = click.testing.CliRunner(mix_stderr=False)
530
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
324
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
531 325
                                    config={'global': {'phrase': 'abc'},
532 326
                                            'services': {}}):
533 327
             result = runner.invoke(cli.derivepassphrase,
... ...
@@ -551,12 +345,11 @@ def test_211_service_needed(
551 345
                     'program unexpectedly failed'
552 346
                 )
553 347
         if check_success:
554
-        with isolated_config(monkeypatch=monkeypatch, runner=runner,
348
+            with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
555 349
                                        config={'global': {'phrase': 'abc'},
556 350
                                                'services': {}}):
557
-            def _prompt2(*args, **kwargs):
558
-                return DUMMY_PASSPHRASE.decode('UTF-8')
559
-            monkeypatch.setattr(cli, '_prompt_for_passphrase', _prompt2)
351
+                monkeypatch.setattr(cli, '_prompt_for_passphrase',
352
+                                    tests.auto_prompt)
560 353
                 result = runner.invoke(cli.derivepassphrase,
561 354
                                        options + [DUMMY_SERVICE]
562 355
                                        if service else options,
... ...
@@ -571,11 +364,12 @@ def test_211_service_needed(
571 364
          for o in INTERESTING_OPTION_COMBINATIONS if o.incompatible],
572 365
     )
573 366
     def test_212_incompatible_options(
574
-    options: list[str], service: bool | None, input: bytes | None,
367
+        self, options: list[str], service: bool | None, input: bytes | None,
575 368
     ) -> None:
576 369
         runner = click.testing.CliRunner(mix_stderr=False)
577 370
         result = runner.invoke(cli.derivepassphrase,
578
-                           options + [DUMMY_SERVICE] if service else options,
371
+                               options + [DUMMY_SERVICE] if service
372
+                               else options,
579 373
                                input=DUMMY_PASSPHRASE, catch_exceptions=False)
580 374
         assert result.exit_code > 0, (
581 375
             'program unexpectedly succeeded'
... ...
@@ -587,9 +381,11 @@ def test_212_incompatible_options(
587 381
             'program did not print the expected error message'
588 382
         )
589 383
 
590
-def test_213_import_bad_config_not_vault_config(monkeypatch: Any) -> None:
384
+    def test_213_import_bad_config_not_vault_config(
385
+        self, monkeypatch: Any,
386
+    ) -> None:
591 387
         runner = click.testing.CliRunner(mix_stderr=False)
592
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
388
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
593 389
                                    config={'services': {}}):
594 390
             result = runner.invoke(cli.derivepassphrase, ['--import', '-'],
595 391
                                    input=b'null', catch_exceptions=False)
... ...
@@ -603,9 +399,11 @@ def test_213_import_bad_config_not_vault_config(monkeypatch: Any) -> None:
603 399
                 'program did not print the expected error message'
604 400
             )
605 401
 
606
-def test_213a_import_bad_config_not_json_data(monkeypatch: Any) -> None:
402
+    def test_213a_import_bad_config_not_json_data(
403
+        self, monkeypatch: Any,
404
+    ) -> None:
607 405
         runner = click.testing.CliRunner(mix_stderr=False)
608
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
406
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
609 407
                                    config={'services': {}}):
610 408
             result = runner.invoke(cli.derivepassphrase, ['--import', '-'],
611 409
                                    input=b'This string is not valid JSON.',
... ...
@@ -620,9 +418,11 @@ def test_213a_import_bad_config_not_json_data(monkeypatch: Any) -> None:
620 418
                 'program did not print the expected error message'
621 419
             )
622 420
 
623
-def test_213b_import_bad_config_not_a_file(monkeypatch: Any) -> None:
421
+    def test_213b_import_bad_config_not_a_file(
422
+        self, monkeypatch: Any,
423
+    ) -> None:
624 424
         runner = click.testing.CliRunner(mix_stderr=False)
625
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
425
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
626 426
                                    config={'services': {}}):
627 427
             with open(cli._config_filename(), 'wt') as outfile:
628 428
                 print('This string is not valid JSON.', file=outfile)
... ...
@@ -639,9 +439,11 @@ def test_213b_import_bad_config_not_a_file(monkeypatch: Any) -> None:
639 439
             # Don't test the actual error message, because it is subject to
640 440
             # locale settings.  TODO: find a way anyway.
641 441
 
642
-def test_214_export_settings_no_stored_settings(monkeypatch: Any) -> None:
442
+    def test_214_export_settings_no_stored_settings(
443
+        self, monkeypatch: Any,
444
+    ) -> None:
643 445
         runner = click.testing.CliRunner(mix_stderr=False)
644
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
446
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
645 447
                                    config={'services': {}}):
646 448
             try:
647 449
                 os.remove(cli._config_filename())
... ...
@@ -653,9 +455,11 @@ def test_214_export_settings_no_stored_settings(monkeypatch: Any) -> None:
653 455
                 'program exited with failure'
654 456
             )
655 457
 
656
-def test_214a_export_settings_bad_stored_config(monkeypatch: Any) -> None:
458
+    def test_214a_export_settings_bad_stored_config(
459
+        self, monkeypatch: Any,
460
+    ) -> None:
657 461
         runner = click.testing.CliRunner(mix_stderr=False)
658
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
462
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
659 463
                                    config={}):
660 464
             result = runner.invoke(cli.derivepassphrase, ['--export', '-'],
661 465
                                    input=b'null', catch_exceptions=False)
... ...
@@ -669,9 +473,11 @@ def test_214a_export_settings_bad_stored_config(monkeypatch: Any) -> None:
669 473
                 'program did not print the expected error message'
670 474
             )
671 475
 
672
-def test_214b_export_settings_not_a_file(monkeypatch: Any) -> None:
476
+    def test_214b_export_settings_not_a_file(
477
+        self, monkeypatch: Any,
478
+    ) -> None:
673 479
         runner = click.testing.CliRunner(mix_stderr=False)
674
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
480
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
675 481
                                    config={'services': {}}):
676 482
             try:
677 483
                 os.remove(cli._config_filename())
... ...
@@ -690,9 +496,11 @@ def test_214b_export_settings_not_a_file(monkeypatch: Any) -> None:
690 496
                 'program did not print the expected error message'
691 497
             )
692 498
 
693
-def test_214c_export_settings_target_not_a_file(monkeypatch: Any) -> None:
499
+    def test_214c_export_settings_target_not_a_file(
500
+        self, monkeypatch: Any,
501
+    ) -> None:
694 502
         runner = click.testing.CliRunner(mix_stderr=False)
695
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
503
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
696 504
                                    config={'services': {}}):
697 505
             dname = os.path.dirname(cli._config_filename())
698 506
             result = runner.invoke(cli.derivepassphrase,
... ...
@@ -708,14 +516,14 @@ def test_214c_export_settings_target_not_a_file(monkeypatch: Any) -> None:
708 516
                 'program did not print the expected error message'
709 517
             )
710 518
 
711
-def test_220_edit_notes_successfully(monkeypatch: Any) -> None:
519
+    def test_220_edit_notes_successfully(self, monkeypatch: Any) -> None:
712 520
         edit_result = '''
713 521
 
714 522
 # - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -
715 523
 contents go here
716 524
 '''
717 525
         runner = click.testing.CliRunner(mix_stderr=False)
718
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
526
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
719 527
                                    config={'global': {'phrase': 'abc'},
720 528
                                            'services': {}}):
721 529
             monkeypatch.setattr(click, 'edit',
... ...
@@ -728,11 +536,12 @@ contents go here
728 536
             with open(cli._config_filename(), 'rt') as infile:
729 537
                 config = json.load(infile)
730 538
             assert config == {'global': {'phrase': 'abc'},
731
-                          'services': {'sv': {'notes': 'contents go here'}}}
539
+                              'services': {'sv': {'notes':
540
+                                                      'contents go here'}}}
732 541
 
733
-def test_221_edit_notes_noop(monkeypatch: Any) -> None:
542
+    def test_221_edit_notes_noop(self, monkeypatch: Any) -> None:
734 543
         runner = click.testing.CliRunner(mix_stderr=False)
735
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
544
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
736 545
                                    config={'global': {'phrase': 'abc'},
737 546
                                            'services': {}}):
738 547
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: None)
... ...
@@ -745,9 +554,9 @@ def test_221_edit_notes_noop(monkeypatch: Any) -> None:
745 554
                 config = json.load(infile)
746 555
             assert config == {'global': {'phrase': 'abc'}, 'services': {}}
747 556
 
748
-def test_222_edit_notes_marker_removed(monkeypatch: Any) -> None:
557
+    def test_222_edit_notes_marker_removed(self, monkeypatch: Any) -> None:
749 558
         runner = click.testing.CliRunner(mix_stderr=False)
750
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
559
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
751 560
                                    config={'global': {'phrase': 'abc'},
752 561
                                            'services': {}}):
753 562
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: 'long\ntext')
... ...
@@ -761,9 +570,9 @@ def test_222_edit_notes_marker_removed(monkeypatch: Any) -> None:
761 570
             assert config == {'global': {'phrase': 'abc'},
762 571
                               'services': {'sv': {'notes': 'long\ntext'}}}
763 572
 
764
-def test_223_edit_notes_abort(monkeypatch: Any) -> None:
573
+    def test_223_edit_notes_abort(self, monkeypatch: Any) -> None:
765 574
         runner = click.testing.CliRunner(mix_stderr=False)
766
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
575
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
767 576
                                    config={'global': {'phrase': 'abc'},
768 577
                                            'services': {}}):
769 578
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: '\n\n')
... ...
@@ -786,7 +595,7 @@ def test_223_edit_notes_abort(monkeypatch: Any) -> None:
786 595
         (
787 596
             ['--key'],
788 597
             b'1\n',
789
-        {'global': {'key': DUMMY_KEY1}, 'services': {}},
598
+            {'global': {'key': DUMMY_KEY1_B64}, 'services': {}},
790 599
         ),
791 600
         (
792 601
             ['--phrase', 'sv'],
... ...
@@ -798,31 +607,25 @@ def test_223_edit_notes_abort(monkeypatch: Any) -> None:
798 607
             ['--key', 'sv'],
799 608
             b'1\n',
800 609
             {'global': {'phrase': 'abc'},
801
-         'services': {'sv': {'key': DUMMY_KEY1}}},
610
+             'services': {'sv': {'key': DUMMY_KEY1_B64}}},
802 611
         ),
803 612
         (
804 613
             ['--key', '--length', '15', 'sv'],
805 614
             b'1\n',
806 615
             {'global': {'phrase': 'abc'},
807
-         'services': {'sv': {'key': DUMMY_KEY1, 'length': 15}}},
616
+             'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}}},
808 617
         ),
809 618
     ])
810 619
     def test_224_store_config_good(
811
-    monkeypatch: Any, command_line: list[str], input: bytes,
620
+        self, monkeypatch: Any, command_line: list[str], input: bytes,
812 621
         result_config: Any,
813 622
     ) -> None:
814
-    KeyCommentPair = ssh_agent_client.types.KeyCommentPair
815
-    key_list = [
816
-        KeyCommentPair(base64.standard_b64decode(DUMMY_KEY1), b'no comment'),
817
-        KeyCommentPair(base64.standard_b64decode(DUMMY_KEY2), b'a comment'),
818
-    ]
819
-    def _suitable_ssh_keys(conn: Any) -> Iterator[KeyCommentPair]:
820
-        yield from key_list
821 623
         runner = click.testing.CliRunner(mix_stderr=False)
822
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
624
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
823 625
                                    config={'global': {'phrase': 'abc'},
824 626
                                            'services': {}}):
825
-        monkeypatch.setattr(cli, '_get_suitable_ssh_keys', _suitable_ssh_keys)
627
+            monkeypatch.setattr(cli, '_get_suitable_ssh_keys',
628
+                                tests.suitable_ssh_keys)
826 629
             result = runner.invoke(cli.derivepassphrase,
827 630
                                    ['--config'] + command_line,
828 631
                                    catch_exceptions=False, input=input)
... ...
@@ -835,25 +638,24 @@ def test_224_store_config_good(
835 638
 
836 639
     @pytest.mark.parametrize(['command_line', 'input', 'err_text'], [
837 640
         ([], b'', b'cannot update global settings without actual settings'),
838
-    (['sv'], b'', b'cannot update service settings without actual settings'),
641
+        (
642
+            ['sv'],
643
+            b'',
644
+            b'cannot update service settings without actual settings',
645
+        ),
839 646
         (['--phrase', 'sv'], b'', b'no passphrase given'),
840 647
         (['--key'], b'', b'no valid SSH key selected'),
841 648
     ])
842 649
     def test_225_store_config_fail(
843
-    monkeypatch: Any, command_line: list[str], input: bytes, err_text: str,
650
+        self, monkeypatch: Any, command_line: list[str],
651
+        input: bytes, err_text: str,
844 652
     ) -> None:
845
-    KeyCommentPair = ssh_agent_client.types.KeyCommentPair
846
-    key_list = [
847
-        KeyCommentPair(base64.standard_b64decode(DUMMY_KEY1), b'no comment'),
848
-        KeyCommentPair(base64.standard_b64decode(DUMMY_KEY2), b'a comment'),
849
-    ]
850
-    def _suitable_ssh_keys(conn: Any) -> Iterator[KeyCommentPair]:
851
-        yield from key_list
852 653
         runner = click.testing.CliRunner(mix_stderr=False)
853
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
654
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
854 655
                                    config={'global': {'phrase': 'abc'},
855 656
                                            'services': {}}):
856
-        monkeypatch.setattr(cli, '_get_suitable_ssh_keys', _suitable_ssh_keys)
657
+            monkeypatch.setattr(cli, '_get_suitable_ssh_keys',
658
+                                tests.suitable_ssh_keys)
857 659
             result = runner.invoke(cli.derivepassphrase,
858 660
                                    ['--config'] + command_line,
859 661
                                    catch_exceptions=False, input=input)
... ...
@@ -863,31 +665,33 @@ def test_225_store_config_fail(
863 665
             )
864 666
 
865 667
     def test_225a_store_config_fail_manual_no_ssh_key_selection(
866
-    monkeypatch: Any,
668
+        self, monkeypatch: Any,
867 669
     ) -> None:
868 670
         runner = click.testing.CliRunner(mix_stderr=False)
869
-    with isolated_config(monkeypatch=monkeypatch, runner=runner,
671
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
870 672
                                    config={'global': {'phrase': 'abc'},
871 673
                                            'services': {}}):
872 674
             def raiser():
873 675
                 raise RuntimeError('custom error message')
874 676
             monkeypatch.setattr(cli, '_select_ssh_key', raiser)
875
-        result = runner.invoke(cli.derivepassphrase, ['--key', '--config'],
677
+            result = runner.invoke(cli.derivepassphrase,
678
+                                   ['--key', '--config'],
876 679
                                    catch_exceptions=False)
877 680
             assert result.exit_code != 0, 'program unexpectedly succeeded'
878 681
             assert b'custom error message' in result.stderr_bytes, (
879 682
                 'expected error message missing'
880 683
             )
881 684
 
882
-def test_226_no_arguments() -> None:
685
+    def test_226_no_arguments(self) -> None:
883 686
         runner = click.testing.CliRunner(mix_stderr=False)
884
-    result = runner.invoke(cli.derivepassphrase, [], catch_exceptions=False)
687
+        result = runner.invoke(cli.derivepassphrase, [],
688
+                               catch_exceptions=False)
885 689
         assert result.exit_code != 0, 'program unexpectedly succeeded'
886 690
         assert b'SERVICE is required' in result.stderr_bytes, (
887 691
             'expected error message missing'
888 692
         )
889 693
 
890
-def test_226a_no_passphrase_or_key() -> None:
694
+    def test_226a_no_passphrase_or_key(self) -> None:
891 695
         runner = click.testing.CliRunner(mix_stderr=False)
892 696
         result = runner.invoke(cli.derivepassphrase, [DUMMY_SERVICE],
893 697
                                catch_exceptions=False)
... ...
@@ -895,3 +699,213 @@ def test_226a_no_passphrase_or_key() -> None:
895 699
         assert b'no passphrase or key given' in result.stderr_bytes, (
896 700
             'expected error message missing'
897 701
         )
702
+
703
+
704
+class TestCLIUtils:
705
+
706
+    def test_100_save_bad_config(self, monkeypatch: Any) -> None:
707
+        runner = click.testing.CliRunner()
708
+        with tests.isolated_config(monkeypatch=monkeypatch, runner=runner,
709
+                                   config={}):
710
+            with pytest.raises(ValueError, match='Invalid vault config'):
711
+                cli._save_config(None)  # type: ignore
712
+
713
+
714
+    def test_101_prompt_for_selection_multiple(self, monkeypatch: Any) -> None:
715
+        @click.command()
716
+        @click.option('--heading', default='Our menu:')
717
+        @click.argument('items', nargs=-1)
718
+        def driver(heading, items):
719
+            # from https://montypython.fandom.com/wiki/Spam#The_menu
720
+            items = items or [
721
+                'Egg and bacon',
722
+                'Egg, sausage and bacon',
723
+                'Egg and spam',
724
+                'Egg, bacon and spam',
725
+                'Egg, bacon, sausage and spam',
726
+                'Spam, bacon, sausage and spam',
727
+                'Spam, egg, spam, spam, bacon and spam',
728
+                'Spam, spam, spam, egg and spam',
729
+                ('Spam, spam, spam, spam, spam, spam, baked beans, '
730
+                 'spam, spam, spam and spam'),
731
+                ('Lobster thermidor aux crevettes with a mornay sauce '
732
+                 'garnished with truffle paté, brandy '
733
+                 'and a fried egg on top and spam'),
734
+            ]
735
+            index = cli._prompt_for_selection(items, heading=heading)
736
+            click.echo('A fine choice: ', nl=False)
737
+            click.echo(items[index])
738
+            click.echo('(Note: Vikings strictly optional.)')
739
+        runner = click.testing.CliRunner(mix_stderr=True)
740
+        result = runner.invoke(driver, [], input='9')
741
+        assert result.exit_code == 0, 'driver program failed'
742
+        assert result.stdout == '''\
743
+Our menu:
744
+[1] Egg and bacon
745
+[2] Egg, sausage and bacon
746
+[3] Egg and spam
747
+[4] Egg, bacon and spam
748
+[5] Egg, bacon, sausage and spam
749
+[6] Spam, bacon, sausage and spam
750
+[7] Spam, egg, spam, spam, bacon and spam
751
+[8] Spam, spam, spam, egg and spam
752
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
753
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
754
+Your selection? (1-10, leave empty to abort): 9
755
+A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
756
+(Note: Vikings strictly optional.)
757
+''', 'driver program produced unexpected output'
758
+        result = runner.invoke(driver, ['--heading='], input='',
759
+                               catch_exceptions=True)
760
+        assert result.exit_code > 0, 'driver program succeeded?!'
761
+        assert result.stdout == '''\
762
+[1] Egg and bacon
763
+[2] Egg, sausage and bacon
764
+[3] Egg and spam
765
+[4] Egg, bacon and spam
766
+[5] Egg, bacon, sausage and spam
767
+[6] Spam, bacon, sausage and spam
768
+[7] Spam, egg, spam, spam, bacon and spam
769
+[8] Spam, spam, spam, egg and spam
770
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
771
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
772
+Your selection? (1-10, leave empty to abort): \n''', (
773
+            'driver program produced unexpected output'
774
+        )
775
+        assert isinstance(result.exception, IndexError), (
776
+            'driver program did not raise IndexError?!'
777
+        )
778
+
779
+
780
+    def test_102_prompt_for_selection_single(self, monkeypatch: Any) -> None:
781
+        @click.command()
782
+        @click.option('--item', default='baked beans')
783
+        @click.argument('prompt')
784
+        def driver(item, prompt):
785
+            try:
786
+                cli._prompt_for_selection([item], heading='',
787
+                                          single_choice_prompt=prompt)
788
+            except IndexError as e:
789
+                click.echo('Boo.')
790
+                raise e
791
+            else:
792
+                click.echo('Great!')
793
+        runner = click.testing.CliRunner(mix_stderr=True)
794
+        result = runner.invoke(driver, ['Will replace with spam. Confirm, y/n?'],
795
+                               input='y')
796
+        assert result.exit_code == 0, 'driver program failed'
797
+        assert result.stdout == '''\
798
+[1] baked beans
799
+Will replace with spam. Confirm, y/n? y
800
+Great!
801
+''', 'driver program produced unexpected output'
802
+        result = runner.invoke(driver,
803
+                               ['Will replace with spam, okay? ' +
804
+                                '(Please say "y" or "n".)'],
805
+                               input='')
806
+        assert result.exit_code > 0, 'driver program succeeded?!'
807
+        assert result.stdout == '''\
808
+[1] baked beans
809
+Will replace with spam, okay? (Please say "y" or "n".): 
810
+Boo.
811
+''', 'driver program produced unexpected output'
812
+        assert isinstance(result.exception, IndexError), (
813
+            'driver program did not raise IndexError?!'
814
+        )
815
+
816
+
817
+    def test_103_prompt_for_passphrase(self, monkeypatch: Any) -> None:
818
+        monkeypatch.setattr(click, 'prompt',
819
+                            lambda *a, **kw: json.dumps({'args': a, 'kwargs': kw}))
820
+        res = json.loads(cli._prompt_for_passphrase())
821
+        assert 'args' in res and 'kwargs' in res, (
822
+            'missing arguments to passphrase prompt'
823
+        )
824
+        assert res['args'][:1] == ['Passphrase'], (
825
+            'missing arguments to passphrase prompt'
826
+        )
827
+        assert (res['kwargs'].get('default') == ''
828
+                and not res['kwargs'].get('show_default', True)), (
829
+            'missing arguments to passphrase prompt'
830
+        )
831
+        assert res['kwargs'].get('err') and res['kwargs'].get('hide_input'), (
832
+            'missing arguments to passphrase prompt'
833
+        )
834
+
835
+
836
+    @pytest.mark.parametrize(['command_line', 'config', 'result_config'], [
837
+        (['--delete-globals'],
838
+         {'global': {'phrase': 'abc'}, 'services': {}}, {'services': {}}),
839
+        (['--delete', DUMMY_SERVICE],
840
+         {'global': {'phrase': 'abc'},
841
+          'services': {DUMMY_SERVICE: {'notes': '...'}}},
842
+         {'global': {'phrase': 'abc'}, 'services': {}}),
843
+        (['--clear'],
844
+         {'global': {'phrase': 'abc'},
845
+          'services': {DUMMY_SERVICE: {'notes': '...'}}},
846
+         {'services': {}}),
847
+    ])
848
+    def test_203_repeated_config_deletion(
849
+        self, monkeypatch: Any, command_line: list[str],
850
+        config: dpp.types.VaultConfig, result_config: dpp.types.VaultConfig,
851
+    ) -> None:
852
+        runner = click.testing.CliRunner(mix_stderr=False)
853
+        for start_config in [config, result_config]:
854
+            with tests.isolated_config(monkeypatch=monkeypatch,
855
+                                       runner=runner, config=start_config):
856
+                result = runner.invoke(cli.derivepassphrase, command_line,
857
+                                       catch_exceptions=False)
858
+                assert (result.exit_code, result.stderr_bytes) == (0, b''), (
859
+                    'program exited with failure'
860
+                )
861
+                with open(cli._config_filename(), 'rt') as infile:
862
+                    config_readback = json.load(infile)
863
+                assert config_readback == result_config
864
+
865
+
866
+    def test_204_phrase_from_key_manually(self) -> None:
867
+        assert (
868
+            dpp.Vault(phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS)
869
+            .generate(DUMMY_SERVICE) == DUMMY_RESULT_KEY1
870
+        )
871
+
872
+
873
+    @pytest.mark.parametrize(['vfunc', 'input'], [
874
+        (cli._validate_occurrence_constraint, 20),
875
+        (cli._validate_length, 20),
876
+    ])
877
+    def test_210a_validate_constraints_manually(
878
+        self,
879
+        vfunc: Callable[[click.Context, click.Parameter, Any], int | None],
880
+        input: int,
881
+    ) -> None:
882
+        ctx = cli.derivepassphrase.make_context(cli.prog_name, [])
883
+        param = cli.derivepassphrase.params[0]
884
+        assert vfunc(ctx, param, input) == input
885
+
886
+
887
+    @tests.skip_if_no_agent
888
+    @pytest.mark.parametrize(['conn_hint'],
889
+                             [('none',), ('socket',), ('client',)])
890
+    def test_227_get_suitable_ssh_keys(self, monkeypatch, conn_hint):
891
+        monkeypatch.setattr(ssh_agent_client.SSHAgentClient,
892
+                            'list_keys', tests.list_keys)
893
+        hint: ssh_agent_client.SSHAgentClient | socket.socket | None
894
+        match conn_hint:
895
+            case 'client':
896
+                hint = ssh_agent_client.SSHAgentClient()
897
+            case 'socket':
898
+                hint = socket.socket(family=socket.AF_UNIX)
899
+                hint.connect(os.environ['SSH_AUTH_SOCK'])
900
+            case _:
901
+                assert conn_hint == 'none'
902
+                hint = None
903
+        exception: Exception | None = None
904
+        try:
905
+            list(cli._get_suitable_ssh_keys(hint))
906
+        except RuntimeError:  # pragma: no cover
907
+            pass
908
+        except Exception as e:  # pragma: no cover
909
+            exception = e
910
+        finally:
911
+            assert exception == None, 'exception querying suitable SSH keys'
... ...
@@ -6,7 +6,7 @@ from __future__ import annotations
6 6
 
7 7
 from typing import Any, cast, TYPE_CHECKING, NamedTuple
8 8
 
9
-import derivepassphrase as dpp
9
+import derivepassphrase.types
10 10
 import pytest
11 11
 
12 12
 @pytest.mark.parametrize(['obj', 'comment'], [
... ...
@@ -43,9 +43,13 @@ import pytest
43 43
       'services': {'sv': {'phrase': 'abc', 'length': 10}}}, ''),
44 44
     ({'global': {'key': '...'},
45 45
       'services': {'sv': {'phrase': 'abc', 'length': 10}}}, ''),
46
+    ({'global': {'key': '...'},
47
+      'services': {'sv1': {'phrase': 'abc', 'length': 10, 'upper': 1},
48
+                   'sv2': {'length': 10, 'repeat': 1, 'lower': 1}}}, ''),
46 49
 ])
47 50
 def test_200_is_vault_config(obj: Any, comment: str) -> None:
48
-    assert dpp.types.is_vault_config(obj) == (not comment), (
51
+    is_vault_config = derivepassphrase.types.is_vault_config
52
+    assert is_vault_config(obj) == (not comment), (
49 53
         'failed to complain about: ' + comment if comment
50 54
         else 'failed on valid example'
51 55
     )
... ...
@@ -1,197 +0,0 @@
1
-# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
-#
3
-# SPDX-License-Identifier: MIT
4
-
5
-"""Test passphrase generation via derivepassphrase.Vault."""
6
-
7
-from __future__ import annotations
8
-
9
-import math
10
-
11
-import derivepassphrase
12
-import sequin
13
-import pytest
14
-
15
-Vault = derivepassphrase.Vault
16
-phrase = b'She cells C shells bye the sea shoars'
17
-google_phrase = rb': 4TVH#5:aZl8LueOT\{'
18
-twitter_phrase = rb"[ (HN_N:lI&<ro=)3'g9"
19
-
20
-@pytest.mark.parametrize(['service', 'expected'], [
21
-    (b'google', google_phrase),
22
-    ('twitter', twitter_phrase),
23
-])
24
-def test_200_basic_configuration(service, expected):
25
-    assert Vault(phrase=phrase).generate(service) == expected
26
-
27
-def test_201_phrase_dependence():
28
-    assert (
29
-        Vault(phrase=(phrase + b'X')).generate('google') ==
30
-        b'n+oIz6sL>K*lTEWYRO%7'
31
-    )
32
-
33
-def test_202_reproducibility_and_bytes_service_name():
34
-    assert (
35
-        Vault(phrase=phrase).generate(b'google') ==
36
-        Vault(phrase=phrase).generate('google')
37
-    )
38
-
39
-def test_203_reproducibility_and_bytearray_service_name():
40
-    assert (
41
-        Vault(phrase=phrase).generate(b'google') ==
42
-        Vault(phrase=phrase).generate(bytearray(b'google'))
43
-    )
44
-
45
-def test_210_nonstandard_length():
46
-    assert Vault(phrase=phrase, length=4).generate('google') == b'xDFu'
47
-
48
-def test_211_repetition_limit():
49
-    assert (
50
-        Vault(phrase=b'', length=24, symbol=0, number=0,
51
-              repeat=1).generate('asd') ==
52
-        b'IVTDzACftqopUXqDHPkuCIhV'
53
-    )
54
-
55
-def test_212_without_symbols():
56
-    assert (
57
-        Vault(phrase=phrase, symbol=0).generate('google') ==
58
-        b'XZ4wRe0bZCazbljCaMqR'
59
-    )
60
-
61
-def test_213_too_many_symbols():
62
-    with pytest.raises(ValueError,
63
-                       match='requested passphrase length too short'):
64
-        Vault(phrase=phrase, symbol=100)
65
-
66
-def test_214_no_numbers():
67
-    assert (
68
-        Vault(phrase=phrase, number=0).generate('google') ==
69
-        b'_*$TVH.%^aZl(LUeOT?>'
70
-    )
71
-
72
-def test_214_no_lowercase_letters():
73
-    assert (
74
-        Vault(phrase=phrase, lower=0).generate('google') ==
75
-        b':{?)+7~@OA:L]!0E$)(+'
76
-    )
77
-
78
-def test_215_at_least_5_digits():
79
-    assert (
80
-        Vault(phrase=phrase, length=8, number=5).generate('songkick') ==
81
-        b'i0908.7['
82
-    )
83
-
84
-def test_216_lots_of_spaces():
85
-    assert (
86
-        Vault(phrase=phrase, space=12).generate('songkick') ==
87
-        b' c   6 Bq  % 5fR    '
88
-    )
89
-
90
-def test_217_no_viable_characters():
91
-    with pytest.raises(ValueError,
92
-                       match='no allowed characters left'):
93
-        Vault(phrase=phrase, lower=0, upper=0, number=0,
94
-              space=0, dash=0, symbol=0)
95
-
96
-def test_218_all_character_classes():
97
-    assert (
98
-        Vault(phrase=phrase, lower=2, upper=2, number=1,
99
-              space=3, dash=2, symbol=1).generate('google') ==
100
-        b': : fv_wqt>a-4w1S  R'
101
-    )
102
-
103
-def test_219_only_numbers_and_very_high_repetition_limit():
104
-    generated = Vault(phrase=b'', length=40, lower=0, upper=0, space=0,
105
-                      dash=0, symbol=0, repeat=4).generate('abcdef')
106
-    assert b'0000' not in generated
107
-    assert b'1111' not in generated
108
-    assert b'2222' not in generated
109
-    assert b'3333' not in generated
110
-    assert b'4444' not in generated
111
-    assert b'5555' not in generated
112
-    assert b'6666' not in generated
113
-    assert b'7777' not in generated
114
-    assert b'8888' not in generated
115
-    assert b'9999' not in generated
116
-
117
-def test_220_very_limited_character_set():
118
-    generated = Vault(phrase=b'', length=24, lower=0, upper=0,
119
-                      space=0, symbol=0).generate('testing')
120
-    assert b'763252593304946694588866' == generated
121
-
122
-def test_300_character_set_subtraction():
123
-    assert Vault._subtract(b'be', b'abcdef') == bytearray(b'acdf')
124
-
125
-def test_301_character_set_subtraction_duplicate():
126
-    with pytest.raises(ValueError, match='duplicate characters'):
127
-        Vault._subtract(b'abcdef', b'aabbccddeeff')
128
-    with pytest.raises(ValueError, match='duplicate characters'):
129
-        Vault._subtract(b'aabbccddeeff', b'abcdef')
130
-
131
-@pytest.mark.parametrize(['length', 'settings', 'entropy'], [
132
-    (20, {}, math.log2(math.factorial(20)) + 20 * math.log2(94)),
133
-    (
134
-        20,
135
-        {'upper': 0, 'number': 0, 'space': 0, 'symbol': 0},
136
-        math.log2(math.factorial(20)) + 20 * math.log2(26)
137
-    ),
138
-    (0, {}, float('-inf')),
139
-    (0, {'lower': 0, 'number': 0, 'space': 0, 'symbol': 0}, float('-inf')),
140
-    (1, {}, math.log2(94)),
141
-    (1, {'upper': 0, 'lower': 0, 'number': 0, 'symbol': 0}, 0.0),
142
-])
143
-def test_400_entropy(
144
-    length: int, settings: dict[str, int], entropy: int
145
-) -> None:
146
-    v = Vault(length=length, **settings)
147
-    assert math.isclose(v._entropy(), entropy)
148
-    assert v._estimate_sufficient_hash_length() > 0
149
-    if math.isfinite(entropy) and entropy:
150
-        assert v._estimate_sufficient_hash_length(1.0) == math.ceil(entropy / 8)
151
-    assert v._estimate_sufficient_hash_length(8.0) >= entropy
152
-
153
-def test_401_hash_length_estimation(
154
-) -> None:
155
-    v = Vault(phrase=phrase)
156
-    with pytest.raises(ValueError,
157
-                       match='invalid safety factor'):
158
-        assert v._estimate_sufficient_hash_length(-1.0)
159
-    with pytest.raises(TypeError,
160
-                       match='invalid safety factor: not a float'):
161
-        assert v._estimate_sufficient_hash_length(None)  # type: ignore
162
-    v2 = Vault(phrase=phrase, lower=0, upper=0, number=0, symbol=0,
163
-               space=1, length=1)
164
-    assert v2._entropy() == 0.0
165
-    assert v2._estimate_sufficient_hash_length() > 0
166
-
167
-@pytest.mark.parametrize(['service', 'expected'], [
168
-    (b'google', google_phrase),
169
-    ('twitter', twitter_phrase),
170
-])
171
-def test_402_hash_length_expansion(
172
-    monkeypatch: Any, service: str | bytes, expected: bytes
173
-) -> None:
174
-    v = Vault(phrase=phrase)
175
-    monkeypatch.setattr(v,
176
-                        '_estimate_sufficient_hash_length',
177
-                        lambda *args, **kwargs: 1)
178
-    assert v._estimate_sufficient_hash_length
179
-    assert v.generate(service) == expected
180
-
181
-@pytest.mark.parametrize(['s', 'raises'], [
182
-    ('ñ', True), ('Düsseldorf', True),
183
-    ('liberté, egalité, fraternité', True), ('ASCII', False),
184
-    ('Düsseldorf'.encode('UTF-8'), False),
185
-    (bytearray([2, 3, 5, 7, 11, 13]), False),
186
-])
187
-def test_403_binary_strings(s: str | bytes | bytearray, raises: bool) -> None:
188
-    binstr = derivepassphrase.Vault._get_binary_string
189
-    if raises:
190
-        with pytest.raises(derivepassphrase.AmbiguousByteRepresentationError):
191
-            binstr(s)
192
-    elif isinstance(s, str):
193
-        assert binstr(s) == s.encode('UTF-8')
194
-        assert binstr(binstr(s)) == s.encode('UTF-8')
195
-    else:
196
-        assert binstr(s) == bytes(s)
197
-        assert binstr(binstr(s)) == bytes(s)
... ...
@@ -1,598 +0,0 @@
1
-# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
-#
3
-# SPDX-License-Identifier: MIT
4
-
5
-"""Test OpenSSH key loading and signing."""
6
-
7
-from __future__ import annotations
8
-
9
-import click
10
-import pytest
11
-
12
-import derivepassphrase
13
-import derivepassphrase.cli
14
-import ssh_agent_client
15
-
16
-import base64
17
-import errno
18
-import io
19
-import os
20
-import socket
21
-import subprocess
22
-
23
-if not os.environ.get('SSH_AUTH_SOCK'):  # pragma: no cover
24
-    pytest.skip('no running SSH agent detected', allow_module_level=True)
25
-
26
-SUPPORTED = {
27
-    'ed25519': {
28
-        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
29
-b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
30
-QyNTUxOQAAACCBeIFoJtYCSF8P/zJIb+TBMIncHGpFBgnpCQ/7whJpdgAAAKDweO7H8Hju
31
-xwAAAAtzc2gtZWQyNTUxOQAAACCBeIFoJtYCSF8P/zJIb+TBMIncHGpFBgnpCQ/7whJpdg
32
-AAAEAbM/A869nkWZbe2tp3Dm/L6gitvmpH/aRZt8sBII3ExYF4gWgm1gJIXw//Mkhv5MEw
33
-idwcakUGCekJD/vCEml2AAAAG3Rlc3Qga2V5IHdpdGhvdXQgcGFzc3BocmFzZQEC
34
------END OPENSSH PRIVATE KEY-----
35
-''',
36
-        'public_key': rb'''ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIF4gWgm1gJIXw//Mkhv5MEwidwcakUGCekJD/vCEml2 test key without passphrase
37
-''',
38
-        'public_key_data': bytes.fromhex('''
39
-            00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
40
-            00 00 00 20
41
-            81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1
42
-            30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76
43
-'''),
44
-        'expected_signature': bytes.fromhex('''
45
-            00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
46
-            00 00 00 40
47
-            f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86
48
-            66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd
49
-            0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c
50
-            1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02
51
-        '''),
52
-        'derived_passphrase': rb'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg==',
53
-    },
54
-    # Currently only supported by PuTTY (which is deficient in other
55
-    # niceties of the SSH agent and the agent's client).
56
-    'ed448': {
57
-        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
58
-b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAASgAAAAlz
59
-c2gtZWQ0NDgAAAA54vZy009Wu8wExjvEb3hqtLz1GO/+d5vmGUbErWQ4AUO9mYLT
60
-zHJHc2m4s+yWzP29Cc3EcxizLG8AAAAA8BdhfCcXYXwnAAAACXNzaC1lZDQ0OAAA
61
-ADni9nLTT1a7zATGO8RveGq0vPUY7/53m+YZRsStZDgBQ72ZgtPMckdzabiz7JbM
62
-/b0JzcRzGLMsbwAAAAByM7GIMRvWJB3YD6SIpAF2uudX4ozZe0X917wPwiBrs373
63
-9TM1n94Nib6hrxGNmCk2iBQDe2KALPgA4vZy009Wu8wExjvEb3hqtLz1GO/+d5vm
64
-GUbErWQ4AUO9mYLTzHJHc2m4s+yWzP29Cc3EcxizLG8AAAAAG3Rlc3Qga2V5IHdp
65
-dGhvdXQgcGFzc3BocmFzZQECAwQFBgcICQ==
66
------END OPENSSH PRIVATE KEY-----
67
-''',
68
-        'public_key': rb'''ssh-ed448 AAAACXNzaC1lZDQ0OAAAADni9nLTT1a7zATGO8RveGq0vPUY7/53m+YZRsStZDgBQ72ZgtPMckdzabiz7JbM/b0JzcRzGLMsbwA= test key without passphrase
69
-''',
70
-        'public_key_data': bytes.fromhex('''
71
-            00 00 00 09 73 73 68 2d 65 64 34 34 38
72
-            00 00 00 39
73
-            e2 f6 72 d3 4f 56 bb cc 04 c6 3b c4 6f 78 6a b4
74
-            bc f5 18 ef fe 77 9b e6 19 46 c4 ad 64 38 01 43
75
-            bd 99 82 d3 cc 72 47 73 69 b8 b3 ec 96 cc fd bd
76
-            09 cd c4 73 18 b3 2c 6f 00
77
-        '''),
78
-
79
-        'expected_signature': bytes.fromhex('''
80
-            00 00 00 09 73 73 68 2d 65 64 34 34 38
81
-            00 00 00 72 06 86
82
-            f4 64 a4 a6 ba d9 c3 22 c4 93 49 99 fc 11 de 67
83
-            97 08 f2 d8 b7 3c 2c 13 e7 c5 1c 1e 92 a6 0e d8
84
-            2f 6d 81 03 82 00 e3 72 e4 32 6d 72 d2 6d 32 84
85
-            3f cc a9 1e 57 2c 00 9a b3 99 de 45 da ce 2e d1
86
-            db e5 89 f3 35 be 24 58 90 c6 ca 04 f0 db 88 80
87
-            db bd 77 7c 80 20 7f 3a 48 61 f6 1f ae a9 5e 53
88
-            7b e0 9d 93 1e ea dc eb b5 cd 56 4c ea 8f 08 00
89
-        '''),
90
-        'derived_passphrase': rb'Bob0ZKSmutnDIsSTSZn8Ed5nlwjy2Lc8LBPnxRwekqYO2C9tgQOCAONy5DJtctJtMoQ/zKkeVywAmrOZ3kXazi7R2+WJ8zW+JFiQxsoE8NuIgNu9d3yAIH86SGH2H66pXlN74J2THurc67XNVkzqjwgA',
91
-    },
92
-    'rsa': {
93
-        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
94
-b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
95
-NhAAAAAwEAAQAAAYEAsaHu6Xs4cVsuDSNJlMCqoPVgmDgEviI8TfXmHKqX3JkIqI3LsvV7
96
-Ijf8WCdTveEq7CkuZhImtsR52AOEVAoU8mDXDNr+nJ5wUPzf1UIaRjDe0lcXW4SlF01hQs
97
-G4wYDuqxshwelraB/L3e0zhD7fjYHF8IbFsqGlFHWEwOtlfhhfbxJsTGguLm4A8/gdEJD5
98
-2rkqDcZpIXCHtJbCzW9aQpWcs/PDw5ylwl/3dB7jfxyfrGz4O3QrzsqhWEsip97mOmwl6q
99
-CHbq8V8x9zu89D/H+bG5ijqxhijbjcVUW3lZfw/97gy9J6rG31HNar5H8GycLTFwuCFepD
100
-mTEpNgQLKoe8ePIEPq4WHhFUovBdwlrOByUKKqxreyvWt5gkpTARz+9Lt8OjBO3rpqK8sZ
101
-VKH3sE3de2RJM3V9PJdmZSs2b8EFK3PsUGdlMPM9pn1uk4uIItKWBmooOynuD8Ll6aPwuW
102
-AFn3l8nLLyWdrmmEYzHWXiRjQJxy1Bi5AbHMOWiPAAAFkDPkuBkz5LgZAAAAB3NzaC1yc2
103
-EAAAGBALGh7ul7OHFbLg0jSZTAqqD1YJg4BL4iPE315hyql9yZCKiNy7L1eyI3/FgnU73h
104
-KuwpLmYSJrbEedgDhFQKFPJg1wza/pyecFD839VCGkYw3tJXF1uEpRdNYULBuMGA7qsbIc
105
-Hpa2gfy93tM4Q+342BxfCGxbKhpRR1hMDrZX4YX28SbExoLi5uAPP4HRCQ+dq5Kg3GaSFw
106
-h7SWws1vWkKVnLPzw8OcpcJf93Qe438cn6xs+Dt0K87KoVhLIqfe5jpsJeqgh26vFfMfc7
107
-vPQ/x/mxuYo6sYYo243FVFt5WX8P/e4MvSeqxt9RzWq+R/BsnC0xcLghXqQ5kxKTYECyqH
108
-vHjyBD6uFh4RVKLwXcJazgclCiqsa3sr1reYJKUwEc/vS7fDowTt66aivLGVSh97BN3Xtk
109
-STN1fTyXZmUrNm/BBStz7FBnZTDzPaZ9bpOLiCLSlgZqKDsp7g/C5emj8LlgBZ95fJyy8l
110
-na5phGMx1l4kY0CcctQYuQGxzDlojwAAAAMBAAEAAAF/cNVYT+Om4x9+SItcz5bOByGIOj
111
-yWUH8f9rRjnr5ILuwabIDgvFaVG+xM1O1hWADqzMnSEcknHRkTYEsqYPykAtxFvjOFEh70
112
-6qRUJ+fVZkqRGEaI3oWyWKTOhcCIYImtONvb0LOv/HQ2H2AXCoeqjST1qr/xSuljBtcB8u
113
-wxs3EqaO1yU7QoZpDcMX9plH7Rmc9nNfZcgrnktPk2deX2+Y/A5tzdVgG1IeqYp6CBMLNM
114
-uhL0OPdDehgBoDujx+rhkZ1gpo1wcULIM94NL7VSHBPX0Lgh9T+3j1HVP+YnMAvhfOvfct
115
-LlbJ06+TYGRAMuF2LPCAZM/m0FEyAurRgWxAjLXm+4kp2GAJXlw82deDkQ+P8cHNT6s9ZH
116
-R5YSy3lpZ35594ZMOLR8KqVvhgJGF6i9019BiF91SDxjE+sp6dNGfN8W+64tHdDv2a0Mso
117
-+8Qjyx7sTpi++EjLU8Iy73/e4B8qbXMyheyA/UUfgMtNKShh6sLlrD9h2Sm9RFTuEAAADA
118
-Jh3u7WfnjhhKZYbAW4TsPNXDMrB0/t7xyAQgFmko7JfESyrJSLg1cO+QMOiDgD7zuQ9RSp
119
-NIKdPsnIna5peh979mVjb2HgnikjyJECmBpLdwZKhX7MnIvgKw5lnQXHboEtWCa1N58l7f
120
-srzwbi9pFUuUp9dShXNffmlUCjDRsVLbK5C6+iaIQyCWFYK8mc6dpNkIoPKf+Xg+EJCIFQ
121
-oITqeu30Gc1+M+fdZc2ghq0b6XLthh/uHEry8b68M5KglMAAAAwQDw1i+IdcvPV/3u/q9O
122
-/kzLpKO3tbT89sc1zhjZsDNjDAGluNr6n38iq/XYRZu7UTL9BG+EgFVfIUV7XsYT5e+BPf
123
-13VS94rzZ7maCsOlULX+VdMO2zBucHIoec9RUlRZrfB21B2W7YGMhbpoa5lN3lKJQ7afHo
124
-dXZUMp0cTFbOmbzJgSzO2/NE7BhVwmvcUzTDJGMMKuxBO6w99YKDKRKm0PNLFDz26rWm9L
125
-dNS2MVfVuPMTpzT26HQG4pFageq9cAAADBALzRBXdZF8kbSBa5MTUBVTTzgKQm1C772gJ8
126
-T01DJEXZsVtOv7mUC1/m/by6Hk4tPyvDBuGj9hHq4N7dPqGutHb1q5n0ADuoQjRW7BXw5Q
127
-vC2EAD91xexdorIA5BgXU+qltBqzzBVzVtF7+jOZOjfzOlaTX9I5I5veyeTaTxZj1XXUzi
128
-btBNdMEJJp7ifucYmoYAAwE7K+VlWagDEK2y8Mte9y9E+N0uO2j+h85sQt/UIb2iE/vhcg
129
-Bgp6142WnSCQAAABt0ZXN0IGtleSB3aXRob3V0IHBhc3NwaHJhc2UB
130
------END OPENSSH PRIVATE KEY-----
131
-''',
132
-        'public_key': rb'''ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCxoe7pezhxWy4NI0mUwKqg9WCYOAS+IjxN9eYcqpfcmQiojcuy9XsiN/xYJ1O94SrsKS5mEia2xHnYA4RUChTyYNcM2v6cnnBQ/N/VQhpGMN7SVxdbhKUXTWFCwbjBgO6rGyHB6WtoH8vd7TOEPt+NgcXwhsWyoaUUdYTA62V+GF9vEmxMaC4ubgDz+B0QkPnauSoNxmkhcIe0lsLNb1pClZyz88PDnKXCX/d0HuN/HJ+sbPg7dCvOyqFYSyKn3uY6bCXqoIdurxXzH3O7z0P8f5sbmKOrGGKNuNxVRbeVl/D/3uDL0nqsbfUc1qvkfwbJwtMXC4IV6kOZMSk2BAsqh7x48gQ+rhYeEVSi8F3CWs4HJQoqrGt7K9a3mCSlMBHP70u3w6ME7eumoryxlUofewTd17ZEkzdX08l2ZlKzZvwQUrc+xQZ2Uw8z2mfW6Ti4gi0pYGaig7Ke4PwuXpo/C5YAWfeXycsvJZ2uaYRjMdZeJGNAnHLUGLkBscw5aI8= test key without passphrase
133
-''',
134
-        'public_key_data': bytes.fromhex('''
135
-            00 00 00 07 73 73 68 2d 72 73 61
136
-            00 00 00 03 01 00 01
137
-            00 00 01 81 00
138
-            b1 a1 ee e9 7b 38 71 5b 2e 0d 23 49 94 c0 aa a0
139
-            f5 60 98 38 04 be 22 3c 4d f5 e6 1c aa 97 dc 99
140
-            08 a8 8d cb b2 f5 7b 22 37 fc 58 27 53 bd e1 2a
141
-            ec 29 2e 66 12 26 b6 c4 79 d8 03 84 54 0a 14 f2
142
-            60 d7 0c da fe 9c 9e 70 50 fc df d5 42 1a 46 30
143
-            de d2 57 17 5b 84 a5 17 4d 61 42 c1 b8 c1 80 ee
144
-            ab 1b 21 c1 e9 6b 68 1f cb dd ed 33 84 3e df 8d
145
-            81 c5 f0 86 c5 b2 a1 a5 14 75 84 c0 eb 65 7e 18
146
-            5f 6f 12 6c 4c 68 2e 2e 6e 00 f3 f8 1d 10 90 f9
147
-            da b9 2a 0d c6 69 21 70 87 b4 96 c2 cd 6f 5a 42
148
-            95 9c b3 f3 c3 c3 9c a5 c2 5f f7 74 1e e3 7f 1c
149
-            9f ac 6c f8 3b 74 2b ce ca a1 58 4b 22 a7 de e6
150
-            3a 6c 25 ea a0 87 6e af 15 f3 1f 73 bb cf 43 fc
151
-            7f 9b 1b 98 a3 ab 18 62 8d b8 dc 55 45 b7 95 97
152
-            f0 ff de e0 cb d2 7a ac 6d f5 1c d6 ab e4 7f 06
153
-            c9 c2 d3 17 0b 82 15 ea 43 99 31 29 36 04 0b 2a
154
-            87 bc 78 f2 04 3e ae 16 1e 11 54 a2 f0 5d c2 5a
155
-            ce 07 25 0a 2a ac 6b 7b 2b d6 b7 98 24 a5 30 11
156
-            cf ef 4b b7 c3 a3 04 ed eb a6 a2 bc b1 95 4a 1f
157
-            7b 04 dd d7 b6 44 93 37 57 d3 c9 76 66 52 b3 66
158
-            fc 10 52 b7 3e c5 06 76 53 0f 33 da 67 d6 e9 38
159
-            b8 82 2d 29 60 66 a2 83 b2 9e e0 fc 2e 5e 9a 3f
160
-            0b 96 00 59 f7 97 c9 cb 2f 25 9d ae 69 84 63 31
161
-            d6 5e 24 63 40 9c 72 d4 18 b9 01 b1 cc 39 68 8f
162
-'''),
163
-        'expected_signature': bytes.fromhex('''
164
-            00 00 00 07 73 73 68 2d 72 73 61
165
-            00 00 01 80
166
-            a2 10 7c 2e f6 bb 53 a8 74 2a a1 19 99 ad 81 be
167
-            79 9c ed d6 9d 09 4e 6e c5 18 48 33 90 77 99 68
168
-            f7 9e 03 5a cd 4e 18 eb 89 7d 85 a2 ee ae 4a 92
169
-            f6 6f ce b9 fe 86 7f 2a 6b 31 da 6e 1a fe a2 a5
170
-            88 b8 44 7f a1 76 73 b3 ec 75 b5 d0 a6 b9 15 97
171
-            65 09 13 7d 94 21 d1 fb 5d 0f 8b 23 04 77 c2 c3
172
-            55 22 b1 a0 09 8a f5 38 2a d6 7f 1b 87 29 a0 25
173
-            d3 25 6f cb 64 61 07 98 dc 14 c5 84 f8 92 24 5e
174
-            50 11 6b 49 e5 f0 cc 29 cb 29 a9 19 d8 a7 71 1f
175
-            91 0b 05 b1 01 4b c2 5f 00 a5 b6 21 bf f8 2c 9d
176
-            67 9b 47 3b 0a 49 6b 79 2d fc 1d ec 0c b0 e5 27
177
-            22 d5 a9 f8 d3 c3 f9 df 48 68 e9 fb ef 3c dc 26
178
-            bf cf ea 29 43 01 a6 e3 c5 51 95 f4 66 6d 8a 55
179
-            e2 47 ec e8 30 45 4c ae 47 e7 c9 a4 21 8b 64 ba
180
-            b6 88 f6 21 f8 73 b9 cb 11 a1 78 75 92 c6 5a e5
181
-            64 fe ed 42 d9 95 99 e6 2b 6f 3c 16 3c 28 74 a4
182
-            72 2f 0d 3f 2c 33 67 aa 35 19 8e e7 b5 11 2f b3
183
-            f7 6a c5 02 e2 6f a3 42 e3 62 19 99 03 ea a5 20
184
-            e7 a1 e3 bc c8 06 a3 b5 7c d6 76 5d df 6f 60 46
185
-            83 2a 08 00 d6 d3 d9 a4 c1 41 8c f8 60 56 45 81
186
-            da 3b a2 16 1f 9e 4e 75 83 17 da c3 53 c3 3e 19
187
-            a4 1b bc d2 29 b8 78 61 2b 78 e6 b1 52 b0 d5 ec
188
-            de 69 2c 48 62 d9 fd d1 9b 6b b0 49 db d3 ff 38
189
-            e7 10 d9 2d ce 9f 0d 5e 09 7b 37 d2 7b c3 bf ce
190
-'''),
191
-        'derived_passphrase': rb'ohB8Lva7U6h0KqEZma2Bvnmc7dadCU5uxRhIM5B3mWj3ngNazU4Y64l9haLurkqS9m/Ouf6GfyprMdpuGv6ipYi4RH+hdnOz7HW10Ka5FZdlCRN9lCHR+10PiyMEd8LDVSKxoAmK9Tgq1n8bhymgJdMlb8tkYQeY3BTFhPiSJF5QEWtJ5fDMKcspqRnYp3EfkQsFsQFLwl8ApbYhv/gsnWebRzsKSWt5Lfwd7Ayw5Sci1an408P530ho6fvvPNwmv8/qKUMBpuPFUZX0Zm2KVeJH7OgwRUyuR+fJpCGLZLq2iPYh+HO5yxGheHWSxlrlZP7tQtmVmeYrbzwWPCh0pHIvDT8sM2eqNRmO57URL7P3asUC4m+jQuNiGZkD6qUg56HjvMgGo7V81nZd329gRoMqCADW09mkwUGM+GBWRYHaO6IWH55OdYMX2sNTwz4ZpBu80im4eGEreOaxUrDV7N5pLEhi2f3Rm2uwSdvT/zjnENktzp8NXgl7N9J7w7/O',
192
-    },
193
-}
194
-
195
-UNSUITABLE = {
196
-    'dsa1024': {
197
-        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
198
-b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABsQAAAAdzc2gtZH
199
-NzAAAAgQC7KAZXqBGNVLBQPrcMYAoNW54BhD8aIhe7BDWYzJcsaMt72VKSkguZ8+XR7nRa
200
-0C/ZsBi+uJp0dpxy9ZMTOWX4u5YPMeQcXEdGExZIfimGqSOAsy6fCld2IfJZJZExcCmhe9
201
-Ssjsd3YSAPJRluOXFQc95MZoR5hMwlIDD8QzrE7QAAABUA99nOZOgd7aHMVGoXpUEBcn7H
202
-ossAAACALr2Ag3hxM3rKdxzVUw8fX0VVPXO+3+Kr8hGe0Kc/7NwVaBVL1GQ8fenBuWynpA
203
-UbH0wo3h1wkB/8hX6p+S8cnu5rIBlUuVNwLw/bIYohK98LfqTYK/V+g6KD+8m34wvEiXZm
204
-qywY54n2bksch1Nqvj/tNpLzExSx/XS0kSM1aigAAACAbQNRPcVEuGDrEcf+xg5tgAejPX
205
-BPXr/Jss+Chk64km3mirMYjAWyWYtVcgT+7hOYxtYRin8LyMLqKRmqa0Q5UrvDfChgLhvs
206
-G9YSb/Mpw5qm8PiHSafwhkaz/te3+8hKogqoe7sd+tCF06IpJr5k70ACiNtRGqssNF8Elr
207
-l1efYAAAH4swlfVrMJX1YAAAAHc3NoLWRzcwAAAIEAuygGV6gRjVSwUD63DGAKDVueAYQ/
208
-GiIXuwQ1mMyXLGjLe9lSkpILmfPl0e50WtAv2bAYvriadHaccvWTEzll+LuWDzHkHFxHRh
209
-MWSH4phqkjgLMunwpXdiHyWSWRMXApoXvUrI7Hd2EgDyUZbjlxUHPeTGaEeYTMJSAw/EM6
210
-xO0AAAAVAPfZzmToHe2hzFRqF6VBAXJ+x6LLAAAAgC69gIN4cTN6yncc1VMPH19FVT1zvt
211
-/iq/IRntCnP+zcFWgVS9RkPH3pwblsp6QFGx9MKN4dcJAf/IV+qfkvHJ7uayAZVLlTcC8P
212
-2yGKISvfC36k2Cv1foOig/vJt+MLxIl2ZqssGOeJ9m5LHIdTar4/7TaS8xMUsf10tJEjNW
213
-ooAAAAgG0DUT3FRLhg6xHH/sYObYAHoz1wT16/ybLPgoZOuJJt5oqzGIwFslmLVXIE/u4T
214
-mMbWEYp/C8jC6ikZqmtEOVK7w3woYC4b7BvWEm/zKcOapvD4h0mn8IZGs/7Xt/vISqIKqH
215
-u7HfrQhdOiKSa+ZO9AAojbURqrLDRfBJa5dXn2AAAAFQDJHfenj4EJ9WkehpdJatPBlqCW
216
-0gAAABt0ZXN0IGtleSB3aXRob3V0IHBhc3NwaHJhc2UBAgMEBQYH
217
------END OPENSSH PRIVATE KEY-----
218
-''',
219
-        'public_key': rb'''ssh-dss AAAAB3NzaC1kc3MAAACBALsoBleoEY1UsFA+twxgCg1bngGEPxoiF7sENZjMlyxoy3vZUpKSC5nz5dHudFrQL9mwGL64mnR2nHL1kxM5Zfi7lg8x5BxcR0YTFkh+KYapI4CzLp8KV3Yh8lklkTFwKaF71KyOx3dhIA8lGW45cVBz3kxmhHmEzCUgMPxDOsTtAAAAFQD32c5k6B3tocxUahelQQFyfseiywAAAIAuvYCDeHEzesp3HNVTDx9fRVU9c77f4qvyEZ7Qpz/s3BVoFUvUZDx96cG5bKekBRsfTCjeHXCQH/yFfqn5Lxye7msgGVS5U3AvD9shiiEr3wt+pNgr9X6DooP7ybfjC8SJdmarLBjnifZuSxyHU2q+P+02kvMTFLH9dLSRIzVqKAAAAIBtA1E9xUS4YOsRx/7GDm2AB6M9cE9ev8myz4KGTriSbeaKsxiMBbJZi1VyBP7uE5jG1hGKfwvIwuopGaprRDlSu8N8KGAuG+wb1hJv8ynDmqbw+IdJp/CGRrP+17f7yEqiCqh7ux360IXToikmvmTvQAKI21Eaqyw0XwSWuXV59g== test key without passphrase
220
-''',
221
-        'public_key_data': bytes.fromhex('''
222
-            00 00 00 07 73 73 68 2d 64 73 73
223
-            00 00 00 81 00
224
-            bb 28 06 57 a8 11 8d 54 b0 50 3e b7 0c 60 0a 0d
225
-            5b 9e 01 84 3f 1a 22 17 bb 04 35 98 cc 97 2c 68
226
-            cb 7b d9 52 92 92 0b 99 f3 e5 d1 ee 74 5a d0 2f
227
-            d9 b0 18 be b8 9a 74 76 9c 72 f5 93 13 39 65 f8
228
-            bb 96 0f 31 e4 1c 5c 47 46 13 16 48 7e 29 86 a9
229
-            23 80 b3 2e 9f 0a 57 76 21 f2 59 25 91 31 70 29
230
-            a1 7b d4 ac 8e c7 77 61 20 0f 25 19 6e 39 71 50
231
-            73 de 4c 66 84 79 84 cc 25 20 30 fc 43 3a c4 ed
232
-            00 00 00 15 00 f7 d9 ce 64
233
-            e8 1d ed a1 cc 54 6a 17 a5 41 01 72 7e c7 a2 cb
234
-            00 00 00 80
235
-            2e bd 80 83 78 71 33 7a ca 77 1c d5 53 0f 1f 5f
236
-            45 55 3d 73 be df e2 ab f2 11 9e d0 a7 3f ec dc
237
-            15 68 15 4b d4 64 3c 7d e9 c1 b9 6c a7 a4 05 1b
238
-            1f 4c 28 de 1d 70 90 1f fc 85 7e a9 f9 2f 1c 9e
239
-            ee 6b 20 19 54 b9 53 70 2f 0f db 21 8a 21 2b df
240
-            0b 7e a4 d8 2b f5 7e 83 a2 83 fb c9 b7 e3 0b c4
241
-            89 76 66 ab 2c 18 e7 89 f6 6e 4b 1c 87 53 6a be
242
-            3f ed 36 92 f3 13 14 b1 fd 74 b4 91 23 35 6a 28
243
-            00 00 00 80
244
-            6d 03 51 3d c5 44 b8 60 eb 11 c7 fe c6 0e 6d 80
245
-            07 a3 3d 70 4f 5e bf c9 b2 cf 82 86 4e b8 92 6d
246
-            e6 8a b3 18 8c 05 b2 59 8b 55 72 04 fe ee 13 98
247
-            c6 d6 11 8a 7f 0b c8 c2 ea 29 19 aa 6b 44 39 52
248
-            bb c3 7c 28 60 2e 1b ec 1b d6 12 6f f3 29 c3 9a
249
-            a6 f0 f8 87 49 a7 f0 86 46 b3 fe d7 b7 fb c8 4a
250
-            a2 0a a8 7b bb 1d fa d0 85 d3 a2 29 26 be 64 ef
251
-            40 02 88 db 51 1a ab 2c 34 5f 04 96 b9 75 79 f6
252
-'''),
253
-        'expected_signature': None,
254
-        'derived_passphrase': None,
255
-    },
256
-    'ecdsa256': {
257
-        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
258
-b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
259
-1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTLbU0zDwsk2Dvp+VYIrsNVf5gWwz2S
260
-3SZ8TbxiQRkpnGSVqyIoHJOJc+NQItAa7xlJ/8Z6gfz57Z3apUkaMJm6AAAAuKeY+YinmP
261
-mIAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMttTTMPCyTYO+n5
262
-Vgiuw1V/mBbDPZLdJnxNvGJBGSmcZJWrIigck4lz41Ai0BrvGUn/xnqB/PntndqlSRowmb
263
-oAAAAhAKIl/3n0pKVIxpZkXTGtii782Qr4yIcvHdpxjO/QsIqKAAAAG3Rlc3Qga2V5IHdp
264
-dGhvdXQgcGFzc3BocmFzZQECAwQ=
265
------END OPENSSH PRIVATE KEY-----
266
-''',
267
-        'public_key': rb'''ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMttTTMPCyTYO+n5Vgiuw1V/mBbDPZLdJnxNvGJBGSmcZJWrIigck4lz41Ai0BrvGUn/xnqB/PntndqlSRowmbo= test key without passphrase
268
-''',
269
-        'public_key_data': bytes.fromhex('''
270
-            00 00 00 13 65 63 64 73 61 2d 73 68 61 32 2d 6e
271
-            69 73 74 70 32 35 36
272
-            00 00 00 08 6e 69 73 74 70 32 35 36
273
-            00 00 00 41 04
274
-            cb 6d 4d 33 0f 0b 24 d8 3b e9 f9 56 08 ae c3 55
275
-            7f 98 16 c3 3d 92 dd 26 7c 4d bc 62 41 19 29 9c
276
-            64 95 ab 22 28 1c 93 89 73 e3 50 22 d0 1a ef 19
277
-            49 ff c6 7a 81 fc f9 ed 9d da a5 49 1a 30 99 ba
278
-'''),
279
-        'expected_signature': None,
280
-        'derived_passphrase': None,
281
-    },
282
-    'ecdsa384': {
283
-        'private_key': rb'''-----BEGIN OPENSSH PRIVATE KEY-----
284
-b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS
285
-1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQSgkOjkAvq7v5vHuj3KBL4/EAWcn5hZ
286
-DyKcbyV0eBMGFq7hKXQlZqIahLVqeMR0QqmkxNJ2rly2VHcXneq3vZ+9fIsWCOdYk5WP3N
287
-ZPzv911Xn7wbEkC7QndD5zKlm4pBUAAADomhj+IZoY/iEAAAATZWNkc2Etc2hhMi1uaXN0
288
-cDM4NAAAAAhuaXN0cDM4NAAAAGEEoJDo5AL6u7+bx7o9ygS+PxAFnJ+YWQ8inG8ldHgTBh
289
-au4Sl0JWaiGoS1anjEdEKppMTSdq5ctlR3F53qt72fvXyLFgjnWJOVj9zWT87/ddV5+8Gx
290
-JAu0J3Q+cypZuKQVAAAAMQD5sTy8p+B1cn/DhOmXquui1BcxvASqzzevkBlbQoBa73y04B
291
-2OdqVOVRkwZWRROz0AAAAbdGVzdCBrZXkgd2l0aG91dCBwYXNzcGhyYXNlAQIDBA==
292
------END OPENSSH PRIVATE KEY-----
293
-''',
294
-        'public_key': rb'''ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBKCQ6OQC+ru/m8e6PcoEvj8QBZyfmFkPIpxvJXR4EwYWruEpdCVmohqEtWp4xHRCqaTE0nauXLZUdxed6re9n718ixYI51iTlY/c1k/O/3XVefvBsSQLtCd0PnMqWbikFQ== test key without passphrase
295
-''',
296
-        'public_key_data': bytes.fromhex('''
297
-            00 00 00 13
298
-            65 63 64 73 61 2d 73 68 61 32 2d 6e 69 73 74 70
299
-            33 38 34
300
-            00 00 00 08 6e 69 73 74 70 33 38 34
301
-            00 00 00 61 04
302
-            a0 90 e8 e4 02 fa bb bf 9b c7 ba 3d ca 04 be 3f
303
-            10 05 9c 9f 98 59 0f 22 9c 6f 25 74 78 13 06 16
304
-            ae e1 29 74 25 66 a2 1a 84 b5 6a 78 c4 74 42 a9
305
-            a4 c4 d2 76 ae 5c b6 54 77 17 9d ea b7 bd 9f bd
306
-            7c 8b 16 08 e7 58 93 95 8f dc d6 4f ce ff 75 d5
307
-            79 fb c1 b1 24 0b b4 27 74 3e 73 2a 59 b8 a4 15
308
-'''),
309
-        'expected_signature': None,
310
-        'derived_passphrase': None,
311
-    },
312
-}
313
-
314
-def test_client_uint32():
315
-    uint32 = ssh_agent_client.SSHAgentClient.uint32
316
-    assert uint32(16777216) == b'\x01\x00\x00\x00'
317
-
318
-@pytest.mark.parametrize(['value', 'exc_type', 'exc_pattern'], [
319
-    (10000000000000000, OverflowError, 'int too big to convert'),
320
-    (-1, OverflowError, "can't convert negative int to unsigned"),
321
-])
322
-def test_client_uint32_exceptions(value, exc_type, exc_pattern):
323
-    uint32 = ssh_agent_client.SSHAgentClient.uint32
324
-    with pytest.raises(exc_type, match=exc_pattern):
325
-        uint32(value)
326
-
327
-@pytest.mark.parametrize(['input', 'expected'], [
328
-    (b'ssh-rsa', b'\x00\x00\x00\x07ssh-rsa'),
329
-    (b'ssh-ed25519', b'\x00\x00\x00\x0bssh-ed25519'),
330
-    (
331
-        ssh_agent_client.SSHAgentClient.string(b'ssh-ed25519'),
332
-        b'\x00\x00\x00\x0f\x00\x00\x00\x0bssh-ed25519',
333
-    ),
334
-])
335
-def test_client_string(input, expected):
336
-    string = ssh_agent_client.SSHAgentClient.string
337
-    assert bytes(string(input)) == expected
338
-
339
-@pytest.mark.parametrize(['input', 'exc_type', 'exc_pattern'], [
340
-    ('some string', TypeError, 'invalid payload type'),
341
-])
342
-def test_client_string_exceptions(input, exc_type, exc_pattern):
343
-    string = ssh_agent_client.SSHAgentClient.string
344
-    with pytest.raises(exc_type, match=exc_pattern):
345
-        string(input)
346
-
347
-@pytest.mark.parametrize(['input', 'expected'], [
348
-    (b'\x00\x00\x00\x07ssh-rsa', b'ssh-rsa'),
349
-    (
350
-        ssh_agent_client.SSHAgentClient.string(b'ssh-ed25519'),
351
-        b'ssh-ed25519',
352
-    ),
353
-])
354
-def test_client_unstring(input, expected):
355
-    unstring = ssh_agent_client.SSHAgentClient.unstring
356
-    unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix
357
-    assert bytes(unstring(input)) == expected
358
-    assert tuple(bytes(x) for x in unstring_prefix(input)) == (expected, b'')
359
-
360
-@pytest.mark.parametrize(
361
-    ['input', 'exc_type', 'exc_pattern', 'has_trailer', 'parts'], [
362
-        (b'ssh', ValueError, 'malformed SSH byte string', False, None),
363
-        (
364
-            b'\x00\x00\x00\x08ssh-rsa',
365
-            ValueError, 'malformed SSH byte string',
366
-            False, None,
367
-        ),
368
-        (
369
-            b'\x00\x00\x00\x04XXX trailing text',
370
-            ValueError, 'malformed SSH byte string',
371
-            True, (b'XXX ', b'trailing text'),
372
-        ),
373
-])
374
-def test_client_unstring_exceptions(input, exc_type, exc_pattern,
375
-                                    has_trailer, parts):
376
-    unstring = ssh_agent_client.SSHAgentClient.unstring
377
-    unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix
378
-    with pytest.raises(exc_type, match=exc_pattern):
379
-        unstring(input)
380
-    if has_trailer:
381
-        assert tuple(bytes(x) for x in unstring_prefix(input)) == parts
382
-    else:
383
-        with pytest.raises(exc_type, match=exc_pattern):
384
-            unstring_prefix(input)
385
-
386
-def test_key_decoding():
387
-    public_key = SUPPORTED['ed25519']['public_key']
388
-    public_key_data = SUPPORTED['ed25519']['public_key_data']
389
-    keydata = base64.b64decode(public_key.split(None, 2)[1])
390
-    assert (
391
-        keydata == public_key_data
392
-    ), "recorded public key data doesn't match"
393
-
394
-@pytest.mark.parametrize(['keytype', 'data_dict'], list(SUPPORTED.items()))
395
-def test_sign_data_via_agent(keytype, data_dict):
396
-    private_key = data_dict['private_key']
397
-    try:
398
-        result = subprocess.run(['ssh-add', '-t', '30', '-q', '-'],
399
-                                input=private_key, check=True,
400
-                                capture_output=True)
401
-    except subprocess.CalledProcessError as e:
402
-        pytest.skip(
403
-            f"uploading test key: {e!r}, stdout={e.stdout!r}, "
404
-            f"stderr={e.stderr!r}"
405
-        )
406
-    else:
407
-        try:
408
-            client = ssh_agent_client.SSHAgentClient()
409
-        except OSError:  # pragma: no cover
410
-            pytest.skip('communication error with the SSH agent')
411
-    with client:
412
-        key_comment_pairs = {bytes(k): bytes(c)
413
-                             for k, c in client.list_keys()}
414
-        public_key_data = data_dict['public_key_data']
415
-        expected_signature = data_dict['expected_signature']
416
-        derived_passphrase = data_dict['derived_passphrase']
417
-        if public_key_data not in key_comment_pairs:  # pragma: no cover
418
-            pytest.skip('prerequisite SSH key not loaded')
419
-        signature = bytes(client.sign(
420
-            payload=derivepassphrase.Vault._UUID, key=public_key_data))
421
-        assert signature == expected_signature, 'SSH signature mismatch'
422
-        signature2 = bytes(client.sign(
423
-            payload=derivepassphrase.Vault._UUID, key=public_key_data))
424
-        assert signature2 == expected_signature, 'SSH signature mismatch'
425
-        assert (
426
-            derivepassphrase.Vault.phrase_from_key(public_key_data) ==
427
-            derived_passphrase
428
-        ), 'SSH signature mismatch'
429
-
430
-@pytest.mark.parametrize(['keytype', 'data_dict'], list(UNSUITABLE.items()))
431
-def test_sign_data_via_agent_unsupported(keytype, data_dict):
432
-    private_key = data_dict['private_key']
433
-    try:
434
-        result = subprocess.run(['ssh-add', '-t', '30', '-q', '-'],
435
-                                input=private_key, check=True,
436
-                                capture_output=True)
437
-    except subprocess.CalledProcessError as e:  # pragma: no cover
438
-        pytest.skip(
439
-            f"uploading test key: {e!r}, stdout={e.stdout!r}, "
440
-            f"stderr={e.stderr!r}"
441
-        )
442
-    else:
443
-        try:
444
-            client = ssh_agent_client.SSHAgentClient()
445
-        except OSError:  # pragma: no cover
446
-            pytest.skip('communication error with the SSH agent')
447
-    with client:
448
-        key_comment_pairs = {bytes(k): bytes(c)
449
-                             for k, c in client.list_keys()}
450
-        public_key_data = data_dict['public_key_data']
451
-        expected_signature = data_dict['expected_signature']
452
-        if public_key_data not in key_comment_pairs:  # pragma: no cover
453
-            pytest.skip('prerequisite SSH key not loaded')
454
-        signature = bytes(client.sign(
455
-            payload=derivepassphrase.Vault._UUID, key=public_key_data))
456
-        signature2 = bytes(client.sign(
457
-            payload=derivepassphrase.Vault._UUID, key=public_key_data))
458
-        assert signature != signature2, 'SSH signature repeatable?!'
459
-        with pytest.raises(ValueError, match='unsuitable SSH key'):
460
-            derivepassphrase.Vault.phrase_from_key(public_key_data)
461
-
462
-@pytest.mark.parametrize(['this_data', 'all_data'],
463
-                         [(v, tuple(SUPPORTED.values()))
464
-                          for v in SUPPORTED.values()])
465
-def test_ssh_key_selector(monkeypatch, this_data, all_data):
466
-    successfully_uploaded_keys: list[bytes] = []
467
-    for data in all_data:
468
-        private_key = data['private_key']
469
-        try:
470
-            result = subprocess.run(['ssh-add', '-t', '60', '-q', '-'],
471
-                                    input=private_key, check=True,
472
-                                    capture_output=True)
473
-        except subprocess.CalledProcessError as e:
474
-            if data == this_data:
475
-                pytest.skip(
476
-                    f"uploading non-optional test key: {e!r}, "
477
-                    f"stdout={e.stdout!r}, stderr={e.stderr!r}"
478
-                )
479
-        else:
480
-            successfully_uploaded_keys.append(data['public_key_data'])
481
-    index = 1 + successfully_uploaded_keys.index(this_data['public_key_data'])
482
-    b64_key = base64.standard_b64encode(
483
-        this_data['public_key_data']).decode('ASCII')
484
-    n = len(successfully_uploaded_keys)
485
-    text = (f'Your selection? (1-{n}, leave empty to abort): {index}\n'
486
-            if n > 1 else 'Use this key? yes\n')
487
-
488
-    @click.command()
489
-    def driver():
490
-        key = derivepassphrase.cli._select_ssh_key()
491
-        click.echo(base64.standard_b64encode(key).decode('ASCII'))
492
-
493
-    runner = click.testing.CliRunner(mix_stderr=True)
494
-    result = runner.invoke(driver, [],
495
-                           input=(f'{index}\n' if n > 1 else f'yes\n'),
496
-                           catch_exceptions=True)
497
-    assert result.exit_code == 0, 'driver program failed?!'
498
-    assert result.stdout.startswith('Suitable SSH keys:\n'), (
499
-        'missing expected output'
500
-    )
501
-    assert text in result.stdout, 'missing expected output'
502
-    assert result.stdout.endswith(f'\n{b64_key}\n'), 'missing expected output'
503
-
504
-@pytest.mark.parametrize(['conn_hint'], [('none',), ('socket',), ('client',)])
505
-def test_get_suitable_ssh_keys(conn_hint):
506
-    hint: ssh_agent_client.SSHAgentClient | socket.socket | None
507
-    match conn_hint:
508
-        case 'client':
509
-            hint = ssh_agent_client.SSHAgentClient()
510
-        case 'socket':
511
-            hint = socket.socket(family=socket.AF_UNIX)
512
-            hint.connect(os.environ['SSH_AUTH_SOCK'])
513
-        case _:
514
-            assert conn_hint == 'none'
515
-            hint = None
516
-    exception: type[Exception] | None = None
517
-    try:
518
-        list(derivepassphrase.cli._get_suitable_ssh_keys(hint))
519
-    except RuntimeError:  # pragma: no cover
520
-        pass
521
-    except Exception as e:  # pragma: no cover
522
-        exception = e
523
-    finally:
524
-        assert exception == None, 'exception querying suitable SSH keys'
525
-
526
-def test_constructor_no_running_agent(monkeypatch):
527
-    monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
528
-    sock = socket.socket(family=socket.AF_UNIX)
529
-    with pytest.raises(RuntimeError, match='missing SSH_AUTH_SOCK'):
530
-        ssh_agent_client.SSHAgentClient(socket=sock)
531
-
532
-def test_constructor_bad_running_agent(monkeypatch):
533
-    monkeypatch.setenv('SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~')
534
-    sock = socket.socket(family=socket.AF_UNIX)
535
-    with pytest.raises(RuntimeError, match='unusable SSH_AUTH_SOCK'):
536
-        ssh_agent_client.SSHAgentClient(socket=sock)
537
-
538
-@pytest.mark.parametrize(['response'], [
539
-    (b'\x00\x00',),
540
-    (b'\x00\x00\x00\x1f some bytes missing',),
541
-])
542
-def test_truncated_server_response(monkeypatch, response):
543
-    client = ssh_agent_client.SSHAgentClient()
544
-    response_stream = io.BytesIO(response)
545
-    class PseudoSocket(object):
546
-        pass
547
-    pseudo_socket = PseudoSocket()
548
-    pseudo_socket.sendall = lambda *a, **kw: None
549
-    pseudo_socket.recv = response_stream.read
550
-    monkeypatch.setattr(client, '_connection', pseudo_socket)
551
-    with pytest.raises(EOFError):
552
-        client.request(255, b'')
553
-
554
-@pytest.mark.parametrize(
555
-    ['response_code', 'response', 'exc_type', 'exc_pattern'],
556
-    [
557
-        (255, b'', RuntimeError, 'error return from SSH agent:'),
558
-        (12, b'\x00\x00\x00\x01', EOFError, 'truncated response'),
559
-        (12, b'\x00\x00\x00\x00abc', RuntimeError, 'overlong response'),
560
-    ]
561
-)
562
-def test_list_keys_error_responses(monkeypatch, response_code, response,
563
-                                   exc_type, exc_pattern):
564
-    client = ssh_agent_client.SSHAgentClient()
565
-    monkeypatch.setattr(client, 'request',
566
-                        lambda *a, **kw: (response_code, response))
567
-    with pytest.raises(exc_type, match=exc_pattern):
568
-        client.list_keys()
569
-
570
-@pytest.mark.parametrize(
571
-    ['key', 'check', 'response', 'exc_type', 'exc_pattern'],
572
-    [
573
-        (
574
-            b'invalid-key',
575
-            True,
576
-            (255, b''),
577
-            RuntimeError,
578
-            'target SSH key not loaded into agent',
579
-        ),
580
-        (
581
-            SUPPORTED['ed25519']['public_key_data'],
582
-            True,
583
-            (255, b''),
584
-            RuntimeError,
585
-            'signing data failed:',
586
-        )
587
-    ]
588
-)
589
-def test_sign_error_responses(monkeypatch, key, check, response, exc_type,
590
-                              exc_pattern):
591
-    client = ssh_agent_client.SSHAgentClient()
592
-    monkeypatch.setattr(client, 'request', lambda a, b: response)
593
-    KeyCommentPair = ssh_agent_client.types.KeyCommentPair
594
-    loaded_keys = [KeyCommentPair(v['public_key_data'], b'no comment')
595
-                   for v in SUPPORTED.values()]
596
-    monkeypatch.setattr(client, 'list_keys', lambda: loaded_keys)
597
-    with pytest.raises(exc_type, match=exc_pattern):
598
-        client.sign(key, b'abc', check_if_key_loaded=check)
... ...
@@ -4,13 +4,12 @@
4 4
 
5 5
 """Test sequin.Sequin."""
6 6
 
7
-import pytest
7
+import collections
8 8
 
9
+import pytest
9 10
 import sequin
10 11
 
11
-import collections
12
-
13
-benum = sequin.Sequin._big_endian_number
12
+class TestStaticFunctionality:
14 13
 
15 14
     @pytest.mark.parametrize(['sequence', 'base', 'expected'], [
16 15
         ([1, 2, 3, 4, 5, 6], 10, 123456),
... ...
@@ -19,17 +18,24 @@ benum = sequin.Sequin._big_endian_number
19 18
         ([1, 0, 0, 1, 0, 0, 0, 0], 2, 144),
20 19
         ([1, 7, 5, 5], 8, 0o1755),
21 20
     ])
22
-def test_big_endian_number(sequence, base, expected):
23
-    assert benum(sequence, base=base) == expected
21
+    def test_200_big_endian_number(self, sequence, base, expected):
22
+        assert (
23
+            sequin.Sequin._big_endian_number(sequence, base=base)
24
+        ) == expected
24 25
 
25
-@pytest.mark.parametrize(['exc_type', 'exc_pattern', 'sequence' , 'base'], [
26
+    @pytest.mark.parametrize(
27
+        ['exc_type', 'exc_pattern', 'sequence' , 'base'], [
26 28
             (ValueError, 'invalid base 3 digit:', [-1], 3),
27 29
             (ValueError, 'invalid base:', [0], 1),
28 30
             (TypeError, 'not an integer:', [0.0, 1.0, 0.0, 1.0], 2),
29
-])
30
-def test_big_endian_number_exceptions(exc_type, exc_pattern, sequence, base):
31
+        ]
32
+    )
33
+    def test_300_big_endian_number_exceptions(self, exc_type, exc_pattern,
34
+                                              sequence, base):
31 35
         with pytest.raises(exc_type, match=exc_pattern):
32
-        benum(sequence, base=base)
36
+            sequin.Sequin._big_endian_number(sequence, base=base)
37
+
38
+class TestSequin:
33 39
 
34 40
     @pytest.mark.parametrize(['sequence', 'is_bitstring', 'expected'], [
35 41
         ([1, 0, 0, 1, 0, 1], False, [0, 0, 0, 0, 0, 0, 0, 1,
... ...
@@ -44,38 +50,11 @@ def test_big_endian_number_exceptions(exc_type, exc_pattern, sequence, base):
44 50
         ('OK', False, [0, 1, 0, 0, 1, 1, 1, 1,
45 51
                        0, 1, 0, 0, 1, 0, 1, 1]),
46 52
     ])
47
-def test_constructor(sequence, is_bitstring, expected):
53
+    def test_200_constructor(self, sequence, is_bitstring, expected):
48 54
         seq = sequin.Sequin(sequence, is_bitstring=is_bitstring)
49 55
         assert seq.bases == {2: collections.deque(expected)}
50 56
 
51
-@pytest.mark.parametrize(
52
-    ['sequence', 'is_bitstring', 'exc_type', 'exc_pattern'],
53
-    [
54
-        ([0, 1, 2, 3, 4, 5, 6, 7], True,
55
-         ValueError, 'sequence item out of range'),
56
-        (u'こんにちは。', False,
57
-         ValueError, 'sequence item out of range'),
58
-    ]
59
-)
60
-def test_constructor_exceptions(sequence, is_bitstring, exc_type, exc_pattern):
61
-    with pytest.raises(exc_type, match=exc_pattern):
62
-        sequin.Sequin(sequence, is_bitstring=is_bitstring)
63
-
64
-def test_shifting():
65
-    seq = sequin.Sequin([1, 0, 1, 0, 0, 1, 0, 0, 0, 1], is_bitstring=True)
66
-    assert seq.bases == {2: collections.deque([1, 0, 1, 0, 0, 1, 0, 0, 0, 1])}
67
-    #
68
-    assert seq._all_or_nothing_shift(3) == (1, 0, 1)
69
-    assert seq._all_or_nothing_shift(3) == (0, 0, 1)
70
-    assert seq.bases[2] == collections.deque([0, 0, 0, 1])
71
-    #
72
-    assert seq._all_or_nothing_shift(5) == ()
73
-    assert seq.bases[2] == collections.deque([0, 0, 0, 1])
74
-    #
75
-    assert seq._all_or_nothing_shift(4), (0, 0, 0, 1)
76
-    assert 2 not in seq.bases
77
-
78
-def test_generating():
57
+    def test_201_generating(self):
79 58
         seq = sequin.Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
80 59
                             is_bitstring=True)
81 60
         assert seq.generate(1) == 0
... ...
@@ -91,7 +70,7 @@ def test_generating():
91 70
         with pytest.raises(ValueError, match='invalid target range'):
92 71
             seq.generate(0)
93 72
 
94
-def test_internal_generating():
73
+    def test_210_internal_generating(self):
95 74
         seq = sequin.Sequin([1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1],
96 75
                             is_bitstring=True)
97 76
         assert seq._generate_inner(5) == 3
... ...
@@ -106,3 +85,31 @@ def test_internal_generating():
106 85
             seq._generate_inner(0)
107 86
         with pytest.raises(ValueError, match='invalid base:'):
108 87
             seq._generate_inner(16, base=1)
88
+
89
+    def test_211_shifting(self):
90
+        seq = sequin.Sequin([1, 0, 1, 0, 0, 1, 0, 0, 0, 1], is_bitstring=True)
91
+        assert seq.bases == {2: collections.deque([1, 0, 1, 0, 0, 1, 0, 0, 0, 1])}
92
+        #
93
+        assert seq._all_or_nothing_shift(3) == (1, 0, 1)
94
+        assert seq._all_or_nothing_shift(3) == (0, 0, 1)
95
+        assert seq.bases[2] == collections.deque([0, 0, 0, 1])
96
+        #
97
+        assert seq._all_or_nothing_shift(5) == ()
98
+        assert seq.bases[2] == collections.deque([0, 0, 0, 1])
99
+        #
100
+        assert seq._all_or_nothing_shift(4), (0, 0, 0, 1)
101
+        assert 2 not in seq.bases
102
+
103
+    @pytest.mark.parametrize(
104
+        ['sequence', 'is_bitstring', 'exc_type', 'exc_pattern'],
105
+        [
106
+            ([0, 1, 2, 3, 4, 5, 6, 7], True,
107
+             ValueError, 'sequence item out of range'),
108
+            (u'こんにちは。', False,
109
+             ValueError, 'sequence item out of range'),
110
+        ]
111
+    )
112
+    def test_300_constructor_exceptions(self, sequence, is_bitstring,
113
+                                        exc_type, exc_pattern):
114
+        with pytest.raises(exc_type, match=exc_pattern):
115
+            sequin.Sequin(sequence, is_bitstring=is_bitstring)
... ...
@@ -0,0 +1,316 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Test OpenSSH key loading and signing."""
6
+
7
+from __future__ import annotations
8
+
9
+import base64
10
+import errno
11
+import io
12
+import os
13
+import socket
14
+import subprocess
15
+from typing import Any
16
+
17
+import click
18
+import click.testing
19
+import derivepassphrase
20
+import derivepassphrase.cli
21
+import pytest
22
+import ssh_agent_client
23
+import tests
24
+
25
+class TestStaticFunctionality:
26
+
27
+    @pytest.mark.parametrize(['public_key', 'public_key_data'],
28
+                             [(val['public_key'], val['public_key_data'])
29
+                              for val in tests.SUPPORTED_KEYS.values()])
30
+    def test_100_key_decoding(self, public_key, public_key_data):
31
+        keydata = base64.b64decode(public_key.split(None, 2)[1])
32
+        assert (
33
+            keydata == public_key_data
34
+        ), "recorded public key data doesn't match"
35
+
36
+    def test_200_constructor_no_running_agent(self, monkeypatch):
37
+        monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
38
+        sock = socket.socket(family=socket.AF_UNIX)
39
+        with pytest.raises(RuntimeError,
40
+                           match='SSH_AUTH_SOCK environment variable'):
41
+            ssh_agent_client.SSHAgentClient(socket=sock)
42
+
43
+    @pytest.mark.parametrize(['input', 'expected'], [
44
+        (16777216, b'\x01\x00\x00\x00'),
45
+    ])
46
+    def test_210_uint32(self, input, expected):
47
+        uint32 = ssh_agent_client.SSHAgentClient.uint32
48
+        assert uint32(input) == expected
49
+
50
+    @pytest.mark.parametrize(['input', 'expected'], [
51
+        (b'ssh-rsa', b'\x00\x00\x00\x07ssh-rsa'),
52
+        (b'ssh-ed25519', b'\x00\x00\x00\x0bssh-ed25519'),
53
+        (
54
+            ssh_agent_client.SSHAgentClient.string(b'ssh-ed25519'),
55
+            b'\x00\x00\x00\x0f\x00\x00\x00\x0bssh-ed25519',
56
+        ),
57
+    ])
58
+    def test_211_string(self, input, expected):
59
+        string = ssh_agent_client.SSHAgentClient.string
60
+        assert bytes(string(input)) == expected
61
+
62
+    @pytest.mark.parametrize(['input', 'expected'], [
63
+        (b'\x00\x00\x00\x07ssh-rsa', b'ssh-rsa'),
64
+        (
65
+            ssh_agent_client.SSHAgentClient.string(b'ssh-ed25519'),
66
+            b'ssh-ed25519',
67
+        ),
68
+    ])
69
+    def test_212_unstring(self, input, expected):
70
+        unstring = ssh_agent_client.SSHAgentClient.unstring
71
+        unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix
72
+        assert bytes(unstring(input)) == expected
73
+        assert tuple(
74
+            bytes(x) for x in unstring_prefix(input)
75
+        ) == (expected, b'')
76
+
77
+    @pytest.mark.parametrize(['value', 'exc_type', 'exc_pattern'], [
78
+        (10000000000000000, OverflowError, 'int too big to convert'),
79
+        (-1, OverflowError, "can't convert negative int to unsigned"),
80
+    ])
81
+    def test_310_uint32_exceptions(self, value, exc_type, exc_pattern):
82
+        uint32 = ssh_agent_client.SSHAgentClient.uint32
83
+        with pytest.raises(exc_type, match=exc_pattern):
84
+            uint32(value)
85
+
86
+    @pytest.mark.parametrize(['input', 'exc_type', 'exc_pattern'], [
87
+        ('some string', TypeError, 'invalid payload type'),
88
+    ])
89
+    def test_311_string_exceptions(self, input, exc_type, exc_pattern):
90
+        string = ssh_agent_client.SSHAgentClient.string
91
+        with pytest.raises(exc_type, match=exc_pattern):
92
+            string(input)
93
+
94
+    @pytest.mark.parametrize(
95
+        ['input', 'exc_type', 'exc_pattern', 'has_trailer', 'parts'], [
96
+            (b'ssh', ValueError, 'malformed SSH byte string', False, None),
97
+            (
98
+                b'\x00\x00\x00\x08ssh-rsa',
99
+                ValueError, 'malformed SSH byte string',
100
+                False, None,
101
+            ),
102
+            (
103
+                b'\x00\x00\x00\x04XXX trailing text',
104
+                ValueError, 'malformed SSH byte string',
105
+                True, (b'XXX ', b'trailing text'),
106
+            ),
107
+    ])
108
+    def test_312_unstring_exceptions(self, input, exc_type, exc_pattern,
109
+                                     has_trailer, parts):
110
+        unstring = ssh_agent_client.SSHAgentClient.unstring
111
+        unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix
112
+        with pytest.raises(exc_type, match=exc_pattern):
113
+            unstring(input)
114
+        if has_trailer:
115
+            assert tuple(bytes(x) for x in unstring_prefix(input)) == parts
116
+        else:
117
+            with pytest.raises(exc_type, match=exc_pattern):
118
+                unstring_prefix(input)
119
+
120
+@tests.skip_if_no_agent
121
+class TestAgentInteraction:
122
+
123
+    @pytest.mark.parametrize(['keytype', 'data_dict'],
124
+                             list(tests.SUPPORTED_KEYS.items()))
125
+    def test_200_sign_data_via_agent(self, keytype, data_dict):
126
+        private_key = data_dict['private_key']
127
+        try:
128
+            result = subprocess.run(['ssh-add', '-t', '30', '-q', '-'],
129
+                                    input=private_key, check=True,
130
+                                    capture_output=True)
131
+        except subprocess.CalledProcessError as e:
132
+            pytest.skip(
133
+                f"uploading test key: {e!r}, stdout={e.stdout!r}, "
134
+                f"stderr={e.stderr!r}"
135
+            )
136
+        else:
137
+            try:
138
+                client = ssh_agent_client.SSHAgentClient()
139
+            except OSError:  # pragma: no cover
140
+                pytest.skip('communication error with the SSH agent')
141
+        with client:
142
+            key_comment_pairs = {bytes(k): bytes(c)
143
+                                 for k, c in client.list_keys()}
144
+            public_key_data = data_dict['public_key_data']
145
+            expected_signature = data_dict['expected_signature']
146
+            derived_passphrase = data_dict['derived_passphrase']
147
+            if public_key_data not in key_comment_pairs:  # pragma: no cover
148
+                pytest.skip('prerequisite SSH key not loaded')
149
+            signature = bytes(client.sign(
150
+                payload=derivepassphrase.Vault._UUID, key=public_key_data))
151
+            assert signature == expected_signature, 'SSH signature mismatch'
152
+            signature2 = bytes(client.sign(
153
+                payload=derivepassphrase.Vault._UUID, key=public_key_data))
154
+            assert signature2 == expected_signature, 'SSH signature mismatch'
155
+            assert (
156
+                derivepassphrase.Vault.phrase_from_key(public_key_data) ==
157
+                derived_passphrase
158
+            ), 'SSH signature mismatch'
159
+
160
+    @pytest.mark.parametrize(['keytype', 'data_dict'],
161
+                             list(tests.UNSUITABLE_KEYS.items()))
162
+    def test_201_sign_data_via_agent_unsupported(self, keytype, data_dict):
163
+        private_key = data_dict['private_key']
164
+        try:
165
+            result = subprocess.run(['ssh-add', '-t', '30', '-q', '-'],
166
+                                    input=private_key, check=True,
167
+                                    capture_output=True)
168
+        except subprocess.CalledProcessError as e:  # pragma: no cover
169
+            pytest.skip(
170
+                f"uploading test key: {e!r}, stdout={e.stdout!r}, "
171
+                f"stderr={e.stderr!r}"
172
+            )
173
+        else:
174
+            try:
175
+                client = ssh_agent_client.SSHAgentClient()
176
+            except OSError:  # pragma: no cover
177
+                pytest.skip('communication error with the SSH agent')
178
+        with client:
179
+            key_comment_pairs = {bytes(k): bytes(c)
180
+                                 for k, c in client.list_keys()}
181
+            public_key_data = data_dict['public_key_data']
182
+            expected_signature = data_dict['expected_signature']
183
+            if public_key_data not in key_comment_pairs:  # pragma: no cover
184
+                pytest.skip('prerequisite SSH key not loaded')
185
+            signature = bytes(client.sign(
186
+                payload=derivepassphrase.Vault._UUID, key=public_key_data))
187
+            signature2 = bytes(client.sign(
188
+                payload=derivepassphrase.Vault._UUID, key=public_key_data))
189
+            assert signature != signature2, 'SSH signature repeatable?!'
190
+            with pytest.raises(ValueError, match='unsuitable SSH key'):
191
+                derivepassphrase.Vault.phrase_from_key(public_key_data)
192
+
193
+    @staticmethod
194
+    def _params():
195
+        for value in tests.SUPPORTED_KEYS.values():
196
+            key = value['public_key_data']
197
+            yield (key, False)
198
+        singleton_key = tests.list_keys_singleton()[0].key
199
+        for value in tests.SUPPORTED_KEYS.values():
200
+            key = value['public_key_data']
201
+            if key == singleton_key:
202
+                yield (key, True)
203
+
204
+    @pytest.mark.parametrize(['key', 'single'], list(_params()))
205
+    def test_210_ssh_key_selector(self, monkeypatch, key, single):
206
+        def key_is_suitable(key: bytes):
207
+            return key in {v['public_key_data']
208
+                           for v in tests.SUPPORTED_KEYS.values()}
209
+        if single:
210
+            monkeypatch.setattr(ssh_agent_client.SSHAgentClient,
211
+                                'list_keys', tests.list_keys_singleton)
212
+            keys = [pair.key for pair in tests.list_keys_singleton()
213
+                    if key_is_suitable(pair.key)]
214
+            index = '1'
215
+            text = f'Use this key? yes\n'
216
+        else:
217
+            monkeypatch.setattr(ssh_agent_client.SSHAgentClient,
218
+                                'list_keys', tests.list_keys)
219
+            keys = [pair.key for pair in tests.list_keys()
220
+                    if key_is_suitable(pair.key)]
221
+            index = str(1 + keys.index(key))
222
+            n = len(keys)
223
+            text = f'Your selection? (1-{n}, leave empty to abort): {index}\n'
224
+        b64_key = base64.standard_b64encode(key).decode('ASCII')
225
+
226
+        @click.command()
227
+        def driver():
228
+            key = derivepassphrase.cli._select_ssh_key()
229
+            click.echo(base64.standard_b64encode(key).decode('ASCII'))
230
+
231
+        runner = click.testing.CliRunner(mix_stderr=True)
232
+        result = runner.invoke(driver, [],
233
+                               input=('yes\n' if single else f'{index}\n'),
234
+                               catch_exceptions=True)
235
+        assert result.stdout.startswith('Suitable SSH keys:\n'), (
236
+            'missing expected output'
237
+        )
238
+        assert text in result.stdout, 'missing expected output'
239
+        assert (
240
+            result.stdout.endswith(f'\n{b64_key}\n')
241
+        ), 'missing expected output'
242
+        assert result.exit_code == 0, 'driver program failed?!'
243
+
244
+    del _params
245
+
246
+    def test_300_constructor_bad_running_agent(self, monkeypatch):
247
+        monkeypatch.setenv('SSH_AUTH_SOCK',
248
+                           os.environ['SSH_AUTH_SOCK'] + '~')
249
+        sock = socket.socket(family=socket.AF_UNIX)
250
+        with pytest.raises(OSError):
251
+            ssh_agent_client.SSHAgentClient(socket=sock)
252
+
253
+    @pytest.mark.parametrize(['response'], [
254
+        (b'\x00\x00',),
255
+        (b'\x00\x00\x00\x1f some bytes missing',),
256
+    ])
257
+    def test_310_truncated_server_response(self, monkeypatch, response):
258
+        client = ssh_agent_client.SSHAgentClient()
259
+        response_stream = io.BytesIO(response)
260
+        class PseudoSocket(object):
261
+            def sendall(self, *args: Any, **kwargs: Any) -> Any:
262
+                return None
263
+            def recv(self, *args: Any, **kwargs: Any) -> Any:
264
+                return response_stream.read(*args, **kwargs)
265
+        pseudo_socket = PseudoSocket()
266
+        monkeypatch.setattr(client, '_connection', pseudo_socket)
267
+        with pytest.raises(EOFError):
268
+            client.request(255, b'')
269
+
270
+    @tests.skip_if_no_agent
271
+    @pytest.mark.parametrize(
272
+        ['response_code', 'response', 'exc_type', 'exc_pattern'],
273
+        [
274
+            (255, b'', RuntimeError, 'error return from SSH agent:'),
275
+            (12, b'\x00\x00\x00\x01', EOFError, 'truncated response'),
276
+            (12, b'\x00\x00\x00\x00abc', RuntimeError, 'overlong response'),
277
+        ]
278
+    )
279
+    def test_320_list_keys_error_responses(self, monkeypatch, response_code,
280
+                                           response, exc_type, exc_pattern):
281
+        client = ssh_agent_client.SSHAgentClient()
282
+        monkeypatch.setattr(client, 'request',
283
+                            lambda *a, **kw: (response_code, response))
284
+        with pytest.raises(exc_type, match=exc_pattern):
285
+            client.list_keys()
286
+
287
+    @tests.skip_if_no_agent
288
+    @pytest.mark.parametrize(
289
+        ['key', 'check', 'response', 'exc_type', 'exc_pattern'],
290
+        [
291
+            (
292
+                b'invalid-key',
293
+                True,
294
+                (255, b''),
295
+                KeyError,
296
+                'target SSH key not loaded into agent',
297
+            ),
298
+            (
299
+                tests.SUPPORTED_KEYS['ed25519']['public_key_data'],
300
+                True,
301
+                (255, b''),
302
+                RuntimeError,
303
+                'signing data failed:',
304
+            )
305
+        ]
306
+    )
307
+    def test_330_sign_error_responses(self, monkeypatch, key, check,
308
+                                      response, exc_type, exc_pattern):
309
+        client = ssh_agent_client.SSHAgentClient()
310
+        monkeypatch.setattr(client, 'request', lambda a, b: response)
311
+        KeyCommentPair = ssh_agent_client.types.KeyCommentPair
312
+        loaded_keys = [KeyCommentPair(v['public_key_data'], b'no comment')
313
+                       for v in tests.SUPPORTED_KEYS.values()]
314
+        monkeypatch.setattr(client, 'list_keys', lambda: loaded_keys)
315
+        with pytest.raises(exc_type, match=exc_pattern):
316
+            client.sign(key, b'abc', check_if_key_loaded=check)
0 317