# SPDX-License-Identifier: BSD-2-Clause
#
# Copyright (c) 2020-2026 Simeon Simeonov
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHORS ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""
CLI entry point for the otp2289 package
Examples:
python -m otp2289 --initiate-new-sequence -s TesT
python -m otp2289 --generate-otp-response -c "otp-md5 499 TesT " -f token
python -m otp2289 --generate-otp-response -s TesT -i 499 -f token
"""
import argparse
import errno
import getpass
import os
import pathlib
import secrets
import string
import sys
import otp2289
[docs]
def eprint(
*value: object, sep: str | None = ' ', end: str | None = '\n'
) -> None:
"""stdderr print wrapper"""
print(*value, sep=sep, end=end, file=sys.stderr, flush=True)
[docs]
def get_rnd_seed() -> str:
"""
Returns a random seed in the format:
2 random letters (capitalize()) + 5 random digits
"""
rnd = secrets.SystemRandom()
return ''.join(
rnd.choices(string.ascii_lowercase, k=2)
).capitalize() + ''.join(rnd.choices(string.digits, k=5))
[docs]
def initiate_new_sequence(args: argparse.Namespace) -> str:
"""
Generates a new sequence based on the parameters sent from the parser.
:param args: The arguments assigned from argparse
:type args: argparse.Namespace
:raises otp2289.OTPChallengeError: If the challenge is invalid
:raises otp2289.OTPGeneratorError: If generator parameters are wrong
:raises otp2289.OTPResponseError: If tokens or hex can not be generated
:return: The response string
:rtype: str
"""
if not args.seed:
args.seed = get_rnd_seed()
header = ''
if not args.quiet:
header = (
f'Seed: {args.seed}, Step: {args.step}, '
f'Hash: {args.hash_algo}{os.linesep}'
)
generator = otp2289.OTPGenerator(
args.password.encode(), args.seed, args.hash_algo
)
if args.challenge_string:
return (
header
+ generator.generate_otp_response_from_challenge(
args.challenge_string
).hexdigest
)
return header + generator.generate_otp_response(args.step).hexdigest
[docs]
def generate_otp_response(args: argparse.Namespace) -> str:
"""
Generates a response based on the parameters sent from the parser
:param args: The arguments assigned from argparse
:type args: argparse.Namespace
:raises otp2289.OTPChallengeError: If the challenge is invalid
:raises otp2289.OTPGeneratorError: If generator parameters are wrong
:raises otp2289.OTPResponseError: If tokens or hex can not be generated
:return: The response string
:rtype: str
"""
generator = otp2289.OTPGenerator(
args.password.encode(), args.seed, args.hash_algo
)
if args.challenge_string:
response = generator.generate_otp_response_from_challenge(
args.challange_string
)
if args.output_format == 'token':
return response.words
return response.hexdigest
# regular parameters
header = ''
if not args.quiet:
header = (
f'Seed: {args.seed}, Step: {args.step}, '
f'Hash: {args.hash_algo}{os.linesep}'
)
response = generator.generate_otp_response(args.step)
if args.output_format == 'token':
return header + response.words
return header + response.hexdigest
[docs]
def generate_otp_range(args: argparse.Namespace) -> str:
"""
Generates range of responses based on the parameters sent from the parser
:param args: The arguments assigned from argparse
:type args: argparse.Namespace
:raises otp2289.OTPChallengeError: If the challenge is invalid
:raises otp2289.OTPGeneratorError: If generator parameters are wrong
:raises otp2289.OTPResponseError: If tokens or hex can not be generated
:return: The responses string
:rtype: str
"""
generator = otp2289.OTPGenerator(
args.password.encode(), args.seed, args.hash_algo
)
# handle most cases explicitly
if args.range == 1:
response = generator.generate_otp_response(args.step)
if args.output_format == 'token':
response_str = response.words
else:
response_str = response.hexdigest
return f'{args.step}: ' + response_str
args.range = min(args.range, args.step + 1)
# any need for quiet?
header = ''
if not args.quiet:
header = (
f'Seed: {args.seed}, Step: {args.step}, '
f'Hash: {args.hash_algo}, Range: {args.range}{os.linesep}'
)
if args.output_format == 'token':
return header + os.linesep.join(
[
f'{step}: ' + generator.generate_otp_response(step).words
for step in range(args.step, args.step - args.range, -1)
]
)
# assume hex
return header + os.linesep.join(
[
f'{step}: ' + generator.generate_otp_response(step).hexdigest
for step in range(args.step, args.step - args.range, -1)
]
)
[docs]
def get_password(args: argparse.Namespace) -> str:
"""
Extract the provided password using the defined argparse arguments
:param args: The arguments assigned from argparse
:type args: argparse.Namespace
:raises KeyboardInterrupt: If the password prompt is interrupted
:return: The extrated password string
:rtype: str
"""
if args.force_password_prompt:
while True:
password = getpass.getpass()
if not args.initiate_new_sequence or password == getpass.getpass(
'Repeat password: '
):
break
eprint('The passwords do not match')
return password
if not args.password:
password = os.environ.get('OTP2289_PASSWORD')
if password is not None:
return password
while True:
password = getpass.getpass()
if not args.initiate_new_sequence or password == getpass.getpass(
'Repeat password: '
):
break
eprint('The passwords do not match')
return password
if pathlib.Path(args.password).is_file():
with pathlib.Path(args.password).open(encoding='utf-8') as fp:
return fp.readline().strip()
return args.password
[docs]
def main(inargs: list[str] | None = None) -> None:
"""the main entry point"""
parser = argparse.ArgumentParser(
prog=__package__,
epilog=(
f'%(prog)s {otp2289.__version__} by Simeon Simeonov '
'(sgs @ LiberaChat)'
),
description='The following options are available',
)
group = parser.add_mutually_exclusive_group(required=True)
password_group = parser.add_mutually_exclusive_group()
group.add_argument(
'--generate-otp-range',
action='store_true',
dest='generate_otp_range',
default=False,
help='Generates a range of OTP responses',
)
group.add_argument(
'--generate-otp-response',
action='store_true',
dest='generate_otp_response',
default=False,
help='Generates a new OTP response',
)
group.add_argument(
'--initiate-new-sequence',
action='store_true',
dest='initiate_new_sequence',
default=False,
help=(
'Initiates a new OTP sequence. Essentially the same as '
'--generate-otp-response only it prompts twice for password '
'and always outputs hex (ignores -f).'
),
)
password_group.add_argument(
'-P',
'--force-password-prompt',
dest='force_password_prompt',
action='store_true',
help=(
'Force password prompt even if the env. variable '
'"OTP2289_PASSWORD" is set'
),
)
password_group.add_argument(
'-p',
'--password',
metavar='<PASSWORD[FILE]>',
type=str,
dest='password',
default='',
help=(
'The password or path to password file '
'(default & recommended: prompt for passwd)'
),
)
parser.add_argument(
'-a',
'--hash-algorithm',
type=str,
choices=['md5', 'sha1'],
dest='hash_algo',
default='md5',
help='The hash algorithm to use. Possible values: md5 (default), sha1',
)
parser.add_argument(
'-c',
'--challenge-string',
metavar='<challenge string>',
type=str,
dest='challenge_string',
default='',
help='Use challenge string when generating response',
)
parser.add_argument(
'-f',
'--output-format',
type=str,
choices=['hex', 'token'],
dest='output_format',
default='hex',
help='The output format to use. Possible values: hex (default), token',
)
parser.add_argument(
'-i',
'--step',
metavar='<step>',
type=int,
dest='step',
default=500,
help='The step. Default for initiating a new sequence is: 500',
)
parser.add_argument(
'-q',
'--quiet',
action='store_true',
dest='quiet',
default=False,
help='Dot not show headers. Only hex / tokens',
)
parser.add_argument(
'-r',
'--range',
metavar='<range>',
type=int,
dest='range',
default=1,
help='Amount of consecutive OTP hex/tokens to generate. default: 1',
)
parser.add_argument(
'-s',
'--seed',
metavar='[seed]',
type=str,
dest='seed',
default='',
help=(
'The seed to use (1 to 16 alphanumeric characters) '
'(default & recommended: random seed)'
),
)
parser.add_argument(
'-v',
'--version',
action='version',
version=f'%(prog)s {otp2289.__version__}',
help='display program-version and exit',
)
args = parser.parse_args(inargs)
# handle the password before everything else
try:
args.password = get_password(args)
except KeyboardInterrupt:
eprint(os.linesep + 'Prompt terminated')
sys.exit(errno.EACCES)
except Exception as exp:
eprint(f'Unable to fetch password: {exp}')
sys.exit(1)
try:
if args.initiate_new_sequence:
print(initiate_new_sequence(args))
if args.generate_otp_range:
print(generate_otp_range(args))
if args.generate_otp_response:
print(generate_otp_response(args))
sys.exit(0)
except otp2289.OTPGeneratorError as exp:
eprint(f'GeneratorException: {exp}')
except otp2289.OTPChallengeError as exp:
eprint(f'ChallengeException: {exp}')
except Exception as exp:
eprint(f'Unknown error: {exp}')
sys.exit(1)
if __name__ == '__main__':
main()