diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e4a2156..beafecb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,8 @@ name: tests
on:
push:
+ paths-ignore:
+ - 'docs/**'
# pull_request:
schedule:
# Run daily on default branch
@@ -29,6 +31,7 @@ jobs:
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
+ check-latest: false
- name: Install zbar shared lib for QReader (Linux)
if: runner.os == 'Linux'
run: |
diff --git a/.github/workflows/ci_docker.yml b/.github/workflows/ci_docker.yml
index a48c1b1..939bbc2 100644
--- a/.github/workflows/ci_docker.yml
+++ b/.github/workflows/ci_docker.yml
@@ -11,13 +11,19 @@ name: docker
on:
# run it on push to the default repository branch
push:
+ paths-ignore:
+ - 'docs/**'
+ tags-ignore:
+ - '**'
+ # branches is needed if tags-ignore is used
+ branches:
+ - '**'
schedule:
# Run weekly on default branch
- cron: '47 3 * * 6'
jobs:
- # define job to build and publish docker image
- build-and-push-docker-image:
+ build-and-push-docker-debian-image:
name: Build Docker image and push to repositories
# run only when code is compiling and tests are passing
runs-on: ubuntu-latest
@@ -57,40 +63,94 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GHCR_IO_TOKEN }}
- - name: "no_qr_reader: Build image and push to Docker Hub and GitHub Container Registry"
- id: docker_build_only_txt
- uses: docker/build-push-action@v2
+ - name: "qr_reader: Build image and push to Docker Hub and GitHub Container Registry"
+ id: docker_build_qr_reader_latest
+ uses: docker/build-push-action@v3
with:
- # relative path to the place where source code with Dockerfile is located
platforms: linux/amd64,linux/arm64
+ # relative path to the place where source code with Dockerfile is located
+ # TODO file:, move to docker/
context: .
- file: Dockerfile_only_txt
+ file: Dockerfile
# builder: ${{ steps.buildx.outputs.name }}
# Note: tags has to be all lower-case
+ pull: true
tags: |
- scit0/extract_otp_secrets:latest-only-txt
- ghcr.io/scito/extract_otp_secrets:latest-only-txt
+ scit0/extract_otp_secrets:latest
+ scit0/extract_otp_secrets:bullseye
+ ghcr.io/scito/extract_otp_secrets:latest
+ ghcr.io/scito/extract_otp_secrets:bullseye
# build on feature branches, push only on master branch
push: ${{ github.ref == 'refs/heads/master' }}
- build-args: |
- RUN_TESTS=true
- - name: "qr_reader: Build image and push to Docker Hub and GitHub Container Registry"
- id: docker_build_qr_reader
- uses: docker/build-push-action@v2
+ - name: Image digest
+ # TODO upload digests to assets
+ run: |
+ echo "extract_otp_secrets: ${{ steps.docker_build_qr_reader_latest.outputs.digest }}"
+
+ build-and-push-docker-alpine-image:
+ name: Build Docker image and push to repositories
+ # run only when code is compiling and tests are passing
+ runs-on: ubuntu-latest
+
+ # steps to perform in job
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ # avoid building if there are testing errors
+ - name: Run smoke test
+ run: |
+ sudo apt-get install -y libzbar0
+ python -m pip install --upgrade pip
+ pip install -U -r requirements-dev.txt
+ pip install -U .
+ pytest
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+
+ # setup Docker build action
+ - name: Set up Docker Buildx
+ id: buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to Github Packages
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GHCR_IO_TOKEN }}
+
+ - name: "only_txt: Build image and push to Docker Hub and GitHub Container Registry"
+ id: docker_build_only_txt
+ uses: docker/build-push-action@v3
with:
- platforms: linux/amd64,linux/arm64
# relative path to the place where source code with Dockerfile is located
+ platforms: linux/amd64,linux/arm64
context: .
+ file: Dockerfile_only_txt
# builder: ${{ steps.buildx.outputs.name }}
# Note: tags has to be all lower-case
+ pull: true
tags: |
- scit0/extract_otp_secrets:latest
- ghcr.io/scito/extract_otp_secrets:latest
+ scit0/extract_otp_secrets:only-txt
+ scit0/extract_otp_secrets:alpine
+ ghcr.io/scito/extract_otp_secrets:only-txt
+ ghcr.io/scito/extract_otp_secrets:alpine
# build on feature branches, push only on master branch
push: ${{ github.ref == 'refs/heads/master' }}
+ build-args: |
+ RUN_TESTS=true
+
- name: Image digest
+ # TODO upload digests to assets
run: |
- echo "extract_otp_secrets: ${{ steps.docker_build_qr_reader.outputs.digest }}"
- echo "extract_otp_secrets_only_txt: ${{ steps.docker_build_only_txt.outputs.digest }}"
+ echo "extract_otp_secrets:only-txt: ${{ steps.docker_build_only_txt.outputs.digest }}"
diff --git a/.github/workflows/ci_release.yml b/.github/workflows/ci_release.yml
new file mode 100644
index 0000000..ec75240
--- /dev/null
+++ b/.github/workflows/ci_release.yml
@@ -0,0 +1,281 @@
+name: release
+
+# https://data-dive.com/multi-os-deployment-in-cloud-using-pyinstaller-and-github-actions
+# https://github.com/actions/create-release (archived)
+# https://github.com/actions/upload-artifact
+# https://github.com/actions/download-artifact
+# https://github.com/actions/upload-release-asset (archived)
+# https://github.com/docker/metadata-action
+# https://github.com/marketplace/actions/generate-release-hashes
+
+# https://github.com/oleksis/pyinstaller-manylinux
+# https://github.com/pypa/manylinux
+# https://github.com/batonogov/docker-pyinstaller
+
+# https://docs.github.com/de/actions/using-workflows/workflow-syntax-for-github-actions
+# https://docs.github.com/en/actions/using-workflows
+# https://docs.github.com/en/actions/learn-github-actions/contexts
+# https://docs.github.com/en/actions/learn-github-actions/expressions
+
+# https://docs.github.com/en/rest/releases/releases
+
+# https://peps.python.org/pep-0440/
+# https://semver.org/
+
+# Build matrix:
+# - Linux x86_64 glibc 2.35: ubuntu-latest
+# - Linux x86_64 glibc 2.34: extract_otp_secrets:buster
+# - Windows x86_64: windows-latest
+# - MacOS x86_64: macos-11
+# - Linux x86_64 glibc 2.28: extract_otp_secrets:buster
+# - Linux aarch64 glibc 2.28: extract_otp_secrets:buster
+# - MacOS universal2: macos-11
+# - Windows arm64: [buildx + https://github.com/batonogov/docker-pyinstaller]
+
+on:
+ push:
+ tags:
+ - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
+
+jobs:
+
+ create-release:
+ name: Create Release
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set meta data
+ id: meta
+ # Writing to env with >> $GITHUB_ENV is an alternative
+ run: |
+ echo "date=$(TZ=Europe/Bern date +'%d.%m.%Y')" >> $GITHUB_OUTPUT
+ echo "version=${TAG_NAME/v/}" >> $GITHUB_OUTPUT
+ echo "tag_name=${{ github.ref_name }}" >> $GITHUB_OUTPUT
+ echo "tag_message=$(git tag -l --format='%(contents:subject)' ${{ github.ref_name }})" >> $GITHUB_OUTPUT
+ env:
+ TAG_NAME: ${{ github.ref_name }}
+ - name: Create Release
+ id: create_release
+ run: |
+ # https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release
+ response=$(curl \
+ -X POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}"\
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ https://api.github.com/repos/scito/extract_otp_secrets/releases \
+ --silent \
+ --show-error \
+ -d '{"tag_name":"${{ github.ref }}","target_commitish":"master","name":"${{ steps.meta.outputs.version }} - ${{ steps.meta.outputs.date }}","body":"${{ steps.meta.outputs.tag_message }}","draft":true,"prerelease":false,"generate_release_notes":true}')
+ echo upload_url=$(jq '.upload_url' <<< "$response") >> $GITHUB_OUTPUT
+ echo $(jq -r '.upload_url' <<< "$response") > release_url.txt
+ - name: Save Release URL File for publish
+ uses: actions/upload-artifact@v3
+ with:
+ name: release_url
+ path: release_url.txt
+
+ build-and-push-docker-image:
+ name: Build Linux release in docker container
+ # run only when code is compiling and tests are passing
+ runs-on: ubuntu-latest
+ needs: create-release
+
+ # steps to perform in job
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ # avoid building if there are testing errors
+ - name: Run smoke test
+ run: |
+ sudo apt-get install -y libzbar0
+ python -m pip install --upgrade pip
+ pip install -U -r requirements-dev.txt
+ pip install -U .
+ pytest
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v2
+
+ # setup Docker build action
+ - name: Set up Docker Buildx
+ id: buildx
+ uses: docker/setup-buildx-action@v2
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Login to Github Packages
+ uses: docker/login-action@v2
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GHCR_IO_TOKEN }}
+
+ - name: "Build image from Buster and push to GitHub Container Registry"
+ id: docker_build_buster
+ uses: docker/build-push-action@v3
+ with:
+ platforms: linux/amd64,linux/arm64
+ # relative path to the place where source code with Dockerfile is located
+ # TODO file:, move to docker/
+ context: .
+ file: Dockerfile
+ # builder: ${{ steps.buildx.outputs.name }}
+ build-args: |
+ BASE_IMAGE=python:3.11-slim-buster
+ # Note: tags has to be all lower-case
+ pull: true
+ tags: |
+ ghcr.io/scito/extract_otp_secrets:buster
+ push: true
+
+ # # https://stackoverflow.com/a/61155718/1663871
+ # - name: Build docker images
+ # run: docker build -t local < .devcontainer/Dockerfile
+ # - name: Run tests
+ # run: docker run -it -v $PWD:/srv -w/srv local make test
+
+ - name: Image digest
+ # TODO upload digests to assets
+ run: |
+ echo "extract_otp_secrets: ${{ steps.docker_build_buster.outputs.digest }}"
+
+ - name: Run Pyinstaller in container
+ run: |
+ # TODO use local docker image https://stackoverflow.com/a/61155718/1663871
+ docker run --pull always --entrypoint /bin/bash --rm -v "$(pwd)":/files -w /files ghcr.io/scito/extract_otp_secrets:buster -c 'apt-get update && apt-get -y install binutils && pip install -U -r /files/requirements.txt && pip install pyinstaller && pyinstaller -y --add-data /usr/local/__yolo_v3_qr_detector/:__yolo_v3_qr_detector/ --onefile --name extract_otp_secrets_linux_x86_64 --distpath /files/dist/ /files/src/extract_otp_secrets.py'
+
+ - name: Smoke tests
+ run: |
+ dist/extract_otp_secrets_linux_x86_64 -h
+ dist/extract_otp_secrets_linux_x86_64 example_export.png
+ dist/extract_otp_secrets_linux_x86_64 - < example_export.txt
+ - name: Load Release URL File from release job
+ uses: actions/download-artifact@v3
+ with:
+ name: release_url
+ - name: Display structure of files
+ run: ls -R
+ - name: Upload Release Asset
+ id: upload-release-asset
+ # TODO only for tags
+ shell: bash
+ run: |
+ response=$(curl \
+ -X POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "Content-Type: application/x-executable" \
+ -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}"\
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ --silent \
+ --show-error \
+ --data-binary @dist/extract_otp_secrets_linux_x86_64 \
+ $(cat release_url.txt)=extract_otp_secrets_linux_x86_64)
+
+ build:
+ name: Build packages
+ needs: create-release
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#choosing-github-hosted-runners
+ include:
+ - os: ubuntu-latest
+ TARGET: linux
+ CMD_BUILD: |
+ pyinstaller -y --add-data $pythonLocation/__yolo_v3_qr_detector/:__yolo_v3_qr_detector/ --onefile src/extract_otp_secrets.py
+ OUT_FILE_NAME: extract_otp_secrets
+ ASSET_NAME: extract_otp_secrets_linux_x86_64_ubuntu_latest
+ ASSET_MIME: application/x-executable
+ UPLOAD: false
+ - os: macos-11
+ TARGET: macos
+ # TODO add --icon
+ # TODO add --osx-bundle-identifier
+ # TODO add --codesign-identity
+ # TODO add --osx-entitlements-file
+ # TODO https://pyinstaller.org/en/stable/spec-files.html#spec-file-options-for-a-macos-bundle
+ # TODO --target-arch universal2
+ CMD_BUILD: |
+ pyinstaller -y --add-data $macos_python_path/__yolo_v3_qr_detector/:__yolo_v3_qr_detector/ --onefile --argv-emulation src/extract_otp_secrets.py
+ OUT_FILE_NAME: extract_otp_secrets
+ ASSET_NAME: extract_otp_secrets_macos_x86_64
+ ASSET_MIME: application/x-newton-compatible-pkg
+ UPLOAD: true
+ - os: windows-latest
+ TARGET: windows
+ # TODO add --icon
+ # TODO add --manifest
+ # TODO find more elegant solution for pyzbar\libiconv.dll and pyzbar\libzbar-64.dll
+ CMD_BUILD: |
+ pyinstaller -y --add-data "$($Env:pythonLocation)\__yolo_v3_qr_detector;__yolo_v3_qr_detector" --add-binary "$($Env:pythonLocation)\Lib\site-packages\pyzbar\libiconv.dll;pyzbar" --add-binary "$($Env:pythonLocation)\Lib\site-packages\pyzbar\libzbar-64.dll;pyzbar" --onefile --version-file build\file_version_info.txt src\extract_otp_secrets.py
+ OUT_FILE_NAME: extract_otp_secrets.exe
+ ASSET_NAME: extract_otp_secrets_win_x86_64.exe
+ ASSET_MIME: application/vnd.microsoft.portable-executable
+ UPLOAD: true
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set macos macos_python_path
+ # TODO use variable for Python version
+ run: echo "macos_python_path=/Library/Frameworks/Python.framework/Versions/3.11" >> $GITHUB_ENV
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v4
+ with:
+ python-version: 3.11
+ check-latest: true
+ - name: Install zbar shared lib for QReader (Linux)
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get install -y libzbar0
+ - name: Install zbar shared lib for QReader (macOS)
+ if: runner.os == 'macOS'
+ run: |
+ brew install zbar
+ - name: Install dependencies
+ # TODO fix --use-pep517
+ run: |
+ python -m pip install --upgrade pip
+ pip install -U -r requirements-dev.txt
+ pip install -U .
+ - name: Create Windows file_version_info.txt
+ shell: bash
+ run: |
+ mkdir -p build/
+ VERSION_STR=$(setuptools-git-versioning) VERSION_MAJOR=$(cut -d '.' -f 1 <<< "$(setuptools-git-versioning)") VERSION_MINOR=$(cut -d '.' -f 2 <<< "$(setuptools-git-versioning)") VERSION_PATCH=$(cut -d '.' -f 3 <<< "$(setuptools-git-versioning)") VERSION_BUILD=$(($(git rev-list --count $(git tag | sort -V -r | sed '1!d')..HEAD))) YEARS='2020-2023' envsubst < file_version_info_template.txt > build/file_version_info.txt
+ - name: Build with pyinstaller for ${{ matrix.TARGET }}
+ run: ${{ matrix.CMD_BUILD }}
+ - name: Smoke tests for generated exe (general)
+ run: |
+ dist/${{ matrix.OUT_FILE_NAME }} -h
+ dist/${{ matrix.OUT_FILE_NAME }} example_export.png
+ - name: Smoke tests for generated exe (stdin)
+ if: runner.os != 'Windows'
+ run: |
+ dist/${{ matrix.OUT_FILE_NAME }} - < example_export.txt
+ - name: Load Release URL File from release job
+ uses: actions/download-artifact@v3
+ with:
+ name: release_url
+ - name: Display structure of files
+ run: ls -R
+ - name: Upload Release Asset
+ id: upload-release-asset
+ # TODO only for tags
+ if: ${{ matrix.UPLOAD }}
+ shell: bash
+ run: |
+ response=$(curl \
+ -X POST \
+ -H "Accept: application/vnd.github+json" \
+ -H "Content-Type: ${{ matrix.ASSET_MIME }}" \
+ -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}"\
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ --silent \
+ --show-error \
+ --data-binary @dist/${{ matrix.OUT_FILE_NAME }} \
+ $(cat release_url.txt)=${{ matrix.ASSET_NAME }})
+
diff --git a/.gitignore b/.gitignore
index 19ff843..318a7a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,9 @@ dist/
*.xml
pytest-coverage.txt
tests/reports/
+dist_*/
+*.spec
+
+file_version_info_python.txt
+file_version_info_explorer.txt
+file_version_info.txt
diff --git a/Dockerfile b/Dockerfile
index 7d5f5fa..43ae3f3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,6 @@
-FROM python:3.11-slim-bullseye
+# --build-arg BASE_IMAGE=python:3.11-slim-buster
+ARG BASE_IMAGE=python:3.11-slim-bullseye
+FROM $BASE_IMAGE
# https://docs.docker.com/engine/reference/builder/
@@ -10,7 +12,7 @@ FROM python:3.11-slim-bullseye
WORKDIR /extract
-COPY . .
+COPY requirements*.txt src/ run_pytest.sh pytest.ini tests/ example_*.txt example_*.png example_*.csv example*.json docker/.alias ./
ARG RUN_TESTS=true
@@ -20,13 +22,14 @@ RUN apt-get update && apt-get install -y \
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
+ && pip install --no-cache-dir -U -r requirements.txt \
+ && if [ "$RUN_TESTS" = "true" ]; then /extract/run_pytest.sh; else echo "Not running tests..."; fi \
+ && echo 'test -s /extract/.alias && . /extract/.alias || true' >> ~/.bashrc
WORKDIR /files
-ENTRYPOINT ["python", "/extract/src/extract_otp_secrets.py"]
+ENTRYPOINT ["python", "/extract/extract_otp_secrets.py"]
LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secrets
LABEL org.opencontainers.image.license GPL-3.0+
+LABEL maintainer="Scito https://scito.ch, https://github.com/scito"
diff --git a/Dockerfile_only_txt b/Dockerfile_only_txt
index b44e665..f7a6f2a 100644
--- a/Dockerfile_only_txt
+++ b/Dockerfile_only_txt
@@ -1,16 +1,19 @@
-FROM python:3.11-alpine
+ARG BASE_IMAGE=python:3.11-alpine
+FROM $BASE_IMAGE
# 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
+# docker run --entrypoint /bin/ash -it --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt -l
+# docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt extract_otp_secrets_test.py -k "not qreader" --relaxed -vvv -s
+
+# https://github.com/pypa/manylinux/blob/main/docker/Dockerfile
WORKDIR /extract
-COPY . .
+COPY requirements*.txt src/ run_pytest.sh pytest.ini tests/ example_*.txt example_*.png example_*.csv example*.json docker/.alias ./
ARG RUN_TESTS=true
@@ -32,11 +35,13 @@ RUN apk add --no-cache \
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
+ && if [[ "$RUN_TESTS" == "true" ]]; then /extract/run_pytest.sh extract_otp_secrets_test.py -k "not qreader" --relaxed; else echo "Not running tests..."; fi \
+ && echo 'test -s /extract/.alias && . /extract/.alias || true' >> ~/.profile
WORKDIR /files
-ENTRYPOINT ["python", "/extract/src/extract_otp_secrets.py"]
+ENTRYPOINT ["python", "/extract/extract_otp_secrets.py"]
LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secrets
LABEL org.opencontainers.image.license GPL-3.0+
+LABEL maintainer="Scito https://scito.ch, https://github.com/scito"
diff --git a/Pipfile b/Pipfile
index e9b33bc..d4a844c 100644
--- a/Pipfile
+++ b/Pipfile
@@ -16,12 +16,14 @@ qreader = "<2.0.0"
[dev-packages]
build = "*"
flake8 = "*"
+gfm-toc = "*"
mypy = "*"
mypy-protobuf = "*"
pylint = "*"
pytest = "*"
pytest-cov = "*"
pytest-mock = "*"
+setuptools-git-versioning = "*"
types-protobuf = "*"
wheel = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index 79aa9a6..ec57988 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "9b56c9708e464fbb035e36b29b0c89f0250bc50ac37234f3948a366136a8e6c9"
+ "sha256": "41edd4aebe075d6c39d035ec7cb10f0253a3ad21f9b4aa5b9c57deccca87874f"
},
"pipfile-spec": 6,
"requires": {
@@ -220,11 +220,11 @@
"develop": {
"astroid": {
"hashes": [
- "sha256:3bc7834720e1a24ca797fd785d77efb14f7a28ee8e635ef040b6e2d80ccb3303",
- "sha256:8f6a8d40c4ad161d6fc419545ae4b2f275ed86d1c989c97825772120842ee0d2"
+ "sha256:14c1603c41cc61aae731cad1884a073c4645e26f126d13ac8346113c95577f3b",
+ "sha256:6afc22718a48a689ca24a97981ad377ba7fb78c133f40335dfd16772f29bcfb1"
],
"markers": "python_full_version >= '3.7.2'",
- "version": "==2.13.2"
+ "version": "==2.13.3"
},
"attrs": {
"hashes": [
@@ -318,6 +318,14 @@
"index": "pypi",
"version": "==6.0.0"
},
+ "gfm-toc": {
+ "hashes": [
+ "sha256:247af7267a6cbbdd4213f8383157997bcb07e39e819db737bd2dbfbdb94ee7ae",
+ "sha256:c53ed0e2cd400e89051377017ca98c11c9cef628b2effddf787db4fc19ff343d"
+ ],
+ "index": "pypi",
+ "version": "==0.0.7"
+ },
"iniconfig": {
"hashes": [
"sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3",
@@ -535,6 +543,22 @@
"index": "pypi",
"version": "==3.10.0"
},
+ "setuptools": {
+ "hashes": [
+ "sha256:6f590d76b713d5de4e49fe4fbca24474469f53c83632d5d0fd056f7ff7e8112b",
+ "sha256:ac4008d396bc9cd983ea483cb7139c0240a07bbc74ffb6232fceffedc6cf03a8"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==66.1.1"
+ },
+ "setuptools-git-versioning": {
+ "hashes": [
+ "sha256:648481f7e1e9e12ccd2b069d616b909a338b4223956319649351751cbc0207f4",
+ "sha256:fde1a7cb3b2566979e5651cfca0d33cd5a82771711cd38a056216391936cf0ff"
+ ],
+ "index": "pypi",
+ "version": "==1.13.1"
+ },
"tomlkit": {
"hashes": [
"sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b",
@@ -545,11 +569,11 @@
},
"types-protobuf": {
"hashes": [
- "sha256:7df483d34ad3fcb1fa7fff1073560d596c9ac1f419cfa851b220c9a93386c998",
- "sha256:aeefcf39d637016998b3c7b699750847071b555f7c2e0c9873d42ab6103d1a39"
+ "sha256:6c87c7f8df61d57a53de8221777e4fcc3c7ed24419fbf43b8e9f50887f3773fa",
+ "sha256:824109e0fe87525a9d2da4cc4eec36ca004f1a0f3d1c0838cfd2873a484cffdd"
],
"index": "pypi",
- "version": "==4.21.0.2"
+ "version": "==4.21.0.3"
},
"typing-extensions": {
"hashes": [
diff --git a/README.md b/README.md
index d02800c..3a598a3 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,20 @@
# Extract TOTP/HOTP secrets from QR codes exported by two-factor authentication apps
[![CI tests](https://github.com/scito/extract_otp_secrets/actions/workflows/ci.yml/badge.svg)](https://github.com/scito/extract_otp_secrets/actions/workflows/ci.yml)
-![coverage](https://img.shields.io/badge/coverage-94%25-brightgreen)
[![CI docker](https://github.com/scito/extract_otp_secrets/actions/workflows/ci_docker.yml/badge.svg)](https://github.com/scito/extract_otp_secrets/actions/workflows/ci_docker.yml)
-![PyPI - Python Version](https://img.shields.io/pypi/pyversions/protobuf)
+![coverage](https://img.shields.io/badge/coverage-94%25-brightgreen)
+
+![python versions](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9%20%7C%203.10%20%7C%203.11-blue)
[![License](https://img.shields.io/github/license/scito/extract_otp_secrets)](https://github.com/scito/extract_otp_secrets/blob/master/LICENSE)
-[![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/scito/extract_otp_secrets?sort=semver&label=version)](https://github.com/scito/extract_otp_secrets/tags)
+[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/scito/extract_otp_secrets?sort=semver)](https://github.com/scito/extract_otp_secrets/releases/latest)
+[![Download executable](https://img.shields.io/badge/download-exe-blue)](https://github.com/scito/extract_otp_secrets/releases/latest)
+[![Linux](https://img.shields.io/badge/os-linux-yellow)](https://github.com/scito/extract_otp_secrets/releases/latest)
+[![Windows](https://img.shields.io/badge/os-windows-yellow)](https://github.com/scito/extract_otp_secrets/releases/latest)
+[![MacOS](https://img.shields.io/badge/os-macos-yellow)](https://github.com/scito/extract_otp_secrets/releases/latest)
+[![Docker image](https://img.shields.io/badge/docker-image-blue)](https://hub.docker.com/repository/docker/scit0/extract_otp_secrets/general)
+
[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua)
---
@@ -23,6 +30,62 @@ The secrets can be exported to JSON or CSV, or printed as QR codes to console or
⚡ **This project/script was renamed from `extract_otp_secret_keys` to `extract_otp_secrets`.** ⚡
+Table of contents
+
+## Table of contents
+
+- [Usage](#usage)
+ - [Capture QR codes from camera (🆕 since version 2.0)](#capture-qr-codes-from-camera--since-version-20)
+ - [With builtin QR decoder from image files (🆕 since version 2.0)](#with-builtin-qr-decoder-from-image-files--since-version-20)
+ - [With external QR decoder app from text files](#with-external-qr-decoder-app-from-text-files)
+- [Installation](#installation)
+ - [Download binary executable (🆕 since v2.1)](#download-binary-executable--since-v21)
+ - [Run as script (recommend for developers or advanced users)](#run-as-script-recommend-for-developers-or-advanced-users)
+ - [Installation of shared system libraries](#installation-of-shared-system-libraries)
+- [Program help: arguments and options](#program-help-arguments-and-options)
+- [Examples](#examples)
+ - [Printing otp secrets form text file](#printing-otp-secrets-form-text-file)
+ - [Printing otp secrets from image file](#printing-otp-secrets-from-image-file)
+ - [Writing otp secrets to csv file](#writing-otp-secrets-to-csv-file)
+ - [Writing otp secrets to json file](#writing-otp-secrets-to-json-file)
+ - [Printing otp secrets multiple files](#printing-otp-secrets-multiple-files)
+ - [Printing otp secrets from stdin (text)](#printing-otp-secrets-from-stdin-text)
+ - [Printing otp secrets from stdin (image)](#printing-otp-secrets-from-stdin-image)
+ - [Printing otp secrets csv to stdout](#printing-otp-secrets-csv-to-stdout)
+ - [Printing otp secrets csv to stdout without header line](#printing-otp-secrets-csv-to-stdout-without-header-line)
+ - [Reading from stdin and printing to stdout](#reading-from-stdin-and-printing-to-stdout)
+- [Features](#features)
+- [KeePass](#keepass)
+- [How to export otp secrets from Google Authenticator app](#how-to-export-otp-secrets-from-google-authenticator-app)
+- [Glossary](#glossary)
+- [Alternative installation methods](#alternative-installation-methods)
+ - [pip using github](#pip-using-github)
+ - [local pip](#local-pip)
+ - [pipenv](#pipenv)
+ - [Visual Studio Code Remote - Containers / VSCode devcontainer](#visual-studio-code-remote---containers--vscode-devcontainer)
+ - [venv](#venv)
+ - [devbox](#devbox)
+ - [docker](#docker)
+ - [More docker examples](#more-docker-examples)
+- [Tests](#tests)
+ - [PyTest](#pytest)
+ - [unittest](#unittest)
+ - [VSCode Setup](#vscode-setup)
+- [Development](#development)
+ - [Build](#build)
+ - [Upgrade pip Packages](#upgrade-pip-packages)
+ - [Build docker images](#build-docker-images)
+ - [Create executables with pyinstaller](#create-executables-with-pyinstaller)
+ - [Full local build (bash)](#full-local-build-bash)
+- [Technical background](#technical-background)
+- [References](#references)
+- [Issues](#issues)
+- [Problems and Troubleshooting](#problems-and-troubleshooting)
+ - [Windows error message](#windows-error-message)
+- [Related projects](#related-projects)
+
usage: extract_otp_secrets.py [-h] [--csv FILE] [--keepass FILE] [--json FILE] [--printqr] [--saveqr DIR] [--camera NUMBER] [--qr {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}] [-i] [--no-color] [-d | -v | -q] [infile ...] +* Docker * VSCode devcontainer * devbox -* Prebuilt Docker images provided for amd64 and arm64 (🆕 since v2.0) * Compatible with major platforms: * Linux * macOS @@ -308,10 +392,16 @@ KeePass can be used as a backup for one time passwords (second factor) from the ## Alternative installation methods -### pip +### pip using github ``` pip install -U git+https://github.com/scito/extract_otp_secrets +extract_otp_secrets +``` + +or run it + +``` python -m extract_otp_secrets ``` @@ -319,7 +409,7 @@ or from a specific tag ``` pip install -U git+https://github.com/scito/extract_otp_secrets.git@v2.0.0 -python -m extract_otp_secrets +extract_otp_secrets curl -s https://raw.githubusercontent.com/scito/extract_otp_secrets/master/example_export.txt | python -m extract_otp_secrets - ``` @@ -328,6 +418,12 @@ curl -s https://raw.githubusercontent.com/scito/extract_otp_secrets/master/examp ``` git clone https://github.com/scito/extract_otp_secrets.git pip install -U -e extract_otp_secrets +extract_otp_secrets extract_otp_secrets/example_export.txt +``` + +or run it + +``` python -m extract_otp_secrets extract_otp_secrets/example_export.txt ``` @@ -500,16 +596,34 @@ docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extrac #### Alpine (only text file processing) ```bash -docker build . -t extract_otp_secrets_only_txt --pull -f Dockerfile_only_txt --build-arg RUN_TESTS=false +docker build . -t extract_otp_secrets:only_txt --pull -f Dockerfile_only_txt --build-arg RUN_TESTS=false ``` Run tests in docker container: ```bash -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 +docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt extract_otp_secrets_test.py -k "not qreader" --relaxed ``` -### Full local build +### Create executables with pyinstaller + +#### Linux + +```bash +pyinstaller -y --add-data $pythonLocation/__yolo_v3_qr_detector/:__yolo_v3_qr_detector/ --onefile src/extract_otp_secrets.py +``` + +Output is executable `dist/extract_otp_secrets` + +#### Windows + +``` +pyinstaller -y --add-data "%pythonLocation%\__yolo_v3_qr_detector;__yolo_v3_qr_detector" --add-binary "%pythonLocation%\pyzbar\libiconv.dll;pyzbar" --add-binary "%pythonLocation%\pyzbar\libzbar-64.dll;pyzbar" --onefile --version-file build\file_version_info.txt src\extract_otp_secrets.py +``` + +Output is `dist\extract_otp_secrets.exe` + +### Full local build (bash) There is a Bash script for a full local build including linting and type checking. diff --git a/build.sh b/build.sh index 3c8fd05..cf473ee 100755 --- a/build.sh +++ b/build.sh @@ -76,6 +76,7 @@ askContinueYn() { interactive=false ignore_version_check=true clean=false +clean_flag="" build_docker=true run_gui=true generate_result_files=false @@ -119,6 +120,7 @@ while test $# -gt 0; do ;; -c) clean=true + clean_flag="--clean" shift ;; esac @@ -160,7 +162,7 @@ if $clean; then if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" - 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;" + cmd="sudo rm -rf dist/ build/ dist_*/ *.whl extracted_*.csv extracted_*.json 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" @@ -277,6 +279,15 @@ 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" + +# 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 + # pip -e install cmd="$PIP install -U -e ." @@ -330,13 +341,26 @@ cmd="$PIP wheel ." if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" -# Generate results files +# Build executable -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 +cmd="LOCAL_GLIBC_VERSION=$(ldd --version | sed '1!d' | sed -E 's/.* ([[:digit:]]+\.[[:digit:]]+)$/\1/')" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" +echo "local glibc: $LOCAL_GLIBC_VERSION" + +cmd="pyinstaller -y --add-data $HOME/.local/__yolo_v3_qr_detector/:__yolo_v3_qr_detector/ --onefile $clean_flag src/extract_otp_secrets.py" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="dist/extract_otp_secrets -h" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# Generate README.md TOC + +cmd="gfm-toc -s 2 -e 3 -t -o README.md > docs/README_TOC.md" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" # Update Code Coverage in README.md @@ -349,7 +373,7 @@ 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" + cmd="docker build . -t extract_otp_secrets_only_txt -t extract_otp_secrets:only-txt -t extract_otp_secrets:alpine -f Dockerfile_only_txt --pull --build-arg RUN_TESTS=false" if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" @@ -361,13 +385,12 @@ if $build_docker; then 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" + cmd="docker run --entrypoint /extract/run_pytest.sh --rm -v \"$(pwd)\":/files:ro extract_otp_secrets_only_txt 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" + # Build extract_otp_secrets (Debian Bullseye) + cmd="docker build . -t extract_otp_secrets -t extract_otp_secrets:bullseye --pull --build-arg RUN_TESTS=false" if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" @@ -387,6 +410,58 @@ if $build_docker; then if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" + # Build extract_otp_secrets (Debian Buster) + cmd="docker build . -t extract_otp_secrets:buster --pull --build-arg RUN_TESTS=false --build-arg BASE_IMAGE=python:3.11-slim-buster" + 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:buster 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:buster - -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:buster = < 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:buster" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + # Build executable from Docker latest + # sed "1!d" is workaround for head -n 1 since it head procduces exit code != 0 + BULLSEYE_GLIBC_VERSION=$(docker run --entrypoint /bin/bash --rm extract_otp_secrets -c 'ldd --version | sed "1!d" | sed -E "s/.* ([[:digit:]]+\.[[:digit:]]+)$/\1/"') + echo "Bullseye glibc: $BULLSEYE_GLIBC_VERSION" + + cmd="docker run --entrypoint /bin/bash --rm -v \"$(pwd)\":/files -w /files extract_otp_secrets -c 'apt-get update && apt-get -y install binutils && pip install -U -r /files/requirements.txt && pip install pyinstaller && pyinstaller -y --add-data /usr/local/__yolo_v3_qr_detector/:__yolo_v3_qr_detector/ --onefile --name extract_otp_secrets_linux_x86_64_bullseye --distpath /files/dist/ /files/src/extract_otp_secrets.py'" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="dist/extract_otp_secrets_linux_x86_64_bullseye -h" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + # Build executable from Docker buster + BUSTER_GLIBC_VERSION=$(docker run --entrypoint /bin/bash --rm extract_otp_secrets:buster -c 'ldd --version | sed "1!d" | sed -E "s/.* ([[:digit:]]+\.[[:digit:]]+)$/\1/"') + echo "Bullseye glibc: $BUSTER_GLIBC_VERSION" + + cmd="docker run --entrypoint /bin/bash --rm -v \"$(pwd)\":/files -w /files extract_otp_secrets:buster -c 'apt-get update && apt-get -y install binutils && pip install -U -r /files/requirements.txt && pip install pyinstaller && pyinstaller -y --add-data /usr/local/__yolo_v3_qr_detector/:__yolo_v3_qr_detector/ --onefile --name extract_otp_secrets_linux_x86_64 --distpath /files/dist/ /files/src/extract_otp_secrets.py'" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="dist/extract_otp_secrets_linux_x86_64 -h" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + # create Windows file_version_info.txt + cmd="VERSION_STR=$(setuptools-git-versioning) VERSION_MAJOR=$(cut -d '.' -f 1 <<< "$(setuptools-git-versioning)") VERSION_MINOR=$(cut -d '.' -f 2 <<< "$(setuptools-git-versioning)") VERSION_PATCH=$(cut -d '.' -f 3 <<< "$(setuptools-git-versioning)") VERSION_BUILD=$(($(git rev-list --count $(git tag | sort -V -r | sed '1!d')..HEAD))) YEARS='2020-2023' envsubst < file_version_info_template.txt > build/file_version_info.txt" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + # Run GUI from Docker 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 diff --git a/docker/.alias b/docker/.alias new file mode 100644 index 0000000..e44949f --- /dev/null +++ b/docker/.alias @@ -0,0 +1,4 @@ +alias ll='ls -lh' +alias la='ls -lha' +alias l='ls -alhF' +alias ls-l='ls -lh' diff --git a/docs/README_TOC.md b/docs/README_TOC.md new file mode 100644 index 0000000..86e2a72 --- /dev/null +++ b/docs/README_TOC.md @@ -0,0 +1,56 @@ +Generate from file: README.md + +## Table of contents + +- [Table of contents](#table-of-contents) +- [Usage](#usage) + - [Capture QR codes from camera (🆕 since version 2.0)](#capture-qr-codes-from-camera--since-version-20) + - [With builtin QR decoder from image files (🆕 since version 2.0)](#with-builtin-qr-decoder-from-image-files--since-version-20) + - [With external QR decoder app from text files](#with-external-qr-decoder-app-from-text-files) +- [Installation](#installation) + - [Download binary executable (🆕 since v2.1)](#download-binary-executable--since-v21) + - [Run as script (recommend for developers or advanced users)](#run-as-script-recommend-for-developers-or-advanced-users) + - [Installation of shared system libraries](#installation-of-shared-system-libraries) +- [Program help: arguments and options](#program-help-arguments-and-options) +- [Examples](#examples) + - [Printing otp secrets form text file](#printing-otp-secrets-form-text-file) + - [Printing otp secrets from image file](#printing-otp-secrets-from-image-file) + - [Writing otp secrets to csv file](#writing-otp-secrets-to-csv-file) + - [Writing otp secrets to json file](#writing-otp-secrets-to-json-file) + - [Printing otp secrets multiple files](#printing-otp-secrets-multiple-files) + - [Printing otp secrets from stdin (text)](#printing-otp-secrets-from-stdin-text) + - [Printing otp secrets from stdin (image)](#printing-otp-secrets-from-stdin-image) + - [Printing otp secrets csv to stdout](#printing-otp-secrets-csv-to-stdout) + - [Printing otp secrets csv to stdout without header line](#printing-otp-secrets-csv-to-stdout-without-header-line) + - [Reading from stdin and printing to stdout](#reading-from-stdin-and-printing-to-stdout) +- [Features](#features) +- [KeePass](#keepass) +- [How to export otp secrets from Google Authenticator app](#how-to-export-otp-secrets-from-google-authenticator-app) +- [Glossary](#glossary) +- [Alternative installation methods](#alternative-installation-methods) + - [pip using github](#pip-using-github) + - [local pip](#local-pip) + - [pipenv](#pipenv) + - [Visual Studio Code Remote - Containers / VSCode devcontainer](#visual-studio-code-remote---containers--vscode-devcontainer) + - [venv](#venv) + - [devbox](#devbox) + - [docker](#docker) + - [More docker examples](#more-docker-examples) +- [Tests](#tests) + - [PyTest](#pytest) + - [unittest](#unittest) + - [VSCode Setup](#vscode-setup) +- [Development](#development) + - [Build](#build) + - [Upgrade pip Packages](#upgrade-pip-packages) + - [Build docker images](#build-docker-images) + - [Create executables with pyinstaller](#create-executables-with-pyinstaller) + - [Full local build (bash)](#full-local-build-bash) +- [Technical background](#technical-background) +- [References](#references) +- [Issues](#issues) +- [Problems and Troubleshooting](#problems-and-troubleshooting) + - [Windows error message](#windows-error-message) +- [Related projects](#related-projects) + +Table of contents generated. diff --git a/cv2_capture_screenshot.png b/docs/cv2_capture_screenshot.png similarity index 100% rename from cv2_capture_screenshot.png rename to docs/cv2_capture_screenshot.png diff --git a/file_version_info_template.txt b/file_version_info_template.txt new file mode 100644 index 0000000..cb4126c --- /dev/null +++ b/file_version_info_template.txt @@ -0,0 +1,46 @@ +# UTF-8 +# +# For more details about fixed file info 'ffi' see: +# http://msdn.microsoft.com/en-us/library/ms646997.aspx +VSVersionInfo( + ffi=FixedFileInfo( + # The elements of each tuple represent 16-bit values from most-significant to least-significant. For example the value (2, 0, 4, 0) resolves to 0002000000040000 in hex. + # filevers and prodvers should be always a tuple with four items: (1, 2, 3, 4) + # Set not needed items to zero 0. + filevers=($VERSION_MAJOR, $VERSION_MINOR, $VERSION_PATCH, $VERSION_BUILD), + prodvers=($VERSION_MAJOR, $VERSION_MINOR, $VERSION_PATCH, $VERSION_BUILD), + # Contains a bitmask that specifies the valid bits 'flags'r + mask=0x3f, + # Contains a bitmask that specifies the Boolean attributes of the file. + flags=0x0, + # The operating system for which this file was designed. + # 0x4 - NT and there is no need to change it. + OS=0x4, + # The general type of file. + # 0x1 - the file is an application. + fileType=0x1, + # The function of the file. + # 0x0 - the function is not defined for this fileType + subtype=0x0, + # Creation date and time stamp. + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + # 0x0409 (U.S. English) + 04B0 (1200 = Unicode), https://learn.microsoft.com/en-us/windows/win32/menurc/stringfileinfo-block + '040904B0', + [StringStruct('CompanyName', 'scito'), + StringStruct('FileDescription', 'extract_otp_secrets'), + StringStruct('FileVersion', '$VERSION_STR'), + StringStruct('InternalName', 'extract_otp_secrets'), + StringStruct('LegalCopyright', 'Copyright © $YEARS Scito.'), + StringStruct('OriginalFilename', 'extract_otp_secrets.exe'), + StringStruct('ProductName', 'extract_otp_secrets'), + StringStruct('ProductVersion', '$VERSION_STR')]) + ]), + # 1033 (0x0409 = U.S. English), 1200 (Unicode) + VarFileInfo([VarStruct('Translation', [0, 1200])]) + ] +) diff --git a/pyproject.toml b/pyproject.toml index bcab23a..898a1d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,10 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: System Administrators", "Programming Language :: Python", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows :: Windows 10", + "Operating System :: Microsoft :: Windows :: Windows 11", + "Operating System :: MacOS", "Natural Language :: English", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Typing :: Typed", @@ -40,7 +44,9 @@ dependencies = [ "pyzbar", "qrcode", "qreader<2.0.0", + # workaround for PYTHON <= 3.7: compatibility "typing_extensions; python_version<='3.7'", + "importlib_metadata; python_version<='3.7'", ] description = "Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps such as 'Google Authenticator'" dynamic = ["version"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 5e80330..2fb7df0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,11 +1,14 @@ build flake8 +gfm-toc mypy mypy-protobuf +pyinstaller pylint pytest pytest-cov pytest-mock setuptools +setuptools-git-versioning types-protobuf wheel diff --git a/requirements.txt b/requirements.txt index c4e52cd..454f7d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ pyzbar qrcode qreader<2.0.0 typing_extensions; python_version<='3.7' +importlib_metadata; python_version<='3.7' diff --git a/run_pytest.sh b/run_pytest.sh index 08ccfdd..8c77ce5 100755 --- a/run_pytest.sh +++ b/run_pytest.sh @@ -1,3 +1,5 @@ #!/bin/sh cd /extract -pip install -U pytest pytest-mock && pip install --no-deps . && pytest "$@" +mkdir -p tests +ln -sf /extract/data tests/data +pip install -U pytest pytest-mock && pytest "$@" diff --git a/setup.cfg b/setup.cfg index e867b90..a879cca 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,6 +6,10 @@ python_requires = >=3.7, <4 py_modules = extract_otp_secrets, protobuf_generated_python.google_auth_pb2 package_dir = =src +platforms = + Linux + Windows + MacOS # packages=find: # [options.packages.find] diff --git a/src/extract_otp_secrets.py b/src/extract_otp_secrets.py index 6b920c6..80c6165 100644 --- a/src/extract_otp_secrets.py +++ b/src/extract_otp_secrets.py @@ -38,11 +38,18 @@ import csv import fileinput import json import os +import platform import re import sys import urllib.parse as urlparse from enum import Enum, IntEnum -from typing import Any, List, Optional, TextIO, Tuple, Union +from typing import Any, List, Optional, Sequence, TextIO, Tuple, Union + +import colorama +from pkg_resources import DistributionNotFound, get_distribution +from qrcode import QRCode # type: ignore + +import protobuf_generated_python.google_auth_pb2 as pb # workaround for PYTHON <= 3.7: compatibility if sys.version_info >= (3, 8): @@ -50,16 +57,17 @@ if sys.version_info >= (3, 8): else: from typing_extensions import Final, TypedDict -from qrcode import QRCode # type: ignore +# workaround for PYTHON <= 3.7: compatibility +if sys.version_info >= (3, 8): + from importlib.metadata import PackageNotFoundError, version +else: + from importlib_metadata import PackageNotFoundError, version -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: @@ -133,6 +141,8 @@ CAMERA: Final[str] = 'camera' verbose: IntEnum = LogLevel.NORMAL quiet: bool = False colored: bool = True +executable: bool = False +__version__: str def sys_main() -> None: @@ -140,6 +150,7 @@ def sys_main() -> None: def main(sys_args: list[str]) -> None: + global executable # allow to use sys.stdout with with (avoid closing) sys.stdout.close = lambda: None # type: ignore # set encoding to utf-8, needed for Windows @@ -150,11 +161,15 @@ def main(sys_args: list[str]) -> None: # StringIO in tests do not have all attributes, ignore it pass + # https://pyinstaller.org/en/stable/runtime-information.html#run-time-information + executable = getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS') + args = parse_args(sys_args) if colored: colorama.just_fix_windows_console() - + if verbose >= LogLevel.DEBUG: + print(f"Version: {get_full_version()}\n") if args.debug: sys.exit(0 if do_debug_checks() else 1) @@ -237,15 +252,19 @@ def extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count: def parse_args(sys_args: list[str]) -> Args: global verbose, quiet, colored + + # For PYTHON <= 3.7: Use := + name = os.path.basename(sys.argv[0]) + cmd = f"python {name}" if name.endswith('.py') else f"{name}" 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""" + example_text = f"""examples: +{cmd} +{cmd} example_*.txt +{cmd} - < example_export.txt +{cmd} --csv - example_*.png | tail -n+2 +{cmd} = < example_export.png""" arg_parser = argparse.ArgumentParser(formatter_class=lambda prog: argparse.RawTextHelpFormatter(prog, max_help_position=32), description=description_text, @@ -262,6 +281,7 @@ b) image file containing a QR code or = for stdin for an image containing a QR c 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') + arg_parser.add_argument('--version', '-V', help='print version and quit', action=PrintVersionAction) 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') @@ -731,6 +751,57 @@ def do_debug_checks() -> bool: return True +class PrintVersionAction(argparse.Action): + def __init__(self, option_strings: Sequence[str], dest: str, nargs: int = 0, **kwargs: Any) -> None: + super().__init__(option_strings, dest, nargs, **kwargs) + + def __call__(self, parser: argparse.ArgumentParser, namespace: Args, values: Union[str, Sequence[Any], None], option_string: Optional[str] = None) -> None: + print_version() + parser.exit() + + +def print_version() -> None: + print(get_full_version()) + + +def get_full_version() -> str: + version = get_raw_version() + meta = [ + platform.python_implementation() + ] + if executable: meta.append('exe') + meta.append(f"called as {'package' if __package__ else 'script'}") + return ( + f"extract_otp_secrets {version} {platform.system()} {platform.machine()}" + f" Python {platform.python_version()}" + f" ({'/'.join(meta)})" + ) + + +# https://setuptools-git-versioning.readthedocs.io/en/stable/runtime_version.html +def get_raw_version() -> str: + global __version__ + + try: + __version__ = version("extract_otp_secrets") + return __version__ + except PackageNotFoundError: + # package is not installed + pass + + # In some cases importlib cannot properly detect package version, for example it was compiled into executable file, so it uses some custom import mechanism. + # Instead, use pkg_resources which is included in setuptools (but has a significant runtime cost) + + try: + __version__ = get_distribution("package-name").version + return __version__ + except DistributionNotFound: + # package is not installed + pass + + return '' + + # workaround for PYTHON <= 3.9 use: BaseException | None def log_debug(*values: object, sep: Optional[str] = ' ') -> None: if colored: diff --git a/tests/data/print_verbose_output-n-vvv.txt b/tests/data/print_verbose_output-n-vvv.txt index 4013a77..a12e0f4 100644 --- a/tests/data/print_verbose_output-n-vvv.txt +++ b/tests/data/print_verbose_output-n-vvv.txt @@ -2,6 +2,8 @@ QReader installed: True CV2 version: 4.7.0 QR reading mode: ZBAR +Version: extract_otp_secrets 2.0.2.post50+git.158245dd.dirty Linux x86_64 Python 3.11.1 (CPython/called as script) + Input files: ['example_export.txt'] Processing infile example_export.txt Reading lines of example_export.txt diff --git a/tests/data/print_verbose_output-vvv.txt b/tests/data/print_verbose_output-vvv.txt index e81eb24..5f53095 100644 --- a/tests/data/print_verbose_output-vvv.txt +++ b/tests/data/print_verbose_output-vvv.txt @@ -2,6 +2,8 @@ QReader installed: True CV2 version: 4.7.0 QR reading mode: ZBAR +Version: extract_otp_secrets 2.0.2.post50+git.158245dd.dirty Linux x86_64 Python 3.11.1 (CPython/called as script) + Input files: ['example_export.txt'] [36mProcessing infile example_export.txt[39m Reading lines of example_export.txt diff --git a/tests/extract_otp_secrets_test.py b/tests/extract_otp_secrets_test.py index 9c56021..e77c648 100644 --- a/tests/extract_otp_secrets_test.py +++ b/tests/extract_otp_secrets_test.py @@ -509,7 +509,7 @@ def test_extract_verbose(verbose_level: str, color: str, capsys: pytest.CaptureF def normalize_verbose_text(text: str, relaxed: bool) -> str: - normalized = re.sub('^.+ version: .+$', '', text, flags=re.MULTILINE | re.IGNORECASE) + normalized = re.sub('^.*version: .+$', '', text, flags=re.MULTILINE | re.IGNORECASE) if not qreader_available: normalized = normalized \ .replace('QReader installed: True', 'QReader installed: False') \ @@ -549,6 +549,20 @@ def test_extract_help(capsys: pytest.CaptureFixture[str]) -> None: assert e.value.code == 0 +def test_extract_version(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as e: + # Act + extract_otp_secrets.main(['--version']) + + # Assert + captured = capsys.readouterr() + + assert captured.out.startswith('extract_otp_secrets ') + 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: # Arrangeusage: extract_otp_secrets.py [-h] [--csv FILE] [--keepass FILE] [--json FILE] [--printqr] [--saveqr DIR] [--camera NUMBER] [--qr {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}] [-i] [--no-color] [--version] [-d | -v | -q] [infile ...] Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps If no infiles are provided, a GUI window starts and QR codes are captured from the camera. @@ -141,6 +220,7 @@ options: QR reader (default: ZBAR) -i, --ignore ignore duplicate otps --no-color, -n do not use ANSI colors in console output + --version, -V print version and quit -d, --debug enter debug mode, do checks and quit -v, --verbose verbose output -q, --quiet no stdout output, except output set by - @@ -226,6 +306,11 @@ python extract_otp_secrets.py = < example_export.png* Portable image format - *.pbm, *.pgm, *.ppm *.pxm, *.pnm * Prints errors and warnings to stderr (🆕 since v2.0) * Prints colored output (🆕 since v2.0) +* Startable as executable (script, Python, and all dependencies packed in one executable) (🆕 since v2.1) + * extract_otp_secrets_linux_x86_64 (requires glibc >= 2.28) + * extract_otp_secrets_win_x86_64.exe + * extract_otp_secrets_macos_x86_64 (untested) +* Prebuilt Docker images provided for amd64 and arm64 (🆕 since v2.0) * Many ways to run the script: * Native Python * pipenv @@ -234,7 +319,6 @@ python extract_otp_secrets.py = < example_export.png