d3bdff5889f148f85d644ecfcb3fc9787b0a1aef
Marco Ricci Import initial project files

Marco Ricci authored 4 months ago

1) # SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2) #
3) # SPDX-License-Identifier: MIT
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

4) 
5) """Work-alike of vault(1) – a deterministic, stateless password manager
6) 
7) """
8) 
9) from __future__ import annotations
10) 
11) import collections
12) import hashlib
13) import math
14) import warnings
15) 
16) from typing import assert_type, reveal_type
17) 
18) import sequin
19) import ssh_agent_client
20) 
Marco Ricci Remove __about__.py files,...

Marco Ricci authored 3 months ago

21) __author__ = "Marco Ricci <m@the13thletter.info>"
22) __version__ = "0.1.0"
23) 
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

24) class Vault:
25)     """A work-alike of James Coglan's vault.
26) 
27)     Store settings for generating (actually: deriving) passphrases for
28)     named services, with various constraints, given only a master
29)     passphrase.  Also, actually generate the passphrase.  The derivation
30)     is deterministic and non-secret; only the master passphrase need be
31)     kept secret.  The implementation is compatible with [vault][].
32) 
33)     [James Coglan explains the passphrase derivation algorithm in great
34)     detail][ALGORITHM] in his blog post on said topic: A principally
35)     infinite bit stream is obtained by running a key-derivation function
36)     on the master passphrase and the service name, then this bit stream
Marco Ricci Fix documentation link to `...

Marco Ricci authored 3 months ago

37)     is fed into a [Sequin][sequin.Sequin] to generate random numbers in
38)     the correct range, and finally these random numbers select
39)     passphrase characters until the desired length is reached.
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

40) 
41)     [vault]: https://getvau.lt
42)     [ALGORITHM]: https://blog.jcoglan.com/2012/07/16/designing-vaults-generator-algorithm/
43) 
44)     """
45)     _UUID = b'e87eb0f4-34cb-46b9-93ad-766c5ab063e7'
46)     """A tag used by vault in the bit stream generation."""
47)     _CHARSETS: collections.OrderedDict[str, bytes]
48)     """
49)         Known character sets from which to draw passphrase characters.
50)         Relies on a certain, fixed order for their definition and their
51)         contents.
52) 
53)     """
54)     _CHARSETS = collections.OrderedDict([
55)         ('lower', b'abcdefghijklmnopqrstuvwxyz'),
56)         ('upper', b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),
57)         ('alpha', b''),  # Placeholder.
58)         ('number', b'0123456789'),
59)         ('alphanum', b''),  # Placeholder.
60)         ('space', b' '),
61)         ('dash', b'-_'),
62)         ('symbol', b'!"#$%&\'()*+,./:;<=>?@[\\]^{|}~-_'),
63)         ('all', b''),  # Placeholder.
64)     ])
65)     _CHARSETS['alpha'] = _CHARSETS['lower'] + _CHARSETS['upper']
66)     _CHARSETS['alphanum'] = _CHARSETS['alpha'] + _CHARSETS['number']
67)     _CHARSETS['all'] = (_CHARSETS['alphanum'] + _CHARSETS['space']
68)                         + _CHARSETS['symbol'])
69) 
70)     def __init__(
71)         self, *, phrase: bytes | bytearray = b'', length: int = 20,
72)         repeat: int = 0, lower: int | None = None,
73)         upper: int | None = None, number: int | None = None,
74)         space: int | None = None, dash: int | None = None,
75)         symbol: int | None = None,
76)     ) -> None:
77)         """Initialize the Vault object.
78) 
79)         Args:
80)             phrase:
81)                 The master passphrase from which to derive the service
82)                 passphrases.
83)             length:
84)                 Desired passphrase length.
85)             repeat:
86)                 The maximum number of immediate character repetitions
87)                 allowed in the passphrase.  Disabled if set to 0.
88)             lower:
89)                 Optional constraint on lowercase characters.  If
90)                 positive, include this many lowercase characters
91)                 somewhere in the passphrase.  If 0, avoid lowercase
92)                 characters altogether.
93)             upper:
94)                 Same as `lower`, but for uppercase characters.
95)             number:
96)                 Same as `lower`, but for ASCII digits.
97)             space:
98)                 Same as `lower`, but for the space character.
99)             dash:
100)                 Same as `lower`, but for the hyphen-minus and underscore
101)                 characters.
102)             symbol:
103)                 Same as `lower`, but for all other hitherto unlisted
104)                 ASCII printable characters (except backquote).
105) 
106)         """
107)         self._phrase = bytes(phrase)
108)         self._length = length
109)         self._repeat = repeat
110)         self._allowed = bytearray(self._CHARSETS['all'])
111)         self._required: list[bytes] = []
112)         def subtract_or_require(
113)             count: int | None, characters: bytes | bytearray
114)         ) -> None:
115)             if not isinstance(count, int):
116)                 return
117)             elif count <= 0:
118)                 self._allowed = self._subtract(characters, self._allowed)
119)             else:
120)                 for _ in range(count):
121)                     self._required.append(characters)
122)         subtract_or_require(lower, self._CHARSETS['lower'])
123)         subtract_or_require(upper, self._CHARSETS['upper'])
124)         subtract_or_require(number, self._CHARSETS['number'])
125)         subtract_or_require(space, self._CHARSETS['space'])
126)         subtract_or_require(dash, self._CHARSETS['dash'])
127)         subtract_or_require(symbol, self._CHARSETS['symbol'])
Marco Ricci Fix numerous argument type...

Marco Ricci authored 3 months ago

128)         if len(self._required) > self._length:
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

129)             raise ValueError('requested passphrase length too short')
130)         if not self._allowed:
131)             raise ValueError('no allowed characters left')
132)         for _ in range(len(self._required), self._length):
133)             self._required.append(bytes(self._allowed))
134) 
Marco Ricci Expose some functionality f...

Marco Ricci authored 2 months ago

135)     def _entropy(self) -> float:
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

136)         """Estimate the passphrase entropy, given the current settings.
137) 
138)         The entropy is the base 2 logarithm of the amount of
Marco Ricci Expose some functionality f...

Marco Ricci authored 2 months ago

139)         possibilities.  We operate directly on the logarithms, and use
140)         sorting and [`math.fsum`][] to keep high accuracy.
141) 
142)         Note:
143)             We actually overestimate the entropy here because of poor
144)             handling of character repetitions.  In the extreme, assuming
145)             that only one character were allowed, then because there is
146)             only one possible string of each given length, the entropy
147)             of that string `s` is always be zero.  However, we calculate
148)             the entropy as `math.log2(math.factorial(len(s)))`, i.e. we
149)             assume the characters at the respective string position are
150)             distinguishable from each other.
151) 
152)         Returns:
153)             A valid (and somewhat close) upper bound to the entropy.
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

154) 
155)         """
156)         factors: list[int] = []
Marco Ricci Expose some functionality f...

Marco Ricci authored 2 months ago

157)         if not self._required or any(not x for x in self._required):
158)             return float('-inf')
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

159)         for i, charset in enumerate(self._required):
160)             factors.append(i + 1)
161)             factors.append(len(charset))
Marco Ricci Expose some functionality f...

Marco Ricci authored 2 months ago

162)         factors.sort()
163)         return math.fsum(math.log2(f) for f in factors)
164) 
165)     def _estimate_sufficient_hash_length(
166)         self, safety_factor: float = 2.0,
167)     ) -> int:
168)         """Estimate the sufficient hash length, given the current settings.
169) 
170)         Using the entropy (via `_entropy`) and a safety factor, give an
171)         initial estimate of the length to use for `create_hash` such
172)         that using a `Sequin` with this hash will not exhaust it during
173)         passphrase generation.
174) 
175)         Args:
176)             safety_factor: The safety factor.  Must be at least 1.
177) 
178)         Returns:
179)             The estimated sufficient hash length.
180) 
181)         Warning:
182)             This is a heuristic, not an exact computation; it may
183)             underestimate the true necessary hash length.  It is
184)             intended as a starting point for searching for a sufficient
185)             hash length, usually by doubling the hash length each time
186)             it does not yet prove so.
187) 
188)         """
189)         try:
190)             safety_factor = float(safety_factor)
191)         except TypeError as e:
192)             raise TypeError(f'invalid safety factor: not a float: '
193)                             f'{safety_factor!r}') from e
194)         if not math.isfinite(safety_factor) or safety_factor < 1.0:
195)             raise ValueError(f'invalid safety factor {safety_factor!r}')
196)         # Ensure the bound is strictly positive.
197)         entropy_bound = max(1, self._entropy())
198)         return int(math.ceil(safety_factor * entropy_bound / 8))
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

199) 
200)     @classmethod
201)     def create_hash(
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 3 months ago

202)         cls, phrase: bytes | bytearray, service: bytes | bytearray, *,
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

203)         length: int = 32,
204)     ) -> bytes:
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 3 months ago

205)         r"""Create a pseudorandom byte stream from phrase and service.
206) 
207)         Create a pseudorandom byte stream from `phrase` and `service` by
208)         feeding them into the key-derivation function PBKDF2
209)         (8 iterations, using SHA-1).
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

210) 
211)         Args:
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 3 months ago

212)             phrase:
213)                 A master passphrase, or sometimes an SSH signature.
214)                 Used as the key for PBKDF2, the underlying cryptographic
215)                 primitive.
216)             service:
217)                 A vault service name.  Will be suffixed with
218)                 `Vault._UUID`, and then used as the salt value for
219)                 PBKDF2.
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

220)             length:
221)                 The length of the byte stream to generate.
222) 
223)         Returns:
224)             A pseudorandom byte string of length `length`.
225) 
226)         Note:
227)             Shorter values returned from this method (with the same key
228)             and message) are prefixes of longer values returned from
229)             this method.  (This property is inherited from the
230)             underlying PBKDF2 function.)  It is thus safe (if slow) to
231)             call this method with the same input with ever-increasing
232)             target lengths.
233) 
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 3 months ago

234)         Examples:
Marco Ricci Fix passphrase-from-SSH-sig...

Marco Ricci authored 2 months ago

235)             >>> # See also Vault.phrase_from_key examples.
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 3 months ago

236)             >>> phrase = bytes.fromhex('''
237)             ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
238)             ... 00 00 00 40
239)             ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86
240)             ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd
241)             ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c
242)             ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02
243)             ... ''')
244)             >>> Vault.create_hash(phrase, b'some_service', length=4)
245)             b'M\xb1<S'
246)             >>> Vault.create_hash(phrase, b'some_service', length=16)
247)             b'M\xb1<S\x827E\xd1M\xaf\xf8~\xc8n\x10\xcc'
248)             >>> Vault.create_hash(phrase, b'NOSUCHSERVICE', length=16)
249)             b'\x1c\xc3\x9c\xd9\xb6\x1a\x99CS\x07\xc41\xf4\x85#s'
250) 
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

251)         """
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 3 months ago

252)         salt = bytes(service) + cls._UUID
253)         return hashlib.pbkdf2_hmac(hash_name='sha1', password=phrase,
254)                                    salt=salt, iterations=8, dklen=length)
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

255) 
256)     def generate(
Marco Ricci Fix numerous argument type...

Marco Ricci authored 3 months ago

257)         self, service_name: str | bytes | bytearray, /, *,
258)         phrase: bytes | bytearray = b'',
259)     ) -> bytes:
260)         r"""Generate a service passphrase.
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

261) 
262)         Args:
263)             service_name:
264)                 The service name.
265)             phrase:
266)                 If given, override the passphrase given during
267)                 construction.
268) 
Marco Ricci Add unit tests, both new an...

Marco Ricci authored 3 months ago

269)         Examples:
270)             >>> phrase = b'She cells C shells bye the sea shoars'
271)             >>> # Using default options in constructor.
272)             >>> Vault(phrase=phrase).generate(b'google')
273)             b': 4TVH#5:aZl8LueOT\\{'
274)             >>> # Also possible:
275)             >>> Vault().generate(b'google', phrase=phrase)
276)             b': 4TVH#5:aZl8LueOT\\{'
277) 
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

278)         """
Marco Ricci Expose some functionality f...

Marco Ricci authored 2 months ago

279)         hash_length = self._estimate_sufficient_hash_length()
280)         assert hash_length >= 1
Marco Ricci Fix numerous argument type...

Marco Ricci authored 3 months ago

281)         # Ensure the phrase is a bytes object.  Needed later for safe
282)         # concatenation.
283)         if isinstance(service_name, str):
284)             service_name = service_name.encode('utf-8')
285)         elif not isinstance(service_name, bytes):
286)             service_name = bytes(service_name)
287)         assert_type(service_name, bytes)
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

288)         if not phrase:
289)             phrase = self._phrase
290)         # Repeat the passphrase generation with ever-increasing hash
291)         # lengths, until the passphrase can be formed without exhausting
292)         # the sequin.  See the guarantee in the create_hash method for
293)         # why this works.
294)         while True:
295)             try:
296)                 required = self._required[:]
297)                 seq = sequin.Sequin(self.create_hash(
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 3 months ago

298)                     phrase=phrase, service=service_name, length=hash_length))
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

299)                 result = bytearray()
300)                 while len(result) < self._length:
301)                     pos = seq.generate(len(required))
302)                     charset = required.pop(pos)
303)                     # Determine if an unlucky choice right now might
304)                     # violate the restriction on repeated characters.
305)                     # That is, check if the current partial passphrase
306)                     # ends with r - 1 copies of the same character
307)                     # (where r is the repeat limit that must not be
308)                     # reached), and if so, remove this same character
309)                     # from the current character's allowed set.
Marco Ricci Fix repeated character dete...

Marco Ricci authored 3 months ago

310)                     if self._repeat and result:
311)                         bad_suffix = bytes(result[-1:]) * (self._repeat - 1)
312)                         if result.endswith(bad_suffix):
313)                             charset = self._subtract(bytes(result[-1:]),
314)                                                      charset)
Marco Ricci Fix numerous argument type...

Marco Ricci authored 3 months ago

315)                     pos = seq.generate(len(charset))
316)                     result.extend(charset[pos:pos+1])
Marco Ricci Expose some functionality f...

Marco Ricci authored 2 months ago

317)             except sequin.SequinExhaustedException:
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

318)                 hash_length *= 2
319)             else:
Marco Ricci Fix numerous argument type...

Marco Ricci authored 3 months ago

320)                 return bytes(result)
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

321) 
Marco Ricci Expose some functionality f...

Marco Ricci authored 2 months ago

322)     @staticmethod
323)     def _is_suitable_ssh_key(key: bytes | bytearray, /) -> bool:
324)         """Check whether the key is suitable for passphrase derivation.
325) 
326)         Currently, this only checks whether signatures with this key
327)         type are deterministic.
328) 
329)         Args:
330)             key: SSH public key to check.
331) 
332)         Returns:
333)             True if and only if the key is suitable for use in deriving
334)             a passphrase deterministically.
335) 
336)         """
337)         deterministic_signature_types = {
338)             'ssh-ed25519':
339)                 lambda k: k.startswith(b'\x00\x00\x00\x0bssh-ed25519'),
340)             'ssh-ed448':
341)                 lambda k: k.startswith(b'\x00\x00\x00\x09ssh-ed448'),
342)             'ssh-rsa':
343)                 lambda k: k.startswith(b'\x00\x00\x00\x07ssh-rsa'),
344)         }
345)         return any(v(key) for v in deterministic_signature_types.values())
346) 
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

347)     @classmethod
Marco Ricci Fix passphrase-from-SSH-sig...

Marco Ricci authored 2 months ago

348)     def phrase_from_key(
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

349)         cls, key: bytes | bytearray, /
Marco Ricci Fix passphrase-from-SSH-sig...

Marco Ricci authored 2 months ago

350)     ) -> bytes:
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

351)         """Obtain the master passphrase from a configured SSH key.
352) 
353)         vault allows the usage of certain SSH keys to derive a master
354)         passphrase, by signing the vault UUID with the SSH key.  The key
355)         type must ensure that signatures are deterministic.
356) 
357)         Args:
358)             key: The (public) SSH key to use for signing.
359) 
360)         Returns:
Marco Ricci Fix passphrase-from-SSH-sig...

Marco Ricci authored 2 months ago

361)             The signature of the vault UUID under this key, unframed but
362)             encoded in base64.
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

363) 
364)         Raises:
365)             ValueError:
366)                 The SSH key is principally unsuitable for this use case.
367)                 Usually this means that the signature is not
368)                 deterministic.
369) 
Marco Ricci Add example for `Vault.phra...

Marco Ricci authored 3 months ago

370)         Examples:
Marco Ricci Fix passphrase-from-SSH-sig...

Marco Ricci authored 2 months ago

371)             >>> import base64
372)             >>> # Actual Ed25519 test public key.
Marco Ricci Add example for `Vault.phra...

Marco Ricci authored 3 months ago

373)             >>> public_key = bytes.fromhex('''
374)             ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
375)             ... 00 00 00 20
376)             ... 81 78 81 68 26 d6 02 48 5f 0f ff 32 48 6f e4 c1
377)             ... 30 89 dc 1c 6a 45 06 09 e9 09 0f fb c2 12 69 76
378)             ... ''')
Marco Ricci Fix passphrase-from-SSH-sig...

Marco Ricci authored 2 months ago

379)             >>> expected_sig_raw = bytes.fromhex('''
Marco Ricci Add example for `Vault.phra...

Marco Ricci authored 3 months ago

380)             ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
381)             ... 00 00 00 40
382)             ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86
383)             ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd
384)             ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c
385)             ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02
386)             ... ''')
Marco Ricci Fix passphrase-from-SSH-sig...

Marco Ricci authored 2 months ago

387)             >>> # Raw Ed25519 signatures are 64 bytes long.
388)             >>> signature_blob = expected_sig_raw[-64:]
389)             >>> phrase = base64.standard_b64encode(signature_blob)
390)             >>> Vault.phrase_from_key(phrase) == expected  # doctest:+SKIP
Marco Ricci Add example for `Vault.phra...

Marco Ricci authored 3 months ago

391)             True
392) 
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

393)         """
Marco Ricci Expose some functionality f...

Marco Ricci authored 2 months ago

394)         if not cls._is_suitable_ssh_key(key):
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

395)             raise ValueError(
396)                 'unsuitable SSH key: bad key, or signature not deterministic')
397)         with ssh_agent_client.SSHAgentClient() as client:
Marco Ricci Fix passphrase-from-SSH-sig...

Marco Ricci authored 2 months ago

398)             raw_sig = client.sign(key, cls._UUID)
399)         keytype, trailer = client.unstring_prefix(raw_sig)
400)         signature_blob = client.unstring(trailer)
401)         return bytes(base64.standard_b64encode(signature_blob))
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

402) 
Marco Ricci Fix character set subtracti...

Marco Ricci authored 3 months ago

403)     @staticmethod
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

404)     def _subtract(
Marco Ricci Fix character set subtracti...

Marco Ricci authored 3 months ago

405)         charset: bytes | bytearray, allowed: bytes | bytearray,
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

406)     ) -> bytearray:
407)         """Remove the characters in charset from allowed.
408) 
409)         This preserves the relative order of characters in `allowed`.
410) 
411)         Args:
Marco Ricci Fix character set subtracti...

Marco Ricci authored 3 months ago

412)             charset:
413)                 Characters to remove.  Must not contain duplicate
414)                 characters.
415)             allowed:
416)                 Character set to remove the other characters from.  Must
417)                 not contain duplicate characters.
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

418) 
419)         Returns:
Marco Ricci Fix character set subtracti...

Marco Ricci authored 3 months ago

420)             The pruned "allowed" character set.
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

421) 
422)         Raises:
Marco Ricci Fix character set subtracti...

Marco Ricci authored 3 months ago

423)             ValueError:
424)                 `allowed` or `charset` contained duplicate characters.
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

425) 
426)         """
427)         allowed = (allowed if isinstance(allowed, bytearray)
428)                    else bytearray(allowed))
429)         assert_type(allowed, bytearray)
Marco Ricci Fix character set subtracti...

Marco Ricci authored 3 months ago

430)         if len(frozenset(allowed)) != len(allowed):
431)             raise ValueError('duplicate characters in set')
Marco Ricci Add prototype implementation

Marco Ricci authored 4 months ago

432)         if len(frozenset(charset)) != len(charset):
433)             raise ValueError('duplicate characters in set')
434)         for c in charset:
435)             try:
436)                 pos = allowed.index(c)
Marco Ricci Fix numerous argument type...

Marco Ricci authored 3 months ago

437)             except ValueError: