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 otp2289.OTPChallengeException: If the challenge is invalid
59
60    :raises otp2289.OTPGeneratorException: If generator parameters are wrong
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 otp2289.OTPChallengeException: If the challenge is invalid
93
94    :raises otp2289.OTPGeneratorException: If generator parameters are wrong
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 otp2289.OTPChallengeException: If the challenge is invalid
141
142    :raises otp2289.OTPGeneratorException: If generator parameters are wrong
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()
294