a101d1f604a3ad6de242989f7a4887b78ab012a1
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 Fix style issues with ruff...

Marco Ricci authored 1 month ago

14) import copy
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

17) import os
18) import socket
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

19) from typing import (
20)     TYPE_CHECKING,
21)     TextIO,
22)     cast,
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

24) 
25) import click
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

26) from typing_extensions import (
27)     Any,
28)     assert_never,
29) )
30) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

31) import derivepassphrase as dpp
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

32) import ssh_agent_client
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

33) from derivepassphrase import types as dpp_types
34) 
35) if TYPE_CHECKING:
36)     import pathlib
37)     from collections.abc import (
38)         Iterator,
39)         Sequence,
40)     )
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

41) 
42) __author__ = dpp.__author__
43) __version__ = dpp.__version__
44) 
45) __all__ = ('derivepassphrase',)
46) 
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

47) PROG_NAME = 'derivepassphrase'
48) KEY_DISPLAY_LENGTH = 30
49) 
50) # Error messages
51) _INVALID_VAULT_CONFIG = 'Invalid vault config'
52) _AGENT_COMMUNICATION_ERROR = 'Error communicating with the SSH agent'
53) _NO_USABLE_KEYS = 'No usable SSH keys were found'
54) _EMPTY_SELECTION = 'Empty selection'
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

55) 
56) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

57) def _config_filename() -> str | bytes | pathlib.Path:
58)     """Return the filename of the configuration file.
59) 
60)     The file is currently named `settings.json`, located within the
61)     configuration directory as determined by the `DERIVEPASSPHRASE_PATH`
62)     environment variable, or by [`click.get_app_dir`][] in POSIX
63)     mode.
64) 
65)     """
66)     path: str | bytes | pathlib.Path
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

67)     path = (os.getenv(PROG_NAME.upper() + '_PATH')
68)             or click.get_app_dir(PROG_NAME, force_posix=True))
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

69)     return os.path.join(path, 'settings.json')
70) 
71) 
72) def _load_config() -> dpp_types.VaultConfig:
73)     """Load a vault(1)-compatible config from the application directory.
74) 
75)     The filename is obtained via
76)     [`derivepassphrase.cli._config_filename`][].  This must be an
77)     unencrypted JSON file.
78) 
79)     Returns:
80)         The vault settings.  See
81)         [`derivepassphrase.types.VaultConfig`][] for details.
82) 
83)     Raises:
84)         OSError:
85)             There was an OS error accessing the file.
86)         ValueError:
87)             The data loaded from the file is not a vault(1)-compatible
88)             config.
89) 
90)     """
91)     filename = _config_filename()
92)     with open(filename, 'rb') as fileobj:
93)         data = json.load(fileobj)
94)     if not dpp_types.is_vault_config(data):
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

95)         raise ValueError(_INVALID_VAULT_CONFIG)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

96)     return data
97) 
98) 
99) def _save_config(config: dpp_types.VaultConfig, /) -> None:
100)     """Save a vault(1)-compatbile config to the application directory.
101) 
102)     The filename is obtained via
103)     [`derivepassphrase.cli._config_filename`][].  The config will be
104)     stored as an unencrypted JSON file.
105) 
106)     Args:
107)         config:
108)             vault configuration to save.
109) 
110)     Raises:
111)         OSError:
112)             There was an OS error accessing or writing the file.
113)         ValueError:
114)             The data cannot be stored as a vault(1)-compatible config.
115) 
116)     """
117)     if not dpp_types.is_vault_config(config):
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

118)         raise ValueError(_INVALID_VAULT_CONFIG)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

119)     filename = _config_filename()
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

120)     with open(filename, 'w', encoding='UTF-8') as fileobj:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

121)         json.dump(config, fileobj)
122) 
123) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

124) def _get_suitable_ssh_keys(
125)     conn: ssh_agent_client.SSHAgentClient | socket.socket | None = None,
126)     /
127) ) -> Iterator[ssh_agent_client.types.KeyCommentPair]:
128)     """Yield all SSH keys suitable for passphrase derivation.
129) 
130)     Suitable SSH keys are queried from the running SSH agent (see
131)     [`ssh_agent_client.SSHAgentClient.list_keys`][]).
132) 
133)     Args:
134)         conn:
135)             An optional connection hint to the SSH agent; specifically,
136)             an SSH agent client, or a socket connected to an SSH agent.
137) 
138)             If an existing SSH agent client, then this client will be
139)             queried for the SSH keys, and otherwise left intact.
140) 
141)             If a socket, then a one-shot client will be constructed
142)             based on the socket to query the agent, and deconstructed
143)             afterwards.
144) 
145)             If neither are given, then the agent's socket location is
146)             looked up in the `SSH_AUTH_SOCK` environment variable, and
147)             used to construct/deconstruct a one-shot client, as in the
148)             previous case.
149) 
150)     Yields:
151)         :
152)             Every SSH key from the SSH agent that is suitable for
153)             passphrase derivation.
154) 
155)     Raises:
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

161) 
162)     """
163)     client: ssh_agent_client.SSHAgentClient
164)     client_context: contextlib.AbstractContextManager
165)     match conn:
166)         case ssh_agent_client.SSHAgentClient():
167)             client = conn
168)             client_context = contextlib.nullcontext()
169)         case socket.socket() | None:
170)             client = ssh_agent_client.SSHAgentClient(socket=conn)
171)             client_context = client
172)         case _:  # pragma: no cover
173)             assert_never(conn)
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

174)             msg = f'invalid connection hint: {conn!r}'
175)             raise TypeError(msg)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

176)     with client_context:
177)         try:
178)             all_key_comment_pairs = list(client.list_keys())
179)         except EOFError as e:  # pragma: no cover
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

180)             raise RuntimeError(_AGENT_COMMUNICATION_ERROR) from e
181)     suitable_keys = copy.copy(all_key_comment_pairs)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

182)     for pair in all_key_comment_pairs:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

183)         key, _comment = pair
184)         if dpp.Vault._is_suitable_ssh_key(key):  # noqa: SLF001
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

185)             yield pair
186)     if not suitable_keys:  # pragma: no cover
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

187)         raise IndexError(_NO_USABLE_KEYS)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

188) 
189) 
190) def _prompt_for_selection(
191)     items: Sequence[str | bytes], heading: str = 'Possible choices:',
192)     single_choice_prompt: str = 'Confirm this choice?',
193) ) -> int:
194)     """Prompt user for a choice among the given items.
195) 
196)     Print the heading, if any, then present the items to the user.  If
197)     there are multiple items, prompt the user for a selection, validate
198)     the choice, then return the list index of the selected item.  If
199)     there is only a single item, request confirmation for that item
200)     instead, and return the correct index.
201) 
202)     Args:
203)         heading:
204)             A heading for the list of items, to print immediately
205)             before.  Defaults to a reasonable standard heading.  If
206)             explicitly empty, print no heading.
207)         single_choice_prompt:
208)             The confirmation prompt if there is only a single possible
209)             choice.  Defaults to a reasonable standard prompt.
210) 
211)     Returns:
212)         An index into the items sequence, indicating the user's
213)         selection.
214) 
215)     Raises:
216)         IndexError:
217)             The user made an invalid or empty selection, or requested an
218)             abort.
219) 
220)     """
221)     n = len(items)
222)     if heading:
223)         click.echo(click.style(heading, bold=True))
224)     for i, x in enumerate(items, start=1):
225)         click.echo(click.style(f'[{i}]', bold=True), nl=False)
226)         click.echo(' ', nl=False)
227)         click.echo(x)
228)     if n > 1:
229)         choices = click.Choice([''] + [str(i) for i in range(1, n + 1)])
230)         choice = click.prompt(
231)             f'Your selection? (1-{n}, leave empty to abort)',
232)             err=True, type=choices, show_choices=False,
233)             show_default=False, default='')
234)         if not choice:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

235)             raise IndexError(_EMPTY_SELECTION)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

236)         return int(choice) - 1
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

237)     prompt_suffix = (' '
238)                      if single_choice_prompt.endswith(tuple('?.!'))
239)                      else ': ')
240)     try:
241)         click.confirm(single_choice_prompt,
242)                       prompt_suffix=prompt_suffix, err=True,
243)                       abort=True, default=False, show_default=False)
244)     except click.Abort:
245)         raise IndexError(_EMPTY_SELECTION) from None
246)     return 0
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

247) 
248) 
249) def _select_ssh_key(
250)     conn: ssh_agent_client.SSHAgentClient | socket.socket | None = None,
251)     /
252) ) -> bytes | bytearray:
253)     """Interactively select an SSH key for passphrase derivation.
254) 
255)     Suitable SSH keys are queried from the running SSH agent (see
256)     [`ssh_agent_client.SSHAgentClient.list_keys`][]), then the user is
257)     prompted interactively (see [`click.prompt`][]) for a selection.
258) 
259)     Args:
260)         conn:
261)             An optional connection hint to the SSH agent; specifically,
262)             an SSH agent client, or a socket connected to an SSH agent.
263) 
264)             If an existing SSH agent client, then this client will be
265)             queried for the SSH keys, and otherwise left intact.
266) 
267)             If a socket, then a one-shot client will be constructed
268)             based on the socket to query the agent, and deconstructed
269)             afterwards.
270) 
271)             If neither are given, then the agent's socket location is
272)             looked up in the `SSH_AUTH_SOCK` environment variable, and
273)             used to construct/deconstruct a one-shot client, as in the
274)             previous case.
275) 
276)     Returns:
277)         The selected SSH key.
278) 
279)     Raises:
280)         IndexError:
281)             The user made an invalid or empty selection, or requested an
282)             abort.
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

288)     """
289)     suitable_keys = list(_get_suitable_ssh_keys(conn))
290)     key_listing: list[str] = []
291)     unstring_prefix = ssh_agent_client.SSHAgentClient.unstring_prefix
292)     for key, comment in suitable_keys:
293)         keytype = unstring_prefix(key)[0].decode('ASCII')
294)         key_str = base64.standard_b64encode(key).decode('ASCII')
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

295)         key_prefix = (key_str
296)                       if len(key_str) < KEY_DISPLAY_LENGTH + len('...')
297)                       else key_str[:KEY_DISPLAY_LENGTH] + '...')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

298)         comment_str = comment.decode('UTF-8', errors='replace')
299)         key_listing.append(f'{keytype} {key_prefix} {comment_str}')
300)     choice = _prompt_for_selection(
301)         key_listing, heading='Suitable SSH keys:',
302)         single_choice_prompt='Use this key?')
303)     return suitable_keys[choice].key
304) 
305) 
306) def _prompt_for_passphrase() -> str:
307)     """Interactively prompt for the passphrase.
308) 
309)     Calls [`click.prompt`][] internally.  Moved into a separate function
310)     mainly for testing/mocking purposes.
311) 
312)     Returns:
313)         The user input.
314) 
315)     """
316)     return click.prompt('Passphrase', default='', hide_input=True,
317)                         show_default=False, err=True)
318) 
319) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

321)     """A [`click.Option`][] with an associated group name and group epilog.
322) 
323)     Used by [`derivepassphrase.cli.CommandWithHelpGroups`][] to print
324)     help sections.  Each subclass contains its own group name and
325)     epilog.
326) 
327)     Attributes:
328)         option_group_name:
329)             The name of the option group.  Used as a heading on the help
330)             text for options in this section.
331)         epilog:
332)             An epilog to print after listing the options in this
333)             section.
334) 
335)     """
336)     option_group_name: str = ''
337)     epilog: str = ''
338) 
339)     def __init__(self, *args, **kwargs):  # type: ignore
340)         if self.__class__ == __class__:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

341)             raise NotImplementedError
342)         super().__init__(*args, **kwargs)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

344) 
345) class CommandWithHelpGroups(click.Command):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

346)     """A [`click.Command`][] with support for help/option groups.
347) 
348)     Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE], and
349)     further modified to support group epilogs.
350) 
351)     [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746
352) 
353)     """
354) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

355)     def format_options(
356)         self, ctx: click.Context, formatter: click.HelpFormatter,
357)     ) -> None:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

360)         This is a callback for [`click.Command.get_help`][] that
361)         implements the `--help` listing, by calling appropriate methods
362)         of the `formatter`.  We list all options (like the base
363)         implementation), but grouped into sections according to the
364)         concrete [`click.Option`][] subclass being used.  If the option
365)         is an instance of some subclass `X` of
366)         [`derivepassphrase.cli.OptionGroupOption`][], then the section
367)         heading and the epilog are taken from `X.option_group_name` and
368)         `X.epilog`; otherwise, the section heading is "Options" (or
369)         "Other options" if there are other option groups) and the epilog
370)         is empty.
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

371) 
372)         Args:
373)             ctx:
374)                 The click context.
375)             formatter:
376)                 The formatter for the `--help` listing.
377) 
Marco Ricci Add minor documentation rew...

Marco Ricci authored 2 months ago

378)         Returns:
379)             Nothing.  Output is generated by calling appropriate methods
380)             on `formatter` instead.
381) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

383)         help_records: dict[str, list[tuple[str, str]]] = {}
384)         epilogs: dict[str, str] = {}
385)         params = self.params[:]
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

386)         if (  # pragma: no branch
387)             (help_opt := self.get_help_option(ctx)) is not None
388)             and help_opt not in params
389)         ):
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

390)             params.append(help_opt)
391)         for param in params:
392)             rec = param.get_help_record(ctx)
393)             if rec is not None:
394)                 if isinstance(param, OptionGroupOption):
395)                     group_name = param.option_group_name
396)                     epilogs.setdefault(group_name, param.epilog)
397)                 else:
398)                     group_name = ''
399)                 help_records.setdefault(group_name, []).append(rec)
400)         default_group = help_records.pop('')
401)         default_group_name = ('Other Options' if len(default_group) > 1
402)                               else 'Options')
403)         help_records[default_group_name] = default_group
404)         for group_name, records in help_records.items():
405)             with formatter.section(group_name):
406)                 formatter.write_dl(records)
407)             epilog = inspect.cleandoc(epilogs.get(group_name, ''))
408)             if epilog:
409)                 formatter.write_paragraph()
410)                 with formatter.indentation():
411)                     formatter.write_text(epilog)
412) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

418)     epilog = '''
419)         Use NUMBER=0, e.g. "--symbol 0", to exclude a character type
420)         from the output.
421)     '''
422) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

423) 
424) class ConfigurationOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

427)     epilog = '''
428)         Use $VISUAL or $EDITOR to configure the spawned editor.
429)     '''
430) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

431) 
432) class StorageManagementOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

435)     epilog = '''
436)         Using "-" as PATH for standard input/standard output is
437)         supported.
438)     '''
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

439) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

443)     """Check that the occurrence constraint is valid (int, 0 or larger)."""
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

444)     del ctx    # Unused.
445)     del param  # Unused.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

446)     if value is None:
447)         return value
448)     if isinstance(value, int):
449)         int_value = value
450)     else:
451)         try:
452)             int_value = int(value, 10)
453)         except ValueError as e:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

454)             msg = 'not an integer'
455)             raise click.BadParameter(msg) from e
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

456)     if int_value < 0:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

457)         msg = 'not a non-negative integer'
458)         raise click.BadParameter(msg)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

459)     return int_value
460) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

461) 
462) def _validate_length(
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

465)     """Check that the length is valid (int, 1 or larger)."""
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

466)     del ctx    # Unused.
467)     del param  # Unused.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

468)     if value is None:
469)         return value
470)     if isinstance(value, int):
471)         int_value = value
472)     else:
473)         try:
474)             int_value = int(value, 10)
475)         except ValueError as e:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

476)             msg = 'not an integer'
477)             raise click.BadParameter(msg) from e
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

478)     if int_value < 1:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

479)         msg = 'not a positive integer'
480)         raise click.BadParameter(msg)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

481)     return int_value
482) 
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

483) DEFAULT_NOTES_TEMPLATE = '''\
484) # Enter notes below the line with the cut mark (ASCII scissors and
485) # dashes).  Lines above the cut mark (such as this one) will be ignored.
486) #
487) # If you wish to clear the notes, leave everything beyond the cut mark
488) # blank.  However, if you leave the *entire* file blank, also removing
489) # the cut mark, then the edit is aborted, and the old notes contents are
490) # retained.
491) #
492) # - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -
493) '''
494) DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
495) 
496) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

506) 
507)         Configuration is stored in a directory according to the
508)         DERIVEPASSPHRASE_PATH variable, which defaults to
509)         `~/.derivepassphrase` on UNIX-like systems and
510)         `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.
511)         The configuration is NOT encrypted, and you are STRONGLY
512)         discouraged from using a stored passphrase.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

513)     ''',
514) )
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

560)               help='deletes settings for SERVICE',
561)               cls=ConfigurationOption)
562) @click.option('--delete-globals', is_flag=True,
563)               help='deletes the global shared settings',
564)               cls=ConfigurationOption)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

574)               help='import saved settings from file PATH',
575)               cls=StorageManagementOption)
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

576) @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

577) @click.argument('service', required=False)
578) @click.pass_context
579) def derivepassphrase(
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

580)     ctx: click.Context, /, *,
581)     service: str | None = None,
582)     use_phrase: bool = False,
583)     use_key: bool = False,
584)     length: int | None = None,
585)     repeat: int | None = None,
586)     lower: int | None = None,
587)     upper: int | None = None,
588)     number: int | None = None,
589)     space: int | None = None,
590)     dash: int | None = None,
591)     symbol: int | None = None,
592)     edit_notes: bool = False,
593)     store_config_only: bool = False,
594)     delete_service_settings: bool = False,
595)     delete_globals: bool = False,
596)     clear_all_settings: bool = False,
597)     export_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
598)     import_settings: TextIO | pathlib.Path | os.PathLike[str] | None = None,
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

599) ) -> None:
600)     """Derive a strong passphrase, deterministically, from a master secret.
601) 
Marco Ricci Fill out README and documen...

Marco Ricci authored 2 months ago

602)     Using a master passphrase or a master SSH key, derive a passphrase
603)     for SERVICE, subject to length, character and character repetition
604)     constraints.  The derivation is cryptographically strong, meaning
605)     that even if a single passphrase is compromised, guessing the master
606)     passphrase or a different service's passphrase is computationally
607)     infeasible.  The derivation is also deterministic, given the same
608)     inputs, thus the resulting passphrase need not be stored explicitly.
609)     The service name and constraints themselves also need not be kept
610)     secret; the latter are usually stored in a world-readable file.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

620) 
621)     [CLICK]: https://click.palletsprojects.com/
622) 
623)     Parameters:
624)         ctx (click.Context):
625)             The `click` context.
626) 
627)     Other Parameters:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

680)         export_settings:
681)             Command-line argument `-e`/`--export`.  If a file object,
682)             then it must be open for writing and accept `str` inputs.
683)             Otherwise, a filename to open for writing.  Using `-` for
684)             standard output is supported.
685)         import_settings:
686)             Command-line argument `-i`/`--import`.  If a file object, it
687)             must be open for reading and yield `str` values.  Otherwise,
688)             a filename to open for reading.  Using `-` for standard
689)             input is supported.
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

690) 
691)     """
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

698)             match param:
699)                 case PasswordGenerationOption():
700)                     group = PasswordGenerationOption
701)                 case ConfigurationOption():
702)                     group = ConfigurationOption
703)                 case StorageManagementOption():
704)                     group = StorageManagementOption
705)                 case OptionGroupOption():
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

706)                     raise AssertionError(  # noqa: TRY003
707)                         f'Unknown option group for {param!r}')  # noqa: EM102
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

708)                 case _:
709)                     group = click.Option
710)             options_in_group.setdefault(group, []).append(param)
711)         params_by_str[param.human_readable_name] = param
712)         for name in param.opts + param.secondary_opts:
713)             params_by_str[name] = param
714) 
715)     def is_param_set(param: click.Parameter):
716)         return bool(ctx.params.get(param.human_readable_name))
717) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

721)         if isinstance(param, str):
722)             param = params_by_str[param]
723)         assert isinstance(param, click.Parameter)
724)         if not is_param_set(param):
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

725)             return
726)         for other in incompatible:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

727)             if isinstance(other, str):
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

728)                 other = params_by_str[other]  # noqa: PLW2901
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

729)             assert isinstance(other, click.Parameter)
730)             if other != param and is_param_set(other):
731)                 opt_str = param.opts[0]
732)                 other_str = other.opts[0]
733)                 raise click.BadOptionUsage(
734)                     opt_str, f'mutually exclusive with {other_str}', ctx=ctx)
735) 
736)     def get_config() -> dpp_types.VaultConfig:
737)         try:
738)             return _load_config()
739)         except FileNotFoundError:
740)             return {'services': {}}
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

741)         except Exception as e:  # noqa: BLE001
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

742)             ctx.fail(f'cannot load config: {e}')
743) 
744)     configuration: dpp_types.VaultConfig
745) 
746)     check_incompatible_options('--phrase', '--key')
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

748)         for opt in options_in_group[group]:
749)             if opt != params_by_str['--config']:
750)                 check_incompatible_options(
751)                     opt, *options_in_group[PasswordGenerationOption])
752) 
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

754)         for opt in options_in_group[group]:
755)             check_incompatible_options(
756)                 opt, *options_in_group[ConfigurationOption],
757)                 *options_in_group[StorageManagementOption])
758)     sv_options = (options_in_group[PasswordGenerationOption] +
759)                   [params_by_str['--notes'], params_by_str['--delete']])
760)     sv_options.remove(params_by_str['--key'])
761)     sv_options.remove(params_by_str['--phrase'])
762)     for param in sv_options:
763)         if is_param_set(param) and not service:
764)             opt_str = param.opts[0]
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

765)             msg = f'{opt_str} requires a SERVICE'
766)             raise click.UsageError(msg)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

767)     for param in [params_by_str['--key'], params_by_str['--phrase']]:
768)         if (
769)             is_param_set(param)
770)             and not (service or is_param_set(params_by_str['--config']))
771)         ):
772)             opt_str = param.opts[0]
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

773)             msg = f'{opt_str} requires a SERVICE or --config'
774)             raise click.UsageError(msg)
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

775)     no_sv_options = [params_by_str['--delete-globals'],
776)                      params_by_str['--clear'],
777)                      *options_in_group[StorageManagementOption]]
778)     for param in no_sv_options:
779)         if is_param_set(param) and service:
780)             opt_str = param.opts[0]
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

781)             msg = f'{opt_str} does not take a SERVICE argument'
782)             raise click.UsageError(msg)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

783) 
784)     if edit_notes:
785)         assert service is not None
786)         configuration = get_config()
787)         text = (DEFAULT_NOTES_TEMPLATE +
788)                 configuration['services']
789)                 .get(service, cast(dpp_types.VaultConfigServicesSettings, {}))
790)                 .get('notes', ''))
791)         notes_value = click.edit(text=text)
792)         if notes_value is not None:
793)             notes_lines = collections.deque(notes_value.splitlines(True))
794)             while notes_lines:
795)                 line = notes_lines.popleft()
796)                 if line.startswith(DEFAULT_NOTES_MARKER):
797)                     notes_value = ''.join(notes_lines)
798)                     break
799)             else:
800)                 if not notes_value.strip():
801)                     ctx.fail('not saving new notes: user aborted request')
802)             configuration['services'].setdefault(service, {})['notes'] = (
803)                 notes_value.strip('\n'))
804)             _save_config(configuration)
805)     elif delete_service_settings:
806)         assert service is not None
807)         configuration = get_config()
808)         if service in configuration['services']:
809)             del configuration['services'][service]
810)             _save_config(configuration)
811)     elif delete_globals:
812)         configuration = get_config()
813)         if 'global' in configuration:
814)             del configuration['global']
815)             _save_config(configuration)
816)     elif clear_all_settings:
817)         _save_config({'services': {}})
818)     elif import_settings:
819)         try:
820)             # TODO: keep track of auto-close; try os.dup if feasible
821)             infile = (cast(TextIO, import_settings)
822)                       if hasattr(import_settings, 'close')
823)                       else click.open_file(os.fspath(import_settings), 'rt'))
824)             with infile:
825)                 maybe_config = json.load(infile)
826)         except json.JSONDecodeError as e:
827)             ctx.fail(f'Cannot load config: cannot decode JSON: {e}')
828)         except OSError as e:
829)             ctx.fail(f'Cannot load config: {e.strerror}')
830)         if dpp_types.is_vault_config(maybe_config):
831)             _save_config(maybe_config)
832)         else:
833)             ctx.fail('not a valid config')
834)     elif export_settings:
835)         configuration = get_config()
836)         try:
837)             # TODO: keep track of auto-close; try os.dup if feasible
838)             outfile = (cast(TextIO, export_settings)
839)                        if hasattr(export_settings, 'close')
840)                        else click.open_file(os.fspath(export_settings), 'wt'))
841)             with outfile:
842)                 json.dump(configuration, outfile)
843)         except OSError as e:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 2 months ago

844)             ctx.fail(f'cannot write config: {e.strerror}')
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

845)     else:
846)         configuration = get_config()
847)         # This block could be type checked more stringently, but this
848)         # would probably involve a lot of code repetition.  Since we
849)         # have a type guarding function anyway, assert that we didn't
850)         # make any mistakes at the end instead.
851)         global_keys = {'key', 'phrase'}
852)         service_keys = {'key', 'phrase', 'length', 'repeat', 'lower',
853)                         'upper', 'number', 'space', 'dash', 'symbol'}
854)         settings: collections.ChainMap[str, Any] = collections.ChainMap(
855)             {k: v for k, v in locals().items()
856)              if k in service_keys and v is not None},
857)             cast(dict[str, Any],
858)                  configuration['services'].get(service or '', {})),
859)             {},
860)             cast(dict[str, Any], configuration.get('global', {}))
861)         )
862)         if use_key:
863)             try:
864)                 key = base64.standard_b64encode(
865)                     _select_ssh_key()).decode('ASCII')
866)             except IndexError:
867)                 ctx.fail('no valid SSH key selected')
Marco Ricci Distinguish between a key l...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

869)                 ctx.fail(str(e))
870)         elif use_phrase:
871)             maybe_phrase = _prompt_for_passphrase()
872)             if not maybe_phrase:
873)                 ctx.fail('no passphrase given')
874)             else:
875)                 phrase = maybe_phrase
876)         if store_config_only:
877)             view: collections.ChainMap[str, Any]
878)             view = (collections.ChainMap(*settings.maps[:2]) if service
879)                     else settings.parents.parents)
880)             if use_key:
881)                 view['key'] = key
882)                 for m in view.maps:
883)                     m.pop('phrase', '')
884)             elif use_phrase:
885)                 view['phrase'] = phrase
886)                 for m in view.maps:
887)                     m.pop('key', '')
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

888)             if not view.maps[0]:
889)                 settings_type = 'service' if service else 'global'
890)                 msg = (f'cannot update {settings_type} settings without '
891)                        f'actual settings')
892)                 raise click.UsageError(msg)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

893)             if service:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

894)                 configuration['services'].setdefault(
895)                     service, {}).update(view)  # type: ignore[typeddict-item]
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

896)             else:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

897)                 configuration.setdefault(
898)                     'global', {}).update(view)  # type: ignore[typeddict-item]
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

899)             assert dpp_types.is_vault_config(configuration), (
900)                 f'invalid vault configuration: {configuration!r}'
901)             )
902)             _save_config(configuration)
903)         else:
904)             if not service:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

905)                 msg = 'SERVICE is required'
906)                 raise click.UsageError(msg)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

907)             kwargs: dict[str, Any] = {k: v for k, v in settings.items()
908)                                       if k in service_keys and v is not None}
909)             # If either --key or --phrase are given, use that setting.
910)             # Otherwise, if both key and phrase are set in the config,
911)             # one must be global (ignore it) and one must be
912)             # service-specific (use that one). Otherwise, if only one of
913)             # key and phrase is set in the config, use that one.  In all
914)             # these above cases, set the phrase via
915)             # derivepassphrase.Vault.phrase_from_key if a key is
916)             # given. Finally, if nothing is set, error out.
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 2 months ago

917)             def key_to_phrase(
918)                 key: str | bytes | bytearray
919)             ) -> bytes | bytearray:
920)                 return dpp.Vault.phrase_from_key(
921)                     base64.standard_b64decode(key))
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

922)             if use_key or use_phrase:
923)                 if use_key:
924)                     kwargs['phrase'] = key_to_phrase(key)
925)                 else:
926)                     kwargs['phrase'] = phrase
927)                     kwargs.pop('key', '')
928)             elif kwargs.get('phrase') and kwargs.get('key'):
929)                 if any('key' in m for m in settings.maps[:2]):
930)                     kwargs['phrase'] = key_to_phrase(kwargs.pop('key'))
931)                 else:
932)                     kwargs.pop('key')
933)             elif kwargs.get('key'):
934)                 kwargs['phrase'] = key_to_phrase(kwargs.pop('key'))
935)             elif kwargs.get('phrase'):
936)                 pass
937)             else:
Marco Ricci Fix style issues with ruff...

Marco Ricci authored 1 month ago

938)                 msg = ('no passphrase or key given on command-line '
939)                        'or in configuration')
940)                 raise click.UsageError(msg)
Marco Ricci Add finished command-line i...

Marco Ricci authored 2 months ago

941)             vault = dpp.Vault(**kwargs)
942)             result = vault.generate(service)
943)             click.echo(result.decode('ASCII'))