summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSimeon Simeonov2020-04-06 10:28:46 +0200
committerSimeon Simeonov2020-04-06 10:28:46 +0200
commite297c0696cf594f8f700dd82fdcdda582e348eda (patch)
treedeea1cfef4041dc492a38d8304c20be14d18c8ba
parent097aaa49fa99cffec9db74432d7025457c34199e (diff)
Add a simple CLI interface with tests and update README.md
-rw-r--r--README.md93
-rw-r--r--otp2289/__main__.py293
-rw-r--r--otp2289/server.py18
-rw-r--r--tests/test_main.py132
4 files changed, 523 insertions, 13 deletions
diff --git a/README.md b/README.md
index ac3bca9..5811b54 100644
--- a/README.md
+++ b/README.md
@@ -70,20 +70,99 @@ one-time password."
70 70
71## Examples 71## Examples
72 72
73We define the two entities: *client* and *server*. The entire application of
74RFC-2289 consists of interactions between them.
75
73 ```python 76 ```python
74 import getpass 77 #
78 import getpass # client only
79
80 import otp2289 # client and server
75 81
76 import otp2289 82 # the server starts by picking:
83 # - algorithm (MD5 or SHA1) to use
84 # - seed - 1 to 16 alphanumeric characters. The seed must never be reused.
85 # - initial step - number (int) that will be decremented for each OTP.
86 # In FreeBSD, the following default values are used:
87 # - MD5
88 # - the first two letters of the hostname + 5 random digits for seed
89 # - initial step: 500
77 90
78 # create a generator object 91 # the client receives those values, chooses a strong password and creates
79 passwd_bytes = getpass.getpass().encode() # Type: This is a test. 92 # initialization digest (hash). The password 'This is a test.' will give you
93 # the same results as in the following example.
94 passwd_bytes = getpass.getpass().encode() # Fetch the password as bytes
80 generator = otp2289.generator.OTPGenerator(passwd_bytes, 95 generator = otp2289.generator.OTPGenerator(passwd_bytes,
81 'TesT', 96 'TesT',
82 otp2289.OTP_ALGO_MD5) 97 otp2289.OTP_ALGO_MD5)
83 generator.generate_otp_hexdigest(0) 98 digest = generator.generate_otp_hexdigest(500)
84 generator.gen.generate_otp_words(0) 99 # digest is now: 0x2b8d82b6ac14346c
100 # the client sends it to the server
101
102 # the server creates the first state. Note that step is decremented by 1:
103 state = otp2289.server.OTPState(digest, 499, 'TesT', otp2289.OTP_ALGO_MD5)
104 # the state can be stored in a OTPStore container:
105 store = otp2289.server.OTPStore()
106 # key can be any str that can be used to reference the state (f.i username)
107 store.add_state('myusername', state) # where key can be any str that can be
108 # OTPStore is provided only for convenience as it is not part of RFC-2289.
109 # The server can store states any way it wants. A normal dict is also fine.
110 # Once the initial state is set on the server, the client can authenticate.
111
112 # Upon authentication request (f.i. login), the server issues a challenge
113 # based on the state:
114 challenge = state.challenge_string # challenge is now 'otp-md5 499 TesT '
115
116 # the client can now respond by using (or recreating) the same generator
117 # created earlier. RFC-2289 defines two types of responses:
118 # - hex (like '0x2b8d82b6ac14346c') - more suited for automation
119 # - tokens consisting of 6 short words - better when responding manually
120 hex_response = generator.generate_otp_hexdigest(499) # '0x6323f96296a2526b'
121 token_response = generator.generate_otp_words(499)
122 # token_response is now: 'CANT JAW BITS NU LO PUP'
123 # a possible shortcut may be to use the challenge-string directly:
124 hex_response = generator.generate_otp_hexdigest_from_challenge(challenge)
125 token_response = generator.generate_otp_words_from_challenge(challenge)
126 # ... giving the same results.
127
128 # once the response is received, the server validates it by yet again using
129 # the current state:
130 result = state.response_validates(hex_response)
131 # or
132 result = state.response_validates(token_response)
133 # result should be True if the response matches the state, False if not
134 # in case of invalid response or response checksum doesn't match, a
135 # otp2289.server.OTPInvalidResponse exception is raised.
136
137 # once the state has successfully validated the corresponding response,
138 the state **must never be used again** and a state corresponding to the
139 "next" (498) step created.
140 state = state.get_next_state()
141
142 # the next authentication attempt...
143 challenge = state.challenge_string # challenge is now 'otp-md5 498 TesT '
144 # ... and on the client side...
145 hex_response = generator.generate_otp_hexdigest_from_challenge(challenge)
146 # etc. etc...
85 ``` 147 ```
86 148
149If you don't care about developing applications in Python and only care about
150generating one-time passwords (tokens / hex digests) and authenticating with
151existing solutions (f.i. FreeBSD servers), pyotp2289 comes with a simple CLI:
152
153 ```bash
154 python -m otp2289 --generate-otp-response -f token -i 498 -s TesT
155 ```
156
157... will prompt for password and generate a 6 words (token) response.
158
159 ```bash
160 python -m otp2289 --generate-otp-range -f token -i 498 -s TesT
161 ```
162
163... will prompt for password and generate a range of 4 one-time passwords
164starting from (and including) 498.
165
87 166
88## Author 167## Author
89 168
@@ -95,5 +174,5 @@ Simeon Simeonov - sgs @ Freenode
95Copyright (c) 2020, Simeon Simeonov 174Copyright (c) 2020, Simeon Simeonov
96All rights reserved. 175All rights reserved.
97 176
98[licensed](LICENSE) under the BSD 2-clause. 177[Licensed](LICENSE) under the BSD 2-clause.
99SPDX-License-Identifier: BSD-2-Clause-FreeBSD 178SPDX-License-Identifier: BSD-2-Clause-FreeBSD
diff --git a/otp2289/__main__.py b/otp2289/__main__.py
new file mode 100644
index 0000000..2962d29
--- /dev/null
+++ b/otp2289/__main__.py
@@ -0,0 +1,293 @@
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: BSD-2-Clause-FreeBSD
3#
4# Copyright (c) 2020, Simeon Simeonov
5# All rights reserved.
6#
7# Redistribution and use in source and binary forms, with or without
8# modification, are permitted provided that the following conditions
9# are met:
10# 1. Redistributions of source code must retain the above copyright
11# notice, this list of conditions and the following disclaimer.
12# 2. Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15#
16# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
17# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26"""
27CLI entry point for the otp2289 package
28
29Examples:
30python -m otp2289 --initiate-new-sequence -s TesT
31
32python -m otp2289 --generate-otp-response -c "otp-md5 499 TesT " -f token
33python -m otp2289 --generate-otp-response -s TesT -i 499 -f token
34"""
35import argparse
36import errno
37import getpass
38import os
39import secrets
40import string
41import sys
42
43import otp2289
44
45
46def eprint(*arg, **kwargs):
47 """stdderr print wrapper"""
48 print(*arg, file=sys.stderr, flush=True, **kwargs)
49
50
51def generate_otp_response(args):
52 """
53 Generates a response based on the parameters sent from the parser
54
55 :param args: The arguments assigned from argparse
56 :type args: argparse.Namespace
57
58 :raises OTPChallengeException: In case of invalid challenge
59
60 :raises OTPGeneratorException: In case of wrong generator parameters
61
62 :return: The response string
63 :rtype: str
64 """
65 generator = otp2289.generator.OTPGenerator(
66 args.password.encode(),
67 args.seed,
68 args.hash_algo)
69 if args.challenge_string:
70 if args.output_format == 'token':
71 return generator.generate_otp_words_from_challenge(
72 args.challenge_string)
73 return generator.generate_otp_hexdigest_from_challenge(
74 args.challenge_string)
75 # regular parameters
76 header = ''
77 if not args.quiet:
78 header = (f'Seed: {args.seed}, Step: {args.step}, '
79 f'Hash: {args.hash_algo}{os.linesep}')
80 if args.output_format == 'token':
81 return header + generator.generate_otp_words(args.step)
82 return header + generator.generate_otp_hexdigest(args.step)
83
84
85def generate_otp_range(args):
86 """
87 Generates range of responses based on the parameters sent from the parser
88
89 :param args: The arguments assigned from argparse
90 :type args: argparse.Namespace
91
92 :raises OTPChallengeException: In case of invalid challenge
93
94 :raises OTPGeneratorException: In case of wrong generator parameters
95
96 :return: The responses string
97 :rtype: str
98 """
99 generator = otp2289.generator.OTPGenerator(
100 args.password.encode(),
101 args.seed,
102 args.hash_algo)
103 if args.output_format == 'token':
104 method = generator.generate_otp_words
105 else:
106 method = generator.generate_otp_hexdigest
107 # handle most cases explicitly
108 if args.range == 1:
109 return f'{args.step}: ' + method(args.step)
110 if args.range > args.step + 1:
111 args.range = args.step + 1
112 # any need for quiet?
113 header = ''
114 if not args.quiet:
115 header = (f'Seed: {args.seed}, Step: {args.step}, '
116 f'Hash: {args.hash_algo}, Range: {args.range}{os.linesep}')
117 return header + os.linesep.join(
118 [f'{step}: ' + method(step) for step in range(
119 args.step, args.step - args.range, -1)])
120
121
122def get_rnd_seed():
123 """
124 Returns a random seed in the format:
125
126 2 random letters (capitalize()) + 5 random digits
127 """
128 rnd = secrets.SystemRandom()
129 return (''.join(rnd.choices(string.ascii_lowercase, k=2)).capitalize() +
130 ''.join(rnd.choices(string.digits, k=5)))
131
132
133def initiate_new_sequence(args):
134 """
135 Generates a new sequence based on the parameters sent from the parser.
136
137 :param args: The arguments assigned from argparse
138 :type args: argparse.Namespace
139
140 :raises OTPChallengeException: In case of invalid challenge
141
142 :raises OTPGeneratorException: In case of wrong generator parameters
143
144 :return: The response string
145 :rtype: str
146 """
147 if not args.seed:
148 args.seed = get_rnd_seed()
149 header = ''
150 if not args.quiet:
151 header = (f'Seed: {args.seed}, Step: {args.step}, '
152 f'Hash: {args.hash_algo}{os.linesep}')
153 generator = otp2289.generator.OTPGenerator(
154 args.password.encode(),
155 args.seed,
156 args.hash_algo)
157 if args.challenge_string:
158 return header + generator.generate_otp_hexdigest_from_challenge(
159 args.challenge_string)
160 return header + generator.generate_otp_hexdigest(args.step)
161
162
163def main(args=None):
164 """the main entry point"""
165 parser = argparse.ArgumentParser(
166 prog=__package__,
167 epilog=(f'%(prog)s {otp2289.__version__} by Simeon Simeonov '
168 '(sgs @ Freenode)'),
169 description='The following options are available')
170 group = parser.add_mutually_exclusive_group(required=True)
171 group.add_argument(
172 '--generate-otp-range',
173 action='store_true',
174 dest='generate_otp_range',
175 default=False,
176 help='Generates a range of OTP responses')
177 group.add_argument(
178 '--generate-otp-response',
179 action='store_true',
180 dest='generate_otp_response',
181 default=False,
182 help='Generates a new OTP response')
183 group.add_argument(
184 '--initiate-new-sequence',
185 action='store_true',
186 dest='initiate_new_sequence',
187 default=False,
188 help=('Initiates a new OTP sequence. Essentially the same as '
189 '--generate-otp-response only it prompts twice for password '
190 'and always outputs hex (ignores -f).'))
191 parser.add_argument(
192 '-a', '--hash-algorithm',
193 metavar='<md5 | sha1>',
194 type=str,
195 dest='hash_algo',
196 default='md5',
197 help='The hash algorithm to use. Possible values: md5 (default), sha1')
198 parser.add_argument(
199 '-c', '--challenge-string',
200 metavar='<challenge string>',
201 type=str,
202 dest='challenge_string',
203 default='',
204 help='Use challenge string when generating response')
205 parser.add_argument(
206 '-f', '--output-format',
207 metavar='<hex | token>',
208 type=str,
209 dest='output_format',
210 default='hex',
211 help='The output format to use. Possible values: hex (default), token')
212 parser.add_argument(
213 '-i', '--step',
214 metavar='<step>',
215 type=int,
216 dest='step',
217 default=500,
218 help='The step. Default for initiating a new sequence is: 500')
219 parser.add_argument(
220 '-p', '--password',
221 metavar='<PASSWORD[FILE]>',
222 type=str,
223 dest='password',
224 default='',
225 help=('The password or path to password file '
226 '(default & recommended: prompt for passwd)'))
227 parser.add_argument(
228 '-q', '--quiet',
229 action='store_true',
230 dest='quiet',
231 default=False,
232 help='Dot not show headers. Only hex / tokens')
233 parser.add_argument(
234 '-r', '--range',
235 metavar='<range>',
236 type=int,
237 dest='range',
238 default=1,
239 help='Amount of consecutive OTP hex/tokens to generate. default: 1')
240 parser.add_argument(
241 '-s', '--seed',
242 metavar='[seed]',
243 type=str,
244 dest='seed',
245 default='',
246 help=('The seed to use (1 to 16 alphanumeric characters) '
247 '(default & recommended: random seed)'))
248 parser.add_argument(
249 '-v', '--version',
250 action='version',
251 version=f'%(prog)s {otp2289.__version__}',
252 help='display program-version and exit')
253 args = parser.parse_args(args)
254 # handle the password before everything else
255 if not args.password:
256 try:
257 while True:
258 args.password = getpass.getpass()
259 if (
260 not args.initiate_new_sequence or
261 args.password == getpass.getpass('Repeat password: ')
262 ):
263 break
264 eprint('The passwords do not match')
265 except KeyboardInterrupt:
266 eprint(os.linesep + 'Prompt terminated')
267 sys.exit(errno.EACCES)
268 elif os.path.isfile(args.password):
269 try:
270 with open(args.password, 'r') as fp:
271 args.password = fp.readline().strip()
272 except Exception as exp:
273 eprint(f'Unable to open password file: {exp}')
274 sys.exit(1)
275 try:
276 if args.initiate_new_sequence:
277 print(initiate_new_sequence(args))
278 if args.generate_otp_range:
279 print(generate_otp_range(args))
280 if args.generate_otp_response:
281 print(generate_otp_response(args))
282 sys.exit(0)
283 except otp2289.generator.OTPGeneratorException as exp:
284 eprint(f'GeneratorException: {exp}')
285 except otp2289.generator.OTPChallengeException as exp:
286 eprint(f'ChallengeException: {exp}')
287 except Exception as exp:
288 eprint(f'Unknown error: {exp}')
289 sys.exit(1)
290
291
292if __name__ == '__main__':
293 main()
diff --git a/otp2289/server.py b/otp2289/server.py
index b765af6..fca3f06 100644
--- a/otp2289/server.py
+++ b/otp2289/server.py
@@ -58,10 +58,9 @@ class OTPState:
58 Constructs an OTPState object with the given arguments. 58 Constructs an OTPState object with the given arguments.
59 59
60 Keyword Arguments: 60 Keyword Arguments:
61 :param ot_hex: The one-time hex from the last successful 61 :param ot_hex: The one-time hex from the last successful authentication
62 authentication or the first OTP of a newly 62 or None for a newly initialized sequence.
63 initialized sequence 63 :type ot_hex: str or None
64 :type ot_hex: str
65 64
66 :param current_step: The current step that is sent with the challenge 65 :param current_step: The current step that is sent with the challenge
67 :type current_step: int 66 :type current_step: int
@@ -81,7 +80,9 @@ class OTPState:
81 self._step = OTPGenerator.validate_step(current_step) 80 self._step = OTPGenerator.validate_step(current_step)
82 except OTPGeneratorException as exp: 81 except OTPGeneratorException as exp:
83 raise OTPStateException(exp.args[0]) 82 raise OTPStateException(exp.args[0])
84 self._current_digest = self.validate_hex(ot_hex) 83 self._current_digest = None
84 if ot_hex is not None:
85 self._current_digest = self.validate_hex(ot_hex)
85 self._new_digest_hex = None # set upon a successful validation 86 self._new_digest_hex = None # set upon a successful validation
86 87
87 @property 88 @property
@@ -223,6 +224,7 @@ class OTPState:
223 if self._hash_algo == 'md5': 224 if self._hash_algo == 'md5':
224 digest = hashlib.md5(response_bytes).digest() 225 digest = hashlib.md5(response_bytes).digest()
225 if ( 226 if (
227 self._current_digest is None or
226 OTPGenerator.strxor(digest[0:8], digest[8:]) == 228 OTPGenerator.strxor(digest[0:8], digest[8:]) ==
227 self._current_digest 229 self._current_digest
228 ): 230 ):
@@ -234,6 +236,7 @@ class OTPState:
234 if self._hash_algo == 'sha1': 236 if self._hash_algo == 'sha1':
235 digest = hashlib.sha1(response_bytes).digest() 237 digest = hashlib.sha1(response_bytes).digest()
236 if ( 238 if (
239 self._current_digest is None or
237 OTPGenerator.sha1_digest_folding( 240 OTPGenerator.sha1_digest_folding(
238 hashlib.sha1( 241 hashlib.sha1(
239 response_bytes).digest()) == self._current_digest 242 response_bytes).digest()) == self._current_digest
@@ -255,7 +258,10 @@ class OTPState:
255 :return: The dict representation of the object 258 :return: The dict representation of the object
256 :rtype: dict 259 :rtype: dict
257 """ 260 """
258 return {'ot_hex': binascii.hexlify(self._current_digest).decode(), 261 ot_hex = self._current_digest
262 if ot_hex is not None:
263 ot_hex = binascii.hexlify(self._current_digest).decode()
264 return {'ot_hex': ot_hex,
259 'current_step': self._step, 265 'current_step': self._step,
260 'seed': self._seed, 266 'seed': self._seed,
261 'hash_algo': self._hash_algo} 267 'hash_algo': self._hash_algo}
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 0000000..b48f625
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,132 @@
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: BSD-2-Clause-FreeBSD
3#
4# Copyright (c) 2020, Simeon Simeonov
5# All rights reserved.
6#
7# Redistribution and use in source and binary forms, with or without
8# modification, are permitted provided that the following conditions
9# are met:
10# 1. Redistributions of source code must retain the above copyright
11# notice, this list of conditions and the following disclaimer.
12# 2. Redistributions in binary form must reproduce the above copyright notice,
13# this list of conditions and the following disclaimer in the documentation
14# and/or other materials provided with the distribution.
15#
16# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
17# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
18# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
19# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
20# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
21# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
22# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
23# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
24# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
25# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
26"""Tests for otp2289.__main__"""
27import os
28
29import pytest
30
31from otp2289.__main__ import main
32
33
34def test_main_generate_otp_response(capsys):
35 """tests main"""
36 args = ['--generate-otp-response',
37 '-a',
38 'sha1',
39 '-i',
40 '99',
41 '-s',
42 'TesT',
43 '-p',
44 'This is a test.']
45 with pytest.raises(SystemExit) as exit_info:
46 main(args)
47 captured = capsys.readouterr()
48 assert captured.out == (f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}'
49 f'0x87fec7768b73ccf9{os.linesep}')
50 assert exit_info.type == SystemExit
51 assert exit_info.value.code == 0
52 args.extend(['-f', 'token'])
53 with pytest.raises(SystemExit) as exit_info:
54 main(args)
55 captured = capsys.readouterr()
56 assert captured.out == (f'Seed: TesT, Step: 99, Hash: sha1{os.linesep}'
57 f'GAFF WAIT SKID GIG SKY EYED{os.linesep}')
58 assert exit_info.type == SystemExit
59 assert exit_info.value.code == 0
60 args.append('-q')
61 with pytest.raises(SystemExit) as exit_info:
62 main(args)
63 captured = capsys.readouterr()
64 assert captured.out == f'GAFF WAIT SKID GIG SKY EYED{os.linesep}'
65 assert exit_info.type == SystemExit
66 assert exit_info.value.code == 0
67
68
69def test_main_generate_otp_range(capsys):
70 """tests main"""
71 args = ['--generate-otp-range',
72 '-i',
73 '2',
74 '-s',
75 'TesT',
76 '-r',
77 '5',
78 '-p',
79 'This is a test.']
80 with pytest.raises(SystemExit) as exit_info:
81 main(args)
82 captured = capsys.readouterr()
83 assert captured.out == (f'Seed: TesT, Step: 2, Hash: md5, Range: 3'
84 f'{os.linesep}'
85 f'2: 0x4049f8b161669b7b{os.linesep}'
86 f'1: 0x7965e05436f5029f{os.linesep}'
87 f'0: 0x9e876134d90499dd{os.linesep}')
88 assert exit_info.type == SystemExit
89 assert exit_info.value.code == 0
90 args.append('-q')
91 with pytest.raises(SystemExit) as exit_info:
92 main(args)
93 captured = capsys.readouterr()
94 assert captured.out == (f'2: 0x4049f8b161669b7b{os.linesep}'
95 f'1: 0x7965e05436f5029f{os.linesep}'
96 f'0: 0x9e876134d90499dd{os.linesep}')
97 assert exit_info.type == SystemExit
98 assert exit_info.value.code == 0
99 args.extend(['-f', 'token'])
100 with pytest.raises(SystemExit) as exit_info:
101 main(args)
102 captured = capsys.readouterr()
103 assert captured.out == (f'2: THY AVON NO NECK COKE MOLL{os.linesep}'
104 f'1: EASE OIL FUM CURE AWRY AVIS{os.linesep}'
105 f'0: INCH SEA ANNE LONG AHEM TOUR{os.linesep}')
106 assert exit_info.type == SystemExit
107 assert exit_info.value.code == 0
108
109
110def test_main_initiate(capsys):
111 """tests main"""
112 args = ['--initiate-new-sequence',
113 '-i',
114 '500',
115 '-s',
116 'TesT',
117 '-p',
118 'This is a test.']
119 with pytest.raises(SystemExit) as exit_info:
120 main(args)
121 captured = capsys.readouterr()
122 assert captured.out == (f'Seed: TesT, Step: 500, Hash: md5{os.linesep}'
123 f'0x2b8d82b6ac14346c{os.linesep}')
124 assert exit_info.type == SystemExit
125 assert exit_info.value.code == 0
126 args.append('-q')
127 with pytest.raises(SystemExit) as exit_info:
128 main(args)
129 captured = capsys.readouterr()
130 assert captured.out == f'0x2b8d82b6ac14346c{os.linesep}'
131 assert exit_info.type == SystemExit
132 assert exit_info.value.code == 0