1e5d605177a2f2a4441c99ca1515efaa55449697
Marco Ricci Import initial project files

Marco Ricci authored 6 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 5 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) 
21) class Vault:
22)     """A work-alike of James Coglan's vault.
23) 
24)     Store settings for generating (actually: deriving) passphrases for
25)     named services, with various constraints, given only a master
26)     passphrase.  Also, actually generate the passphrase.  The derivation
27)     is deterministic and non-secret; only the master passphrase need be
28)     kept secret.  The implementation is compatible with [vault][].
29) 
30)     [James Coglan explains the passphrase derivation algorithm in great
31)     detail][ALGORITHM] in his blog post on said topic: A principally
32)     infinite bit stream is obtained by running a key-derivation function
33)     on the master passphrase and the service name, then this bit stream
34)     is fed into a [sequin][] to generate random numbers in the correct
35)     range, and finally these random numbers select passphrase characters
36)     until the desired length is reached.
37) 
38)     [vault]: https://getvau.lt
39)     [ALGORITHM]: https://blog.jcoglan.com/2012/07/16/designing-vaults-generator-algorithm/
40) 
41)     """
42)     _UUID = b'e87eb0f4-34cb-46b9-93ad-766c5ab063e7'
43)     """A tag used by vault in the bit stream generation."""
44)     _CHARSETS: collections.OrderedDict[str, bytes]
45)     """
46)         Known character sets from which to draw passphrase characters.
47)         Relies on a certain, fixed order for their definition and their
48)         contents.
49) 
50)     """
51)     _CHARSETS = collections.OrderedDict([
52)         ('lower', b'abcdefghijklmnopqrstuvwxyz'),
53)         ('upper', b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),
54)         ('alpha', b''),  # Placeholder.
55)         ('number', b'0123456789'),
56)         ('alphanum', b''),  # Placeholder.
57)         ('space', b' '),
58)         ('dash', b'-_'),
59)         ('symbol', b'!"#$%&\'()*+,./:;<=>?@[\\]^{|}~-_'),
60)         ('all', b''),  # Placeholder.
61)     ])
62)     _CHARSETS['alpha'] = _CHARSETS['lower'] + _CHARSETS['upper']
63)     _CHARSETS['alphanum'] = _CHARSETS['alpha'] + _CHARSETS['number']
64)     _CHARSETS['all'] = (_CHARSETS['alphanum'] + _CHARSETS['space']
65)                         + _CHARSETS['symbol'])
66) 
67)     def __init__(
68)         self, *, phrase: bytes | bytearray = b'', length: int = 20,
69)         repeat: int = 0, lower: int | None = None,
70)         upper: int | None = None, number: int | None = None,
71)         space: int | None = None, dash: int | None = None,
72)         symbol: int | None = None,
73)     ) -> None:
74)         """Initialize the Vault object.
75) 
76)         Args:
77)             phrase:
78)                 The master passphrase from which to derive the service
79)                 passphrases.
80)             length:
81)                 Desired passphrase length.
82)             repeat:
83)                 The maximum number of immediate character repetitions
84)                 allowed in the passphrase.  Disabled if set to 0.
85)             lower:
86)                 Optional constraint on lowercase characters.  If
87)                 positive, include this many lowercase characters
88)                 somewhere in the passphrase.  If 0, avoid lowercase
89)                 characters altogether.
90)             upper:
91)                 Same as `lower`, but for uppercase characters.
92)             number:
93)                 Same as `lower`, but for ASCII digits.
94)             space:
95)                 Same as `lower`, but for the space character.
96)             dash:
97)                 Same as `lower`, but for the hyphen-minus and underscore
98)                 characters.
99)             symbol:
100)                 Same as `lower`, but for all other hitherto unlisted
101)                 ASCII printable characters (except backquote).
102) 
103)         """
104)         self._phrase = bytes(phrase)
105)         self._length = length
106)         self._repeat = repeat
107)         self._allowed = bytearray(self._CHARSETS['all'])
108)         self._required: list[bytes] = []
109)         def subtract_or_require(
110)             count: int | None, characters: bytes | bytearray
111)         ) -> None:
112)             if not isinstance(count, int):
113)                 return
114)             elif count <= 0:
115)                 self._allowed = self._subtract(characters, self._allowed)
116)             else:
117)                 for _ in range(count):
118)                     self._required.append(characters)
119)         subtract_or_require(lower, self._CHARSETS['lower'])
120)         subtract_or_require(upper, self._CHARSETS['upper'])
121)         subtract_or_require(number, self._CHARSETS['number'])
122)         subtract_or_require(space, self._CHARSETS['space'])
123)         subtract_or_require(dash, self._CHARSETS['dash'])
124)         subtract_or_require(symbol, self._CHARSETS['symbol'])
Marco Ricci Fix numerous argument type...

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

126)             raise ValueError('requested passphrase length too short')
127)         if not self._allowed:
128)             raise ValueError('no allowed characters left')
129)         for _ in range(len(self._required), self._length):
130)             self._required.append(bytes(self._allowed))
131) 
132)     def _entropy_upper_bound(self) -> int:
133)         """Estimate the passphrase entropy, given the current settings.
134) 
135)         The entropy is the base 2 logarithm of the amount of
136)         possibilities.  We operate directly on the logarithms, and round
137)         each summand up, overestimating the true entropy.
138) 
139)         """
140)         factors: list[int] = []
141)         for i, charset in enumerate(self._required):
142)             factors.append(i + 1)
143)             factors.append(len(charset))
144)         return sum(int(math.ceil(math.log2(f))) for f in factors)
145) 
146)     @classmethod
147)     def create_hash(
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

151)         r"""Create a pseudorandom byte stream from phrase and service.
152) 
153)         Create a pseudorandom byte stream from `phrase` and `service` by
154)         feeding them into the key-derivation function PBKDF2
155)         (8 iterations, using SHA-1).
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

156) 
157)         Args:
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 5 months ago

158)             phrase:
159)                 A master passphrase, or sometimes an SSH signature.
160)                 Used as the key for PBKDF2, the underlying cryptographic
161)                 primitive.
162)             service:
163)                 A vault service name.  Will be suffixed with
164)                 `Vault._UUID`, and then used as the salt value for
165)                 PBKDF2.
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

166)             length:
167)                 The length of the byte stream to generate.
168) 
169)         Returns:
170)             A pseudorandom byte string of length `length`.
171) 
172)         Note:
173)             Shorter values returned from this method (with the same key
174)             and message) are prefixes of longer values returned from
175)             this method.  (This property is inherited from the
176)             underlying PBKDF2 function.)  It is thus safe (if slow) to
177)             call this method with the same input with ever-increasing
178)             target lengths.
179) 
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 5 months ago

180)         Examples:
181)             >>> # See also Vault.phrase_from_signature examples.
182)             >>> phrase = bytes.fromhex('''
183)             ... 00 00 00 0b 73 73 68 2d 65 64 32 35 35 31 39
184)             ... 00 00 00 40
185)             ... f0 98 19 80 6c 1a 97 d5 26 03 6e cc e3 65 8f 86
186)             ... 66 07 13 19 13 09 21 33 33 f9 e4 36 53 1d af fd
187)             ... 0d 08 1f ec f8 73 9b 8c 5f 55 39 16 7c 53 54 2c
188)             ... 1e 52 bb 30 ed 7f 89 e2 2f 69 51 55 d8 9e a6 02
189)             ... ''')
190)             >>> Vault.create_hash(phrase, b'some_service', length=4)
191)             b'M\xb1<S'
192)             >>> Vault.create_hash(phrase, b'some_service', length=16)
193)             b'M\xb1<S\x827E\xd1M\xaf\xf8~\xc8n\x10\xcc'
194)             >>> Vault.create_hash(phrase, b'NOSUCHSERVICE', length=16)
195)             b'\x1c\xc3\x9c\xd9\xb6\x1a\x99CS\x07\xc41\xf4\x85#s'
196) 
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

201) 
202)     def generate(
Marco Ricci Fix numerous argument type...

Marco Ricci authored 5 months ago

203)         self, service_name: str | bytes | bytearray, /, *,
204)         phrase: bytes | bytearray = b'',
205)     ) -> bytes:
206)         r"""Generate a service passphrase.
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

207) 
208)         Args:
209)             service_name:
210)                 The service name.
211)             phrase:
212)                 If given, override the passphrase given during
213)                 construction.
214) 
Marco Ricci Add unit tests, both new an...

Marco Ricci authored 5 months ago

215)         Examples:
216)             >>> phrase = b'She cells C shells bye the sea shoars'
217)             >>> # Using default options in constructor.
218)             >>> Vault(phrase=phrase).generate(b'google')
219)             b': 4TVH#5:aZl8LueOT\\{'
220)             >>> # Also possible:
221)             >>> Vault().generate(b'google', phrase=phrase)
222)             b': 4TVH#5:aZl8LueOT\\{'
223) 
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

224)         """
225)         entropy_bound = self._entropy_upper_bound()
226)         # Use a safety factor, because a sequin will potentially throw
227)         # bits away and we cannot rely on having generated a hash of
228)         # exactly the right length.
229)         safety_factor = 2
230)         hash_length = int(math.ceil(safety_factor * entropy_bound / 8))
Marco Ricci Fix numerous argument type...

Marco Ricci authored 5 months ago

231)         # Ensure the phrase is a bytes object.  Needed later for safe
232)         # concatenation.
233)         if isinstance(service_name, str):
234)             service_name = service_name.encode('utf-8')
235)         elif not isinstance(service_name, bytes):
236)             service_name = bytes(service_name)
237)         assert_type(service_name, bytes)
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

238)         if not phrase:
239)             phrase = self._phrase
240)         # Repeat the passphrase generation with ever-increasing hash
241)         # lengths, until the passphrase can be formed without exhausting
242)         # the sequin.  See the guarantee in the create_hash method for
243)         # why this works.
244)         while True:
245)             try:
246)                 required = self._required[:]
247)                 seq = sequin.Sequin(self.create_hash(
Marco Ricci Use neutral arguments in `V...

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

249)                 result = bytearray()
250)                 while len(result) < self._length:
251)                     pos = seq.generate(len(required))
252)                     charset = required.pop(pos)
253)                     # Determine if an unlucky choice right now might
254)                     # violate the restriction on repeated characters.
255)                     # That is, check if the current partial passphrase
256)                     # ends with r - 1 copies of the same character
257)                     # (where r is the repeat limit that must not be
258)                     # reached), and if so, remove this same character
259)                     # from the current character's allowed set.
Marco Ricci Fix repeated character dete...

Marco Ricci authored 5 months ago

260)                     if self._repeat and result:
261)                         bad_suffix = bytes(result[-1:]) * (self._repeat - 1)
262)                         if result.endswith(bad_suffix):
263)                             charset = self._subtract(bytes(result[-1:]),
264)                                                      charset)
Marco Ricci Fix numerous argument type...

Marco Ricci authored 5 months ago

265)                     pos = seq.generate(len(charset))
266)                     result.extend(charset[pos:pos+1])
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

267)             except sequin.SequinExhaustedException:
268)                 hash_length *= 2
269)             else:
Marco Ricci Fix numerous argument type...

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

271) 
272)     @classmethod
273)     def phrase_from_signature(
274)         cls, key: bytes | bytearray, /
275)     ) -> bytes | bytearray:
276)         """Obtain the master passphrase from a configured SSH key.
277) 
278)         vault allows the usage of certain SSH keys to derive a master
279)         passphrase, by signing the vault UUID with the SSH key.  The key
280)         type must ensure that signatures are deterministic.
281) 
282)         Args:
283)             key: The (public) SSH key to use for signing.
284) 
285)         Returns:
286)             The signature of the vault UUID under this key.
287) 
288)         Raises:
289)             ValueError:
290)                 The SSH key is principally unsuitable for this use case.
291)                 Usually this means that the signature is not
292)                 deterministic.
293) 
294)         """
295)         deterministic_signature_types = {
296)             'ssh-ed25519':
297)                 lambda k: k.startswith(b'\x00\x00\x00\x0bssh-ed25519'),
298)             'ssh-rsa':
299)                 lambda k: k.startswith(b'\x00\x00\x00\x07ssh-rsa'),
300)         }
301)         if not any(v(key) for v in deterministic_signature_types.values()):
302)             raise ValueError(
303)                 'unsuitable SSH key: bad key, or signature not deterministic')
304)         with ssh_agent_client.SSHAgentClient() as client:
305)             ret = client.sign(key, cls._UUID)
306)         return ret
307) 
Marco Ricci Fix character set subtracti...

Marco Ricci authored 5 months ago

308)     @staticmethod
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

311)     ) -> bytearray:
312)         """Remove the characters in charset from allowed.
313) 
314)         This preserves the relative order of characters in `allowed`.
315) 
316)         Args:
Marco Ricci Fix character set subtracti...

Marco Ricci authored 5 months ago

317)             charset:
318)                 Characters to remove.  Must not contain duplicate
319)                 characters.
320)             allowed:
321)                 Character set to remove the other characters from.  Must
322)                 not contain duplicate characters.
Marco Ricci Add prototype implementation

Marco Ricci authored 5 months ago

323) 
324)         Returns:
Marco Ricci Fix character set subtracti...

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

326) 
327)         Raises:
Marco Ricci Fix character set subtracti...

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

330) 
331)         """
332)         allowed = (allowed if isinstance(allowed, bytearray)
333)                    else bytearray(allowed))
334)         assert_type(allowed, bytearray)
Marco Ricci Fix character set subtracti...

Marco Ricci authored 5 months ago

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

Marco Ricci authored 5 months ago

337)         if len(frozenset(charset)) != len(charset):
338)             raise ValueError('duplicate characters in set')
339)         for c in charset:
340)             try:
341)                 pos = allowed.index(c)
Marco Ricci Fix numerous argument type...

Marco Ricci authored 5 months ago

342)             except ValueError: