summaryrefslogtreecommitdiff
path: root/otp2289
diff options
context:
space:
mode:
authorSimeon Simeonov2020-04-06 10:28:46 +0200
committerSimeon Simeonov2020-04-06 10:28:46 +0200
commite297c0696cf594f8f700dd82fdcdda582e348eda (patch)
treedeea1cfef4041dc492a38d8304c20be14d18c8ba /otp2289
parent097aaa49fa99cffec9db74432d7025457c34199e (diff)
Add a simple CLI interface with tests and update README.md
Diffstat (limited to 'otp2289')
-rw-r--r--otp2289/__main__.py293
-rw-r--r--otp2289/server.py18
2 files changed, 305 insertions, 6 deletions
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}