Coverage for src/derivepassphrase/cli.py: 100.000%
343 statements
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-14 11:39 +0200
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-14 11:39 +0200
1# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2#
3# SPDX-License-Identifier: MIT
5"""Command-line interface for derivepassphrase.
7"""
9from __future__ import annotations
11import base64
12import collections
13import contextlib
14import inspect
15import json
16import os
17import pathlib
18import socket
19from typing_extensions import (
20 Any, assert_never, cast, Iterator, Sequence, TextIO,
21)
23import click
24import derivepassphrase as dpp
25from derivepassphrase import types as dpp_types
26import ssh_agent_client
28__author__ = dpp.__author__
29__version__ = dpp.__version__
31__all__ = ('derivepassphrase',)
33prog_name = 'derivepassphrase'
36def _config_filename() -> str | bytes | pathlib.Path:
37 """Return the filename of the configuration file.
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.
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')
51def _load_config() -> dpp_types.VaultConfig:
52 """Load a vault(1)-compatible config from the application directory.
54 The filename is obtained via
55 [`derivepassphrase.cli._config_filename`][]. This must be an
56 unencrypted JSON file.
58 Returns:
59 The vault settings. See
60 [`derivepassphrase.types.VaultConfig`][] for details.
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.
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
78def _save_config(config: dpp_types.VaultConfig, /) -> None:
79 """Save a vault(1)-compatbile config to the application directory.
81 The filename is obtained via
82 [`derivepassphrase.cli._config_filename`][]. The config will be
83 stored as an unencrypted JSON file.
85 Args:
86 config:
87 vault configuration to save.
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.
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)
103def _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.
109 Suitable SSH keys are queried from the running SSH agent (see
110 [`ssh_agent_client.SSHAgentClient.list_keys`][]).
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.
117 If an existing SSH agent client, then this client will be
118 queried for the SSH keys, and otherwise left intact.
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.
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.
129 Yields:
130 :
131 Every SSH key from the SSH agent that is suitable for
132 passphrase derivation.
134 Raises:
135 LookupError:
136 No keys usable for passphrase derivation are loaded into the
137 SSH agent.
138 RuntimeError:
139 There was an error communicating with the SSH agent.
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
167 raise IndexError('No usable SSH keys were found')
170def _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.
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.
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.
191 Returns:
192 An index into the items sequence, indicating the user's
193 selection.
195 Raises:
196 IndexError:
197 The user made an invalid or empty selection, or requested an
198 abort.
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
230def _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.
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.
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.
245 If an existing SSH agent client, then this client will be
246 queried for the SSH keys, and otherwise left intact.
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.
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.
257 Returns:
258 The selected SSH key.
260 Raises:
261 IndexError:
262 The user made an invalid or empty selection, or requested an
263 abort.
264 LookupError:
265 No keys usable for passphrase derivation are loaded into the
266 SSH agent.
267 RuntimeError:
268 There was an error communicating with the SSH agent.
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
285def _prompt_for_passphrase() -> str:
286 """Interactively prompt for the passphrase.
288 Calls [`click.prompt`][] internally. Moved into a separate function
289 mainly for testing/mocking purposes.
291 Returns:
292 The user input.
294 """
295 return click.prompt('Passphrase', default='', hide_input=True,
296 show_default=False, err=True)
299class OptionGroupOption(click.Option):
300 """A [`click.Option`][] with an associated group name and group epilog.
302 Used by [`derivepassphrase.cli.CommandWithHelpGroups`][] to print
303 help sections. Each subclass contains its own group name and
304 epilog.
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.
314 """
315 option_group_name: str = ''
316 epilog: str = ''
318 def __init__(self, *args, **kwargs): # type: ignore
319 if self.__class__ == __class__:
320 raise NotImplementedError()
321 return super().__init__(*args, **kwargs)
324class CommandWithHelpGroups(click.Command):
325 """A [`click.Command`][] with support for help/option groups.
327 Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE], and
328 further modified to support group epilogs.
330 [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746
332 """
334 def format_options(
335 self, ctx: click.Context, formatter: click.HelpFormatter,
336 ) -> None:
337 r"""Format options on the help listing, grouped into sections.
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.
351 Args:
352 ctx:
353 The click context.
354 formatter:
355 The formatter for the `--help` listing.
357 Returns:
358 Nothing. Output is generated by calling appropriate methods
359 on `formatter` instead.
361 """
362 help_records: dict[str, list[tuple[str, str]]] = {}
363 epilogs: dict[str, str] = {}
364 params = self.params[:]
365 if ( # pragma: no branch
366 (help_opt := self.get_help_option(ctx)) is not None
367 and help_opt not in params
368 ):
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)
393# Concrete option groups used by this command-line interface.
394class PasswordGenerationOption(OptionGroupOption):
395 """Password generation options for the CLI."""
396 option_group_name = 'Password generation'
397 epilog = '''
398 Use NUMBER=0, e.g. "--symbol 0", to exclude a character type
399 from the output.
400 '''
403class ConfigurationOption(OptionGroupOption):
404 """Configuration options for the CLI."""
405 option_group_name = 'Configuration'
406 epilog = '''
407 Use $VISUAL or $EDITOR to configure the spawned editor.
408 '''
411class StorageManagementOption(OptionGroupOption):
412 """Storage management options for the CLI."""
413 option_group_name = 'Storage management'
414 epilog = '''
415 Using "-" as PATH for standard input/standard output is
416 supported.
417 '''
419def _validate_occurrence_constraint(
420 ctx: click.Context, param: click.Parameter, value: Any,
421) -> int | None:
422 """Check that the occurrence constraint is valid (int, 0 or larger)."""
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
437def _validate_length(
438 ctx: click.Context, param: click.Parameter, value: Any,
439) -> int | None:
440 """Check that the length is valid (int, 1 or larger)."""
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
454DEFAULT_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'''
465DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
468@click.command(
469 context_settings={"help_option_names": ["-h", "--help"]},
470 cls=CommandWithHelpGroups,
471 epilog=r'''
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.
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.
484 ''',
485)
486@click.option('-p', '--phrase', 'use_phrase', is_flag=True,
487 help='prompts you for your passphrase',
488 cls=PasswordGenerationOption)
489@click.option('-k', '--key', 'use_key', is_flag=True,
490 help='uses your SSH private key to generate passwords',
491 cls=PasswordGenerationOption)
492@click.option('-l', '--length', metavar='NUMBER',
493 callback=_validate_length,
494 help='emits password of length NUMBER',
495 cls=PasswordGenerationOption)
496@click.option('-r', '--repeat', metavar='NUMBER',
497 callback=_validate_occurrence_constraint,
498 help='allows maximum of NUMBER repeated adjacent chars',
499 cls=PasswordGenerationOption)
500@click.option('--lower', metavar='NUMBER',
501 callback=_validate_occurrence_constraint,
502 help='includes at least NUMBER lowercase letters',
503 cls=PasswordGenerationOption)
504@click.option('--upper', metavar='NUMBER',
505 callback=_validate_occurrence_constraint,
506 help='includes at least NUMBER uppercase letters',
507 cls=PasswordGenerationOption)
508@click.option('--number', metavar='NUMBER',
509 callback=_validate_occurrence_constraint,
510 help='includes at least NUMBER digits',
511 cls=PasswordGenerationOption)
512@click.option('--space', metavar='NUMBER',
513 callback=_validate_occurrence_constraint,
514 help='includes at least NUMBER spaces',
515 cls=PasswordGenerationOption)
516@click.option('--dash', metavar='NUMBER',
517 callback=_validate_occurrence_constraint,
518 help='includes at least NUMBER "-" or "_"',
519 cls=PasswordGenerationOption)
520@click.option('--symbol', metavar='NUMBER',
521 callback=_validate_occurrence_constraint,
522 help='includes at least NUMBER symbol chars',
523 cls=PasswordGenerationOption)
524@click.option('-n', '--notes', 'edit_notes', is_flag=True,
525 help='spawn an editor to edit notes for SERVICE',
526 cls=ConfigurationOption)
527@click.option('-c', '--config', 'store_config_only', is_flag=True,
528 help='saves the given settings for SERVICE or global',
529 cls=ConfigurationOption)
530@click.option('-x', '--delete', 'delete_service_settings', is_flag=True,
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)
536@click.option('-X', '--clear', 'clear_all_settings', is_flag=True,
537 help='deletes all settings',
538 cls=ConfigurationOption)
539@click.option('-e', '--export', 'export_settings', metavar='PATH',
540 type=click.Path(file_okay=True, allow_dash=True, exists=False),
541 help='export all saved settings into file PATH',
542 cls=StorageManagementOption)
543@click.option('-i', '--import', 'import_settings', metavar='PATH',
544 type=click.Path(file_okay=True, allow_dash=True, exists=False),
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
550def derivepassphrase(
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,
570) -> None:
571 """Derive a strong passphrase, deterministically, from a master secret.
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.
583 If operating on global settings, or importing/exporting settings,
584 then SERVICE must be omitted. Otherwise it is required.\f
586 This is a [`click`][CLICK]-powered command-line interface function,
587 and not intended for programmatic use. Call with arguments
588 `['--help']` to see full documentation of the interface. (See also
589 [`click.testing.CliRunner`][] for controlled, programmatic
590 invocation.)
592 [CLICK]: https://click.palletsprojects.com/
594 Parameters:
595 ctx (click.Context):
596 The `click` context.
598 Other Parameters:
599 service:
600 A service name. Required, unless operating on global
601 settings or importing/exporting settings.
602 use_phrase:
603 Command-line argument `-p`/`--phrase`. If given, query the
604 user for a passphrase instead of an SSH key.
605 use_key:
606 Command-line argument `-k`/`--key`. If given, query the
607 user for an SSH key instead of a passphrase.
608 length:
609 Command-line argument `-l`/`--length`. Override the default
610 length of the generated passphrase.
611 repeat:
612 Command-line argument `-r`/`--repeat`. Override the default
613 repetition limit if positive, or disable the repetition
614 limit if 0.
615 lower:
616 Command-line argument `--lower`. Require a given amount of
617 ASCII lowercase characters if positive, else forbid ASCII
618 lowercase characters if 0.
619 upper:
620 Command-line argument `--upper`. Same as `lower`, but for
621 ASCII uppercase characters.
622 number:
623 Command-line argument `--number`. Same as `lower`, but for
624 ASCII digits.
625 space:
626 Command-line argument `--number`. Same as `lower`, but for
627 the space character.
628 dash:
629 Command-line argument `--number`. Same as `lower`, but for
630 the hyphen-minus and underscore characters.
631 symbol:
632 Command-line argument `--number`. Same as `lower`, but for
633 all other ASCII printable characters (except backquote).
634 edit_notes:
635 Command-line argument `-n`/`--notes`. If given, spawn an
636 editor to edit notes for `service`.
637 store_config_only:
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.
642 delete_service_settings:
643 Command-line argument `-x`/`--delete`. If given, removes
644 the settings for `service` from the configuration file.
645 delete_globals:
646 Command-line argument `--delete-globals`. If given, removes
647 the global settings from the configuration file.
648 clear_all_settings:
649 Command-line argument `-X`/`--clear`. If given, removes all
650 settings from the configuration file.
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.
662 """
664 options_in_group: dict[type[click.Option], list[click.Option]] = {}
665 params_by_str: dict[str, click.Parameter] = {}
666 for param in ctx.command.params:
667 if isinstance(param, click.Option):
668 group: type[click.Option]
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
686 def is_param_set(param: click.Parameter):
687 return bool(ctx.params.get(param.human_readable_name))
689 def check_incompatible_options(
690 param: click.Parameter | str, *incompatible: click.Parameter | str,
691 ) -> None:
692 if isinstance(param, str):
693 param = params_by_str[param]
694 assert isinstance(param, click.Parameter)
695 if not is_param_set(param):
696 return
697 for other in incompatible:
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)
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}')
715 configuration: dpp_types.VaultConfig
717 check_incompatible_options('--phrase', '--key')
718 for group in (ConfigurationOption, StorageManagementOption):
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])
724 for group in (ConfigurationOption, StorageManagementOption):
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]
750 raise click.UsageError(
751 f'{opt_str} does not take a SERVICE argument')
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')
837 except (LookupError, RuntimeError) as e:
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'))
914if __name__ == '__main__':
915 derivepassphrase()