Skip to content

Commit 132f54a

Browse files
author
Hugo Osvaldo Barrera
authored
Merge pull request #10 from edudobay/cleaner-cli
Proposal: Cleaner CLI + Support for adding otpauth:// URIs
2 parents 8b57840 + 2c26977 commit 132f54a

File tree

5 files changed

+188
-55
lines changed

5 files changed

+188
-55
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
build
33
dist
44
.eggs
5+
__pycache__/

README.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ Default X selection can be overridden with the PASSWORD_STORE_X_SELECTION
2323
environment variable.
2424

2525
Shared keys should be stored in your pass storage under ``2fa/SERVICE/code``,
26-
for example ``2fa/github/code``. The ``-a`` flag can be used to add this less
27-
painfully
26+
for example ``2fa/github/code``. The ``-a`` flag (or alternatively the ``add``
27+
subcommand) can be used to add this less painfully.
2828

2929
.. _pass: http://www.passwordstore.org/
3030

@@ -58,6 +58,8 @@ For example::
5858
To add a service::
5959

6060
totp -a SERVICE
61+
# OR
62+
totp add SERVICE
6163

6264
For example::
6365

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
packages=['totp'],
2222
entry_points={
2323
'console_scripts': [
24-
'totp = totp:run',
24+
'totp = totp.cli:run',
2525
]
2626
},
2727
install_requires=[

totp/__init__.py

Lines changed: 63 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,38 @@
22
#
33
# Print a TOTP token getting the shared key from pass(1).
44

5-
import getpass
65
import os
76
import platform
87
import re
98
import subprocess
109
import sys
11-
from base64 import b32decode
10+
import urllib.parse
1211

1312
import onetimepass
1413

1514

15+
DIGITS_DEFAULT = 6
16+
17+
18+
class BackendError(Exception):
19+
backend_name = '<none>'
20+
21+
class PassBackendError(BackendError):
22+
backend_name = 'pass'
23+
24+
def __init__(self, err):
25+
if isinstance(err, bytes):
26+
err = err.decode('utf-8', 'replace')
27+
super().__init__(err.rstrip('\n'))
28+
29+
class ValidationError(Exception): pass
30+
31+
def validate(*args):
32+
for (validator, error_message) in args:
33+
if not validator():
34+
raise ValidationError(error_message)
35+
36+
1637
def get_length(pass_entry):
1738
"""Return the required token length."""
1839
for line in pass_entry:
@@ -22,24 +43,16 @@ def get_length(pass_entry):
2243
return 6
2344

2445

25-
def add_pass_entry(path):
46+
def add_pass_entry_from_uri(path, uri):
47+
parsed = parse_otpauth_uri(uri)
48+
add_pass_entry(path, parsed['digits'], parsed['secret'])
49+
50+
51+
def add_pass_entry(path, token_length, shared_key):
2652
"""Add a new entry via pass."""
2753
code_path = "2fa/{}/code"
2854
code_path = code_path.format(path)
2955

30-
token_length = input('Token length [6]: ')
31-
token_length = int(token_length) if token_length else 6
32-
33-
while True:
34-
try:
35-
shared_key = return_secret(getpass.getpass('Shared key: '))
36-
b32decode(shared_key.upper())
37-
if shared_key == "":
38-
raise ValueError('The key entered was empty')
39-
break
40-
except ValueError as err:
41-
print(err.args)
42-
4356
pass_entry = "{}\ndigits: {}\n".format(shared_key, token_length)
4457

4558
p = subprocess.Popen(
@@ -54,9 +67,7 @@ def add_pass_entry(path):
5467
)
5568

5669
if len(err) > 0:
57-
print("pass returned an error:")
58-
print(err)
59-
sys.exit(-1)
70+
raise PassBackendError(err)
6071

6172

6273
def get_pass_entry(path):
@@ -73,9 +84,7 @@ def get_pass_entry(path):
7384
pass_output, err = p.communicate()
7485

7586
if len(err) > 0:
76-
print("pass returned an error:")
77-
print(err)
78-
sys.exit(-1)
87+
raise PassBackendError(err)
7988

8089
return pass_output.decode()
8190

@@ -109,15 +118,14 @@ def copy_to_clipboard(text):
109118
)
110119

111120

112-
def return_secret(pass_entry):
113-
pass_length = len(pass_entry)
114-
if pass_length % 8 == 0:
115-
secret = pass_entry
116-
return secret
117-
else:
118-
closestmultiple = 8 * (int(pass_length / 8) + (pass_length % 8 > 0))
119-
secret = pass_entry.ljust(closestmultiple, '=')
120-
return secret
121+
def normalize_secret(secret):
122+
s = secret.replace(' ', '')
123+
124+
if len(s) % 8 != 0:
125+
num_needed_padding_chars = 8 - (len(s) % 8)
126+
s += '=' * num_needed_padding_chars
127+
128+
return s
121129

122130

123131
def generate_token(path, seconds=0):
@@ -130,8 +138,7 @@ def generate_token(path, seconds=0):
130138
# Remove the trailing newline or any other custom data users might have
131139
# saved:
132140
pass_entry = pass_entry.splitlines()
133-
134-
secret = return_secret(pass_entry[0])
141+
secret = normalize_secret(pass_entry[0])
135142

136143
digits = get_length(pass_entry)
137144
token = onetimepass.get_totp(secret, as_string=True, token_length=digits,
@@ -141,26 +148,30 @@ def generate_token(path, seconds=0):
141148
copy_to_clipboard(token)
142149

143150

144-
def help():
145-
print("Usage: totp [option] service")
146-
print("Options:")
147-
print("-a : Add the named service to pass")
148-
print("-h : This help")
149-
print("-s -/+[sec] : Add an offset to the time.")
151+
def parse_otpauth_uri(uri):
152+
parsed = urllib.parse.urlsplit(uri)
153+
query = urllib.parse.parse_qs(parsed.query)
150154

155+
secret = query.get('secret', [])
156+
digits = query.get('digits', [])
157+
issuer = query.get('issuer', [])
151158

152-
def run():
153-
if len(sys.argv) == 1:
154-
help()
155-
elif sys.argv[1] == '-a':
156-
add_pass_entry(sys.argv[2])
157-
elif sys.argv[1] == '-s':
158-
generate_token(sys.argv[3], seconds=sys.argv[2])
159-
elif sys.argv[1] == '-h':
160-
help()
161-
else:
162-
generate_token(sys.argv[1])
159+
validate(
160+
(lambda: parsed.scheme == 'otpauth', 'invalid URI scheme: %s' % parsed.scheme),
161+
(lambda: parsed.netloc == 'totp', 'unsupported key type: %s' % parsed.netloc),
162+
(lambda: len(secret) <= 1, 'too many \'secret\' arguments'),
163+
(lambda: len(digits) <= 1, 'too many \'digits\' arguments'),
164+
(lambda: len(issuer) <= 1, 'too many \'issuer\' arguments'),
165+
(lambda: len(secret) == 1, 'no secret found'),
166+
)
163167

168+
secret, = secret
169+
issuer = issuer[0] if issuer else None
170+
digits = int(digits[0]) if digits else DIGITS_DEFAULT
164171

165-
if __name__ == '__main__':
166-
run()
172+
return {
173+
'secret': secret,
174+
'digits': digits,
175+
'issuer': issuer,
176+
'label': parsed.path.lstrip('/'),
177+
}

totp/cli.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import argparse
2+
import getpass
3+
import sys
4+
from collections import OrderedDict, defaultdict, namedtuple
5+
from functools import reduce
6+
7+
import totp
8+
9+
_subcommands = {}
10+
11+
_argument = namedtuple('argument', ['args', 'kwargs'])
12+
def argument(*args, **kwargs):
13+
return _argument(args, kwargs)
14+
15+
def subcommand(name, *args, **kwargs):
16+
def decorator(func):
17+
_subcommands[name] = (args, kwargs, func)
18+
return decorator
19+
20+
def _parse_args(args):
21+
parser = argparse.ArgumentParser(
22+
description='Print a TOTP token getting the shared key from pass(1).'
23+
)
24+
25+
subparsers = parser.add_subparsers(dest='command')
26+
aliases = {}
27+
28+
for name, (_args, kwargs, func) in _subcommands.items():
29+
_parser = subparsers.add_parser(name, **kwargs)
30+
_parser.set_defaults(func=func)
31+
for arg in _args:
32+
_parser.add_argument(*arg.args, **arg.kwargs)
33+
34+
_aliases = kwargs.get('aliases', ())
35+
for alias in _aliases:
36+
aliases[alias] = name
37+
38+
def replace_aliases(args, aliases):
39+
for i, arg in enumerate(args):
40+
if arg in aliases:
41+
args[i] = aliases[arg]
42+
43+
def add_default_subcommand_if_omitted(args, default):
44+
if not any(arg in ('-h', '--help') or arg in subparsers.choices for arg in args):
45+
args.insert(0, default)
46+
47+
replace_aliases(args, aliases)
48+
add_default_subcommand_if_omitted(args, 'show')
49+
50+
return parser.parse_args(args)
51+
52+
def run():
53+
args = _parse_args(sys.argv[1:])
54+
try:
55+
args.func(args)
56+
except totp.BackendError as e:
57+
print('%s returned an error:\n%s' % (e.backend_name, e))
58+
raise SystemExit(-1)
59+
except KeyboardInterrupt:
60+
print()
61+
62+
@subcommand(
63+
'add',
64+
argument(
65+
'identifier',
66+
help='the identifier under the \'2fa\' folder where the key should be saved'),
67+
argument(
68+
'-u',
69+
'--uri',
70+
help='an optional otpauth uri to read the entry data from'),
71+
aliases=['-a'],
72+
description='Add a new TOTP entry to the database.',
73+
help='add a new TOTP entry to the database')
74+
def _cmd_add(args):
75+
if args.uri:
76+
add_uri(args.identifier, args.uri)
77+
else:
78+
add_interactive(args.identifier)
79+
80+
def input_shared_key():
81+
while True:
82+
try:
83+
shared_key = totp.normalize_secret(getpass.getpass('Shared key: '))
84+
b32decode(shared_key.upper())
85+
if shared_key == "":
86+
raise ValueError('The key entered was empty')
87+
return shared_key
88+
except ValueError as err:
89+
print(*err.args)
90+
91+
def add_interactive(path):
92+
token_length = input('Token length [%d]: ' % totp.DIGITS_DEFAULT)
93+
token_length = int(token_length) if token_length else totp.DIGITS_DEFAULT
94+
95+
shared_key = input_shared_key()
96+
97+
totp.add_pass_entry(path, token_length, shared_key)
98+
99+
def add_uri(path, uri):
100+
totp.add_pass_entry_from_uri(path, uri)
101+
102+
@subcommand(
103+
'show',
104+
argument(
105+
'-s',
106+
dest='offset_seconds',
107+
metavar='SECONDS',
108+
default=0,
109+
help='offset the clock by the given number of seconds'),
110+
argument(
111+
'identifier',
112+
help='the identifier by which the key can be found under the \'2fa\' folder'),
113+
description='Show the current TOTP token for a registered entry.',
114+
help='(default action) show the current TOTP token for a registered entry')
115+
def _cmd_show(args):
116+
totp.generate_token(args.identifier, seconds=args.offset_seconds)
117+
118+
if __name__ == '__main__':
119+
run()

0 commit comments

Comments
 (0)