205eab07669bb7db5bb2f996b1b20741f6b19691
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) from collections.abc import MutableMapping
14) import contextlib
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
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

19) import socket
20) from typing import (
21)     Any, assert_never, cast, reveal_type, Iterator, Never, NotRequired,
22)     Sequence, TextIO, TypedDict, TYPE_CHECKING,
23) )
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

325) 
326) class CommandWithHelpGroups(click.Command):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

404) 
405) class ConfigurationOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

412) 
413) class StorageManagementOption(OptionGroupOption):
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

420) 
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

438) 
439) def _validate_length(
Marco Ricci Add prototype command-line...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

486)     ''',
487) )
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

663) 
664)     """
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

698)             return
699)         for other in incompatible:
Marco Ricci Fortify the argument parsin...

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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

Marco Ricci authored 2 months ago

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