Add a script to query test key signatures and derived passphrases from an agent
Marco Ricci

Marco Ricci commited on 2025-08-03 20:07:56
Zeige 1 geänderte Dateien mit 205 Einfügungen und 0 Löschungen.


The script attempts to upload each test key to the running SSH agent,
then queries the agent for the signature of the `vault` UUID and
computes the derived passphrase.  If that works, then it prints the test
key, augmented by the new signature and new derived passphrase, in
a `repr`-compatible representation that could principally directly be
included into the `tests` module.

(The real code in the `tests` module basically only differs in
whitespace: the hex codes that make up the signatures and the key data
are wrapped manually, in a manner that highlights their structure but
which is difficult/tedious to produce automatically.)

The script has only been tested on UNIX, and because it imports the
`tests` module directly, it should be run in a compatible environment,
e.g., the `hatch-static-analysis` environment.
... ...
@@ -0,0 +1,205 @@
1
+#!/usr/bin/python3
2
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info>
3
+#
4
+# SPDX-License-Identifier: Zlib
5
+
6
+"""Get signatures/derived passphrases for test keys from a "real" SSH agent.
7
+
8
+Attempt to upload every known test key against the running SSH agent and
9
+generate signatures and the derived passphrase from it.  If this is
10
+successful, then print the test key in a (quasi) `repr` format for
11
+inclusion in the tests module source code.
12
+
13
+This script is intended for use to capture "known good" values for the
14
+test keys: what the current SSH agent thinks are the correct signature
15
+and derived passphrase for the respective key (if supported).  It works
16
+especially well with OpenSSH's `ssh-agent` and PuTTY's `pageant` on UNIX
17
+systems:
18
+
19
+    $ ssh-agent python3 test_key_signatures_and_outputs.py
20
+    ...
21
+    $ pageant --exec python3 test_key_signatures_and_outputs.py
22
+    ...
23
+
24
+"""
25
+
26
+from __future__ import annotations
27
+
28
+import argparse
29
+import textwrap
30
+
31
+import tests
32
+
33
+from derivepassphrase import _types, ssh_agent, vault  # noqa: PLC2701
34
+
35
+
36
+def try_key(
37
+    client: ssh_agent.SSHAgentClient,
38
+    keyname: str,
39
+    /,
40
+    *,
41
+    deterministic_signature_class: (
42
+        tests.SSHTestKeyDeterministicSignatureClass
43
+    ) = tests.SSHTestKeyDeterministicSignatureClass.RFC_6979,
44
+) -> tests.SSHTestKey | None:
45
+    """Query a signature and derived passphrase for the named key, if possible.
46
+
47
+    Args:
48
+        client:
49
+            A connected SSH agent client.
50
+        keyname:
51
+            The name of the test key, from [`tests.ALL_KEYS`][].
52
+        deterministic_signature_class:
53
+            The class of deterministic signatures to record this
54
+            signature as, if the key turns out to be agent-specifically
55
+            suitable, but not suitable in general.  Usually, this will
56
+            be either
57
+            [RFC 6979][tests.SSHTestKeyDeterministicSignatureClass.RFC_6979]
58
+            or
59
+            [Pageant 0.68–0.80][tests.SSHTestKeyDeterministicSignatureClass.PAGEANT_068_080],
60
+            for deterministic DSA signatures.  (Use
61
+            [`SPEC`][tests.SSHTestKeyDeterministicSignatureClass.SPEC]
62
+            to disable.)
63
+
64
+    Returns:
65
+        A modified SSH test key, augmented with the new signature, or
66
+        `None` if no such signature augmentation could be performed.
67
+
68
+    """  # noqa: E501,RUF002
69
+    key = tests.ALL_KEYS[keyname]
70
+    if not vault.Vault.is_suitable_ssh_key(key.public_key_data, client=client):
71
+        return None
72
+    signature: bytes
73
+    derived_passphrase: bytes
74
+    try:
75
+        client.request(_types.SSH_AGENTC.ADD_IDENTITY, key.private_key_blob)
76
+    except ssh_agent.SSHAgentFailedError:
77
+        return None
78
+    try:
79
+        signature = client.sign(key.public_key_data, vault.Vault.UUID)
80
+        derived_passphrase = vault.Vault.phrase_from_key(key.public_key_data)
81
+    except ssh_agent.SSHAgentFailedError:
82
+        return None
83
+    expected_signatures = dict(key.expected_signatures)
84
+    signature_class = (
85
+        tests.SSHTestKeyDeterministicSignatureClass.SPEC
86
+        if vault.Vault.is_suitable_ssh_key(key.public_key_data)
87
+        else deterministic_signature_class
88
+    )
89
+    expected_signatures[signature_class] = (
90
+        tests.SSHTestKeyDeterministicSignature(
91
+            signature=signature,
92
+            derived_passphrase=derived_passphrase,
93
+            signature_class=signature_class,
94
+        )
95
+    )
96
+    return tests.SSHTestKey(
97
+        public_key=key.public_key,
98
+        public_key_data=key.public_key_data,
99
+        private_key=key.private_key,
100
+        private_key_blob=key.private_key_blob,
101
+        expected_signatures=expected_signatures,
102
+    )
103
+
104
+
105
+def format_key(key: tests.SSHTestKey) -> str:
106
+    """Return a formatted SSH test key."""
107
+    ascii_printables = range(32, 127)
108
+    ascii_whitespace = {ord(' '), ord('\n'), ord('\t'), ord('\r'), ord('\f')}
109
+
110
+    def as_raw_string_or_hex(bytestring: bytes) -> str:
111
+        if bytestring.find(b'"""') < 0 and all(
112
+            byte in ascii_printables or byte in ascii_whitespace
113
+            for byte in bytestring
114
+        ):
115
+            return f'rb"""{bytestring.decode("ascii")}"""'
116
+        hexstring = bytestring.hex(' ', 1)
117
+        wrapped_hexstring = '\n'.join(
118
+            textwrap.TextWrapper(width=48).wrap(hexstring)
119
+        )
120
+        return f'''bytes.fromhex("""
121
+{wrapped_hexstring}
122
+""")'''
123
+
124
+    f = as_raw_string_or_hex
125
+
126
+    lines = [
127
+        'SSHTestKey(\n',
128
+        '    public_key=' + f(key.public_key) + ',\n',
129
+        '    public_key_data=' + f(key.public_key_data) + ',\n',
130
+        '    private_key=' + f(key.private_key) + ',\n',
131
+        '    private_key_blob=' + f(key.private_key_blob) + ',\n',
132
+    ]
133
+    if key.expected_signatures:
134
+        expected_signature_lines = [
135
+            'expected_signatures={\n',
136
+        ]
137
+        for sig in key.expected_signatures.values():
138
+            expected_signature_lines.extend([
139
+                f'    {sig.signature_class!s}: '
140
+                'SSHTestKeyDeterministicSignature(\n',
141
+                '        signature=' + f(sig.signature) + ',\n',
142
+                '        derived_passphrase='
143
+                + f(sig.derived_passphrase)
144
+                + ',\n',
145
+            ])
146
+            if (
147
+                sig.signature_class
148
+                != tests.SSHTestKeyDeterministicSignatureClass.SPEC
149
+            ):
150
+                expected_signature_lines.append(
151
+                    f'        signature_class={sig.signature_class!s},\n'
152
+                )
153
+            expected_signature_lines.append('    ),\n')
154
+        expected_signature_lines.append('},\n')
155
+        lines.extend('    ' + x for x in expected_signature_lines)
156
+    else:
157
+        lines.append('    expected_signatures={},\n')
158
+    lines.append(')')
159
+
160
+    return ''.join(lines)
161
+
162
+
163
+def main(argv: list[str] | None = None) -> None:
164
+    """"""  # noqa: D419
165
+    ap = argparse.ArgumentParser()
166
+    group = ap.add_mutually_exclusive_group()
167
+    group.add_argument(
168
+        '--rfc-6979',
169
+        action='store_const',
170
+        dest='deterministic_signature_class',
171
+        const=tests.SSHTestKeyDeterministicSignatureClass.RFC_6979,
172
+        default=tests.SSHTestKeyDeterministicSignatureClass.RFC_6979,
173
+        help='assume RFC 6979 signatures for deterministic DSA',
174
+    )
175
+    group.add_argument(
176
+        '--pageant-068-080',
177
+        action='store_const',
178
+        dest='deterministic_signature_class',
179
+        const=tests.SSHTestKeyDeterministicSignatureClass.Pageant_068_080,
180
+        default=tests.SSHTestKeyDeterministicSignatureClass.RFC_6979,
181
+        help='assume Pageant 0.68-0.80 signatures for deterministic DSA',
182
+    )
183
+    ap.add_argument(
184
+        'keynames',
185
+        nargs='*',
186
+        metavar='KEYNAME',
187
+        help='query the named test key in the agent '
188
+        '(multiple use possible; default: all keys)',
189
+    )
190
+    args = ap.parse_args(args=argv)
191
+    if not args.keynames:
192
+        args.keynames = list(tests.ALL_KEYS.keys())
193
+    with ssh_agent.SSHAgentClient.ensure_agent_subcontext() as client:
194
+        for keyname in args.keynames:
195
+            key = try_key(
196
+                client,
197
+                keyname,
198
+                deterministic_signature_class=args.deterministic_signature_class,
199
+            )
200
+            if key is not None:
201
+                print(f'keys[{keyname!r}] =', format_key(key))
202
+
203
+
204
+if __name__ == '__main__':
205
+    main()
0 206