Coverage for src\derivepassphrase\vault.py: 98.454%

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

2#

3# SPDX-License-Identifier: Zlib

4

5"""Python port of the vault(1) password generation scheme."""

6

7 from __future__ import annotations (empty)

8

9 import base64 (empty)

10 import collections (empty)

11 import hashlib (empty)

12 import hmac (empty)

13 import math (empty)

14 import types (empty)

15 from typing import TYPE_CHECKING, Final (empty)

16

17 from typing_extensions import TypeAlias, assert_type (empty)

18

19 from derivepassphrase import _types, sequin, ssh_agent (empty)

20

21 if TYPE_CHECKING: (empty)

22 from collections.abc import Callable, Sequence

23

24 from typing_extensions import Buffer

25

26

27 class Vault: (empty)

28 """A work-alike of James Coglan's vault.

29

30 Store settings for generating (actually: deriving) passphrases for

31 named services, with various constraints, given only a master

32 passphrase. Also, actually generate the passphrase. The derivation

33 is deterministic and non-secret; only the master passphrase need be

34 kept secret. The implementation is compatible with [vault][].

35

36 [James Coglan explains the passphrase derivation algorithm in great

37 detail][ALGORITHM] in his blog post on said topic: A principally

38 infinite bit stream is obtained by running a key-derivation function

39 on the master passphrase and the service name, then this bit stream

40 is fed into a [sequin.Sequin][] to generate random numbers in the

41 correct range, and finally these random numbers select passphrase

42 characters until the desired length is reached.

43

44 [vault]: https://www.npmjs.com/package/vault

45 [ALGORITHM]: https://blog.jcoglan.com/2012/07/16/designing-vaults-generator-algorithm/

46

47 """

48

49 UUID: Final = b'e87eb0f4-34cb-46b9-93ad-766c5ab063e7' (empty)

50 """A tag used by vault in the bit stream generation.""" (empty)

51 CHARSETS: Final = types.MappingProxyType( (empty)

52 collections.OrderedDict([

53 ('lower', b'abcdefghijklmnopqrstuvwxyz'),

54 ('upper', b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),

55 (

56 'alpha',

57 (

58 # CHARSETS['lower']

59 b'abcdefghijklmnopqrstuvwxyz'

60 # CHARSETS['upper']

61 b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

62 ),

63 ),

64 ('number', b'0123456789'),

65 (

66 'alphanum',

67 (

68 # CHARSETS['lower']

69 b'abcdefghijklmnopqrstuvwxyz'

70 # CHARSETS['upper']

71 b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

72 # CHARSETS['number']

73 b'0123456789'

74 ),

75 ),

76 ('space', b' '),

77 ('dash', b'-_'),

78 ('symbol', b'!"#$%&\'()*+,./:;<=>?@[\\]^{|}~-_'),

79 (

80 'all',

81 (

82 # CHARSETS['lower']

83 b'abcdefghijklmnopqrstuvwxyz'

84 # CHARSETS['upper']

85 b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

86 # CHARSETS['number']

87 b'0123456789'

88 # CHARSETS['space']

89 b' '

90 # CHARSETS['symbol']

91 b'!"#$%&\'()*+,./:;<=>?@[\\]^{|}~-_'

92 ),

93 ),

94 ])

95 )

96 """ (empty)

97 Known character sets from which to draw passphrase characters.

98 Relies on a certain, fixed order for their definition and their

99 contents.

100

101 """

102

103 def __init__( # noqa: PLR0913 (empty)

104 self,

105 *,

106 phrase: Buffer | str = b'',

107 length: int = 20,

108 repeat: int = 0,

109 lower: int | None = None,

110 upper: int | None = None,

111 number: int | None = None,

112 space: int | None = None,

113 dash: int | None = None,

114 symbol: int | None = None,

115 ) -> None:

116 """Initialize the Vault object.

117

118 Args:

119 phrase:

120 The master passphrase from which to derive the service

121 passphrases. If a string, then the UTF-8 encoding of

122 the string is used.

123 length:

124 Desired passphrase length.

125 repeat:

126 The maximum number of immediate character repetitions

127 allowed in the passphrase. Disabled if set to 0.

128 lower:

129 Optional constraint on ASCII lowercase characters. If

130 positive, include this many lowercase characters

131 somewhere in the passphrase. If 0, avoid lowercase

132 characters altogether.

133 upper:

134 Same as `lower`, but for ASCII uppercase characters.

135 number:

136 Same as `lower`, but for ASCII digits.

137 space:

138 Same as `lower`, but for the space character.

139 dash:

140 Same as `lower`, but for the hyphen-minus and underscore

141 characters.

142 symbol:

143 Same as `lower`, but for all other ASCII printable

144 characters except lowercase characters, uppercase

145 characters, digits, space and backquote.

146

147 Raises:

148 ValueError:

149 Conflicting passphrase constraints. Permit more

150 characters, or increase the desired passphrase length.

151

152 Warning:

153 Because of repetition constraints, it is not always possible

154 to detect conflicting passphrase constraints at construction

155 time.

156

157 """

158 self._phrase = self._get_binary_string(phrase) 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

159 self._length = length 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

160 self._repeat = repeat 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

161 self._allowed = bytearray(self.CHARSETS['all']) 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

162 self._required: list[bytes] = [] 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

163

164 def subtract_or_require( 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

165 count: int | None, characters: bytes | bytearray

166 ) -> None:

167 if not isinstance(count, int): 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

168 return 31 ctx 1ahziojABCJKDfklmwxdnMNLQPEFGHIg

169 if count <= 0: 27 ctx 1ahpqeorjstuvfklmwxydnMNQOcb

170 self._allowed = self._subtract(characters, self._allowed) 14 ctx 1ahjfklmdnMNOcb

171 else:

172 for _ in range(count): 17 ctx 1apqeorstuvwxyNQcb

173 self._required.append(characters) 17 ctx 1apqeorstuvwxyNQcb

174

175 subtract_or_require(lower, self.CHARSETS['lower']) 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

176 subtract_or_require(upper, self.CHARSETS['upper']) 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

177 subtract_or_require(number, self.CHARSETS['number']) 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

178 subtract_or_require(space, self.CHARSETS['space']) 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

179 subtract_or_require(dash, self.CHARSETS['dash']) 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

180 subtract_or_require(symbol, self.CHARSETS['symbol']) 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

181 if len(self._required) > self._length: 43 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLQOPEFGHcIbg

182 msg = 'requested passphrase length too short' 1 ctx 1Q

183 raise ValueError(msg) 1 ctx 1Q

184 if not self._allowed: 42 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLOPEFGHcIbg

185 msg = 'no allowed characters left' 1 ctx 1O

186 raise ValueError(msg) 1 ctx 1O

187 for _ in range(len(self._required), self._length): 41 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNLPEFGHcIbg

188 self._required.append(bytes(self._allowed)) 40 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMLPEFGHcIbg

189

190 def _entropy(self) -> float: (empty)

191 """Estimate the passphrase entropy, given the current settings.

192

193 The entropy is the base 2 logarithm of the amount of

194 possibilities. We operate directly on the logarithms, and use

195 sorting and [`math.fsum`][] to keep high accuracy.

196

197 Note:

198 We actually overestimate the entropy here because of poor

199 handling of character repetitions. In the extreme, assuming

200 that only one character were allowed, then because there is

201 only one possible string of each given length, the entropy

202 of that string `s` is always be zero. However, we calculate

203 the entropy as `math.log2(math.factorial(len(s)))`, i.e. we

204 assume the characters at the respective string position are

205 distinguishable from each other.

206

207 Returns:

208 A valid (and somewhat close) upper bound to the entropy.

209

210 """

211 factors: list[int] = [] 39 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNEFGHcIbg

212 if not self._required or any(not x for x in self._required): 39 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNEFGHcIbg

213 return float('-inf') 1 ctx 1M

214 for i, charset in enumerate(self._required): 39 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNEFGHcIbg

215 factors.extend([i + 1, len(charset)]) 39 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNEFGHcIbg

216 factors.sort() 39 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNEFGHcIbg

217 return math.fsum(math.log2(f) for f in factors) 39 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNEFGHcIbg

218

219 def _estimate_sufficient_hash_length( (empty)

220 self,

221 safety_factor: float = 2.0,

222 ) -> int:

223 """Estimate the sufficient hash length, given the current settings.

224

225 Using the entropy (via [`_entropy`][]) and a safety factor, give

226 an initial estimate of the length to use for [`create_hash`][]

227 such that using a [`sequin.Sequin`][] with this hash will not

228 exhaust it during passphrase generation.

229

230 Args:

231 safety_factor: The safety factor. Must be at least 1.

232

233 Returns:

234 The estimated sufficient hash length.

235

236 Raises:

237 ValueError: The safety factor is less than 1, or not finite.

238

239 Warning:

240 This is a heuristic, not an exact computation; it may

241 underestimate the true necessary hash length. It is

242 intended as a starting point for searching for a sufficient

243 hash length, usually by doubling the hash length each time

244 it does not yet prove so.

245

246 """ # noqa: DOC501

247 try: 40 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNPEFGHcIbg

248 safety_factor = float(safety_factor) 40 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNPEFGHcIbg

249 except TypeError as e: 1 ctx 1P

250 msg = f'invalid safety factor: not a float: {safety_factor! r}' 1 ctx 1P

251 raise TypeError(msg) from e 1 ctx 1P

252 if not math.isfinite(safety_factor) or safety_factor < 1.0: 40 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNPEFGHcIbg

253 msg = f'invalid safety factor {safety_factor! r}' 1 ctx 1P

254 raise ValueError(msg) 1 ctx 1P

255 # Ensure the bound is strictly positive.

256 entropy_bound = max(1, self._entropy()) 39 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNEFGHcIbg

257 return math.ceil(safety_factor * entropy_bound / 8) 39 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNEFGHcIbg

258

259 @staticmethod (empty)

260 def _get_binary_string(s: Buffer | str, /) -> bytes: (empty)

261 """Convert the input string to a read-only, binary string.

262

263 If it is a text string, return the string's UTF-8

264 representation.

265

266 Args:

267 s: The string to (check and) convert.

268

269 Returns:

270 A read-only, binary copy of the string.

271

272 """

273 if isinstance(s, str): 51 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNL4QOPZ0123SREFGHcIbg

274 return s.encode('UTF-8') 44 ctx 1ahzpqieorjstuvABCDfklmwxydnL4Z0123SREFGHcIbg

275 return bytes(s) 51 ctx 1ahzpqieorjstuvABCJKDfklmwxydnMNL4QOPZ0123SREFGHcIbg

276

277 @classmethod (empty)

278 def create_hash( (empty)

279 cls,

280 phrase: Buffer | str,

281 service: Buffer | str,

282 *,

283 length: int = 32,

284 ) -> bytes:

285 r"""Create a pseudorandom byte stream from phrase and service.

286

287 Create a pseudorandom byte stream from `phrase` and `service` by

288 feeding them into the key-derivation function PBKDF2

289 (8 iterations, using SHA-1).

290

291 Args:

292 phrase:

293 A master passphrase, or sometimes an SSH signature.

294 Used as the key for PBKDF2, the underlying cryptographic

295 primitive. If a string, then the UTF-8 encoding of the

296 string is used.

297 service:

298 A vault service name. Will be suffixed with the

299 [`UUID`][], and then used as the salt value for

300 PBKDF2. If a string, then the UTF-8 encoding of the

301 string is used.

302 length:

303 The length of the byte stream to generate.

304

305 Returns:

306 A pseudorandom byte string of length `length`.

307

308 Note:

309 Shorter values returned from this method (with the same key

310 and message) are prefixes of longer values returned from

311 this method. (This property is inherited from the

312 underlying PBKDF2 function.) It is thus safe (if slow) to

313 call this method with the same input with ever-increasing

314 target lengths.

315

316 Examples:

317 >>> # See also Vault.phrase_from_key examples.

318 >>> phrase = bytes.fromhex('''

319 ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39

320 ... 00 00 00 40

321 ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86

322 ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd

323 ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c

324 ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02

325 ... ''')

326 >>> Vault.create_hash(phrase, 'some_service', length=4)

327 b'M\xb1<S'

328 >>> Vault.create_hash(phrase, b'some_service', length=16)

329 b'M\xb1<S\x827E\xd1M\xaf\xf8~\xc8n\x10\xcc'

330 >>> Vault.create_hash(phrase, b'NOSUCHSERVICE', length=16)

331 b'\x1c\xc3\x9c\xd9\xb6\x1a\x99CS\x07\xc41\xf4\x85#s'

332

333 """

334 phrase = cls._get_binary_string(phrase) 45 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLZ0123SREFGHcIbg

335 assert isinstance(phrase, bytes) 45 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLZ0123SREFGHcIbg

336 salt = cls._get_binary_string(service) + cls.UUID 45 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLZ0123SREFGHcIbg

337 return hashlib.pbkdf2_hmac( 45 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLZ0123SREFGHcIbg

338 hash_name='sha1',

339 password=phrase,

340 salt=salt,

341 iterations=8,

342 dklen=length,

343 )

344

345 def generate( (empty)

346 self,

347 service_name: Buffer | str,

348 /,

349 *,

350 phrase: Buffer | str = b'',

351 ) -> bytes:

352 r"""Generate a service passphrase.

353

354 Args:

355 service_name:

356 The service name. If a string, then the UTF-8 encoding

357 of the string is used.

358 phrase:

359 If given, override the passphrase given during

360 construction. If a string, then the UTF-8 encoding of

361 the string is used.

362

363 Returns:

364 The service passphrase.

365

366 Raises:

367 ValueError:

368 Conflicting passphrase constraints. Permit more

369 characters, or increase the desired passphrase length.

370

371 Examples:

372 >>> phrase = b'She cells C shells bye the sea shoars'

373 >>> # Using default options in constructor.

374 >>> Vault(phrase=phrase).generate(b'google')

375 b': 4TVH#5:aZl8LueOT\\{'

376 >>> # Also possible:

377 >>> Vault().generate(b'google', phrase=phrase)

378 b': 4TVH#5:aZl8LueOT\\{'

379

380 Conflicting constraints are sometimes only found during

381 generation.

382

383 >>> # Note: no error here...

384 >>> v = Vault(

385 ... lower=0,

386 ... upper=0,

387 ... number=0,

388 ... space=2,

389 ... dash=0,

390 ... symbol=1,

391 ... repeat=2,

392 ... length=3,

393 ... )

394 >>> # ... but here.

395 >>> v.generate(

396 ... '0', phrase=b'\x00'

397 ... ) # doctest: +IGNORE_EXCEPTION_DETAIL

398 Traceback (most recent call last):

399 ...

400 ValueError: no allowed characters left

401

402

403 """

404 hash_length = self._estimate_sufficient_hash_length() 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

405 assert hash_length >= 1 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

406 # Ensure the phrase and the service name are bytes objects.

407 # This is needed later for safe concatenation.

408 service_name = self._get_binary_string(service_name) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

409 assert_type(service_name, bytes) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

410 if not phrase: 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

411 phrase = self._phrase 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

412 phrase = self._get_binary_string(phrase) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

413 assert_type(phrase, bytes) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

414 # Repeat the passphrase generation with ever-increasing hash

415 # lengths, until the passphrase can be formed without exhausting

416 # the sequin. See the guarantee in the create_hash method for

417 # why this works.

418 while True: 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

419 try: 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

420 required = self._required[:] 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

421 seq = sequin.Sequin( 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

422 self.create_hash(

423 phrase=phrase, service=service_name, length=hash_length

424 )

425 )

426 result = bytearray() 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

427 while len(result) < self._length: 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

428 pos = seq.generate(len(required)) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

429 charset = required.pop(pos) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

430 # Determine if an unlucky choice right now might

431 # violate the restriction on repeated characters.

432 # That is, check if the current partial passphrase

433 # ends with r - 1 copies of the same character

434 # (where r is the repeat limit that must not be

435 # reached), and if so, remove this same character

436 # from the current character's allowed set.

437 if self._repeat and result: 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

438 bad_suffix = bytes(result[-1:]) * (self._repeat - 1) 15 ctx 1apqeorstuvfdcbg

439 if result.endswith(bad_suffix): 15 ctx 1apqeorstuvfdcbg

440 charset = self._subtract( 6 ctx 1afdcbg

441 bytes(result[-1:]), charset

442 )

443 pos = seq.generate(len(charset)) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

444 result.extend(charset[pos : pos + 1]) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

445 except ValueError as exc: 3 ctx 1aLb

446 msg = 'no allowed characters left' 2 ctx 1ab

447 raise ValueError(msg) from exc 2 ctx 1ab

448 except sequin.SequinExhaustedError: 1 ctx 1L

449 hash_length *= 2 1 ctx 1L

450 else:

451 return bytes(result) 38 ctx 1ahzpqieorjstuvABCJKDfklmwxydnLEFGHcIbg

452

453 @staticmethod (empty)

454 def is_suitable_ssh_key( (empty)

455 key: Buffer,

456 /,

457 *,

458 client: ssh_agent.SSHAgentClient | None = None,

459 ) -> bool:

460 """Check whether the key is suitable for passphrase derivation.

461

462 Some key types are guaranteed to be deterministic. Other keys

463 types are only deterministic if the SSH agent supports this

464 feature.

465

466 Args:

467 key:

468 SSH public key to check.

469 client:

470 An optional SSH agent client to check for additional

471 deterministic key types. If not given, assume no such

472 types.

473

474 Returns:

475 True if and only if the key is guaranteed suitable for use

476 in deriving a passphrase deterministically (perhaps

477 restricted to the indicated SSH agent).

478

479 """

480 key = bytes(key) 7 ctx 1aieXVTY

481 TestFunc: TypeAlias = 'Callable[[bytes | bytearray], bool]' 7 ctx 1aieXVTY

482 deterministic_signature_types: dict[str, TestFunc]

483 deterministic_signature_types = { 7 ctx 1aieXVTY

484 'ssh-ed25519': lambda k: k.startswith(

485 b'\x00\x00\x00\x0bssh-ed25519'

486 ),

487 'ssh-ed448': lambda k: k.startswith(b'\x00\x00\x00\x09ssh-ed448'),

488 'ssh-rsa': lambda k: k.startswith(b'\x00\x00\x00\x07ssh-rsa'),

489 }

490 dsa_signature_types = { 7 ctx 1aieXVTY

491 'ssh-dss': lambda k: k.startswith(b'\x00\x00\x00\x07ssh-dss'),

492 'ecdsa-sha2-nistp256': lambda k: k.startswith(

493 b'\x00\x00\x00\x13ecdsa-sha2-nistp256'

494 ),

495 'ecdsa-sha2-nistp384': lambda k: k.startswith(

496 b'\x00\x00\x00\x13ecdsa-sha2-nistp384'

497 ),

498 'ecdsa-sha2-nistp521': lambda k: k.startswith(

499 b'\x00\x00\x00\x13ecdsa-sha2-nistp521'

500 ),

501 }

502 criteria = [ 7 ctx 1aieXVTY

503 lambda: any(

504 v(key) for v in deterministic_signature_types.values()

505 ),

506 ]

507 if client is not None: 7 ctx 1aieXVTY

508 criteria.append( 6 ctx 1ieXVTY

509 lambda: (

510 client.has_deterministic_dsa_signatures()

511 and any(v(key) for v in dsa_signature_types.values())

512 )

513 )

514 return any(crit() for crit in criteria) 7 ctx 1aieXVTY

515

516 @classmethod (empty)

517 def phrase_from_key( (empty)

518 cls,

519 key: Buffer,

520 /,

521 *,

522 conn: ssh_agent.SSHAgentClient

523 | _types.SSHAgentSocket

524 | Sequence[str]

525 | None = None,

526 ) -> bytes:

527 """Obtain the master passphrase from a configured SSH key.

528

529 vault allows the usage of certain SSH keys to derive a master

530 passphrase, by signing the vault [`UUID`][] with the SSH key.

531 The key type must ensure that signatures are deterministic

532 (perhaps only in conjunction with the given SSH agent).

533

534 Args:

535 key:

536 The (public) SSH key to use for signing.

537 conn:

538 An optional connection hint to the SSH agent. See

539 [`ssh_agent.SSHAgentClient.ensure_agent_subcontext`][].

540

541 Returns:

542 The signature of the vault [`UUID`][] under this key,

543 unframed but encoded in base64.

544

545 Raises:

546 derivepassphrase.ssh_agent.socketprovider.NoSuchProviderError:

547 As per [`ssh_agent.SSHAgentClient.__init__`][]. Only

548 applicable if agent auto-discovery is used.

549 KeyError:

550 As per [`ssh_agent.SSHAgentClient.__init__`][]. Only

551 applicable if agent auto-discovery is used.

552 NotImplementedError:

553 As per [`ssh_agent.SSHAgentClient.__init__`][],

554 including the mulitple raise as an exception group.

555 Only applicable if agent auto-discovery is used.

556 OSError:

557 If the connection hint was a socket, then there was an

558 error setting up the socket connection to the agent.

559

560 Otherwise, as per

561 [`ssh_agent.SSHAgentClient.__init__`][]. Only

562 applicable if agent auto-discovery is used.

563 ValueError:

564 The SSH key is principally unsuitable for this use case.

565 Usually this means that the signature is not

566 deterministic.

567

568 Examples:

569 >>> import base64

570 >>> # Actual Ed25519 test public key.

571 >>> public_key = bytes.fromhex('''

572 ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39

573 ... 00 00 00 20

574 ... 81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1

575 ... 30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76

576 ... ''')

577 >>> expected_sig_raw = bytes.fromhex('''

578 ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39

579 ... 00 00 00 40

580 ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86

581 ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd

582 ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c

583 ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02

584 ... ''')

585 >>> # Raw Ed25519 signatures are 64 bytes long.

586 >>> signature_blob = expected_sig_raw[-64:]

587 >>> phrase = base64.standard_b64encode(signature_blob)

588 >>> Vault.phrase_from_key(phrase) == expected # doctest:+SKIP

589 True

590

591 """

592 with ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn) as client: 4 ctx 1ieVT

593 if not cls.is_suitable_ssh_key(key, client=client): 593 ↛ 594line 593 didn't jump to line 594 because the condition on line 593 was never true 4 ctx 1ieVT

594 msg = (

595 'unsuitable SSH key: bad key, or '

596 'signature not deterministic under this agent'

597 )

598 raise ValueError(msg)

599 raw_sig = client.sign(key, cls.UUID) 4 ctx 1ieVT

600 _keytype, trailer = ssh_agent.SSHAgentClient.unstring_prefix(raw_sig) 3 ctx 1ieT

601 signature_blob = ssh_agent.SSHAgentClient.unstring(trailer) 3 ctx 1ieT

602 return bytes(base64.standard_b64encode(signature_blob)) 3 ctx 1ieT

603

604 @classmethod (empty)

605 def phrases_are_interchangable( (empty)

606 cls,

607 phrase1: Buffer,

608 phrase2: Buffer,

609 /,

610 ) -> bool:

611 """Return true if the passphrases are interchangable to Vault.

612

613 Vault internally passes the passphrase as the key to HMAC-SHA1.

614 HMAC requires keys to have a certain fixed length, and therefore

615 transforms keys of other lengths suitably. Because of this, in

616 general, there exist multiple passphrases that behave

617 identically under Vault.

618

619 Note: HMAC key transformation

620 Keys strictly larger than the SHA1 block size (64 bytes) are

621 first hashed with SHA1, then the digest is used in place of

622 the original key. Then, any keys/digests smaller than the

623 block size are padded with NUL bytes on the right, up to the

624 block size.

625

626 As a result, keys smaller than the block size are padded,

627 keys larger than the block size are hashed and then padded,

628 and keys exactly as large as the block size are used as-is.

629

630 Args:

631 phrase1:

632 A passphrase to compare. Must be a binary string to

633 mitigate timing attacks.

634 phrase2:

635 A passphrase to compare. Must be a binary string to

636 mitigate timing attacks.

637

638 Warning: Likely non-resistant to timing attacks

639 This method makes some effort to be resistant to timing

640 attacks, but cannot guarantee that Python

641 micro-optimizations, version or platform differences affect

642 the effectiveness of these efforts.

643

644 Callers can definitely observe timing differences due to the

645 length of the passphrase passed in.

646

647 """

648 to_key = cls._phrase_to_hmac_key 2 ctx 1SR

649 return hmac.compare_digest(to_key(phrase1), to_key(phrase2)) 2 ctx 1SR

650

651 @classmethod (empty)

652 def _phrase_to_hmac_key( (empty)

653 cls,

654 phrase: Buffer | str,

655 /,

656 ) -> bytes:

657 r"""Return the HMAC key belonging to a passphrase.

658

659 This is the actual HMAC key this passphrase would be transformed

660 into when used within Vault.

661

662 See [`phrases_are_interchangable`][] for further explanations

663 and warnings about timing attack resistance.

664

665 Args:

666 phrase:

667 A passphrase to compare. Must be a binary string to

668 mitigate timing attacks.

669

670 """

671 phrase = cls._get_binary_string(phrase) 2 ctx 1SR

672 h = hashlib.sha1(phrase, usedforsecurity=False) 2 ctx 1SR

673 try: 2 ctx 1SR

674 key = bytearray(h.block_size) 2 ctx 1SR

675 for i, byte in enumerate(phrase): 2 ctx 1SR

676 key[i] = byte 2 ctx 1SR

677 return bytes(key) 2 ctx 1SR

678 except IndexError: 1 ctx 1R

679 return h.digest() + b'\x00' * (h.block_size - h.digest_size) 1 ctx 1R

680

681 @staticmethod (empty)

682 def _subtract( (empty)

683 charset: Buffer,

684 allowed: Buffer,

685 ) -> bytearray:

686 """Remove the characters in charset from allowed.

687

688 This preserves the relative order of characters in `allowed`.

689

690 Args:

691 charset:

692 Characters to remove. Must not contain duplicate

693 characters.

694 allowed:

695 Character set to remove the other characters from. Must

696 not contain duplicate characters.

697

698 Returns:

699 The pruned "allowed" character set.

700

701 Raises:

702 ValueError:

703 `allowed` or `charset` contained duplicate characters.

704

705 """

706 allowed = ( 17 ctx 1ahjfklmdnUMNOWcbg

707 allowed if isinstance(allowed, bytearray) else bytearray(allowed)

708 )

709 assert_type(allowed, bytearray) 17 ctx 1ahjfklmdnUMNOWcbg

710 charset = memoryview(charset).toreadonly().cast('c') 17 ctx 1ahjfklmdnUMNOWcbg

711 assert_type(charset, 'memoryview[bytes]') 17 ctx 1ahjfklmdnUMNOWcbg

712 msg_dup_characters = 'duplicate characters in set' 17 ctx 1ahjfklmdnUMNOWcbg

713 if len(frozenset(allowed)) != len(allowed): 17 ctx 1ahjfklmdnUMNOWcbg

714 raise ValueError(msg_dup_characters) 1 ctx 1W

715 if len(frozenset(charset)) != len(charset): 17 ctx 1ahjfklmdnUMNOWcbg

716 raise ValueError(msg_dup_characters) 1 ctx 1W

717 for c in charset: 16 ctx 1ahjfklmdnUMNOcbg

718 try: 16 ctx 1ahjfklmdnUMNOcbg

719 pos = allowed.index(c) 16 ctx 1ahjfklmdnUMNOcbg

720 except ValueError: 5 ctx 1adOcb

721 pass 5 ctx 1adOcb

722 else:

723 allowed[pos : pos + 1] = [] 16 ctx 1ahjfklmdnUMNOcbg

724 return allowed 16 ctx 1ahjfklmdnUMNOcbg