2020-05-23 09:10:50 +02:00
# Extract two-factor authentication (2FA, TFA) secret keys from export QR codes of "Google Authenticator" app
2020-05-23 08:51:41 +02:00
#
# Usage:
# 1. Export the QR codes from "Google Authenticator" app
2021-02-13 16:58:30 +01:00
# 2. Read QR codes with QR code reader (e.g. with a second device)
2020-05-23 08:51:41 +02:00
# 3. Save the captured QR codes in a text file. Save each QR code on a new line. (The captured QR codes look like "otpauth-migration://offline?data=...")
# 4. Call this script with the file as input:
2022-09-08 21:11:49 +02:00
# python extract_otp_secret_keys.py example_export.txt
2020-05-23 08:51:41 +02:00
#
# Requirement:
# The protobuf package of Google for proto3 is required for running this script.
# pip install protobuf
#
# Optional:
# For printing QR codes, the qrcode module is required
# pip install qrcode
#
# Technical background:
# The export QR code of "Google Authenticator" contains the URL "otpauth-migration://offline?data=...".
# The data parameter is a base64 encoded proto3 message (Google Protocol Buffers).
#
2020-05-23 09:31:59 +02:00
# Command for regeneration of Python code from proto3 message definition file (only necessary in case of changes of the proto3 message definition):
2020-05-23 08:51:41 +02:00
# protoc --python_out=generated_python google_auth.proto
#
# References:
# Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial
# Template code: https://github.com/beemdevelopment/Aegis/pull/406
# Author: Scito (https://scito.ch)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import argparse
import base64
import fileinput
import sys
2022-06-29 06:18:51 +02:00
import csv
import json
2022-12-21 16:47:31 -08:00
import cv2
from qreader import QReader
2020-11-14 13:15:06 +01:00
from urllib.parse import parse_qs , urlencode , urlparse , quote
2022-09-09 13:13:13 +02:00
from os import path , makedirs
2022-09-03 16:12:28 +02:00
from re import compile as rcompile
2022-09-03 14:31:09 +02:00
import protobuf_generated_python.google_auth_pb2
2020-05-23 08:51:41 +02:00
2022-09-03 16:12:28 +02:00
2022-12-21 16:47:31 -08:00
verbose = False
quiet = True
2022-09-04 08:37:03 +02:00
def sys_main ():
main ( sys . argv [ 1 :])
2020-05-23 08:51:41 +02:00
2022-09-03 16:12:28 +02:00
2022-09-04 08:37:03 +02:00
def main ( sys_args ):
global verbose , quiet
2022-12-18 19:24:07 +01:00
# allow to use sys.stdout with with (avoid closing)
sys . stdout . close = lambda : None
2022-09-04 08:37:03 +02:00
args = parse_args ( sys_args )
2022-09-07 21:58:03 +02:00
verbose = args . verbose if args . verbose else 0
2022-09-04 08:37:03 +02:00
quiet = args . quiet
2020-05-23 08:51:41 +02:00
2022-09-04 08:37:03 +02:00
otps = extract_otps ( args )
write_csv ( args , otps )
2022-12-04 12:23:39 +01:00
write_keepass_csv ( args , otps )
2022-09-04 08:37:03 +02:00
write_json ( args , otps )
2020-05-23 08:51:41 +02:00
2022-09-03 16:12:28 +02:00
2022-09-03 14:31:09 +02:00
def parse_args ( sys_args ):
2022-12-18 21:34:24 +01:00
formatter = lambda prog : argparse . HelpFormatter ( prog , max_help_position = 52 )
2022-12-04 12:23:39 +01:00
arg_parser = argparse . ArgumentParser ( formatter_class = formatter )
2022-12-21 16:47:31 -08:00
arg_parser . add_argument ( 'infile' ,
help = "image file containing a QR code from a Google Authenticator export or a text file "
"or - for stdin with \" otpauth-migration://... \" URLs separated by newlines. Lines "
"starting with # are ignored." )
2022-12-18 19:24:07 +01:00
arg_parser . add_argument ( '--json' , '-j' , help = 'export json file or - for stdout' , metavar = ( 'FILE' ))
arg_parser . add_argument ( '--csv' , '-c' , help = 'export csv file or - for stdout' , metavar = ( 'FILE' ))
2022-12-21 16:47:31 -08:00
arg_parser . add_argument ( '--keepass' , '-k' , help = 'export totp/hotp csv file(s) for KeePass, - for stdout' ,
metavar = ( 'FILE' ))
arg_parser . add_argument ( '--printqr' , '-p' , help = 'print QR code(s) as text to the terminal (requires qrcode module)' ,
action = 'store_true' )
arg_parser . add_argument ( '--saveqr' , '-s' ,
help = 'save QR code(s) as images to the given folder (requires qrcode module)' ,
metavar = ( 'DIR' ))
2022-12-18 21:34:24 +01:00
output_group = arg_parser . add_mutually_exclusive_group ()
output_group . add_argument ( '--verbose' , '-v' , help = 'verbose output' , action = 'count' )
output_group . add_argument ( '--quiet' , '-q' , help = 'no stdout output, except output set by -' , action = 'store_true' )
2022-09-03 14:31:09 +02:00
args = arg_parser . parse_args ( sys_args )
2022-12-18 21:34:24 +01:00
if args . csv == '-' or args . json == '-' or args . keepass == '-' :
args . quiet = args . q = True
2022-09-03 14:31:09 +02:00
return args
2020-05-23 08:51:41 +02:00
2022-09-03 16:12:28 +02:00
2022-09-03 14:31:09 +02:00
def extract_otps ( args ):
global verbose , quiet
quiet = args . quiet
otps = []
2022-12-21 16:47:31 -08:00
lines = get_lines_from_file ( args . infile )
2022-09-03 14:31:09 +02:00
i = j = 0
2022-12-21 16:47:31 -08:00
for line in lines :
if verbose :
print ( line )
if line . startswith ( '#' ) or line == '' :
continue
i += 1
payload = get_payload_from_line ( line , i , args )
# pylint: disable=no-member
for raw_otp in payload . otp_parameters :
j += 1
if verbose :
print ( ' \n {} . Secret Key' . format ( j ))
secret = convert_secret_from_bytes_to_base32_str ( raw_otp . secret )
otp_type_enum = get_enum_name_by_number ( raw_otp , 'type' )
otp_type = get_otp_type_str_from_code ( raw_otp . type )
otp_url = build_otp_url ( secret , raw_otp )
otp = {
"name" : raw_otp . name ,
"secret" : secret ,
"issuer" : raw_otp . issuer ,
"type" : otp_type ,
"counter" : raw_otp . counter if raw_otp . type == 1 else None ,
"url" : otp_url
}
if not quiet :
print_otp ( otp )
if args . printqr :
print_qr ( args , otp_url )
if args . saveqr :
save_qr ( otp , args , j )
if not quiet :
print ()
otps . append ( otp )
2022-09-03 14:31:09 +02:00
return otps
2022-09-03 16:12:28 +02:00
2022-12-21 16:47:31 -08:00
def get_lines_from_file ( filepath ):
global verbose
# Check if this is an image file
if ( path . splitext ( filepath )[ 1 ][ 1 :] . lower () in ( 'bmp' , 'jpg' , 'jpeg' , 'png' , 'tif' , 'tiff' )):
# It's an image file, so try to read it as a QR Code
try :
decoder = QReader ()
if not path . isfile ( filepath ):
eprint ( ' \n ERROR: Input file provided is non-existent or not a file.'
' \n input file: {} ' . format ( filepath ))
return []
image = cv2 . imread ( filepath )
if image is None :
eprint ( ' \n ERROR: Unable to open file for reading. Please ensure that you have read access to the '
'file and that the file is a valid image file. \n input file: {} ' . format ( filepath ))
return []
decoded_text = decoder . detect_and_decode ( image = image )
if decoded_text is None :
eprint ( ' \n ERROR: Unable to read QR Code from file. \n input file: {} ' . format ( filepath ))
return []
return [ decoded_text ]
except Exception as e :
eprint ( ' \n ERROR: Encountered exception " {} ". \n input file: {} ' . format ( str ( e ), filepath ))
return []
else :
# Not an image file, so assume it's a text file and proceed as usual
lines = []
finput = fileinput . input ( filepath )
try :
for line in ( line . strip () for line in finput ):
if verbose :
print ( line )
if line . startswith ( '#' ) or line == '' :
continue
lines . append ( line )
finally :
finput . close ()
return lines
2022-09-04 08:37:03 +02:00
def get_payload_from_line ( line , i , args ):
2022-09-07 21:58:03 +02:00
global verbose
2022-09-04 08:37:03 +02:00
if not line . startswith ( 'otpauth-migration://' ):
2022-12-21 16:47:31 -08:00
eprint (
' \n WARN: line is not a otpauth-migration:// URL \n input file: {} \n line " {} " \n Probably a wrong file was given' . format (
args . infile , line ))
2022-09-04 08:37:03 +02:00
parsed_url = urlparse ( line )
2022-09-07 21:58:03 +02:00
if verbose > 1 : print ( ' \n DEBUG: parsed_url= {} ' . format ( parsed_url ))
2022-12-16 13:10:22 +01:00
try :
params = parse_qs ( parsed_url . query , strict_parsing = True )
2022-12-21 16:47:31 -08:00
except : # Not necessary for Python >= 3.11
2022-12-16 13:10:22 +01:00
params = []
2022-09-07 21:58:03 +02:00
if verbose > 1 : print ( ' \n DEBUG: querystring params= {} ' . format ( params ))
2022-09-04 08:37:03 +02:00
if 'data' not in params :
2022-12-21 16:47:31 -08:00
eprint (
' \n ERROR: no data query parameter in input URL \n input file: {} \n line " {} " \n Probably a wrong file was given' . format (
args . infile , line ))
2022-09-04 08:37:03 +02:00
sys . exit ( 1 )
2022-09-07 21:58:03 +02:00
data_base64 = params [ 'data' ][ 0 ]
if verbose > 1 : print ( ' \n DEBUG: data_base64= {} ' . format ( data_base64 ))
data_base64_fixed = data_base64 . replace ( ' ' , '+' )
if verbose > 1 : print ( ' \n DEBUG: data_base64_fixed= {} ' . format ( data_base64 ))
data = base64 . b64decode ( data_base64_fixed , validate = True )
2022-09-04 08:37:03 +02:00
payload = protobuf_generated_python . google_auth_pb2 . MigrationPayload ()
2022-12-16 12:43:32 +01:00
try :
payload . ParseFromString ( data )
except :
2022-12-18 19:24:07 +01:00
eprint ( ' \n ERROR: Cannot decode otpauth-migration migration payload.' )
eprint ( 'data= {} ' . format ( data_base64 ))
2022-12-21 16:47:31 -08:00
exit ( 1 )
2022-09-04 08:37:03 +02:00
if verbose :
print ( ' \n {} . Payload Line' . format ( i ), payload , sep = ' \n ' )
return payload
# https://stackoverflow.com/questions/40226049/find-enums-listed-in-python-descriptor-for-protobuf
def get_enum_name_by_number ( parent , field_name ):
field_value = getattr ( parent , field_name )
return parent . DESCRIPTOR . fields_by_name [ field_name ] . enum_type . values_by_number . get ( field_value ) . name
2022-12-04 12:23:39 +01:00
def get_otp_type_str_from_code ( otp_type ):
return 'totp' if otp_type == 2 else 'hotp'
2022-09-04 08:37:03 +02:00
def convert_secret_from_bytes_to_base32_str ( bytes ):
return str ( base64 . b32encode ( bytes ), 'utf-8' ) . replace ( '=' , '' )
def build_otp_url ( secret , raw_otp ):
url_params = { 'secret' : secret }
if raw_otp . type == 1 : url_params [ 'counter' ] = raw_otp . counter
if raw_otp . issuer : url_params [ 'issuer' ] = raw_otp . issuer
2022-12-21 16:47:31 -08:00
otp_url = 'otpauth:// {} / {} ?' . format ( get_otp_type_str_from_code ( raw_otp . type ), quote ( raw_otp . name )) + urlencode (
url_params )
2022-09-04 08:37:03 +02:00
return otp_url
def print_otp ( otp ):
2022-12-04 12:23:39 +01:00
print ( 'Name: {} ' . format ( otp [ 'name' ]))
print ( 'Secret: {} ' . format ( otp [ 'secret' ]))
if otp [ 'issuer' ]: print ( 'Issuer: {} ' . format ( otp [ 'issuer' ]))
print ( 'Type: {} ' . format ( otp [ 'type' ]))
if otp [ 'type' ] == 'hotp' :
print ( 'Counter: {} ' . format ( otp [ 'counter' ]))
2022-09-04 08:37:03 +02:00
if verbose :
print ( otp [ 'url' ])
def save_qr ( otp , args , j ):
2022-09-09 13:13:13 +02:00
dir = args . saveqr
if not ( path . exists ( dir )): makedirs ( dir , exist_ok = True )
2022-09-04 08:37:03 +02:00
pattern = rcompile ( r '[\W_]+' )
2022-09-09 13:08:35 +02:00
file_otp_name = pattern . sub ( '' , otp [ 'name' ])
file_otp_issuer = pattern . sub ( '' , otp [ 'issuer' ])
2022-12-21 16:47:31 -08:00
save_qr_file ( args , otp [ 'url' ],
' {} / {} - {}{} .png' . format ( dir , j , file_otp_name , '-' + file_otp_issuer if file_otp_issuer else '' ))
2022-09-04 08:37:03 +02:00
return file_otp_issuer
def save_qr_file ( args , data , name ):
from qrcode import QRCode
global verbose
qr = QRCode ()
qr . add_data ( data )
img = qr . make_image ( fill_color = 'black' , back_color = 'white' )
if verbose : print ( 'Saving to {} ' . format ( name ))
img . save ( name )
def print_qr ( args , data ):
from qrcode import QRCode
qr = QRCode ()
qr . add_data ( data )
qr . print_ascii ()
2022-09-03 14:31:09 +02:00
def write_csv ( args , otps ):
global verbose , quiet
if args . csv and len ( otps ) > 0 :
2022-12-18 19:24:07 +01:00
with open_file_or_stdout_for_csv ( args . csv ) as outfile :
2022-09-03 14:31:09 +02:00
writer = csv . DictWriter ( outfile , otps [ 0 ] . keys ())
writer . writeheader ()
writer . writerows ( otps )
2022-12-04 12:23:39 +01:00
if not quiet : print ( "Exported {} otps to csv {} " . format ( len ( otps ), args . csv ))
def write_keepass_csv ( args , otps ):
global verbose , quiet
if args . keepass and len ( otps ) > 0 :
has_totp = has_otp_type ( otps , 'totp' )
has_hotp = has_otp_type ( otps , 'hotp' )
otp_filename_totp = args . keepass if has_totp != has_hotp else add_pre_suffix ( args . keepass , "totp" )
otp_filename_hotp = args . keepass if has_totp != has_hotp else add_pre_suffix ( args . keepass , "hotp" )
count_totp_entries = 0
count_hotp_entries = 0
if has_totp :
2022-12-18 19:24:07 +01:00
with open_file_or_stdout_for_csv ( otp_filename_totp ) as outfile :
2022-12-04 12:23:39 +01:00
writer = csv . DictWriter ( outfile , [ "Title" , "User Name" , "TimeOtp-Secret-Base32" , "Group" ])
writer . writeheader ()
for otp in otps :
if otp [ 'type' ] == 'totp' :
writer . writerow ({
'Title' : otp [ 'issuer' ],
'User Name' : otp [ 'name' ],
'TimeOtp-Secret-Base32' : otp [ 'secret' ] if otp [ 'type' ] == 'totp' else None ,
'Group' : "OTP/ {} " . format ( otp [ 'type' ] . upper ())
})
count_totp_entries += 1
if has_hotp :
2022-12-18 19:24:07 +01:00
with open_file_or_stdout_for_csv ( otp_filename_hotp ) as outfile :
2022-12-21 16:47:31 -08:00
writer = csv . DictWriter ( outfile ,
[ "Title" , "User Name" , "HmacOtp-Secret-Base32" , "HmacOtp-Counter" , "Group" ])
2022-12-04 12:23:39 +01:00
writer . writeheader ()
for otp in otps :
if otp [ 'type' ] == 'hotp' :
writer . writerow ({
'Title' : otp [ 'issuer' ],
'User Name' : otp [ 'name' ],
'HmacOtp-Secret-Base32' : otp [ 'secret' ] if otp [ 'type' ] == 'hotp' else None ,
'HmacOtp-Counter' : otp [ 'counter' ] if otp [ 'type' ] == 'hotp' else None ,
'Group' : "OTP/ {} " . format ( otp [ 'type' ] . upper ())
})
count_hotp_entries += 1
if not quiet :
2022-12-21 16:47:31 -08:00
if count_totp_entries > 0 : print (
"Exported {} totp entries to keepass csv file {} " . format ( count_totp_entries , otp_filename_totp ))
if count_hotp_entries > 0 : print (
"Exported {} hotp entries to keepass csv file {} " . format ( count_hotp_entries , otp_filename_hotp ))
2022-09-03 14:31:09 +02:00
2022-09-03 16:12:28 +02:00
2022-09-03 14:31:09 +02:00
def write_json ( args , otps ):
global verbose , quiet
if args . json :
2022-12-18 19:24:07 +01:00
with open_file_or_stdout ( args . json ) as outfile :
2022-09-03 16:12:28 +02:00
json . dump ( otps , outfile , indent = 4 )
2022-12-04 12:23:39 +01:00
if not quiet : print ( "Exported {} otp entries to json {} " . format ( len ( otps ), args . json ))
def has_otp_type ( otps , otp_type ):
for otp in otps :
if otp [ 'type' ] == otp_type :
return True
return False
def add_pre_suffix ( file , pre_suffix ):
'''filename.ext, pre -> filename.pre.ext'''
name , ext = path . splitext ( file )
return name + "." + pre_suffix + ( ext if ext else "" )
2022-09-03 14:31:09 +02:00
2022-09-03 16:12:28 +02:00
2022-12-18 19:24:07 +01:00
def open_file_or_stdout ( filename ):
'''stdout is denoted as "-".
Note: Set before the following line:
sys.stdout.close = lambda: None'''
2022-12-19 16:39:28 +01:00
return open ( filename , "w" , encoding = 'utf-8' ) if filename != '-' else sys . stdout
2022-12-18 19:24:07 +01:00
def open_file_or_stdout_for_csv ( filename ):
'''stdout is denoted as "-".
newline=''
Note: Set before the following line:
sys.stdout.close = lambda: None'''
2022-12-19 16:39:28 +01:00
return open ( filename , "w" , encoding = 'utf-8' , newline = '' ) if filename != '-' else sys . stdout
2022-12-18 19:24:07 +01:00
def eprint ( * args , ** kwargs ):
'''Print to stderr.'''
print ( * args , file = sys . stderr , ** kwargs )
2022-09-03 14:31:09 +02:00
if __name__ == '__main__' :
sys_main ()