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

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 

9from __future__ import annotations 

10 

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) 

22 

23import click 

24import derivepassphrase as dpp 

25from derivepassphrase import types as dpp_types 

26import ssh_agent_client 

27 

28__author__ = dpp.__author__ 

29__version__ = dpp.__version__ 

30 

31__all__ = ('derivepassphrase',) 

32 

33prog_name = 'derivepassphrase' 

34 

35 

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

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

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

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. 

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 

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. 

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 

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. 

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 

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

299class 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 

324class 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. 

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

401 

402 

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

409 

410 

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

418 

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 

435 

436 

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 

453 

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

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 

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. 

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 

914if __name__ == '__main__': 

915 derivepassphrase()