Marco Ricci commited on 2025-07-26 11:45:19
Zeige 2 geänderte Dateien mit 237 Einfügungen und 3 Löschungen.
A typo existed in the ECDSA NIST P-521 raw public key data, making it incompatible with the copy of the public key included in the raw private key blob. This caused UNIX Pageant (and likely any other SSH agent supporting deterministic DSA signatures, if they exist) to reject unloading and signing with that key, despite actually supporting the key format. So fix the typo, and add tests and machinery to ensure rudimentary consistency of all test key data.
... | ... |
@@ -68,13 +68,13 @@ class SSHTestKey(NamedTuple): |
68 | 68 |
|
69 | 69 |
""" |
70 | 70 |
|
71 |
- public_key: bytes | str |
|
71 |
+ public_key: bytes |
|
72 | 72 |
"""""" |
73 | 73 |
public_key_data: bytes |
74 | 74 |
"""""" |
75 | 75 |
private_key: bytes |
76 | 76 |
"""""" |
77 |
- private_key_blob: bytes | None = None |
|
77 |
+ private_key_blob: bytes |
|
78 | 78 |
"""""" |
79 | 79 |
expected_signature: bytes | None = None |
80 | 80 |
"""""" |
... | ... |
@@ -1126,7 +1126,7 @@ Rlc3Qga2V5IHdpdGhvdXQgcGFzc3BocmFzZQ== |
1126 | 1126 |
""", |
1127 | 1127 |
public_key_data=bytes.fromhex(""" |
1128 | 1128 |
00 00 00 13 65 63 64 |
1129 |
- 73 61 2d 73 68 61 32 2d 6e 69 73 74 70 32 35 36 |
|
1129 |
+ 73 61 2d 73 68 61 32 2d 6e 69 73 74 70 35 32 31 |
|
1130 | 1130 |
00 00 00 08 6e 69 73 74 70 35 32 31 |
1131 | 1131 |
00 00 00 85 04 00 49 53 9d |
1132 | 1132 |
c0 3c e7 9a 57 06 aa 22 ef 16 d6 1e 56 da c0 12 |
... | ... |
@@ -0,0 +1,234 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+from __future__ import annotations |
|
6 |
+ |
|
7 |
+import base64 |
|
8 |
+ |
|
9 |
+import pytest |
|
10 |
+ |
|
11 |
+import tests |
|
12 |
+from derivepassphrase import ssh_agent |
|
13 |
+ |
|
14 |
+OPENSSH_MAGIC = b'openssh-key-v1\x00' |
|
15 |
+OPENSSH_HEADER = ( |
|
16 |
+ OPENSSH_MAGIC # magic |
|
17 |
+ + b'\x00\x00\x00\x04none' # ciphername |
|
18 |
+ + b'\x00\x00\x00\x04none' # kdfname |
|
19 |
+ + b'\x00\x00\x00\x00' # kdfoptions |
|
20 |
+ + b'\x00\x00\x00\x01' # number of keys |
|
21 |
+) |
|
22 |
+ |
|
23 |
+ |
|
24 |
+def as_openssh_keyfile_payload( |
|
25 |
+ public_key: bytes, private_key: bytes, checkint: int |
|
26 |
+) -> bytes: |
|
27 |
+ """Format an SSH private key in OpenSSH format. |
|
28 |
+ |
|
29 |
+ Args: |
|
30 |
+ public_key: |
|
31 |
+ The unframed public key, in SSH wire format. |
|
32 |
+ private_key: |
|
33 |
+ The unframed private key, in SSH wire format, including the |
|
34 |
+ comment. |
|
35 |
+ checkint: |
|
36 |
+ The "check" integer to use. |
|
37 |
+ |
|
38 |
+ Returns: |
|
39 |
+ The payload for a formatted OpenSSH private key, as a byte |
|
40 |
+ string, without the base64 encoding and the framing lines. |
|
41 |
+ |
|
42 |
+ """ |
|
43 |
+ # The OpenSSH private key file format is described in PROTOCOL.key |
|
44 |
+ # in their git repository; see below for links to OpenSSH 10.0p2. |
|
45 |
+ # The block size of the "none" cipher is 8 bytes; see line 108 of |
|
46 |
+ # cipher.c, with definitions from line 67 onwards. Padding is not |
|
47 |
+ # used if the payload already is a multiple of 8 bytes long; see |
|
48 |
+ # line 2935 onwards of sshkey.c |
|
49 |
+ # |
|
50 |
+ # https://github.com/openssh/openssh-portable/raw/2593769fb291fe6c542173927698c69e9f9a08b9/PROTOCOL.key |
|
51 |
+ # https://github.com/openssh/openssh-portable/raw/2593769fb291fe6c542173927698c69e9f9a08b9/cipher.c |
|
52 |
+ # https://github.com/openssh/openssh-portable/raw/2593769fb291fe6c542173927698c69e9f9a08b9/sshkey.c |
|
53 |
+ string = ssh_agent.SSHAgentClient.string |
|
54 |
+ uint32 = ssh_agent.SSHAgentClient.uint32 |
|
55 |
+ payload = bytearray(OPENSSH_HEADER) |
|
56 |
+ payload.extend(string(public_key)) |
|
57 |
+ secret = bytearray() |
|
58 |
+ secret.extend(uint32(checkint)) # checkint |
|
59 |
+ secret.extend(uint32(checkint)) # checkint |
|
60 |
+ secret.extend(private_key) # privatekey1 and comment1 |
|
61 |
+ i = 1 |
|
62 |
+ while len(secret) % 16 != 0: |
|
63 |
+ secret.append(i) |
|
64 |
+ i += 1 |
|
65 |
+ payload.extend(string(secret)) # encrypted, padded list of private keys |
|
66 |
+ return bytes(payload) |
|
67 |
+ |
|
68 |
+ |
|
69 |
+def minimize_openssh_keyfile_padding( |
|
70 |
+ decoded_openssh_private_key: bytes, |
|
71 |
+) -> bytes: |
|
72 |
+ """Minimize the padding used in an OpenSSH private key file. |
|
73 |
+ |
|
74 |
+ Args: |
|
75 |
+ decoded_openssh_private_key: |
|
76 |
+ The non-base64-encoded, unframed, formatted OpenSSH private |
|
77 |
+ key. |
|
78 |
+ |
|
79 |
+ Returns: |
|
80 |
+ The same non-base64-encoded, unframed, formatted OpenSSH private |
|
81 |
+ key, but with minimal padding applied. |
|
82 |
+ |
|
83 |
+ """ |
|
84 |
+ string = ssh_agent.SSHAgentClient.string |
|
85 |
+ unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
86 |
+ |
|
87 |
+ _public_key, framed_private_block = unstring_prefix( |
|
88 |
+ decoded_openssh_private_key.removeprefix(OPENSSH_HEADER) |
|
89 |
+ ) |
|
90 |
+ result = bytearray(decoded_openssh_private_key).removesuffix( |
|
91 |
+ framed_private_block |
|
92 |
+ ) |
|
93 |
+ private_block, trailer = unstring_prefix(framed_private_block) |
|
94 |
+ assert not trailer |
|
95 |
+ |
|
96 |
+ # Skip two checkint values. |
|
97 |
+ key_type, remainder = unstring_prefix(private_block[8:]) |
|
98 |
+ # We need to semi-generically skip private key payloads. Currently, |
|
99 |
+ # all supported (test) key types exclusively store multi-precision |
|
100 |
+ # integers or strings as their private key payload (which are both |
|
101 |
+ # parsed the same way, but interpreted differently). We can |
|
102 |
+ # therefore generically parse `k` strings/mpints (for different |
|
103 |
+ # values of `k`, depending on key type) to correctly skip the |
|
104 |
+ # private key payload, and don't have to deal with having to parse |
|
105 |
+ # and skip other types of data such as uint32s. |
|
106 |
+ # |
|
107 |
+ # (This scheme needs updating if ever a different data type needs to |
|
108 |
+ # be parsed.) |
|
109 |
+ num_mpints = { |
|
110 |
+ b'ssh-ed25519': 2, |
|
111 |
+ b'ssh-ed448': 2, |
|
112 |
+ b'ssh-rsa': 6, |
|
113 |
+ b'ssh-dss': 5, |
|
114 |
+ b'ecdsa-sha2-nistp256': 3, |
|
115 |
+ b'ecdsa-sha2-nistp384': 3, |
|
116 |
+ b'ecdsa-sha2-nistp521': 3, |
|
117 |
+ } |
|
118 |
+ for _ in range(num_mpints[key_type]): |
|
119 |
+ _, remainder = unstring_prefix(remainder) |
|
120 |
+ # Skip comment. |
|
121 |
+ _comment, remainder = unstring_prefix(remainder) |
|
122 |
+ new_private_block = bytearray(private_block).removesuffix(remainder) |
|
123 |
+ padding = bytearray(remainder) |
|
124 |
+ |
|
125 |
+ expected_padding = bytearray() |
|
126 |
+ for i in range(1, len(padding) + 1): |
|
127 |
+ expected_padding.append(i & 0xFF) |
|
128 |
+ assert padding == expected_padding |
|
129 |
+ while len(padding) >= 8: |
|
130 |
+ padding[-8:] = b'' |
|
131 |
+ |
|
132 |
+ new_private_block.extend(padding) |
|
133 |
+ result.extend(string(new_private_block)) |
|
134 |
+ return result |
|
135 |
+ |
|
136 |
+ |
|
137 |
+class Parametrize: |
|
138 |
+ """Common test parametrizations.""" |
|
139 |
+ |
|
140 |
+ TEST_KEYS = pytest.mark.parametrize( |
|
141 |
+ ['keyname', 'key'], tests.ALL_KEYS.items(), ids=tests.ALL_KEYS.keys() |
|
142 |
+ ) |
|
143 |
+ |
|
144 |
+ |
|
145 |
+@Parametrize.TEST_KEYS |
|
146 |
+def test_100_test_keys_public_keys_are_internally_consistent( |
|
147 |
+ keyname: str, |
|
148 |
+ key: tests.SSHTestKey, |
|
149 |
+) -> None: |
|
150 |
+ """The test key public key data structures are internally consistent.""" |
|
151 |
+ del keyname |
|
152 |
+ string = ssh_agent.SSHAgentClient.string |
|
153 |
+ public_key_lines = key.public_key.splitlines(keepends=False) |
|
154 |
+ assert len(public_key_lines) == 1 |
|
155 |
+ line_parts = public_key_lines[0].strip(b'\r\n').split(None, 2) |
|
156 |
+ key_type_name, public_key_b64 = line_parts[:2] |
|
157 |
+ assert base64.standard_b64encode(key.public_key_data) == public_key_b64 |
|
158 |
+ assert key.public_key_data.startswith(string(key_type_name)) |
|
159 |
+ |
|
160 |
+ |
|
161 |
+@Parametrize.TEST_KEYS |
|
162 |
+def test_101_test_keys_private_keys_are_consistent_with_public_keys( |
|
163 |
+ keyname: str, |
|
164 |
+ key: tests.SSHTestKey, |
|
165 |
+) -> None: |
|
166 |
+ """The test key private key data are consistent with their public parts.""" |
|
167 |
+ del keyname |
|
168 |
+ string = ssh_agent.SSHAgentClient.string |
|
169 |
+ |
|
170 |
+ if key.public_key_data.startswith(string(b'ssh-rsa')): |
|
171 |
+ # RSA public keys are *not* prefixes of the corresponding private |
|
172 |
+ # key in OpenSSH format! RSA public keys consist of an exponent |
|
173 |
+ # e and a modulus n, which in the public key are in the order (e, |
|
174 |
+ # n), but in the order (n, e) in the OpenSSH private key. We thus |
|
175 |
+ # need to parse and rearrange the components of the public key into |
|
176 |
+ # a new "mangled" public key that then *is* a prefix of the |
|
177 |
+ # respective private key. |
|
178 |
+ unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
|
179 |
+ key_type, numbers = unstring_prefix(key.public_key_data) |
|
180 |
+ e, encoded_n = unstring_prefix(numbers) |
|
181 |
+ n, trailer = unstring_prefix(encoded_n) |
|
182 |
+ assert not trailer |
|
183 |
+ mangled_public_key_data = string(key_type) + string(n) + string(e) |
|
184 |
+ assert ( |
|
185 |
+ key.private_key_blob[: len(mangled_public_key_data)] |
|
186 |
+ == mangled_public_key_data |
|
187 |
+ ) |
|
188 |
+ else: |
|
189 |
+ assert ( |
|
190 |
+ key.private_key_blob[: len(key.public_key_data)] |
|
191 |
+ == key.public_key_data |
|
192 |
+ ) |
|
193 |
+ |
|
194 |
+ |
|
195 |
+@Parametrize.TEST_KEYS |
|
196 |
+def test_102_test_keys_private_keys_are_internally_consistent( |
|
197 |
+ keyname: str, |
|
198 |
+ key: tests.SSHTestKey, |
|
199 |
+) -> None: |
|
200 |
+ """The test key private key data structures are internally consistent.""" |
|
201 |
+ del keyname |
|
202 |
+ string = ssh_agent.SSHAgentClient.string |
|
203 |
+ |
|
204 |
+ private_key_lines = [ |
|
205 |
+ line |
|
206 |
+ for line in key.private_key.splitlines(keepends=False) |
|
207 |
+ if line and not line.startswith((b'-----BEGIN', b'-----END')) |
|
208 |
+ ] |
|
209 |
+ private_key_from_openssh = base64.standard_b64decode( |
|
210 |
+ b''.join(private_key_lines) |
|
211 |
+ ) |
|
212 |
+ wrapped_public_key = string(key.public_key_data) |
|
213 |
+ assert ( |
|
214 |
+ private_key_from_openssh[ |
|
215 |
+ len(OPENSSH_HEADER) : len(OPENSSH_HEADER) + len(wrapped_public_key) |
|
216 |
+ ] |
|
217 |
+ == wrapped_public_key |
|
218 |
+ ) |
|
219 |
+ |
|
220 |
+ # Offset skips the header, the wrapped public key, and the framing |
|
221 |
+ # of the private keys section. |
|
222 |
+ offset = len(OPENSSH_HEADER) + len(wrapped_public_key) + 4 |
|
223 |
+ checkint = int.from_bytes( |
|
224 |
+ private_key_from_openssh[offset : offset + 4], 'big' |
|
225 |
+ ) |
|
226 |
+ assert minimize_openssh_keyfile_padding( |
|
227 |
+ private_key_from_openssh |
|
228 |
+ ) == minimize_openssh_keyfile_padding( |
|
229 |
+ as_openssh_keyfile_payload( |
|
230 |
+ public_key=key.public_key_data, |
|
231 |
+ private_key=key.private_key_blob, |
|
232 |
+ checkint=checkint, |
|
233 |
+ ) |
|
234 |
+ ) |
|
0 | 235 |