e8b3ecf264495b6e5cf9b5f07889545ed242b64b
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

11) import base64
12) import collections
13) import contextlib
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

14) import inspect
15) import json
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

16) import os
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

17) import pathlib
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

18) import socket
Marco Ricci Support Python 3.10 and PyP...

Marco Ricci authored 2 months ago

19) from typing_extensions import (
20)     Any, assert_never, cast, Iterator, Sequence, TextIO,
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

21) )
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

22) 
23) import click
24) import derivepassphrase as dpp
25) from derivepassphrase import types as dpp_types
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

26) import ssh_agent_client
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

27) 
28) __author__ = dpp.__author__
29) __version__ = dpp.__version__
30) 
31) __all__ = ('derivepassphrase',)
32) 
33) prog_name = 'derivepassphrase'
34) 
35) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

36) def _config_filename() -> str | bytes | pathlib.Path:
37)     """Return the filename of the configuration file.
38) 
39)     The file is currently named `settings.json`, located within the
40)     configuration directory as determined by the `DERIVEPASSPHRASE_PATH`
41)     environment variable, or by [`click.get_app_dir`][] in POSIX
42)     mode.
43) 
44)     """
45)     path: str | bytes | pathlib.Path
46)     path = (os.getenv(prog_name.upper() + '_PATH')
47)             or click.get_app_dir(prog_name, force_posix=True))
48)     return os.path.join(path, 'settings.json')
49) 
50) 
51) def _load_config() -> dpp_types.VaultConfig:
52)     """Load a vault(1)-compatible config from the application directory.
53) 
54)     The filename is obtained via
55)     [`derivepassphrase.cli._config_filename`][].  This must be an
56)     unencrypted JSON file.
57) 
58)     Returns:
59)         The vault settings.  See
60)         [`derivepassphrase.types.VaultConfig`][] for details.
61) 
62)     Raises:
63)         OSError:
64)             There was an OS error accessing the file.
65)         ValueError:
66)             The data loaded from the file is not a vault(1)-compatible
67)             config.
68) 
69)     """
70)     filename = _config_filename()
71)     with open(filename, 'rb') as fileobj:
72)         data = json.load(fileobj)
73)     if not dpp_types.is_vault_config(data):
74)         raise ValueError('Invalid vault config')
75)     return data
76) 
77) 
78) def _save_config(config: dpp_types.VaultConfig, /) -> None:
79)     """Save a vault(1)-compatbile config to the application directory.
80) 
81)     The filename is obtained via
82)     [`derivepassphrase.cli._config_filename`][].  The config will be
83)     stored as an unencrypted JSON file.
84) 
85)     Args:
86)         config:
87)             vault configuration to save.
88) 
89)     Raises:
90)         OSError:
91)             There was an OS error accessing or writing the file.
92)         ValueError:
93)             The data cannot be stored as a vault(1)-compatible config.
94) 
95)     """
96)     if not dpp_types.is_vault_config(config):
97)         raise ValueError('Invalid vault config')
98)     filename = _config_filename()
99)     with open(filename, 'wt', encoding='UTF-8') as fileobj:
100)         json.dump(config, fileobj)
101) 
102) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

103) def _get_suitable_ssh_keys(
104)     conn: ssh_agent_client.SSHAgentClient | socket.socket | None = None,
105)     /
106) ) -> Iterator[ssh_agent_client.types.KeyCommentPair]:
107)     """Yield all SSH keys suitable for passphrase derivation.
108) 
109)     Suitable SSH keys are queried from the running SSH agent (see
110)     [`ssh_agent_client.SSHAgentClient.list_keys`][]).
111) 
112)     Args:
113)         conn:
114)             An optional connection hint to the SSH agent; specifically,
115)             an SSH agent client, or a socket connected to an SSH agent.
116) 
117)             If an existing SSH agent client, then this client will be
118)             queried for the SSH keys, and otherwise left intact.
119) 
120)             If a socket, then a one-shot client will be constructed
121)             based on the socket to query the agent, and deconstructed
122)             afterwards.
123) 
124)             If neither are given, then the agent's socket location is
125)             looked up in the `SSH_AUTH_SOCK` environment variable, and
126)             used to construct/deconstruct a one-shot client, as in the
127)             previous case.
128) 
129)     Yields:
130)         :
131)             Every SSH key from the SSH agent that is suitable for
132)             passphrase derivation.
133) 
134)     Raises:
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

135)         LookupError:
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

136)             No keys usable for passphrase derivation are loaded into the
137)             SSH agent.
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

138)         RuntimeError:
139)             There was an error communicating with the SSH agent.
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

140) 
141)     """
142)     client: ssh_agent_client.SSHAgentClient
143)     client_context: contextlib.AbstractContextManager
144)     match conn:
145)         case ssh_agent_client.SSHAgentClient():
146)             client = conn
147)             client_context = contextlib.nullcontext()
148)         case socket.socket() | None:
149)             client = ssh_agent_client.SSHAgentClient(socket=conn)
150)             client_context = client
151)         case _:  # pragma: no cover
152)             assert_never(conn)
153)             raise TypeError(f'invalid connection hint: {conn!r}')
154)     with client_context:
155)         try:
156)             all_key_comment_pairs = list(client.list_keys())
157)         except EOFError as e:  # pragma: no cover
158)             raise RuntimeError(
159)                 'error communicating with the SSH agent'
160)             ) from e
161)     suitable_keys = all_key_comment_pairs[:]
162)     for pair in all_key_comment_pairs:
163)         key, comment = pair
164)         if dpp.Vault._is_suitable_ssh_key(key):
165)             yield pair
166)     if not suitable_keys:  # pragma: no cover
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

167)         raise IndexError('No usable SSH keys were found')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

168) 
169) 
170) def _prompt_for_selection(
171)     items: Sequence[str | bytes], heading: str = 'Possible choices:',
172)     single_choice_prompt: str = 'Confirm this choice?',
173) ) -> int:
174)     """Prompt user for a choice among the given items.
175) 
176)     Print the heading, if any, then present the items to the user.  If
177)     there are multiple items, prompt the user for a selection, validate
178)     the choice, then return the list index of the selected item.  If
179)     there is only a single item, request confirmation for that item
180)     instead, and return the correct index.
181) 
182)     Args:
183)         heading:
184)             A heading for the list of items, to print immediately
185)             before.  Defaults to a reasonable standard heading.  If
186)             explicitly empty, print no heading.
187)         single_choice_prompt:
188)             The confirmation prompt if there is only a single possible
189)             choice.  Defaults to a reasonable standard prompt.
190) 
191)     Returns:
192)         An index into the items sequence, indicating the user's
193)         selection.
194) 
195)     Raises:
196)         IndexError:
197)             The user made an invalid or empty selection, or requested an
198)             abort.
199) 
200)     """
201)     n = len(items)
202)     if heading:
203)         click.echo(click.style(heading, bold=True))
204)     for i, x in enumerate(items, start=1):
205)         click.echo(click.style(f'[{i}]', bold=True), nl=False)
206)         click.echo(' ', nl=False)
207)         click.echo(x)
208)     if n > 1:
209)         choices = click.Choice([''] + [str(i) for i in range(1, n + 1)])
210)         choice = click.prompt(
211)             f'Your selection? (1-{n}, leave empty to abort)',
212)             err=True, type=choices, show_choices=False,
213)             show_default=False, default='')
214)         if not choice:
215)             raise IndexError('empty selection')
216)         return int(choice) - 1
217)     else:
218)         prompt_suffix = (' '
219)                          if single_choice_prompt.endswith(tuple('?.!'))
220)                          else ': ')
221)         try:
222)             click.confirm(single_choice_prompt,
223)                           prompt_suffix=prompt_suffix, err=True,
224)                           abort=True, default=False, show_default=False)
225)         except click.Abort:
226)             raise IndexError('empty selection') from None
227)         return 0
228) 
229) 
230) def _select_ssh_key(
231)     conn: ssh_agent_client.SSHAgentClient | socket.socket | None = None,
232)     /
233) ) -> bytes | bytearray:
234)     """Interactively select an SSH key for passphrase derivation.
235) 
236)     Suitable SSH keys are queried from the running SSH agent (see
237)     [`ssh_agent_client.SSHAgentClient.list_keys`][]), then the user is
238)     prompted interactively (see [`click.prompt`][]) for a selection.
239) 
240)     Args:
241)         conn:
242)             An optional connection hint to the SSH agent; specifically,
243)             an SSH agent client, or a socket connected to an SSH agent.
244) 
245)             If an existing SSH agent client, then this client will be
246)             queried for the SSH keys, and otherwise left intact.
247) 
248)             If a socket, then a one-shot client will be constructed
249)             based on the socket to query the agent, and deconstructed
250)             afterwards.
251) 
252)             If neither are given, then the agent's socket location is
253)             looked up in the `SSH_AUTH_SOCK` environment variable, and
254)             used to construct/deconstruct a one-shot client, as in the
255)             previous case.
256) 
257)     Returns:
258)         The selected SSH key.
259) 
260)     Raises:
261)         IndexError:
262)             The user made an invalid or empty selection, or requested an
263)             abort.
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

264)         LookupError:
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

265)             No keys usable for passphrase derivation are loaded into the
266)             SSH agent.
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

267)         RuntimeError:
268)             There was an error communicating with the SSH agent.
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

269)     """
270)     suitable_keys = list(_get_suitable_ssh_keys(conn))
271)     key_listing: list[str] = []
272)     unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix
273)     for key, comment in suitable_keys:
274)         keytype = unstring_prefix(key)[0].decode('ASCII')
275)         key_str = base64.standard_b64encode(key).decode('ASCII')
276)         key_prefix = key_str if len(key_str) < 30 else key_str[:27] + '...'
277)         comment_str = comment.decode('UTF-8', errors='replace')
278)         key_listing.append(f'{keytype} {key_prefix} {comment_str}')
279)     choice = _prompt_for_selection(
280)         key_listing, heading='Suitable SSH keys:',
281)         single_choice_prompt='Use this key?')
282)     return suitable_keys[choice].key
283) 
284) 
285) def _prompt_for_passphrase() -> str:
286)     """Interactively prompt for the passphrase.
287) 
288)     Calls [`click.prompt`][] internally.  Moved into a separate function
289)     mainly for testing/mocking purposes.
290) 
291)     Returns:
292)         The user input.
293) 
294)     """
295)     return click.prompt('Passphrase', default='', hide_input=True,
296)                         show_default=False, err=True)
297) 
298) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

299) class OptionGroupOption(click.Option):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

300)     """A [`click.Option`][] with an associated group name and group epilog.
301) 
302)     Used by [`derivepassphrase.cli.CommandWithHelpGroups`][] to print
303)     help sections.  Each subclass contains its own group name and
304)     epilog.
305) 
306)     Attributes:
307)         option_group_name:
308)             The name of the option group.  Used as a heading on the help
309)             text for options in this section.
310)         epilog:
311)             An epilog to print after listing the options in this
312)             section.
313) 
314)     """
315)     option_group_name: str = ''
316)     epilog: str = ''
317) 
318)     def __init__(self, *args, **kwargs):  # type: ignore
319)         if self.__class__ == __class__:
320)             raise NotImplementedError()
321)         return super().__init__(*args, **kwargs)
322) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

323) 
324) class CommandWithHelpGroups(click.Command):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

325)     """A [`click.Command`][] with support for help/option groups.
326) 
327)     Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE], and
328)     further modified to support group epilogs.
329) 
330)     [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746
331) 
332)     """
333) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

334)     def format_options(
335)         self, ctx: click.Context, formatter: click.HelpFormatter,
336)     ) -> None:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

337)         r"""Format options on the help listing, grouped into sections.
338) 
Marco Ricci Add minor documentation rew...

Marco Ricci authored 2 months ago

339)         This is a callback for [`click.Command.get_help`][] that
340)         implements the `--help` listing, by calling appropriate methods
341)         of the `formatter`.  We list all options (like the base
342)         implementation), but grouped into sections according to the
343)         concrete [`click.Option`][] subclass being used.  If the option
344)         is an instance of some subclass `X` of
345)         [`derivepassphrase.cli.OptionGroupOption`][], then the section
346)         heading and the epilog are taken from `X.option_group_name` and
347)         `X.epilog`; otherwise, the section heading is "Options" (or
348)         "Other options" if there are other option groups) and the epilog
349)         is empty.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

350) 
351)         Args:
352)             ctx:
353)                 The click context.
354)             formatter:
355)                 The formatter for the `--help` listing.
356) 
Marco Ricci Add minor documentation rew...

Marco Ricci authored 2 months ago

357)         Returns:
358)             Nothing.  Output is generated by calling appropriate methods
359)             on `formatter` instead.
360) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

361)         """
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

362)         help_records: dict[str, list[tuple[str, str]]] = {}
363)         epilogs: dict[str, str] = {}
364)         params = self.params[:]
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

365)         if (  # pragma: no branch
366)             (help_opt := self.get_help_option(ctx)) is not None
367)             and help_opt not in params
368)         ):
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

369)             params.append(help_opt)
370)         for param in params:
371)             rec = param.get_help_record(ctx)
372)             if rec is not None:
373)                 if isinstance(param, OptionGroupOption):
374)                     group_name = param.option_group_name
375)                     epilogs.setdefault(group_name, param.epilog)
376)                 else:
377)                     group_name = ''
378)                 help_records.setdefault(group_name, []).append(rec)
379)         default_group = help_records.pop('')
380)         default_group_name = ('Other Options' if len(default_group) > 1
381)                               else 'Options')
382)         help_records[default_group_name] = default_group
383)         for group_name, records in help_records.items():
384)             with formatter.section(group_name):
385)                 formatter.write_dl(records)
386)             epilog = inspect.cleandoc(epilogs.get(group_name, ''))
387)             if epilog:
388)                 formatter.write_paragraph()
389)                 with formatter.indentation():
390)                     formatter.write_text(epilog)
391) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

392) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

393) # Concrete option groups used by this command-line interface.
394) class PasswordGenerationOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

395)     """Password generation options for the CLI."""
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

396)     option_group_name = 'Password generation'
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

397)     epilog = '''
398)         Use NUMBER=0, e.g. "--symbol 0", to exclude a character type
399)         from the output.
400)     '''
401) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

402) 
403) class ConfigurationOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

404)     """Configuration options for the CLI."""
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

405)     option_group_name = 'Configuration'
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

406)     epilog = '''
407)         Use $VISUAL or $EDITOR to configure the spawned editor.
408)     '''
409) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

410) 
411) class StorageManagementOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

412)     """Storage management options for the CLI."""
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

413)     option_group_name = 'Storage management'
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

414)     epilog = '''
415)         Using "-" as PATH for standard input/standard output is
416)         supported.
417)     '''
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

418) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

419) def _validate_occurrence_constraint(
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

420)     ctx: click.Context, param: click.Parameter, value: Any,
421) ) -> int | None:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

422)     """Check that the occurrence constraint is valid (int, 0 or larger)."""
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

423)     if value is None:
424)         return value
425)     if isinstance(value, int):
426)         int_value = value
427)     else:
428)         try:
429)             int_value = int(value, 10)
430)         except ValueError as e:
431)             raise click.BadParameter('not an integer') from e
432)     if int_value < 0:
433)         raise click.BadParameter('not a non-negative integer')
434)     return int_value
435) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

436) 
437) def _validate_length(
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

438)     ctx: click.Context, param: click.Parameter, value: Any,
439) ) -> int | None:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

440)     """Check that the length is valid (int, 1 or larger)."""
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

441)     if value is None:
442)         return value
443)     if isinstance(value, int):
444)         int_value = value
445)     else:
446)         try:
447)             int_value = int(value, 10)
448)         except ValueError as e:
449)             raise click.BadParameter('not an integer') from e
450)     if int_value < 1:
451)         raise click.BadParameter('not a positive integer')
452)     return int_value
453) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

454) DEFAULT_NOTES_TEMPLATE = '''\
455) # Enter notes below the line with the cut mark (ASCII scissors and
456) # dashes).  Lines above the cut mark (such as this one) will be ignored.
457) #
458) # If you wish to clear the notes, leave everything beyond the cut mark
459) # blank.  However, if you leave the *entire* file blank, also removing
460) # the cut mark, then the edit is aborted, and the old notes contents are
461) # retained.
462) #
463) # - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -
464) '''
465) DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
466) 
467) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

468) @click.command(
469)     context_settings={"help_option_names": ["-h", "--help"]},
470)     cls=CommandWithHelpGroups,
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

471)     epilog=r'''
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

472)         WARNING: There is NO WAY to retrieve the generated passphrases
473)         if the master passphrase, the SSH key, or the exact passphrase
474)         settings are lost, short of trying out all possible
475)         combinations.  You are STRONGLY advised to keep independent
476)         backups of the settings and the SSH key, if any.
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

477) 
478)         Configuration is stored in a directory according to the
479)         DERIVEPASSPHRASE_PATH variable, which defaults to
480)         `~/.derivepassphrase` on UNIX-like systems and
481)         `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
482)         The configuration is NOT encrypted, and you are STRONGLY
483)         discouraged from using a stored passphrase.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

484)     ''',
485) )
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

486) @click.option('-p', '--phrase', 'use_phrase', is_flag=True,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

487)               help='prompts you for your passphrase',
488)               cls=PasswordGenerationOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

489) @click.option('-k', '--key', 'use_key', is_flag=True,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

490)               help='uses your SSH private key to generate passwords',
491)               cls=PasswordGenerationOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

492) @click.option('-l', '--length', metavar='NUMBER',
493)               callback=_validate_length,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

494)               help='emits password of length NUMBER',
495)               cls=PasswordGenerationOption)
496) @click.option('-r', '--repeat', metavar='NUMBER',
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

497)               callback=_validate_occurrence_constraint,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

498)               help='allows maximum of NUMBER repeated adjacent chars',
499)               cls=PasswordGenerationOption)
500) @click.option('--lower', metavar='NUMBER',
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

501)               callback=_validate_occurrence_constraint,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

502)               help='includes at least NUMBER lowercase letters',
503)               cls=PasswordGenerationOption)
504) @click.option('--upper', metavar='NUMBER',
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

505)               callback=_validate_occurrence_constraint,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

506)               help='includes at least NUMBER uppercase letters',
507)               cls=PasswordGenerationOption)
508) @click.option('--number', metavar='NUMBER',
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

509)               callback=_validate_occurrence_constraint,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

510)               help='includes at least NUMBER digits',
511)               cls=PasswordGenerationOption)
512) @click.option('--space', metavar='NUMBER',
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

513)               callback=_validate_occurrence_constraint,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

514)               help='includes at least NUMBER spaces',
515)               cls=PasswordGenerationOption)
516) @click.option('--dash', metavar='NUMBER',
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

517)               callback=_validate_occurrence_constraint,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

518)               help='includes at least NUMBER "-" or "_"',
519)               cls=PasswordGenerationOption)
520) @click.option('--symbol', metavar='NUMBER',
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

521)               callback=_validate_occurrence_constraint,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

522)               help='includes at least NUMBER symbol chars',
523)               cls=PasswordGenerationOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

524) @click.option('-n', '--notes', 'edit_notes', is_flag=True,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

525)               help='spawn an editor to edit notes for SERVICE',
526)               cls=ConfigurationOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

527) @click.option('-c', '--config', 'store_config_only', is_flag=True,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

528)               help='saves the given settings for SERVICE or global',
529)               cls=ConfigurationOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

530) @click.option('-x', '--delete', 'delete_service_settings', is_flag=True,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

531)               help='deletes settings for SERVICE',
532)               cls=ConfigurationOption)
533) @click.option('--delete-globals', is_flag=True,
534)               help='deletes the global shared settings',
535)               cls=ConfigurationOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

536) @click.option('-X', '--clear', 'clear_all_settings', is_flag=True,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

537)               help='deletes all settings',
538)               cls=ConfigurationOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

539) @click.option('-e', '--export', 'export_settings', metavar='PATH',
540)               type=click.Path(file_okay=True, allow_dash=True, exists=False),
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

541)               help='export all saved settings into file PATH',
542)               cls=StorageManagementOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

543) @click.option('-i', '--import', 'import_settings', metavar='PATH',
544)               type=click.Path(file_okay=True, allow_dash=True, exists=False),
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

545)               help='import saved settings from file PATH',
546)               cls=StorageManagementOption)
547) @click.version_option(version=dpp.__version__, prog_name=prog_name)
548) @click.argument('service', required=False)
549) @click.pass_context
550) def derivepassphrase(
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

551)     ctx: click.Context, /, *,
552)     service: str | None = None,
553)     use_phrase: bool = False,
554)     use_key: bool = False,
555)     length: int | None = None,
556)     repeat: int | None = None,
557)     lower: int | None = None,
558)     upper: int | None = None,
559)     number: int | None = None,
560)     space: int | None = None,
561)     dash: int | None = None,
562)     symbol: int | None = None,
563)     edit_notes: bool = False,
564)     store_config_only: bool = False,
565)     delete_service_settings: bool = False,
566)     delete_globals: bool = False,
567)     clear_all_settings: bool = False,
568)     export_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
569)     import_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

570) ) -> None:
571)     """Derive a strong passphrase, deterministically, from a master secret.
572) 
Marco Ricci Fill out README and documen...

Marco Ricci authored 2 months ago

573)     Using a master passphrase or a master SSH key, derive a passphrase
574)     for SERVICE, subject to length, character and character repetition
575)     constraints.  The derivation is cryptographically strong, meaning
576)     that even if a single passphrase is compromised, guessing the master
577)     passphrase or a different service's passphrase is computationally
578)     infeasible.  The derivation is also deterministic, given the same
579)     inputs, thus the resulting passphrase need not be stored explicitly.
580)     The service name and constraints themselves also need not be kept
581)     secret; the latter are usually stored in a world-readable file.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

582) 
583)     If operating on global settings, or importing/exporting settings,
584)     then SERVICE must be omitted.  Otherwise it is required.\f
585) 
586)     This is a [`click`][CLICK]-powered command-line interface function,
587)     and not intended for programmatic use.  Call with arguments
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

588)     `['--help']` to see full documentation of the interface.  (See also
589)     [`click.testing.CliRunner`][] for controlled, programmatic
590)     invocation.)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

591) 
592)     [CLICK]: https://click.palletsprojects.com/
593) 
594)     Parameters:
595)         ctx (click.Context):
596)             The `click` context.
597) 
598)     Other Parameters:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

599)         service:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

600)             A service name.  Required, unless operating on global
601)             settings or importing/exporting settings.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

602)         use_phrase:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

603)             Command-line argument `-p`/`--phrase`.  If given, query the
604)             user for a passphrase instead of an SSH key.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

605)         use_key:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

606)             Command-line argument `-k`/`--key`.  If given, query the
607)             user for an SSH key instead of a passphrase.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

608)         length:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

609)             Command-line argument `-l`/`--length`.  Override the default
610)             length of the generated passphrase.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

611)         repeat:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

612)             Command-line argument `-r`/`--repeat`.  Override the default
613)             repetition limit if positive, or disable the repetition
614)             limit if 0.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

615)         lower:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

616)             Command-line argument `--lower`.  Require a given amount of
617)             ASCII lowercase characters if positive, else forbid ASCII
618)             lowercase characters if 0.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

619)         upper:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

620)             Command-line argument `--upper`.  Same as `lower`, but for
621)             ASCII uppercase characters.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

622)         number:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

623)             Command-line argument `--number`.  Same as `lower`, but for
624)             ASCII digits.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

625)         space:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

626)             Command-line argument `--number`.  Same as `lower`, but for
627)             the space character.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

628)         dash:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

629)             Command-line argument `--number`.  Same as `lower`, but for
630)             the hyphen-minus and underscore characters.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

631)         symbol:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

632)             Command-line argument `--number`.  Same as `lower`, but for
633)             all other ASCII printable characters (except backquote).
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

634)         edit_notes:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

635)             Command-line argument `-n`/`--notes`.  If given, spawn an
636)             editor to edit notes for `service`.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

637)         store_config_only:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

638)             Command-line argument `-c`/`--config`.  If given, saves the
639)             other given settings (`--key`, ..., `--symbol`) to the
640)             configuration file, either specifically for `service` or as
641)             global settings.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

642)         delete_service_settings:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

643)             Command-line argument `-x`/`--delete`.  If given, removes
644)             the settings for `service` from the configuration file.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

645)         delete_globals:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

646)             Command-line argument `--delete-globals`.  If given, removes
647)             the global settings from the configuration file.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

648)         clear_all_settings:
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

649)             Command-line argument `-X`/`--clear`.  If given, removes all
650)             settings from the configuration file.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

651)         export_settings:
652)             Command-line argument `-e`/`--export`.  If a file object,
653)             then it must be open for writing and accept `str` inputs.
654)             Otherwise, a filename to open for writing.  Using `-` for
655)             standard output is supported.
656)         import_settings:
657)             Command-line argument `-i`/`--import`.  If a file object, it
658)             must be open for reading and yield `str` values.  Otherwise,
659)             a filename to open for reading.  Using `-` for standard
660)             input is supported.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

661) 
662)     """
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

663) 
664)     options_in_group: dict[type[click.Option], list[click.Option]] = {}
665)     params_by_str: dict[str, click.Parameter] = {}
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

666)     for param in ctx.command.params:
667)         if isinstance(param, click.Option):
668)             group: type[click.Option]
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

669)             match param:
670)                 case PasswordGenerationOption():
671)                     group = PasswordGenerationOption
672)                 case ConfigurationOption():
673)                     group = ConfigurationOption
674)                 case StorageManagementOption():
675)                     group = StorageManagementOption
676)                 case OptionGroupOption():
677)                     raise AssertionError(
678)                         f'Unknown option group for {param!r}')
679)                 case _:
680)                     group = click.Option
681)             options_in_group.setdefault(group, []).append(param)
682)         params_by_str[param.human_readable_name] = param
683)         for name in param.opts + param.secondary_opts:
684)             params_by_str[name] = param
685) 
686)     def is_param_set(param: click.Parameter):
687)         return bool(ctx.params.get(param.human_readable_name))
688) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

689)     def check_incompatible_options(
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

690)         param: click.Parameter | str, *incompatible: click.Parameter | str,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

691)     ) -> None:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

692)         if isinstance(param, str):
693)             param = params_by_str[param]
694)         assert isinstance(param, click.Parameter)
695)         if not is_param_set(param):
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

696)             return
697)         for other in incompatible:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

698)             if isinstance(other, str):
699)                 other = params_by_str[other]
700)             assert isinstance(other, click.Parameter)
701)             if other != param and is_param_set(other):
702)                 opt_str = param.opts[0]
703)                 other_str = other.opts[0]
704)                 raise click.BadOptionUsage(
705)                     opt_str, f'mutually exclusive with {other_str}', ctx=ctx)
706) 
707)     def get_config() -> dpp_types.VaultConfig:
708)         try:
709)             return _load_config()
710)         except FileNotFoundError:
711)             return {'services': {}}
712)         except Exception as e:
713)             ctx.fail(f'cannot load config: {e}')
714) 
715)     configuration: dpp_types.VaultConfig
716) 
717)     check_incompatible_options('--phrase', '--key')
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

718)     for group in (ConfigurationOption, StorageManagementOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

719)         for opt in options_in_group[group]:
720)             if opt != params_by_str['--config']:
721)                 check_incompatible_options(
722)                     opt, *options_in_group[PasswordGenerationOption])
723) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

724)     for group in (ConfigurationOption, StorageManagementOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

725)         for opt in options_in_group[group]:
726)             check_incompatible_options(
727)                 opt, *options_in_group[ConfigurationOption],
728)                 *options_in_group[StorageManagementOption])
729)     sv_options = (options_in_group[PasswordGenerationOption] +
730)                   [params_by_str['--notes'], params_by_str['--delete']])
731)     sv_options.remove(params_by_str['--key'])
732)     sv_options.remove(params_by_str['--phrase'])
733)     for param in sv_options:
734)         if is_param_set(param) and not service:
735)             opt_str = param.opts[0]
736)             raise click.UsageError(f'{opt_str} requires a SERVICE')
737)     for param in [params_by_str['--key'], params_by_str['--phrase']]:
738)         if (
739)             is_param_set(param)
740)             and not (service or is_param_set(params_by_str['--config']))
741)         ):
742)             opt_str = param.opts[0]
743)             raise click.UsageError(f'{opt_str} requires a SERVICE or --config')
744)     no_sv_options = [params_by_str['--delete-globals'],
745)                      params_by_str['--clear'],
746)                      *options_in_group[StorageManagementOption]]
747)     for param in no_sv_options:
748)         if is_param_set(param) and service:
749)             opt_str = param.opts[0]
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

750)             raise click.UsageError(
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

751)                 f'{opt_str} does not take a SERVICE argument')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

752) 
753)     if edit_notes:
754)         assert service is not None
755)         configuration = get_config()
756)         text = (DEFAULT_NOTES_TEMPLATE +
757)                 configuration['services']
758)                 .get(service, cast(dpp_types.VaultConfigServicesSettings, {}))
759)                 .get('notes', ''))
760)         notes_value = click.edit(text=text)
761)         if notes_value is not None:
762)             notes_lines = collections.deque(notes_value.splitlines(True))
763)             while notes_lines:
764)                 line = notes_lines.popleft()
765)                 if line.startswith(DEFAULT_NOTES_MARKER):
766)                     notes_value = ''.join(notes_lines)
767)                     break
768)             else:
769)                 if not notes_value.strip():
770)                     ctx.fail('not saving new notes: user aborted request')
771)             configuration['services'].setdefault(service, {})['notes'] = (
772)                 notes_value.strip('\n'))
773)             _save_config(configuration)
774)     elif delete_service_settings:
775)         assert service is not None
776)         configuration = get_config()
777)         if service in configuration['services']:
778)             del configuration['services'][service]
779)             _save_config(configuration)
780)     elif delete_globals:
781)         configuration = get_config()
782)         if 'global' in configuration:
783)             del configuration['global']
784)             _save_config(configuration)
785)     elif clear_all_settings:
786)         _save_config({'services': {}})
787)     elif import_settings:
788)         try:
789)             # TODO: keep track of auto-close; try os.dup if feasible
790)             infile = (cast(TextIO, import_settings)
791)                       if hasattr(import_settings, 'close')
792)                       else click.open_file(os.fspath(import_settings), 'rt'))
793)             with infile:
794)                 maybe_config = json.load(infile)
795)         except json.JSONDecodeError as e:
796)             ctx.fail(f'Cannot load config: cannot decode JSON: {e}')
797)         except OSError as e:
798)             ctx.fail(f'Cannot load config: {e.strerror}')
799)         if dpp_types.is_vault_config(maybe_config):
800)             _save_config(maybe_config)
801)         else:
802)             ctx.fail('not a valid config')
803)     elif export_settings:
804)         configuration = get_config()
805)         try:
806)             # TODO: keep track of auto-close; try os.dup if feasible
807)             outfile = (cast(TextIO, export_settings)
808)                        if hasattr(export_settings, 'close')
809)                        else click.open_file(os.fspath(export_settings), 'wt'))
810)             with outfile:
811)                 json.dump(configuration, outfile)
812)         except OSError as e:
813)             ctx.fail('cannot write config: {e.strerror}')
814)     else:
815)         configuration = get_config()
816)         # This block could be type checked more stringently, but this
817)         # would probably involve a lot of code repetition.  Since we
818)         # have a type guarding function anyway, assert that we didn't
819)         # make any mistakes at the end instead.
820)         global_keys = {'key', 'phrase'}
821)         service_keys = {'key', 'phrase', 'length', 'repeat', 'lower',
822)                         'upper', 'number', 'space', 'dash', 'symbol'}
823)         settings: collections.ChainMap[str, Any] = collections.ChainMap(
824)             {k: v for k, v in locals().items()
825)              if k in service_keys and v is not None},
826)             cast(dict[str, Any],
827)                  configuration['services'].get(service or '', {})),
828)             {},
829)             cast(dict[str, Any], configuration.get('global', {}))
830)         )
831)         if use_key:
832)             try:
833)                 key = base64.standard_b64encode(
834)                     _select_ssh_key()).decode('ASCII')
835)             except IndexError:
836)                 ctx.fail('no valid SSH key selected')
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

837)             except (LookupError, RuntimeError) as e:
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

838)                 ctx.fail(str(e))
839)         elif use_phrase:
840)             maybe_phrase = _prompt_for_passphrase()
841)             if not maybe_phrase:
842)                 ctx.fail('no passphrase given')
843)             else:
844)                 phrase = maybe_phrase
845)         if store_config_only:
846)             view: collections.ChainMap[str, Any]
847)             view = (collections.ChainMap(*settings.maps[:2]) if service
848)                     else settings.parents.parents)
849)             if use_key:
850)                 view['key'] = key
851)                 for m in view.maps:
852)                     m.pop('phrase', '')
853)             elif use_phrase:
854)                 view['phrase'] = phrase
855)                 for m in view.maps:
856)                     m.pop('key', '')
857)             if service:
858)                 if not view.maps[0]:
859)                     raise click.UsageError('cannot update service settings '
860)                                            'without actual settings')
861)                 else:
862)                     configuration['services'].setdefault(
863)                         service, {}).update(view)  # type: ignore[typeddict-item]
864)             else:
865)                 if not view.maps[0]:
866)                     raise click.UsageError('cannot update global settings '
867)                                            'without actual settings')
868)                 else:
869)                     configuration.setdefault(
870)                         'global', {}).update(view)  # type: ignore[typeddict-item]
871)             assert dpp_types.is_vault_config(configuration), (
872)                 f'invalid vault configuration: {configuration!r}'
873)             )
874)             _save_config(configuration)
875)         else:
876)             if not service:
877)                 raise click.UsageError(f'SERVICE is required')
878)             kwargs: dict[str, Any] = {k: v for k, v in settings.items()
879)                                       if k in service_keys and v is not None}
880)             # If either --key or --phrase are given, use that setting.
881)             # Otherwise, if both key and phrase are set in the config,
882)             # one must be global (ignore it) and one must be
883)             # service-specific (use that one). Otherwise, if only one of
884)             # key and phrase is set in the config, use that one.  In all
885)             # these above cases, set the phrase via
886)             # derivepassphrase.Vault.phrase_from_key if a key is
887)             # given. Finally, if nothing is set, error out.
888)             key_to_phrase = lambda key: dpp.Vault.phrase_from_key(
889)                 base64.standard_b64decode(key))
890)             if use_key or use_phrase:
891)                 if use_key:
892)                     kwargs['phrase'] = key_to_phrase(key)
893)                 else:
894)                     kwargs['phrase'] = phrase
895)                     kwargs.pop('key', '')
896)             elif kwargs.get('phrase') and kwargs.get('key'):
897)                 if any('key' in m for m in settings.maps[:2]):
898)                     kwargs['phrase'] = key_to_phrase(kwargs.pop('key'))
899)                 else:
900)                     kwargs.pop('key')
901)             elif kwargs.get('key'):
902)                 kwargs['phrase'] = key_to_phrase(kwargs.pop('key'))
903)             elif kwargs.get('phrase'):
904)                 pass
905)             else:
906)                 raise click.UsageError(
907)                     'no passphrase or key given on command-line '
908)                     'or in configuration')
909)             vault = dpp.Vault(**kwargs)
910)             result = vault.generate(service)
911)             click.echo(result.decode('ASCII'))