Compare commits

...

17 Commits

Author SHA1 Message Date
scito
65c52f4d81 skip verbose tests for pypy 2022-12-19 22:37:23 +01:00
scito
8ba4439305 add utf-8 encoding tests 2022-12-19 22:37:23 +01:00
scito
10bc6959a3 add python to vscode recommended extensions 2022-12-19 17:48:17 +01:00
u231053
a51507b701 fix #30: enforce utf-8 encoding
since windows used non-utf8-encoding
2022-12-19 17:48:17 +01:00
scito
7af4017910 ci: daily tests and order python from new to old
- more recent version first
2022-12-19 00:11:30 +01:00
scito
7af631ff1e ci: setup testing for linux, macos and windows platforms
- fix ci for windows: remove bash if/fi
- exlucde failing test from windows
- enable scheduled tests
- use --use-pep517 for pip install: avoid deprecation message
- exlcude windows-latest and pypy-3.9 since there is a problem with installing (missing zlib.h)
2022-12-18 23:06:52 +01:00
scito
ca4a0bc7d2 improvements for - (stdout)
- - implies -q
- use add_mutually_exclusive_group for -v and -q
- adapt and improve tests
2022-12-18 21:34:24 +01:00
scito
1be4c7e0ef suppor writing csv and json to stdout; print errors to stderr
- add tests
2022-12-18 19:24:07 +01:00
scito
fd1841f8dd update Pipfile.lock 2022-12-18 17:42:42 +01:00
scito
81c2cb498a add stdin pytest 2022-12-18 17:41:35 +01:00
scito
21c16ed44e add pypy Python versions 3.7, 3.8, and 3.9 to GitHub CI testing 2022-12-18 13:26:17 +01:00
scito
30638041d8 upgrade to protobuf 4.21.12 2022-12-16 13:17:39 +01:00
scito
892f4f92ae fix compatibility for Python < 3.11 2022-12-16 13:10:22 +01:00
scito
bda0186d10 test wrong data and improve error handling 2022-12-16 12:43:32 +01:00
scito
96c8836a98 upgrade to protobuf 4.21.11 2022-12-10 12:25:15 +01:00
scito
c44a3f45de add pip installation; improve README
- improve README
    - add Installation section
    - add Features section
    - add Glossary
    - add related projects

- add setup.py for pip installation:
    pip install git+https://github.com/scito/extract_otp_secret_keys
2022-12-10 12:23:13 +01:00
dependabot[bot]
5783d086ad Bump pylint from 2.15.7 to 2.15.8
Bumps [pylint](https://github.com/PyCQA/pylint) from 2.15.7 to 2.15.8.
- [Release notes](https://github.com/PyCQA/pylint/releases)
- [Commits](https://github.com/PyCQA/pylint/compare/v2.15.7...v2.15.8)

---
updated-dependencies:
- dependency-name: pylint
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-05 17:36:39 +01:00
21 changed files with 592 additions and 141 deletions

View File

@@ -1,14 +1,23 @@
name: tests name: tests
on: [push] on:
push:
pull_request:
schedule:
- cron: '47 3 * * *'
jobs: jobs:
build: build:
runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.x"] python-version: ["3.x", "3.11", "3.10", "3.9", "pypy-3.9", "3.8", "pypy-3.8", "3.7", "pypy-3.7"]
platform: [ubuntu-latest, macos-latest, windows-latest]
exclude:
- platform: windows-latest
- python-version: [pypy-3.9]
runs-on: ${{ matrix.platform }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -20,7 +29,7 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install flake8 pytest pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi pip install --use-pep517 -r requirements.txt
- name: Lint with flake8 - name: Lint with flake8
run: | run: |
# stop the build if there are Python syntax errors or undefined names # stop the build if there are Python syntax errors or undefined names
@@ -28,5 +37,4 @@ jobs:
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide # 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 flake8 . --count --exit-zero --max-complexity=10 --max-line-length=200 --statistics
- name: Test with pytest - name: Test with pytest
run: | run: pytest
pytest

2
.gitignore vendored
View File

@@ -14,3 +14,5 @@ venv/
!.devcontainer/ !.devcontainer/
!.devcontainer/*.json !.devcontainer/*.json
*.whl *.whl
build/
extract_otp_secret_keys.egg-info/

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"ms-python.python"
]
}

68
Pipfile.lock generated
View File

@@ -85,23 +85,23 @@
}, },
"protobuf": { "protobuf": {
"hashes": [ "hashes": [
"sha256:0413addc126c40a5440ee59be098de1007183d68e9f5f20ed5fbc44848f417ca", "sha256:1f22ac0ca65bb70a876060d96d914dae09ac98d114294f77584b0d2644fa9c30",
"sha256:05cbcb9a25cd781fd949f93f6f98a911883868c0360c6d2264fc99a903c8f0d7", "sha256:237216c3326d46808a9f7c26fd1bd4b20015fb6867dc5d263a493ef9a539293b",
"sha256:0c968753028cb14b1d24cc839723f7e9505b305fc588a37a9e0f7d270cb59d89", "sha256:27f4d15021da6d2b706ddc3860fac0a5ddaba34ab679dc182b60a8bb4e1121cc",
"sha256:2a172741b5b041a896b621cef4277077afd571e0d3a6e524e7171f1c70e33200", "sha256:299ea899484ee6f44604deb71f424234f654606b983cb496ea2a53e3c63ab791",
"sha256:3f08f04b4f101dd469efbcc1731fbf48068eccd8a42f4e2ea530aa012a5f56f8", "sha256:3d164928ff0727d97022957c2b849250ca0e64777ee31efd7d6de2e07c494717",
"sha256:4d97c16c0d11155b3714a29245461f0eb60cace294455077f3a3b8a629afa383", "sha256:6ab80df09e3208f742c98443b6166bcb70d65f52cfeb67357d52032ea1ae9bec",
"sha256:5096b3922b45e4b7a04d3d3cb855d13bb5ccd4d5e44b129e706232ebf0ffb870", "sha256:78a28c9fa223998472886c77042e9b9afb6fe4242bd2a2a5aced88e3f4422aa7",
"sha256:5efa8a8162ada7e10847140308fbf84fdc5b89dc21655d12ec04aed87284fe07", "sha256:7cd532c4566d0e6feafecc1059d04c7915aec8e182d1cf7adee8b24ef1e2e6ab",
"sha256:6b809f20923b6ef49dc1755cb50bdb21be179b4a3c7ffcab1fe5d3f139b58a51", "sha256:89f9149e4a0169cddfc44c74f230d7743002e3aa0b9472d8c28f0388102fc4c2",
"sha256:81b233a06c62387ea5c9be2cd9aedd2ba09940e91da53b920e9ff5bd98e48e7f", "sha256:a53fd3f03e578553623272dc46ac2f189de23862e68565e83dde203d41b76fc5",
"sha256:a5e89eabaa0ca72ce1b7c8104a740d44cdb67942cbbed00c69a4c0541de17107", "sha256:b135410244ebe777db80298297a97fbb4c862c881b4403b71bac9d4107d61fd1",
"sha256:b78d7c2c36b51c0041b9bf000be4adb09f4112bfc40bc7a9d48ac0b0dfad139e", "sha256:b98d0148f84e3a3c569e19f52103ca1feacdac0d2df8d6533cf983d1fda28462",
"sha256:e53165dd14d19abc7f50733f365de431e51d1d262db40c0ee22e271a074fac59", "sha256:d1736130bce8cf131ac7957fa26880ca19227d4ad68b4888b3be0dea1f95df97",
"sha256:e92768d17473657c87e98b79a4c7724b0ddfa23211b05ce137bfdc55e734e36f" "sha256:f45460f9ee70a0ec1b6694c6e4e348ad2019275680bd68a1d9314b8c7e01e574"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.21.10" "version": "==4.21.12"
}, },
"qrcode": { "qrcode": {
"hashes": [ "hashes": [
@@ -133,7 +133,7 @@
"sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0", "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0",
"sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373" "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.11'",
"version": "==0.3.6" "version": "==0.3.6"
}, },
"flake8": { "flake8": {
@@ -153,11 +153,11 @@
}, },
"isort": { "isort": {
"hashes": [ "hashes": [
"sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7", "sha256:83155ffa936239d986b0f190347a3f2285f42a9b9e1725c89d865b27dd0627e5",
"sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951" "sha256:a8ca25fbfad0f7d5d8447a4314837298d9f6b23aed8618584c894574f626b64b"
], ],
"markers": "python_version < '4.0' and python_full_version >= '3.6.1'", "markers": "python_full_version >= '3.7.0'",
"version": "==5.10.1" "version": "==5.11.3"
}, },
"lazy-object-proxy": { "lazy-object-proxy": {
"hashes": [ "hashes": [
@@ -194,19 +194,19 @@
}, },
"packaging": { "packaging": {
"hashes": [ "hashes": [
"sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3",
"sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"
], ],
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.7'",
"version": "==21.3" "version": "==22.0"
}, },
"platformdirs": { "platformdirs": {
"hashes": [ "hashes": [
"sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7", "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca",
"sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10" "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2.5.4" "version": "==2.6.0"
}, },
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
@@ -234,19 +234,11 @@
}, },
"pylint": { "pylint": {
"hashes": [ "hashes": [
"sha256:1d561d1d3e8be9dd880edc685162fbdaa0409c88b9b7400873c0cf345602e326", "sha256:18783cca3cfee5b83c6c5d10b3cdb66c6594520ffae61890858fe8d932e1c6b4",
"sha256:91e4776dbcb4b4d921a3e4b6fec669551107ba11f29d9199154a01622e460a57" "sha256:349c8cd36aede4d50a0754a8c0218b43323d13d5d88f4b2952ddfe3e169681eb"
], ],
"index": "pypi", "index": "pypi",
"version": "==2.15.7" "version": "==2.15.9"
},
"pyparsing": {
"hashes": [
"sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb",
"sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
],
"markers": "python_full_version >= '3.6.8'",
"version": "==3.0.9"
}, },
"pytest": { "pytest": {
"hashes": [ "hashes": [

View File

@@ -3,16 +3,21 @@
[![CI Status](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml/badge.svg)](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml) [![CI Status](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml/badge.svg)](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml)
![PyPI - Python Version](https://img.shields.io/pypi/pyversions/protobuf) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/protobuf)
[![GitHub Pipenv locked Python version](https://img.shields.io/github/pipenv/locked/python-version/scito/extract_otp_secret_keys)](https://github.com/scito/extract_otp_secret_keys/blob/master/Pipfile.lock) [![GitHub Pipenv locked Python version](https://img.shields.io/github/pipenv/locked/python-version/scito/extract_otp_secret_keys)](https://github.com/scito/extract_otp_secret_keys/blob/master/Pipfile.lock)
![protobuf version](https://img.shields.io/badge/protobuf-4.21.10-informational) ![protobuf version](https://img.shields.io/badge/protobuf-4.21.12-informational)
[![License](https://img.shields.io/github/license/scito/extract_otp_secret_keys)](https://github.com/scito/extract_otp_secret_keys/blob/master/LICENSE) [![License](https://img.shields.io/github/license/scito/extract_otp_secret_keys)](https://github.com/scito/extract_otp_secret_keys/blob/master/LICENSE)
[![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/scito/extract_otp_secret_keys?sort=semver&label=version)](https://github.com/scito/extract_otp_secret_keys/tags) [![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/scito/extract_otp_secret_keys?sort=semver&label=version)](https://github.com/scito/extract_otp_secret_keys/tags)
[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua) [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua)
--- ---
Extract two-factor authentication (2FA, TFA, one time passwords, otp) secret keys from export QR codes of "Google Authenticator" app. Extract two-factor authentication (2FA, TFA, OTP) secret keys from export QR codes of "Google Authenticator" app.
The secret and otp values can be printed and exported to json or csv. The QR codes can be printed or saved as PNG images. The secret and otp values can be printed and exported to json or csv. The QR codes can be printed or saved as PNG images.
## Installation
git clone https://github.com/scito/extract_otp_secret_keys.git
cd extract_otp_secret_keys
## Usage ## Usage
1. Open "Google Authenticator" app on the mobile phone 1. Open "Google Authenticator" app on the mobile phone
@@ -22,24 +27,24 @@ The secret and otp values can be printed and exported to json or csv. The QR cod
5. Transfer the file to the computer where his script is installed. 5. Transfer the file to the computer where his script is installed.
6. Call this script with the file as input: 6. Call this script with the file as input:
python extract_otp_secret_keys.py example_export.txt python extract_otp_secret_keys.py example_export.txt
## Program help: arguments and options ## Program help: arguments and options
<pre>usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose] [--quiet] infile <pre>usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose | --quiet] infile
positional arguments: positional arguments:
infile file or - for stdin (default: -) with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored infile file or - for stdin with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored
options: options:
-h, --help show this help message and exit -h, --help show this help message and exit
--json FILE, -j FILE export json file --json FILE, -j FILE export json file or - for stdout
--csv FILE, -c FILE export csv file --csv FILE, -c FILE export csv file or - for stdout
--keepass FILE, -k FILE export totp/hotp csv file(s) for KeePass --keepass FILE, -k FILE export totp/hotp csv file(s) for KeePass, - for stdout
--printqr, -p print QR code(s) as text to the terminal (requires qrcode module) --printqr, -p print QR code(s) as text to the terminal (requires qrcode module)
--saveqr DIR, -s DIR save QR code(s) as images to the given folder (requires qrcode module) --saveqr DIR, -s DIR save QR code(s) as images to the given folder (requires qrcode module)
--verbose, -v verbose output --verbose, -v verbose output
--quiet, -q no stdout output</pre> --quiet, -q no stdout output, except output set by -</pre>
## Dependencies ## Dependencies
@@ -48,7 +53,7 @@ options:
Known to work with Known to work with
* Python 3.10.8, protobuf 4.21.9, qrcode 7.3.1, and pillow 9.2 * Python 3.10.8, protobuf 4.21.9, qrcode 7.3.1, and pillow 9.2
* Python 3.11.0, protobuf 4.21.10, qrcode 7.3.1, and pillow 9.2 * Python 3.11.1, protobuf 4.21.12, qrcode 7.3.1, and pillow 9.2
For protobuf versions 3.14.0 or similar or Python 3.6, use the extract_otp_secret_keys version 1.4.0. For protobuf versions 3.14.0 or similar or Python 3.6, use the extract_otp_secret_keys version 1.4.0.
@@ -58,6 +63,29 @@ For printing QR codes, the qrcode module is required, otherwise it can be omitte
pip install qrcode[pil] pip install qrcode[pil]
## Features
* Free and open source
* Supports Google Authenticator export
* All functionality in one Python script: extract_otp_secret_keys.py (except protobuf generated code in protobuf_generated_python)
* Supports TOTP and HOTP
* Generates QR codes
* Various export formats:
* CSV
* JSON
* Dedicated CSV for KeePass
* QR code images
* Supports reading from stdin and writing to stdout by specifying '-'
* Errors and warnings are written to stderr
* Many ways to run the script:
* Native Python
* pipenv
* venv
* Docker
* VSCode devcontainer
* devbox
* pip
## KeePass ## KeePass
[KeePass 2.51](https://keepass.info/news/n220506_2.51.html) (released in May 2022) and newer [support the generation of OTPs (TOTP and HOTP)](https://keepass.info/help/base/placeholders.html#otp). [KeePass 2.51](https://keepass.info/news/n220506_2.51.html) (released in May 2022) and newer [support the generation of OTPs (TOTP and HOTP)](https://keepass.info/help/base/placeholders.html#otp).
@@ -105,15 +133,38 @@ Command for regeneration of Python code from proto3 message definition file (onl
protoc --python_out=protobuf_generated_python google_auth.proto protoc --python_out=protobuf_generated_python google_auth.proto
The generated protobuf Python code was generated by protoc 21.10 (https://github.com/protocolbuffers/protobuf/releases/tag/v21.10). The generated protobuf Python code was generated by protoc 21.12 (https://github.com/protocolbuffers/protobuf/releases/tag/v21.12).
## References ## References
* Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial * Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial
* Template code: https://github.com/beemdevelopment/Aegis/pull/406 * Template code: https://github.com/beemdevelopment/Aegis/pull/406
## Glossary
* OTP = One-time password
* TOTP = Time-based one-time password
* HOTP = HMAC-based one-time password (using a counter)
* 2FA = Second factor authentication
* TFA = Two factor authentication
* QR code = Quick response code
## Alternative installation methods ## Alternative installation methods
### pip
```
pip install git+https://github.com/scito/extract_otp_secret_keys
python -m extract_otp_secret_keys
```
#### Example
```
wget https://raw.githubusercontent.com/scito/extract_otp_secret_keys/master/example_export.txt
python -m extract_otp_secret_keys example_export.txt
```
### pipenv ### pipenv
You can you use [Pipenv](https://github.com/pypa/pipenv) for running extract_otp_secret_keys. You can you use [Pipenv](https://github.com/pypa/pipenv) for running extract_otp_secret_keys.
@@ -212,6 +263,12 @@ Setup for running the tests in VSCode.
pip install -U -r requirements.txt pip install -U -r requirements.txt
``` ```
## Related projects
* [ZBar](https://github.com/mchehab/zbar) is an open source software suite for reading bar codes from various sources, including webcams.
* [Aegis Authenticator](https://github.com/beemdevelopment/Aegis) is a free, secure and open source 2FA app for Android.
* [Android OTP Extractor](https://github.com/puddly/android-otp-extractor) can extract your tokens from popular Android OTP apps and export them in a standard format or just display them as QR codes for easy importing. [Requires a _rooted_ Android phone.]
*** ***
# #StandWithUkraine 🇺🇦 # #StandWithUkraine 🇺🇦

View File

@@ -12,3 +12,7 @@ otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXB
# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 # otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4
otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D
# otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
# Name: "encoding: ¿äÄéÉ? (demo)"
otpauth-migration://offline?data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D

View File

@@ -3,3 +3,4 @@ raspberrypi,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP ,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP ,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
raspberrypi,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP raspberrypi,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
,encoding: ¿äÄéÉ? (demo),7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
1 Title User Name TimeOtp-Secret-Base32 Group
3 pi@raspberrypi 7KSQL2JTUDIS5EF65KLMRQIIGY OTP/TOTP
4 pi@raspberrypi 7KSQL2JTUDIS5EF65KLMRQIIGY OTP/TOTP
5 raspberrypi pi@raspberrypi 7KSQL2JTUDIS5EF65KLMRQIIGY OTP/TOTP
6 encoding: ¿äÄéÉ? (demo) 7KSQL2JTUDIS5EF65KLMRQIIGY OTP/TOTP

View File

@@ -4,3 +4,4 @@ pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,,totp,,otpauth://totp/pi%40raspberrypi
pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,raspberrypi,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,raspberrypi,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
hotp demo,7KSQL2JTUDIS5EF65KLMRQIIGY,,hotp,4,otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 hotp demo,7KSQL2JTUDIS5EF65KLMRQIIGY,,hotp,4,otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4
encoding: ¿äÄéÉ? (demo),7KSQL2JTUDIS5EF65KLMRQIIGY,,totp,,otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
1 name secret issuer type counter url
4 pi@raspberrypi 7KSQL2JTUDIS5EF65KLMRQIIGY totp otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
5 pi@raspberrypi 7KSQL2JTUDIS5EF65KLMRQIIGY raspberrypi totp otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
6 hotp demo 7KSQL2JTUDIS5EF65KLMRQIIGY hotp 4 otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4
7 encoding: ¿äÄéÉ? (demo) 7KSQL2JTUDIS5EF65KLMRQIIGY totp otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY

View File

@@ -38,5 +38,13 @@
"type": "hotp", "type": "hotp",
"counter": 4, "counter": 4,
"url": "otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4" "url": "otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4"
},
{
"name": "encoding: ¿äÄéÉ? (demo)",
"secret": "7KSQL2JTUDIS5EF65KLMRQIIGY",
"issuer": "",
"type": "totp",
"counter": null,
"url": "otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY"
} }
] ]

View File

@@ -59,6 +59,10 @@ def sys_main():
def main(sys_args): def main(sys_args):
global verbose, quiet global verbose, quiet
# allow to use sys.stdout with with (avoid closing)
sys.stdout.close = lambda: None
args = parse_args(sys_args) args = parse_args(sys_args)
verbose = args.verbose if args.verbose else 0 verbose = args.verbose if args.verbose else 0
quiet = args.quiet quiet = args.quiet
@@ -70,20 +74,20 @@ def main(sys_args):
def parse_args(sys_args): def parse_args(sys_args):
formatter = lambda prog: argparse.HelpFormatter(prog,max_help_position=52) formatter = lambda prog: argparse.HelpFormatter(prog, max_help_position=52)
arg_parser = argparse.ArgumentParser(formatter_class=formatter) arg_parser = argparse.ArgumentParser(formatter_class=formatter)
arg_parser.add_argument('infile', help='file or - for stdin (default: -) with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored') arg_parser.add_argument('infile', help='file or - for stdin with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored')
arg_parser.add_argument('--json', '-j', help='export json file', metavar=('FILE')) 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', metavar=('FILE')) arg_parser.add_argument('--csv', '-c', help='export csv file or - for stdout', metavar=('FILE'))
arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass', metavar=('FILE')) 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('--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')) arg_parser.add_argument('--saveqr', '-s', help='save QR code(s) as images to the given folder (requires qrcode module)', metavar=('DIR'))
arg_parser.add_argument('--verbose', '-v', help='verbose output', action='count') output_group = arg_parser.add_mutually_exclusive_group()
arg_parser.add_argument('--quiet', '-q', help='no stdout output', action='store_true') 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')
args = arg_parser.parse_args(sys_args) args = arg_parser.parse_args(sys_args)
if args.verbose and args.quiet: if args.csv == '-' or args.json == '-' or args.keepass == '-':
print("The arguments --verbose and --quiet are mutually exclusive.") args.quiet = args.q = True
sys.exit(1)
return args return args
@@ -94,51 +98,58 @@ def extract_otps(args):
otps = [] otps = []
i = j = 0 i = j = 0
for line in (line.strip() for line in fileinput.input(args.infile)): finput = fileinput.input(args.infile)
if verbose: print(line) try:
if line.startswith('#') or line == '': continue for line in (line.strip() for line in finput):
i += 1 if verbose: print(line)
payload = get_payload_from_line(line, i, args) if line.startswith('#') or line == '': continue
i += 1
payload = get_payload_from_line(line, i, args)
# pylint: disable=no-member # pylint: disable=no-member
for raw_otp in payload.otp_parameters: for raw_otp in payload.otp_parameters:
j += 1 j += 1
if verbose: print('\n{}. Secret Key'.format(j)) if verbose: print('\n{}. Secret Key'.format(j))
secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret) secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret)
otp_type_enum = get_enum_name_by_number(raw_otp, 'type') otp_type_enum = get_enum_name_by_number(raw_otp, 'type')
otp_type = get_otp_type_str_from_code(raw_otp.type) otp_type = get_otp_type_str_from_code(raw_otp.type)
otp_url = build_otp_url(secret, raw_otp) otp_url = build_otp_url(secret, raw_otp)
otp = { otp = {
"name": raw_otp.name, "name": raw_otp.name,
"secret": secret, "secret": secret,
"issuer": raw_otp.issuer, "issuer": raw_otp.issuer,
"type": otp_type, "type": otp_type,
"counter": raw_otp.counter if raw_otp.type == 1 else None, "counter": raw_otp.counter if raw_otp.type == 1 else None,
"url": otp_url "url": otp_url
} }
if not quiet: if not quiet:
print_otp(otp) print_otp(otp)
if args.printqr: if args.printqr:
print_qr(args, otp_url) print_qr(args, otp_url)
if args.saveqr: if args.saveqr:
save_qr(otp, args, j) save_qr(otp, args, j)
if not quiet: if not quiet:
print() print()
otps.append(otp) otps.append(otp)
finally:
finput.close()
return otps return otps
def get_payload_from_line(line, i, args): def get_payload_from_line(line, i, args):
global verbose global verbose
if not line.startswith('otpauth-migration://'): 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)) eprint('\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) parsed_url = urlparse(line)
if verbose > 1: print('\nDEBUG: parsed_url={}'.format(parsed_url)) if verbose > 1: print('\nDEBUG: parsed_url={}'.format(parsed_url))
params = parse_qs(parsed_url.query, strict_parsing=True) try:
params = parse_qs(parsed_url.query, strict_parsing=True)
except: # Not necessary for Python >= 3.11
params = []
if verbose > 1: print('\nDEBUG: querystring params={}'.format(params)) if verbose > 1: print('\nDEBUG: querystring params={}'.format(params))
if 'data' not 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)) eprint('\nERROR: no data query parameter in input URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(args.infile, line))
sys.exit(1) sys.exit(1)
data_base64 = params['data'][0] data_base64 = params['data'][0]
if verbose > 1: print('\nDEBUG: data_base64={}'.format(data_base64)) if verbose > 1: print('\nDEBUG: data_base64={}'.format(data_base64))
@@ -146,7 +157,12 @@ def get_payload_from_line(line, i, args):
if verbose > 1: print('\nDEBUG: data_base64_fixed={}'.format(data_base64)) if verbose > 1: print('\nDEBUG: data_base64_fixed={}'.format(data_base64))
data = base64.b64decode(data_base64_fixed, validate=True) data = base64.b64decode(data_base64_fixed, validate=True)
payload = protobuf_generated_python.google_auth_pb2.MigrationPayload() payload = protobuf_generated_python.google_auth_pb2.MigrationPayload()
payload.ParseFromString(data) try:
payload.ParseFromString(data)
except:
eprint('\nERROR: Cannot decode otpauth-migration migration payload.')
eprint('data={}'.format(data_base64))
exit(1);
if verbose: if verbose:
print('\n{}. Payload Line'.format(i), payload, sep='\n') print('\n{}. Payload Line'.format(i), payload, sep='\n')
@@ -216,7 +232,7 @@ def print_qr(args, data):
def write_csv(args, otps): def write_csv(args, otps):
global verbose, quiet global verbose, quiet
if args.csv and len(otps) > 0: if args.csv and len(otps) > 0:
with open(args.csv, "w") as outfile: with open_file_or_stdout_for_csv(args.csv) as outfile:
writer = csv.DictWriter(outfile, otps[0].keys()) writer = csv.DictWriter(outfile, otps[0].keys())
writer.writeheader() writer.writeheader()
writer.writerows(otps) writer.writerows(otps)
@@ -233,7 +249,7 @@ def write_keepass_csv(args, otps):
count_totp_entries = 0 count_totp_entries = 0
count_hotp_entries = 0 count_hotp_entries = 0
if has_totp: if has_totp:
with open(otp_filename_totp, "w") as outfile: with open_file_or_stdout_for_csv(otp_filename_totp) as outfile:
writer = csv.DictWriter(outfile, ["Title", "User Name", "TimeOtp-Secret-Base32", "Group"]) writer = csv.DictWriter(outfile, ["Title", "User Name", "TimeOtp-Secret-Base32", "Group"])
writer.writeheader() writer.writeheader()
for otp in otps: for otp in otps:
@@ -246,7 +262,7 @@ def write_keepass_csv(args, otps):
}) })
count_totp_entries += 1 count_totp_entries += 1
if has_hotp: if has_hotp:
with open(otp_filename_hotp, "w") as outfile: with open_file_or_stdout_for_csv(otp_filename_hotp) as outfile:
writer = csv.DictWriter(outfile, ["Title", "User Name", "HmacOtp-Secret-Base32", "HmacOtp-Counter", "Group"]) writer = csv.DictWriter(outfile, ["Title", "User Name", "HmacOtp-Secret-Base32", "HmacOtp-Counter", "Group"])
writer.writeheader() writer.writeheader()
for otp in otps: for otp in otps:
@@ -267,7 +283,7 @@ def write_keepass_csv(args, otps):
def write_json(args, otps): def write_json(args, otps):
global verbose, quiet global verbose, quiet
if args.json: if args.json:
with open(args.json, "w") as outfile: with open_file_or_stdout(args.json) 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), args.json)) if not quiet: print("Exported {} otp entries to json {}".format(len(otps), args.json))
@@ -285,5 +301,25 @@ def add_pre_suffix(file, pre_suffix):
return name + "." + pre_suffix + (ext if ext else "") return name + "." + pre_suffix + (ext if ext else "")
def open_file_or_stdout(filename):
'''stdout is denoted as "-".
Note: Set before the following line:
sys.stdout.close = lambda: None'''
return open(filename, "w", encoding='utf-8') if filename != '-' else sys.stdout
def open_file_or_stdout_for_csv(filename):
'''stdout is denoted as "-".
newline=''
Note: Set before the following line:
sys.stdout.close = lambda: None'''
return open(filename, "w", encoding='utf-8', newline='') if filename != '-' else sys.stdout
def eprint(*args, **kwargs):
'''Print to stderr.'''
print(*args, file=sys.stderr, **kwargs)
if __name__ == '__main__': if __name__ == '__main__':
sys_main() sys_main()

60
setup.py Normal file
View File

@@ -0,0 +1,60 @@
import pathlib
from setuptools import setup
setup(
name='extract_otp_secret_keys',
version='1.6.0',
description='Extract two-factor authentication (2FA, TFA, OTP) secret keys from export QR codes of "Google Authenticator" app',
long_description=(pathlib.Path(__file__).parent / 'README.md').read_text(),
long_description_content_type='text/markdown',
url='https://github.com/scito/extract_otp_secret_keys',
author='scito',
author_email='info@scito.ch',
classifiers=[
'Development Status :: 5 - Production/Stable',
'Environment :: Console',
'Topic :: System :: Archiving :: Backup',
'Topic :: Utilities',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Intended Audience :: End Users/Desktop',
'Intended Audience :: Developers',
'Intended Audience :: System Administrators',
'Programming Language :: Python'
'Natural Language :: English',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
],
keywords='python security json otp csv protobuf qrcode two-factor totp google-authenticator recovery proto3 mfa two-factor-authentication tfa qr-codes otpauth 2fa security-tools',
py_modules=['extract_otp_secret_keys', 'protobuf_generated_python.google_auth_pb2'],
entry_points={
'console_scripts': [
'extract_otp_secret_keys = extract_otp_secret_keys:sys_main',
]
},
python_requires='>=3.7, <4',
install_requires=[
'protobuf',
'qrcode',
'Pillow'
],
project_urls={
'Bug Reports': 'https://github.com/scito/extract_otp_secret_keys/issues',
'Source': 'https://github.com/scito/extract_otp_secret_keys',
},
license='GNU General Public License v3 (GPLv3)',
)

View File

@@ -9,3 +9,7 @@ otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXB
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B
# otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
# Name: "encoding: ¿äÄéÉ? (demo)"
otpauth-migration://offline?data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D

View File

@@ -112,3 +112,27 @@ Type: hotp
Counter: 4 Counter: 4
otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4
# otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
# Name: "encoding: ¿äÄéÉ? (demo)"
otpauth-migration://offline?data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D
5. Payload Line
otp_parameters {
secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106"
name: "encoding: ¿äÄéÉ? (demo)"
algorithm: ALGO_SHA1
digits: 1
type: OTP_TOTP
}
version: 1
batch_size: 1
batch_id: -171198419
6. Secret Key
Name: encoding: ¿äÄéÉ? (demo)
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp
otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY

View File

@@ -132,3 +132,32 @@ Counter: 4
                                                                                           
                                                                                           
Name: encoding: ¿äÄéÉ? (demo)
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp
                                                 
                                                 
    █▀▀▀▀▀█ ▄▀▀  ▀  ▄▀█ ▄▄▄██▀ ▄▀█▄█▀ █▀▀▀▀▀█    
    █ ███ █ █▄▀█▄▄ ▀  ▄▀█  █  █  ▀▀▀▄ █ ███ █    
    █ ▀▀▀ █  ▄ ▀ ▀▄▀▄ ▄▄▀▄▄█▄ ▀▄ █▀▀█ █ ▀▀▀ █    
    ▀▀▀▀▀▀▀ █ █ █▄█▄▀ ▀▄▀ █▄█ ▀ █ ▀ █ ▀▀▀▀▀▀▀    
    ▀   ▄ ▀ █▀▀▄▀ ▄▄▀▀▄█▄ █▄▀▀▄█▀██▄▄█▀ ▄█▀▀▄    
    ▄▀█▄█ ▀ ▀ █▄█▄▄    ▄███▄▄▀▀▀▄▄▀▄    ▄█▀▄▄    
     ▀█▀ ▄▀▄▄█ ▄▀███▀ ▄▀█▀▄▀▄  ▀██▄▄ ▄█▀█  ▀▄    
     ▄▀ █▄▀▀ █▀▄▄▄  ▄█▄█ ▀▄ ▄▄ ▄  ▀▀█▄▄ ▀█▄▄▄    
    ▀ ▄▄▄▀▀▄▄█▀▄ ▀▀▀█▄ █▄ ▀ ▄█▄▄▀▄▀▀▀▄▄█▄ ▀ ▀    
    █ ▄   ▀█ ▄▀ ██ █ ▄▄▀▀▀███▄     ▄▄██ ██▀█▀    
    █▀█▀██▀▀███▄  ▀▀▄▄▄▄█▀ █ ▄█▄█▄▀ ▄▄▀  ▄▄ ▀    
    ▄██▄▄ ▀ ▀   ▀▀  ██▄▄▄▀▀▄█▀█▄ ▀ █▄▀▄   ▀▄▄    
      ███▄▀█▄█▄▄█ ▀█▄ ▀▄█  ▄▀▄ █▄  ▄ █▄  ▄▀▄▀    
    █  ▄ ▄▀▀▄▄█▄▄█▄█ ▄▄▄ █▄▄▀█ █▀█▄ ▄▀▀█▄▄▄▀▀    
    ▄  ▀▄▀▀ ▄▄▀██  ▀▀▄█▀▀▄ ▀▀█   ▄ ████ █▀█▀█    
    █▀▄▀█ ▀▄▄ ▄ ▀▀▀ ▄▀ ▀ █▀▄▀▀█▀▀█▄▀█ ▀▄▀▄ █     
    ▀ ▀  ▀▀ ▄ █▄▀█▀▀▄▀█ ▀▄▄█▄▀ ██ ▀██▀▀▀█▀▄▄     
    █▀▀▀▀▀█  ▀█ ▄▄██▀ ▀██▄▀██ ▄▄██ ▀█ ▀ █ ▀█     
    █ ███ █  █▄ █▀▀█▀▀▀█▀█ ▀ ▀█ █▀▀ ██▀▀▀███▀    
    █ ▀▀▀ █  ▄ ▄▀█▄▄ ▀█ ▀▀  ▄ ▀█▀ ▄▀ █▀▀██ ▀▄    
    ▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀▀   ▀  ▀ ▀▀▀▀ ▀  ▀   ▀ ▀      
                                                 
                                                 

View File

@@ -0,0 +1 @@
Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.

View File

@@ -0,0 +1 @@
otpauth-migration://offline?data=XXXX

View File

@@ -0,0 +1 @@
QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B

View File

@@ -18,13 +18,40 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from utils import read_csv, read_json, remove_files, remove_dir_with_files, read_file_to_str, file_exits from utils import read_csv, read_csv_str, read_json, read_json_str, remove_files, remove_dir_with_files, read_file_to_str, file_exits
from os import path from os import path
from pytest import raises from pytest import raises, mark
from io import StringIO
from sys import implementation
import extract_otp_secret_keys import extract_otp_secret_keys
def test_extract_stdout(capsys):
# Act
extract_otp_secret_keys.main(['example_export.txt'])
# Assert
captured = capsys.readouterr()
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT
assert captured.err == ''
def test_extract_stdin_stdout(capsys, monkeypatch):
# Arrange
monkeypatch.setattr('sys.stdin', StringIO(read_file_to_str('example_export.txt')))
# Act
extract_otp_secret_keys.main(['-'])
# Assert
captured = capsys.readouterr()
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT
assert captured.err == ''
def test_extract_csv(capsys): def test_extract_csv(capsys):
# Arrange # Arrange
cleanup() cleanup()
@@ -47,6 +74,51 @@ def test_extract_csv(capsys):
cleanup() cleanup()
def test_extract_csv_stdout(capsys):
# Arrange
cleanup()
# Act
extract_otp_secret_keys.main(['-c', '-', 'example_export.txt'])
# Assert
assert not file_exits('test_example_output.csv')
captured = capsys.readouterr()
expected_csv = read_csv('example_output.csv')
actual_csv = read_csv_str(captured.out)
assert actual_csv == expected_csv
assert captured.err == ''
# Clean up
cleanup()
def test_extract_stdin_and_csv_stdout(capsys, monkeypatch):
# Arrange
cleanup()
monkeypatch.setattr('sys.stdin', StringIO(read_file_to_str('example_export.txt')))
# Act
extract_otp_secret_keys.main(['-c', '-', '-'])
# Assert
assert not file_exits('test_example_output.csv')
captured = capsys.readouterr()
expected_csv = read_csv('example_output.csv')
actual_csv = read_csv_str(captured.out)
assert actual_csv == expected_csv
assert captured.err == ''
# Clean up
cleanup()
def test_keepass_csv(capsys): def test_keepass_csv(capsys):
'''Two csv files .totp and .htop are generated.''' '''Two csv files .totp and .htop are generated.'''
# Arrange # Arrange
@@ -74,6 +146,31 @@ def test_keepass_csv(capsys):
cleanup() cleanup()
def test_keepass_csv_stdout(capsys):
'''Two csv files .totp and .htop are generated.'''
# Arrange
cleanup()
# Act
extract_otp_secret_keys.main(['-k', '-', 'test/example_export_only_totp.txt'])
# Assert
expected_totp_csv = read_csv('example_keepass_output.totp.csv')
expected_hotp_csv = read_csv('example_keepass_output.hotp.csv')
assert not file_exits('test_example_keepass_output.totp.csv')
assert not file_exits('test_example_keepass_output.hotp.csv')
assert not file_exits('test_example_keepass_output.csv')
captured = capsys.readouterr()
actual_totp_csv = read_csv_str(captured.out)
assert actual_totp_csv == expected_totp_csv
assert captured.err == ''
# Clean up
cleanup()
def test_single_keepass_csv(capsys): def test_single_keepass_csv(capsys):
'''Does not add .totp or .hotp pre-suffix''' '''Does not add .totp or .hotp pre-suffix'''
# Arrange # Arrange
@@ -121,41 +218,25 @@ def test_extract_json(capsys):
cleanup() cleanup()
def test_extract_stdout(capsys): def test_extract_json_stdout(capsys):
# Arrange
cleanup()
# Act # Act
extract_otp_secret_keys.main(['example_export.txt']) extract_otp_secret_keys.main(['-j', '-', 'example_export.txt'])
# Assert # Assert
expected_json = read_json('example_output.json')
assert not file_exits('test_example_output.json')
captured = capsys.readouterr() captured = capsys.readouterr()
actual_json = read_json_str(captured.out)
expected_stdout = '''Name: pi@raspberrypi assert actual_json == expected_json
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Issuer: raspberrypi
Type: totp
Name: pi@raspberrypi
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp
Name: pi@raspberrypi
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp
Name: pi@raspberrypi
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Issuer: raspberrypi
Type: totp
Name: hotp demo
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: hotp
Counter: 4
'''
assert captured.out == expected_stdout
assert captured.err == '' assert captured.err == ''
# Clean up
cleanup()
def test_extract_not_encoded_plus(capsys): def test_extract_not_encoded_plus(capsys):
# Act # Act
@@ -225,6 +306,7 @@ def test_extract_saveqr(capsys):
cleanup() cleanup()
@mark.skipif(implementation.name == 'pypy', reason="Encoding problems in verbose mode in pypy.")
def test_extract_verbose(capsys): def test_extract_verbose(capsys):
# Act # Act
extract_otp_secret_keys.main(['-v', 'example_export.txt']) extract_otp_secret_keys.main(['-v', 'example_export.txt'])
@@ -267,6 +349,20 @@ def test_extract_help(capsys):
assert pytest_wrapped_e.value.code == 0 assert pytest_wrapped_e.value.code == 0
def test_extract_no_arguments(capsys):
# Act
with raises(SystemExit) as pytest_wrapped_e:
extract_otp_secret_keys.main([])
# Assert
captured = capsys.readouterr()
expected_err_msg = 'error: the following arguments are required: infile'
assert expected_err_msg in captured.err
assert captured.out == ''
def test_verbose_and_quiet(capsys): def test_verbose_and_quiet(capsys):
with raises(SystemExit) as pytest_wrapped_e: with raises(SystemExit) as pytest_wrapped_e:
# Act # Act
@@ -275,8 +371,75 @@ def test_verbose_and_quiet(capsys):
# Assert # Assert
captured = capsys.readouterr() captured = capsys.readouterr()
assert len(captured.out) > 0 assert len(captured.err) > 0
assert 'The arguments --verbose and --quiet are mutually exclusive.' in captured.out assert 'error: argument --quiet/-q: not allowed with argument --verbose/-v' in captured.err
assert captured.out == ''
def test_wrong_data(capsys):
with raises(SystemExit) as pytest_wrapped_e:
# Act
extract_otp_secret_keys.main(['test/test_export_wrong_data.txt'])
# Assert
captured = capsys.readouterr()
expected_stderr = '''
ERROR: Cannot decode otpauth-migration migration payload.
data=XXXX
'''
assert captured.err == expected_stderr
assert captured.out == ''
def test_wrong_content(capsys):
with raises(SystemExit) as pytest_wrapped_e:
# Act
extract_otp_secret_keys.main(['test/test_export_wrong_content.txt'])
# Assert
captured = capsys.readouterr()
expected_stderr = '''
WARN: line is not a otpauth-migration:// URL
input file: test/test_export_wrong_content.txt
line "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua."
Probably a wrong file was given
ERROR: no data query parameter in input URL
input file: test/test_export_wrong_content.txt
line "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua."
Probably a wrong file was given
'''
assert captured.out == ''
assert captured.err == expected_stderr
def test_wrong_prefix(capsys):
# Act
extract_otp_secret_keys.main(['test/test_export_wrong_prefix.txt'])
# Assert
captured = capsys.readouterr()
expected_stderr = '''
WARN: line is not a otpauth-migration:// URL
input file: test/test_export_wrong_prefix.txt
line "QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B"
Probably a wrong file was given
'''
expected_stdout = '''Name: pi@raspberrypi
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Issuer: raspberrypi
Type: totp
'''
assert captured.out == expected_stdout
assert captured.err == expected_stderr
def test_add_pre_suffix(capsys): def test_add_pre_suffix(capsys):
@@ -289,3 +452,33 @@ def cleanup():
remove_files('test_example_*.csv') remove_files('test_example_*.csv')
remove_files('test_example_*.json') remove_files('test_example_*.json')
remove_dir_with_files('testout/') remove_dir_with_files('testout/')
EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT = '''Name: pi@raspberrypi
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Issuer: raspberrypi
Type: totp
Name: pi@raspberrypi
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp
Name: pi@raspberrypi
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp
Name: pi@raspberrypi
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Issuer: raspberrypi
Type: totp
Name: hotp demo
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: hotp
Counter: 4
Name: encoding: ¿äÄéÉ? (demo)
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp
'''

View File

@@ -23,6 +23,7 @@ import io
from contextlib import redirect_stdout from contextlib import redirect_stdout
from utils import read_csv, read_json, remove_file, remove_dir_with_files, Capturing, read_file_to_str from utils import read_csv, read_json, remove_file, remove_dir_with_files, Capturing, read_file_to_str
from os import path from os import path
from sys import implementation
import extract_otp_secret_keys import extract_otp_secret_keys
@@ -72,6 +73,10 @@ class TestExtract(unittest.TestCase):
'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY',
'Type: hotp', 'Type: hotp',
'Counter: 4', 'Counter: 4',
'',
'Name: encoding: ¿äÄéÉ? (demo)',
'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY',
'Type: totp',
'' ''
] ]
self.assertEqual(output, expected_output) self.assertEqual(output, expected_output)
@@ -106,6 +111,10 @@ Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: hotp Type: hotp
Counter: 4 Counter: 4
Name: encoding: ¿äÄéÉ? (demo)
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp
''' '''
self.assertEqual(actual_output, expected_output) self.assertEqual(actual_output, expected_output)
@@ -157,6 +166,7 @@ Type: totp
self.assertTrue(path.isfile('testout/qr/4-piraspberrypi-raspberrypi.png')) self.assertTrue(path.isfile('testout/qr/4-piraspberrypi-raspberrypi.png'))
def test_extract_verbose(self): def test_extract_verbose(self):
if implementation.name == 'pypy': self.skipTest("Encoding problems in verbose mode in pypy.")
out = io.StringIO() out = io.StringIO()
with redirect_stdout(out): with redirect_stdout(out):
extract_otp_secret_keys.main(['-v', 'example_export.txt']) extract_otp_secret_keys.main(['-v', 'example_export.txt'])

View File

@@ -181,11 +181,11 @@ eval "$cmd"
$PIP --version $PIP --version
cmd="$PIP install -U -r requirements.txt" cmd="$PIP install --use-pep517 -U -r requirements.txt"
if $interactive ; then askContinueYn "$cmd"; fi if $interactive ; then askContinueYn "$cmd"; fi
eval "$cmd" eval "$cmd"
cmd="$PIP install -U -r requirements-dev.txt" cmd="$PIP install --use-pep517 -U -r requirements-dev.txt"
if $interactive ; then askContinueYn "$cmd"; fi if $interactive ; then askContinueYn "$cmd"; fi
eval "$cmd" eval "$cmd"

View File

@@ -59,7 +59,7 @@ def remove_dir_with_files(dir):
def read_csv(filename): def read_csv(filename):
"""Returns a list of lines.""" """Returns a list of lines."""
with open(filename, "r") as infile: with open(filename, "r", encoding="utf-8", newline='') as infile:
lines = [] lines = []
reader = csv.reader(infile) reader = csv.reader(infile)
for line in reader: for line in reader:
@@ -67,15 +67,29 @@ def read_csv(filename):
return lines return lines
def read_csv_str(str):
"""Returns a list of lines."""
lines = []
reader = csv.reader(str.splitlines())
for line in reader:
lines.append(line)
return lines
def read_json(filename): def read_json(filename):
"""Returns a list or a dictionary.""" """Returns a list or a dictionary."""
with open(filename, "r") as infile: with open(filename, "r", encoding="utf-8") as infile:
return json.load(infile) return json.load(infile)
def read_json_str(str):
"""Returns a list or a dictionary."""
return json.loads(str)
def read_file_to_list(filename): def read_file_to_list(filename):
"""Returns a list of lines.""" """Returns a list of lines."""
with open(filename, "r") as infile: with open(filename, "r", encoding="utf-8") as infile:
return infile.readlines() return infile.readlines()