The Python Command-line Reimplementaion of Google Authenticator
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
#======================================================================
#
# googauth.py - Google Authenticator
#
# Created by skywind on 2018/08/10
# Last Modified: 2018/08/10 21:50
#
#======================================================================
from __future__ import print_function, unicode_literals
import sys
import time
import base64
import hashlib
import hmac
import struct
import os


#----------------------------------------------------------------------
# python 2/3 compatible
#----------------------------------------------------------------------
if sys.version_info[0] >= 3:
    long = int
    unicode = str
    xrange = range


#----------------------------------------------------------------------
# validate
#----------------------------------------------------------------------
def validate_secret(secret):
    token = secret.replace(' ', '').upper()
    try:
        base64.b32decode(token)
    except:
        return False
    return True


#----------------------------------------------------------------------
# generate verification code from secret key and value 
#----------------------------------------------------------------------
def generate_code(secret, value = None):
    if value is None:
        value = int(time.time() / 30)
    value = struct.pack('>q', value)

    token = secret.replace(' ', '').upper()
    
    try:
        secretkey = base64.b32decode(token)
    except:
        return 'BASE32-DECODING-ERROR'

    hash = hmac.new(secretkey, value, hashlib.sha1).digest()

    offset = struct.unpack('>B', hash[-1:])[0] & 0xf
    truncated_hash = hash[offset:offset + 4]

    truncated_hash = struct.unpack('>L', truncated_hash)[0]
    truncated_hash &= 0x7fffffff
    truncated_hash %= 1000000

    return '%06d' % truncated_hash


#----------------------------------------------------------------------
# counter based code varification
#----------------------------------------------------------------------
def verify_counter_based(secret, code, counter, window = 3):
    if (not isinstance(code, str)) and (not isinstance(code, unicode)):
        raise TypeError('code must be a string')

    for offset in xrange(1, window + 1):
        valid_code = generate_code(secret, counter + offset)
        if code == valid_code:
            return counter + offset
    
    return None


#----------------------------------------------------------------------
# time based code verification
#----------------------------------------------------------------------
def verify_time_based(secret, code, window = 3):
    if (not isinstance(code, str)) and (not isinstance(code, unicode)):
        raise TypeError('code must be a string')

    epoch = int(time.time() / 30)

    for offset in xrange(-(window // 2), window - (window // 2)):
        valid_code = generate_code(secret, epoch + offset)
        if code == valid_code:
            return epoch + offset
    
    return None


#----------------------------------------------------------------------
# generate_secretkey
#----------------------------------------------------------------------
def generate_secret_key(length = 16):

    def _generate_random_bytes():
        sha_hash = hashlib.sha512()
        sha_hash.update(os.urandom(8192))
        byte_hash = sha_hash.digest()

        for i in xrange(6):
            sha_hash = hashlib.sha512()
            sha_hash.update(byte_hash)
            byte_hash = sha_hash.digest()

        return byte_hash
    
    if length < 8 or length > 128:
        raise TypeError('Secret key length is invalid.')

    byte_hash = _generate_random_bytes()
    if length > 102:
        byte_hash += _generate_random_bytes()
    
    text = base64.b32encode(byte_hash)[:length]
    text = str(text.decode('latin1'))

    return text


#----------------------------------------------------------------------
# get url
#----------------------------------------------------------------------
def get_otpauth_url(user, domain, secret):
    return 'otpauth://totp/' + user + '@' + domain + '?secret=' + secret


#----------------------------------------------------------------------
# barcode 
#----------------------------------------------------------------------
def get_barcode_url(user, domain, secret):
    import urllib
    if sys.version_info[0] < 3:
        from urllib import urlencode
    else:
        from urllib.parse import urlencode
    url = 'https://www.google.com/chart?chs=200x200&chld=M|0&cht=qr&'
    opt_url = get_otpauth_url(user, domain, secret)
    url += urlencode({'chl': opt_url})
    return url


#----------------------------------------------------------------------
# tabulify: style = 0, 1, 2
#----------------------------------------------------------------------
def tabulify (rows, style = 0):
    colsize = {}
    maxcol = 0
    output = []
    if not rows:
        return ''
    for row in rows:
        maxcol = max(len(row), maxcol)
        for col, text in enumerate(row):
            text = str(text)
            size = len(text)
            if col not in colsize:
                colsize[col] = size
            else:
                colsize[col] = max(size, colsize[col])
    if maxcol <= 0:
        return ''
    def gettext(row, col):
        csize = colsize[col]
        if row >= len(rows):
            return ' ' * (csize + 2)
        row = rows[row]
        if col >= len(row):
            return ' ' * (csize + 2)
        text = str(row[col])
        padding = 2 + csize - len(text)
        pad1 = 1
        pad2 = padding - pad1
        return (' ' * pad1) + text + (' ' * pad2)
    if style == 0:
        for y, row in enumerate(rows):
            line = ''.join([ gettext(y, x) for x in xrange(maxcol) ])
            output.append(line)
    elif style == 1:
        if rows:
            newrows = rows[:1]
            head = [ '-' * colsize[i] for i in xrange(maxcol) ]
            newrows.append(head)
            newrows.extend(rows[1:])
            rows = newrows
        for y, row in enumerate(rows):
            line = ''.join([ gettext(y, x) for x in xrange(maxcol) ])
            output.append(line)
    elif style == 2:
        sep = '+'.join([ '-' * (colsize[x] + 2) for x in xrange(maxcol) ])
        sep = '+' + sep + '+'
        for y, row in enumerate(rows):
            output.append(sep)
            line = '|'.join([ gettext(y, x) for x in xrange(maxcol) ])
            output.append('|' + line + '|')
        output.append(sep)
    return '\n'.join(output)


#----------------------------------------------------------------------
# load ini
#----------------------------------------------------------------------
def load_ini(ininame, codec = None):
    try:
        content = open(ininame, 'rb').read()
    except IOError:
        content = b''
    if content[:3] == b'\xef\xbb\xbf':
        text = content[3:].decode('utf-8')
    elif codec is not None:
        text = content.decode(codec, 'ignore')
    else:
        codec = sys.getdefaultencoding()
        text = None
        for name in [codec, 'gbk', 'utf-8']:
            try:
                text = content.decode(name)
                break
            except:
                pass
        if text is None:
            text = content.decode('utf-8', 'ignore')
    if sys.version_info[0] < 3:
        import StringIO
        import ConfigParser
        sio = StringIO.StringIO(text)
        cp = ConfigParser.ConfigParser()
        cp.readfp(sio)
    else:
        import configparser
        cp = configparser.ConfigParser()
        cp.read_string(text)
    config = {}
    for sect in cp.sections():
        for key, val in cp.items(sect):
            lowsect, lowkey = sect.lower(), key.lower()
            config.setdefault(lowsect, {})[lowkey] = val
    return config


#----------------------------------------------------------------------
# list code
#----------------------------------------------------------------------
def list_code(table, cont):
    while True:
        current = int(time.time())
        epoch = current // 30
        life = 30 - current % 30
        rows = []
        rows.append([ 'User', 'Domain', 'Code', 'Life Time' ])
        for record in table:
            secret = record[0]
            user = record[1]
            domain = record[2]
            code = generate_code(secret, epoch)
            rows.append([ user, domain, code, '  %d (s)'%life ])
        style = os.environ.get('GOOGAUTH_STYLE', 2)
        print(tabulify(rows, int(style)))
        # print()
        if not cont:
            break
        print('press Ctrl+C to break ...')
        try:
            time.sleep(1)
        except KeyboardInterrupt:
            print()
            break
        print()
    return 0


#----------------------------------------------------------------------
# main
#----------------------------------------------------------------------
def main(argv = None):
    argv = argv and argv or sys.argv
    argv = [ n for n in argv ]
    if len(argv) <= 1:
        prog = argv[0]
        print('usage: %s <operation> [...]'%prog)
        print('operations:')
        head = '    %s '%prog
        print(head, '{-c --create} [user] [domain]')
        print(head, '{-v --verify} secret code')
        print(head, '{-d --display} secret')
        print(head, '{-l --list} filename [--continue]')
        return 0
    cmd = argv[1]
    if cmd in ('-c', '--create'):
        key = generate_secret_key()
        print('secret:', key)
        user = len(argv) > 2 and argv[2] or ''
        domain = len(argv) > 3 and argv[3] or ''
        print('url:', get_otpauth_url(user, domain, key))
        print('barcode:', get_barcode_url(user, domain, key))
        return 0
    if cmd in ('-v', '--verify'):
        if len(argv) < 4:
            print('require secret and code parameters')
            return 1
        key = argv[2]
        code = argv[3]
        if not verify_time_based(key, code):
            print('verification failed')
            return 2
        print('verification succeeded')
        return 0
    if cmd in ('-d', '--display'):
        if len(argv) < 3:
            print('require secret parameter')
            return 1
        key = argv[2]
        print(generate_code(key))
        return 0
    if cmd in ('-l', '--list'):
        if len(argv) < 3:
            print('require file name')
            return 1
        name = argv[2]
        if '~' in name:
            name = os.path.expanduser(name)
        name = os.path.abspath(name)
        if not os.path.exists(name):
            print('can not read: %s'%name)
            return 255
        cont = False
        if len(argv) >= 4:
            if argv[3] in ('-', '-c', '--continue'):
                cont = True
        config = load_ini(name)
        keys = []
        for key in config:
            text = str(key)
            try:
                num = int(key)
                text = num
            except:
                pass
            keys.append((text, key))
        keys.sort()
        table = []
        for _, key in keys:
            cfg = config[key]
            if not cfg:
                continue
            if 'secret' not in cfg:
                continue
            secret = cfg['secret'].strip()
            user = cfg.get('user', '').strip()
            domain = cfg.get('domain', '').strip()
            table.append((secret, user, domain))
        list_code(table, cont)
        return 0
    print('unknown operation')
    return 1



#----------------------------------------------------------------------
# testing case
#----------------------------------------------------------------------
if __name__ == '__main__':
    def test1():
        key = generate_secret_key()
        code = generate_code(key)
        print(code)
        print(verify_time_based(key, code))
        print(generate_secret_key())
        print(get_otpauth_url('skywind3000', 'uex', key))
        print(get_barcode_url('skywind3000', 'uex', key))
        return 0
    def test2():
        key = generate_secret_key()
        code = generate_code(key)
        argv = [sys.argv[0], '-v', key, code]
        main(argv)
        print(code)
        main([sys.argv[0], '-d', key])
        return 0
    def test3():
        rows = []
        rows.append(['ID', 'Name', 'Score'])
        rows.append(['1', 'Lin Wei', '10'])
        rows.append(['2', 'Zhang Jia', '20'])
        rows.append([100, 'Cheng Jing Yi', '100'])
        rows.append([102, 'Li Lei', '99'])
        print(tabulify(rows, 2))
        # tabulify([[],[]])
        return 0
    def test4():
        args = [ sys.argv[0], '-l', '~/.config/googauth.ini', '-c' ]
        main(args)
        return 0
    # test1()
    argv = None
    # argv = [sys.argv[0], '-v', '']
    # test4()
    main(argv)




googauth.py
skywind3000/googauth