22#
33# Print a TOTP token getting the shared key from pass(1).
44
5- import getpass
65import os
76import platform
87import re
98import subprocess
109import sys
11- from base64 import b32decode
10+ import urllib . parse
1211
1312import 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+
1637def 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 = "{}\n digits: {}\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
6273def 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
123131def 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+ }
0 commit comments