mirror of
https://github.com/scito/extract_otp_secrets.git
synced 2025-12-14 19:01:03 +01:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65c52f4d81 | ||
|
|
8ba4439305 | ||
|
|
10bc6959a3 | ||
|
|
a51507b701 | ||
|
|
7af4017910 | ||
|
|
7af631ff1e | ||
|
|
ca4a0bc7d2 | ||
|
|
1be4c7e0ef | ||
|
|
fd1841f8dd | ||
|
|
81c2cb498a | ||
|
|
21c16ed44e | ||
|
|
30638041d8 | ||
|
|
892f4f92ae | ||
|
|
bda0186d10 | ||
|
|
96c8836a98 | ||
|
|
c44a3f45de | ||
|
|
5783d086ad |
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -1,14 +1,23 @@
|
||||
name: tests
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '47 3 * * *'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
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:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -20,7 +29,7 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
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
|
||||
run: |
|
||||
# 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
|
||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=200 --statistics
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
pytest
|
||||
run: pytest
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,3 +14,5 @@ venv/
|
||||
!.devcontainer/
|
||||
!.devcontainer/*.json
|
||||
*.whl
|
||||
build/
|
||||
extract_otp_secret_keys.egg-info/
|
||||
|
||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.python"
|
||||
]
|
||||
}
|
||||
68
Pipfile.lock
generated
68
Pipfile.lock
generated
@@ -85,23 +85,23 @@
|
||||
},
|
||||
"protobuf": {
|
||||
"hashes": [
|
||||
"sha256:0413addc126c40a5440ee59be098de1007183d68e9f5f20ed5fbc44848f417ca",
|
||||
"sha256:05cbcb9a25cd781fd949f93f6f98a911883868c0360c6d2264fc99a903c8f0d7",
|
||||
"sha256:0c968753028cb14b1d24cc839723f7e9505b305fc588a37a9e0f7d270cb59d89",
|
||||
"sha256:2a172741b5b041a896b621cef4277077afd571e0d3a6e524e7171f1c70e33200",
|
||||
"sha256:3f08f04b4f101dd469efbcc1731fbf48068eccd8a42f4e2ea530aa012a5f56f8",
|
||||
"sha256:4d97c16c0d11155b3714a29245461f0eb60cace294455077f3a3b8a629afa383",
|
||||
"sha256:5096b3922b45e4b7a04d3d3cb855d13bb5ccd4d5e44b129e706232ebf0ffb870",
|
||||
"sha256:5efa8a8162ada7e10847140308fbf84fdc5b89dc21655d12ec04aed87284fe07",
|
||||
"sha256:6b809f20923b6ef49dc1755cb50bdb21be179b4a3c7ffcab1fe5d3f139b58a51",
|
||||
"sha256:81b233a06c62387ea5c9be2cd9aedd2ba09940e91da53b920e9ff5bd98e48e7f",
|
||||
"sha256:a5e89eabaa0ca72ce1b7c8104a740d44cdb67942cbbed00c69a4c0541de17107",
|
||||
"sha256:b78d7c2c36b51c0041b9bf000be4adb09f4112bfc40bc7a9d48ac0b0dfad139e",
|
||||
"sha256:e53165dd14d19abc7f50733f365de431e51d1d262db40c0ee22e271a074fac59",
|
||||
"sha256:e92768d17473657c87e98b79a4c7724b0ddfa23211b05ce137bfdc55e734e36f"
|
||||
"sha256:1f22ac0ca65bb70a876060d96d914dae09ac98d114294f77584b0d2644fa9c30",
|
||||
"sha256:237216c3326d46808a9f7c26fd1bd4b20015fb6867dc5d263a493ef9a539293b",
|
||||
"sha256:27f4d15021da6d2b706ddc3860fac0a5ddaba34ab679dc182b60a8bb4e1121cc",
|
||||
"sha256:299ea899484ee6f44604deb71f424234f654606b983cb496ea2a53e3c63ab791",
|
||||
"sha256:3d164928ff0727d97022957c2b849250ca0e64777ee31efd7d6de2e07c494717",
|
||||
"sha256:6ab80df09e3208f742c98443b6166bcb70d65f52cfeb67357d52032ea1ae9bec",
|
||||
"sha256:78a28c9fa223998472886c77042e9b9afb6fe4242bd2a2a5aced88e3f4422aa7",
|
||||
"sha256:7cd532c4566d0e6feafecc1059d04c7915aec8e182d1cf7adee8b24ef1e2e6ab",
|
||||
"sha256:89f9149e4a0169cddfc44c74f230d7743002e3aa0b9472d8c28f0388102fc4c2",
|
||||
"sha256:a53fd3f03e578553623272dc46ac2f189de23862e68565e83dde203d41b76fc5",
|
||||
"sha256:b135410244ebe777db80298297a97fbb4c862c881b4403b71bac9d4107d61fd1",
|
||||
"sha256:b98d0148f84e3a3c569e19f52103ca1feacdac0d2df8d6533cf983d1fda28462",
|
||||
"sha256:d1736130bce8cf131ac7957fa26880ca19227d4ad68b4888b3be0dea1f95df97",
|
||||
"sha256:f45460f9ee70a0ec1b6694c6e4e348ad2019275680bd68a1d9314b8c7e01e574"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.21.10"
|
||||
"version": "==4.21.12"
|
||||
},
|
||||
"qrcode": {
|
||||
"hashes": [
|
||||
@@ -133,7 +133,7 @@
|
||||
"sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0",
|
||||
"sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"markers": "python_version >= '3.11'",
|
||||
"version": "==0.3.6"
|
||||
},
|
||||
"flake8": {
|
||||
@@ -153,11 +153,11 @@
|
||||
},
|
||||
"isort": {
|
||||
"hashes": [
|
||||
"sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7",
|
||||
"sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"
|
||||
"sha256:83155ffa936239d986b0f190347a3f2285f42a9b9e1725c89d865b27dd0627e5",
|
||||
"sha256:a8ca25fbfad0f7d5d8447a4314837298d9f6b23aed8618584c894574f626b64b"
|
||||
],
|
||||
"markers": "python_version < '4.0' and python_full_version >= '3.6.1'",
|
||||
"version": "==5.10.1"
|
||||
"markers": "python_full_version >= '3.7.0'",
|
||||
"version": "==5.11.3"
|
||||
},
|
||||
"lazy-object-proxy": {
|
||||
"hashes": [
|
||||
@@ -194,19 +194,19 @@
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
"sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
|
||||
"sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
|
||||
"sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3",
|
||||
"sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==21.3"
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==22.0"
|
||||
},
|
||||
"platformdirs": {
|
||||
"hashes": [
|
||||
"sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7",
|
||||
"sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10"
|
||||
"sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca",
|
||||
"sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2.5.4"
|
||||
"version": "==2.6.0"
|
||||
},
|
||||
"pluggy": {
|
||||
"hashes": [
|
||||
@@ -234,19 +234,11 @@
|
||||
},
|
||||
"pylint": {
|
||||
"hashes": [
|
||||
"sha256:1d561d1d3e8be9dd880edc685162fbdaa0409c88b9b7400873c0cf345602e326",
|
||||
"sha256:91e4776dbcb4b4d921a3e4b6fec669551107ba11f29d9199154a01622e460a57"
|
||||
"sha256:18783cca3cfee5b83c6c5d10b3cdb66c6594520ffae61890858fe8d932e1c6b4",
|
||||
"sha256:349c8cd36aede4d50a0754a8c0218b43323d13d5d88f4b2952ddfe3e169681eb"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==2.15.7"
|
||||
},
|
||||
"pyparsing": {
|
||||
"hashes": [
|
||||
"sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb",
|
||||
"sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"
|
||||
],
|
||||
"markers": "python_full_version >= '3.6.8'",
|
||||
"version": "==3.0.9"
|
||||
"version": "==2.15.9"
|
||||
},
|
||||
"pytest": {
|
||||
"hashes": [
|
||||
|
||||
79
README.md
79
README.md
@@ -3,16 +3,21 @@
|
||||
[](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml)
|
||||

|
||||
[](https://github.com/scito/extract_otp_secret_keys/blob/master/Pipfile.lock)
|
||||

|
||||

|
||||
[](https://github.com/scito/extract_otp_secret_keys/blob/master/LICENSE)
|
||||
[](https://github.com/scito/extract_otp_secret_keys/tags)
|
||||
[](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.
|
||||
|
||||
## Installation
|
||||
|
||||
git clone https://github.com/scito/extract_otp_secret_keys.git
|
||||
cd extract_otp_secret_keys
|
||||
|
||||
## Usage
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
<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:
|
||||
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:
|
||||
-h, --help show this help message and exit
|
||||
--json FILE, -j FILE export json file
|
||||
--csv FILE, -c FILE export csv file
|
||||
--keepass FILE, -k FILE export totp/hotp csv file(s) for KeePass
|
||||
--json FILE, -j FILE export json file or - for stdout
|
||||
--csv FILE, -c FILE export csv file or - for stdout
|
||||
--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)
|
||||
--saveqr DIR, -s DIR save QR code(s) as images to the given folder (requires qrcode module)
|
||||
--verbose, -v verbose output
|
||||
--quiet, -q no stdout output</pre>
|
||||
--quiet, -q no stdout output, except output set by -</pre>
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -48,7 +53,7 @@ options:
|
||||
Known to work with
|
||||
|
||||
* 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.
|
||||
|
||||
@@ -58,6 +63,29 @@ For printing QR codes, the qrcode module is required, otherwise it can be omitte
|
||||
|
||||
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 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
|
||||
|
||||
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
|
||||
|
||||
* Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial
|
||||
* 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
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## 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 🇺🇦
|
||||
|
||||
@@ -12,3 +12,7 @@ otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXB
|
||||
|
||||
# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4
|
||||
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
|
||||
|
||||
@@ -3,3 +3,4 @@ raspberrypi,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
|
||||
,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
|
||||
,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
|
||||
raspberrypi,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
|
||||
,encoding: ¿äÄéÉ? (demo),7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP
|
||||
|
||||
|
@@ -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,raspberrypi,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||
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
|
||||
|
||||
|
@@ -38,5 +38,13 @@
|
||||
"type": "hotp",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -59,6 +59,10 @@ def sys_main():
|
||||
|
||||
def main(sys_args):
|
||||
global verbose, quiet
|
||||
|
||||
# allow to use sys.stdout with with (avoid closing)
|
||||
sys.stdout.close = lambda: None
|
||||
|
||||
args = parse_args(sys_args)
|
||||
verbose = args.verbose if args.verbose else 0
|
||||
quiet = args.quiet
|
||||
@@ -70,20 +74,20 @@ def main(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.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('--json', '-j', help='export json file', metavar=('FILE'))
|
||||
arg_parser.add_argument('--csv', '-c', help='export csv file', metavar=('FILE'))
|
||||
arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass', metavar=('FILE'))
|
||||
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 or - for stdout', 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, - 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'))
|
||||
arg_parser.add_argument('--verbose', '-v', help='verbose output', action='count')
|
||||
arg_parser.add_argument('--quiet', '-q', help='no stdout output', action='store_true')
|
||||
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')
|
||||
args = arg_parser.parse_args(sys_args)
|
||||
if args.verbose and args.quiet:
|
||||
print("The arguments --verbose and --quiet are mutually exclusive.")
|
||||
sys.exit(1)
|
||||
if args.csv == '-' or args.json == '-' or args.keepass == '-':
|
||||
args.quiet = args.q = True
|
||||
return args
|
||||
|
||||
|
||||
@@ -94,51 +98,58 @@ def extract_otps(args):
|
||||
otps = []
|
||||
|
||||
i = j = 0
|
||||
for line in (line.strip() for line in fileinput.input(args.infile)):
|
||||
if verbose: print(line)
|
||||
if line.startswith('#') or line == '': continue
|
||||
i += 1
|
||||
payload = get_payload_from_line(line, i, args)
|
||||
finput = fileinput.input(args.infile)
|
||||
try:
|
||||
for line in (line.strip() for line in finput):
|
||||
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()
|
||||
# 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)
|
||||
otps.append(otp)
|
||||
finally:
|
||||
finput.close()
|
||||
return otps
|
||||
|
||||
|
||||
def get_payload_from_line(line, i, args):
|
||||
global verbose
|
||||
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)
|
||||
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 '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)
|
||||
data_base64 = params['data'][0]
|
||||
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))
|
||||
data = base64.b64decode(data_base64_fixed, validate=True)
|
||||
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:
|
||||
print('\n{}. Payload Line'.format(i), payload, sep='\n')
|
||||
|
||||
@@ -216,7 +232,7 @@ def print_qr(args, data):
|
||||
def write_csv(args, otps):
|
||||
global verbose, quiet
|
||||
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.writeheader()
|
||||
writer.writerows(otps)
|
||||
@@ -233,7 +249,7 @@ def write_keepass_csv(args, otps):
|
||||
count_totp_entries = 0
|
||||
count_hotp_entries = 0
|
||||
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.writeheader()
|
||||
for otp in otps:
|
||||
@@ -246,7 +262,7 @@ def write_keepass_csv(args, otps):
|
||||
})
|
||||
count_totp_entries += 1
|
||||
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.writeheader()
|
||||
for otp in otps:
|
||||
@@ -267,7 +283,7 @@ def write_keepass_csv(args, otps):
|
||||
def write_json(args, otps):
|
||||
global verbose, quiet
|
||||
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)
|
||||
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 "")
|
||||
|
||||
|
||||
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__':
|
||||
sys_main()
|
||||
|
||||
60
setup.py
Normal file
60
setup.py
Normal 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)',
|
||||
)
|
||||
@@ -9,3 +9,7 @@ otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXB
|
||||
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||
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
|
||||
|
||||
@@ -112,3 +112,27 @@ Type: hotp
|
||||
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
|
||||
|
||||
|
||||
@@ -132,3 +132,32 @@ Counter: 4
|
||||
|
||||
|
||||
|
||||
Name: encoding: ¿äÄéÉ? (demo)
|
||||
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||
Type: totp
|
||||
|
||||
|
||||
█▀▀▀▀▀█ ▄▀▀ ▀ ▄▀█ ▄▄▄██▀ ▄▀█▄█▀ █▀▀▀▀▀█
|
||||
█ ███ █ █▄▀█▄▄ ▀ ▄▀█ █ █ ▀▀▀▄ █ ███ █
|
||||
█ ▀▀▀ █ ▄ ▀ ▀▄▀▄ ▄▄▀▄▄█▄ ▀▄ █▀▀█ █ ▀▀▀ █
|
||||
▀▀▀▀▀▀▀ █ █ █▄█▄▀ ▀▄▀ █▄█ ▀ █ ▀ █ ▀▀▀▀▀▀▀
|
||||
▀ ▄ ▀ █▀▀▄▀ ▄▄▀▀▄█▄ █▄▀▀▄█▀██▄▄█▀ ▄█▀▀▄
|
||||
▄▀█▄█ ▀ ▀ █▄█▄▄ ▄███▄▄▀▀▀▄▄▀▄ ▄█▀▄▄
|
||||
▀█▀ ▄▀▄▄█ ▄▀███▀ ▄▀█▀▄▀▄ ▀██▄▄ ▄█▀█ ▀▄
|
||||
▄▀ █▄▀▀ █▀▄▄▄ ▄█▄█ ▀▄ ▄▄ ▄ ▀▀█▄▄ ▀█▄▄▄
|
||||
▀ ▄▄▄▀▀▄▄█▀▄ ▀▀▀█▄ █▄ ▀ ▄█▄▄▀▄▀▀▀▄▄█▄ ▀ ▀
|
||||
█ ▄ ▀█ ▄▀ ██ █ ▄▄▀▀▀███▄ ▄▄██ ██▀█▀
|
||||
█▀█▀██▀▀███▄ ▀▀▄▄▄▄█▀ █ ▄█▄█▄▀ ▄▄▀ ▄▄ ▀
|
||||
▄██▄▄ ▀ ▀ ▀▀ ██▄▄▄▀▀▄█▀█▄ ▀ █▄▀▄ ▀▄▄
|
||||
███▄▀█▄█▄▄█ ▀█▄ ▀▄█ ▄▀▄ █▄ ▄ █▄ ▄▀▄▀
|
||||
█ ▄ ▄▀▀▄▄█▄▄█▄█ ▄▄▄ █▄▄▀█ █▀█▄ ▄▀▀█▄▄▄▀▀
|
||||
▄ ▀▄▀▀ ▄▄▀██ ▀▀▄█▀▀▄ ▀▀█ ▄ ████ █▀█▀█
|
||||
█▀▄▀█ ▀▄▄ ▄ ▀▀▀ ▄▀ ▀ █▀▄▀▀█▀▀█▄▀█ ▀▄▀▄ █
|
||||
▀ ▀ ▀▀ ▄ █▄▀█▀▀▄▀█ ▀▄▄█▄▀ ██ ▀██▀▀▀█▀▄▄
|
||||
█▀▀▀▀▀█ ▀█ ▄▄██▀ ▀██▄▀██ ▄▄██ ▀█ ▀ █ ▀█
|
||||
█ ███ █ █▄ █▀▀█▀▀▀█▀█ ▀ ▀█ █▀▀ ██▀▀▀███▀
|
||||
█ ▀▀▀ █ ▄ ▄▀█▄▄ ▀█ ▀▀ ▄ ▀█▀ ▄▀ █▀▀██ ▀▄
|
||||
▀▀▀▀▀▀▀ ▀ ▀ ▀ ▀▀ ▀ ▀ ▀▀▀▀ ▀ ▀ ▀ ▀
|
||||
|
||||
|
||||
|
||||
|
||||
1
test/test_export_wrong_content.txt
Normal file
1
test/test_export_wrong_content.txt
Normal 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.
|
||||
1
test/test_export_wrong_data.txt
Normal file
1
test/test_export_wrong_data.txt
Normal file
@@ -0,0 +1 @@
|
||||
otpauth-migration://offline?data=XXXX
|
||||
1
test/test_export_wrong_prefix.txt
Normal file
1
test/test_export_wrong_prefix.txt
Normal file
@@ -0,0 +1 @@
|
||||
QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B
|
||||
@@ -18,13 +18,40 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# 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 pytest import raises
|
||||
from pytest import raises, mark
|
||||
from io import StringIO
|
||||
from sys import implementation
|
||||
|
||||
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):
|
||||
# Arrange
|
||||
cleanup()
|
||||
@@ -47,6 +74,51 @@ def test_extract_csv(capsys):
|
||||
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):
|
||||
'''Two csv files .totp and .htop are generated.'''
|
||||
# Arrange
|
||||
@@ -74,6 +146,31 @@ def test_keepass_csv(capsys):
|
||||
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):
|
||||
'''Does not add .totp or .hotp pre-suffix'''
|
||||
# Arrange
|
||||
@@ -121,41 +218,25 @@ def test_extract_json(capsys):
|
||||
cleanup()
|
||||
|
||||
|
||||
def test_extract_stdout(capsys):
|
||||
def test_extract_json_stdout(capsys):
|
||||
# Arrange
|
||||
cleanup()
|
||||
|
||||
# Act
|
||||
extract_otp_secret_keys.main(['example_export.txt'])
|
||||
extract_otp_secret_keys.main(['-j', '-', 'example_export.txt'])
|
||||
|
||||
# Assert
|
||||
expected_json = read_json('example_output.json')
|
||||
assert not file_exits('test_example_output.json')
|
||||
captured = capsys.readouterr()
|
||||
actual_json = read_json_str(captured.out)
|
||||
|
||||
expected_stdout = '''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
|
||||
|
||||
'''
|
||||
|
||||
assert captured.out == expected_stdout
|
||||
assert actual_json == expected_json
|
||||
assert captured.err == ''
|
||||
|
||||
# Clean up
|
||||
cleanup()
|
||||
|
||||
|
||||
def test_extract_not_encoded_plus(capsys):
|
||||
# Act
|
||||
@@ -225,6 +306,7 @@ def test_extract_saveqr(capsys):
|
||||
cleanup()
|
||||
|
||||
|
||||
@mark.skipif(implementation.name == 'pypy', reason="Encoding problems in verbose mode in pypy.")
|
||||
def test_extract_verbose(capsys):
|
||||
# Act
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
with raises(SystemExit) as pytest_wrapped_e:
|
||||
# Act
|
||||
@@ -275,8 +371,75 @@ def test_verbose_and_quiet(capsys):
|
||||
# Assert
|
||||
captured = capsys.readouterr()
|
||||
|
||||
assert len(captured.out) > 0
|
||||
assert 'The arguments --verbose and --quiet are mutually exclusive.' in captured.out
|
||||
assert len(captured.err) > 0
|
||||
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):
|
||||
@@ -289,3 +452,33 @@ def cleanup():
|
||||
remove_files('test_example_*.csv')
|
||||
remove_files('test_example_*.json')
|
||||
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
|
||||
|
||||
'''
|
||||
|
||||
@@ -23,6 +23,7 @@ import io
|
||||
from contextlib import redirect_stdout
|
||||
from utils import read_csv, read_json, remove_file, remove_dir_with_files, Capturing, read_file_to_str
|
||||
from os import path
|
||||
from sys import implementation
|
||||
|
||||
import extract_otp_secret_keys
|
||||
|
||||
@@ -72,6 +73,10 @@ class TestExtract(unittest.TestCase):
|
||||
'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY',
|
||||
'Type: hotp',
|
||||
'Counter: 4',
|
||||
'',
|
||||
'Name: encoding: ¿äÄéÉ? (demo)',
|
||||
'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY',
|
||||
'Type: totp',
|
||||
''
|
||||
]
|
||||
self.assertEqual(output, expected_output)
|
||||
@@ -106,6 +111,10 @@ Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||
Type: hotp
|
||||
Counter: 4
|
||||
|
||||
Name: encoding: ¿äÄéÉ? (demo)
|
||||
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||
Type: totp
|
||||
|
||||
'''
|
||||
self.assertEqual(actual_output, expected_output)
|
||||
|
||||
@@ -157,6 +166,7 @@ Type: totp
|
||||
self.assertTrue(path.isfile('testout/qr/4-piraspberrypi-raspberrypi.png'))
|
||||
|
||||
def test_extract_verbose(self):
|
||||
if implementation.name == 'pypy': self.skipTest("Encoding problems in verbose mode in pypy.")
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
extract_otp_secret_keys.main(['-v', 'example_export.txt'])
|
||||
|
||||
@@ -181,11 +181,11 @@ eval "$cmd"
|
||||
|
||||
$PIP --version
|
||||
|
||||
cmd="$PIP install -U -r requirements.txt"
|
||||
cmd="$PIP install --use-pep517 -U -r requirements.txt"
|
||||
if $interactive ; then askContinueYn "$cmd"; fi
|
||||
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
|
||||
eval "$cmd"
|
||||
|
||||
|
||||
20
utils.py
20
utils.py
@@ -59,7 +59,7 @@ def remove_dir_with_files(dir):
|
||||
|
||||
def read_csv(filename):
|
||||
"""Returns a list of lines."""
|
||||
with open(filename, "r") as infile:
|
||||
with open(filename, "r", encoding="utf-8", newline='') as infile:
|
||||
lines = []
|
||||
reader = csv.reader(infile)
|
||||
for line in reader:
|
||||
@@ -67,15 +67,29 @@ def read_csv(filename):
|
||||
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):
|
||||
"""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)
|
||||
|
||||
|
||||
def read_json_str(str):
|
||||
"""Returns a list or a dictionary."""
|
||||
return json.loads(str)
|
||||
|
||||
|
||||
def read_file_to_list(filename):
|
||||
"""Returns a list of lines."""
|
||||
with open(filename, "r") as infile:
|
||||
with open(filename, "r", encoding="utf-8") as infile:
|
||||
return infile.readlines()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user