#!/usr/bin/env python3 # -*- encoding: utf-8 -*- # # This script is licensed under GNU GPL version 2.0 or above # (c) 2023 Antonio J. Delgado """Convert andOTP json backups to FreeOTP format""" import sys import os import logging from logging.handlers import SysLogHandler import click import click_config_file import json # import base64 from urllib.parse import quote class andotp2freeotp: def __init__(self, **kwargs): self.config = kwargs if 'log_file' not in kwargs or kwargs['log_file'] is None: self.config['log_file'] = os.path.join( os.environ.get( 'HOME', os.environ.get( 'USERPROFILE', os.getcwd() ) ), 'log', 'andotp2freeotp.log' ) self._init_log() self._read_file() if self.config['format'] == 'json': self._log.error( """Sorry JSON format is not implemented yet. Pending on https://github.com/freeotp/freeotp-android/issues/368""" ) self._convert_to_json_data() self._write_json_file() elif self.config['format'] == 'uris': self._convert_to_uris_data() self._write_uris_file() def _read_file(self): with open(self.config['input'], 'r') as filepointer: self.data = json.load(filepointer) def _convert_to_json_data(self): self.new_data = { "tokenOrder": [], "tokens": [] } for item in self.data: new_item = { "algo": item['algorithm'], "counter": 0, "digits": item['digits'], "issuerExt": item['issuer'], "label": item['label'], "period": item['period'], # "secret": [ # -32, # -17, # -127, # -72, # -1, # -57, # -105, # 22, # 53, # -54 # ], # 73G5LA12XF6IYUQF "type": item['type'], # This is the way around # "secret": base64.b32encode( # bytes(x & 0xff for x in item["secret"]) # ).decode("utf8") } self.new_data['tokens'].append(new_item) self.new_data['tokenOrder'].append( f"{item['issuer']}:{item['label']}" ) def _write_json_file(self): if self.config['output'] == '-': json.dumps(self.new_data, indent=2) else: with open(self.config['output'], 'w') as filepointer: json.dump(self.new_data, filepointer) def _convert_to_uris_data(self): self.uris = [] for item in self.data: uri_type = item['type'].lower() label = quote(f"{item['label']}@{item['issuer']}") uri = f"otpauth://{uri_type}/{label}?secret={item['secret']}" other_fields = [ 'algorithm', 'digits', 'period', 'issuer', 'counter' ] for field in other_fields: if field in item and item[field] != '': uri += f"&{field}={item[field]}" self.uris.append(uri) def _write_uris_file(self): if self.config['output'] == '-': for uri in self.uris: print(uri) else: with open(self.config['output'], 'w') as filepointer: for uri in self.uris: filepointer.write(f"{uri}\n") def _init_log(self): ''' Initialize log object ''' self._log = logging.getLogger("andotp2freeotp") self._log.setLevel(logging.DEBUG) sysloghandler = SysLogHandler() sysloghandler.setLevel(logging.DEBUG) self._log.addHandler(sysloghandler) streamhandler = logging.StreamHandler(sys.stdout) streamhandler.setLevel( logging.getLevelName(self.config.get("debug_level", 'INFO')) ) self._log.addHandler(streamhandler) if 'log_file' in self.config: log_file = self.config['log_file'] else: home_folder = os.environ.get( 'HOME', os.environ.get('USERPROFILE', '') ) log_folder = os.path.join(home_folder, "log") log_file = os.path.join(log_folder, "andotp2freeotp.log") if not os.path.exists(os.path.dirname(log_file)): os.mkdir(os.path.dirname(log_file)) filehandler = logging.handlers.RotatingFileHandler( log_file, maxBytes=102400000 ) # create formatter formatter = logging.Formatter( '%(asctime)s %(name)-12s %(levelname)-8s %(message)s' ) filehandler.setFormatter(formatter) filehandler.setLevel(logging.DEBUG) self._log.addHandler(filehandler) return True @click.command() @click.option( "--debug-level", "-d", default="INFO", type=click.Choice( ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"], case_sensitive=False, ), help='Set the debug level for the standard output.' ) @click.option('--log-file', '-l', help="File to store all debug messages.") # @click.option("--dummy","-n", is_flag=True, # help="Don't do anything, just show what would be done.") @click.option( '--input', '-i', required=True, help='JSON file from andOTP to convert' ) @click.option( '--output', '-o', required=True, help='JSON/URI file to write for FreeOTP' ) @click.option( '--format', '-f', default='uris', type=click.Choice( ['uris', 'json'], case_sensitive=False, ), help='Format for the output', ) @click_config_file.configuration_option() def __main__(**kwargs): return andotp2freeotp(**kwargs) if __name__ == "__main__": __main__()