Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable GitHub workflow and add tests #13

Merged
merged 4 commits into from
Sep 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[flake8]
ignore =
E701
exclude =
protobuf_generated_python
__pycache__
per-file-ignores =
extract_otp_secret_keys.py: F821, F401
35 changes: 35 additions & 0 deletions .github/workflows/extract_otp_secret_keys.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: extract_otp_secret_keys

on: [push]

jobs:
build:

runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.x"]

steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=200 --statistics
- name: Test with pytest
run: |
pytest
- name: Test with unittest
run: |
python -m unittest
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ venv/
!devbox.json
!example_output.json
!example_output.csv
!.github/
!.flake8
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Extract two-factor authentication (2FA, TFA) secret keys from export QR codes of "Google Authenticator" app

## Testing Status

[![extract_otp_secret_keys](https://github.com/scito/extract_otp_secret_keys/actions/workflows/extract_otp_secret_keys.yml/badge.svg)](https://github.com/scito/extract_otp_secret_keys/actions/workflows/extract_otp_secret_keys.yml)

## Usage

1. Export the QR codes from "Google Authenticator" app
Expand Down Expand Up @@ -63,7 +67,9 @@ Install [devbox](https://github.com/jetpack-io/devbox), which is a wrapper for n
devbox shell
```

## Unit Tests
## Tests

### unittest

There are basic unit tests, see `unittest_extract_otp_secret_keys.py`.

Expand All @@ -72,3 +78,18 @@ Run unit tests:
```
python -m unittest
```

### PyTest

There are basic pytests, see `test_extract_otp_secret_keys.py`.

Run pytests:

```
pytest unittest
```
or

```
python -m pytest
```
23 changes: 17 additions & 6 deletions extract_otp_secret_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,20 @@
import json
from urllib.parse import parse_qs, urlencode, urlparse, quote
from os import path, mkdir
from re import sub, compile as rcompile
from re import compile as rcompile
import protobuf_generated_python.google_auth_pb2


# 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


def convert_secret_from_bytes_to_base32_str(bytes):
return str(base64.b32encode(bytes), 'utf-8').replace('=', '')


def save_qr(data, name):
global verbose
qr = QRCode()
Expand All @@ -68,11 +71,13 @@ def save_qr(data, name):
if verbose: print('Saving to {}'.format(name))
img.save(name)


def print_qr(data):
qr = QRCode()
qr.add_data(data)
qr.print_ascii()


def parse_args(sys_args):
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument('--verbose', '-v', help='verbose output', action='store_true')
Expand All @@ -88,9 +93,11 @@ def parse_args(sys_args):
sys.exit(1)
return args


def sys_main():
main(sys.argv[1:])


def main(sys_args):
global verbose, quiet
args = parse_args(sys_args)
Expand All @@ -102,6 +109,7 @@ def main(sys_args):
write_csv(args, otps)
write_json(args, otps)


def extract_otps(args):
global verbose, quiet
quiet = args.quiet
Expand All @@ -115,7 +123,7 @@ def extract_otps(args):
if not line.startswith('otpauth-migration://'): print('\nWARN: line is not a otpauth-migration:// URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(args.infile, line))
parsed_url = urlparse(line)
params = parse_qs(parsed_url.query)
if not 'data' in params:
if 'data' not in params:
print('\nERROR: no data query parameter in input URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(args.infile, line))
sys.exit(1)
data_encoded = params['data'][0]
Expand All @@ -135,15 +143,15 @@ def extract_otps(args):
if otp.issuer and not quiet: print('Issuer: {}'.format(otp.issuer))
otp_type = get_enum_name_by_number(otp, 'type')
if not quiet: print('Type: {}'.format(otp_type))
url_params = { 'secret': secret }
url_params = {'secret': secret}
if otp.type == 1: url_params['counter'] = otp.counter
if otp.issuer: url_params['issuer'] = otp.issuer
otp_url = 'otpauth://{}/{}?'.format('totp' if otp.type == 2 else 'hotp', quote(otp.name)) + urlencode(url_params)
if verbose: print(otp_url)
if args.printqr:
print_qr(otp_url)
if args.saveqr:
if not(path.exists('qr')): mkdir('qr')
if not (path.exists('qr')): mkdir('qr')
pattern = rcompile(r'[\W_]+')
file_otp_name = pattern.sub('', otp.name)
file_otp_issuer = pattern.sub('', otp.issuer)
Expand All @@ -156,9 +164,10 @@ def extract_otps(args):
"issuer": otp.issuer,
"type": otp_type,
"url": otp_url
})
})
return otps


def write_csv(args, otps):
global verbose, quiet
if args.csv and len(otps) > 0:
Expand All @@ -168,12 +177,14 @@ def write_csv(args, otps):
writer.writerows(otps)
if not quiet: print("Exported {} otps to csv".format(len(otps)))


def write_json(args, otps):
global verbose, quiet
if args.json:
with open(args.json, "w") as outfile:
json.dump(otps, outfile, indent = 4)
json.dump(otps, outfile, indent=4)
if not quiet: print("Exported {} otp entries to json".format(len(otps)))


if __name__ == '__main__':
sys_main()
83 changes: 83 additions & 0 deletions test_extract_otp_secret_keys_pytest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# pytest for extract_otp_secret_keys.py

# Run tests:
# pytest

# 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 csv
import json
import os

import extract_otp_secret_keys


def test_extract_csv():
# Arrange
cleanup()

# Act
extract_otp_secret_keys.main(['-q', '-c', 'test_example_output.csv', 'example_export.txt'])

# Assert
expected_csv = read_csv('example_output.csv')
actual_csv = read_csv('test_example_output.csv')

assert actual_csv == expected_csv

# Clean up
cleanup()


def test_extract_json():
# Arrange
cleanup()

# Act
extract_otp_secret_keys.main(['-q', '-j', 'test_example_output.json', 'example_export.txt'])

expected_json = read_json('example_output.json')
actual_json = read_json('test_example_output.json')

assert actual_json == expected_json

# Clean up
cleanup()


def cleanup():
remove_file('test_example_output.csv')
remove_file('test_example_output.json')


def remove_file(filename):
if os.path.exists(filename): os.remove(filename)


def read_csv(filename):
"""Returns a list of lines."""
with open(filename, "r") as infile:
lines = []
reader = csv.reader(infile)
for line in reader:
lines.append(line)
return lines


def read_json(filename):
"""Returns a list or a dictionary."""
with open(filename, "r") as infile:
return json.load(infile)
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

import extract_otp_secret_keys


class TestExtract(unittest.TestCase):

def test_extract_csv(self):
Expand Down Expand Up @@ -53,9 +54,11 @@ def cleanup(self):
remove_file('test_example_output.csv')
remove_file('test_example_output.json')


def remove_file(filename):
if os.path.exists(filename): os.remove(filename)


def read_csv(filename):
"""Returns a list of lines."""
with open(filename, "r") as infile:
Expand All @@ -65,6 +68,7 @@ def read_csv(filename):
lines.append(line)
return lines


def read_json(filename):
"""Returns a list or a dictionary."""
with open(filename, "r") as infile:
Expand Down