Add prototype command-line interface
Marco Ricci

Marco Ricci commited on 2024-06-22 19:54:19
Zeige 3 geänderte Dateien mit 413 Einfügungen und 18 Löschungen.

... ...
@@ -0,0 +1,330 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Command-line interface for derivepassphrase.
6
+
7
+"""
8
+
9
+from __future__ import annotations
10
+
11
+import inspect
12
+import json
13
+import pathlib
14
+from typing import Any, TextIO
15
+
16
+import click
17
+import derivepassphrase as dpp
18
+from derivepassphrase import types as dpp_types
19
+
20
+__author__ = dpp.__author__
21
+__version__ = dpp.__version__
22
+
23
+__all__ = ('derivepassphrase',)
24
+
25
+prog_name = 'derivepassphrase'
26
+
27
+
28
+# Implement help text groups. Inspired by
29
+# https://github.com/pallets/click/issues/373#issuecomment-515293746 and
30
+# modified to support group epilogs as well.
31
+class OptionGroupOption(click.Option):
32
+    option_group_name = ''
33
+    epilog = ''
34
+
35
+class CommandWithHelpGroups(click.Command):
36
+    def format_options(
37
+        self, ctx: click.Context, formatter: click.HelpFormatter,
38
+    ) -> None:
39
+        help_records: dict[str, list[tuple[str, str]]] = {}
40
+        epilogs: dict[str, str] = {}
41
+        params = self.params[:]
42
+        if (help_opt := self.get_help_option(ctx)) and help_opt not in params:
43
+            params.append(help_opt)
44
+        for param in params:
45
+            rec = param.get_help_record(ctx)
46
+            if rec is not None:
47
+                if isinstance(param, OptionGroupOption):
48
+                    group_name = param.option_group_name
49
+                    epilogs.setdefault(group_name, param.epilog)
50
+                else:
51
+                    group_name = ''
52
+                help_records.setdefault(group_name, []).append(rec)
53
+        default_group = help_records.pop('')
54
+        default_group_name = ('Other Options' if len(default_group) > 1
55
+                              else 'Options')
56
+        help_records[default_group_name] = default_group
57
+        for group_name, records in help_records.items():
58
+            with formatter.section(group_name):
59
+                formatter.write_dl(records)
60
+            epilog = inspect.cleandoc(epilogs.get(group_name, ''))
61
+            if epilog:
62
+                formatter.write_paragraph()
63
+                with formatter.indentation():
64
+                    formatter.write_text(epilog)
65
+
66
+# Concrete option groups used by this command-line interface.
67
+class PasswordGenerationOption(OptionGroupOption):
68
+    option_group_name = 'Password generation'
69
+    epilog = 'Use NUMBER=0, e.g. "--symbol 0", to exclude a character type from the output'
70
+
71
+class ConfigurationOption(OptionGroupOption):
72
+    option_group_name = 'Configuration'
73
+    epilog = 'Use $VISUAL or $EDITOR to configure the spawned editor.'
74
+
75
+class StorageManagementOption(OptionGroupOption):
76
+    option_group_name = 'Storage management'
77
+    epilog = 'Using "-" as PATH for standard input/standard output is supported.'
78
+
79
+def validate_occurrence_constraint(
80
+    ctx: click.Context, param: click.Parameter, value: Any,
81
+) -> int | None:
82
+    if value is None:
83
+        return value
84
+    if isinstance(value, int):
85
+        int_value = value
86
+    else:
87
+        try:
88
+            int_value = int(value, 10)
89
+        except ValueError as e:
90
+            raise click.BadParameter('not an integer') from e
91
+    if int_value < 0:
92
+        raise click.BadParameter('not a non-negative integer')
93
+    return int_value
94
+
95
+def validate_length(
96
+    ctx: click.Context, param: click.Parameter, value: Any,
97
+) -> int | None:
98
+    if value is None:
99
+        return value
100
+    if isinstance(value, int):
101
+        int_value = value
102
+    else:
103
+        try:
104
+            int_value = int(value, 10)
105
+        except ValueError as e:
106
+            raise click.BadParameter('not an integer') from e
107
+    if int_value < 1:
108
+        raise click.BadParameter('not a positive integer')
109
+    return int_value
110
+
111
+@click.command(
112
+    context_settings={"help_option_names": ["-h", "--help"]},
113
+    cls=CommandWithHelpGroups,
114
+    epilog='''
115
+        WARNING: There is NO WAY to retrieve the generated passphrases
116
+        if the master passphrase, the SSH key, or the exact passphrase
117
+        settings are lost, short of trying out all possible
118
+        combinations.  You are STRONGLY advised to keep independent
119
+        backups of the settings and the SSH key, if any.
120
+    ''',
121
+)
122
+@click.option('-p', '--phrase', is_flag=True,
123
+              help='prompts you for your passphrase',
124
+              cls=PasswordGenerationOption)
125
+@click.option('-k', '--key', is_flag=True,
126
+              help='uses your SSH private key to generate passwords',
127
+              cls=PasswordGenerationOption)
128
+@click.option('-l', '--length', metavar='NUMBER', callback=validate_length,
129
+              help='emits password of length NUMBER',
130
+              cls=PasswordGenerationOption)
131
+@click.option('-r', '--repeat', metavar='NUMBER',
132
+              callback=validate_occurrence_constraint,
133
+              help='allows maximum of NUMBER repeated adjacent chars',
134
+              cls=PasswordGenerationOption)
135
+@click.option('--lower', metavar='NUMBER',
136
+              callback=validate_occurrence_constraint,
137
+              help='includes at least NUMBER lowercase letters',
138
+              cls=PasswordGenerationOption)
139
+@click.option('--upper', metavar='NUMBER',
140
+              callback=validate_occurrence_constraint,
141
+              help='includes at least NUMBER uppercase letters',
142
+              cls=PasswordGenerationOption)
143
+@click.option('--number', metavar='NUMBER',
144
+              callback=validate_occurrence_constraint,
145
+              help='includes at least NUMBER digits',
146
+              cls=PasswordGenerationOption)
147
+@click.option('--space', metavar='NUMBER',
148
+              callback=validate_occurrence_constraint,
149
+              help='includes at least NUMBER spaces',
150
+              cls=PasswordGenerationOption)
151
+@click.option('--dash', metavar='NUMBER',
152
+              callback=validate_occurrence_constraint,
153
+              help='includes at least NUMBER "-" or "_"',
154
+              cls=PasswordGenerationOption)
155
+@click.option('--symbol', metavar='NUMBER',
156
+              callback=validate_occurrence_constraint,
157
+              help='includes at least NUMBER symbol chars',
158
+              cls=PasswordGenerationOption)
159
+@click.option('-n', '--notes', is_flag=True,
160
+              help='spawn an editor to edit notes for SERVICE',
161
+              cls=ConfigurationOption)
162
+@click.option('-c', '--config', is_flag=True,
163
+              help='saves the given settings for SERVICE or global',
164
+              cls=ConfigurationOption)
165
+@click.option('-x', '--delete', is_flag=True,
166
+              help='deletes settings for SERVICE',
167
+              cls=ConfigurationOption)
168
+@click.option('--delete-globals', is_flag=True,
169
+              help='deletes the global shared settings',
170
+              cls=ConfigurationOption)
171
+@click.option('-X', '--clear', is_flag=True,
172
+              help='deletes all settings',
173
+              cls=ConfigurationOption)
174
+@click.option('-e', '--export', metavar='PATH', type=click.File('wt'),
175
+              help='export all saved settings into file PATH',
176
+              cls=StorageManagementOption)
177
+@click.option('-i', '--import', metavar='PATH', type=click.File('rt'),
178
+              help='import saved settings from file PATH',
179
+              cls=StorageManagementOption)
180
+@click.version_option(version=dpp.__version__, prog_name=prog_name)
181
+@click.argument('service', required=False)
182
+@click.pass_context
183
+def derivepassphrase(
184
+    ctx: click.Context, service: str | None = None, **kwargs: Any,
185
+) -> None:
186
+    """Derive a strong passphrase, deterministically, from a master secret.
187
+
188
+    Using a master passphrase or a master SSH key, derive a strong
189
+    passphrase for SERVICE, deterministically, subject to length,
190
+    character and character repetition constraints.  The service name
191
+    and constraints themselves need not be kept secret; the latter are
192
+    usually stored in a world-readable file.
193
+
194
+    If operating on global settings, or importing/exporting settings,
195
+    then SERVICE must be omitted.  Otherwise it is required.\f
196
+
197
+    This is a [`click`][CLICK]-powered command-line interface function,
198
+    and not intended for programmatic use.  Call with arguments
199
+    `['--help']` to see full documentation of the interface.
200
+
201
+    [CLICK]: https://click.palletsprojects.com/
202
+
203
+    Parameters:
204
+        ctx (click.Context):
205
+            The `click` context.
206
+
207
+    Other Parameters:
208
+        service (str | None):
209
+            A service name.  Required, unless operating on global
210
+            settings or importing/exporting settings.
211
+        phrase (bool):
212
+            Command-line argument `-p`/`--phrase`.  If given, query the
213
+            user for a passphrase instead of an SSH key.
214
+        key (bool):
215
+            Command-line argument `-k`/`--key`.  If given, query the
216
+            user for an SSH key instead of a passphrase.
217
+        length (int | None):
218
+            Command-line argument `-l`/`--length`.  Override the default
219
+            length of the generated passphrase.
220
+        repeat (int | None):
221
+            Command-line argument `-r`/`--repeat`.  Override the default
222
+            repetition limit if positive, or disable the repetition
223
+            limit if 0.
224
+        lower (int | None):
225
+            Command-line argument `--lower`.  Require a given amount of
226
+            ASCII lowercase characters if positive, else forbid ASCII
227
+            lowercase characters if 0.
228
+        upper (int | None):
229
+            Command-line argument `--upper`.  Same as `lower`, but for
230
+            ASCII uppercase characters.
231
+        number (int | None):
232
+            Command-line argument `--number`.  Same as `lower`, but for
233
+            ASCII digits.
234
+        space (int | None):
235
+            Command-line argument `--number`.  Same as `lower`, but for
236
+            the space character.
237
+        dash (int | None):
238
+            Command-line argument `--number`.  Same as `lower`, but for
239
+            the hyphen-minus and underscore characters.
240
+        symbol (int | None):
241
+            Command-line argument `--number`.  Same as `lower`, but for
242
+            all other ASCII printable characters (except backquote).
243
+        notes (bool):
244
+            Command-line argument `-n`/`--notes`.  If given, spawn an
245
+            editor to edit notes for `service`.
246
+        config (bool):
247
+            Command-line argument `-c`/`--config`.  If given, saves the
248
+            other given settings (`--key`, ..., `--symbol`) to the
249
+            configuration file, either specifically for `service` or as
250
+            global settings.
251
+        delete (bool):
252
+            Command-line argument `-x`/`--delete`.  If given, removes
253
+            the settings for `service` from the configuration file.
254
+        delete_globals (bool):
255
+            Command-line argument `--delete-globals`.  If given, removes
256
+            the global settings from the configuration file.
257
+        clear (bool):
258
+            Command-line argument `-X`/`--clear`.  If given, removes all
259
+            settings from the configuration file.
260
+        export (TextIO | click.utils.LazyFile | None):
261
+            Command-line argument `-e`/`--export`.  If given, exports
262
+            the settings to the given file object (or
263
+            a `click.utils.LazyFile` instance that eventually supplies
264
+            such a file object), which must be open for writing and
265
+            accept `str` inputs.
266
+        import (TextIO | click.utils.LazyFile | None):
267
+            Command-line argument `-i`/`--import`.  If given, imports
268
+            the settings from the given file object (or
269
+            a `click.utils.LazyFile` instance that eventually supplies
270
+            such a file object), which must be open for reading and
271
+            yield `str` values.
272
+
273
+    """
274
+    options: dict[type[click.Option], list[str]] = {}
275
+    for param in ctx.command.params:
276
+        if isinstance(param, click.Option):
277
+            param_name = param.human_readable_name
278
+            group: type[click.Option]
279
+            if isinstance(param, PasswordGenerationOption):
280
+                group = PasswordGenerationOption
281
+            elif isinstance(param, ConfigurationOption):
282
+                group = ConfigurationOption
283
+            elif isinstance(param, StorageManagementOption):
284
+                group = StorageManagementOption
285
+            elif isinstance(param, OptionGroupOption):
286
+                raise AssertionError(f'Unknown option group for {param!r}')
287
+            else:
288
+                group = click.Option
289
+            options.setdefault(group, []).append(param_name)
290
+    def check_incompatible_options(
291
+        param_name: str, *incompatible: str
292
+    ) -> None:
293
+        parsed_params = ctx.params
294
+        if parsed_params.get(param_name) is None:
295
+            return
296
+        for other in incompatible:
297
+            if other != param_name and parsed_params.get(other) is not None:
298
+                param_name = param_name.replace('_', '-')
299
+                other = other.replace('_', '-')
300
+                raise click.UsageError(
301
+                     f'--{param_name} and --{other} are mutually exclusive')
302
+    check_incompatible_options('phrase', 'key')
303
+    for group in (ConfigurationOption, StorageManagementOption):
304
+        for opt in options[group]:
305
+            if opt != 'config':
306
+                check_incompatible_options(opt,
307
+                                           *options[PasswordGenerationOption])
308
+    for group in (ConfigurationOption, StorageManagementOption):
309
+        for opt in options[group]:
310
+            check_incompatible_options(opt,
311
+                                       *options[ConfigurationOption],
312
+                                       *options[StorageManagementOption])
313
+    for opt in ['notes', 'delete']:
314
+        if kwargs.get(opt) is not None and not service:
315
+            opt = opt.replace('_', '-')
316
+            raise click.UsageError(f'--{opt} requires a SERVICE')
317
+    for opt in ['delete_globals', 'clear'] + options[StorageManagementOption]:
318
+        if kwargs.get(opt) is not None and service:
319
+            opt = opt.replace('_', '-')
320
+            raise click.UsageError(
321
+                f'--{opt} does not take a SERVICE argument')
322
+    #if kwargs['length'] is None:
323
+    #    kwargs['length'] = dpp.Vault.__init__.__kwdefaults__['length']
324
+    #if kwargs['repeat'] is None:
325
+    #    kwargs['repeat'] = dpp.Vault.__init__.__kwdefaults__['repeat']
326
+    click.echo(repr({'service': service, **kwargs}))
327
+
328
+
329
+if __name__ == '__main__':
330
+    derivepassphrase()
... ...
@@ -1,18 +0,0 @@
1
-# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
-#
3
-# SPDX-License-Identifier: MIT
4
-import click
5
-
6
-import derivepassphrase as dpp
7
-
8
-__author__ = dpp.__author__
9
-__version__ = dpp.__version__
10
-
11
-__all__ = ('derivepassphrase',)
12
-
13
-prog_name = 'derivepassphrase'
14
-
15
-@click.group(context_settings={"help_option_names": ["-h", "--help"]}, invoke_without_command=True)
16
-@click.version_option(version=__version__, prog_name="derivepassphrase")
17
-def derivepassphrase():
18
-    click.echo("Hello world!")
... ...
@@ -0,0 +1,83 @@
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+import click.testing
6
+import derivepassphrase
7
+import derivepassphrase.cli
8
+import pytest
9
+
10
+DUMMY_SERVICE = 'service1'
11
+DUMMY_PASSPHRASE = b'my secret passphrase\n'
12
+
13
+def test_200_help_output():
14
+    runner = click.testing.CliRunner(mix_stderr=False)
15
+    result = runner.invoke(derivepassphrase.cli.derivepassphrase, ['--help'])
16
+    assert result.exit_code == 0
17
+    assert 'Password generation:\n' in result.output, (
18
+        'Option groups not respected in help text.'
19
+    )
20
+    assert 'Use NUMBER=0, e.g. "--symbol 0"' in result.output, (
21
+        'Option group epilog not printed.'
22
+    )
23
+
24
+@pytest.mark.parametrize(['option'],
25
+                         [('--lower',), ('--upper',), ('--number',),
26
+                          ('--space',), ('--dash',), ('--symbol',),
27
+                          ('--repeat',), ('--length',)])
28
+def test_201_invalid_argument_range(option):
29
+    runner = click.testing.CliRunner(mix_stderr=False)
30
+    result = runner.invoke(derivepassphrase.cli.derivepassphrase,
31
+                           [option, '-42', '-p', DUMMY_SERVICE],
32
+                           input=DUMMY_PASSPHRASE)
33
+    assert result.exit_code > 0, (
34
+        f'program unexpectedly succeeded'
35
+    )
36
+    assert result.stderr_bytes, (
37
+        f'program did not print any error message'
38
+    )
39
+    assert b'Error: Invalid value' in result.stderr_bytes, (
40
+        f'program did not print the expected error message'
41
+    )
42
+
43
+@pytest.mark.parametrize(['charset_name'],
44
+                         [('lower',), ('upper',), ('number',), ('space',),
45
+                          ('dash',), ('symbol',)])
46
+@pytest.mark.xfail(reason='implementation not written yet')
47
+def test_202_disable_character_set(charset_name):
48
+    option = f'--{charset_name}'
49
+    charset = derivepassphrase.Vault._CHARSETS[charset_name].decode('ascii')
50
+    runner = click.testing.CliRunner(mix_stderr=False)
51
+    result = runner.invoke(derivepassphrase.cli.derivepassphrase,
52
+                           [option, '0', '-p', DUMMY_SERVICE],
53
+                           input=DUMMY_PASSPHRASE)
54
+    assert result.exit_code == 0, (
55
+        f'program died unexpectedly with exit code {result.exit_code}'
56
+    )
57
+    assert not result.stderr_bytes, (
58
+        f'program barfed on stderr: {result.stderr_bytes}'
59
+    )
60
+    for c in charset:
61
+        assert c not in result.stdout, (
62
+            f'derived password contains forbidden character {c!r}: '
63
+            f'{result.stdout!r}'
64
+        )
65
+
66
+@pytest.mark.xfail(reason='implementation not written yet')
67
+def test_203_disable_repetition():
68
+    runner = click.testing.CliRunner(mix_stderr=False)
69
+    result = runner.invoke(derivepassphrase.cli.derivepassphrase,
70
+                           ['--repeat', '0', '-p', DUMMY_SERVICE],
71
+                           input=DUMMY_PASSPHRASE)
72
+    assert result.exit_code == 0, (
73
+        f'program died unexpectedly with exit code {result.exit_code}'
74
+    )
75
+    assert not result.stderr_bytes, (
76
+        f'program barfed on stderr: {result.stderr_bytes}'
77
+    )
78
+    passphrase = result.stdout.rstrip('\r\n')
79
+    for i in range(len(passphrase) - 1):
80
+        assert passphrase[i:i+1] != passphrase[i+1:i+2], (
81
+            f'derived password contains repeated character at position {i}: '
82
+            f'{result.stdout!r}'
83
+        )
0 84