capture QR codes from camera and major refactoring
- add GUI for QR code capturing from camera (CV2 is used) - support different QR readers: ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT - support several input files - add option to ignore duplicate otps - write warnings and errors to stderr - add output coloring - rename project from extract_otp_secret_keys to extract_otp_secrets - improve help - clean verbose level output - use Python type hints and check with mypy - use f-strings - clean up code - add more tests - calculate code coverage - use src-layout: move files and folders - support wheel packing - enhance README.md - bugfixes * fix -k - * fix utf-8 encoding on windowspull/36/head v2.0.0c1
parent
9d052dc78a
commit
4ba0fad000
@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"ms-python.python"
|
"ms-python.python",
|
||||||
|
"ms-python.isort",
|
||||||
|
"tamasfe.even-better-toml",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Python: extract_otp_secrets.py",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "src/extract_otp_secrets.py",
|
||||||
|
"args": [
|
||||||
|
"example_export.txt"
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python: extract_otp_secrets.py stdin pic",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "src/extract_otp_secrets.py",
|
||||||
|
"args": [
|
||||||
|
"-",
|
||||||
|
"<",
|
||||||
|
"test/test_googleauth_export.png",
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Python: extract_otp_secrets.py capture",
|
||||||
|
"type": "python",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "src/extract_otp_secrets.py",
|
||||||
|
"args": [
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
@ -1,17 +1,32 @@
|
|||||||
FROM python:3.11-slim-bullseye
|
FROM python:3.11-slim-bullseye
|
||||||
|
|
||||||
|
# https://docs.docker.com/engine/reference/builder/
|
||||||
|
|
||||||
|
# For debugging
|
||||||
|
# docker build . -t extract_otp_secrets --pull --build-arg RUN_TESTS=false
|
||||||
|
# docker run --rm -v "$(pwd)":/files:ro extract_otp_secrets
|
||||||
|
# docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets
|
||||||
|
# docker run --entrypoint /bin/bash -it --rm -v "$(pwd)":/files:ro --device="/dev/video0:/dev/video0" --env="DISPLAY" -v /tmp/.X11-unix:/tmp/.X11-unix:ro extract_otp_secrets
|
||||||
|
|
||||||
WORKDIR /extract
|
WORKDIR /extract
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG run_tests=true
|
ARG RUN_TESTS=true
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y libzbar0 python3-opencv nano \
|
RUN apt-get update && apt-get install -y \
|
||||||
&& pip install -r requirements.txt \
|
libgl1 \
|
||||||
&& if [[ "$run_tests" == "true" ]] ; then /extract/run_pytest.sh ; else echo "Not running tests..." ; fi
|
libglib2.0-0 \
|
||||||
|
libsm6 \
|
||||||
|
libzbar0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& pip install --no-cache-dir -U -r \
|
||||||
|
requirements.txt \
|
||||||
|
&& if [ "$RUN_TESTS" = "true" ]; then /extract/run_pytest.sh; else echo "Not running tests..."; fi
|
||||||
|
|
||||||
WORKDIR /files
|
WORKDIR /files
|
||||||
|
|
||||||
ENTRYPOINT ["python", "/extract/extract_otp_secret_keys.py"]
|
ENTRYPOINT ["python", "/extract/src/extract_otp_secrets.py"]
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secret_keys
|
LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secrets
|
||||||
|
LABEL org.opencontainers.image.license GPL-3.0+
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
FROM python:3.11-alpine
|
|
||||||
|
|
||||||
WORKDIR /extract
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
ARG run_tests=true
|
|
||||||
|
|
||||||
RUN pip install protobuf qrcode Pillow \
|
|
||||||
&& if [[ "$run_tests" == "true" ]] ; then /extract/run_pytest.sh test_extract_otp_secret_keys_pytest.py -k "not qreader" --relaxed ; else echo "Not running tests..." ; fi
|
|
||||||
|
|
||||||
WORKDIR /files
|
|
||||||
|
|
||||||
ENTRYPOINT ["python", "/extract/extract_otp_secret_keys.py"]
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secret_keys
|
|
@ -0,0 +1,42 @@
|
|||||||
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
|
# https://docs.docker.com/engine/reference/builder/
|
||||||
|
|
||||||
|
# For debugging
|
||||||
|
# docker run --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt
|
||||||
|
# docker build . -t extract_otp_secrets_only_txt -f Dockerfile_only_txt --pull --build-arg RUN_TESTS=false
|
||||||
|
# docker run --entrypoint /bin/sh -it --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt
|
||||||
|
# docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt tests/extract_otp_secrets_test.py -k "not qreader" --relaxed -vvv -s
|
||||||
|
|
||||||
|
WORKDIR /extract
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG RUN_TESTS=true
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
jpeg \
|
||||||
|
zlib \
|
||||||
|
&& echo "Arch: $(apk --print-arch)" \
|
||||||
|
&& if [[ "$(apk --print-arch)" == "aarch64" ]]; then apk add --no-cache --virtual .build-deps \
|
||||||
|
gcc \
|
||||||
|
jpeg-dev \
|
||||||
|
libc-dev \
|
||||||
|
py3-setuptools \
|
||||||
|
python3-dev \
|
||||||
|
zlib-dev \
|
||||||
|
; fi \
|
||||||
|
&& pip install --no-cache-dir -U \
|
||||||
|
colorama \
|
||||||
|
Pillow \
|
||||||
|
protobuf \
|
||||||
|
qrcode \
|
||||||
|
&& if [[ "$(apk --print-arch)" == "aarch64" ]]; then apk del .build-deps; fi \
|
||||||
|
&& if [[ "$RUN_TESTS" == "true" ]]; then /extract/run_pytest.sh tests/extract_otp_secrets_test.py -k "not qreader" --relaxed; else echo "Not running tests..."; fi
|
||||||
|
|
||||||
|
WORKDIR /files
|
||||||
|
|
||||||
|
ENTRYPOINT ["python", "/extract/src/extract_otp_secrets.py"]
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secrets
|
||||||
|
LABEL org.opencontainers.image.license GPL-3.0+
|
@ -0,0 +1,407 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Upgrades Protoc from https://github.com/protocolbuffers/protobuf/releases
|
||||||
|
|
||||||
|
black='\e[0;30m'
|
||||||
|
blackBold='\e[1;30m'
|
||||||
|
blackBackground='\e[1;40m'
|
||||||
|
red='\e[0;31m'
|
||||||
|
redBold='\e[1;31m'
|
||||||
|
redBackground='\e[0;41m'
|
||||||
|
green='\e[0;32m'
|
||||||
|
greenBold='\e[1;32m'
|
||||||
|
greenBackground='\e[0;42m'
|
||||||
|
yellow='\e[0;33m'
|
||||||
|
yellowBold='\e[1;33m'
|
||||||
|
yellowBackground='\e[0;43m'
|
||||||
|
blue='\e[0;34m'
|
||||||
|
blueBold='\e[1;34m'
|
||||||
|
blueBackground='\e[0;44m'
|
||||||
|
magenta='\e[0;35m'
|
||||||
|
magentaBold='\e[1;35m'
|
||||||
|
magentaBackground='\e[0;45m'
|
||||||
|
cyan='\e[0;36m'
|
||||||
|
cyanBold='\e[1;36m'
|
||||||
|
cyanBackground='\e[0;46m'
|
||||||
|
white='\e[0;37m'
|
||||||
|
whiteBold='\e[1;37m'
|
||||||
|
whiteBackground='\e[0;47m'
|
||||||
|
reset='\e[0m'
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
echo '
|
||||||
|
***************
|
||||||
|
*** ABORTED ***
|
||||||
|
***************
|
||||||
|
' >&2
|
||||||
|
echo "An error occurred on line $1. Exiting..." >&2
|
||||||
|
date -Iseconds >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trap 'abort $LINENO' ERR
|
||||||
|
set -e -o pipefail
|
||||||
|
|
||||||
|
quit() {
|
||||||
|
trap : 0
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asks if [Yn] if script shoud continue, otherwise exit 1
|
||||||
|
# $1: msg or nothing
|
||||||
|
# Example call 1: askContinueYn
|
||||||
|
# Example call 1: askContinueYn "Backup DB?"
|
||||||
|
askContinueYn() {
|
||||||
|
if [[ $1 ]]; then
|
||||||
|
msg="$1 "
|
||||||
|
else
|
||||||
|
msg=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# http://stackoverflow.com/questions/3231804/in-bash-how-to-add-are-you-sure-y-n-to-any-command-or-alias
|
||||||
|
read -e -p "${msg}Continue? [Y/n] " response
|
||||||
|
response=${response,,} # tolower
|
||||||
|
if [[ $response =~ ^(yes|y|)$ ]] ; then
|
||||||
|
# echo ""
|
||||||
|
# OK
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo "Aborted"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reference: https://gist.github.com/steinwaywhw/a4cd19cda655b8249d908261a62687f8
|
||||||
|
|
||||||
|
echo "Checking Protoc version..."
|
||||||
|
VERSION=$(curl -sL https://github.com/protocolbuffers/protobuf/releases/latest | grep -E "<title>" | perl -pe's%.*Protocol Buffers v(\d+\.\d+(\.\d+)?).*%\1%')
|
||||||
|
BASEVERSION=4
|
||||||
|
echo
|
||||||
|
|
||||||
|
interactive=false
|
||||||
|
ignore_version_check=true
|
||||||
|
clean=false
|
||||||
|
build_docker=true
|
||||||
|
run_gui=true
|
||||||
|
generate_result_files=false
|
||||||
|
|
||||||
|
while test $# -gt 0; do
|
||||||
|
case $1 in
|
||||||
|
-h|--help)
|
||||||
|
echo "Upgrade Protoc"
|
||||||
|
echo
|
||||||
|
echo "$0 [options]"
|
||||||
|
echo
|
||||||
|
echo "Options:"
|
||||||
|
echo "-i Interactive"
|
||||||
|
echo "-C Ignore version check"
|
||||||
|
echo "-D No docker build"
|
||||||
|
echo "-G No not run gui"
|
||||||
|
echo "-c Clean"
|
||||||
|
echo "-r Generate result files"
|
||||||
|
echo "-h, --help Help"
|
||||||
|
quit
|
||||||
|
;;
|
||||||
|
-a)
|
||||||
|
interactive=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-C)
|
||||||
|
ignore_version_check=false
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-D)
|
||||||
|
build_docker=false
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-G)
|
||||||
|
run_gui=false
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-r)
|
||||||
|
generate_result_files=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-c)
|
||||||
|
clean=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
BIN="$HOME/bin"
|
||||||
|
DOWNLOADS="$HOME/downloads"
|
||||||
|
|
||||||
|
PYTHON="python3.11"
|
||||||
|
PIP="pip3.11"
|
||||||
|
PIPENV="$PYTHON -m pipenv"
|
||||||
|
FLAKE8="$PYTHON -m flake8"
|
||||||
|
MYPY="$PYTHON -m mypy"
|
||||||
|
|
||||||
|
# Upgrade protoc
|
||||||
|
|
||||||
|
DEST="protoc"
|
||||||
|
|
||||||
|
OLDVERSION=$(cat $BIN/$DEST/.VERSION.txt || echo "")
|
||||||
|
echo -e "\nProtoc remote version $VERSION\n"
|
||||||
|
echo -e "Protoc local version: $OLDVERSION\n"
|
||||||
|
|
||||||
|
if $clean; then
|
||||||
|
cmd="rm -r dist/ build/ *.whl pytest.xml pytest-coverage.txt .coverage tests/reports || true; find . -name '*.pyc' -type f -delete; find . -name '__pycache__' -type d -exec rm -r {} \; || true; find . -name '*.egg-info' -type d -exec rm -r {} \; || true; find . -name '*_cache' -type d -exec rm -r {} \; || true; mkdir -p tests/reports;"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="pipenv --rm || true"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="sudo pipenv --rm || true"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$OLDVERSION" != "$VERSION" ] || ! $ignore_version_check; then
|
||||||
|
echo "Upgrade protoc from $OLDVERSION to $VERSION"
|
||||||
|
|
||||||
|
NAME="protoc-$VERSION"
|
||||||
|
ARCHIVE="$NAME.zip"
|
||||||
|
|
||||||
|
mkdir -p $DOWNLOADS
|
||||||
|
# https://github.com/protocolbuffers/protobuf/releases/download/v21.6/protoc-21.6-linux-x86_64.zip
|
||||||
|
cmd="wget --trust-server-names https://github.com/protocolbuffers/protobuf/releases/download/v$VERSION/protoc-$VERSION-linux-x86_64.zip -O $DOWNLOADS/$ARCHIVE"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="echo -e '\nSize [Byte]'; stat --printf='%s\n' $DOWNLOADS/$ARCHIVE; echo -e '\nMD5'; md5sum $DOWNLOADS/$ARCHIVE; echo -e '\nSHA256'; sha256sum $DOWNLOADS/$ARCHIVE;"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="mkdir -p $BIN/$NAME; unzip $DOWNLOADS/$ARCHIVE -d $BIN/$NAME"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="echo $VERSION > $BIN/$NAME/.VERSION.txt; echo $VERSION > $BIN/$NAME/.VERSION_$VERSION.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="[ -d $BIN/$DEST.old ] && rm -rf $BIN/$DEST.old || echo 'No old dir to delete'"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="[ -d $BIN/$DEST ] && mv -iT $BIN/$DEST $BIN/$DEST.old || echo 'No previous dir to keep'"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="mv -iT $BIN/$NAME $BIN/$DEST"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="rm $DOWNLOADS/$ARCHIVE"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="$BIN/$DEST/bin/protoc --plugin=protoc-gen-mypy=$HOME/.local/bin/protoc-gen-mypy --python_out=src/protobuf_generated_python --mypy_out=src/protobuf_generated_python --proto_path=src google_auth.proto"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# Update README.md
|
||||||
|
|
||||||
|
cmd="perl -i -pe 's%proto(buf|c)([- ])(\d\.)?$OLDVERSION%proto\$1\$2\${3}$VERSION%g' README.md && perl -i -pe 's%(protobuf/releases/tag/v)$OLDVERSION%\${1}$VERSION%g' README.md"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
else
|
||||||
|
echo -e "\nVersion has not changed. Quit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Upgrade pip requirements
|
||||||
|
|
||||||
|
cmd="sudo pip install -U pip"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
$PIP --version
|
||||||
|
|
||||||
|
cmd="$PIP install --use-pep517 -U -r requirements.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="$PIP install --use-pep517 -U -r requirements-dev.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
|
||||||
|
LINT_OUT_FILE="tests/reports/flake8_results.txt"
|
||||||
|
cmd="$FLAKE8 . --count --select=E9,F63,F7,F82 --show-source --statistics | tee $LINT_OUT_FILE"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="$FLAKE8 . --count --exit-zero --max-complexity=10 --max-line-length=200 --statistics --exclude=.git,__pycache__,docs/source/conf.py,old,build,dist,protobuf_generated_python | tee -a $LINT_OUT_FILE"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
|
||||||
|
TYPE_CHECK_OUT_FILE="tests/reports/mypy_results.txt"
|
||||||
|
cmd="$MYPY --install-types --non-interactive src/*.py tests/*.py"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# change to src as python -m mypy adds the current dir Python sys.path
|
||||||
|
# execute in a subshell in order not to loose the exit code and not to change the dir in the currrent shell
|
||||||
|
cmd="$MYPY --strict src/*.py tests/*.py | tee $TYPE_CHECK_OUT_FILE"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# Test
|
||||||
|
|
||||||
|
cmd="$PYTHON src/extract_otp_secrets.py example_export.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="$PYTHON src/extract_otp_secrets.py - < example_export.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
COVERAGE_OUT_FILE="tests/reports/pytest-coverage.txt"
|
||||||
|
cmd="pytest --cov=extract_otp_secrets_test --junitxml=tests/reports/pytest.xml --cov-report html:tests/reports/html --cov-report=term-missing tests/ | tee $COVERAGE_OUT_FILE"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# Pipenv
|
||||||
|
|
||||||
|
cmd="$PIP install -U pipenv"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
$PIPENV --version
|
||||||
|
|
||||||
|
cmd="$PIPENV update && $PIPENV --rm && $PIPENV install"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
$PIPENV run python --version
|
||||||
|
|
||||||
|
cmd="$PIPENV run pytest --cov=extract_otp_secrets_test tests/"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# sudo pip
|
||||||
|
|
||||||
|
cmd="sudo $PIP install --use-pep517 -U -r requirements.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="sudo $PIP install --use-pep517 -U -r requirements-dev.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="sudo $PIP install -U pipenv"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# pip -e install (must be after other pip installs in order to have this environment for development)
|
||||||
|
|
||||||
|
cmd="$PIP install -U -e ."
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="extract_otp_secrets example_export.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="extract_otp_secrets - < example_export.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# Build wheel
|
||||||
|
|
||||||
|
cmd="$PIP wheel .
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
# Generate results files
|
||||||
|
|
||||||
|
if $generate_result_files; then
|
||||||
|
cmd="for color in '' '-n'; do for level in '' '-v' '-vv' '-vvv'; do $PYTHON src/extract_otp_secrets.py example_export.txt $color $level > tests/data/print_verbose_output$color$level.txt; done; done"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update Code Coverage in README.md
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/pytest-coverage-comment
|
||||||
|
# Coverage-95%25-yellowgreen
|
||||||
|
echo -e "Update code coverage in README.md"
|
||||||
|
TOTAL_COVERAGE=$(cat $COVERAGE_OUT_FILE | grep 'TOTAL' | perl -ne 'print "$&" if /\b(\d{1,3})%/') && perl -i -pe "s/coverage-(\d{1,3}%)25-/coverage-${TOTAL_COVERAGE}25-/" README.md
|
||||||
|
|
||||||
|
if $build_docker; then
|
||||||
|
# Build docker
|
||||||
|
|
||||||
|
# Build Dockerfile_only_txt (Alpine)
|
||||||
|
cmd="docker build . -t extract_otp_secrets_only_txt -f Dockerfile_only_txt --pull --build-arg RUN_TESTS=false"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="docker run --rm -v \"$(pwd)\":/files:ro extract_otp_secrets_only_txt example_export.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="docker run --rm -i -v \"$(pwd)\":/files:ro extract_otp_secrets_only_txt - < example_export.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="docker run --entrypoint /extract/run_pytest.sh --rm -v \"$(pwd)\":/files:ro extract_otp_secrets_only_txt tests/extract_otp_secrets_test.py -k 'not qreader' -vvv --relaxed"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
|
||||||
|
# Build extract_otp_secrets (Debian)
|
||||||
|
cmd="docker build . -t extract_otp_secrets --pull --build-arg RUN_TESTS=false"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="docker run --rm -v \"$(pwd)\":/files:ro extract_otp_secrets example_export.txt"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="cat example_export.txt | docker run --rm -i -v \"$(pwd)\":/files:ro extract_otp_secrets - -c - > example_output.csv"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="docker run --rm -i -v \"$(pwd)\":/files:ro extract_otp_secrets = < example_export.png"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="docker run --entrypoint /extract/run_pytest.sh --rm -v \"$(pwd)\":/files:ro extract_otp_secrets"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
cmd="docker image prune -f || echo 'No docker image pruned'"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
if $run_gui; then
|
||||||
|
cmd="docker run --rm -v "$(pwd)":/files:ro --device=\"/dev/video0:/dev/video0\" --env=\"DISPLAY\" -v /tmp/.X11-unix:/tmp/.X11-unix:ro extract_otp_secrets &"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $run_gui; then
|
||||||
|
cmd="$PYTHON src/extract_otp_secrets.py &"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
fi
|
||||||
|
|
||||||
|
line=$(printf '#%.0s' $(eval echo {1..$(( ($COLUMNS - 10) / 2))}))
|
||||||
|
echo -e "\n${blueBold}$line RESULTS $line${reset}"
|
||||||
|
|
||||||
|
cmd="cat $TYPE_CHECK_OUT_FILE $LINT_OUT_FILE $COVERAGE_OUT_FILE"
|
||||||
|
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
||||||
|
eval "$cmd"
|
||||||
|
|
||||||
|
echo -e "\n${greenBold}SUCCESS${reset}"
|
||||||
|
|
||||||
|
quit
|
@ -1,10 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
|
||||||
parser.addoption( "--relaxed", action='store_true', help="run tests in relaxed mode")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def relaxed(request):
|
|
||||||
return request.config.getoption("--relaxed")
|
|
Binary file not shown.
After Width: | Height: | Size: 528 KiB |
@ -1,451 +0,0 @@
|
|||||||
# Extract two-factor authentication (2FA, TFA) secret keys from export QR codes of "Google Authenticator" app
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# 1. Export the QR codes from "Google Authenticator" app
|
|
||||||
# 2. Read QR codes with QR code reader (e.g. with a second device)
|
|
||||||
# 3. Save the captured QR codes in a text file. Save each QR code on a new line. (The captured QR codes look like "otpauth-migration://offline?data=...")
|
|
||||||
# 4. Call this script with the file as input:
|
|
||||||
# python extract_otp_secret_keys.py example_export.txt
|
|
||||||
#
|
|
||||||
# Requirement:
|
|
||||||
# The protobuf package of Google for proto3 is required for running this script.
|
|
||||||
# pip install protobuf
|
|
||||||
#
|
|
||||||
# Optional:
|
|
||||||
# For printing QR codes, the qrcode module is required
|
|
||||||
# pip install qrcode
|
|
||||||
#
|
|
||||||
# Technical background:
|
|
||||||
# The export QR code of "Google Authenticator" contains the URL "otpauth-migration://offline?data=...".
|
|
||||||
# The data parameter is a base64 encoded proto3 message (Google Protocol Buffers).
|
|
||||||
#
|
|
||||||
# Command for regeneration of Python code from proto3 message definition file (only necessary in case of changes of the proto3 message definition):
|
|
||||||
# protoc --python_out=generated_python google_auth.proto
|
|
||||||
#
|
|
||||||
# References:
|
|
||||||
# Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial
|
|
||||||
# Template code: https://github.com/beemdevelopment/Aegis/pull/406
|
|
||||||
|
|
||||||
# Author: Scito (https://scito.ch)
|
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import csv
|
|
||||||
import fileinput
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import urllib.parse as urlparse
|
|
||||||
|
|
||||||
import protobuf_generated_python.google_auth_pb2
|
|
||||||
|
|
||||||
# These dynamic import are below:
|
|
||||||
# import cv2
|
|
||||||
# import numpy
|
|
||||||
# from qreader import QReader
|
|
||||||
|
|
||||||
def sys_main():
|
|
||||||
main(sys.argv[1:])
|
|
||||||
|
|
||||||
|
|
||||||
def main(sys_args):
|
|
||||||
global verbose, quiet, qreader_available
|
|
||||||
|
|
||||||
# allow to use sys.stdout with with (avoid closing)
|
|
||||||
sys.stdout.close = lambda: None
|
|
||||||
# sys.stdout.reconfigure(encoding='utf-8')
|
|
||||||
|
|
||||||
|
|
||||||
args = parse_args(sys_args)
|
|
||||||
verbose = args.verbose if args.verbose else 0
|
|
||||||
quiet = args.quiet
|
|
||||||
|
|
||||||
otps = extract_otps(args)
|
|
||||||
write_csv(args, otps)
|
|
||||||
write_keepass_csv(args, otps)
|
|
||||||
write_json(args, otps)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args(sys_args):
|
|
||||||
formatter = lambda prog: argparse.RawDescriptionHelpFormatter(prog, max_help_position=52)
|
|
||||||
example_text = '''examples:
|
|
||||||
python extract_otp_secret_keys.py example_*.txt
|
|
||||||
python extract_otp_secret_keys.py - < example_export.txt
|
|
||||||
python extract_otp_secret_keys.py --csv - example_*.png | tail -n+2
|
|
||||||
python extract_otp_secret_keys.py = < example_export.png'''
|
|
||||||
|
|
||||||
arg_parser = argparse.ArgumentParser(formatter_class=formatter,
|
|
||||||
epilog=example_text)
|
|
||||||
arg_parser.add_argument('infile', help='1) file or - for stdin with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored; or 2) image file containing a QR code or = for stdin for an image containing a QR code', nargs='+')
|
|
||||||
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'))
|
|
||||||
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.csv == '-' or args.json == '-' or args.keepass == '-':
|
|
||||||
args.quiet = args.q = True
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def extract_otps(args):
|
|
||||||
global verbose, quiet
|
|
||||||
quiet = args.quiet
|
|
||||||
|
|
||||||
otps = []
|
|
||||||
|
|
||||||
i = j = k = 0
|
|
||||||
if verbose: print('Input files: {}'.format(args.infile))
|
|
||||||
for infile in args.infile:
|
|
||||||
if verbose: print('Processing infile {}'.format(infile))
|
|
||||||
k += 1
|
|
||||||
for line in get_lines_from_file(infile):
|
|
||||||
if verbose: print(line)
|
|
||||||
if line.startswith('#') or line == '': continue
|
|
||||||
i += 1
|
|
||||||
payload = get_payload_from_line(line, i, infile)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
if verbose: print('{} infile(s) processed'.format(k))
|
|
||||||
return otps
|
|
||||||
|
|
||||||
|
|
||||||
def get_lines_from_file(filename):
|
|
||||||
global qreader_available
|
|
||||||
# stdin stream cannot be rewinded, thus distinguish, use - for utf-8 stdin and = for binary image stdin
|
|
||||||
if filename != '=':
|
|
||||||
check_file_exists(filename)
|
|
||||||
lines = read_lines_from_text_file(filename)
|
|
||||||
if lines or filename == '-':
|
|
||||||
return lines
|
|
||||||
|
|
||||||
# could not process text file, try reading as image
|
|
||||||
if filename != '-':
|
|
||||||
return convert_img_to_line(filename)
|
|
||||||
|
|
||||||
|
|
||||||
def read_lines_from_text_file(filename):
|
|
||||||
if verbose: print('Reading lines of {}'.format(filename))
|
|
||||||
finput = fileinput.input(filename)
|
|
||||||
try:
|
|
||||||
lines = []
|
|
||||||
for line in (line.strip() for line in finput):
|
|
||||||
if verbose: print(line)
|
|
||||||
if is_binary(line):
|
|
||||||
abort('\nBinary input was given in stdin, please use = instead of - as infile argument for images.')
|
|
||||||
# unfortunately yield line leads to random test fails
|
|
||||||
lines.append(line)
|
|
||||||
if not lines:
|
|
||||||
eprint("WARN: {} is empty".format(filename.replace('-', 'stdin')))
|
|
||||||
return lines
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
if filename == '-':
|
|
||||||
abort('\nERROR: Unable to open text file form stdin. '
|
|
||||||
'In case you want read an image file from stdin, you must use "=" instead of "-".')
|
|
||||||
else: # The file is probably an image, process below
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
finput.close()
|
|
||||||
|
|
||||||
|
|
||||||
def convert_img_to_line(filename):
|
|
||||||
try:
|
|
||||||
import cv2
|
|
||||||
import numpy
|
|
||||||
except Exception as e:
|
|
||||||
eprint("WARNING: No cv2 or numpy module installed. Exception: {}".format(str(e)))
|
|
||||||
return []
|
|
||||||
if verbose: print('Reading image {}'.format(filename))
|
|
||||||
try:
|
|
||||||
if filename != '=':
|
|
||||||
image = cv2.imread(filename)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
stdin = sys.stdin.buffer.read()
|
|
||||||
except AttributeError:
|
|
||||||
# Workaround for pytest, since pytest cannot monkeypatch sys.stdin.buffer
|
|
||||||
stdin = sys.stdin.read()
|
|
||||||
if not stdin:
|
|
||||||
eprint("WARN: stdin is empty")
|
|
||||||
try:
|
|
||||||
img_array = numpy.frombuffer(stdin, dtype='uint8')
|
|
||||||
except TypeError as e:
|
|
||||||
abort('\nERROR: Cannot read binary stdin buffer. Exception: {}'.format(str(e)))
|
|
||||||
if not img_array.size:
|
|
||||||
return []
|
|
||||||
image = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED)
|
|
||||||
|
|
||||||
if image is None:
|
|
||||||
abort('\nERROR: Unable to open file for reading.\ninput file: {}'.format(filename))
|
|
||||||
|
|
||||||
# dynamic import of QReader since this module has a dependency to zbar lib and import it only when necessary
|
|
||||||
try:
|
|
||||||
from qreader import QReader
|
|
||||||
except ImportError as e:
|
|
||||||
abort('''
|
|
||||||
ERROR: Cannot import QReader module. This problem is probably due to the missing zbar shared library.
|
|
||||||
On Linux and macOS libzbar0 must be installed.
|
|
||||||
See in README.md for the installation of the libzbar0.
|
|
||||||
Exception: {}'''.format(str(e)))
|
|
||||||
|
|
||||||
decoder = QReader()
|
|
||||||
decoded_text = decoder.detect_and_decode(image=image)
|
|
||||||
if decoded_text is None:
|
|
||||||
abort('\nERROR: Unable to read QR Code from file.\ninput file: {}'.format(filename))
|
|
||||||
|
|
||||||
return [decoded_text]
|
|
||||||
except Exception as e:
|
|
||||||
abort('\nERROR: Encountered exception "{}".\ninput file: {}'.format(str(e), filename))
|
|
||||||
|
|
||||||
|
|
||||||
def get_payload_from_line(line, i, infile):
|
|
||||||
global verbose
|
|
||||||
if not line.startswith('otpauth-migration://'):
|
|
||||||
eprint( '\nWARN: line is not a otpauth-migration:// URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(infile, line))
|
|
||||||
parsed_url = urlparse.urlparse(line)
|
|
||||||
if verbose > 1: print('\nDEBUG: parsed_url={}'.format(parsed_url))
|
|
||||||
try:
|
|
||||||
params = urlparse.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:
|
|
||||||
abort('\nERROR: no data query parameter in input URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(infile, line))
|
|
||||||
data_base64 = params['data'][0]
|
|
||||||
if verbose > 1: print('\nDEBUG: data_base64={}'.format(data_base64))
|
|
||||||
data_base64_fixed = data_base64.replace(' ', '+')
|
|
||||||
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()
|
|
||||||
try:
|
|
||||||
payload.ParseFromString(data)
|
|
||||||
except:
|
|
||||||
abort('\nERROR: Cannot decode otpauth-migration migration payload.\n'
|
|
||||||
'data={}'.format(data_base64))
|
|
||||||
if verbose:
|
|
||||||
print('\n{}. Payload Line'.format(i), payload, sep='\n')
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
# https://stackoverflow.com/questions/40226049/find-enums-listed-in-python-descriptor-for-protobuf
|
|
||||||
def get_enum_name_by_number(parent, field_name):
|
|
||||||
field_value = getattr(parent, field_name)
|
|
||||||
return parent.DESCRIPTOR.fields_by_name[field_name].enum_type.values_by_number.get(field_value).name
|
|
||||||
|
|
||||||
|
|
||||||
def get_otp_type_str_from_code(otp_type):
|
|
||||||
return 'totp' if otp_type == 2 else 'hotp'
|
|
||||||
|
|
||||||
|
|
||||||
def convert_secret_from_bytes_to_base32_str(bytes):
|
|
||||||
return str(base64.b32encode(bytes), 'utf-8').replace('=', '')
|
|
||||||
|
|
||||||
|
|
||||||
def build_otp_url(secret, raw_otp):
|
|
||||||
url_params = {'secret': secret}
|
|
||||||
if raw_otp.type == 1: url_params['counter'] = raw_otp.counter
|
|
||||||
if raw_otp.issuer: url_params['issuer'] = raw_otp.issuer
|
|
||||||
otp_url = 'otpauth://{}/{}?'.format(get_otp_type_str_from_code(raw_otp.type), urlparse.quote(raw_otp.name)) + urlparse.urlencode( url_params)
|
|
||||||
return otp_url
|
|
||||||
|
|
||||||
|
|
||||||
def print_otp(otp):
|
|
||||||
print('Name: {}'.format(otp['name']))
|
|
||||||
print('Secret: {}'.format(otp['secret']))
|
|
||||||
if otp['issuer']: print('Issuer: {}'.format(otp['issuer']))
|
|
||||||
print('Type: {}'.format(otp['type']))
|
|
||||||
if otp['type'] == 'hotp':
|
|
||||||
print('Counter: {}'.format(otp['counter']))
|
|
||||||
if verbose:
|
|
||||||
print(otp['url'])
|
|
||||||
|
|
||||||
|
|
||||||
def save_qr(otp, args, j):
|
|
||||||
dir = args.saveqr
|
|
||||||
if not (os.path.exists(dir)): os.makedirs(dir, exist_ok=True)
|
|
||||||
pattern = re.compile(r'[\W_]+')
|
|
||||||
file_otp_name = pattern.sub('', otp['name'])
|
|
||||||
file_otp_issuer = pattern.sub('', otp['issuer'])
|
|
||||||
save_qr_file(args, otp['url'], '{}/{}-{}{}.png'.format(dir, j, file_otp_name, '-' + file_otp_issuer if file_otp_issuer else ''))
|
|
||||||
return file_otp_issuer
|
|
||||||
|
|
||||||
|
|
||||||
def save_qr_file(args, data, name):
|
|
||||||
from qrcode import QRCode
|
|
||||||
global verbose
|
|
||||||
qr = QRCode()
|
|
||||||
qr.add_data(data)
|
|
||||||
img = qr.make_image(fill_color='black', back_color='white')
|
|
||||||
if verbose: print('Saving to {}'.format(name))
|
|
||||||
img.save(name)
|
|
||||||
|
|
||||||
|
|
||||||
def print_qr(args, data):
|
|
||||||
from qrcode import QRCode
|
|
||||||
qr = QRCode()
|
|
||||||
qr.add_data(data)
|
|
||||||
qr.print_ascii()
|
|
||||||
|
|
||||||
|
|
||||||
def write_csv(args, otps):
|
|
||||||
global verbose, quiet
|
|
||||||
if args.csv and len(otps) > 0:
|
|
||||||
with open_file_or_stdout_for_csv(args.csv) as outfile:
|
|
||||||
writer = csv.DictWriter(outfile, otps[0].keys())
|
|
||||||
writer.writeheader()
|
|
||||||
writer.writerows(otps)
|
|
||||||
if not quiet: print("Exported {} otps to csv {}".format(len(otps), args.csv))
|
|
||||||
|
|
||||||
|
|
||||||
def write_keepass_csv(args, otps):
|
|
||||||
global verbose, quiet
|
|
||||||
if args.keepass and len(otps) > 0:
|
|
||||||
has_totp = has_otp_type(otps, 'totp')
|
|
||||||
has_hotp = has_otp_type(otps, 'hotp')
|
|
||||||
otp_filename_totp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "totp")
|
|
||||||
otp_filename_hotp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "hotp")
|
|
||||||
count_totp_entries = 0
|
|
||||||
count_hotp_entries = 0
|
|
||||||
if has_totp:
|
|
||||||
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:
|
|
||||||
if otp['type'] == 'totp':
|
|
||||||
writer.writerow({
|
|
||||||
'Title': otp['issuer'],
|
|
||||||
'User Name': otp['name'],
|
|
||||||
'TimeOtp-Secret-Base32': otp['secret'] if otp['type'] == 'totp' else None,
|
|
||||||
'Group': "OTP/{}".format(otp['type'].upper())
|
|
||||||
})
|
|
||||||
count_totp_entries += 1
|
|
||||||
if has_hotp:
|
|
||||||
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:
|
|
||||||
if otp['type'] == 'hotp':
|
|
||||||
writer.writerow({
|
|
||||||
'Title': otp['issuer'],
|
|
||||||
'User Name': otp['name'],
|
|
||||||
'HmacOtp-Secret-Base32': otp['secret'] if otp['type'] == 'hotp' else None,
|
|
||||||
'HmacOtp-Counter': otp['counter'] if otp['type'] == 'hotp' else None,
|
|
||||||
'Group': "OTP/{}".format(otp['type'].upper())
|
|
||||||
})
|
|
||||||
count_hotp_entries += 1
|
|
||||||
if not quiet:
|
|
||||||
if count_totp_entries > 0: print( "Exported {} totp entries to keepass csv file {}".format(count_totp_entries, otp_filename_totp))
|
|
||||||
if count_hotp_entries > 0: print( "Exported {} hotp entries to keepass csv file {}".format(count_hotp_entries, otp_filename_hotp))
|
|
||||||
|
|
||||||
|
|
||||||
def write_json(args, otps):
|
|
||||||
global verbose, quiet
|
|
||||||
if args.json:
|
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
def has_otp_type(otps, otp_type):
|
|
||||||
for otp in otps:
|
|
||||||
if otp['type'] == otp_type:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def add_pre_suffix(file, pre_suffix):
|
|
||||||
'''filename.ext, pre -> filename.pre.ext'''
|
|
||||||
name, ext = os.path.splitext(file)
|
|
||||||
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 check_file_exists(filename):
|
|
||||||
if filename != '-' and not os.path.isfile(filename):
|
|
||||||
abort('\nERROR: Input file provided is non-existent or not a file.'
|
|
||||||
'\ninput file: {}'.format(filename))
|
|
||||||
|
|
||||||
|
|
||||||
def is_binary(line):
|
|
||||||
try:
|
|
||||||
line.startswith('#')
|
|
||||||
return False
|
|
||||||
except (UnicodeDecodeError, AttributeError, TypeError):
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def check_module_available(module_name):
|
|
||||||
module_spec = importlib.util.find_spec(module_name)
|
|
||||||
return module_spec is not None
|
|
||||||
|
|
||||||
|
|
||||||
def eprint(*args, **kwargs):
|
|
||||||
'''Print to stderr.'''
|
|
||||||
print(*args, file=sys.stderr, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def abort(*args, **kwargs):
|
|
||||||
eprint(*args, **kwargs)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys_main()
|
|
@ -0,0 +1,4 @@
|
|||||||
|
[mypy]
|
||||||
|
|
||||||
|
[mypy-protobuf_generated_python.*]
|
||||||
|
ignore_errors = True
|
@ -0,0 +1,66 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = [
|
||||||
|
"setuptools>=64.0.0", "wheel>=0.37.0", "pip",
|
||||||
|
# https://setuptools-git-versioning.readthedocs.io/en/latest/differences.html
|
||||||
|
"setuptools-git-versioning",
|
||||||
|
]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "extract_otp_secrets"
|
||||||
|
# https://pypi.org/classifiers/
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Environment :: Console",
|
||||||
|
"Environment :: X11 Applications :: Qt",
|
||||||
|
"Environment :: Win32 (MS Windows)",
|
||||||
|
"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)",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"protobuf",
|
||||||
|
"qrcode",
|
||||||
|
"Pillow",
|
||||||
|
"qreader",
|
||||||
|
"pyzbar",
|
||||||
|
"opencv-contrib-python<=4.7.0; sys_platform == 'darwin'",
|
||||||
|
"opencv-contrib-python; sys_platform != 'darwin'",
|
||||||
|
"typing_extensions; python_version<='3.7'",
|
||||||
|
"colorama>=0.4.6",
|
||||||
|
]
|
||||||
|
description = "Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps such as 'Google Authenticator'"
|
||||||
|
dynamic = ["version"]
|
||||||
|
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", "cv2"]
|
||||||
|
license = {text = "GNU General Public License v3 (GPLv3)"}
|
||||||
|
readme = "README.md"
|
||||||
|
authors = [{name = "scito", email = "info@scito.ch"}]
|
||||||
|
maintainers = [{name = "scito", email = "info@scito.ch"}]
|
||||||
|
requires-python = ">=3.7, <4"
|
||||||
|
scripts = {extract_otp_secrets = "extract_otp_secrets:sys_main"}
|
||||||
|
urls = {Project-URL = "https://github.com/scito/extract_otp_secrets", Bug-Reports = "https://github.com/scito/extract_otp_secrets/issues", Source = "https://github.com/scito/extract_otp_secrets"}
|
||||||
|
|
||||||
|
# [tool.setuptools]
|
||||||
|
# Still in beta, once it is stable move config from setup.cfg to pyproject.toml
|
||||||
|
# py-modules = ["extract_otp_secrets", "protobuf_generated_python.protobuf_generated_python"]
|
||||||
|
|
||||||
|
# [tool.setuptools.dynamic]
|
||||||
|
# version = {attr = "extract_otp_secrets.VERSION"}
|
||||||
|
|
||||||
|
[tool.setuptools-git-versioning]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# https://blog.ionelmc.ro/2014/05/25/python-packaging/#the-structure%3E
|
||||||
|
# https://docs.pytest.org/en/7.1.x/explanation/goodpractices.html#which-import-mode
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = [ "--import-mode=importlib", ]
|
@ -1,4 +1,10 @@
|
|||||||
wheel
|
|
||||||
pytest
|
|
||||||
flake8
|
flake8
|
||||||
|
mypy
|
||||||
|
types-protobuf
|
||||||
pylint
|
pylint
|
||||||
|
pytest
|
||||||
|
pytest-mock
|
||||||
|
pytest-cov
|
||||||
|
setuptools
|
||||||
|
wheel
|
||||||
|
build
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
cd /extract
|
cd /extract
|
||||||
pip install -U pytest && pytest "$@"
|
pip install -U pytest pytest-mock && pip install --no-deps . && pytest "$@"
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
[metadata]
|
||||||
|
name = extract_otp_secrets
|
||||||
|
|
||||||
|
[options]
|
||||||
|
python_requires = >=3.7, <4
|
||||||
|
py_modules = extract_otp_secrets, protobuf_generated_python.google_auth_pb2
|
||||||
|
package_dir =
|
||||||
|
=src
|
||||||
|
# packages=find:
|
||||||
|
|
||||||
|
# [options.packages.find]
|
||||||
|
# where=src
|
@ -0,0 +1,774 @@
|
|||||||
|
# Extract one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps such as "Google Authenticator"
|
||||||
|
#
|
||||||
|
# For more information, see README.md
|
||||||
|
#
|
||||||
|
# Source code available on https://github.com/scito/extract_otp_secrets
|
||||||
|
#
|
||||||
|
# Technical background:
|
||||||
|
# The export QR code from "Google Authenticator" contains the URL "otpauth-migration://offline?data=...".
|
||||||
|
# The data parameter is a base64 encoded proto3 message (Google Protocol Buffers).
|
||||||
|
#
|
||||||
|
# Command for regeneration of Python code from proto3 message definition file (only necessary in case of changes of the proto3 message definition):
|
||||||
|
# protoc --plugin=protoc-gen-mypy=path/to/protoc-gen-mypy --python_out=src/protobuf_generated_python --mypy_out=src/protobuf_generated_python --proto_path=src google_auth.proto
|
||||||
|
#
|
||||||
|
# References:
|
||||||
|
# Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial
|
||||||
|
# Template code: https://github.com/beemdevelopment/Aegis/pull/406
|
||||||
|
|
||||||
|
# Author: Scito (https://scito.ch)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations # workaround for PYTHON <= 3.10
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import csv
|
||||||
|
import fileinput
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.parse as urlparse
|
||||||
|
from enum import Enum, IntEnum
|
||||||
|
from typing import Any, List, Optional, TextIO, Tuple, Union
|
||||||
|
|
||||||
|
# workaround for PYTHON <= 3.7: compatibility
|
||||||
|
if sys.version_info >= (3, 8):
|
||||||
|
from typing import Final, TypedDict
|
||||||
|
else:
|
||||||
|
from typing_extensions import Final, TypedDict
|
||||||
|
|
||||||
|
from qrcode import QRCode # type: ignore
|
||||||
|
|
||||||
|
import protobuf_generated_python.google_auth_pb2 as pb
|
||||||
|
import colorama
|
||||||
|
|
||||||
|
debug_mode = '-d' in sys.argv[1:] or '--debug' in sys.argv[1:]
|
||||||
|
|
||||||
|
try:
|
||||||
|
import cv2 # type: ignore # TODO use cv2 types if available
|
||||||
|
|
||||||
|
import numpy as np # TODO use numpy types if available
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyzbar.pyzbar as zbar # type: ignore
|
||||||
|
from qreader import QReader # type: ignore
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"""
|
||||||
|
ERROR: Cannot import QReader module. This problem is probably due to the missing zbar shared library.
|
||||||
|
On Linux and macOS libzbar0 must be installed.
|
||||||
|
See in README.md for the installation of the libzbar0.
|
||||||
|
Exception: {e}\n""", file=sys.stderr)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# Types
|
||||||
|
# workaround for PYTHON <= 3.9: Final[tuple[int]]
|
||||||
|
ColorBGR = Tuple[int, int, int] # RGB Color specified as Blue, Green, Red
|
||||||
|
Point = Tuple[int, int]
|
||||||
|
|
||||||
|
# CV2 camera capture constants
|
||||||
|
FONT: Final[int] = cv2.FONT_HERSHEY_PLAIN
|
||||||
|
FONT_SCALE: Final[float] = 1.3
|
||||||
|
FONT_THICKNESS: Final[int] = 1
|
||||||
|
FONT_LINE_STYLE: Final[int] = cv2.LINE_AA
|
||||||
|
FONT_COLOR: Final[ColorBGR] = (255, 0, 0)
|
||||||
|
BOX_THICKNESS: Final[int] = 5
|
||||||
|
# workaround for PYTHON <= 3.7: must use () for assignments
|
||||||
|
WINDOW_X: Final[int] = 0
|
||||||
|
WINDOW_Y: Final[int] = 1
|
||||||
|
WINDOW_WIDTH: Final[int] = 2
|
||||||
|
WINDOW_HEIGHT: Final[int] = 3
|
||||||
|
TEXT_WIDTH: Final[int] = 0
|
||||||
|
TEXT_HEIGHT: Final[int] = 1
|
||||||
|
BORDER: Final[int] = 5
|
||||||
|
START_Y: Final[int] = 20
|
||||||
|
START_POS_TEXT: Final[Point] = (BORDER, START_Y)
|
||||||
|
NORMAL_COLOR: Final[ColorBGR] = (255, 0, 255)
|
||||||
|
SUCCESS_COLOR: Final[ColorBGR] = (0, 255, 0)
|
||||||
|
FAILURE_COLOR: Final[ColorBGR] = (0, 0, 255)
|
||||||
|
CHAR_DX: Final[int] = (lambda text: cv2.getTextSize(text, FONT, FONT_SCALE, FONT_THICKNESS)[0][TEXT_WIDTH] // len(text))("28 QR codes capturedMMM")
|
||||||
|
FONT_DY: Final[int] = cv2.getTextSize("M", FONT, FONT_SCALE, FONT_THICKNESS)[0][TEXT_HEIGHT] + 5
|
||||||
|
WINDOW_NAME: Final[str] = "Extract OTP Secrets: Capture QR Codes from Camera"
|
||||||
|
|
||||||
|
TextPosition = Enum('TextPosition', ['LEFT', 'RIGHT'])
|
||||||
|
|
||||||
|
qreader_available = True
|
||||||
|
except ImportError as e:
|
||||||
|
qreader_available = False
|
||||||
|
if debug_mode:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# Workaround for PYTHON <= 3.9: Union[int, None] used instead of int | None
|
||||||
|
|
||||||
|
# Types
|
||||||
|
Args = argparse.Namespace
|
||||||
|
OtpUrl = str
|
||||||
|
# workaround for PYTHON <= 3.7: Otp = TypedDict('Otp', {'name': str, 'secret': str, 'issuer': str, 'type': str, 'counter': int | None, 'url': OtpUrl})
|
||||||
|
Otp = TypedDict('Otp', {'name': str, 'secret': str, 'issuer': str, 'type': str, 'counter': Union[int, None], 'url': OtpUrl})
|
||||||
|
# workaround for PYTHON <= 3.9: Otps = list[Otp]
|
||||||
|
Otps = List[Otp]
|
||||||
|
# workaround for PYTHON <= 3.9: OtpUrls = list[OtpUrl]
|
||||||
|
OtpUrls = List[OtpUrl]
|
||||||
|
|
||||||
|
QRMode = Enum('QRMode', ['ZBAR', 'QREADER', 'QREADER_DEEP', 'CV2', 'CV2_WECHAT'], start=0)
|
||||||
|
LogLevel = IntEnum('LogLevel', ['QUIET', 'NORMAL', 'VERBOSE', 'MORE_VERBOSE', 'DEBUG'], start=-1)
|
||||||
|
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
CAMERA: Final[str] = 'camera'
|
||||||
|
|
||||||
|
# Global variable declaration
|
||||||
|
verbose: IntEnum = LogLevel.NORMAL
|
||||||
|
quiet: bool = False
|
||||||
|
colored: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def sys_main() -> None:
|
||||||
|
main(sys.argv[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def main(sys_args: list[str]) -> None:
|
||||||
|
# allow to use sys.stdout with with (avoid closing)
|
||||||
|
sys.stdout.close = lambda: None # type: ignore
|
||||||
|
# set encoding to utf-8, needed for Windows
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8') # type: ignore
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8') # type: ignore
|
||||||
|
except AttributeError: # '_io.StringIO' object has no attribute 'reconfigure'
|
||||||
|
# StringIO in tests do not have all attributes, ignore it
|
||||||
|
pass
|
||||||
|
|
||||||
|
args = parse_args(sys_args)
|
||||||
|
|
||||||
|
if colored:
|
||||||
|
colorama.just_fix_windows_console()
|
||||||
|
|
||||||
|
if args.debug:
|
||||||
|
sys.exit(0 if do_debug_checks() else 1)
|
||||||
|
|
||||||
|
otps = extract_otps(args)
|
||||||
|
write_csv(args, otps)
|
||||||
|
write_keepass_csv(args, otps)
|
||||||
|
write_json(args, otps)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(sys_args: list[str]) -> Args:
|
||||||
|
global verbose, quiet, colored
|
||||||
|
description_text = "Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps"
|
||||||
|
if qreader_available:
|
||||||
|
description_text += "\nIf no infiles are provided, a GUI window starts and QR codes are captured from the camera."
|
||||||
|
example_text = """examples:
|
||||||
|
python extract_otp_secrets.py
|
||||||
|
python extract_otp_secrets.py example_*.txt
|
||||||
|
python extract_otp_secrets.py - < example_export.txt
|
||||||
|
python extract_otp_secrets.py --csv - example_*.png | tail -n+2
|
||||||
|
python extract_otp_secrets.py = < example_export.png"""
|
||||||
|
|
||||||
|
arg_parser = argparse.ArgumentParser(formatter_class=lambda prog: argparse.RawTextHelpFormatter(prog, max_help_position=32),
|
||||||
|
description=description_text,
|
||||||
|
epilog=example_text)
|
||||||
|
arg_parser.add_argument('infile', help="""a) file or - for stdin with 'otpauth-migration://...' URLs separated by newlines, lines starting with # are ignored;
|
||||||
|
b) image file containing a QR code or = for stdin for an image containing a QR code""", nargs='*' if qreader_available else '+')
|
||||||
|
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('--json', '-j', help='export json file or - 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'))
|
||||||
|
if qreader_available:
|
||||||
|
arg_parser.add_argument('--camera', '-C', help='camera number of system (default camera: 0)', default=0, type=int, metavar=('NUMBER'))
|
||||||
|
arg_parser.add_argument('--qr', '-Q', help=f'QR reader (default: {QRMode.ZBAR.name})', type=str, choices=[mode.name for mode in QRMode], default=QRMode.ZBAR.name)
|
||||||
|
arg_parser.add_argument('-i', '--ignore', help='ignore duplicate otps', action='store_true')
|
||||||
|
arg_parser.add_argument('--no-color', '-n', help='do not use ANSI colors in console output', action='store_true')
|
||||||
|
output_group = arg_parser.add_mutually_exclusive_group()
|
||||||
|
output_group.add_argument('-d', '--debug', help='enter debug mode, do checks and quit', action='count')
|
||||||
|
output_group.add_argument('-v', '--verbose', help='verbose output', action='count')
|
||||||
|
output_group.add_argument('-q', '--quiet', help='no stdout output, except output set by -', action='store_true')
|
||||||
|
args = arg_parser.parse_args(sys_args)
|
||||||
|
colored = not args.no_color
|
||||||
|
if args.csv == '-' or args.json == '-' or args.keepass == '-':
|
||||||
|
args.quiet = args.q = True
|
||||||
|
|
||||||
|
verbose = args.verbose if args.verbose else LogLevel.NORMAL
|
||||||
|
if args.debug:
|
||||||
|
verbose = LogLevel.DEBUG
|
||||||
|
log_debug('Debug mode start')
|
||||||
|
quiet = True if args.quiet else False
|
||||||
|
if verbose: print(f"QReader installed: {qreader_available}")
|
||||||
|
if qreader_available:
|
||||||
|
if verbose >= LogLevel.VERBOSE: print(f"CV2 version: {cv2.__version__}")
|
||||||
|
if verbose: print(f"QR reading mode: {args.qr}\n")
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def extract_otps(args: Args) -> Otps:
|
||||||
|
if not args.infile:
|
||||||
|
return extract_otps_from_camera(args)
|
||||||
|
else:
|
||||||
|
return extract_otps_from_files(args)
|
||||||
|
|
||||||
|
|
||||||
|
def get_color(new_otps_count: int, otp_url: str) -> ColorBGR:
|
||||||
|
if new_otps_count:
|
||||||
|
return SUCCESS_COLOR
|
||||||
|
else:
|
||||||
|
if otp_url:
|
||||||
|
return FAILURE_COLOR
|
||||||
|
else:
|
||||||
|
return NORMAL_COLOR
|
||||||
|
|
||||||
|
|
||||||
|
# TODO use cv2 types if available
|
||||||
|
def cv2_draw_box(img: Any, raw_pts: Any, color: ColorBGR) -> Any:
|
||||||
|
pts = np.array([raw_pts], np.int32)
|
||||||
|
pts = pts.reshape((-1, 1, 2))
|
||||||
|
cv2.polylines(img, [pts], True, color, BOX_THICKNESS)
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
# TODO use cv2 types if available
|
||||||
|
def cv2_print_text(img: Any, text: str, line_number: int, position: TextPosition, color: ColorBGR, opposite_len: Optional[int] = None) -> None:
|
||||||
|
window_dim = cv2.getWindowImageRect(WINDOW_NAME)
|
||||||
|
out_text = text
|
||||||
|
if opposite_len:
|
||||||
|
text_dim, _ = cv2.getTextSize(out_text, FONT, FONT_SCALE, FONT_THICKNESS)
|
||||||
|
actual_width = text_dim[TEXT_WIDTH] + opposite_len * CHAR_DX + 4 * BORDER
|
||||||
|
if actual_width >= window_dim[WINDOW_WIDTH]:
|
||||||
|
out_text = out_text[:(window_dim[WINDOW_WIDTH] - actual_width) // CHAR_DX] + '.'
|
||||||
|
text_dim, _ = cv2.getTextSize(out_text, FONT, FONT_SCALE, FONT_THICKNESS)
|
||||||
|
if position == TextPosition.LEFT:
|
||||||
|
pos = BORDER, START_Y + line_number * FONT_DY
|
||||||
|
else:
|
||||||
|
pos = window_dim[WINDOW_WIDTH] - text_dim[TEXT_WIDTH] - BORDER, START_Y + line_number * FONT_DY
|
||||||
|
|
||||||
|
cv2.putText(img, out_text, pos, FONT, FONT_SCALE, color, FONT_THICKNESS, FONT_LINE_STYLE)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_otps_from_camera(args: Args) -> Otps:
|
||||||
|
if verbose: print("Capture QR codes from camera")
|
||||||
|
otp_urls: OtpUrls = []
|
||||||
|
otps: Otps = []
|
||||||
|
|
||||||
|
qr_mode = QRMode[args.qr]
|
||||||
|
|
||||||
|
cam = cv2.VideoCapture(args.camera)
|
||||||
|
cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_AUTOSIZE)
|
||||||
|
|
||||||
|
qreader = QReader()
|
||||||
|
cv2_qr = cv2.QRCodeDetector()
|
||||||
|
cv2_qr_wechat = cv2.wechat_qrcode.WeChatQRCode()
|
||||||
|
while True:
|
||||||
|
success, img = cam.read()
|
||||||
|
new_otps_count = 0
|
||||||
|
if not success:
|
||||||
|
log_error("Failed to capture image from camera")
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
if qr_mode in [QRMode.QREADER, QRMode.QREADER_DEEP]:
|
||||||
|
found, bbox = qreader.detect(img)
|
||||||
|
if qr_mode == QRMode.QREADER_DEEP:
|
||||||
|
otp_url = qreader.detect_and_decode(img, True)
|
||||||
|
elif qr_mode == QRMode.QREADER:
|
||||||
|
otp_url = qreader.decode(img, bbox) if found else None
|
||||||
|
if otp_url:
|
||||||
|
new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args)
|
||||||
|
if found:
|
||||||
|
cv2.rectangle(img, (bbox[0], bbox[1]), (bbox[2], bbox[3]), get_color(new_otps_count, otp_url), BOX_THICKNESS)
|
||||||
|
elif qr_mode == QRMode.ZBAR:
|
||||||
|
for qrcode in zbar.decode(img):
|
||||||
|
otp_url = qrcode.data.decode('utf-8')
|
||||||
|
new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args)
|
||||||
|
cv2_draw_box(img, [qrcode.polygon], get_color(new_otps_count, otp_url))
|
||||||
|
elif qr_mode in [QRMode.CV2, QRMode.CV2_WECHAT]:
|
||||||
|
if QRMode.CV2:
|
||||||
|
otp_url, raw_pts, _ = cv2_qr.detectAndDecode(img)
|
||||||
|
else:
|
||||||
|
otp_url, raw_pts = cv2_qr_wechat.detectAndDecode(img)
|
||||||
|
if raw_pts is not None:
|
||||||
|
if otp_url:
|
||||||
|
new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args)
|
||||||
|
cv2_draw_box(img, raw_pts, get_color(new_otps_count, otp_url))
|
||||||
|
else:
|
||||||
|
abort(f"Invalid QReader mode: {qr_mode.name}")
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f'An error occured during QR detection and decoding for QR reader {qr_mode}. Changed to the next QR reader.', e)
|
||||||
|
qr_mode = next_qr_mode(qr_mode)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cv2_print_text(img, f"Mode: {qr_mode.name} (Hit space to change)", 0, TextPosition.LEFT, FONT_COLOR, 20)
|
||||||
|
cv2_print_text(img, "Hit ESC to quit", 1, TextPosition.LEFT, FONT_COLOR, 17)
|
||||||
|
|
||||||
|
cv2_print_text(img, f"{len(otp_urls)} QR code{'s'[:len(otp_urls) != 1]} captured", 0, TextPosition.RIGHT, FONT_COLOR)
|
||||||
|
cv2_print_text(img, f"{len(otps)} otp{'s'[:len(otps) != 1]} extracted", 1, TextPosition.RIGHT, FONT_COLOR)
|
||||||
|
|
||||||
|
cv2.imshow(WINDOW_NAME, img)
|
||||||
|
|
||||||
|
quit, qr_mode = cv2_handle_pressed_keys(qr_mode)
|
||||||
|
if quit:
|
||||||
|
break
|
||||||
|
|
||||||
|
cam.release()
|
||||||
|
cv2.destroyAllWindows()
|
||||||
|
|
||||||
|
return otps
|
||||||
|
|
||||||
|
|
||||||
|
def cv2_handle_pressed_keys(qr_mode: QRMode) -> Tuple[bool, QRMode]:
|
||||||
|
key = cv2.waitKey(1) & 0xFF
|
||||||
|
quit = False
|
||||||
|
if key == 27 or key == ord('q') or key == 13:
|
||||||
|
# ESC or Enter or q pressed
|
||||||
|
quit = True
|
||||||
|
elif key == 32:
|
||||||
|
qr_mode = next_qr_mode(qr_mode)
|
||||||
|
if verbose >= LogLevel.MORE_VERBOSE: print(f"QR reading mode: {qr_mode}")
|
||||||
|
if cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE) < 1:
|
||||||
|
# Window close clicked
|
||||||
|
quit = True
|
||||||
|
return quit, qr_mode
|
||||||
|
|
||||||
|
|
||||||
|
def extract_otps_from_otp_url(otp_url: str, otp_urls: OtpUrls, otps: Otps, args: Args) -> int:
|
||||||
|
'''Returns -1 if opt_url was already added.'''
|
||||||
|
if otp_url and verbose >= LogLevel.VERBOSE: print(otp_url)
|
||||||
|
if not otp_url:
|
||||||
|
return 0
|
||||||
|
if otp_url not in otp_urls:
|
||||||
|
new_otps_count = extract_otp_from_otp_url(otp_url, otps, len(otp_urls), CAMERA, args)
|
||||||
|
if new_otps_count:
|
||||||
|
otp_urls.append(otp_url)
|
||||||
|
if verbose: print(f"Extracted {new_otps_count} otp{'s'[:len(otps) != 1]}. {len(otps)} otp{'s'[:len(otps) != 1]} from {len(otp_urls)} QR code{'s'[:len(otp_urls) != 1]} extracted")
|
||||||
|
return new_otps_count
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def extract_otps_from_files(args: Args) -> Otps:
|
||||||
|
otps: Otps = []
|
||||||
|
|
||||||
|
files_count = urls_count = otps_count = 0
|
||||||
|
if verbose: print(f"Input files: {args.infile}")
|
||||||
|
for infile in args.infile:
|
||||||
|
if verbose >= LogLevel.MORE_VERBOSE: log_verbose(f"Processing infile {infile}")
|
||||||
|
files_count += 1
|
||||||
|
for line in get_otp_urls_from_file(infile, args):
|
||||||
|
if verbose >= LogLevel.MORE_VERBOSE: log_verbose(line)
|
||||||
|
if line.startswith('#') or line == '': continue
|
||||||
|
urls_count += 1
|
||||||
|
otps_count += extract_otp_from_otp_url(line, otps, urls_count, infile, args)
|
||||||
|
if verbose: print(f"Extracted {otps_count} otp{'s'[:otps_count != 1]} from {urls_count} otp url{'s'[:urls_count != 1]} by reading {files_count} infile{'s'[:files_count != 1]}")
|
||||||
|
return otps
|
||||||
|
|
||||||
|
|
||||||
|
def get_otp_urls_from_file(filename: str, args: Args) -> OtpUrls:
|
||||||
|
# stdin stream cannot be rewinded, thus distinguish, use - for utf-8 stdin and = for binary image stdin
|
||||||
|
if filename != '=':
|
||||||
|
check_file_exists(filename)
|
||||||
|
lines = read_lines_from_text_file(filename)
|
||||||
|
if lines or filename == '-':
|
||||||
|
return lines
|
||||||
|
|
||||||
|
# could not process text file, try reading as image
|
||||||
|
if filename != '-' and qreader_available:
|
||||||
|
return convert_img_to_otp_urls(filename, args)
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def read_lines_from_text_file(filename: str) -> list[str]:
|
||||||
|
if verbose >= LogLevel.DEBUG: print(f"Reading lines of {filename}")
|
||||||
|
# workaround for PYTHON <= 3.9 support encoding
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
finput = fileinput.input(filename, encoding='utf-8')
|
||||||
|
else:
|
||||||
|
finput = fileinput.input(filename)
|
||||||
|
try:
|
||||||
|
lines = []
|
||||||
|
for line in (line.strip() for line in finput):
|
||||||
|
if verbose >= LogLevel.DEBUG: log_verbose(line)
|
||||||
|
if is_binary(line):
|
||||||
|
abort("Binary input was given in stdin, please use = instead of - as infile argument for images.")
|
||||||
|
# unfortunately yield line leads to random test fails
|
||||||
|
lines.append(line)
|
||||||
|
if not lines:
|
||||||
|
log_warn(f"{filename.replace('-', 'stdin')} is empty")
|
||||||
|
except UnicodeDecodeError as e:
|
||||||
|
if filename == '-':
|
||||||
|
abort("Unable to open text file form stdin. "
|
||||||
|
"In case you want read an image file from stdin, you must use '=' instead of '-'.", e)
|
||||||
|
else: # The file is probably an image, process below
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
finput.close()
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count: int, infile: str, args: Args) -> int:
|
||||||
|
payload = get_payload_from_otp_url(otpauth_migration_url, urls_count, infile)
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
new_otps_count = 0
|
||||||
|
# pylint: disable=no-member
|
||||||
|
for raw_otp in payload.otp_parameters:
|
||||||
|
if verbose: print(f"\n{len(otps) + 1}. Secret")
|
||||||
|
secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret)
|
||||||
|
if verbose >= LogLevel.DEBUG: log_debug('OTP enum type:', 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: 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 otp not in otps or not args.ignore:
|
||||||
|
otps.append(otp)
|
||||||
|
new_otps_count += 1
|
||||||
|
if not quiet:
|
||||||
|
print_otp(otp)
|
||||||
|
if args.printqr:
|
||||||
|
print_qr(args, otp_url)
|
||||||
|
if args.saveqr:
|
||||||
|
save_qr(otp, args, len(otps))
|
||||||
|
if not quiet:
|
||||||
|
print()
|
||||||
|
elif args.ignore and not quiet:
|
||||||
|
eprint(f"Ignored duplicate otp: {otp['name']}", f" / {otp['issuer']}\n" if otp['issuer'] else '\n', sep='')
|
||||||
|
|
||||||
|
return new_otps_count
|
||||||
|
|
||||||
|
|
||||||
|
def convert_img_to_otp_urls(filename: str, args: Args) -> OtpUrls:
|
||||||
|
if verbose: print(f"Reading image {filename}")
|
||||||
|
try:
|
||||||
|
if filename != '=':
|
||||||
|
img = cv2.imread(filename)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
stdin = sys.stdin.buffer.read()
|
||||||
|
except AttributeError:
|
||||||
|
# Workaround for pytest, since pytest cannot monkeypatch sys.stdin.buffer
|
||||||
|
stdin = sys.stdin.read() # type: ignore # Workaround for pytest fixtures
|
||||||
|
if not stdin:
|
||||||
|
log_warn("stdin is empty")
|
||||||
|
try:
|
||||||
|
img_array = np.frombuffer(stdin, dtype='uint8')
|
||||||
|
except TypeError as e:
|
||||||
|
abort("Cannot read binary stdin buffer.", e)
|
||||||
|
if not img_array.size:
|
||||||
|
return []
|
||||||
|
img = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED)
|
||||||
|
|
||||||
|
if img is None:
|
||||||
|
abort(f"Unable to open file for reading.\ninput file: {filename}")
|
||||||
|
|
||||||
|
qr_mode = QRMode[args.qr]
|
||||||
|
otp_urls = decode_qr_img_otp_urls(img, qr_mode)
|
||||||
|
if len(otp_urls) == 0:
|
||||||
|
abort(f"Unable to read QR Code from file.\ninput file: {filename}")
|
||||||
|
except Exception as e:
|
||||||
|
abort(f"Encountered exception\ninput file: {filename}", e)
|
||||||
|
return otp_urls
|
||||||
|
|
||||||
|
|
||||||
|
def decode_qr_img_otp_urls(img: Any, qr_mode: QRMode) -> OtpUrls:
|
||||||
|
otp_urls: OtpUrls = []
|
||||||
|
if qr_mode in [QRMode.QREADER, QRMode.QREADER_DEEP]:
|
||||||
|
otp_url = QReader().detect_and_decode(img, qr_mode == QRMode.QREADER_DEEP)
|
||||||
|
otp_urls.append(otp_url)
|
||||||
|
elif qr_mode == QRMode.CV2:
|
||||||
|
otp_url, _, _ = cv2.QRCodeDetector().detectAndDecode(img)
|
||||||
|
otp_urls.append(otp_url)
|
||||||
|
elif qr_mode == QRMode.CV2_WECHAT:
|
||||||
|
otp_url, _ = cv2.wechat_qrcode.WeChatQRCode().detectAndDecode(img)
|
||||||
|
otp_urls += list(otp_url)
|
||||||
|
elif qr_mode == QRMode.ZBAR:
|
||||||
|
qrcodes = zbar.decode(img)
|
||||||
|
otp_urls += [qrcode.data.decode('utf-8') for qrcode in qrcodes]
|
||||||
|
else:
|
||||||
|
assert False, f"Wrong QReader mode {qr_mode.name}"
|
||||||
|
|
||||||
|
return otp_urls
|
||||||
|
|
||||||
|
|
||||||
|
# workaround for PYTHON <= 3.9 use: pb.MigrationPayload | None
|
||||||
|
def get_payload_from_otp_url(otp_url: str, i: int, source: str) -> Optional[pb.MigrationPayload]:
|
||||||
|
'''Extracts the otp migration payload from an otp url. This function is the core of the this appliation.'''
|
||||||
|
if not is_opt_url(otp_url, source):
|
||||||
|
return None
|
||||||
|
parsed_url = urlparse.urlparse(otp_url)
|
||||||
|
if verbose >= LogLevel.DEBUG: log_debug(f"parsed_url={parsed_url}")
|
||||||
|
try:
|
||||||
|
params = urlparse.parse_qs(parsed_url.query, strict_parsing=True)
|
||||||
|
except Exception: # workaround for PYTHON <= 3.10
|
||||||
|
params = {}
|
||||||
|
if verbose >= LogLevel.DEBUG: log_debug(f"querystring params={params}")
|
||||||
|
if 'data' not in params:
|
||||||
|
log_error(f"could not parse query parameter in input url\nsource: {source}\nurl: {otp_url}")
|
||||||
|
return None
|
||||||
|
data_base64 = params['data'][0]
|
||||||
|
if verbose >= LogLevel.DEBUG: log_debug(f"data_base64={data_base64}")
|
||||||
|
data_base64_fixed = data_base64.replace(' ', '+')
|
||||||
|
if verbose >= LogLevel.DEBUG: log_debug(f"data_base64_fixed={data_base64_fixed}")
|
||||||
|
data = base64.b64decode(data_base64_fixed, validate=True)
|
||||||
|
payload = pb.MigrationPayload()
|
||||||
|
try:
|
||||||
|
payload.ParseFromString(data)
|
||||||
|
except Exception as e:
|
||||||
|
abort(f"Cannot decode otpauth-migration migration payload.\n"
|
||||||
|
f"data={data_base64}", e)
|
||||||
|
if verbose >= LogLevel.DEBUG: log_debug(f"\n{i}. Payload Line", payload, sep='\n')
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def is_opt_url(otp_url: str, source: str) -> bool:
|
||||||
|
if not otp_url.startswith('otpauth-migration://'):
|
||||||
|
msg = f"input is not a otpauth-migration:// url\nsource: {source}\ninput: {otp_url}"
|
||||||
|
if source == CAMERA:
|
||||||
|
log_warn(f"{msg}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
log_warn(f"{msg}\nMaybe a wrong file was given")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# https://stackoverflow.com/questions/40226049/find-enums-listed-in-python-descriptor-for-protobuf
|
||||||
|
def get_enum_name_by_number(parent: Any, field_name: str) -> str:
|
||||||
|
field_value = getattr(parent, field_name)
|
||||||
|
return parent.DESCRIPTOR.fields_by_name[field_name].enum_type.values_by_number.get(field_value).name # type: ignore # generic code
|
||||||
|
|
||||||
|
|
||||||
|
def get_otp_type_str_from_code(otp_type: int) -> str:
|
||||||
|
return 'totp' if otp_type == 2 else 'hotp'
|
||||||
|
|
||||||
|
|
||||||
|
def convert_secret_from_bytes_to_base32_str(bytes: bytes) -> str:
|
||||||
|
return str(base64.b32encode(bytes), 'utf-8').replace('=', '')
|
||||||
|
|
||||||
|
|
||||||
|
def build_otp_url(secret: str, raw_otp: pb.MigrationPayload.OtpParameters) -> str:
|
||||||
|
url_params = {'secret': secret}
|
||||||
|
if raw_otp.type == 1: url_params['counter'] = str(raw_otp.counter)
|
||||||
|
if raw_otp.issuer: url_params['issuer'] = raw_otp.issuer
|
||||||
|
otp_url = f"otpauth://{get_otp_type_str_from_code(raw_otp.type)}/{urlparse.quote(raw_otp.name)}?" + urlparse.urlencode(url_params)
|
||||||
|
return otp_url
|
||||||
|
|
||||||
|
|
||||||
|
def print_otp(otp: Otp) -> None:
|
||||||
|
print(f"Name: {otp['name']}")
|
||||||
|
print(f"Secret: {otp['secret']}")
|
||||||
|
if otp['issuer']: print(f"Issuer: {otp['issuer']}")
|
||||||
|
print(f"Type: {otp['type']}")
|
||||||
|
if otp['type'] == 'hotp':
|
||||||
|
print(f"Counter: {otp['counter']}")
|
||||||
|
if verbose:
|
||||||
|
print(otp['url'])
|
||||||
|
|
||||||
|
|
||||||
|
def save_qr(otp: Otp, args: Args, j: int) -> str:
|
||||||
|
dir = args.saveqr
|
||||||
|
if not (os.path.exists(dir)): os.makedirs(dir, exist_ok=True)
|
||||||
|
pattern = re.compile(r'[\W_]+')
|
||||||
|
file_otp_name = pattern.sub('', otp['name'])
|
||||||
|
file_otp_issuer = pattern.sub('', otp['issuer'])
|
||||||
|
save_qr_file(args, otp['url'], f"{dir}/{j}-{file_otp_name}{'-' + file_otp_issuer if file_otp_issuer else ''}.png")
|
||||||
|
return file_otp_name
|
||||||
|
|
||||||
|
|
||||||
|
def save_qr_file(args: Args, otp_url: OtpUrl, name: str) -> None:
|
||||||
|
qr = QRCode()
|
||||||
|
qr.add_data(otp_url)
|
||||||
|
img = qr.make_image(fill_color='black', back_color='white')
|
||||||
|
if verbose: print(f"Saving to {name}")
|
||||||
|
img.save(name)
|
||||||
|
|
||||||
|
|
||||||
|
def print_qr(args: Args, otp_url: str) -> None:
|
||||||
|
qr = QRCode()
|
||||||
|
qr.add_data(otp_url)
|
||||||
|
qr.print_ascii()
|
||||||
|
|
||||||
|
|
||||||
|
def write_csv(args: Args, otps: Otps) -> None:
|
||||||
|
if args.csv and len(otps) > 0:
|
||||||
|
with open_file_or_stdout_for_csv(args.csv) as outfile:
|
||||||
|
writer = csv.DictWriter(outfile, otps[0].keys())
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(otps)
|
||||||
|
if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to csv {args.csv}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_keepass_csv(args: Args, otps: Otps) -> None:
|
||||||
|
if args.keepass and len(otps) > 0:
|
||||||
|
has_totp = has_otp_type(otps, 'totp')
|
||||||
|
has_hotp = has_otp_type(otps, 'hotp')
|
||||||
|
if args.keepass != '-':
|
||||||
|
otp_filename_totp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "totp")
|
||||||
|
otp_filename_hotp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "hotp")
|
||||||
|
else:
|
||||||
|
otp_filename_totp = otp_filename_hotp = '-'
|
||||||
|
if has_totp:
|
||||||
|
count_totp_entries = write_keepass_totp_csv(otp_filename_totp, otps)
|
||||||
|
if has_hotp:
|
||||||
|
count_hotp_entries = write_keepass_htop_csv(otp_filename_hotp, otps)
|
||||||
|
if not quiet:
|
||||||
|
if count_totp_entries: print(f"Exported {count_totp_entries} totp entrie{'s'[:count_totp_entries != 1]} to keepass csv file {otp_filename_totp}")
|
||||||
|
if count_hotp_entries: print(f"Exported {count_hotp_entries} hotp entrie{'s'[:count_hotp_entries != 1]} to keepass csv file {otp_filename_hotp}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_keepass_totp_csv(otp_filename: str, otps: Otps) -> int:
|
||||||
|
count_entries = 0
|
||||||
|
with open_file_or_stdout_for_csv(otp_filename) as outfile:
|
||||||
|
writer = csv.DictWriter(outfile, ["Title", "User Name", "TimeOtp-Secret-Base32", "Group"])
|
||||||
|
writer.writeheader()
|
||||||
|
for otp in otps:
|
||||||
|
if otp['type'] == 'totp':
|
||||||
|
writer.writerow({
|
||||||
|
'Title': otp['issuer'],
|
||||||
|
'User Name': otp['name'],
|
||||||
|
'TimeOtp-Secret-Base32': otp['secret'] if otp['type'] == 'totp' else None,
|
||||||
|
'Group': f"OTP/{otp['type'].upper()}"
|
||||||
|
})
|
||||||
|
count_entries += 1
|
||||||
|
return count_entries
|
||||||
|
|
||||||
|
|
||||||
|
def write_keepass_htop_csv(otp_filename: str, otps: Otps) -> int:
|
||||||
|
count_entries = 0
|
||||||
|
with open_file_or_stdout_for_csv(otp_filename) as outfile:
|
||||||
|
writer = csv.DictWriter(outfile, ["Title", "User Name", "HmacOtp-Secret-Base32", "HmacOtp-Counter", "Group"])
|
||||||
|
writer.writeheader()
|
||||||
|
for otp in otps:
|
||||||
|
if otp['type'] == 'hotp':
|
||||||
|
writer.writerow({
|
||||||
|
'Title': otp['issuer'],
|
||||||
|
'User Name': otp['name'],
|
||||||
|
'HmacOtp-Secret-Base32': otp['secret'] if otp['type'] == 'hotp' else None,
|
||||||
|
'HmacOtp-Counter': otp['counter'] if otp['type'] == 'hotp' else None,
|
||||||
|
'Group': f"OTP/{otp['type'].upper()}"
|
||||||
|
})
|
||||||
|
count_entries += 1
|
||||||
|
return count_entries
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(args: Args, otps: Otps) -> None:
|
||||||
|
if args.json:
|
||||||
|
with open_file_or_stdout(args.json) as outfile:
|
||||||
|
json.dump(otps, outfile, indent=4)
|
||||||
|
if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to json {args.json}")
|
||||||
|
|
||||||
|
|
||||||
|
def has_otp_type(otps: Otps, otp_type: str) -> bool:
|
||||||
|
for otp in otps:
|
||||||
|
if otp['type'] == otp_type:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def add_pre_suffix(file: str, pre_suffix: str) -> str:
|
||||||
|
'''filename.ext, pre -> filename.pre.ext'''
|
||||||
|
name, ext = os.path.splitext(file)
|
||||||
|
return name + "." + pre_suffix + (ext if ext else "")
|
||||||
|
|
||||||
|
|
||||||
|
def open_file_or_stdout(filename: str) -> TextIO:
|
||||||
|
'''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: str) -> TextIO:
|
||||||
|
'''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 check_file_exists(filename: str) -> None:
|
||||||
|
if filename != '-' and not os.path.isfile(filename):
|
||||||
|
abort(f"Input file provided is non-existent or not a file."
|
||||||
|
f"\ninput file: {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_binary(line: str) -> bool:
|
||||||
|
try:
|
||||||
|
line.startswith('#')
|
||||||
|
return False
|
||||||
|
except (UnicodeDecodeError, AttributeError, TypeError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def next_qr_mode(qr_mode: QRMode) -> QRMode:
|
||||||
|
return QRMode((qr_mode.value + 1) % len(QRMode))
|
||||||
|
|
||||||
|
|
||||||
|
def do_debug_checks() -> bool:
|
||||||
|
log_debug('Do debug checks')
|
||||||
|
log_debug('Try: import cv2')
|
||||||
|
import cv2 # noqa: F401 # This is only a debug import
|
||||||
|
log_debug('Try: import numpy as np')
|
||||||
|
import numpy as np # noqa: F401 # This is only a debug import
|
||||||
|
print(color('\nDebug checks passed', colorama.Fore.GREEN))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# workaround for PYTHON <= 3.9 use: BaseException | None
|
||||||
|
def log_debug(*values: object, sep: Optional[str] = ' ') -> None:
|
||||||
|
if colored:
|
||||||
|
print(f"{colorama.Fore.CYAN}\nDEBUG: {str(values[0])}", *values[1:], colorama.Fore.RESET, sep)
|
||||||
|
else:
|
||||||
|
print(f"\nDEBUG: {str(values[0])}", *values[1:], sep)
|
||||||
|
|
||||||
|
|
||||||
|
# workaround for PYTHON <= 3.9 use: BaseException | None
|
||||||
|
def log_verbose(msg: str) -> None:
|
||||||
|
print(color(msg, colorama.Fore.CYAN))
|
||||||
|
|
||||||
|
|
||||||
|
# workaround for PYTHON <= 3.9 use: BaseException | None
|
||||||
|
def log_warn(msg: str, exception: Optional[BaseException] = None) -> None:
|
||||||
|
exception_text = "\nException: "
|
||||||
|
eprint(color(f"\nWARN: {msg}{(exception_text + str(exception)) if exception else ''}", colorama.Fore.RED))
|
||||||
|
|
||||||
|
|
||||||
|
# workaround for PYTHON <= 3.9 use: BaseException | None
|
||||||
|
def log_error(msg: str, exception: Optional[BaseException] = None) -> None:
|
||||||
|
exception_text = "\nException: "
|
||||||
|
eprint(color(f"\nERROR: {msg}{(exception_text + str(exception)) if exception else ''}", colorama.Fore.RED))
|
||||||
|
|
||||||
|
|
||||||
|
def color(msg: str, color: Optional[str] = None) -> str:
|
||||||
|
return f"{color if colored and color else ''}{msg}{colorama.Fore.RESET if colored and color else ''}"
|
||||||
|
|
||||||
|
|
||||||
|
def eprint(*values: object, **kwargs: Any) -> None:
|
||||||
|
'''Print to stderr.'''
|
||||||
|
print(*values, file=sys.stderr, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# workaround for PYTHON <= 3.9 use: BaseException | None
|
||||||
|
def abort(msg: str, exception: Optional[BaseException] = None) -> None:
|
||||||
|
log_error(msg, exception)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys_main()
|
@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
@generated by mypy-protobuf. Do not edit manually!
|
||||||
|
isort:skip_file
|
||||||
|
"""
|
||||||
|
import builtins
|
||||||
|
import collections.abc
|
||||||
|
import google.protobuf.descriptor
|
||||||
|
import google.protobuf.internal.containers
|
||||||
|
import google.protobuf.internal.enum_type_wrapper
|
||||||
|
import google.protobuf.message
|
||||||
|
import sys
|
||||||
|
import typing
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 10):
|
||||||
|
import typing as typing_extensions
|
||||||
|
else:
|
||||||
|
import typing_extensions
|
||||||
|
|
||||||
|
DESCRIPTOR: google.protobuf.descriptor.FileDescriptor
|
||||||
|
|
||||||
|
@typing_extensions.final
|
||||||
|
class MigrationPayload(google.protobuf.message.Message):
|
||||||
|
"""Copied from: https://github.com/beemdevelopment/Aegis/blob/master/app/src/main/proto/google_auth.proto"""
|
||||||
|
|
||||||
|
DESCRIPTOR: google.protobuf.descriptor.Descriptor
|
||||||
|
|
||||||
|
class _Algorithm:
|
||||||
|
ValueType = typing.NewType("ValueType", builtins.int)
|
||||||
|
V: typing_extensions.TypeAlias = ValueType
|
||||||
|
|
||||||
|
class _AlgorithmEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[MigrationPayload._Algorithm.ValueType], builtins.type): # noqa: F821
|
||||||
|
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor
|
||||||
|
ALGO_INVALID: MigrationPayload._Algorithm.ValueType # 0
|
||||||
|
ALGO_SHA1: MigrationPayload._Algorithm.ValueType # 1
|
||||||
|
|
||||||
|
class Algorithm(_Algorithm, metaclass=_AlgorithmEnumTypeWrapper): ...
|
||||||
|
ALGO_INVALID: MigrationPayload.Algorithm.ValueType # 0
|
||||||
|
ALGO_SHA1: MigrationPayload.Algorithm.ValueType # 1
|
||||||
|
|
||||||
|
class _OtpType:
|
||||||
|
ValueType = typing.NewType("ValueType", builtins.int)
|
||||||
|
V: typing_extensions.TypeAlias = ValueType
|
||||||
|
|
||||||
|
class _OtpTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[MigrationPayload._OtpType.ValueType], builtins.type): # noqa: F821
|
||||||
|
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor
|
||||||
|
OTP_INVALID: MigrationPayload._OtpType.ValueType # 0
|
||||||
|
OTP_HOTP: MigrationPayload._OtpType.ValueType # 1
|
||||||
|
OTP_TOTP: MigrationPayload._OtpType.ValueType # 2
|
||||||
|
|
||||||
|
class OtpType(_OtpType, metaclass=_OtpTypeEnumTypeWrapper): ...
|
||||||
|
OTP_INVALID: MigrationPayload.OtpType.ValueType # 0
|
||||||
|
OTP_HOTP: MigrationPayload.OtpType.ValueType # 1
|
||||||
|
OTP_TOTP: MigrationPayload.OtpType.ValueType # 2
|
||||||
|
|
||||||
|
@typing_extensions.final
|
||||||
|
class OtpParameters(google.protobuf.message.Message):
|
||||||
|
DESCRIPTOR: google.protobuf.descriptor.Descriptor
|
||||||
|
|
||||||
|
SECRET_FIELD_NUMBER: builtins.int
|
||||||
|
NAME_FIELD_NUMBER: builtins.int
|
||||||
|
ISSUER_FIELD_NUMBER: builtins.int
|
||||||
|
ALGORITHM_FIELD_NUMBER: builtins.int
|
||||||
|
DIGITS_FIELD_NUMBER: builtins.int
|
||||||
|
TYPE_FIELD_NUMBER: builtins.int
|
||||||
|
COUNTER_FIELD_NUMBER: builtins.int
|
||||||
|
secret: builtins.bytes
|
||||||
|
name: builtins.str
|
||||||
|
issuer: builtins.str
|
||||||
|
algorithm: global___MigrationPayload.Algorithm.ValueType
|
||||||
|
digits: builtins.int
|
||||||
|
type: global___MigrationPayload.OtpType.ValueType
|
||||||
|
counter: builtins.int
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
secret: builtins.bytes = ...,
|
||||||
|
name: builtins.str = ...,
|
||||||
|
issuer: builtins.str = ...,
|
||||||
|
algorithm: global___MigrationPayload.Algorithm.ValueType = ...,
|
||||||
|
digits: builtins.int = ...,
|
||||||
|
type: global___MigrationPayload.OtpType.ValueType = ...,
|
||||||
|
counter: builtins.int = ...,
|
||||||
|
) -> None: ...
|
||||||
|
def ClearField(self, field_name: typing_extensions.Literal["algorithm", b"algorithm", "counter", b"counter", "digits", b"digits", "issuer", b"issuer", "name", b"name", "secret", b"secret", "type", b"type"]) -> None: ...
|
||||||
|
|
||||||
|
OTP_PARAMETERS_FIELD_NUMBER: builtins.int
|
||||||
|
VERSION_FIELD_NUMBER: builtins.int
|
||||||
|
BATCH_SIZE_FIELD_NUMBER: builtins.int
|
||||||
|
BATCH_INDEX_FIELD_NUMBER: builtins.int
|
||||||
|
BATCH_ID_FIELD_NUMBER: builtins.int
|
||||||
|
@property
|
||||||
|
def otp_parameters(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___MigrationPayload.OtpParameters]: ...
|
||||||
|
version: builtins.int
|
||||||
|
batch_size: builtins.int
|
||||||
|
batch_index: builtins.int
|
||||||
|
batch_id: builtins.int
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
otp_parameters: collections.abc.Iterable[global___MigrationPayload.OtpParameters] | None = ...,
|
||||||
|
version: builtins.int = ...,
|
||||||
|
batch_size: builtins.int = ...,
|
||||||
|
batch_index: builtins.int = ...,
|
||||||
|
batch_id: builtins.int = ...,
|
||||||
|
) -> None: ...
|
||||||
|
def ClearField(self, field_name: typing_extensions.Literal["batch_id", b"batch_id", "batch_index", b"batch_index", "batch_size", b"batch_size", "otp_parameters", b"otp_parameters", "version", b"version"]) -> None: ...
|
||||||
|
|
||||||
|
global___MigrationPayload = MigrationPayload
|
@ -1,756 +0,0 @@
|
|||||||
# pytest for extract_otp_secret_keys.py
|
|
||||||
|
|
||||||
# Run tests:
|
|
||||||
# pytest
|
|
||||||
|
|
||||||
# Author: Scito (https://scito.ch)
|
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import extract_otp_secret_keys
|
|
||||||
from utils import *
|
|
||||||
|
|
||||||
qreader_available = extract_otp_secret_keys.check_module_available('cv2')
|
|
||||||
|
|
||||||
|
|
||||||
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 == ''
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.qreader
|
|
||||||
def test_extract_multiple_files_and_mixed(capsys):
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main([
|
|
||||||
'example_export.txt',
|
|
||||||
'test/test_googleauth_export.png',
|
|
||||||
'example_export.txt',
|
|
||||||
'test/test_googleauth_export.png'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_non_existent_file(capsys):
|
|
||||||
# Act
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
extract_otp_secret_keys.main(['test/non_existent_file.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stderr = '\nERROR: Input file provided is non-existent or not a file.\ninput file: test/non_existent_file.txt\n'
|
|
||||||
|
|
||||||
assert captured.err == expected_stderr
|
|
||||||
assert captured.out == ''
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_stdin_stdout(capsys, monkeypatch):
|
|
||||||
# Arrange
|
|
||||||
monkeypatch.setattr('sys.stdin', io.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_stdin_empty(capsys, monkeypatch):
|
|
||||||
# Arrange
|
|
||||||
monkeypatch.setattr('sys.stdin', io.StringIO())
|
|
||||||
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == ''
|
|
||||||
assert captured.err == 'WARN: stdin is empty\n'
|
|
||||||
|
|
||||||
|
|
||||||
# @pytest.mark.skipif(not qreader_available, reason='Test if cv2 and qreader are not available.')
|
|
||||||
def test_extract_empty_file_no_qreader(capsys):
|
|
||||||
if qreader_available:
|
|
||||||
# Act
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
extract_otp_secret_keys.main(['test/empty_file.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stderr = 'WARN: test/empty_file.txt is empty\n\nERROR: Unable to open file for reading.\ninput file: test/empty_file.txt\n'
|
|
||||||
|
|
||||||
assert captured.err == expected_stderr
|
|
||||||
assert captured.out == ''
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
else:
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['test/empty_file.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.err == ''
|
|
||||||
assert captured.out == ''
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.qreader
|
|
||||||
def test_extract_stdin_img_empty(capsys, monkeypatch):
|
|
||||||
# Arrange
|
|
||||||
monkeypatch.setattr('sys.stdin', io.BytesIO())
|
|
||||||
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['='])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == ''
|
|
||||||
assert captured.err == 'WARN: stdin is empty\n'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.qreader
|
|
||||||
def test_extract_stdin_stdout_wrong_symbol(capsys, monkeypatch):
|
|
||||||
# Arrange
|
|
||||||
monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt')))
|
|
||||||
|
|
||||||
# Act
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
extract_otp_secret_keys.main(['='])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stderr = "\nERROR: Cannot read binary stdin buffer. Exception: a bytes-like object is required, not 'str'\n"
|
|
||||||
|
|
||||||
assert captured.err == expected_stderr
|
|
||||||
assert captured.out == ''
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_csv(capsys):
|
|
||||||
# Arrange
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-q', '-c', 'test_example_output.csv', 'example_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
expected_csv = read_csv('example_output.csv')
|
|
||||||
actual_csv = read_csv('test_example_output.csv')
|
|
||||||
|
|
||||||
assert actual_csv == expected_csv
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == ''
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
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', io.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
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-q', '-k', 'test_example_keepass_output.csv', 'example_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
expected_totp_csv = read_csv('example_keepass_output.totp.csv')
|
|
||||||
expected_hotp_csv = read_csv('example_keepass_output.hotp.csv')
|
|
||||||
actual_totp_csv = read_csv('test_example_keepass_output.totp.csv')
|
|
||||||
actual_hotp_csv = read_csv('test_example_keepass_output.hotp.csv')
|
|
||||||
|
|
||||||
assert actual_totp_csv == expected_totp_csv
|
|
||||||
assert actual_hotp_csv == expected_hotp_csv
|
|
||||||
assert not file_exits('test_example_keepass_output.csv')
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == ''
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
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
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-q', '-k', 'test_example_keepass_output.csv', 'test/example_export_only_totp.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
expected_totp_csv = read_csv('example_keepass_output.totp.csv')
|
|
||||||
actual_totp_csv = read_csv('test_example_keepass_output.csv')
|
|
||||||
|
|
||||||
assert actual_totp_csv == expected_totp_csv
|
|
||||||
assert not file_exits('test_example_keepass_output.totp.csv')
|
|
||||||
assert not file_exits('test_example_keepass_output.hotp.csv')
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == ''
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_json(capsys):
|
|
||||||
# Arrange
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-q', '-j', 'test_example_output.json', 'example_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
expected_json = read_json('example_output.json')
|
|
||||||
actual_json = read_json('test_example_output.json')
|
|
||||||
|
|
||||||
assert actual_json == expected_json
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == ''
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_json_stdout(capsys):
|
|
||||||
# Arrange
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
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)
|
|
||||||
|
|
||||||
assert actual_json == expected_json
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_not_encoded_plus(capsys):
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['test/test_plus_problem_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stdout = '''Name: SerenityLabs:test1@serenitylabs.co.uk
|
|
||||||
Secret: A4RFDYMF4GSLUIBQV4ZP67OJEZ2XUQVM
|
|
||||||
Issuer: SerenityLabs
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
Name: SerenityLabs:test2@serenitylabs.co.uk
|
|
||||||
Secret: SCDDZ7PW5MOZLE3PQCAZM7L4S35K3UDX
|
|
||||||
Issuer: SerenityLabs
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
Name: SerenityLabs:test3@serenitylabs.co.uk
|
|
||||||
Secret: TR76272RVYO6EAEY2FX7W7R7KUDEGPJ4
|
|
||||||
Issuer: SerenityLabs
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
Name: SerenityLabs:test4@serenitylabs.co.uk
|
|
||||||
Secret: N2ILWSXSJUQUB7S6NONPJSC62NPG7EXN
|
|
||||||
Issuer: SerenityLabs
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
assert captured.out == expected_stdout
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_printqr(capsys):
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-p', 'example_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stdout = read_file_to_str('test/printqr_output.txt')
|
|
||||||
|
|
||||||
assert captured.out == expected_stdout
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_saveqr(capsys):
|
|
||||||
# Arrange
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-q', '-s', 'testout/qr/', 'example_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == ''
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
assert os.path.isfile('testout/qr/1-piraspberrypi-raspberrypi.png')
|
|
||||||
assert os.path.isfile('testout/qr/2-piraspberrypi.png')
|
|
||||||
assert os.path.isfile('testout/qr/3-piraspberrypi.png')
|
|
||||||
assert os.path.isfile('testout/qr/4-piraspberrypi-raspberrypi.png')
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_bytes():
|
|
||||||
assert replace_escaped_octal_utf8_bytes_with_str('Before\\\\302\\\\277\\\\303\nname: enc: \\302\\277\\303\\244\\303\\204\\303\\251\\303\\211?\nAfter') == 'Before\\\\302\\\\277\\\\303\nname: enc: ¿äÄéÉ?\nAfter'
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_verbose(capsys, relaxed):
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-v', 'example_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stdout = read_file_to_str('test/print_verbose_output.txt')
|
|
||||||
|
|
||||||
if relaxed or sys.implementation.name == 'pypy':
|
|
||||||
print('\nRelaxed mode\n')
|
|
||||||
|
|
||||||
assert replace_escaped_octal_utf8_bytes_with_str(captured.out) == replace_escaped_octal_utf8_bytes_with_str(expected_stdout)
|
|
||||||
assert quick_and_dirty_workaround_encoding_problem(captured.out) == quick_and_dirty_workaround_encoding_problem(expected_stdout)
|
|
||||||
else:
|
|
||||||
assert captured.out == expected_stdout
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_debug(capsys):
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-vv', 'example_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stdout = read_file_to_str('test/print_verbose_output.txt')
|
|
||||||
|
|
||||||
assert len(captured.out) > len(expected_stdout)
|
|
||||||
assert "DEBUG: " in captured.out
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_help(capsys):
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-h'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert len(captured.out) > 0
|
|
||||||
assert "-h, --help" in captured.out and "--verbose, -v" in captured.out
|
|
||||||
assert captured.err == ''
|
|
||||||
assert e.type == SystemExit
|
|
||||||
assert e.value.code == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_no_arguments(capsys):
|
|
||||||
# Act
|
|
||||||
with pytest.raises(SystemExit) as 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 == ''
|
|
||||||
assert e.value.code == 2
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
def test_verbose_and_quiet(capsys):
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['-v', '-q', 'example_export.txt'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert len(captured.err) > 0
|
|
||||||
assert 'error: argument --quiet/-q: not allowed with argument --verbose/-v' in captured.err
|
|
||||||
assert captured.out == ''
|
|
||||||
assert e.value.code == 2
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_data(capsys):
|
|
||||||
with pytest.raises(SystemExit) as 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 == ''
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
def test_wrong_content(capsys):
|
|
||||||
with pytest.raises(SystemExit) as 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
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
assert extract_otp_secret_keys.add_pre_suffix("name.csv", "totp") == "name.totp.csv"
|
|
||||||
assert extract_otp_secret_keys.add_pre_suffix("name.csv", "") == "name..csv"
|
|
||||||
assert extract_otp_secret_keys.add_pre_suffix("name", "totp") == "name.totp"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.qreader
|
|
||||||
def test_img_qr_reader_from_file_happy_path(capsys):
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['test/test_googleauth_export.png'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
@pytest.mark.qreader
|
|
||||||
def test_img_qr_reader_from_stdin(capsys, monkeypatch):
|
|
||||||
# Arrange
|
|
||||||
# sys.stdin.buffer should be monkey patched, but it does not work
|
|
||||||
monkeypatch.setattr('sys.stdin', read_binary_file_as_stream('test/test_googleauth_export.png'))
|
|
||||||
|
|
||||||
# Act
|
|
||||||
extract_otp_secret_keys.main(['='])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stdout =\
|
|
||||||
'''Name: Test1:test1@example1.com
|
|
||||||
Secret: JBSWY3DPEHPK3PXP
|
|
||||||
Issuer: Test1
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
Name: Test2:test2@example2.com
|
|
||||||
Secret: JBSWY3DPEHPK3PXQ
|
|
||||||
Issuer: Test2
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
Name: Test3:test3@example3.com
|
|
||||||
Secret: JBSWY3DPEHPK3PXR
|
|
||||||
Issuer: Test3
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
assert captured.out == expected_stdout
|
|
||||||
assert captured.err == ''
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.qreader
|
|
||||||
def test_img_qr_reader_from_stdin_wrong_symbol(capsys, monkeypatch):
|
|
||||||
# Arrange
|
|
||||||
# sys.stdin.buffer should be monkey patched, but it does not work
|
|
||||||
monkeypatch.setattr('sys.stdin', read_binary_file_as_stream('test/test_googleauth_export.png'))
|
|
||||||
|
|
||||||
# Act
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
extract_otp_secret_keys.main(['-'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stderr = '\nBinary input was given in stdin, please use = instead of - as infile argument for images.\n'
|
|
||||||
|
|
||||||
assert captured.err == expected_stderr
|
|
||||||
assert captured.out == ''
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.qreader
|
|
||||||
def test_img_qr_reader_no_qr_code_in_image(capsys):
|
|
||||||
# Act
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
extract_otp_secret_keys.main(['test/lena_std.tif'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stderr = '\nERROR: Unable to read QR Code from file.\ninput file: test/lena_std.tif\n'
|
|
||||||
|
|
||||||
assert captured.err == expected_stderr
|
|
||||||
assert captured.out == ''
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.qreader
|
|
||||||
def test_img_qr_reader_nonexistent_file(capsys):
|
|
||||||
# Act
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
extract_otp_secret_keys.main(['test/nonexistent.bmp'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
|
|
||||||
expected_stderr = '\nERROR: Input file provided is non-existent or not a file.\ninput file: test/nonexistent.bmp\n'
|
|
||||||
|
|
||||||
assert captured.err == expected_stderr
|
|
||||||
assert captured.out == ''
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_image_file(capsys):
|
|
||||||
# Act
|
|
||||||
with pytest.raises(SystemExit) as e:
|
|
||||||
extract_otp_secret_keys.main(['test/text_masquerading_as_image.jpeg'])
|
|
||||||
|
|
||||||
# Assert
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
expected_stderr = '''
|
|
||||||
WARN: line is not a otpauth-migration:// URL
|
|
||||||
input file: test/text_masquerading_as_image.jpeg
|
|
||||||
line "This is just a text file masquerading as an image file."
|
|
||||||
Probably a wrong file was given
|
|
||||||
|
|
||||||
ERROR: no data query parameter in input URL
|
|
||||||
input file: test/text_masquerading_as_image.jpeg
|
|
||||||
line "This is just a text file masquerading as an image file."
|
|
||||||
Probably a wrong file was given
|
|
||||||
'''
|
|
||||||
|
|
||||||
assert captured.err == expected_stderr
|
|
||||||
assert captured.out == ''
|
|
||||||
assert e.value.code == 1
|
|
||||||
assert e.type == SystemExit
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
'''
|
|
||||||
|
|
||||||
EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG =\
|
|
||||||
'''Name: Test1:test1@example1.com
|
|
||||||
Secret: JBSWY3DPEHPK3PXP
|
|
||||||
Issuer: Test1
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
Name: Test2:test2@example2.com
|
|
||||||
Secret: JBSWY3DPEHPK3PXQ
|
|
||||||
Issuer: Test2
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
Name: Test3:test3@example3.com
|
|
||||||
Secret: JBSWY3DPEHPK3PXR
|
|
||||||
Issuer: Test3
|
|
||||||
Type: totp
|
|
||||||
|
|
||||||
'''
|
|
@ -1,92 +0,0 @@
|
|||||||
# Unit test for extract_otp_secret_keys.py
|
|
||||||
|
|
||||||
# Run tests:
|
|
||||||
# python -m unittest
|
|
||||||
|
|
||||||
# Author: sssudame
|
|
||||||
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from utils import Capturing
|
|
||||||
|
|
||||||
import extract_otp_secret_keys
|
|
||||||
|
|
||||||
class TestQRImageExtract(unittest.TestCase):
|
|
||||||
def test_img_qr_reader_happy_path(self):
|
|
||||||
with Capturing() as actual_output:
|
|
||||||
extract_otp_secret_keys.main(['test/test_googleauth_export.png'])
|
|
||||||
|
|
||||||
expected_output =\
|
|
||||||
['Name: Test1:test1@example1.com', 'Secret: JBSWY3DPEHPK3PXP', 'Issuer: Test1', 'Type: totp', '',
|
|
||||||
'Name: Test2:test2@example2.com', 'Secret: JBSWY3DPEHPK3PXQ', 'Issuer: Test2', 'Type: totp', '',
|
|
||||||
'Name: Test3:test3@example3.com', 'Secret: JBSWY3DPEHPK3PXR', 'Issuer: Test3', 'Type: totp', '']
|
|
||||||
|
|
||||||
self.assertEqual(actual_output, expected_output)
|
|
||||||
|
|
||||||
def test_img_qr_reader_no_qr_code_in_image(self):
|
|
||||||
with Capturing() as actual_output:
|
|
||||||
with self.assertRaises(SystemExit) as context:
|
|
||||||
extract_otp_secret_keys.main(['test/lena_std.tif'])
|
|
||||||
|
|
||||||
expected_output =\
|
|
||||||
['', 'ERROR: Unable to read QR Code from file.', 'input file: test/lena_std.tif']
|
|
||||||
|
|
||||||
self.assertEqual(actual_output, expected_output)
|
|
||||||
self.assertEqual(context.exception.code, 1)
|
|
||||||
|
|
||||||
def test_img_qr_reader_nonexistent_file(self):
|
|
||||||
with Capturing() as actual_output:
|
|
||||||
with self.assertRaises(SystemExit) as context:
|
|
||||||
extract_otp_secret_keys.main(['test/nonexistent.bmp'])
|
|
||||||
|
|
||||||
expected_output =\
|
|
||||||
['', 'ERROR: Input file provided is non-existent or not a file.', 'input file: test/nonexistent.bmp']
|
|
||||||
|
|
||||||
self.assertEqual(actual_output, expected_output)
|
|
||||||
self.assertEqual(context.exception.code, 1)
|
|
||||||
|
|
||||||
def test_img_qr_reader_non_image_file(self):
|
|
||||||
with Capturing() as actual_output:
|
|
||||||
with self.assertRaises(SystemExit) as context:
|
|
||||||
extract_otp_secret_keys.main(['test/text_masquerading_as_image.jpeg'])
|
|
||||||
|
|
||||||
expected_output = [
|
|
||||||
'',
|
|
||||||
'WARN: line is not a otpauth-migration:// URL',
|
|
||||||
'input file: test/text_masquerading_as_image.jpeg',
|
|
||||||
'line "This is just a text file masquerading as an image file."',
|
|
||||||
'Probably a wrong file was given',
|
|
||||||
'',
|
|
||||||
'ERROR: no data query parameter in input URL',
|
|
||||||
'input file: test/text_masquerading_as_image.jpeg',
|
|
||||||
'line "This is just a text file masquerading as an image file."',
|
|
||||||
'Probably a wrong file was given'
|
|
||||||
]
|
|
||||||
|
|
||||||
self.assertEqual(actual_output, expected_output)
|
|
||||||
self.assertEqual(context.exception.code, 1)
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.cleanup()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.cleanup()
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
@ -0,0 +1,22 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from extract_otp_secrets import QRMode
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||||
|
parser.addoption("--relaxed", action='store_true', help="run tests in relaxed mode")
|
||||||
|
parser.addoption("--fast", action="store_true", help="faster execution, do not run all combinations")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def relaxed(request: pytest.FixtureRequest) -> Any:
|
||||||
|
return request.config.getoption("--relaxed")
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
|
||||||
|
if "qr_mode" in metafunc.fixturenames:
|
||||||
|
number = 2 if metafunc.config.getoption("fast") else len(QRMode)
|
||||||
|
qr_modes = [mode.name for mode in QRMode]
|
||||||
|
metafunc.parametrize("qr_mode", qr_modes[0:number])
|
@ -0,0 +1,51 @@
|
|||||||
|
QReader installed: True
|
||||||
|
CV2 version: 4.7.0
|
||||||
|
QR reading mode: ZBAR
|
||||||
|
|
||||||
|
Input files: ['example_export.txt']
|
||||||
|
|
||||||
|
1. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Issuer: raspberrypi
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||||
|
|
||||||
|
|
||||||
|
2. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
|
||||||
|
|
||||||
|
3. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
|
||||||
|
|
||||||
|
4. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Issuer: raspberrypi
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||||
|
|
||||||
|
|
||||||
|
5. Secret
|
||||||
|
Name: hotp demo
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: hotp
|
||||||
|
Counter: 4
|
||||||
|
otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4
|
||||||
|
|
||||||
|
|
||||||
|
6. Secret
|
||||||
|
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
|
||||||
|
|
||||||
|
Extracted 6 otps from 5 otp urls by reading 1 infile
|
@ -0,0 +1,71 @@
|
|||||||
|
QReader installed: True
|
||||||
|
CV2 version: 4.7.0
|
||||||
|
QR reading mode: ZBAR
|
||||||
|
|
||||||
|
Input files: ['example_export.txt']
|
||||||
|
Processing infile example_export.txt
|
||||||
|
# 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/
|
||||||
|
|
||||||
|
# Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||||
|
otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B
|
||||||
|
|
||||||
|
1. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Issuer: raspberrypi
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||||
|
|
||||||
|
|
||||||
|
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D
|
||||||
|
|
||||||
|
2. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
|
||||||
|
|
||||||
|
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||||
|
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B
|
||||||
|
|
||||||
|
3. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
|
||||||
|
|
||||||
|
4. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Issuer: raspberrypi
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||||
|
|
||||||
|
|
||||||
|
# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4
|
||||||
|
otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D
|
||||||
|
|
||||||
|
5. Secret
|
||||||
|
Name: hotp demo
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
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
|
||||||
|
|
||||||
|
6. Secret
|
||||||
|
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
|
||||||
|
|
||||||
|
Extracted 6 otps from 5 otp urls by reading 1 infile
|
@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
|
|
@ -0,0 +1,51 @@
|
|||||||
|
QReader installed: True
|
||||||
|
CV2 version: 4.7.0
|
||||||
|
QR reading mode: ZBAR
|
||||||
|
|
||||||
|
Input files: ['example_export.txt']
|
||||||
|
|
||||||
|
1. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Issuer: raspberrypi
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||||
|
|
||||||
|
|
||||||
|
2. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
|
||||||
|
|
||||||
|
3. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
|
||||||
|
|
||||||
|
4. Secret
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Issuer: raspberrypi
|
||||||
|
Type: totp
|
||||||
|
otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
|
||||||
|
|
||||||
|
|
||||||
|
5. Secret
|
||||||
|
Name: hotp demo
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: hotp
|
||||||
|
Counter: 4
|
||||||
|
otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4
|
||||||
|
|
||||||
|
|
||||||
|
6. Secret
|
||||||
|
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
|
||||||
|
|
||||||
|
Extracted 6 otps from 5 otp urls by reading 1 infile
|
@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
|
|
Before Width: | Height: | Size: 653 KiB After Width: | Height: | Size: 653 KiB |
@ -0,0 +1,89 @@
|
|||||||
|
# Unit test for extract_otp_secrets.py
|
||||||
|
|
||||||
|
# Run tests:
|
||||||
|
# python -m unittest
|
||||||
|
|
||||||
|
# Author: sssudame
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations # workaround for PYTHON <= 3.10
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import extract_otp_secrets
|
||||||
|
from utils import Capturing
|
||||||
|
|
||||||
|
|
||||||
|
class TestQRImageExtract(unittest.TestCase):
|
||||||
|
def test_img_qr_reader_happy_path(self) -> None:
|
||||||
|
with Capturing() as actual_output:
|
||||||
|
extract_otp_secrets.main(['tests/data/test_googleauth_export.png'])
|
||||||
|
|
||||||
|
expected_output =\
|
||||||
|
['Name: Test1:test1@example1.com', 'Secret: JBSWY3DPEHPK3PXP', 'Issuer: Test1', 'Type: totp', '',
|
||||||
|
'Name: Test2:test2@example2.com', 'Secret: JBSWY3DPEHPK3PXQ', 'Issuer: Test2', 'Type: totp', '',
|
||||||
|
'Name: Test3:test3@example3.com', 'Secret: JBSWY3DPEHPK3PXR', 'Issuer: Test3', 'Type: totp', '']
|
||||||
|
|
||||||
|
self.assertEqual(actual_output, expected_output)
|
||||||
|
|
||||||
|
def test_img_qr_reader_no_qr_code_in_image(self) -> None:
|
||||||
|
with Capturing() as actual_output:
|
||||||
|
with self.assertRaises(SystemExit) as context:
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/lena_std.tif'])
|
||||||
|
|
||||||
|
expected_output = ['', 'ERROR: Unable to read QR Code from file.', 'input file: tests/data/lena_std.tif']
|
||||||
|
|
||||||
|
self.assertEqual(actual_output, expected_output)
|
||||||
|
self.assertEqual(context.exception.code, 1)
|
||||||
|
|
||||||
|
def test_img_qr_reader_nonexistent_file(self) -> None:
|
||||||
|
with Capturing() as actual_output:
|
||||||
|
with self.assertRaises(SystemExit) as context:
|
||||||
|
extract_otp_secrets.main(['-n', 'nonexistent.bmp'])
|
||||||
|
|
||||||
|
expected_output = ['', 'ERROR: Input file provided is non-existent or not a file.', 'input file: nonexistent.bmp']
|
||||||
|
|
||||||
|
self.assertEqual(actual_output, expected_output)
|
||||||
|
self.assertEqual(context.exception.code, 1)
|
||||||
|
|
||||||
|
def test_img_qr_reader_non_image_file(self) -> None:
|
||||||
|
with Capturing() as actual_output:
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/text_masquerading_as_image.jpeg'])
|
||||||
|
|
||||||
|
expected_output = [
|
||||||
|
'',
|
||||||
|
'WARN: input is not a otpauth-migration:// url',
|
||||||
|
'source: tests/data/text_masquerading_as_image.jpeg',
|
||||||
|
"input: This is just a text file masquerading as an image file.",
|
||||||
|
'Maybe a wrong file was given',
|
||||||
|
'',
|
||||||
|
'ERROR: could not parse query parameter in input url',
|
||||||
|
'source: tests/data/text_masquerading_as_image.jpeg',
|
||||||
|
"url: This is just a text file masquerading as an image file.",
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(actual_output, expected_output)
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.cleanup()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.cleanup()
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
@ -0,0 +1,914 @@
|
|||||||
|
# pytest for extract_otp_secrets.py
|
||||||
|
|
||||||
|
# Run tests:
|
||||||
|
# pytest
|
||||||
|
|
||||||
|
# Author: Scito (https://scito.ch)
|
||||||
|
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import annotations # workaround for PYTHON <= 3.10
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import colorama
|
||||||
|
import pytest
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
from utils import (count_files_in_dir, file_exits, read_binary_file_as_stream,
|
||||||
|
read_csv, read_csv_str, read_file_to_str, read_json,
|
||||||
|
read_json_str, replace_escaped_octal_utf8_bytes_with_str)
|
||||||
|
|
||||||
|
import extract_otp_secrets
|
||||||
|
|
||||||
|
qreader_available: bool = extract_otp_secrets.qreader_available
|
||||||
|
|
||||||
|
|
||||||
|
# Quickfix comment
|
||||||
|
# @pytest.mark.skipif(sys.platform.startswith("win") or not qreader_available or sys.implementation.name == 'pypy' or sys.version_info >= (3, 10), reason="Quickfix")
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_stdout(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_non_existent_file(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
extract_otp_secrets.main(['-n', 'non_existent_file.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '\nERROR: Input file provided is non-existent or not a file.\ninput file: non_existent_file.txt\n'
|
||||||
|
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
assert captured.out == ''
|
||||||
|
assert e.value.code == 1
|
||||||
|
assert e.type == SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_stdin_stdout(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt')))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_stdin_empty(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr('sys.stdin', io.StringIO())
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', '-'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == ''
|
||||||
|
assert captured.err == '\nWARN: stdin is empty\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_empty_file_no_qreader(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
if qreader_available:
|
||||||
|
# Act
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/empty_file.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '\nWARN: tests/data/empty_file.txt is empty\n\nERROR: Unable to open file for reading.\ninput file: tests/data/empty_file.txt\n'
|
||||||
|
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
assert captured.out == ''
|
||||||
|
assert e.value.code == 1
|
||||||
|
assert e.type == SystemExit
|
||||||
|
else:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['tests/data/empty_file.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.err == ''
|
||||||
|
assert captured.out == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_extract_stdin_img_empty(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr('sys.stdin', io.BytesIO())
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', '='])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == ''
|
||||||
|
assert captured.err == '\nWARN: stdin is empty\n'
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_csv(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
output_file = str(tmp_path / 'test_example_output.csv')
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-q', '-c', output_file, 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
expected_csv = read_csv('example_output.csv')
|
||||||
|
actual_csv = read_csv(output_file)
|
||||||
|
|
||||||
|
assert actual_csv == expected_csv
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == ''
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_csv_stdout(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.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 == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_stdin_and_csv_stdout(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt')))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.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 == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepass_csv(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None:
|
||||||
|
'''Two csv files .totp and .htop are generated.'''
|
||||||
|
# Arrange
|
||||||
|
file_name = str(tmp_path / 'test_example_keepass_output.csv')
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-q', '-k', file_name, 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
expected_totp_csv = read_csv('example_keepass_output.totp.csv')
|
||||||
|
expected_hotp_csv = read_csv('example_keepass_output.hotp.csv')
|
||||||
|
actual_totp_csv = read_csv(str(tmp_path / 'test_example_keepass_output.totp.csv'))
|
||||||
|
actual_hotp_csv = read_csv(str(tmp_path / 'test_example_keepass_output.hotp.csv'))
|
||||||
|
|
||||||
|
assert actual_totp_csv == expected_totp_csv
|
||||||
|
assert actual_hotp_csv == expected_hotp_csv
|
||||||
|
assert not file_exits(file_name)
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == ''
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_keepass_csv_stdout(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
'''Two csv files .totp and .htop are generated.'''
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-k', '-', 'tests/data/example_export_only_totp.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
expected_totp_csv = read_csv('example_keepass_output.totp.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 == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_keepass_csv(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None:
|
||||||
|
'''Does not add .totp or .hotp pre-suffix'''
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-q', '-k', str(tmp_path / 'test_example_keepass_output.csv'), 'tests/data/example_export_only_totp.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
expected_totp_csv = read_csv('example_keepass_output.totp.csv')
|
||||||
|
actual_totp_csv = read_csv(str(tmp_path / 'test_example_keepass_output.csv'))
|
||||||
|
|
||||||
|
assert actual_totp_csv == expected_totp_csv
|
||||||
|
assert not file_exits(tmp_path / 'test_example_keepass_output.totp.csv')
|
||||||
|
assert not file_exits(tmp_path / 'test_example_keepass_output.hotp.csv')
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == ''
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_json(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None:
|
||||||
|
# Arrange
|
||||||
|
output_file = str(tmp_path / 'test_example_output.json')
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-q', '-j', output_file, 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
expected_json = read_json('example_output.json')
|
||||||
|
actual_json = read_json(output_file)
|
||||||
|
|
||||||
|
assert actual_json == expected_json
|
||||||
|
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == ''
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_json_stdout(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.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)
|
||||||
|
|
||||||
|
assert actual_json == expected_json
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_not_encoded_plus(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['tests/data/test_plus_problem_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stdout = '''Name: SerenityLabs:test1@serenitylabs.co.uk
|
||||||
|
Secret: A4RFDYMF4GSLUIBQV4ZP67OJEZ2XUQVM
|
||||||
|
Issuer: SerenityLabs
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: SerenityLabs:test2@serenitylabs.co.uk
|
||||||
|
Secret: SCDDZ7PW5MOZLE3PQCAZM7L4S35K3UDX
|
||||||
|
Issuer: SerenityLabs
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: SerenityLabs:test3@serenitylabs.co.uk
|
||||||
|
Secret: TR76272RVYO6EAEY2FX7W7R7KUDEGPJ4
|
||||||
|
Issuer: SerenityLabs
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: SerenityLabs:test4@serenitylabs.co.uk
|
||||||
|
Secret: N2ILWSXSJUQUB7S6NONPJSC62NPG7EXN
|
||||||
|
Issuer: SerenityLabs
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.out == expected_stdout
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_printqr(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-p', 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stdout = read_file_to_str('tests/data/printqr_output.txt')
|
||||||
|
|
||||||
|
assert captured.out == expected_stdout
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_saveqr(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-q', '-s', str(tmp_path), 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == ''
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
assert os.path.isfile(tmp_path / '1-piraspberrypi-raspberrypi.png')
|
||||||
|
assert os.path.isfile(tmp_path / '2-piraspberrypi.png')
|
||||||
|
assert os.path.isfile(tmp_path / '3-piraspberrypi.png')
|
||||||
|
assert os.path.isfile(tmp_path / '4-piraspberrypi-raspberrypi.png')
|
||||||
|
assert os.path.isfile(tmp_path / '5-hotpdemo.png')
|
||||||
|
assert os.path.isfile(tmp_path / '6-encodingäÄéÉdemo.png')
|
||||||
|
assert count_files_in_dir(tmp_path) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_ignored_duplicates(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-i', 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stdout = '''Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Issuer: raspberrypi
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: pi@raspberrypi
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: hotp demo
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: hotp
|
||||||
|
Counter: 4
|
||||||
|
|
||||||
|
Name: encoding: ¿äÄéÉ? (demo)
|
||||||
|
Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
expected_stderr = '''Ignored duplicate otp: pi@raspberrypi
|
||||||
|
|
||||||
|
Ignored duplicate otp: pi@raspberrypi / raspberrypi
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.out == expected_stdout
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_bytes() -> None:
|
||||||
|
assert replace_escaped_octal_utf8_bytes_with_str(
|
||||||
|
'Before\\\\302\\\\277\\\\303\nname: enc: \\302\\277\\303\\244\\303\\204\\303\\251\\303\\211?\nAfter') == 'Before\\\\302\\\\277\\\\303\nname: enc: ¿äÄéÉ?\nAfter'
|
||||||
|
|
||||||
|
|
||||||
|
# Generate verbose output:
|
||||||
|
# for color in '' '-n'; do for level in '' '-v' '-vv' '-vvv'; do python3.11 src/extract_otp_secrets.py example_export.txt $color $level > tests/data/print_verbose_output$color$level.txt; done; done
|
||||||
|
# workaround for PYTHON <= 3.10
|
||||||
|
@pytest.mark.skipif(sys.version_info < (3, 10), reason="fileinput.input encoding exists since PYTHON 3.10")
|
||||||
|
@pytest.mark.parametrize("verbose_level", ['', '-v', '-vv', '-vvv'])
|
||||||
|
@pytest.mark.parametrize("color", ['', '-n'])
|
||||||
|
def test_extract_verbose(verbose_level: str, color: str, capsys: pytest.CaptureFixture[str], relaxed: bool) -> None:
|
||||||
|
args = ['example_export.txt']
|
||||||
|
if verbose_level:
|
||||||
|
args.append(verbose_level)
|
||||||
|
if color:
|
||||||
|
args.append(color)
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(args)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stdout = normalize_verbose_text(read_file_to_str(f'tests/data/print_verbose_output{color}{verbose_level}.txt'), relaxed or sys.implementation.name == 'pypy')
|
||||||
|
actual_stdout = normalize_verbose_text(captured.out, relaxed or sys.implementation.name == 'pypy')
|
||||||
|
|
||||||
|
assert actual_stdout == expected_stdout
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_verbose_text(text: str, relaxed: bool) -> str:
|
||||||
|
normalized = re.sub('^.+ version: .+$', '', text, flags=re.MULTILINE | re.IGNORECASE)
|
||||||
|
if not qreader_available:
|
||||||
|
normalized = normalized \
|
||||||
|
.replace('QReader installed: True', 'QReader installed: False') \
|
||||||
|
.replace('\nQR reading mode: ZBAR\n\n', '')
|
||||||
|
if relaxed:
|
||||||
|
print('\nRelaxed mode\n')
|
||||||
|
normalized = replace_escaped_octal_utf8_bytes_with_str(normalized)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_debug(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-vvv', 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stdout = read_file_to_str('tests/data/print_verbose_output.txt')
|
||||||
|
|
||||||
|
assert len(captured.out) > len(expected_stdout)
|
||||||
|
assert "DEBUG: " in captured.out
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_help(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-h'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert len(captured.out) > 0
|
||||||
|
assert "-h, --help" in captured.out and "-v, --verbose" in captured.out
|
||||||
|
assert captured.err == ''
|
||||||
|
assert e.type == SystemExit
|
||||||
|
assert e.value.code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_no_arguments(capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None:
|
||||||
|
if qreader_available:
|
||||||
|
# Arrange
|
||||||
|
otps = read_json('example_output.json')
|
||||||
|
mocker.patch('extract_otp_secrets.extract_otps_from_camera', return_value=otps)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-c', '-'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
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 == ''
|
||||||
|
else:
|
||||||
|
# Act
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
extract_otp_secrets.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 == ''
|
||||||
|
assert e.value.code == 2
|
||||||
|
assert e.type == SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
def test_verbose_and_quiet(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', '-v', '-q', 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert len(captured.err) > 0
|
||||||
|
assert 'error: argument -q/--quiet: not allowed with argument -v/--verbose' in captured.err
|
||||||
|
assert captured.out == ''
|
||||||
|
assert e.value.code == 2
|
||||||
|
assert e.type == SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("parameter,parameter_value,stdout_expected,stderr_expected", [
|
||||||
|
('-c', 'outfile', False, False),
|
||||||
|
('-c', '-', True, False),
|
||||||
|
('-k', 'outfile', False, False),
|
||||||
|
('-k', '-', True, False),
|
||||||
|
('-j', 'outfile', False, False),
|
||||||
|
('-s', 'outfile', False, False),
|
||||||
|
('-j', '-', True, False),
|
||||||
|
('-i', None, False, False),
|
||||||
|
('-p', None, True, False),
|
||||||
|
('-Q', 'CV2', False, False),
|
||||||
|
('-C', '0', False, False),
|
||||||
|
('-n', None, False, False),
|
||||||
|
])
|
||||||
|
def test_quiet(parameter: str, parameter_value: Optional[str], stdout_expected: bool, stderr_expected: bool, capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None:
|
||||||
|
if parameter in ['-Q', '-C'] and not qreader_available:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Arrange
|
||||||
|
args = ['-q', 'example_export.txt', 'example_export.png', parameter]
|
||||||
|
if parameter_value == 'outfile':
|
||||||
|
args.append(str(tmp_path / parameter_value))
|
||||||
|
elif parameter_value:
|
||||||
|
args.append(parameter_value)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(args)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert (captured.out == '' and not stdout_expected) or (len(captured.out) > 0 and stdout_expected)
|
||||||
|
assert (captured.err == '' and not stderr_expected) or (len(captured.err) > 0 and stderr_expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_data(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/test_export_wrong_data.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
first_expected_stderr = '''
|
||||||
|
ERROR: Cannot decode otpauth-migration migration payload.
|
||||||
|
data=XXXX
|
||||||
|
Exception: Error parsing message
|
||||||
|
'''
|
||||||
|
|
||||||
|
# Alpine Linux prints this exception message
|
||||||
|
second_expected_stderr = '''
|
||||||
|
ERROR: Cannot decode otpauth-migration migration payload.
|
||||||
|
data=XXXX
|
||||||
|
Exception: unpack requires a buffer of 4 bytes
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.err == first_expected_stderr or captured.err == second_expected_stderr
|
||||||
|
assert captured.out == ''
|
||||||
|
assert e.value.code == 1
|
||||||
|
assert e.type == SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_content(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/test_export_wrong_content.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '''
|
||||||
|
WARN: input is not a otpauth-migration:// url
|
||||||
|
source: tests/data/test_export_wrong_content.txt
|
||||||
|
input: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
|
||||||
|
Maybe a wrong file was given
|
||||||
|
|
||||||
|
ERROR: could not parse query parameter in input url
|
||||||
|
source: tests/data/test_export_wrong_content.txt
|
||||||
|
url: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.out == ''
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_wrong_file(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/test_export_wrong_content.txt', 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '''
|
||||||
|
WARN: input is not a otpauth-migration:// url
|
||||||
|
source: tests/data/test_export_wrong_content.txt
|
||||||
|
input: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
|
||||||
|
Maybe a wrong file was given
|
||||||
|
|
||||||
|
ERROR: could not parse query parameter in input url
|
||||||
|
source: tests/data/test_export_wrong_content.txt
|
||||||
|
url: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_wrong_file_colored(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['tests/data/test_export_wrong_content.txt', 'example_export.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = f'''{colorama.Fore.RED}
|
||||||
|
WARN: input is not a otpauth-migration:// url
|
||||||
|
source: tests/data/test_export_wrong_content.txt
|
||||||
|
input: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
|
||||||
|
Maybe a wrong file was given{colorama.Fore.RESET}
|
||||||
|
{colorama.Fore.RED}
|
||||||
|
ERROR: could not parse query parameter in input url
|
||||||
|
source: tests/data/test_export_wrong_content.txt
|
||||||
|
url: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.{colorama.Fore.RESET}
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_wrong_line(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr('sys.stdin',
|
||||||
|
io.StringIO(read_file_to_str('tests/data/test_export_wrong_content.txt') + read_file_to_str('example_export.txt')))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', '-'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '''
|
||||||
|
WARN: input is not a otpauth-migration:// url
|
||||||
|
source: -
|
||||||
|
input: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
|
||||||
|
Maybe a wrong file was given
|
||||||
|
|
||||||
|
ERROR: could not parse query parameter in input url
|
||||||
|
source: -
|
||||||
|
url: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_prefix(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/test_export_wrong_prefix.txt'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '''
|
||||||
|
WARN: input is not a otpauth-migration:// url
|
||||||
|
source: tests/data/test_export_wrong_prefix.txt
|
||||||
|
input: QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B
|
||||||
|
Maybe 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: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert extract_otp_secrets.add_pre_suffix("name.csv", "totp") == "name.totp.csv"
|
||||||
|
assert extract_otp_secrets.add_pre_suffix("name.csv", "") == "name..csv"
|
||||||
|
assert extract_otp_secrets.add_pre_suffix("name", "totp") == "name.totp"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_img_qr_reader_from_file_happy_path(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['tests/data/test_googleauth_export.png'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_img_qr_reader_by_parameter(capsys: pytest.CaptureFixture[str], qr_mode: str) -> None:
|
||||||
|
# Act
|
||||||
|
start_s = time.process_time()
|
||||||
|
extract_otp_secrets.main(['--qr', qr_mode, 'tests/data/test_googleauth_export.png'])
|
||||||
|
elapsed_s = time.process_time() - start_s
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
print(f"Elapsed time for {qr_mode}: {elapsed_s:.2f}s")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_extract_multiple_files_and_mixed(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main([
|
||||||
|
'example_export.txt',
|
||||||
|
'tests/data/test_googleauth_export.png',
|
||||||
|
'example_export.txt',
|
||||||
|
'tests/data/test_googleauth_export.png'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_img_qr_reader_from_stdin(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Arrange
|
||||||
|
# sys.stdin.buffer should be monkey patched, but it does not work
|
||||||
|
monkeypatch.setattr('sys.stdin', read_binary_file_as_stream('tests/data/test_googleauth_export.png'))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['='])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stdout = '''Name: Test1:test1@example1.com
|
||||||
|
Secret: JBSWY3DPEHPK3PXP
|
||||||
|
Issuer: Test1
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: Test2:test2@example2.com
|
||||||
|
Secret: JBSWY3DPEHPK3PXQ
|
||||||
|
Issuer: Test2
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: Test3:test3@example3.com
|
||||||
|
Secret: JBSWY3DPEHPK3PXR
|
||||||
|
Issuer: Test3
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.out == expected_stdout
|
||||||
|
assert captured.err == ''
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_img_qr_reader_from_stdin_wrong_symbol(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Arrange
|
||||||
|
# sys.stdin.buffer should be monkey patched, but it does not work
|
||||||
|
monkeypatch.setattr('sys.stdin', read_binary_file_as_stream('tests/data/test_googleauth_export.png'))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
extract_otp_secrets.main(['-n', '-'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '\nERROR: Binary input was given in stdin, please use = instead of - as infile argument for images.\n'
|
||||||
|
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
assert captured.out == ''
|
||||||
|
assert e.value.code == 1
|
||||||
|
assert e.type == SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_extract_stdin_stdout_wrong_symbol(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
# Arrange
|
||||||
|
monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt')))
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
extract_otp_secrets.main(['-n', '='])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = "\nERROR: Cannot read binary stdin buffer.\nException: a bytes-like object is required, not 'str'\n"
|
||||||
|
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
assert captured.out == ''
|
||||||
|
assert e.value.code == 1
|
||||||
|
assert e.type == SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_img_qr_reader_no_qr_code_in_image(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/lena_std.tif'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '\nERROR: Unable to read QR Code from file.\ninput file: tests/data/lena_std.tif\n'
|
||||||
|
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
assert captured.out == ''
|
||||||
|
assert e.value.code == 1
|
||||||
|
assert e.type == SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.qreader
|
||||||
|
def test_img_qr_reader_nonexistent_file(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
extract_otp_secrets.main(['-n', 'nonexistent.bmp'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
|
||||||
|
expected_stderr = '\nERROR: Input file provided is non-existent or not a file.\ninput file: nonexistent.bmp\n'
|
||||||
|
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
assert captured.out == ''
|
||||||
|
assert e.value.code == 1
|
||||||
|
assert e.type == SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_image_file(capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
# Act
|
||||||
|
extract_otp_secrets.main(['-n', 'tests/data/text_masquerading_as_image.jpeg'])
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
expected_stderr = '''
|
||||||
|
WARN: input is not a otpauth-migration:// url
|
||||||
|
source: tests/data/text_masquerading_as_image.jpeg
|
||||||
|
input: This is just a text file masquerading as an image file.
|
||||||
|
Maybe a wrong file was given
|
||||||
|
|
||||||
|
ERROR: could not parse query parameter in input url
|
||||||
|
source: tests/data/text_masquerading_as_image.jpeg
|
||||||
|
url: This is just a text file masquerading as an image file.
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert captured.err == expected_stderr
|
||||||
|
assert captured.out == ''
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG = '''Name: Test1:test1@example1.com
|
||||||
|
Secret: JBSWY3DPEHPK3PXP
|
||||||
|
Issuer: Test1
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: Test2:test2@example2.com
|
||||||
|
Secret: JBSWY3DPEHPK3PXQ
|
||||||
|
Issuer: Test2
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
Name: Test3:test3@example3.com
|
||||||
|
Secret: JBSWY3DPEHPK3PXR
|
||||||
|
Issuer: Test3
|
||||||
|
Type: totp
|
||||||
|
|
||||||
|
'''
|
@ -1,236 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Upgrades Protoc from https://github.com/protocolbuffers/protobuf/releases
|
|
||||||
|
|
||||||
black='\e[0;30m'
|
|
||||||
blackBold='\e[1;30m'
|
|
||||||
blackBackground='\e[1;40m'
|
|
||||||
red='\e[0;31m'
|
|
||||||
redBold='\e[1;31m'
|
|
||||||
redBackground='\e[0;41m'
|
|
||||||
green='\e[0;32m'
|
|
||||||
greenBold='\e[1;32m'
|
|
||||||
greenBackground='\e[0;42m'
|
|
||||||
yellow='\e[0;33m'
|
|
||||||
yellowBold='\e[1;33m'
|
|
||||||
yellowBackground='\e[0;43m'
|
|
||||||
blue='\e[0;34m'
|
|
||||||
blueBold='\e[1;34m'
|
|
||||||
blueBackground='\e[0;44m'
|
|
||||||
magenta='\e[0;35m'
|
|
||||||
magentaBold='\e[1;35m'
|
|
||||||
magentaBackground='\e[0;45m'
|
|
||||||
cyan='\e[0;36m'
|
|
||||||
cyanBold='\e[1;36m'
|
|
||||||
cyanBackground='\e[0;46m'
|
|
||||||
white='\e[0;37m'
|
|
||||||
whiteBold='\e[1;37m'
|
|
||||||
whiteBackground='\e[0;47m'
|
|
||||||
reset='\e[0m'
|
|
||||||
|
|
||||||
abort() {
|
|
||||||
echo '
|
|
||||||
***************
|
|
||||||
*** ABORTED ***
|
|
||||||
***************
|
|
||||||
' >&2
|
|
||||||
echo "An error occurred on line $1. Exiting..." >&2
|
|
||||||
date -Iseconds >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
trap 'abort $LINENO' ERR
|
|
||||||
set -e -o pipefail
|
|
||||||
|
|
||||||
quit() {
|
|
||||||
trap : 0
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Asks if [Yn] if script shoud continue, otherwise exit 1
|
|
||||||
# $1: msg or nothing
|
|
||||||
# Example call 1: askContinueYn
|
|
||||||
# Example call 1: askContinueYn "Backup DB?"
|
|
||||||
askContinueYn() {
|
|
||||||
if [[ $1 ]]; then
|
|
||||||
msg="$1 "
|
|
||||||
else
|
|
||||||
msg=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# http://stackoverflow.com/questions/3231804/in-bash-how-to-add-are-you-sure-y-n-to-any-command-or-alias
|
|
||||||
read -e -p "${msg}Continue? [Y/n] " response
|
|
||||||
response=${response,,} # tolower
|
|
||||||
if [[ $response =~ ^(yes|y|)$ ]] ; then
|
|
||||||
# echo ""
|
|
||||||
# OK
|
|
||||||
:
|
|
||||||
else
|
|
||||||
echo "Aborted"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Reference: https://gist.github.com/steinwaywhw/a4cd19cda655b8249d908261a62687f8
|
|
||||||
|
|
||||||
echo "Checking Protoc version..."
|
|
||||||
VERSION=$(curl -sL https://github.com/protocolbuffers/protobuf/releases/latest | grep -E "<title>" | perl -pe's%.*Protocol Buffers v(\d+\.\d+(\.\d+)?).*%\1%')
|
|
||||||
BASEVERSION=4
|
|
||||||
echo
|
|
||||||
|
|
||||||
interactive=true
|
|
||||||
check_version=true
|
|
||||||
|
|
||||||
while test $# -gt 0; do
|
|
||||||
case $1 in
|
|
||||||
-h|--help)
|
|
||||||
echo "Upgrade Protoc"
|
|
||||||
echo
|
|
||||||
echo "$0 [options]"
|
|
||||||
echo
|
|
||||||
echo "Options:"
|
|
||||||
echo "-a Automatic mode"
|
|
||||||
echo "-C Ignore version check"
|
|
||||||
echo "-h, --help Help"
|
|
||||||
quit
|
|
||||||
;;
|
|
||||||
-a)
|
|
||||||
interactive=false
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-C)
|
|
||||||
check_version=false
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
BIN="$HOME/bin"
|
|
||||||
DOWNLOADS="$HOME/downloads"
|
|
||||||
|
|
||||||
PIP="pip3.11"
|
|
||||||
PIPENV="python3.11 -m pipenv"
|
|
||||||
|
|
||||||
# Upgrade protoc
|
|
||||||
|
|
||||||
DEST="protoc"
|
|
||||||
|
|
||||||
OLDVERSION=$(cat $BIN/$DEST/.VERSION.txt || echo "")
|
|
||||||
echo -e "\nProtoc remote version $VERSION\n"
|
|
||||||
echo -e "Protoc local version: $OLDVERSION\n"
|
|
||||||
|
|
||||||
if [ "$OLDVERSION" != "$VERSION" ]; then
|
|
||||||
echo "Upgrade protoc from $OLDVERSION to $VERSION"
|
|
||||||
|
|
||||||
NAME="protoc-$VERSION"
|
|
||||||
ARCHIVE="$NAME.zip"
|
|
||||||
|
|
||||||
mkdir -p $DOWNLOADS
|
|
||||||
# https://github.com/protocolbuffers/protobuf/releases/download/v21.6/protoc-21.6-linux-x86_64.zip
|
|
||||||
cmd="wget --trust-server-names https://github.com/protocolbuffers/protobuf/releases/download/v$VERSION/protoc-$VERSION-linux-x86_64.zip -O $DOWNLOADS/$ARCHIVE"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="echo -e '\nSize [Byte]'; stat --printf='%s\n' $DOWNLOADS/$ARCHIVE; echo -e '\nMD5'; md5sum $DOWNLOADS/$ARCHIVE; echo -e '\nSHA256'; sha256sum $DOWNLOADS/$ARCHIVE;"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="mkdir -p $BIN/$NAME; unzip $DOWNLOADS/$ARCHIVE -d $BIN/$NAME"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="echo $VERSION > $BIN/$NAME/.VERSION.txt; echo $VERSION > $BIN/$NAME/.VERSION_$VERSION.txt"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="[ -d $BIN/$DEST.old ] && rm -rf $BIN/$DEST.old || echo 'No old dir to delete'"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="[ -d $BIN/$DEST ] && mv -iT $BIN/$DEST $BIN/$DEST.old || echo 'No previous dir to keep'"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="mv -iT $BIN/$NAME $BIN/$DEST"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="rm $DOWNLOADS/$ARCHIVE"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="$BIN/$DEST/bin/protoc --python_out=protobuf_generated_python google_auth.proto"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
# Update README.md
|
|
||||||
|
|
||||||
cmd="perl -i -pe 's%proto(buf|c)([- ])(\d\.)?$OLDVERSION%proto\$1\$2\${3}$VERSION%g' README.md && perl -i -pe 's%(protobuf/releases/tag/v)$OLDVERSION%\${1}$VERSION%g' README.md"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
else
|
|
||||||
echo -e "\nVersion has not changed. Quit"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# Upgrade pip requirements
|
|
||||||
|
|
||||||
cmd="sudo pip install --upgrade pip"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
$PIP --version
|
|
||||||
|
|
||||||
cmd="$PIP install --use-pep517 -U -r requirements.txt"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="$PIP install --use-pep517 -U -r requirements-dev.txt"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="$PIP install -U pipenv"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
$PIPENV --version
|
|
||||||
|
|
||||||
cmd="$PIPENV update && $PIPENV --rm && $PIPENV install"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
$PIPENV run python --version
|
|
||||||
|
|
||||||
# Test
|
|
||||||
|
|
||||||
cmd="pytest"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="$PIPENV run pytest"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
# Build docker
|
|
||||||
|
|
||||||
cmd="docker build . -t extract_otp_secret_keys_no_qr_reader -f Dockerfile_no_qr_reader --pull"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys_no_qr_reader test_extract_otp_secret_keys_pytest.py -k 'not qreader' -vvv --relaxed"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="docker build . -t extract_otp_secret_keys --pull"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
cmd="docker image prune"
|
|
||||||
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
|
|
||||||
eval "$cmd"
|
|
||||||
|
|
||||||
quit
|
|
Loading…
Reference in New Issue