Coverage for src/derivepassphrase/cli.py: 100.000%

1# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>

2#

3# SPDX-License-Identifier: MIT

4

5"""Command-line interface for derivepassphrase.

6

7"""

8

9 from __future__ import annotations

10

11 import base64

12 import collections

13 import contextlib

14 import inspect

15 import json

16 import os

17 import pathlib

18 import socket

19 from typing_extensions import (

20 Any, assert_never, cast, Iterator, Sequence, TextIO,

21)

22

23 import click

24 import derivepassphrase as dpp

25 from derivepassphrase import types as dpp_types

26 import ssh_agent_client

27

28 __author__ = dpp.__author__

29 __version__ = dpp.__version__

30

31 __all__ = ('derivepassphrase',)

32

33 prog_name = 'derivepassphrase'

34

35

36 def _config_filename() -> str | bytes | pathlib.Path:

37 """Return the filename of the configuration file.

38

39 The file is currently named `settings.json`, located within the

40 configuration directory as determined by the `DERIVEPASSPHRASE_PATH`

41 environment variable, or by [`click.get_app_dir`][] in POSIX

42 mode.

43

44 """

45 path: str | bytes | pathlib.Path

46 path = (os.getenv(prog_name.upper() + '_PATH')

47 or click.get_app_dir(prog_name, force_posix=True))

48 return os.path.join(path, 'settings.json')

49

50

51 def _load_config() -> dpp_types.VaultConfig:

52 """Load a vault(1)-compatible config from the application directory.

53

54 The filename is obtained via

55 [`derivepassphrase.cli._config_filename`][]. This must be an

56 unencrypted JSON file.

57

58 Returns:

59 The vault settings. See

60 [`derivepassphrase.types.VaultConfig`][] for details.

61

62 Raises:

63 OSError:

64 There was an OS error accessing the file.

65 ValueError:

66 The data loaded from the file is not a vault(1)-compatible

67 config.

68

69 """

70 filename = _config_filename()

71 with open(filename, 'rb') as fileobj:

72 data = json.load(fileobj)

73 if not dpp_types.is_vault_config(data):

74 raise ValueError('Invalid vault config')

75 return data

76

77

78 def _save_config(config: dpp_types.VaultConfig, /) -> None:

79 """Save a vault(1)-compatbile config to the application directory.

80

81 The filename is obtained via

82 [`derivepassphrase.cli._config_filename`][]. The config will be

83 stored as an unencrypted JSON file.

84

85 Args:

86 config:

87 vault configuration to save.

88

89 Raises:

90 OSError:

91 There was an OS error accessing or writing the file.

92 ValueError:

93 The data cannot be stored as a vault(1)-compatible config.

94

95 """

96 if not dpp_types.is_vault_config(config):

97 raise ValueError('Invalid vault config')

98 filename = _config_filename()

99 with open(filename, 'wt', encoding='UTF-8') as fileobj:

100 json.dump(config, fileobj)

101

102

103 def _get_suitable_ssh_keys(

104 conn: ssh_agent_client.SSHAgentClient | socket.socket | None = None,

105 /

106) -> Iterator[ssh_agent_client.types.KeyCommentPair]:

107 """Yield all SSH keys suitable for passphrase derivation.

108

109 Suitable SSH keys are queried from the running SSH agent (see

110 [`ssh_agent_client.SSHAgentClient.list_keys`][]).

111

112 Args:

113 conn:

114 An optional connection hint to the SSH agent; specifically,

115 an SSH agent client, or a socket connected to an SSH agent.

116

117 If an existing SSH agent client, then this client will be

118 queried for the SSH keys, and otherwise left intact.

119

120 If a socket, then a one-shot client will be constructed

121 based on the socket to query the agent, and deconstructed

122 afterwards.

123

124 If neither are given, then the agent's socket location is

125 looked up in the `SSH_AUTH_SOCK` environment variable, and

126 used to construct/deconstruct a one-shot client, as in the

127 previous case.

128

129 Yields:

130 :

131 Every SSH key from the SSH agent that is suitable for

132 passphrase derivation.

133

134 Raises:

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.

140

141 """

142 client: ssh_agent_client.SSHAgentClient

143 client_context: contextlib.AbstractContextManager

144 match conn:

145 case ssh_agent_client.SSHAgentClient():

146 client = conn

147 client_context = contextlib.nullcontext()

148 case socket.socket() | None:

149 client = ssh_agent_client.SSHAgentClient(socket=conn)

150 client_context = client

151 case _: # pragma: no cover

152 assert_never(conn)

153 raise TypeError(f'invalid connection hint: {conn!r}')

154 with client_context:

155 try:

156 all_key_comment_pairs = list(client.list_keys())

157 except EOFError as e: # pragma: no cover

158 raise RuntimeError(

159 'error communicating with the SSH agent'

160 ) from e

161 suitable_keys = all_key_comment_pairs[:]

162 for pair in all_key_comment_pairs:

163 key, comment = pair

164 if dpp.Vault._is_suitable_ssh_key(key):

165 yield pair

166 if not suitable_keys: # pragma: no cover

167 raise IndexError('No usable SSH keys were found')

168

169

170 def _prompt_for_selection(

171 items: Sequence[str | bytes], heading: str = 'Possible choices:',

172 single_choice_prompt: str = 'Confirm this choice?',

173) -> int:

174 """Prompt user for a choice among the given items.

175

176 Print the heading, if any, then present the items to the user. If

177 there are multiple items, prompt the user for a selection, validate

178 the choice, then return the list index of the selected item. If

179 there is only a single item, request confirmation for that item

180 instead, and return the correct index.

181

182 Args:

183 heading:

184 A heading for the list of items, to print immediately

185 before. Defaults to a reasonable standard heading. If

186 explicitly empty, print no heading.

187 single_choice_prompt:

188 The confirmation prompt if there is only a single possible

189 choice. Defaults to a reasonable standard prompt.

190

191 Returns:

192 An index into the items sequence, indicating the user's

193 selection.

194

195 Raises:

196 IndexError:

197 The user made an invalid or empty selection, or requested an

198 abort.

199

200 """

201 n = len(items)

202 if heading:

203 click.echo(click.style(heading, bold=True))

204 for i, x in enumerate(items, start=1):

205 click.echo(click.style(f'[{i}]', bold=True), nl=False)

206 click.echo(' ', nl=False)

207 click.echo(x)

208 if n > 1:

209 choices = click.Choice([''] + [str(i) for i in range(1, n + 1)])

210 choice = click.prompt(

211 f'Your selection? (1-{n}, leave empty to abort)',

212 err=True, type=choices, show_choices=False,

213 show_default=False, default='')

214 if not choice:

215 raise IndexError('empty selection')

216 return int(choice) - 1

217 else:

218 prompt_suffix = (' '

219 if single_choice_prompt.endswith(tuple('?.!'))

220 else ': ')

221 try:

222 click.confirm(single_choice_prompt,

223 prompt_suffix=prompt_suffix, err=True,

224 abort=True, default=False, show_default=False)

225 except click.Abort:

226 raise IndexError('empty selection') from None

227 return 0

228

229

230 def _select_ssh_key(

231 conn: ssh_agent_client.SSHAgentClient | socket.socket | None = None,

232 /

233) -> bytes | bytearray:

234 """Interactively select an SSH key for passphrase derivation.

235

236 Suitable SSH keys are queried from the running SSH agent (see

237 [`ssh_agent_client.SSHAgentClient.list_keys`][]), then the user is

238 prompted interactively (see [`click.prompt`][]) for a selection.

239

240 Args:

241 conn:

242 An optional connection hint to the SSH agent; specifically,

243 an SSH agent client, or a socket connected to an SSH agent.

244

245 If an existing SSH agent client, then this client will be

246 queried for the SSH keys, and otherwise left intact.

247

248 If a socket, then a one-shot client will be constructed

249 based on the socket to query the agent, and deconstructed

250 afterwards.

251

252 If neither are given, then the agent's socket location is

253 looked up in the `SSH_AUTH_SOCK` environment variable, and

254 used to construct/deconstruct a one-shot client, as in the

255 previous case.

256

257 Returns:

258 The selected SSH key.

259

260 Raises:

261 IndexError:

262 The user made an invalid or empty selection, or requested an

263 abort.

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

283

284

285 def _prompt_for_passphrase() -> str:

286 """Interactively prompt for the passphrase.

287

288 Calls [`click.prompt`][] internally. Moved into a separate function

289 mainly for testing/mocking purposes.

290

291 Returns:

292 The user input.

293

294 """

295 return click.prompt('Passphrase', default='', hide_input=True,

296 show_default=False, err=True)

297

298

299 class OptionGroupOption(click.Option):

300 """A [`click.Option`][] with an associated group name and group epilog.

301

302 Used by [`derivepassphrase.cli.CommandWithHelpGroups`][] to print

303 help sections. Each subclass contains its own group name and

304 epilog.

305

306 Attributes:

307 option_group_name:

308 The name of the option group. Used as a heading on the help

309 text for options in this section.

310 epilog:

311 An epilog to print after listing the options in this

312 section.

313

314 """

315 option_group_name: str = ''

316 epilog: str = ''

317

318 def __init__(self, *args, **kwargs): # type: ignore

319 if self.__class__ == __class__:

320 raise NotImplementedError()

321 return super().__init__(*args, **kwargs)

322

323

324 class CommandWithHelpGroups(click.Command):

325 """A [`click.Command`][] with support for help/option groups.

326

327 Inspired by [a comment on `pallets/click#373`][CLICK_ISSUE], and

328 further modified to support group epilogs.

329

330 [CLICK_ISSUE]: https://github.com/pallets/click/issues/373#issuecomment-515293746

331

332 """

333

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.

338

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.

350

351 Args:

352 ctx:

353 The click context.

354 formatter:

355 The formatter for the `--help` listing.

356

357 Returns:

358 Nothing. Output is generated by calling appropriate methods

359 on `formatter` instead.

360

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)

391

392

393# Concrete option groups used by this command-line interface.

394 class 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 '''

401

402

403 class 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 '''

409

410

411 class 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 '''

418

419 def _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

435

436

437 def _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

453

454 DEFAULT_NOTES_TEMPLATE = '''\

455# Enter notes below the line with the cut mark (ASCII scissors and

456# dashes). Lines above the cut mark (such as this one) will be ignored.

457#

458# If you wish to clear the notes, leave everything beyond the cut mark

459# blank. However, if you leave the *entire* file blank, also removing

460# the cut mark, then the edit is aborted, and the old notes contents are

461# retained.

462#

463# - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -

464'''

465 DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'

466

467

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.

477

478 Configuration is stored in a directory according to the

479 DERIVEPASSPHRASE_PATH variable, which defaults to

480 `~/.derivepassphrase` on UNIX-like systems and

481 `C:\Users\<user>\AppData\Roaming\Derivepassphrase` on Windows.

482 The configuration is NOT encrypted, and you are STRONGLY

483 discouraged from using a stored passphrase.

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

550 def 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.

572

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.

582

583 If operating on global settings, or importing/exporting settings,

584 then SERVICE must be omitted. Otherwise it is required.\f

585

586 This is a [`click`][CLICK]-powered command-line interface function,

587 and not intended for programmatic use. Call with arguments

588 `['--help']` to see full documentation of the interface. (See also

589 [`click.testing.CliRunner`][] for controlled, programmatic

590 invocation.)

591

592 [CLICK]: https://click.palletsprojects.com/

593

594 Parameters:

595 ctx (click.Context):

596 The `click` context.

597

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.

661

662 """

663

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

685

686 def is_param_set(param: click.Parameter):

687 return bool(ctx.params.get(param.human_readable_name))

688

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)

706

707 def get_config() -> dpp_types.VaultConfig:

708 try:

709 return _load_config()

710 except FileNotFoundError:

711 return {'services': {}}

712 except Exception as e:

713 ctx.fail(f'cannot load config: {e}')

714

715 configuration: dpp_types.VaultConfig

716

717 check_incompatible_options('--phrase', '--key')

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])

723

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')

752

753 if edit_notes:

754 assert service is not None

755 configuration = get_config()

756 text = (DEFAULT_NOTES_TEMPLATE +

757 configuration['services']

758 .get(service, cast(dpp_types.VaultConfigServicesSettings, {}))

759 .get('notes', ''))

760 notes_value = click.edit(text=text)

761 if notes_value is not None:

762 notes_lines = collections.deque(notes_value.splitlines(True))

763 while notes_lines:

764 line = notes_lines.popleft()

765 if line.startswith(DEFAULT_NOTES_MARKER):

766 notes_value = ''.join(notes_lines)

767 break

768 else:

769 if not notes_value.strip():

770 ctx.fail('not saving new notes: user aborted request')

771 configuration['services'].setdefault(service, {})['notes'] = (

772 notes_value.strip('\n'))

773 _save_config(configuration)

774 elif delete_service_settings:

775 assert service is not None

776 configuration = get_config()

777 if service in configuration['services']:

778 del configuration['services'][service]

779 _save_config(configuration)

780 elif delete_globals:

781 configuration = get_config()

782 if 'global' in configuration:

783 del configuration['global']

784 _save_config(configuration)

785 elif clear_all_settings:

786 _save_config({'services': {}})

787 elif import_settings:

788 try:

789 # TODO: keep track of auto-close; try os.dup if feasible

790 infile = (cast(TextIO, import_settings)

791 if hasattr(import_settings, 'close')

792 else click.open_file(os.fspath(import_settings), 'rt'))

793 with infile:

794 maybe_config = json.load(infile)

795 except json.JSONDecodeError as e:

796 ctx.fail(f'Cannot load config: cannot decode JSON: {e}')

797 except OSError as e:

798 ctx.fail(f'Cannot load config: {e.strerror}')

799 if dpp_types.is_vault_config(maybe_config):

800 _save_config(maybe_config)

801 else:

802 ctx.fail('not a valid config')

803 elif export_settings:

804 configuration = get_config()

805 try:

806 # TODO: keep track of auto-close; try os.dup if feasible

807 outfile = (cast(TextIO, export_settings)

808 if hasattr(export_settings, 'close')

809 else click.open_file(os.fspath(export_settings), 'wt'))

810 with outfile:

811 json.dump(configuration, outfile)

812 except OSError as e:

813 ctx.fail('cannot write config: {e.strerror}')

814 else:

815 configuration = get_config()

816 # This block could be type checked more stringently, but this

817 # would probably involve a lot of code repetition. Since we

818 # have a type guarding function anyway, assert that we didn't

819 # make any mistakes at the end instead.

820 global_keys = {'key', 'phrase'}

821 service_keys = {'key', 'phrase', 'length', 'repeat', 'lower',

822 'upper', 'number', 'space', 'dash', 'symbol'}

823 settings: collections.ChainMap[str, Any] = collections.ChainMap(

824 {k: v for k, v in locals().items()

825 if k in service_keys and v is not None},

826 cast(dict[str, Any],

827 configuration['services'].get(service or '', {})),

828 {},

829 cast(dict[str, Any], configuration.get('global', {}))

830 )

831 if use_key:

832 try:

833 key = base64.standard_b64encode(

834 _select_ssh_key()).decode('ASCII')

835 except IndexError:

836 ctx.fail('no valid SSH key selected')

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'))

912

913

914 if __name__ == '__main__':

915 derivepassphrase()