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

1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5 

6"""Helper functions for the derivepassphrase command-line. 

7 

8Warning: 

9 Non-public module (implementation detail), provided for didactical and 

10 educational purposes only. Subject to change without notice, including 

11 removal. 

12 

13""" 

14 

15from __future__ import annotations 

16 

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 

31 

32import click 

33import click.shell_completion 

34import exceptiongroup 

35from typing_extensions import Any 

36 

37from derivepassphrase import _types, ssh_agent, vault 

38from derivepassphrase._internals import cli_messages as _msg 

39from derivepassphrase.ssh_agent import socketprovider 

40 

41if sys.version_info >= (3, 11): 

42 import tomllib 

43else: 

44 import tomli as tomllib 

45 from exceptiongroup import BaseExceptionGroup 

46 

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 ) 

61 

62 from typing_extensions import Buffer, Self 

63 

64PROG_NAME = _msg.PROG_NAME 

65KEY_DISPLAY_LENGTH = 50 

66 

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" 

72 

73 

74# Shell completion 

75# ================ 

76 

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

86 

87 

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

96 

97 

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. 

121 

122 

123def is_completable_item(obj: object) -> bool: 

124 """Return whether the item is completable on the command-line. 

125 

126 The item is completable if and only if it contains no ASCII control 

127 characters (U+0000 through U+001F, and U+007F). 

128 

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

133 

134 

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. 

141 

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. 

146 

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(

168 

169 

170# Vault 

171# ===== 

172 

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} 

183 

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. 

188 

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

193 

194 

195class ConfigurationMutex: 

196 """A mutual exclusion context manager for configuration edits. 

197 

198 See [`configuration_mutex`][]. 

199 

200 """ 

201 

202 lock: Callable[[], None] 

203 """A function to lock the mutex exclusively. 

204 

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`][]. 

209 

210 Note: 

211 This is a normal Python function, not a method. 

212 

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. 

217 

218 """ 

219 unlock: Callable[[], None] 

220 """A function to unlock the mutex. 

221 

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`][]. 

227 

228 Note: 

229 This is a normal Python function, not a method. 

230 

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. 

235 

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.""" 

243 

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

248 

249 locking = msvcrt.locking 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

250 LK_LOCK = msvcrt.LK_LOCK # noqa: N806 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

251 LK_UNLCK = msvcrt.LK_UNLCK # noqa: N806 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

252 

253 def lock_fd(fd: int, /) -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

254 locking(fd, LK_LOCK, LOCK_SIZE) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

255 

256 def unlock_fd(fd: int, /) -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

257 locking(fd, LK_UNLCK, LOCK_SIZE) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

258 

259 else: # pragma: unless posix no cover 

260 import fcntl # noqa: PLC0415 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

261 

262 flock = fcntl.flock 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

263 LOCK_EX = fcntl.LOCK_EX # noqa: N806 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

264 LOCK_UN = fcntl.LOCK_UN # noqa: N806 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

265 

266 def lock_fd(fd: int, /) -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

267 flock(fd, LOCK_EX) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

268 

269 def unlock_fd(fd: int, /) -> None: 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

270 flock(fd, LOCK_UN) 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

271 

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

281 

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

291 

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

297 

298 def __enter__(self) -> Self: 

299 """Enter the context, locking the configuration file.""" # noqa: DOC201 

300 self.lock() 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

301 return self 1aztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

302 

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

313 

314 

315def configuration_mutex() -> AbstractContextManager[AbstractContextManager]: 

316 """Enter a mutually exclusive context for configuration writes. 

317 

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. 

323 

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. 

329 

330 Upon entering the context, the context manager returns itself. 

331 

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>`. 

342 

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. 

351 

352 """ 

353 return ConfigurationMutex() 1ztnlAqHdhegBurLMNvwmofIGiKcjksxybJDEF

354 

355 

356def get_tempdir() -> pathlib.Path: 

357 """Return a suitable temporary directory. 

358 

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. 

363 

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;

400 

401 

402def config_filename( 

403 subsystem: str | None = "old settings.json", 

404) -> pathlib.Path: 

405 """Return the filename of the configuration file for the subsystem. 

406 

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. 

413 

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. 

420 

421 Raises: 

422 AssertionError: 

423 An unknown subsystem was passed. 

424 

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. 

429 

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

449 

450 

451def load_config() -> _types.VaultConfig: 

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

453 

454 The filename is obtained via [`config_filename`][]. This must be 

455 an unencrypted JSON file. 

456 

457 Returns: 

458 The vault settings. See [`_types.VaultConfig`][] for details. 

459 

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. 

466 

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

474 

475 

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. 

480 

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. 

484 

485 Returns: 

486 The vault settings, and an optional exception encountered during 

487 migration. See [`_types.VaultConfig`][] for details on the 

488 former. 

489 

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. 

496 

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

510 

511 

512def save_config(config: _types.VaultConfig, /) -> None: 

513 """Save a vault(1)-compatible config to the application directory. 

514 

515 The filename is obtained via [`config_filename`][]. The config 

516 will be stored as an unencrypted JSON file. 

517 

518 Args: 

519 config: 

520 vault configuration to save. 

521 

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. 

527 

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 ) 

538 

539 

540def load_user_config() -> dict[str, Any]: 

541 """Load the user config from the application directory. 

542 

543 The filename is obtained via [`config_filename`][]. 

544 

545 Returns: 

546 The user configuration, as a nested `dict`. 

547 

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. 

554 

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

559 

560 

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. 

569 

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

571 [`ssh_agent.SSHAgentClient.list_keys`][]). 

572 

573 Args: 

574 conn: 

575 An optional connection hint to the SSH agent. See 

576 [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][]. 

577 

578 Yields: 

579 Every SSH key from the SSH agent that is suitable for passphrase 

580 derivation. 

581 

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. 

599 

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. 

610 

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

624 

625 

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. 

633 

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. 

639 

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. 

653 

654 Returns: 

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

656 selection. 

657 

658 Raises: 

659 IndexError: 

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

661 abort. 

662 

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

712 

713 

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*. 

719 

720 Returns a function emitting a standard user-facing message. 

721 

722 """ # noqa: DOC201, E501 

723 del warning_callback 1lYCQROpSmoficjkbP

724 

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 ) 

738 

739 return handle_nosuchprovidererror 1lYCQROpSmoficjkbP

740 

741 

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*. 

747 

748 Returns a function emitting a standard user-facing message. 

749 

750 """ # noqa: DOC201 

751 del warning_callback 1lYCQROpSmoficjkbP

752 

753 def handle_keyerror(_excgroup: BaseExceptionGroup) -> NoReturn: 1lYCQROpSmoficjkbP

754 error_callback( 1Yo

755 _msg.TranslatedString(_msg.ErrMsgTemplate.NO_SSH_AGENT_FOUND) 

756 ) 

757 

758 return handle_keyerror 1lYCQROpSmoficjkbP

759 

760 

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*. 

766 

767 Returns a function emitting a standard user-facing message. 

768 

769 """ # noqa: DOC201 

770 

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 ) 

791 

792 return handle_notimplementederror 1lYCQROpSmoficjkbP

793 

794 

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*. 

800 

801 Returns a function emitting a standard user-facing message. 

802 

803 """ # noqa: DOC201 

804 del warning_callback 1lYCQROpSmoficjkbP

805 

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

817 

818 return handle_oserror 1lYCQROpSmoficjkbP

819 

820 

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*. 

826 

827 Returns a function emitting a standard user-facing message. 

828 

829 """ # noqa: DOC201 

830 del warning_callback 1lYCQROpSmoficjkbP

831 

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

841 

842 return handle_runtimeerror 1lYCQROpSmoficjkbP

843 

844 

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) 

853 

854 

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. 

867 

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. 

872 

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. 

892 

893 Returns: 

894 The selected SSH key. 

895 

896 """ 

897 

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 ) 

905 

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

915 

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

957 

958 

959def prompt_for_passphrase() -> str: 

960 """Interactively prompt for the passphrase. 

961 

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

963 mainly for testing/mocking purposes. 

964 

965 Returns: 

966 The user input. 

967 

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

988 

989 

990def toml_key(*parts: str) -> str: 

991 """Return a formatted TOML key, given its parts.""" 

992 

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

1016 

1017 return ".".join(map(escape, parts)) 1tn

1018 

1019 

1020class ORIGIN(enum.Enum): 

1021 """The origin of a setting, if not from the user configuration file. 

1022 

1023 Attributes: 

1024 INTERACTIVE (_msg.Label): interactive input 

1025 

1026 """ 

1027 

1028 INTERACTIVE = _msg.Label.SETTINGS_ORIGIN_INTERACTIVE 

1029 """""" 

1030 

1031 

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. 

1040 

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. 

1044 

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. 

1058 

1059 Raises: 

1060 AssertionError: 

1061 The main user configuration is invalid. 

1062 

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 ) 

1100 

1101 

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. 

1112 

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. 

1116 

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. 

1123 

1124 Returns: 

1125 A connection hint suitable for use with 

1126 [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][]. 

1127 

1128 """ 

1129 

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

1153 

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 ) 

1162 

1163 

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. 

1176 

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. 

1181 

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. 

1201 

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 ) 

1240 

1241 

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. 

1250 

1251 This implements the `--export-as=sh` option of `derivepassphrase vault`. 

1252 

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"]`. 

1261 

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