Compare commits
13 commits
ae73b46773
...
e046b62a93
| Author | SHA1 | Date | |
|---|---|---|---|
| e046b62a93 | |||
| 938c652053 | |||
| 1784ede3a3 | |||
| 556b997ae4 | |||
| cdac5f90e5 | |||
| d156e0e2ad | |||
| d797bc1d24 | |||
| 76648063dd | |||
| 4c670cc286 | |||
| 7767fa4b66 | |||
| 32c5240fea | |||
| 5b1f9ac2e8 | |||
| 6ba5fef1fc |
11 changed files with 544 additions and 270 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# SPDX-FileCopyrightText: 2023 Jeff Epler
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
/venv
|
||||
*.egg-info/
|
||||
__version__.py
|
||||
34
.pre-commit-config.yaml
Normal file
34
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# SPDX-FileCopyrightText: 2020 Diego Elio Pettenò
|
||||
#
|
||||
# SPDX-License-Identifier: Unlicense
|
||||
|
||||
default_language_version:
|
||||
python: python3
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
exclude: tests
|
||||
- id: trailing-whitespace
|
||||
exclude: tests
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.2.6
|
||||
hooks:
|
||||
- id: codespell
|
||||
args: [-w]
|
||||
- repo: https://github.com/fsfe/reuse-tool
|
||||
rev: v2.1.0
|
||||
hooks:
|
||||
- id: reuse
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.1.6
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
args: [ --fix, --preview ]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
9
LICENSES/MIT.txt
Normal file
9
LICENSES/MIT.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) <year> <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
10
LICENSES/Unlicense.txt
Normal file
10
LICENSES/Unlicense.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this software, either in source code form or as a compiled binary, for any purpose, commercial or non-commercial, and by any means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors of this software dedicate any and all copyright interest in the software to the public domain. We make this dedication for the benefit of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <http://unlicense.org/>
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# SPDX-FileCopyrightText: 2023 Jeff Epler <jepler@gmail.com>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
.PHONY: mypy
|
||||
mypy: venv/bin/mypy
|
||||
venv/bin/mypy
|
||||
|
||||
# Update CONTRIBUTING.md if these commands change
|
||||
venv/bin/mypy:
|
||||
python -mvenv venv
|
||||
venv/bin/pip install -r requirements.txt 'mypy!=1.7.0'
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf venv
|
||||
40
README.md
40
README.md
|
|
@ -1,9 +1,15 @@
|
|||
# textual-totp: TOTP (authenticator) application using Python & Textual
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2023 Jeff Epler
|
||||
|
||||
SPDX-License-Identifier: MIT
|
||||
-->
|
||||
|
||||
# textual-totp: TOTP (authenticator) application using Python & Textual
|
||||
|
||||
# Installation
|
||||
|
||||
Right now, you have to `pip install` the requirements and then run the program with
|
||||
`python ttotp.py`
|
||||
Right now you have to pick this up from github, it's not yet on pypi:
|
||||
`pipx install https://github.com/jepler/textual-totp@main`
|
||||
|
||||
# Configuration
|
||||
|
||||
|
|
@ -25,22 +31,36 @@ otp-command = "echo 'otpauth://totp/example?algorithm=SHA1&digits=6&secret=IHACD
|
|||
|
||||
If the command is a string, it is interpreted with the shell; otherwise, the list of arguments is used directly.
|
||||
|
||||
# Using the app
|
||||
# Using textual-totp
|
||||
|
||||
Once the app has started, it will show each available TOTP. The code will show as "\*\*\*\*\*\*" until it is revealed.
|
||||
To reveal a code, tab to the desired line and press "s".
|
||||
When the code expires, it will be replaced with "\*\*\*\*\*\*" again.
|
||||
The command to start textual-totp is `ttotp`.
|
||||
It has several options which can be shown with `ttotp --help`.
|
||||
|
||||
You can also copy a code directly to the operating system's clipboard by pressing "c".
|
||||
`ttotp` will first invoke the `otp-command` to get the list of TOTPs.
|
||||
This may require interaction
|
||||
(for instance, the `pass` command may need to request your GPG key passphrase)
|
||||
|
||||
Once the otp-command finishes, `ttotp` will show each available TOTP.
|
||||
Each code will show as `******` until it is revealed.
|
||||
|
||||
Navigate up/down in several ways:
|
||||
* up and down keys
|
||||
* tab and shift-tab keys
|
||||
* "j" and "k" (vi keys)
|
||||
|
||||
To reveal a code, move to the desired line and press "s".
|
||||
When the code expires, it will be replaced with `******` again.
|
||||
|
||||
Copy a code directly to the operating system's clipboard by pressing "c".
|
||||
The code will be cleared from the clipboard after 30 seconds.
|
||||
Your Operating System may report that `ttotp` "pasted from the clipboard".
|
||||
This is because `ttotp` tries to only clear values that it set,
|
||||
by checking that the current clipboard value is equal to the value it pasted earlier.
|
||||
|
||||
You can exit the app with Ctrl+C.
|
||||
Exit the app with Ctrl+C.
|
||||
|
||||
# In-memory storage of TOTPs
|
||||
As long as `ttotp` is open, the TOTP secret values are stored in memory in plain text.
|
||||
|
||||
`ttotp` never writes secret values to operating system files or store them in environment variables.
|
||||
`ttotp` never writes secret values to operating system files or stores them in environment variables.
|
||||
(but your otp-command might! check any related documentation carefully)
|
||||
|
|
|
|||
53
pyproject.toml
Normal file
53
pyproject.toml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# SPDX-FileCopyrightText: 2021 Jeff Epler
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools>=68.2.2",
|
||||
"setuptools_scm[toml]>=6.0",
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[project]
|
||||
name="textual-totp"
|
||||
authors = [{name = "Jeff Epler", email = "jepler@gmail.com"}]
|
||||
description = "TOTP (authenticator) application using Python & Textual"
|
||||
dynamic = ["readme","version","dependencies"]
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["otp", "totp", "2fa", "authenticator"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
]
|
||||
[project.urls]
|
||||
homepage = "https://github.com/jepler/textual-totp"
|
||||
repository = "https://github.com/jepler/textual-totp"
|
||||
|
||||
[project.scripts]
|
||||
ttotp = "ttotp.__main__:main"
|
||||
|
||||
[tool.setuptools_scm]
|
||||
write_to = "src/ttotp/__version__.py"
|
||||
[tool.setuptools.dynamic]
|
||||
readme = {file = ["README.md"], content-type="text/markdown"}
|
||||
dependencies = {file = "requirements.txt"}
|
||||
[tool.setuptools.package-data]
|
||||
"pkgname" = ["py.typed"]
|
||||
[tool.mypy]
|
||||
mypy_path = ["src"]
|
||||
warn_unused_ignores = false
|
||||
warn_redundant_casts = true
|
||||
strict = true
|
||||
packages = ["ttotp"]
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# SPDX-FileCopyrightText: 2023 Jeff Epler
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
textual
|
||||
pyperclip
|
||||
pyotp
|
||||
click
|
||||
platformdirs
|
||||
0
src/ttotp/__init__.py
Normal file
0
src/ttotp/__init__.py
Normal file
376
src/ttotp/__main__.py
Executable file
376
src/ttotp/__main__.py
Executable file
|
|
@ -0,0 +1,376 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
# SPDX-FileCopyrightText: 2023 Jeff Epler
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import time
|
||||
import hashlib
|
||||
import pathlib
|
||||
import subprocess
|
||||
from urllib.parse import parse_qsl, unquote, urlparse
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Sequence, cast
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Label, Footer, ProgressBar, Button
|
||||
from textual.binding import Binding
|
||||
from textual.containers import VerticalScroll, Horizontal
|
||||
from textual.css.query import DOMQuery
|
||||
from textual.timer import Timer
|
||||
|
||||
import click
|
||||
import pyotp
|
||||
import platformdirs
|
||||
import tomllib
|
||||
|
||||
# workaround for pyperclip being un-typed
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def pyperclip_paste() -> str:
|
||||
...
|
||||
|
||||
def pyperclip_copy(data: str) -> None:
|
||||
...
|
||||
else:
|
||||
from pyperclip import paste as pyperclip_paste
|
||||
from pyperclip import copy as pyperclip_copy
|
||||
|
||||
from typing import TypeGuard # use `typing_extensions` for Python 3.9 and below
|
||||
|
||||
|
||||
def is_str_list(val: Any) -> TypeGuard[list[str]]:
|
||||
"""Determines whether all objects in the list are strings"""
|
||||
if not isinstance(val, list):
|
||||
return False
|
||||
return all(isinstance(x, str) for x in val)
|
||||
|
||||
|
||||
# Copied from pyotp with the issuer mismatch check removed and HTOP support removed
|
||||
def parse_uri(uri: str) -> pyotp.TOTP:
|
||||
"""
|
||||
Parses the provisioning URI for the TOTP
|
||||
|
||||
See also:
|
||||
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||
|
||||
:param uri: the hotp/totp URI to parse
|
||||
:returns: OTP object
|
||||
"""
|
||||
|
||||
# Secret (to be filled in later)
|
||||
secret = None
|
||||
|
||||
# Data we'll parse to the correct constructor
|
||||
otp_data: dict[str, Any] = {}
|
||||
|
||||
# Parse with URLlib
|
||||
parsed_uri = urlparse(unquote(uri))
|
||||
|
||||
if parsed_uri.scheme != "otpauth":
|
||||
raise ValueError("Not an otpauth URI")
|
||||
|
||||
# Parse issuer/accountname info
|
||||
accountinfo_parts = re.split(":|%3A", parsed_uri.path[1:], maxsplit=1)
|
||||
if len(accountinfo_parts) == 1:
|
||||
otp_data["name"] = accountinfo_parts[0]
|
||||
else:
|
||||
otp_data["issuer"] = accountinfo_parts[0]
|
||||
otp_data["name"] = accountinfo_parts[1]
|
||||
|
||||
# Parse values
|
||||
for key, value in parse_qsl(parsed_uri.query):
|
||||
if key == "secret":
|
||||
secret = value
|
||||
elif key == "issuer":
|
||||
otp_data["issuer"] = value
|
||||
elif key == "algorithm":
|
||||
if value == "SHA1":
|
||||
otp_data["digest"] = hashlib.sha1
|
||||
elif value == "SHA256":
|
||||
otp_data["digest"] = hashlib.sha256
|
||||
elif value == "SHA512":
|
||||
otp_data["digest"] = hashlib.sha512
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid value for algorithm, must be SHA1, SHA256 or SHA512"
|
||||
)
|
||||
elif key == "digits":
|
||||
digits = int(value)
|
||||
if digits not in [6, 7, 8]:
|
||||
raise ValueError("Digits may only be 6, 7, or 8")
|
||||
otp_data["digits"] = digits
|
||||
elif key == "period":
|
||||
otp_data["interval"] = int(value)
|
||||
elif key == "counter":
|
||||
otp_data["initial_count"] = int(value)
|
||||
elif key != "image":
|
||||
raise ValueError("{} is not a valid parameter".format(key))
|
||||
|
||||
if not secret:
|
||||
raise ValueError("No secret found in URI")
|
||||
|
||||
# Create objects
|
||||
if parsed_uri.netloc == "totp":
|
||||
return pyotp.TOTP(secret, **otp_data)
|
||||
|
||||
raise ValueError(f"Not a supported OTP type: {parsed_uri.netloc}")
|
||||
|
||||
|
||||
default_conffile = platformdirs.user_config_path("ttotp") / "settings.toml"
|
||||
|
||||
|
||||
class TOTPLabel(Label, can_focus=True):
|
||||
otp: "TOTPData"
|
||||
|
||||
BINDINGS = [
|
||||
Binding("c", "copy", "Copy code", show=True),
|
||||
Binding("s", "show", "Show code", show=True),
|
||||
Binding("up", "focus_previous", show=False),
|
||||
Binding("down", "focus_next", show=False),
|
||||
Binding("k", "focus_previous", show=False),
|
||||
Binding("j", "focus_next", show=False),
|
||||
]
|
||||
|
||||
def __init__(self, otp: "TOTPData") -> None:
|
||||
self.otp = otp
|
||||
super().__init__(
|
||||
f"{otp.totp.name} / {otp.totp.issuer}",
|
||||
classes=f"otp-name otp-name-{otp.id} otp-{otp.id}",
|
||||
expand=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def css_class(self) -> str:
|
||||
for c in self.classes:
|
||||
if re.match("otp-[0-9]", c):
|
||||
return c
|
||||
raise RuntimeError("Class not found")
|
||||
|
||||
@property
|
||||
def related(self, arg: str = "") -> DOMQuery[Widget]:
|
||||
return self.screen.query(f".{self.css_class}{arg}")
|
||||
|
||||
def related_remove_class(self, cls: str) -> None:
|
||||
for widget in self.related:
|
||||
widget.remove_class(cls)
|
||||
|
||||
def related_add_class(self, cls: str) -> None:
|
||||
for widget in self.related:
|
||||
widget.add_class(cls)
|
||||
|
||||
def on_blur(self) -> None:
|
||||
self.related_remove_class("otp-focused")
|
||||
self.otp.value_widget.update("*" * self.otp.totp.digits)
|
||||
self.shown = False
|
||||
|
||||
|
||||
class TOTPButton(Button, can_focus=False):
|
||||
def __init__(self, otp: "TOTPData", label: str, classes: str):
|
||||
self.otp = otp
|
||||
super().__init__(label=label, classes=classes)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TOTPData:
|
||||
totp: pyotp.TOTP
|
||||
generation = None
|
||||
name_widget: Label = field(init=False)
|
||||
value_widget: Label = field(init=False)
|
||||
progress_widget: ProgressBar = field(init=False)
|
||||
copy_widget: TOTPButton = field(init=False)
|
||||
show_widget: TOTPButton = field(init=False)
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
return id(self)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.name_widget = TOTPLabel(self)
|
||||
self.copy_widget = TOTPButton(
|
||||
self, "🗐 ", classes=f"otp-copy otp-copy-{self.id} otp-{self.id}"
|
||||
)
|
||||
self.show_widget = TOTPButton(
|
||||
self, "👀", classes=f"otp-show otp-show-{self.id} otp-{self.id}"
|
||||
)
|
||||
self.value_widget = Label(
|
||||
"*" * self.totp.digits,
|
||||
classes=f"otp-value otp-value-{self.id} otp-{self.id}",
|
||||
expand=True,
|
||||
)
|
||||
self.progress_widget = ProgressBar(
|
||||
classes=f"otp-progress otp-progress-{self.id} otp-{self.id}",
|
||||
show_percentage=False,
|
||||
show_eta=False,
|
||||
)
|
||||
self.progress_widget.total = self.totp.interval
|
||||
|
||||
def tick(self, now: float) -> None:
|
||||
generation, progress = divmod(now, self.totp.interval)
|
||||
if generation != self.generation:
|
||||
self.generation = generation
|
||||
self.value_widget.update("*" * self.totp.digits)
|
||||
self.progress_widget.progress = self.totp.interval - progress
|
||||
|
||||
@property
|
||||
def widgets(self) -> Sequence[Widget]:
|
||||
return (
|
||||
self.value_widget,
|
||||
self.name_widget,
|
||||
self.progress_widget,
|
||||
self.show_widget,
|
||||
self.copy_widget,
|
||||
)
|
||||
|
||||
|
||||
class TTOTP(App[None]):
|
||||
CSS = """
|
||||
VerticalScroll { min-height: 1; }
|
||||
.otp-progress { width: 12; }
|
||||
.otp-value { width: 9; }
|
||||
TOTPLabel { width: 1fr; }
|
||||
Horizontal:focus-within { background: $primary-background; }
|
||||
Bar > .bar--bar { color: $success; }
|
||||
Bar { width: 1fr; }
|
||||
Button { border: none; height: 1; width: 3; min-width: 4 }
|
||||
Horizontal { height: 1; }
|
||||
"""
|
||||
|
||||
def __init__(self, tokens: Sequence[pyotp.TOTP]) -> None:
|
||||
super().__init__()
|
||||
self.tokens = tokens
|
||||
self.otp_data: list[TOTPData] = []
|
||||
self.timer: Timer | None = None
|
||||
self.clear_clipboard_time: Timer | None = None
|
||||
self.copied = ""
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.timer_func()
|
||||
self.timer = self.set_interval(1, self.timer_func)
|
||||
self.clear_clipboard_timer = self.set_timer(
|
||||
30, self.clear_clipboard_func, pause=True
|
||||
)
|
||||
|
||||
def clear_clipboard_func(self) -> None:
|
||||
if pyperclip_paste() == self.copied:
|
||||
self.notify("Clipboard cleared", title="")
|
||||
pyperclip_copy("")
|
||||
|
||||
def timer_func(self) -> None:
|
||||
now = time.time()
|
||||
for otp in self.otp_data:
|
||||
otp.tick(now)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Footer()
|
||||
with VerticalScroll() as v:
|
||||
v.can_focus = False
|
||||
for otp in self.tokens:
|
||||
data = TOTPData(otp)
|
||||
self.otp_data.append(data)
|
||||
with Horizontal():
|
||||
yield from data.widgets
|
||||
|
||||
def action_show(self) -> None:
|
||||
widget = self.focused
|
||||
if widget is not None:
|
||||
otp = cast(TOTPLabel, widget).otp
|
||||
otp.value_widget.update(otp.totp.now())
|
||||
|
||||
def action_copy(self) -> None:
|
||||
widget = self.focused
|
||||
if widget is not None:
|
||||
otp = cast(TOTPLabel, widget).otp
|
||||
code = otp.totp.now()
|
||||
pyperclip_copy(code)
|
||||
self.copied = code
|
||||
self.clear_clipboard_timer.reset()
|
||||
self.clear_clipboard_timer.resume()
|
||||
|
||||
self.notify("Code copied", title="")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
button = cast(TOTPButton, event.button)
|
||||
self.screen.set_focus(button.otp.name_widget)
|
||||
if "otp-show" in button.classes:
|
||||
self.action_show()
|
||||
else:
|
||||
self.action_copy()
|
||||
|
||||
|
||||
@click.command
|
||||
@click.option(
|
||||
"--config",
|
||||
type=pathlib.Path,
|
||||
default=default_conffile,
|
||||
help="Configuration file to use",
|
||||
)
|
||||
@click.option(
|
||||
"--profile",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Profile to use within the configuration file",
|
||||
)
|
||||
def main(config: pathlib.Path, profile: str) -> None:
|
||||
def config_hint(extra: str) -> None:
|
||||
config.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(
|
||||
f"""\
|
||||
You need to create the configuration file:
|
||||
{config}
|
||||
|
||||
It's a toml file which specifies a command to run to retrieve the list of OTPs.
|
||||
One way to do this is with the `pass` program (https://www.passwordstore.org/)
|
||||
`pass` keeps your secrets safe using GPG. Typical contents:
|
||||
|
||||
otp-command = ['pass', 'totp-tokens']
|
||||
|
||||
By default, the otp-command in the global section is used. You can have
|
||||
multiple profiles as configuration file sections, and select one with
|
||||
`ttotp --profile profile-name`:
|
||||
|
||||
[work]
|
||||
otp-command = ['pass', 'totp-tokens-work']
|
||||
|
||||
{extra}"""
|
||||
)
|
||||
raise SystemExit(2)
|
||||
|
||||
if not config.exists():
|
||||
config_hint(f"The configuration file {config} does not exist.")
|
||||
|
||||
with open(config, "rb") as f:
|
||||
config_data = tomllib.load(f)
|
||||
|
||||
if profile:
|
||||
config_data = config_data.get(profile, None)
|
||||
if config_data is None:
|
||||
config_hint(f"The profile {profile!r} file does not exist.")
|
||||
|
||||
otp_command = config_data.get("otp-command")
|
||||
if otp_command is None:
|
||||
config_hint("The otp-command value is missing.")
|
||||
|
||||
if isinstance(otp_command, str) or is_str_list(otp_command):
|
||||
c = subprocess.check_output(
|
||||
otp_command, shell=isinstance(otp_command, str), text=True
|
||||
)
|
||||
else:
|
||||
config_hint("The otp-command value must be a string or list of strings.")
|
||||
|
||||
tokens: list[pyotp.TOTP] = []
|
||||
for row in c.strip().split("\n"):
|
||||
if row.startswith("otpauth://"):
|
||||
tokens.append(parse_uri(row))
|
||||
|
||||
if not tokens:
|
||||
config_hint("No tokens were found when running the given command.")
|
||||
|
||||
TTOTP(tokens).run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
260
ttotp.py
260
ttotp.py
|
|
@ -1,260 +0,0 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import time
|
||||
import hashlib
|
||||
import pathlib
|
||||
import subprocess
|
||||
from urllib.parse import parse_qsl, unquote, urlparse
|
||||
import re
|
||||
|
||||
from textual.app import App
|
||||
from textual.widgets import Label, Footer, ProgressBar
|
||||
from textual.binding import Binding
|
||||
from textual.containers import VerticalScroll
|
||||
|
||||
import pyperclip
|
||||
import click
|
||||
import pyotp
|
||||
import platformdirs
|
||||
import tomllib
|
||||
|
||||
# Copied from pyotp with the issuer mismatch check removed
|
||||
def parse_uri(uri: str) -> pyotp.OTP:
|
||||
"""
|
||||
Parses the provisioning URI for the OTP; works for either TOTP or HOTP.
|
||||
|
||||
See also:
|
||||
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
|
||||
|
||||
:param uri: the hotp/totp URI to parse
|
||||
:returns: OTP object
|
||||
"""
|
||||
|
||||
# Secret (to be filled in later)
|
||||
secret = None
|
||||
|
||||
# Data we'll parse to the correct constructor
|
||||
otp_data = {} # type: Dict[str, Any]
|
||||
|
||||
# Parse with URLlib
|
||||
parsed_uri = urlparse(unquote(uri))
|
||||
|
||||
if parsed_uri.scheme != "otpauth":
|
||||
raise ValueError("Not an otpauth URI")
|
||||
|
||||
# Parse issuer/accountname info
|
||||
accountinfo_parts = re.split(":|%3A", parsed_uri.path[1:], maxsplit=1)
|
||||
if len(accountinfo_parts) == 1:
|
||||
otp_data["name"] = accountinfo_parts[0]
|
||||
else:
|
||||
otp_data["issuer"] = accountinfo_parts[0]
|
||||
otp_data["name"] = accountinfo_parts[1]
|
||||
|
||||
# Parse values
|
||||
for key, value in parse_qsl(parsed_uri.query):
|
||||
if key == "secret":
|
||||
secret = value
|
||||
elif key == "issuer":
|
||||
otp_data["issuer"] = value
|
||||
elif key == "algorithm":
|
||||
if value == "SHA1":
|
||||
otp_data["digest"] = hashlib.sha1
|
||||
elif value == "SHA256":
|
||||
otp_data["digest"] = hashlib.sha256
|
||||
elif value == "SHA512":
|
||||
otp_data["digest"] = hashlib.sha512
|
||||
else:
|
||||
raise ValueError("Invalid value for algorithm, must be SHA1, SHA256 or SHA512")
|
||||
elif key == "digits":
|
||||
digits = int(value)
|
||||
if digits not in [6, 7, 8]:
|
||||
raise ValueError("Digits may only be 6, 7, or 8")
|
||||
otp_data["digits"] = digits
|
||||
elif key == "period":
|
||||
otp_data["interval"] = int(value)
|
||||
elif key == "counter":
|
||||
otp_data["initial_count"] = int(value)
|
||||
elif key != "image":
|
||||
raise ValueError("{} is not a valid parameter".format(key))
|
||||
|
||||
if not secret:
|
||||
raise ValueError("No secret found in URI")
|
||||
|
||||
# Create objects
|
||||
if parsed_uri.netloc == "totp":
|
||||
return pyotp.TOTP(secret, **otp_data)
|
||||
elif parsed_uri.netloc == "hotp":
|
||||
return pyotp.HOTP(secret, **otp_data)
|
||||
|
||||
raise ValueError("Not a supported OTP type")
|
||||
|
||||
default_conffile = platformdirs.user_config_path("ttotp") / "settings.toml"
|
||||
|
||||
class TOTPLabel(Label, can_focus=True):
|
||||
BINDINGS = [
|
||||
Binding("c", "copy", "Copy code", show=True),
|
||||
Binding("s", "show", "Show code", show=True),
|
||||
]
|
||||
|
||||
@property
|
||||
def idx(self):
|
||||
return int(self.css_class.split('-')[1])
|
||||
|
||||
@property
|
||||
def css_class(self):
|
||||
for c in self.classes:
|
||||
if re.match('otp-[0-9]', c):
|
||||
return c
|
||||
return None
|
||||
|
||||
@property
|
||||
def related(self, arg=''):
|
||||
return self.screen.query(f'.{self.css_class}{arg}')
|
||||
|
||||
def related_remove_class(self, cls):
|
||||
for widget in self.related:
|
||||
widget.remove_class(cls)
|
||||
|
||||
def related_add_class(self, cls):
|
||||
for widget in self.related:
|
||||
widget.add_class(cls)
|
||||
|
||||
def on_blur(self):
|
||||
self.related_remove_class("otp-focused")
|
||||
self.shown = False
|
||||
|
||||
def on_focus(self):
|
||||
self.related_add_class("otp-focused")
|
||||
|
||||
@dataclass
|
||||
class TOTPData:
|
||||
totp: pyotp.TOTP
|
||||
name_widget: Label
|
||||
value_widget: Label
|
||||
progress_widget: ProgressBar
|
||||
generation = None
|
||||
|
||||
def tick(self, now):
|
||||
now = time.time()
|
||||
generation, progress = divmod(now, self.totp.interval)
|
||||
if generation != self.generation:
|
||||
self.generation = generation
|
||||
self.value_widget.update("*" * self.totp.digits)
|
||||
self.progress_widget.progress = self.totp.interval - progress
|
||||
|
||||
class TTOTP(App[None]):
|
||||
|
||||
CSS = '''
|
||||
VerticalScroll {
|
||||
layout: grid;
|
||||
grid-size: 2;
|
||||
grid-columns: 8 1fr;
|
||||
grid-rows: 1;
|
||||
}
|
||||
.otp-focused { background: $primary-background; }
|
||||
ProgressBar { column-span: 2; }
|
||||
Bar > .bar--bar { color: $success; }
|
||||
Bar { width: 1fr; }
|
||||
'''
|
||||
|
||||
def __init__(self, tokens):
|
||||
super().__init__()
|
||||
self.tokens = tokens
|
||||
self.otp_data = {}
|
||||
self.timer = None
|
||||
self.clear_clipboard_timer = None
|
||||
self.copied = None
|
||||
|
||||
def on_mount(self):
|
||||
self.timer_func()
|
||||
self.timer = self.set_interval(.1, self.timer_func)
|
||||
self.clear_clipboard_timer = self.set_timer(30, self.clear_clipboard_func, pause=True)
|
||||
|
||||
def clear_clipboard_func(self):
|
||||
if pyperclip.paste() == self.copied:
|
||||
self.notify("Clipboard cleared", title="")
|
||||
pyperclip.copy('')
|
||||
|
||||
def timer_func(self):
|
||||
now = time.time()
|
||||
for otp in self.otp_data.values():
|
||||
otp.tick(now)
|
||||
|
||||
def compose(self):
|
||||
yield Footer()
|
||||
with VerticalScroll() as v:
|
||||
v.can_focus = False
|
||||
for i, otp in enumerate(self.tokens):
|
||||
otp_name = TOTPLabel(f"{otp.name} / {otp.issuer}", classes=f"otp-name otp-name-{i} otp-{i}", expand=True)
|
||||
otp_value = Label("", classes=f"otp-value otp-value-{i} otp-{i}", expand=True)
|
||||
otp_progress = ProgressBar(classes=f"otp-progress otp-progress-{i} otp-{i}", show_percentage=False, show_eta=False)
|
||||
|
||||
otp_progress.total = otp.interval
|
||||
otpdata = TOTPData(otp, otp_name, otp_value, otp_progress)
|
||||
self.otp_data[otp] = self.otp_data[i] = otpdata
|
||||
|
||||
yield otp_value
|
||||
yield otp_name
|
||||
yield otp_progress
|
||||
|
||||
def action_show(self):
|
||||
widget = self.focused
|
||||
otp = self.otp_data[widget.idx]
|
||||
otp.value_widget.update(otp.totp.now())
|
||||
|
||||
def action_copy(self):
|
||||
widget = self.focused
|
||||
otp = self.otp_data[widget.idx]
|
||||
code = otp.totp.now()
|
||||
pyperclip.copy(code)
|
||||
self.copied = code
|
||||
self.clear_clipboard_timer.reset()
|
||||
self.clear_clipboard_timer.resume()
|
||||
|
||||
self.notify("Code copied", title="")
|
||||
|
||||
@click.command
|
||||
@click.option("--config", type=pathlib.Path, default=default_conffile, help="Configuration file to use")
|
||||
def main(config):
|
||||
|
||||
def config_hint():
|
||||
config.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"""\
|
||||
You need to create the configuration file: {config}
|
||||
|
||||
It's a toml file which specifies a command to run to retrieve the list of OTPs.
|
||||
One way to do this is with the `pass` program from https://www.passwordstore.org/
|
||||
(it keeps your secrets safe using GPG):
|
||||
|
||||
otp-command = ['pass', 'totp-tokens']
|
||||
""")
|
||||
raise SystemExit(2)
|
||||
|
||||
if not config.exists():
|
||||
config_hint()
|
||||
|
||||
with open(config, "rb") as f:
|
||||
config_data = tomllib.load(f)
|
||||
|
||||
print(config_data)
|
||||
|
||||
otp_command = config_data.get('otp-command')
|
||||
if otp_command is None:
|
||||
config_hint()
|
||||
|
||||
c = subprocess.check_output(otp_command, shell=isinstance(otp_command, str), text=True)
|
||||
print(f"{c=!r}")
|
||||
|
||||
global tokens
|
||||
tokens = []
|
||||
for row in c.strip().split('\n'):
|
||||
if row.startswith("otpauth://"):
|
||||
print(f"parsing {row=!r}")
|
||||
tokens.append(parse_uri(row))
|
||||
|
||||
TTOTP(tokens).run()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loading…
Reference in a new issue