<commit_before>import time
import json
import shlex
import base64
import hashlib
import getpass
import logging

from pathlib import PurePosixPath
from xml.etree import ElementTree
from collections import namedtuple

import cryptography
from cryptography.hazmat.primitives.ciphers.aead import AESGCM


from .contrib import open_remote_sqlite_database
from .otp import TOTPAccount, HOTPAccount, SteamAccount, lenient_base32_decode


LOGGER = logging.getLogger(__name__)


SupportedApp = namedtuple('SupportedApp', ['name', 'simple_name', 'extractor'])
SUPPORTED_APPS = []


def supported_app(name):
    '''
    Simple decorator to populate the SUPPORTED_APPS list
    '''

    simple_name = name.lower().replace('+', '_plus').replace(' ', '_')

    def inner(extractor):
        SUPPORTED_APPS.append(SupportedApp(name, simple_name, extractor))

        return extractor

    return inner


@supported_app('Authy')
def read_authy_accounts(adb):
    for pref_file in ['com.authy.storage.tokens.authenticator.xml', 'com.authy.storage.tokens.authy.xml']:
        try:
            f = adb.read_file(adb.data_root/'com.authy.authy/shared_prefs'/pref_file)
        except FileNotFoundError:
            continue

        accounts = json.loads(ElementTree.parse(f).find('string').text)

        for account in accounts:
            if 'decryptedSecret' in account:
                period = 30
                dec_secret = account['decryptedSecret']

                # Authy strips all digits that aren't Base32
                fixed_secret = ''.join(c for c in dec_secret if c.upper() in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')

                if dec_secret.upper() != fixed_secret.upper():
                    LOGGER.warning("Transformed Authy secret %s into %s", dec_secret, fixed_secret)

                secret = lenient_base32_decode(fixed_secret.upper())
            else:
                period = 10
                secret = bytes.fromhex(account['secretSeed'])

            # Authy stores its secrets in the same format as they're provided so we have to guess their type
            yield TOTPAccount(account['name'], secret=secret, digits=account['digits'], period=period)


def _read_freeotp_accounts(adb, *, package_name):
    try:
        f = adb.read_file(adb.data_root/package_name/'shared_prefs/tokens.xml')
    except FileNotFoundError:
        return

    for string in ElementTree.parse(f).findall('string'):
        account = json.loads(string.text)

        # <string name="tokenOrder"> doesn't contain an account
        if 'secret' not in account:
            continue

        secret = bytes([b & 0xFF for b in account['secret']])
        issuer = account.get('issuerAlt') or account['issuerExt'] or None
        name = account['label']

        if account['type'] == 'TOTP':
            yield TOTPAccount(name, secret, issuer=issuer, digits=account['digits'], period=account['period'], algorithm=account['algo'])
        elif account['type'] == 'HOTP':
            yield HOTPAccount(name, secret, issuer=issuer, digits=account['digits'], counter=account['counter'], algorithm=account['algo'])
        else:
            LOGGER.warning('Unknown FreeOTP account type: %s', account['type'])


@supported_app('FreeOTP')
def read_freeotp_accounts(adb):
    return _read_freeotp_accounts(adb, package_name='org.fedorahosted.freeotp')


@supported_app('FreeOTP+')
def read_freeotp_plus_accounts(adb):
    return _read_freeotp_accounts(adb, package_name='org.liberty.android.freeotpplus')


@supported_app('Duo')
def read_duo_accounts(adb):
    try:
        f = adb.read_file(adb.data_root/'com.duosecurity.duomobile/files/duokit/accounts.json')
    except FileNotFoundError:
        return

    for account in json.load(f):
        secret = base64.b32decode(account['otpGenerator']['otpSecret'])

        if 'counter' in account['otpGenerator']:
            yield HOTPAccount(account['name'], secret, counter=account['otpGenerator']['counter'])
        else:
            yield TOTPAccount(account['name'], secret)


@supported_app('Google Authenticator')
def read_google_authenticator_accounts(adb):
    try:
        with open_remote_sqlite_database(adb, adb.data_root/'com.google.android.apps.authenticator2/databases/databases') as connection:
            cursor = connection.cursor()
            cursor.execute('SELECT * FROM accounts;')

            for row in cursor.fetchall():
                row = dict(row)

                name = row.get('name') or row.get('original_name')
                email = row.get('email')
                issuer = row.get('issuer')

                if not name:
                    name = email
                elif not issuer:
                    issuer = email

                secret = lenient_base32_decode(row['secret'])

                if row['type'] == 0:
                    yield TOTPAccount(name, secret, issuer=issuer)
                elif row['type'] == 1:
                    yield HOTPAccount(name, secret, issuer=issuer, counter=row['counter'])
                else:
                    LOGGER.warning('Unknown Google Authenticator account type: %s', row['type'])
    except FileNotFoundError:
        return


@supported_app('Microsoft Authenticator')
def read_microsoft_authenticator_accounts(adb):
    try:
        with open_remote_sqlite_database(adb, adb.data_root/'com.azure.authenticator/databases/PhoneFactor') as connection:
            cursor = connection.cursor()
            cursor.execute('SELECT * FROM accounts;')

            for row in cursor.fetchall():<commit_msg>Uppercase Google Authenticator secrets (fixes #36)<commit_after>import time
import json
import shlex
import base64
import hashlib
import getpass
import logging

from pathlib import PurePosixPath
from xml.etree import ElementTree
from collections import namedtuple

import cryptography
from cryptography.hazmat.primitives.ciphers.aead import AESGCM


from .contrib import open_remote_sqlite_database
from .otp import TOTPAccount, HOTPAccount, SteamAccount, lenient_base32_decode


LOGGER = logging.getLogger(__name__)


SupportedApp = namedtuple('SupportedApp', ['name', 'simple_name', 'extractor'])
SUPPORTED_APPS = []


def supported_app(name):
    '''
    Simple decorator to populate the SUPPORTED_APPS list
    '''

    simple_name = name.lower().replace('+', '_plus').replace(' ', '_')

    def inner(extractor):
        SUPPORTED_APPS.append(SupportedApp(name, simple_name, extractor))

        return extractor

    return inner


@supported_app('Authy')
def read_authy_accounts(adb):
    for pref_file in ['com.authy.storage.tokens.authenticator.xml', 'com.authy.storage.tokens.authy.xml']:
        try:
            f = adb.read_file(adb.data_root/'com.authy.authy/shared_prefs'/pref_file)
        except FileNotFoundError:
            continue

        accounts = json.loads(ElementTree.parse(f).find('string').text)

        for account in accounts:
            if 'decryptedSecret' in account:
                period = 30
                dec_secret = account['decryptedSecret']

                # Authy strips all digits that aren't Base32
                fixed_secret = ''.join(c for c in dec_secret if c.upper() in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567')

                if dec_secret.upper() != fixed_secret.upper():
                    LOGGER.warning("Transformed Authy secret %s into %s", dec_secret, fixed_secret)

                secret = lenient_base32_decode(fixed_secret.upper())
            else:
                period = 10
                secret = bytes.fromhex(account['secretSeed'])

            # Authy stores its secrets in the same format as they're provided so we have to guess their type
            yield TOTPAccount(account['name'], secret=secret, digits=account['digits'], period=period)


def _read_freeotp_accounts(adb, *, package_name):
    try:
        f = adb.read_file(adb.data_root/package_name/'shared_prefs/tokens.xml')
    except FileNotFoundError:
        return

    for string in ElementTree.parse(f).findall('string'):
        account = json.loads(string.text)

        # <string name="tokenOrder"> doesn't contain an account
        if 'secret' not in account:
            continue

        secret = bytes([b & 0xFF for b in account['secret']])
        issuer = account.get('issuerAlt') or account['issuerExt'] or None
        name = account['label']

        if account['type'] == 'TOTP':
            yield TOTPAccount(name, secret, issuer=issuer, digits=account['digits'], period=account['period'], algorithm=account['algo'])
        elif account['type'] == 'HOTP':
            yield HOTPAccount(name, secret, issuer=issuer, digits=account['digits'], counter=account['counter'], algorithm=account['algo'])
        else:
            LOGGER.warning('Unknown FreeOTP account type: %s', account['type'])


@supported_app('FreeOTP')
def read_freeotp_accounts(adb):
    return _read_freeotp_accounts(adb, package_name='org.fedorahosted.freeotp')


@supported_app('FreeOTP+')
def read_freeotp_plus_accounts(adb):
    return _read_freeotp_accounts(adb, package_name='org.liberty.android.freeotpplus')


@supported_app('Duo')
def read_duo_accounts(adb):
    try:
        f = adb.read_file(adb.data_root/'com.duosecurity.duomobile/files/duokit/accounts.json')
    except FileNotFoundError:
        return

    for account in json.load(f):
        secret = base64.b32decode(account['otpGenerator']['otpSecret'])

        if 'counter' in account['otpGenerator']:
            yield HOTPAccount(account['name'], secret, counter=account['otpGenerator']['counter'])
        else:
            yield TOTPAccount(account['name'], secret)


@supported_app('Google Authenticator')
def read_google_authenticator_accounts(adb):
    try:
        with open_remote_sqlite_database(adb, adb.data_root/'com.google.android.apps.authenticator2/databases/databases') as connection:
            cursor = connection.cursor()
            cursor.execute('SELECT * FROM accounts;')

            for row in cursor.fetchall():
                row = dict(row)

                name = row.get('name') or row.get('original_name')
                email = row.get('email')
                issuer = row.get('issuer')

                if not name:
                    name = email
                elif not issuer:
                    issuer = email

                # Google Authenticator's Base-32 decoder is case-insensitive
                secret = lenient_base32_decode(row['secret'].upper())

                if row['type'] == 0:
                    yield TOTPAccount(name, secret, issuer=issuer)
                elif row['type'] == 1:
                    yield HOTPAccount(name, secret, issuer=issuer, counter=row['counter'])
                else:
                    LOGGER.warning('Unknown Google Authenticator account type: %s', row['type'])
    except FileNotFoundError:
        return


@supported_app('Microsoft Authenticator')
def read_microsoft_authenticator_accounts(adb):
    try:
        with open_remote_sqlite_database(adb, adb.data_root/'com.azure.authenticator/databases/PhoneFactor') as connection:
            cursor = connection.cursor()
            cursor.execute('SELECT * FROM accounts;')

            for row in cursor.fetchall():
{'fraction_non_alphanumeric': 0.10982142857142857, 'fraction_numerical': 0.005357142857142857, 'mean_word_length': 2.870421561852108, 'pattern_counts': {'":': 0, '<': 5, '<?xml version=': 0, '>': 5, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 8}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '952543', 'n_tokens_mistral': 3397, 'n_tokens_neox': 3233, 'n_words': 714}