Coverage for tests/test_derivepassphrase_vault.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 sequin

14 import pytest

15 import tests

16

17 Vault = derivepassphrase.Vault

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

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

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

21

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

23 (b'google', google_phrase),

24 ('twitter', twitter_phrase),

25])

26 def test_200_basic_configuration(service, expected):

27 assert Vault(phrase=phrase).generate(service) == expected

28

29 def test_201_phrase_dependence():

30 assert (

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

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

33 )

34

35 def test_202_reproducibility_and_bytes_service_name():

36 assert (

37 Vault(phrase=phrase).generate(b'google') ==

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

39 )

40

41 def test_203_reproducibility_and_bytearray_service_name():

42 assert (

43 Vault(phrase=phrase).generate(b'google') ==

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

45 )

46

47 def test_210_nonstandard_length():

48 assert Vault(phrase=phrase, length=4).generate('google') == b'xDFu'

49

50 def test_211_repetition_limit():

51 assert (

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

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

54 b'IVTDzACftqopUXqDHPkuCIhV'

55 )

56

57 def test_212_without_symbols():

58 assert (

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

60 b'XZ4wRe0bZCazbljCaMqR'

61 )

62

63 def test_213_too_many_symbols():

64 with pytest.raises(ValueError,

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

66 Vault(phrase=phrase, symbol=100)

67

68 def test_214_no_numbers():

69 assert (

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

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

72 )

73

74 def test_214_no_lowercase_letters():

75 assert (

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

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

78 )

79

80 def test_215_at_least_5_digits():

81 assert (

82 Vault(phrase=phrase, length=8, number=5).generate('songkick') ==

83 b'i0908.7['

84 )

85

86 def test_216_lots_of_spaces():

87 assert (

88 Vault(phrase=phrase, space=12).generate('songkick') ==

89 b' c 6 Bq % 5fR '

90 )

91

92 def test_217_no_viable_characters():

93 with pytest.raises(ValueError,

94 match='no allowed characters left'):

95 Vault(phrase=phrase, lower=0, upper=0, number=0,

96 space=0, dash=0, symbol=0)

97

98 def test_218_all_character_classes():

99 assert (

100 Vault(phrase=phrase, lower=2, upper=2, number=1,

101 space=3, dash=2, symbol=1).generate('google') ==

102 b': : fv_wqt>a-4w1S R'

103 )

104

105 def test_219_only_numbers_and_very_high_repetition_limit():

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

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

108 assert b'0000' not in generated

109 assert b'1111' not in generated

110 assert b'2222' not in generated

111 assert b'3333' not in generated

112 assert b'4444' not in generated

113 assert b'5555' not in generated

114 assert b'6666' not in generated

115 assert b'7777' not in generated

116 assert b'8888' not in generated

117 assert b'9999' not in generated

118

119 def test_220_very_limited_character_set():

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

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

122 assert b'763252593304946694588866' == generated

123

124 def test_300_character_set_subtraction():

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

126

127 def test_301_character_set_subtraction_duplicate():

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

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

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

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

132

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

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

135 (

136 20,

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

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

139 ),

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

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

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

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

144])

145 def test_400_entropy(

146 length: int, settings: dict[str, int], entropy: int

147) -> None:

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

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

150 assert v._estimate_sufficient_hash_length() > 0

151 if math.isfinite(entropy) and entropy:

152 assert v._estimate_sufficient_hash_length(1.0) == math.ceil(entropy / 8)

153 assert v._estimate_sufficient_hash_length(8.0) >= entropy

154

155 def test_401_hash_length_estimation(

156) -> None:

157 v = Vault(phrase=phrase)

158 with pytest.raises(ValueError,

159 match='invalid safety factor'):

160 assert v._estimate_sufficient_hash_length(-1.0)

161 with pytest.raises(TypeError,

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

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

164 v2 = Vault(phrase=phrase, lower=0, upper=0, number=0, symbol=0,

165 space=1, length=1)

166 assert v2._entropy() == 0.0

167 assert v2._estimate_sufficient_hash_length() > 0

168

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

170 (b'google', google_phrase),

171 ('twitter', twitter_phrase),

172])

173 def test_402_hash_length_expansion(

174 monkeypatch: Any, service: str | bytes, expected: bytes

175) -> None:

176 v = Vault(phrase=phrase)

177 monkeypatch.setattr(v,

178 '_estimate_sufficient_hash_length',

179 lambda *args, **kwargs: 1)

180 assert v._estimate_sufficient_hash_length() < len(phrase)

181 assert v.generate(service) == expected

182

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

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

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

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

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

188])

189 def test_403_binary_strings(s: str | bytes | bytearray, raises: bool) -> None:

190 binstr = derivepassphrase.Vault._get_binary_string

191 if raises:

192 with pytest.raises(derivepassphrase.AmbiguousByteRepresentationError):

193 binstr(s)

194 elif isinstance(s, str):

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

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

197 else:

198 assert binstr(s) == bytes(s)

199 assert binstr(binstr(s)) == bytes(s)