Coverage for tests/test_derivepassphrase.py: 100.000%

1# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>

2#

3# SPDX-License-Identifier: MIT

4

5"""Test passphrase generation via derivepassphrase.Vault."""

6

7 from __future__ import annotations

8

9 import math

10 from typing import Any

11

12 import derivepassphrase

13 import pytest

14

15 Vault = derivepassphrase.Vault

16

17 class TestVault:

18

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

20 google_phrase = rb': 4TVH#5:aZl8LueOT\{'

21 twitter_phrase = rb"[ (HN_N:lI&<ro=)3'g9"

22

23 @pytest.mark.parametrize(['service', 'expected'], [

24 (b'google', google_phrase),

25 ('twitter', twitter_phrase),

26 ])

27 def test_200_basic_configuration(self, service, expected):

28 assert Vault(phrase=self.phrase).generate(service) == expected

29

30 def test_201_phrase_dependence(self):

31 assert (

32 Vault(phrase=(self.phrase + b'X')).generate('google') ==

33 b'n+oIz6sL>K*lTEWYRO%7'

34 )

35

36 def test_202_reproducibility_and_bytes_service_name(self):

37 assert (

38 Vault(phrase=self.phrase).generate(b'google') ==

39 Vault(phrase=self.phrase).generate('google')

40 )

41

42 def test_203_reproducibility_and_bytearray_service_name(self):

43 assert (

44 Vault(phrase=self.phrase).generate(b'google') ==

45 Vault(phrase=self.phrase).generate(bytearray(b'google'))

46 )

47

48 def test_210_nonstandard_length(self):

49 assert (

50 Vault(phrase=self.phrase, length=4).generate('google')

51 == b'xDFu'

52 )

53

54 def test_211_repetition_limit(self):

55 assert (

56 Vault(phrase=b'', length=24, symbol=0, number=0,

57 repeat=1).generate('asd') ==

58 b'IVTDzACftqopUXqDHPkuCIhV'

59 )

60

61 def test_212_without_symbols(self):

62 assert (

63 Vault(phrase=self.phrase, symbol=0).generate('google') ==

64 b'XZ4wRe0bZCazbljCaMqR'

65 )

66

67 def test_213_no_numbers(self):

68 assert (

69 Vault(phrase=self.phrase, number=0).generate('google') ==

70 b'_*$TVH.%^aZl(LUeOT?>'

71 )

72

73 def test_214_no_lowercase_letters(self):

74 assert (

75 Vault(phrase=self.phrase, lower=0).generate('google') ==

76 b':{?)+7~@OA:L]!0E$)(+'

77 )

78

79 def test_215_at_least_5_digits(self):

80 assert (

81 Vault(phrase=self.phrase, length=8, number=5)

82 .generate('songkick') == b'i0908.7['

83 )

84

85 def test_216_lots_of_spaces(self):

86 assert (

87 Vault(phrase=self.phrase, space=12)

88 .generate('songkick') == b' c 6 Bq % 5fR '

89 )

90

91 def test_217_all_character_classes(self):

92 assert (

93 Vault(phrase=self.phrase, lower=2, upper=2, number=1,

94 space=3, dash=2, symbol=1)

95 .generate('google') == b': : fv_wqt>a-4w1S R'

96 )

97

98 def test_218_only_numbers_and_very_high_repetition_limit(self):

99 generated = Vault(phrase=b'', length=40, lower=0, upper=0, space=0,

100 dash=0, symbol=0, repeat=4).generate('abcdef')

101 forbidden_substrings = {b'0000', b'1111', b'2222', b'3333', b'4444',

102 b'5555', b'6666', b'7777', b'8888', b'9999'}

103 for substring in forbidden_substrings:

104 assert substring not in generated

105

106 def test_219_very_limited_character_set(self):

107 generated = Vault(phrase=b'', length=24, lower=0, upper=0,

108 space=0, symbol=0).generate('testing')

109 assert b'763252593304946694588866' == generated

110

111 def test_220_character_set_subtraction(self):

112 assert Vault._subtract(b'be', b'abcdef') == bytearray(b'acdf')

113

114 @pytest.mark.parametrize(['length', 'settings', 'entropy'], [

115 (20, {}, math.log2(math.factorial(20)) + 20 * math.log2(94)),

116 (

117 20,

118 {'upper': 0, 'number': 0, 'space': 0, 'symbol': 0},

119 math.log2(math.factorial(20)) + 20 * math.log2(26)

120 ),

121 (0, {}, float('-inf')),

122 (0, {'lower': 0, 'number': 0, 'space': 0, 'symbol': 0}, float('-inf')),

123 (1, {}, math.log2(94)),

124 (1, {'upper': 0, 'lower': 0, 'number': 0, 'symbol': 0}, 0.0),

125 ])

126 def test_221_entropy(

127 self, length: int, settings: dict[str, int], entropy: int

128 ) -> None:

129 v = Vault(length=length, **settings) # type: ignore[arg-type]

130 assert math.isclose(v._entropy(), entropy)

131 assert v._estimate_sufficient_hash_length() > 0

132 if math.isfinite(entropy) and entropy:

133 assert (

134 v._estimate_sufficient_hash_length(1.0) ==

135 math.ceil(entropy / 8)

136 )

137 assert v._estimate_sufficient_hash_length(8.0) >= entropy

138

139 def test_222_hash_length_estimation(self) -> None:

140 v = Vault(phrase=self.phrase)

141 v2 = Vault(phrase=self.phrase, lower=0, upper=0, number=0,

142 symbol=0, space=1, length=1)

143 assert v2._entropy() == 0.0

144 assert v2._estimate_sufficient_hash_length() > 0

145

146 @pytest.mark.parametrize(['service', 'expected'], [

147 (b'google', google_phrase),

148 ('twitter', twitter_phrase),

149 ])

150 def test_223_hash_length_expansion(

151 self, monkeypatch: Any, service: str | bytes, expected: bytes

152 ) -> None:

153 v = Vault(phrase=self.phrase)

154 monkeypatch.setattr(v,

155 '_estimate_sufficient_hash_length',

156 lambda *args, **kwargs: 1)

157 assert v._estimate_sufficient_hash_length() < len(self.phrase)

158 assert v.generate(service) == expected

159

160 @pytest.mark.parametrize(['s', 'raises'], [

161 ('ñ', True), ('Düsseldorf', True),

162 ('liberté, egalité, fraternité', True), ('ASCII', False),

163 ('Düsseldorf'.encode('UTF-8'), False),

164 (bytearray([2, 3, 5, 7, 11, 13]), False),

165 ])

166 def test_224_binary_strings(

167 self, s: str | bytes | bytearray, raises: bool

168 ) -> None:

169 binstr = derivepassphrase.Vault._get_binary_string

170 AmbiguousByteRepresentationError = (

171 derivepassphrase.AmbiguousByteRepresentationError

172 )

173 if raises:

174 with pytest.raises(AmbiguousByteRepresentationError):

175 binstr(s)

176 elif isinstance(s, str):

177 assert binstr(s) == s.encode('UTF-8')

178 assert binstr(binstr(s)) == s.encode('UTF-8')

179 else:

180 assert binstr(s) == bytes(s)

181 assert binstr(binstr(s)) == bytes(s)

182

183 def test_310_too_many_symbols(self):

184 with pytest.raises(ValueError,

185 match='requested passphrase length too short'):

186 Vault(phrase=self.phrase, symbol=100)

187

188 def test_311_no_viable_characters(self):

189 with pytest.raises(ValueError,

190 match='no allowed characters left'):

191 Vault(phrase=self.phrase, lower=0, upper=0, number=0,

192 space=0, dash=0, symbol=0)

193

194 def test_320_character_set_subtraction_duplicate(self):

195 with pytest.raises(ValueError, match='duplicate characters'):

196 Vault._subtract(b'abcdef', b'aabbccddeeff')

197 with pytest.raises(ValueError, match='duplicate characters'):

198 Vault._subtract(b'aabbccddeeff', b'abcdef')

199

200 def test_322_hash_length_estimation(self) -> None:

201 v = Vault(phrase=self.phrase)

202 with pytest.raises(ValueError,

203 match='invalid safety factor'):

204 assert v._estimate_sufficient_hash_length(-1.0)

205 with pytest.raises(TypeError,

206 match='invalid safety factor: not a float'):

207 assert v._estimate_sufficient_hash_length(None) # type: ignore