Coverage for src / derivepassphrase / _internals / cli_helpers.py: 100.000%
324 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
6"""Helper functions for the derivepassphrase command-line.
8Warning:
9 Non-public module (implementation detail), provided for didactical and
10 educational purposes only. Subject to change without notice, including
11 removal.
13"""
15from __future__ import annotations
17import base64
18import copy
19import enum
20import hashlib
21import json
22import logging
23import os
24import pathlib
25import shlex
26import sys
27import threading
28import unicodedata
29from collections.abc import Sequence
30from typing import TYPE_CHECKING, cast
32import click
33import click.shell_completion
34import exceptiongroup
35from typing_extensions import Any
37from derivepassphrase import _types, ssh_agent, vault
38from derivepassphrase._internals import cli_messages as _msg
39from derivepassphrase.ssh_agent import socketprovider
41if sys.version_info >= (3, 11):
42 import tomllib
43else:
44 import tomli as tomllib
45 from exceptiongroup import BaseExceptionGroup
47if TYPE_CHECKING:
48 import types
49 from collections.abc import (
50 Generator,
51 Mapping,
52 )
53 from contextlib import AbstractContextManager
54 from typing import (
55 BinaryIO,
56 Callable,
57 Literal,
58 NoReturn,
59 TextIO,
60 )
62 from typing_extensions import Buffer, Self
64PROG_NAME = _msg.PROG_NAME
65KEY_DISPLAY_LENGTH = 50
67# Error messages
68INVALID_VAULT_CONFIG = "Invalid vault config"
69AGENT_COMMUNICATION_ERROR = "Error communicating with the SSH agent"
70NO_SUITABLE_KEYS = "No suitable SSH keys were found"
71EMPTY_SELECTION = "Empty selection"
74# Shell completion
75# ================
77# Use naive filename completion for the `path` argument of
78# `derivepassphrase vault`'s `--import` and `--export` options, as well
79# as the `path` argument of `derivepassphrase export vault`. The latter
80# treats the pseudo-filename `VAULT_PATH` specially, but this is awkward
81# to combine with standard filename completion, particularly in bash, so
82# we would probably have to implement *all* completion (`VAULT_PATH` and
83# filename completion) ourselves, lacking some niceties of bash's
84# built-in completion (e.g., adding spaces or slashes depending on
85# whether the completion is a directory or a complete filename).
88def shell_complete_path(
89 ctx: click.Context,
90 parameter: click.Parameter,
91 value: str,
92) -> list[str | click.shell_completion.CompletionItem]:
93 """Request standard path completion for the `path` argument.""" # noqa: DOC201
94 del ctx, parameter, value 2zbZ
95 return [click.shell_completion.CompletionItem("", type="file")] 2zbZ
98# The standard `click` shell completion scripts serialize the completion
99# items as newline-separated one-line entries, which get silently
100# corrupted if the value contains newlines. Each shell imposes
101# additional restrictions: Fish uses newlines in all internal completion
102# helper scripts, so it is difficult, if not impossible, to register
103# completion entries containing newlines if completion comes from within
104# a Fish completion function (instead of a Fish builtin). Zsh's
105# completion system supports descriptions for each completion item, and
106# the completion helper functions parse every entry as a colon-separated
107# 2-tuple of item and description, meaning any colon in the item value
108# must be escaped. Finally, Bash requires the result array to be
109# populated at the completion function's top-level scope, but for/while
110# loops within pipelines do not run at top-level scope, and Bash *also*
111# strips NUL characters from command substitution output, making it
112# difficult to read in external data into an array in a cross-platform
113# manner from entirely within Bash.
114#
115# We capitulate in front of these problems---most egregiously because of
116# Fish---and ensure that completion items (in this case: service names)
117# never contain ASCII control characters by refusing to offer such
118# items as valid completions. On the other side, `derivepassphrase`
119# will warn the user when configuring or importing a service with such
120# a name that it will not be available for shell completion.
123def is_completable_item(obj: object) -> bool:
124 """Return whether the item is completable on the command-line.
126 The item is completable if and only if it contains no ASCII control
127 characters (U+0000 through U+001F, and U+007F).
129 """
130 obj = str(obj) 2a z t n A 1 q ybZ T d e g B u r v w I G s x y b J D E F
131 forbidden = frozenset(chr(i) for i in range(32)) | {"\x7f"} 2a z t n A 1 q ybZ T d e g B u r v w I G s x y b J D E F
132 return not any(f in obj for f in forbidden) 2a z t n A 1 q ybZ T d e g B u r v w I G s x y b J D E F
135def shell_complete_service(
136 ctx: click.Context,
137 parameter: click.Parameter,
138 value: str,
139) -> list[str]:
140 """Return known vault service names as completion items.
142 Service names are looked up in the vault configuration file. All
143 errors will be suppressed. Additionally, any service names deemed
144 not completable as per [`is_completable_item`][] will be silently
145 skipped.
147 """
148 del ctx, parameter 10(1qZT
149 try: 10(1qZT
150 config = load_config() 10(1qZT
151 return sorted( 11qZ
152 sv
153 for sv in config["services"]
154 if sv.startswith(value) and is_completable_item(sv)
155 )
156 except FileNotFoundError: 10(T
157 try: 10T
158 config, _exc = migrate_and_load_old_config() 10T
159 return sorted( 1T
160 sv
161 for sv in config["services"]
162 if sv.startswith(value) and is_completable_item(sv)
163 )
164 except FileNotFoundError: 10
165 return [] 10
166 except Exception: # noqa: BLE001 1(
167 return [] 1(
170# Vault
171# =====
173config_filename_table = {
174 None: ".",
175 "write lock": "",
176 "vault": "vault.json",
177 "user configuration": "config.toml",
178 # TODO(the-13th-letter): Remove the old settings.json file.
179 # https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file
180 "old settings.json": "settings.json",
181 "notes backup": "old-notes.txt",
182}
184LOCK_SIZE = 4096
185"""
186The size of the record to lock at the beginning of the file, for locking
187implementations that lock byte ranges instead of whole files.
189While POSIX specifies that [`fcntl`][] locks shall support a size of zero to
190denote "any conceivable file size", the locking system available in
191[`msvcrt`][] does not support this, and requires an explicit size.
192"""
195class ConfigurationMutex:
196 """A mutual exclusion context manager for configuration edits.
198 See [`configuration_mutex`][].
200 """
202 lock: Callable[[], None]
203 """A function to lock the mutex exclusively.
205 This implementation uses a file descriptor of a well-known file,
206 which is opened before locking and closed after unlocking (and on
207 error when locking). On Windows, we use [`msvcrt.locking`][], on
208 other systems, we use [`fcntl.flock`][].
210 Note:
211 This is a normal Python function, not a method.
213 Warning:
214 You really should not have to change this. *If you absolutely
215 must*, then it is *your responsibility* to ensure that
216 [`lock`][] and [`unlock`][] are still compatible.
218 """
219 unlock: Callable[[], None]
220 """A function to unlock the mutex.
222 This implementation uses a file descriptor of a well-known file,
223 which is opened before locking and closed after unlocking (and on
224 error when locking). It will fail if the file descriptor is
225 unavailable. On Windows, we use [`msvcrt.locking`][], on other
226 systems, we use [`fcntl.flock`][].
228 Note:
229 This is a normal Python function, not a method.
231 Warning:
232 You really should not have to change this. *If you absolutely
233 must*, then it is *your responsibility* to ensure that
234 [`lock`][] and [`unlock`][] are still compatible.
236 """
237 write_lock_file: pathlib.Path
238 """The filename to lock."""
239 write_lock_fileobj: BinaryIO | None
240 """The file object, if currently locked by this context manager."""
241 write_lock_condition: threading.Condition
242 """The lock protecting access to the file object."""
244 def __init__(self) -> None:
245 """Initialize self."""
246 if sys.platform == "win32": # pragma: unless the-annoying-os no cover 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
247 import msvcrt # noqa: PLC0415 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
249 locking = msvcrt.locking 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
250 LK_LOCK = msvcrt.LK_LOCK # noqa: N806 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
251 LK_UNLCK = msvcrt.LK_UNLCK # noqa: N806 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
253 def lock_fd(fd: int, /) -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
254 locking(fd, LK_LOCK, LOCK_SIZE) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
256 def unlock_fd(fd: int, /) -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
257 locking(fd, LK_UNLCK, LOCK_SIZE) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
259 else: # pragma: unless posix no cover
260 import fcntl # noqa: PLC0415 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
262 flock = fcntl.flock 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
263 LOCK_EX = fcntl.LOCK_EX # noqa: N806 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
264 LOCK_UN = fcntl.LOCK_UN # noqa: N806 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
266 def lock_fd(fd: int, /) -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
267 flock(fd, LOCK_EX) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
269 def unlock_fd(fd: int, /) -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
270 flock(fd, LOCK_UN) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
272 def lock_func() -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
273 with self.write_lock_condition: 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
274 self.write_lock_condition.wait_for( 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
275 lambda: self.write_lock_fileobj is None
276 )
277 self.write_lock_condition.notify() 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
278 self.write_lock_file.touch() 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
279 self.write_lock_fileobj = self.write_lock_file.open("wb") 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
280 lock_fd(self.write_lock_fileobj.fileno()) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
282 def unlock_func() -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
283 with self.write_lock_condition: 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
284 assert self.write_lock_fileobj is not None, ( 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
285 "We lost track of the configuration write lock "
286 "file object, so we cannot unlock it anymore!"
287 )
288 unlock_fd(self.write_lock_fileobj.fileno()) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
289 self.write_lock_fileobj.close() 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
290 self.write_lock_fileobj = None 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
292 self.lock = lock_func 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
293 self.unlock = unlock_func 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
294 self.write_lock_fileobj = None 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
295 self.write_lock_file = config_filename("write lock") 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
296 self.write_lock_condition = threading.Condition(threading.Lock()) 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
298 def __enter__(self) -> Self:
299 """Enter the context, locking the configuration file.""" # noqa: DOC201
300 self.lock() 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
301 return self 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
303 def __exit__(
304 self,
305 exc_type: type[BaseException] | None,
306 exc_value: BaseException | None,
307 exc_tb: types.TracebackType | None,
308 /,
309 ) -> Literal[False]:
310 """Exit the context, releasing the lock on the configuration file.""" # noqa: DOC201
311 self.unlock() 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
312 return False 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
315def configuration_mutex() -> AbstractContextManager[AbstractContextManager]:
316 """Enter a mutually exclusive context for configuration writes.
318 Within this context, no other cooperating instance of
319 `derivepassphrase` will attempt to write to its configuration
320 directory. We achieve this by locking a specific temporary file
321 (whose name depends on the location of the configuration directory)
322 for the duration of the context.
324 Returns:
325 A reusable but not reentrant context manager, ensuring mutual
326 exclusion (while within its context) with all other
327 `derivepassphrase` instances using the same configuration
328 directory.
330 Upon entering the context, the context manager returns itself.
332 Note: Locking specifics
333 The directory for the lock file is determined via
334 [`get_tempdir`][]. The lock filename is
335 `derivepassphrase-lock-<hash>.txt`, where `<hash>` is computed
336 as follows. First, canonicalize the path to the configuration
337 directory with [`pathlib.Path.resolve`][]. Then encode the
338 result as per the filesystem encoding ([`os.fsencode`][]), and
339 hash it with SHA256. Finally, convert the result to standard
340 base32 and use the first twelve characters, in lowercase, as
341 `<hash>`.
343 We use [`msvcrt.locking`][] on Windows platforms (`sys.platform
344 == "win32"`) and [`fcntl.flock`][] on all others. All locks are
345 exclusive locks. If the locking system requires a byte range,
346 we lock the first [`LOCK_SIZE`][] bytes. For maximum
347 portability between locking implementations, we first open the
348 lock file for writing, which is sometimes necessary to lock
349 a file exclusively. Thus locking will fail if we lack
350 permission to write to an already-existing lockfile.
352 """
353 return ConfigurationMutex() 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF
356def get_tempdir() -> pathlib.Path:
357 """Return a suitable temporary directory.
359 We implement the same algorithm as [`tempfile.gettempdir`][], except
360 that we default to the `derivepassphrase` configuration directory
361 instead of the current directory if no other choice is suitable, and
362 that we return [`pathlib.Path`][] objects directly.
364 """
365 paths_to_try: list[pathlib.PurePath] = [] 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
366 env_paths_to_try = [ 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
367 os.getenv("TMPDIR"),
368 os.getenv("TEMP"),
369 os.getenv("TMP"),
370 ]
371 paths_to_try.extend( 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
372 pathlib.PurePath(p) for p in env_paths_to_try if p is not None
373 )
374 posix_paths_to_try = [ 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
375 pathlib.PurePosixPath("/tmp"), # noqa: S108
376 pathlib.PurePosixPath("/var/tmp"), # noqa: S108
377 pathlib.PurePosixPath("/usr/tmp"),
378 ]
379 windows_paths_to_try = [ 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
380 pathlib.PureWindowsPath(r"~\AppData\Local\Temp"),
381 pathlib.PureWindowsPath(os.path.expandvars(r"%SYSTEMROOT%\Temp")),
382 pathlib.PureWindowsPath(r"C:\TEMP"),
383 pathlib.PureWindowsPath(r"C:\TMP"),
384 pathlib.PureWindowsPath(r"\TEMP"),
385 pathlib.PureWindowsPath(r"\TMP"),
386 ]
387 paths_to_try.extend( 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
388 windows_paths_to_try if sys.platform == "win32" else posix_paths_to_try
389 )
390 for p in paths_to_try: 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
391 path = pathlib.Path(p).expanduser() 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
392 try: 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
393 points_to_dir = path.is_dir() 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
394 except OSError: 1;
395 continue 1;
396 else:
397 if points_to_dir: 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
398 return path.resolve(strict=True) 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
399 return config_filename(subsystem=None) 1;
402def config_filename(
403 subsystem: str | None = "old settings.json",
404) -> pathlib.Path:
405 """Return the filename of the configuration file for the subsystem.
407 The (implicit default) file is currently named `settings.json`,
408 located within the configuration directory as determined by the
409 `DERIVEPASSPHRASE_PATH` environment variable, or by
410 [`click.get_app_dir`][] in POSIX mode. Depending on the requested
411 subsystem, this will usually be a different file within that
412 directory.
414 Args:
415 subsystem:
416 Name of the configuration subsystem whose configuration
417 filename to return. If not given, return the old filename
418 from before the subcommand migration. If `None`, return the
419 configuration directory instead.
421 Raises:
422 AssertionError:
423 An unknown subsystem was passed.
425 Deprecated:
426 Since v0.2.0: The implicit default subsystem and the old
427 configuration filename are deprecated, and will be removed in v1.0.
428 The subsystem will be mandatory to specify.
430 """
431 path = pathlib.Path( 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
432 os.getenv(PROG_NAME.upper() + "_PATH")
433 or click.get_app_dir(PROG_NAME, force_posix=True)
434 )
435 if subsystem == "write lock": 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
436 path_hash = base64.b32encode( 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
437 hashlib.sha256(os.fsencode(path.resolve())).digest()
438 )
439 path_hash_text = path_hash[:12].lower().decode("ASCII") 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
440 temp_path = get_tempdir() 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
441 filename_ = f"derivepassphrase-lock-{path_hash_text}.txt" 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
442 return temp_path / filename_ 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { bbV 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F cbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbub
443 try: 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { V 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F
444 filename = config_filename_table[subsystem] 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { V 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F
445 except (KeyError, TypeError): # pragma: no cover [failsafe]
446 msg = f"Unknown configuration subsystem: {subsystem!r}"
447 raise AssertionError(msg) from None
448 return path / filename 2a z t n l . / A @ [ ] ^ _ ` 0 ( 1 q Z { V 8 4 3 ) T 6 7 5 * : H ; d h e g | W X } B ~ ab9 ! u C Q R O p S r U # + = ? , 2 - L M N v w m o f I G i K c j k s x y b J $ D E % ' F
451def load_config() -> _types.VaultConfig:
452 """Load a vault(1)-compatible config from the application directory.
454 The filename is obtained via [`config_filename`][]. This must be
455 an unencrypted JSON file.
457 Returns:
458 The vault settings. See [`_types.VaultConfig`][] for details.
460 Raises:
461 OSError:
462 There was an OS error accessing the file.
463 ValueError:
464 The data loaded from the file is not a vault(1)-compatible
465 config.
467 """
468 filename = config_filename(subsystem="vault") 1aztnlA01qZV843T*HdhegWXB9!uCQROpSrU#+=?,2-vwmofIGiKcjksxybJ$DE%'F
469 with filename.open("rb") as fileobj: 1aztnlA01qZV843T*HdhegWXB9!uCQROpSrU#+=?,2-vwmofIGiKcjksxybJ$DE%'F
470 data = json.load(fileobj) 1aztnl1qZ*HdhegB9!uCQROpSrU#+,-vwmofIGiKcjkxybJ$DE%'F
471 if not _types.is_vault_config(data): 1aztnl1qZ*HdhegB9!uCQROpSrU#+,-vwmofIGiKcjkxybJ$DE%'F
472 raise ValueError(INVALID_VAULT_CONFIG) 1+
473 return data 1aztnl1qZ*HdhegB9!uCQROpSrU#,-vwmofIGiKcjkxybJ$DE%'F
476# TODO(the-13th-letter): Remove this function.
477# https://the13thletter.info/derivepassphrase/latest/upgrade-notes.html#v1.0-old-settings-file
478def migrate_and_load_old_config() -> tuple[_types.VaultConfig, OSError | None]:
479 """Load and migrate a vault(1)-compatible config.
481 The (old) filename is obtained via [`config_filename`][]. This
482 must be an unencrypted JSON file. After loading, the file is
483 migrated to the new standard filename.
485 Returns:
486 The vault settings, and an optional exception encountered during
487 migration. See [`_types.VaultConfig`][] for details on the
488 former.
490 Raises:
491 OSError:
492 There was an OS error accessing the old file.
493 ValueError:
494 The data loaded from the file is not a vault(1)-compatible
495 config.
497 """
498 new_filename = config_filename(subsystem="vault") 1A0V843)T675WX2s
499 old_filename = config_filename(subsystem="old settings.json") 1A0V843)T675WX2s
500 with old_filename.open("rb") as fileobj: 1A0V843)T675WX2s
501 data = json.load(fileobj) 143)T675
502 if not _types.is_vault_config(data): 143)T675
503 raise ValueError(INVALID_VAULT_CONFIG) 1)
504 try: 143T675
505 old_filename.rename(new_filename) 143T675
506 except OSError as exc: 135
507 return data, exc 135
508 else:
509 return data, None 14T67
512def save_config(config: _types.VaultConfig, /) -> None:
513 """Save a vault(1)-compatible config to the application directory.
515 The filename is obtained via [`config_filename`][]. The config
516 will be stored as an unencrypted JSON file.
518 Args:
519 config:
520 vault configuration to save.
522 Raises:
523 OSError:
524 There was an OS error accessing or writing the file.
525 ValueError:
526 The data cannot be stored as a vault(1)-compatible config.
528 """
529 if not _types.is_vault_config(config): 1aznAq:HdhegBurvwGsxybDEF
530 raise ValueError(INVALID_VAULT_CONFIG) 1:
531 filename = config_filename(subsystem="vault") 1aznAqHdhegBurvwGsxybDEF
532 filedir = filename.resolve().parent 1aznAqHdhegBurvwGsxybDEF
533 filedir.mkdir(parents=True, exist_ok=True) 1aznAqHdhegBurvwGsxybDEF
534 with filename.open("w", encoding="UTF-8") as fileobj: 1aznAqHdhegBurvGsxybDEF
535 json.dump( 1aznAqHdhegBurvsxybDEF
536 config, fileobj, ensure_ascii=False, indent=2, sort_keys=True
537 )
540def load_user_config() -> dict[str, Any]:
541 """Load the user config from the application directory.
543 The filename is obtained via [`config_filename`][].
545 Returns:
546 The user configuration, as a nested `dict`.
548 Raises:
549 OSError:
550 There was an OS error accessing the file.
551 ValueError:
552 The data loaded from the file is not a valid configuration
553 file.
555 """
556 filename = config_filename(subsystem="user configuration") 1aztnl./AqV8dhegWXB9!uCQROpSrU#LMNvwmofIGiKcjksxybJ$DE%'F
557 with filename.open("rb") as fileobj: 1aztnl./AqV8dhegWXB9!uCQROpSrU#LMNvwmofIGiKcjksxybJ$DE%'F
558 return tomllib.load(fileobj) 1tn.RO
561def get_suitable_ssh_keys(
562 conn: ssh_agent.SSHAgentClient
563 | _types.SSHAgentSocket
564 | Sequence[str]
565 | None = None,
566 /,
567) -> Generator[_types.SSHKeyCommentPair, None, None]:
568 """Yield all SSH keys suitable for passphrase derivation.
570 Suitable SSH keys are queried from the running SSH agent (see
571 [`ssh_agent.SSHAgentClient.list_keys`][]).
573 Args:
574 conn:
575 An optional connection hint to the SSH agent. See
576 [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][].
578 Yields:
579 Every SSH key from the SSH agent that is suitable for passphrase
580 derivation.
582 Raises:
583 derivepassphrase.ssh_agent.socketprovider.NoSuchProviderError:
584 As per
585 [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
586 Only applicable if agent auto-discovery is used.
587 KeyError:
588 As per
589 [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
590 Only applicable if agent auto-discovery is used.
591 NotImplementedError:
592 As per
593 [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient],
594 including the mulitple raise as an exception group. Only
595 applicable if agent auto-discovery is used.
596 OSError:
597 If the connection hint was a socket, then there was an error
598 setting up the socket connection to the agent.
600 Otherwise, as per
601 [`ssh_agent.SSHAgentClient.__init__`][ssh_agent.SSHAgentClient].
602 Only applicable if agent auto-discovery is used.
603 LookupError:
604 No keys usable for passphrase derivation are loaded into the
605 SSH agent.
606 RuntimeError:
607 There was an error communicating with the SSH agent.
608 ssh_agent.SSHAgentFailedError:
609 The agent failed to supply a list of loaded keys.
611 """
612 with ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn) as client: 2l xbp m o i j k P
613 try: 2xbp i j k P
614 all_key_comment_pairs = list(client.list_keys()) 2xbp i j k P
615 except EOFError as exc: # pragma: no cover [failsafe] 1jk
616 raise RuntimeError(AGENT_COMMUNICATION_ERROR) from exc
617 suitable_keys = copy.copy(all_key_comment_pairs) 2xbp i P
618 for pair in all_key_comment_pairs: 2xbp i P
619 key, _comment = pair 2xbp P
620 if vault.Vault.is_suitable_ssh_key(key, client=client): 2xbp P
621 yield pair 2xbp j k P
622 if not suitable_keys: 2xbp i P
623 raise LookupError(NO_SUITABLE_KEYS) 1i
626def prompt_for_selection(
627 items: Sequence[str | bytes],
628 heading: str = "Possible choices:",
629 single_choice_prompt: str = "Confirm this choice?",
630 ctx: click.Context | None = None,
631) -> int:
632 """Prompt user for a choice among the given items.
634 Print the heading, if any, then present the items to the user. If
635 there are multiple items, prompt the user for a selection, validate
636 the choice, then return the list index of the selected item. If
637 there is only a single item, request confirmation for that item
638 instead, and return the correct index.
640 Args:
641 items:
642 The list of items to choose from.
643 heading:
644 A heading for the list of items, to print immediately
645 before. Defaults to a reasonable standard heading. If
646 explicitly empty, print no heading.
647 single_choice_prompt:
648 The confirmation prompt if there is only a single possible
649 choice. Defaults to a reasonable standard prompt.
650 ctx:
651 An optional `click` context, from which output device
652 properties and color preferences will be queried.
654 Returns:
655 An index into the items sequence, indicating the user's
656 selection.
658 Raises:
659 IndexError:
660 The user made an invalid or empty selection, or requested an
661 abort.
663 """
664 n = len(items) 2vbwbC p c b P
665 color = ctx.color if ctx is not None else None 2vbwbC p c b P
666 if heading: 2vbwbC p c b P
667 click.echo(click.style(heading, bold=True), err=True, color=color) 2vbC p c b P
668 for i, x in enumerate(items, start=1): 2vbwbC p c b P
669 click.echo( 2vbwbC p c b P
670 click.style(f"[{i}]", bold=True), nl=False, err=True, color=color
671 )
672 click.echo(" ", nl=False, err=True, color=color) 2vbwbC p c b P
673 click.echo(x, err=True, color=color) 2vbwbC p c b P
674 if n > 1: 2vbwbC p c b P
675 choices = click.Choice([""] + [str(i) for i in range(1, n + 1)]) 2vbC p c b P
676 try: 2vbC p c b P
677 choice = click.prompt( 2vbC p c b P
678 f"Your selection? (1-{n}, leave empty to abort)",
679 err=True,
680 type=choices,
681 show_choices=False,
682 show_default=False,
683 default="",
684 )
685 except click.Abort: # pragma: no cover [external] 2vbc
686 # This branch will not be triggered during testing on
687 # `click` versions < 8.2.1, due to (non-monkeypatch-able)
688 # deficiencies in `click.testing.CliRunner`. Therefore, as
689 # an external source of nondeterminism, exclude it from
690 # coverage.
691 #
692 # https://github.com/pallets/click/issues/2934
693 choice = "" 2vbc
694 if not choice: 2vbC p c b P
695 raise IndexError(EMPTY_SELECTION) 2vbc
696 return int(choice) - 1 2vbC p b P
697 prompt_suffix = ( 2wbP
698 " " if single_choice_prompt.endswith(tuple("?.!")) else ": "
699 )
700 try: 2wbP
701 click.confirm( 2wbP
702 single_choice_prompt,
703 prompt_suffix=prompt_suffix,
704 err=True,
705 abort=True,
706 default=False,
707 show_default=False,
708 )
709 except click.Abort: 2wb
710 raise IndexError(EMPTY_SELECTION) from None 2wb
711 return 0 2wbP
714def handle_nosuchprovidererror(
715 error_callback: Callable[..., NoReturn],
716 warning_callback: Callable[..., None],
717) -> Callable[[BaseExceptionGroup], NoReturn]:
718 """Generate a handler for socketprovider.NoSuchProviderException in try-except*.
720 Returns a function emitting a standard user-facing message.
722 """ # noqa: DOC201, E501
723 del warning_callback 1lYCQROpSmoficjkbP
725 def handle_nosuchprovidererror(excgroup: BaseExceptionGroup) -> NoReturn: 1lYCQROpSmoficjkbP
726 subset = excgroup.subgroup(socketprovider.NoSuchProviderError) 1O
727 provider_name = ( 1O
728 ascii(subset.exceptions[0].args[0])
729 if subset is not None
730 else "(null)"
731 )
732 error_callback( 1O
733 _msg.TranslatedString(
734 _msg.ErrMsgTemplate.UNKNOWN_SSH_AGENT_SOCKET_PROVIDER,
735 provider=provider_name,
736 )
737 )
739 return handle_nosuchprovidererror 1lYCQROpSmoficjkbP
742def handle_keyerror(
743 error_callback: Callable[..., NoReturn],
744 warning_callback: Callable[..., None],
745) -> Callable[[BaseExceptionGroup], NoReturn]:
746 """Generate a handler for KeyError in try-except*.
748 Returns a function emitting a standard user-facing message.
750 """ # noqa: DOC201
751 del warning_callback 1lYCQROpSmoficjkbP
753 def handle_keyerror(_excgroup: BaseExceptionGroup) -> NoReturn: 1lYCQROpSmoficjkbP
754 error_callback( 1Yo
755 _msg.TranslatedString(_msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND)
756 )
758 return handle_keyerror 1lYCQROpSmoficjkbP
761def handle_notimplementederror(
762 error_callback: Callable[..., NoReturn],
763 warning_callback: Callable[..., None],
764) -> Callable[[BaseExceptionGroup], NoReturn]:
765 """Generate a handler for NotImplementedError in try-except*.
767 Returns a function emitting a standard user-facing message.
769 """ # noqa: DOC201
771 def handle_notimplementederror(excgroup: BaseExceptionGroup) -> NoReturn: 1lYCQROpSmoficjkbP
772 if excgroup.subgroup( 1lY
773 socketprovider.UnixDomainSocketsNotAvailableError
774 ):
775 warning_callback( 1lY
776 _msg.TranslatedString(
777 _msg.WarnMsgTemplate.NO_UNIX_DOMAIN_SOCKETS
778 )
779 )
780 if excgroup.subgroup( 1lY
781 socketprovider.WindowsNamedPipesNotAvailableError
782 ):
783 warning_callback( 1lY
784 _msg.TranslatedString(
785 _msg.WarnMsgTemplate.NO_WINDOWS_NAMED_PIPES
786 )
787 )
788 error_callback( 1lY
789 _msg.TranslatedString(_msg.ErrMsgTemplate.NO_AGENT_SUPPORT)
790 )
792 return handle_notimplementederror 1lYCQROpSmoficjkbP
795def handle_oserror(
796 error_callback: Callable[..., NoReturn],
797 warning_callback: Callable[..., None],
798) -> Callable[[BaseExceptionGroup], NoReturn]:
799 """Generate a handler for OSError in try-except*.
801 Returns a function emitting a standard user-facing message.
803 """ # noqa: DOC201
804 del warning_callback 1lYCQROpSmoficjkbP
806 def handle_oserror(excgroup: BaseExceptionGroup) -> NoReturn: 1lYCQROpSmoficjkbP
807 for exc in excgroup.exceptions: 1Ym
808 assert isinstance(exc, OSError) 1Ym
809 error_callback( 1Ym
810 _msg.TranslatedString(
811 _msg.ErrMsgTemplate.CANNOT_CONNECT_TO_AGENT,
812 error=exc.strerror,
813 filename=exc.filename,
814 ).maybe_without_filename()
815 )
816 raise AssertionError()
818 return handle_oserror 1lYCQROpSmoficjkbP
821def handle_runtimeerror(
822 error_callback: Callable[..., NoReturn],
823 warning_callback: Callable[..., None],
824) -> Callable[[BaseExceptionGroup], NoReturn]:
825 """Generate a handler for RuntimeError in try-except*.
827 Returns a function emitting a standard user-facing message.
829 """ # noqa: DOC201
830 del warning_callback 1lYCQROpSmoficjkbP
832 def handle_runtimeerror(excgroup: BaseExceptionGroup) -> NoReturn: 1lYCQROpSmoficjkbP
833 for exc in excgroup.exceptions: 1Yk
834 error_callback( 1Yk
835 _msg.TranslatedString(
836 _msg.ErrMsgTemplate.CANNOT_UNDERSTAND_AGENT
837 ),
838 exc_info=exc,
839 )
840 raise AssertionError()
842 return handle_runtimeerror 1lYCQROpSmoficjkbP
845def default_error_callback(
846 message: Any, # noqa: ANN401
847 /,
848 *_args: Any, # noqa: ANN401
849 **_kwargs: Any, # noqa: ANN401
850) -> NoReturn: # pragma: no cover [failsafe]
851 """Calls [`sys.exit`][] on its first argument, ignoring the rest."""
852 sys.exit(message)
855def select_ssh_key(
856 conn: ssh_agent.SSHAgentClient
857 | _types.SSHAgentSocket
858 | Sequence[str]
859 | None = None,
860 /,
861 *,
862 ctx: click.Context | None = None,
863 error_callback: Callable[..., NoReturn],
864 warning_callback: Callable[..., None],
865) -> bytes | bytearray:
866 """Interactively select an SSH key for passphrase derivation.
868 Suitable SSH keys are queried from the running SSH agent (see
869 [`ssh_agent.SSHAgentClient.list_keys`][]), then the user is prompted
870 interactively (see [`click.prompt`][]) for a selection. If any
871 error occurs, the user receives an appropriate error message.
873 Args:
874 conn:
875 An optional connection hint to the SSH agent. See
876 [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][].
877 ctx:
878 An `click` context, queried for output device properties and
879 color preferences when issuing the prompt.
880 error_callback:
881 A callback function for an error message, if any. The
882 callback function is responsible for aborting this function
883 call after acknowledging, formatting and/or forwarding the
884 error message; it would typically call [`sys.exit`][] or
885 raise an exception of its own, based on the provided error
886 message.
887 warning_callback:
888 A callback function for a warning message, if any. The
889 callback function is responsible for formatting the warning
890 message and dispatching it into the warning system, if so
891 desired.
893 Returns:
894 The selected SSH key.
896 """
898 def handle_lookuperror(_excgroup: BaseExceptionGroup) -> NoReturn: 1lCpmoficjkbP
899 error_callback( 1i
900 _msg.TranslatedString(
901 _msg.ErrMsgTemplate.NO_SUITABLE_SSH_KEYS,
902 PROG_NAME=PROG_NAME,
903 )
904 )
906 def handle_sshagentfailederror(excgroup: BaseExceptionGroup) -> NoReturn: 1lCpmoficjkbP
907 for exc in excgroup.exceptions: 1j
908 error_callback( 1j
909 _msg.TranslatedString(
910 _msg.ErrMsgTemplate.AGENT_REFUSED_LIST_KEYS
911 ),
912 exc_info=exc,
913 )
914 raise AssertionError()
916 with exceptiongroup.catch({ 1lCpmoficjkbP
917 socketprovider.NoSuchProviderError: handle_nosuchprovidererror(
918 error_callback, warning_callback
919 ),
920 KeyError: handle_keyerror(error_callback, warning_callback),
921 LookupError: handle_lookuperror,
922 NotImplementedError: handle_notimplementederror(
923 error_callback, warning_callback
924 ),
925 OSError: handle_oserror(error_callback, warning_callback),
926 ssh_agent.SSHAgentFailedError: handle_sshagentfailederror,
927 RuntimeError: handle_runtimeerror(error_callback, warning_callback),
928 }):
929 suitable_keys = list(get_suitable_ssh_keys(conn)) 1lCpmoficjkbP
930 key_listing: list[str] = [] 1CpfcbP
931 unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix 1CpfcbP
932 for key, comment in suitable_keys: 1CpfcbP
933 keytype = unstring_prefix(key)[0].decode("ASCII") 1CpfcbP
934 key_str = base64.standard_b64encode(key).decode("ASCII") 1CpfcbP
935 remaining_key_display_length = KEY_DISPLAY_LENGTH - 1 - len(keytype) 1CpfcbP
936 key_extract = min( 1CpfcbP
937 key_str,
938 "..." + key_str[-remaining_key_display_length:],
939 key=len,
940 )
941 comment_str = comment.decode("UTF-8", errors="replace") 1CpfcbP
942 key_listing.append(f"{keytype} {key_extract} {comment_str}") 1CpfcbP
943 try: 1CpfcbP
944 choice = prompt_for_selection( 1CpfcbP
945 key_listing,
946 heading="Suitable SSH keys:",
947 single_choice_prompt="Use this key?",
948 ctx=ctx,
949 )
950 except IndexError: 1fc
951 error_callback( 1fc
952 _msg.TranslatedString(
953 _msg.ErrMsgTemplate.USER_ABORTED_SSH_KEY_SELECTION
954 )
955 )
956 return suitable_keys[choice].key 1CpbP
959def prompt_for_passphrase() -> str:
960 """Interactively prompt for the passphrase.
962 Calls [`click.prompt`][] internally. Moved into a separate function
963 mainly for testing/mocking purposes.
965 Returns:
966 The user input.
968 """
969 try: 2t n Abr U w c s b
970 return cast( 2t n Abr U w c s b
971 "str",
972 click.prompt(
973 "Passphrase",
974 default="",
975 hide_input=True,
976 show_default=False,
977 err=True,
978 ),
979 )
980 except click.Abort: # pragma: no cover [external] 1c
981 # This branch will not be triggered during testing on `click`
982 # versions < 8.2.1, due to (non-monkeypatch-able) deficiencies
983 # in `click.testing.CliRunner`. Therefore, as an external source
984 # of nondeterminism, exclude it from coverage.
985 #
986 # https://github.com/pallets/click/issues/2934
987 return "" 1c
990def toml_key(*parts: str) -> str:
991 """Return a formatted TOML key, given its parts."""
993 def escape(string: str) -> str: 1tn
994 translated = string.translate({ 1tn
995 0: r"\u0000",
996 1: r"\u0001",
997 2: r"\u0002",
998 3: r"\u0003",
999 4: r"\u0004",
1000 5: r"\u0005",
1001 6: r"\u0006",
1002 7: r"\u0007",
1003 8: r"\b",
1004 9: r"\t",
1005 10: r"\n",
1006 11: r"\u000B",
1007 12: r"\f",
1008 13: r"\r",
1009 14: r"\u000E",
1010 15: r"\u000F",
1011 ord('"'): r"\"",
1012 ord("\\"): r"\\",
1013 127: r"\u007F",
1014 })
1015 return f'"{translated}"' if translated != string else string 1tn
1017 return ".".join(map(escape, parts)) 1tn
1020class ORIGIN(enum.Enum):
1021 """The origin of a setting, if not from the user configuration file.
1023 Attributes:
1024 INTERACTIVE (_msg.Label): interactive input
1026 """
1028 INTERACTIVE = _msg.Label.SETTINGS_ORIGIN_INTERACTIVE
1029 """"""
1032def check_for_misleading_passphrase(
1033 key: tuple[str, ...] | ORIGIN,
1034 value: Mapping[str, Any],
1035 *,
1036 main_config: dict[str, Any],
1037 ctx: click.Context | None = None,
1038) -> None:
1039 """Check for a misleading passphrase according to user configuration.
1041 Look up the desired Unicode normalization form in the user
1042 configuration, and if the passphrase is not normalized according to
1043 this form, issue a warning to the user.
1045 Args:
1046 key:
1047 A vault configuration key or an origin of the
1048 value/configuration section, e.g. [`ORIGIN.INTERACTIVE`][],
1049 or `("global",)`, or `("services", "foo")`.
1050 value:
1051 The vault configuration section maybe containing
1052 a passphrase to vet.
1053 main_config:
1054 The parsed main user configuration.
1055 ctx:
1056 The click context. This is necessary to pass output options
1057 set on the context to the logging machinery.
1059 Raises:
1060 AssertionError:
1061 The main user configuration is invalid.
1063 """
1064 form_key = "unicode-normalization-form" 1aztnAqVdhegWXBurUvwsxyb
1065 default_form: str = main_config.get("vault", {}).get( 1aztnAqVdhegWXBurUvwsxyb
1066 f"default-{form_key}", "NFC"
1067 )
1068 form_dict: dict[str, dict] = main_config.get("vault", {}).get(form_key, {}) 1aztnAqVdhegWXBurUvwsxyb
1069 form: Any = ( 1aztnAqVdhegWXBurUvwsxyb
1070 default_form
1071 if isinstance(key, ORIGIN) or key == ("global",)
1072 else form_dict.get(key[1], default_form)
1073 )
1074 config_key = ( 1aztnAqVdhegWXBurUvwsxyb
1075 toml_key("vault", key[1], form_key)
1076 if isinstance(key, tuple) and len(key) > 1 and key[1] in form_dict
1077 else f"vault.default-{form_key}"
1078 )
1079 if form not in {"NFC", "NFD", "NFKC", "NFKD"}: 1aztnAqVdhegWXBurUvwsxyb
1080 msg = f"Invalid value {form!r} for config key {config_key}" 1t
1081 raise AssertionError(msg) 1t
1082 logger = logging.getLogger(PROG_NAME) 1aztnAqVdhegWXBurUvwsxyb
1083 formatted_key = ( 1aztnAqVdhegWXBurUvwsxyb
1084 str(_msg.TranslatedString(key.value))
1085 if isinstance(key, ORIGIN)
1086 else _types.json_path(key)
1087 )
1088 if "phrase" in value: 1aztnAqVdhegWXBurUvwsxyb
1089 phrase = value["phrase"] 1nVdhegWXurUvwsxyb
1090 if not unicodedata.is_normalized(form, phrase): 1nVdhegWXurUvwsxyb
1091 logger.warning( 1n
1092 _msg.TranslatedString(
1093 _msg.WarnMsgTemplate.PASSPHRASE_NOT_NORMALIZED,
1094 key=formatted_key,
1095 form=form,
1096 ),
1097 stacklevel=2,
1098 extra={"color": ctx.color if ctx is not None else None},
1099 )
1102def get_configured_connection_hint(
1103 conn: ssh_agent.SSHAgentClient
1104 | _types.SSHAgentSocket
1105 | Sequence[str]
1106 | None = None,
1107 /,
1108 *,
1109 main_config: dict[str, Any],
1110) -> ssh_agent.SSHAgentClient | _types.SSHAgentSocket | Sequence[str] | None:
1111 """Return a suitable connection hint for the SSH agent client.
1113 If a connection hint is already known, return that connection hint.
1114 Otherwise, look up configured SSH agent socket providers in the user
1115 configuration and return those as a hint, if possible.
1117 Args:
1118 conn:
1119 An optional connection hint to the SSH agent, to be used if
1120 non-trivial.
1121 main_config:
1122 The parsed main user configuration.
1124 Returns:
1125 A connection hint suitable for use with
1126 [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][].
1128 """
1130 def handle_return_value( 1lCQROpSmoficjkb
1131 val: ssh_agent.SSHAgentClient 1lCQROpSmoficjkb
1132 | _types.SSHAgentSocket
1133 | str
1134 | Sequence[str]
1135 | None,
1136 ) -> (
1137 ssh_agent.SSHAgentClient | _types.SSHAgentSocket | Sequence[str] | None 1lCQROpSmoficjkb
1138 ): # pragma: no cover [external]
1139 # A separate function, to easily exclude it from coverage,
1140 # because it's just type handling, which the type checker
1141 # already checks.
1142 #
1143 # Strings should be packed into singleton tuples, and general
1144 # sequences should be turned into readonly tuples. All other
1145 # types of values can be passed through. But because strings
1146 # are sequences as well, we need to check for strings
1147 # separately.
1148 if isinstance(val, str): 1lCQROpSmoficjkb
1149 return (val,) 1RO
1150 if isinstance(val, Sequence): 1lCQpSmoficjkb
1151 return tuple(val)
1152 return val 1lCQpSmoficjkb
1154 return handle_return_value( 1lCQROpSmoficjkb
1155 conn
1156 if conn is not None
1157 else cast(
1158 "str | Sequence[str] | None",
1159 main_config.get("vault", {}).get("ssh-agent-socket-provider"),
1160 )
1161 )
1164def key_to_phrase(
1165 key: str | Buffer,
1166 /,
1167 *,
1168 error_callback: Callable[..., NoReturn],
1169 warning_callback: Callable[..., None],
1170 conn: ssh_agent.SSHAgentClient
1171 | _types.SSHAgentSocket
1172 | Sequence[str]
1173 | None = None,
1174) -> bytes:
1175 """Return the equivalent master passphrase, or abort.
1177 This wrapper around [`vault.Vault.phrase_from_key`][] emits
1178 user-facing error messages if no equivalent master passphrase can be
1179 obtained from the key, because this is the first point of contact
1180 with the SSH agent.
1182 Args:
1183 key:
1184 The SSH key for which the equivalent master passphrase shall
1185 be computed, encoded in base64.
1186 conn:
1187 An optional connection hint to the SSH agent. See
1188 [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][].
1189 error_callback:
1190 A callback function for an error message, if any. The
1191 callback function is responsible for aborting this function
1192 call after acknowledging, formatting and/or forwarding the
1193 error message; it would typically call [`sys.exit`][] or
1194 raise an exception of its own, based on the provided error
1195 message.
1196 warning_callback:
1197 A callback function for a warning message, if any. The
1198 callback function is responsible for formatting the warning
1199 message and dispatching it into the warning system, if so
1200 desired.
1202 """
1203 key = base64.standard_b64decode(key) 1YCQROpS
1204 with exceptiongroup.catch({ # noqa: SIM117 1YCQROpS
1205 socketprovider.NoSuchProviderError: handle_nosuchprovidererror(
1206 error_callback, warning_callback
1207 ),
1208 KeyError: handle_keyerror(error_callback, warning_callback),
1209 NotImplementedError: handle_notimplementederror(
1210 error_callback, warning_callback
1211 ),
1212 OSError: handle_oserror(error_callback, warning_callback),
1213 RuntimeError: handle_runtimeerror(error_callback, warning_callback),
1214 }):
1215 with ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn) as client: 1YCQROpS
1216 try: 1YCQRpS
1217 return vault.Vault.phrase_from_key(key, conn=client) 1YCQRpS
1218 except ssh_agent.SSHAgentFailedError as exc: 1Y
1219 try: 1Y
1220 keylist = client.list_keys() 1Y
1221 except ssh_agent.SSHAgentFailedError: 1Y
1222 pass 1Y
1223 except Exception as exc2: # noqa: BLE001 1Y
1224 exc.__context__ = exc2 1Y
1225 else:
1226 if not any( # pragma: no branch 1Y
1227 k == key for k, _ in keylist
1228 ):
1229 error_callback( 1Y
1230 _msg.TranslatedString(
1231 _msg.ErrMsgTemplate.SSH_KEY_NOT_LOADED
1232 )
1233 )
1234 error_callback( 1YO
1235 _msg.TranslatedString(
1236 _msg.ErrMsgTemplate.AGENT_REFUSED_SIGNATURE
1237 ),
1238 exc_info=exc,
1239 )
1242def print_config_as_sh_script(
1243 config: _types.VaultConfig,
1244 /,
1245 *,
1246 outfile: TextIO,
1247 prog_name_list: Sequence[str],
1248) -> None:
1249 """Print the given vault configuration as a sh(1) script.
1251 This implements the `--export-as=sh` option of `derivepassphrase vault`.
1253 Args:
1254 config:
1255 The configuration to serialize.
1256 outfile:
1257 A file object to write the output to.
1258 prog_name_list:
1259 A list of (subcommand) names for the command emitting this
1260 output, e.g. `["derivepassphrase", "vault"]`.
1262 """
1263 service_keys = ( 1dheg2
1264 "length",
1265 "repeat",
1266 "lower",
1267 "upper",
1268 "number",
1269 "space",
1270 "dash",
1271 "symbol",
1272 )
1273 print("#!/bin/sh -e", file=outfile) 1dheg2
1274 print(file=outfile) 1dheg2
1275 print(shlex.join([*prog_name_list, "--clear"]), file=outfile) 1dheg2
1276 sv_obj_pairs: list[ 1dheg2
1277 tuple[
1278 str | None,
1279 _types.VaultConfigGlobalSettings
1280 | _types.VaultConfigServicesSettings,
1281 ],
1282 ] = list(config["services"].items())
1283 if config.get("global", {}): 1dheg2
1284 sv_obj_pairs.insert(0, (None, config["global"])) 1dh
1285 for sv, sv_obj in sv_obj_pairs: 1dheg2
1286 this_service_keys = tuple(k for k in service_keys if k in sv_obj) 1dheg
1287 this_other_keys = tuple(k for k in sv_obj if k not in service_keys) 1dheg
1288 if this_other_keys: 1dheg
1289 other_sv_obj = {k: sv_obj[k] for k in this_other_keys} # type: ignore[literal-required] 1dheg
1290 dumped_config = json.dumps( 1dheg
1291 (
1292 {"services": {sv: other_sv_obj}}
1293 if sv is not None
1294 else {"global": other_sv_obj, "services": {}}
1295 ),
1296 ensure_ascii=False,
1297 indent=None,
1298 )
1299 print( 1dheg
1300 shlex.join([*prog_name_list, "--import", "-"]) + " <<'HERE'",
1301 dumped_config,
1302 "HERE",
1303 sep="\n",
1304 file=outfile,
1305 )
1306 if not this_service_keys and not this_other_keys and sv: 1dheg
1307 dumped_config = json.dumps( 1g
1308 {"services": {sv: {}}},
1309 ensure_ascii=False,
1310 indent=None,
1311 )
1312 print( 1g
1313 shlex.join([*prog_name_list, "--import", "-"]) + " <<'HERE'",
1314 dumped_config,
1315 "HERE",
1316 sep="\n",
1317 file=outfile,
1318 )
1319 elif this_service_keys: 1dheg
1320 tokens = [*prog_name_list, "--config"] 1de
1321 for key in this_service_keys: 1de
1322 tokens.extend([f"--{key}", str(sv_obj[key])]) # type: ignore[literal-required] 1de
1323 if sv is not None: 1de
1324 tokens.extend(["--", sv]) 1e
1325 print(shlex.join(tokens), file=outfile) 1de