timo-otp 1.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- timo_otp-1.0.1/LICENSE +21 -0
- timo_otp-1.0.1/PKG-INFO +149 -0
- timo_otp-1.0.1/README.md +114 -0
- timo_otp-1.0.1/pyproject.toml +57 -0
- timo_otp-1.0.1/setup.cfg +4 -0
- timo_otp-1.0.1/src/timo/__init__.py +3 -0
- timo_otp-1.0.1/src/timo/__main__.py +5 -0
- timo_otp-1.0.1/src/timo/cli.py +245 -0
- timo_otp-1.0.1/src/timo/clipboard.py +55 -0
- timo_otp-1.0.1/src/timo/commands.py +787 -0
- timo_otp-1.0.1/src/timo/fileio.py +66 -0
- timo_otp-1.0.1/src/timo/keystore.py +69 -0
- timo_otp-1.0.1/src/timo/messages.py +165 -0
- timo_otp-1.0.1/src/timo/otpauth.py +87 -0
- timo_otp-1.0.1/src/timo/preview.py +46 -0
- timo_otp-1.0.1/src/timo/protobuf/__init__.py +0 -0
- timo_otp-1.0.1/src/timo/protobuf/migration.proto +39 -0
- timo_otp-1.0.1/src/timo/protobuf/migration.py +64 -0
- timo_otp-1.0.1/src/timo/protobuf/migration_pb2.py +33 -0
- timo_otp-1.0.1/src/timo/qr.py +20 -0
- timo_otp-1.0.1/src/timo/ui.py +260 -0
- timo_otp-1.0.1/src/timo/utils.py +274 -0
- timo_otp-1.0.1/src/timo/vault.py +94 -0
- timo_otp-1.0.1/src/timo/webcam.py +384 -0
- timo_otp-1.0.1/src/timo_otp.egg-info/PKG-INFO +149 -0
- timo_otp-1.0.1/src/timo_otp.egg-info/SOURCES.txt +45 -0
- timo_otp-1.0.1/src/timo_otp.egg-info/dependency_links.txt +1 -0
- timo_otp-1.0.1/src/timo_otp.egg-info/entry_points.txt +2 -0
- timo_otp-1.0.1/src/timo_otp.egg-info/requires.txt +13 -0
- timo_otp-1.0.1/src/timo_otp.egg-info/top_level.txt +1 -0
- timo_otp-1.0.1/tests/test_add.py +206 -0
- timo_otp-1.0.1/tests/test_atomic.py +123 -0
- timo_otp-1.0.1/tests/test_backups.py +150 -0
- timo_otp-1.0.1/tests/test_clipboard.py +152 -0
- timo_otp-1.0.1/tests/test_commands.py +247 -0
- timo_otp-1.0.1/tests/test_confirm.py +99 -0
- timo_otp-1.0.1/tests/test_duplicates.py +134 -0
- timo_otp-1.0.1/tests/test_export.py +185 -0
- timo_otp-1.0.1/tests/test_help.py +84 -0
- timo_otp-1.0.1/tests/test_hidden_input.py +85 -0
- timo_otp-1.0.1/tests/test_import.py +190 -0
- timo_otp-1.0.1/tests/test_keychain.py +183 -0
- timo_otp-1.0.1/tests/test_otp.py +49 -0
- timo_otp-1.0.1/tests/test_remove.py +159 -0
- timo_otp-1.0.1/tests/test_ui.py +178 -0
- timo_otp-1.0.1/tests/test_vault.py +67 -0
- timo_otp-1.0.1/tests/test_webcam.py +241 -0
timo_otp-1.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Leonardo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
timo_otp-1.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: timo-otp
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Your authenticator app, in the terminal, one word away: an encrypted TOTP manager with fzf, QR import and export
|
|
5
|
+
Author: Leonardo Martignoni
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Source, https://github.com/leomarss/timo
|
|
8
|
+
Project-URL: Issues, https://github.com/leomarss/timo/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/leomarss/timo/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: 2fa,totp,otp,authenticator,cli,terminal,fzf,google-authenticator
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: cryptography>=42
|
|
24
|
+
Requires-Dist: keyring>=25
|
|
25
|
+
Requires-Dist: protobuf<7,>=4.21
|
|
26
|
+
Requires-Dist: pyotp>=2.9
|
|
27
|
+
Requires-Dist: pyperclip>=1.9
|
|
28
|
+
Requires-Dist: segno>=1.5
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
31
|
+
Provides-Extra: webcam
|
|
32
|
+
Requires-Dist: av>=14; extra == "webcam"
|
|
33
|
+
Requires-Dist: zxing-cpp>=2.3; extra == "webcam"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
<h1 align="center">
|
|
37
|
+
<picture>
|
|
38
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/leomarss/timo/main/assets/banner-light.png">
|
|
39
|
+
<img src="https://raw.githubusercontent.com/leomarss/timo/main/assets/banner.png" width="100%" alt="timo: encrypted at rest, no phone needed, no network. Fast, encrypted, import from Google Authenticator or any QR, export to any other app">
|
|
40
|
+
</picture>
|
|
41
|
+
</h1>
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<b>Your authenticator app, in the terminal, one word away.</b><br>
|
|
45
|
+
<sub><i>Time-based Identity Manager for OTPs</i></sub>
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
<p align="center">
|
|
49
|
+
<img alt="Python 3.9+" src="https://img.shields.io/badge/python-3.9%2B-4f8cff?style=flat-square&logo=python&logoColor=white&labelColor=0f1626">
|
|
50
|
+
<img alt="macOS · Linux" src="https://img.shields.io/badge/macOS%20%C2%B7%20Linux-supported-4f8cff?style=flat-square&labelColor=0f1626">
|
|
51
|
+
<a href="https://github.com/leomarss/timo/releases"><img alt="Release" src="https://img.shields.io/github/v/release/leomarss/timo?style=flat-square&color=4f8cff&labelColor=0f1626&label=release"></a>
|
|
52
|
+
<a href="https://github.com/leomarss/timo/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-4f8cff?style=flat-square&labelColor=0f1626"></a>
|
|
53
|
+
</p>
|
|
54
|
+
|
|
55
|
+
<p align="center">
|
|
56
|
+
<a href="#demo">Demo</a> ·
|
|
57
|
+
<a href="#install">Install</a> ·
|
|
58
|
+
<a href="#usage">Usage</a> ·
|
|
59
|
+
<a href="#security">Security</a> ·
|
|
60
|
+
<a href="#development">Development</a>
|
|
61
|
+
</p>
|
|
62
|
+
|
|
63
|
+
<br>
|
|
64
|
+
|
|
65
|
+
## Demo
|
|
66
|
+
<p align="center">
|
|
67
|
+
<picture>
|
|
68
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/leomarss/timo/main/assets/demo-light.gif">
|
|
69
|
+
<img src="https://raw.githubusercontent.com/leomarss/timo/main/assets/demo.gif" width="100%" alt="timo google copies a code, then timo opens the fzf picker to find and copy Cloudflare">
|
|
70
|
+
</picture>
|
|
71
|
+
</p>
|
|
72
|
+
|
|
73
|
+
<br>
|
|
74
|
+
|
|
75
|
+
Type a word, get the code on your clipboard. No phone, no app switching. Pick from a fuzzy list with `fzf` when you're not sure, import everything from Google Authenticator in one go, and take it all with you when you leave.
|
|
76
|
+
|
|
77
|
+
- **One word, one code**: copied to your clipboard, cleared after 45s
|
|
78
|
+
- **Encrypted at rest**: scrypt + AES-256-GCM, passphrase in your OS keychain
|
|
79
|
+
- **Bring your accounts**: Google Authenticator export or any 2FA setup QR, via image, webcam or link
|
|
80
|
+
- **Take them with you**: export as setup QR codes or `otpauth://` links to any other app
|
|
81
|
+
|
|
82
|
+
<br>
|
|
83
|
+
|
|
84
|
+
## Install
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
brew install pipx fzf # Debian/Ubuntu: sudo apt install pipx fzf
|
|
88
|
+
pipx install timo-otp
|
|
89
|
+
|
|
90
|
+
# Optional: scan QR codes from images and with the webcam
|
|
91
|
+
brew install zbar # Debian/Ubuntu: sudo apt install zbar-tools
|
|
92
|
+
pipx inject timo-otp av zxing-cpp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Update with `pipx upgrade timo-otp`.
|
|
96
|
+
|
|
97
|
+
<br>
|
|
98
|
+
|
|
99
|
+
## Usage
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
timo --initialize # import a Google Authenticator export (first run sets your passphrase)
|
|
103
|
+
timo --add # add one account from a setup QR, otpauth:// link or secret key
|
|
104
|
+
timo github # copy the code of the account matching "github"
|
|
105
|
+
timo # pick an account with fzf
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| Command | What it does |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `timo WORDS` | Copy the code of the matching account (`--no-copy` to just print it) |
|
|
111
|
+
| `--list` · `--stats` | List accounts, or count them per issuer |
|
|
112
|
+
| `--watch` | Show every code, live |
|
|
113
|
+
| `--add` · `--remove` · `--edit` | Manage single accounts |
|
|
114
|
+
| `--export [WORDS]` | Move accounts to another app (`--format uri` for links) |
|
|
115
|
+
| `--backup` · `--restore NAME` | Encrypted backups, made automatically before every change |
|
|
116
|
+
| `--passwd` · `--forget` | Change the passphrase, or drop it from the keychain |
|
|
117
|
+
|
|
118
|
+
Run `timo --help` for the full list. Plain words are always a search, so an account can be called `edit` or `list`.
|
|
119
|
+
|
|
120
|
+
> [!WARNING]
|
|
121
|
+
> Your passphrase encrypts everything. **If you forget it, the secrets cannot be recovered**, so keep your original export somewhere safe.
|
|
122
|
+
|
|
123
|
+
<br>
|
|
124
|
+
|
|
125
|
+
## Security
|
|
126
|
+
|
|
127
|
+
- 🗝️ Secrets live in `~/.config/timo/secrets.enc` (override with `TIMO_HOME`), with `700`/`600` permissions and crash-safe writes.
|
|
128
|
+
- The `fzf` preview never sees your secrets or passphrase, only the next 10 minutes of codes.
|
|
129
|
+
- `--edit` temp files are deleted, webcam frames stay in memory, and export QR codes are cleared from scrollback.
|
|
130
|
+
- Exported `otpauth://` links are **not** encrypted: delete them once imported.
|
|
131
|
+
- No `sudo`, no network.
|
|
132
|
+
|
|
133
|
+
<br>
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
python3 -m venv env
|
|
139
|
+
env/bin/pip install -e '.[dev]'
|
|
140
|
+
env/bin/python -m pytest
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
See [CONTRIBUTING.md](https://github.com/leomarss/timo/blob/main/CONTRIBUTING.md) before opening a pull request, and [SECURITY.md](https://github.com/leomarss/timo/blob/main/SECURITY.md) to report a vulnerability.
|
|
144
|
+
|
|
145
|
+
<br>
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT. Built on [fzf](https://github.com/junegunn/fzf), [zbar](https://github.com/mchehab/zbar), [PyAV](https://github.com/PyAV-Org/PyAV) and [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp).
|
timo_otp-1.0.1/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/leomarss/timo/main/assets/banner-light.png">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/leomarss/timo/main/assets/banner.png" width="100%" alt="timo: encrypted at rest, no phone needed, no network. Fast, encrypted, import from Google Authenticator or any QR, export to any other app">
|
|
5
|
+
</picture>
|
|
6
|
+
</h1>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<b>Your authenticator app, in the terminal, one word away.</b><br>
|
|
10
|
+
<sub><i>Time-based Identity Manager for OTPs</i></sub>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<img alt="Python 3.9+" src="https://img.shields.io/badge/python-3.9%2B-4f8cff?style=flat-square&logo=python&logoColor=white&labelColor=0f1626">
|
|
15
|
+
<img alt="macOS · Linux" src="https://img.shields.io/badge/macOS%20%C2%B7%20Linux-supported-4f8cff?style=flat-square&labelColor=0f1626">
|
|
16
|
+
<a href="https://github.com/leomarss/timo/releases"><img alt="Release" src="https://img.shields.io/github/v/release/leomarss/timo?style=flat-square&color=4f8cff&labelColor=0f1626&label=release"></a>
|
|
17
|
+
<a href="https://github.com/leomarss/timo/blob/main/LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-4f8cff?style=flat-square&labelColor=0f1626"></a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p align="center">
|
|
21
|
+
<a href="#demo">Demo</a> ·
|
|
22
|
+
<a href="#install">Install</a> ·
|
|
23
|
+
<a href="#usage">Usage</a> ·
|
|
24
|
+
<a href="#security">Security</a> ·
|
|
25
|
+
<a href="#development">Development</a>
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<br>
|
|
29
|
+
|
|
30
|
+
## Demo
|
|
31
|
+
<p align="center">
|
|
32
|
+
<picture>
|
|
33
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/leomarss/timo/main/assets/demo-light.gif">
|
|
34
|
+
<img src="https://raw.githubusercontent.com/leomarss/timo/main/assets/demo.gif" width="100%" alt="timo google copies a code, then timo opens the fzf picker to find and copy Cloudflare">
|
|
35
|
+
</picture>
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<br>
|
|
39
|
+
|
|
40
|
+
Type a word, get the code on your clipboard. No phone, no app switching. Pick from a fuzzy list with `fzf` when you're not sure, import everything from Google Authenticator in one go, and take it all with you when you leave.
|
|
41
|
+
|
|
42
|
+
- **One word, one code**: copied to your clipboard, cleared after 45s
|
|
43
|
+
- **Encrypted at rest**: scrypt + AES-256-GCM, passphrase in your OS keychain
|
|
44
|
+
- **Bring your accounts**: Google Authenticator export or any 2FA setup QR, via image, webcam or link
|
|
45
|
+
- **Take them with you**: export as setup QR codes or `otpauth://` links to any other app
|
|
46
|
+
|
|
47
|
+
<br>
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
brew install pipx fzf # Debian/Ubuntu: sudo apt install pipx fzf
|
|
53
|
+
pipx install timo-otp
|
|
54
|
+
|
|
55
|
+
# Optional: scan QR codes from images and with the webcam
|
|
56
|
+
brew install zbar # Debian/Ubuntu: sudo apt install zbar-tools
|
|
57
|
+
pipx inject timo-otp av zxing-cpp
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Update with `pipx upgrade timo-otp`.
|
|
61
|
+
|
|
62
|
+
<br>
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
timo --initialize # import a Google Authenticator export (first run sets your passphrase)
|
|
68
|
+
timo --add # add one account from a setup QR, otpauth:// link or secret key
|
|
69
|
+
timo github # copy the code of the account matching "github"
|
|
70
|
+
timo # pick an account with fzf
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Command | What it does |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `timo WORDS` | Copy the code of the matching account (`--no-copy` to just print it) |
|
|
76
|
+
| `--list` · `--stats` | List accounts, or count them per issuer |
|
|
77
|
+
| `--watch` | Show every code, live |
|
|
78
|
+
| `--add` · `--remove` · `--edit` | Manage single accounts |
|
|
79
|
+
| `--export [WORDS]` | Move accounts to another app (`--format uri` for links) |
|
|
80
|
+
| `--backup` · `--restore NAME` | Encrypted backups, made automatically before every change |
|
|
81
|
+
| `--passwd` · `--forget` | Change the passphrase, or drop it from the keychain |
|
|
82
|
+
|
|
83
|
+
Run `timo --help` for the full list. Plain words are always a search, so an account can be called `edit` or `list`.
|
|
84
|
+
|
|
85
|
+
> [!WARNING]
|
|
86
|
+
> Your passphrase encrypts everything. **If you forget it, the secrets cannot be recovered**, so keep your original export somewhere safe.
|
|
87
|
+
|
|
88
|
+
<br>
|
|
89
|
+
|
|
90
|
+
## Security
|
|
91
|
+
|
|
92
|
+
- 🗝️ Secrets live in `~/.config/timo/secrets.enc` (override with `TIMO_HOME`), with `700`/`600` permissions and crash-safe writes.
|
|
93
|
+
- The `fzf` preview never sees your secrets or passphrase, only the next 10 minutes of codes.
|
|
94
|
+
- `--edit` temp files are deleted, webcam frames stay in memory, and export QR codes are cleared from scrollback.
|
|
95
|
+
- Exported `otpauth://` links are **not** encrypted: delete them once imported.
|
|
96
|
+
- No `sudo`, no network.
|
|
97
|
+
|
|
98
|
+
<br>
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python3 -m venv env
|
|
104
|
+
env/bin/pip install -e '.[dev]'
|
|
105
|
+
env/bin/python -m pytest
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
See [CONTRIBUTING.md](https://github.com/leomarss/timo/blob/main/CONTRIBUTING.md) before opening a pull request, and [SECURITY.md](https://github.com/leomarss/timo/blob/main/SECURITY.md) to report a vulnerability.
|
|
109
|
+
|
|
110
|
+
<br>
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT. Built on [fzf](https://github.com/junegunn/fzf), [zbar](https://github.com/mchehab/zbar), [PyAV](https://github.com/PyAV-Org/PyAV) and [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "timo-otp"
|
|
7
|
+
description = "Your authenticator app, in the terminal, one word away: an encrypted TOTP manager with fzf, QR import and export"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Leonardo Martignoni" }]
|
|
13
|
+
keywords = ["2fa", "totp", "otp", "authenticator", "cli", "terminal", "fzf", "google-authenticator"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Security",
|
|
22
|
+
"Topic :: Security :: Cryptography",
|
|
23
|
+
"Topic :: Utilities",
|
|
24
|
+
]
|
|
25
|
+
dynamic = ["version"]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"cryptography>=42",
|
|
28
|
+
"keyring>=25",
|
|
29
|
+
"protobuf>=4.21,<7",
|
|
30
|
+
"pyotp>=2.9",
|
|
31
|
+
"pyperclip>=1.9",
|
|
32
|
+
"segno>=1.5",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest>=8"]
|
|
37
|
+
webcam = ["av>=14", "zxing-cpp>=2.3"]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
timo = "timo.cli:main"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Source = "https://github.com/leomarss/timo"
|
|
44
|
+
Issues = "https://github.com/leomarss/timo/issues"
|
|
45
|
+
Changelog = "https://github.com/leomarss/timo/blob/main/CHANGELOG.md"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.dynamic]
|
|
48
|
+
version = {attr = "timo.__version__"}
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
where = ["src"]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-data]
|
|
54
|
+
"timo.protobuf" = ["migration.proto"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
testpaths = ["tests"]
|
timo_otp-1.0.1/setup.cfg
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Command-line entry point.
|
|
2
|
+
|
|
3
|
+
Every option starts with '-' or '--'; plain words are always a search, so an
|
|
4
|
+
account can be called "edit" or "list" without clashing with a command.
|
|
5
|
+
"""
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from timo import __version__, commands, ui
|
|
11
|
+
from timo import messages as msg
|
|
12
|
+
from timo.commands import Abort, Context
|
|
13
|
+
|
|
14
|
+
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
15
|
+
|
|
16
|
+
INTRO = "timo · your authenticator app, in the terminal, one word away"
|
|
17
|
+
FOOTER = (
|
|
18
|
+
"Secrets live in ~/.config/timo/ (set TIMO_HOME to use another folder).",
|
|
19
|
+
"Set TIMO_CLEAR_AFTER to change the default of --clear-after.",
|
|
20
|
+
'To search for a word that starts with "-": timo -- -word',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
EXPORT_FORMATS = ("setup-qr", "uri")
|
|
24
|
+
|
|
25
|
+
# action name -> (flag, handler)
|
|
26
|
+
ACTIONS = {
|
|
27
|
+
"list": ("--list", commands.cmd_list),
|
|
28
|
+
"stats": ("--stats", commands.cmd_stats),
|
|
29
|
+
"watch": ("--watch", commands.cmd_watch),
|
|
30
|
+
"edit": ("--edit", commands.cmd_edit),
|
|
31
|
+
"initialize": ("--initialize", commands.cmd_import),
|
|
32
|
+
"export": ("--export", commands.cmd_export),
|
|
33
|
+
"add": ("--add", commands.cmd_add),
|
|
34
|
+
"passwd": ("--passwd", commands.cmd_passwd),
|
|
35
|
+
"forget": ("--forget", commands.cmd_forget),
|
|
36
|
+
"remember": ("--remember", commands.cmd_remember),
|
|
37
|
+
"list-backups": ("--list-backups", commands.cmd_backup_list),
|
|
38
|
+
"restore": ("--restore", commands.cmd_backup_restore),
|
|
39
|
+
"remove": ("--remove", commands.cmd_remove),
|
|
40
|
+
"remove-backups": ("--remove-backups", commands.cmd_backup_remove),
|
|
41
|
+
"remove-all-backups": ("--remove-all-backups", commands.cmd_backup_remove_all),
|
|
42
|
+
"backup": ("--backup", commands.cmd_backup_create),
|
|
43
|
+
"get": (None, commands.cmd_get),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def config_dir():
|
|
48
|
+
if os.environ.get("TIMO_HOME"):
|
|
49
|
+
return os.environ["TIMO_HOME"]
|
|
50
|
+
base = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config")
|
|
51
|
+
return os.path.join(base, "timo")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def make_context():
|
|
55
|
+
home = config_dir()
|
|
56
|
+
return Context(
|
|
57
|
+
secrets_path=os.path.join(home, "secrets.enc"),
|
|
58
|
+
backup_dir=os.path.join(home, "backups"),
|
|
59
|
+
python_path=sys.executable,
|
|
60
|
+
preview_script=os.path.join(PACKAGE_DIR, "preview.py"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _seconds(value):
|
|
65
|
+
try:
|
|
66
|
+
seconds = int(value)
|
|
67
|
+
except ValueError:
|
|
68
|
+
seconds = -1
|
|
69
|
+
if seconds < 0:
|
|
70
|
+
raise argparse.ArgumentTypeError("must be a whole number of seconds (0 = never clear)")
|
|
71
|
+
return seconds
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Parser(argparse.ArgumentParser):
|
|
75
|
+
"""An ArgumentParser whose --help is grouped by task.
|
|
76
|
+
|
|
77
|
+
The help is generated from the add_argument() calls: flag names and
|
|
78
|
+
placeholders come from each action, descriptions from its help=. Only the
|
|
79
|
+
section titles, INTRO and FOOTER are written by hand. Options that only
|
|
80
|
+
work with one action are listed indented under it.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, *args, **kwargs):
|
|
84
|
+
super().__init__(*args, **kwargs)
|
|
85
|
+
self.sections = [] # [(title, [(action, help, indented)])]
|
|
86
|
+
|
|
87
|
+
def section(self, title):
|
|
88
|
+
self.sections.append((title, []))
|
|
89
|
+
|
|
90
|
+
def option(self, *flags, under=None, **kwargs):
|
|
91
|
+
"""add_argument(), and list it in the current section (indented if `under` an action)."""
|
|
92
|
+
action = self.add_argument(*flags, **kwargs)
|
|
93
|
+
self.show(action, under=under)
|
|
94
|
+
return action
|
|
95
|
+
|
|
96
|
+
def show(self, action, help=None, under=None):
|
|
97
|
+
"""List an action (again) in the current section, e.g. --backup under --edit."""
|
|
98
|
+
self.sections[-1][1].append((action, help or action.help, under is not None))
|
|
99
|
+
|
|
100
|
+
def format_help(self):
|
|
101
|
+
rows = [(title, [(_invocation(action), text, indented) for action, text, indented in entries])
|
|
102
|
+
for title, entries in self.sections]
|
|
103
|
+
width = max(len(name) + len(meta) + (4 if indented else 0)
|
|
104
|
+
for _, entries in rows for (name, meta), _, indented in entries)
|
|
105
|
+
lines = [ui.paint(INTRO, ui.BOLD), ""]
|
|
106
|
+
for title, entries in rows:
|
|
107
|
+
lines.append(ui.paint(title, ui.BOLD))
|
|
108
|
+
for (name, meta), text, indented in entries:
|
|
109
|
+
indent = " " if indented else " "
|
|
110
|
+
gap = " " * (width - len(name) - len(meta) - len(indent) + 5)
|
|
111
|
+
shown = ui.paint(name, ui.CYAN) + (ui.paint(meta, ui.DIM) if meta else "")
|
|
112
|
+
lines.append(f"{indent}{shown}{gap}{text}")
|
|
113
|
+
lines.append("")
|
|
114
|
+
lines += [ui.paint(line, ui.DIM) for line in FOOTER]
|
|
115
|
+
return "\n".join(lines) + "\n"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _invocation(action):
|
|
119
|
+
"""("--restore", " NAME"): how an action is typed, split into name and placeholder."""
|
|
120
|
+
if not action.option_strings:
|
|
121
|
+
return "timo", f" [{action.metavar}...]"
|
|
122
|
+
name = ", ".join(sorted(action.option_strings, key=len, reverse=True))
|
|
123
|
+
if action.nargs == 0:
|
|
124
|
+
return name, ""
|
|
125
|
+
metavar = action.metavar or action.dest.upper()
|
|
126
|
+
return name, f" {metavar}..." if action.nargs == "+" else f" {metavar}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_parser():
|
|
130
|
+
parser = Parser(
|
|
131
|
+
prog="timo",
|
|
132
|
+
usage="timo [WORDS...] [--no-copy] [--clear-after SECONDS]\n timo --<action> [options]",
|
|
133
|
+
add_help=False, # -h is declared below, in its section
|
|
134
|
+
allow_abbrev=False, # flags must be spelled out: --lis is not --list
|
|
135
|
+
)
|
|
136
|
+
option = parser.option
|
|
137
|
+
|
|
138
|
+
parser.section("Copy a code")
|
|
139
|
+
words = option("query", nargs="*", metavar="WORDS",
|
|
140
|
+
help="copy the code of the account matching all words (none: pick with fzf)")
|
|
141
|
+
option("--no-copy", action="store_true", under=words, help="print the code, don't copy it")
|
|
142
|
+
option("--clear-after", type=_seconds, metavar="SECONDS", under=words,
|
|
143
|
+
help="clear the clipboard after SECONDS, 0 = never (default 45)")
|
|
144
|
+
|
|
145
|
+
parser.section("See your accounts")
|
|
146
|
+
option("--list", action="store_true", help="list all accounts and the day each was added")
|
|
147
|
+
option("--stats", action="store_true", help="show account counts per issuer")
|
|
148
|
+
option("--watch", action="store_true", help="show all codes, refreshing every second")
|
|
149
|
+
|
|
150
|
+
parser.section("Add and change accounts")
|
|
151
|
+
option("--initialize", action="store_true", help="import a Google Authenticator export")
|
|
152
|
+
export = option("--export", action="store_true",
|
|
153
|
+
help="export accounts matching WORDS (none: all) to another app")
|
|
154
|
+
option("--format", choices=EXPORT_FORMATS, metavar="FORMAT", under=export,
|
|
155
|
+
help="setup-qr: one QR code per account (default in a terminal), or uri")
|
|
156
|
+
option("--output", metavar="FILE", under=export, help="write otpauth:// links to FILE (readable only by you)")
|
|
157
|
+
option("--add", action="store_true", help="add one account (setup QR code, otpauth:// link or secret key)")
|
|
158
|
+
option("--remove", action="store_true", help="pick an account and remove it (asks first)")
|
|
159
|
+
edit = option("--edit", action="store_true", help="edit the decrypted accounts in your editor")
|
|
160
|
+
backup = option("--backup", action="store_true", under=edit, help="back up before saving the changes")
|
|
161
|
+
option("--editor", metavar="CMD", under=edit, help="editor to use (default: $EDITOR, then vi/nano/vim)")
|
|
162
|
+
|
|
163
|
+
parser.section("Backups")
|
|
164
|
+
parser.show(backup, help="create a backup")
|
|
165
|
+
option("--list-backups", action="store_true", help="show available backups")
|
|
166
|
+
option("--restore", metavar="NAME", help="restore a backup (current secrets are backed up first)")
|
|
167
|
+
option("--remove-backups", nargs="+", metavar="NAME", help="delete backups")
|
|
168
|
+
option("--remove-all-backups", action="store_true", help="delete all backups (asks first)")
|
|
169
|
+
|
|
170
|
+
parser.section("Passphrase")
|
|
171
|
+
option("--passwd", action="store_true", help="change the passphrase")
|
|
172
|
+
option("--forget", action="store_true", help="remove it from your keychain and stop saving it")
|
|
173
|
+
option("--remember", action="store_true", help="save it in your keychain again on the next unlock")
|
|
174
|
+
|
|
175
|
+
parser.section("Other")
|
|
176
|
+
option("--version", action="version", version=f"%(prog)s {__version__}", help="show the installed version")
|
|
177
|
+
option("-h", "--help", action="help", help="show this help")
|
|
178
|
+
return parser
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _chosen_actions(args):
|
|
182
|
+
chosen = []
|
|
183
|
+
for name, (flag, _) in ACTIONS.items():
|
|
184
|
+
if flag is None:
|
|
185
|
+
continue
|
|
186
|
+
value = getattr(args, flag[2:].replace("-", "_"))
|
|
187
|
+
if value not in (None, False):
|
|
188
|
+
chosen.append(name)
|
|
189
|
+
# --backup is a modifier of --edit
|
|
190
|
+
if "backup" in chosen and "edit" in chosen:
|
|
191
|
+
chosen.remove("backup")
|
|
192
|
+
return chosen
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def parse(argv):
|
|
196
|
+
parser = build_parser()
|
|
197
|
+
# everything after "--" is a search word; intermixed parsing loses track of "--" itself
|
|
198
|
+
literal = []
|
|
199
|
+
if "--" in argv:
|
|
200
|
+
i = argv.index("--")
|
|
201
|
+
argv, literal = argv[:i], argv[i + 1:]
|
|
202
|
+
args = parser.parse_intermixed_args(argv) # search words may come before and after options
|
|
203
|
+
args.query = args.query + literal
|
|
204
|
+
chosen = _chosen_actions(args)
|
|
205
|
+
|
|
206
|
+
if len(chosen) > 1:
|
|
207
|
+
parser.error("use only one of: " + ", ".join(ACTIONS[a][0] for a in chosen))
|
|
208
|
+
action = chosen[0] if chosen else "get"
|
|
209
|
+
|
|
210
|
+
if action != "get":
|
|
211
|
+
if args.query and action != "export":
|
|
212
|
+
parser.error(f"search words can't be combined with {ACTIONS[action][0]}")
|
|
213
|
+
if args.no_copy or args.clear_after is not None:
|
|
214
|
+
parser.error("--no-copy and --clear-after only apply when copying a code")
|
|
215
|
+
if args.editor is not None and action != "edit":
|
|
216
|
+
parser.error("--editor only works with --edit")
|
|
217
|
+
if (args.format is not None or args.output is not None) and action != "export":
|
|
218
|
+
parser.error("--format and --output only work with --export")
|
|
219
|
+
if args.output is not None and args.format == "setup-qr":
|
|
220
|
+
parser.error("--output only works with --format uri")
|
|
221
|
+
|
|
222
|
+
# names the command handlers expect
|
|
223
|
+
args.action = action
|
|
224
|
+
args.func = ACTIONS[action][1]
|
|
225
|
+
args.names = args.remove_backups or []
|
|
226
|
+
args.name = args.restore
|
|
227
|
+
return args
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main(argv=None):
|
|
231
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
232
|
+
|
|
233
|
+
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
|
234
|
+
ui.error(msg.ROOT, msg.ROOT_HINT)
|
|
235
|
+
return 1
|
|
236
|
+
|
|
237
|
+
args = parse(argv)
|
|
238
|
+
try:
|
|
239
|
+
return args.func(args, make_context())
|
|
240
|
+
except Abort as e:
|
|
241
|
+
ui.error(str(e), *e.hints)
|
|
242
|
+
return 1
|
|
243
|
+
except (KeyboardInterrupt, EOFError):
|
|
244
|
+
print(file=sys.stderr)
|
|
245
|
+
return 130
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Copy codes to the clipboard and clear them again after a delay.
|
|
2
|
+
|
|
3
|
+
Clearing runs in a small detached process (this file, run as a script), so
|
|
4
|
+
timo itself exits immediately. The code is passed on stdin rather than the
|
|
5
|
+
command line, where other users could see it with ps. The clipboard is only
|
|
6
|
+
cleared if it still holds the code, so anything copied since is left alone.
|
|
7
|
+
"""
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
DEFAULT_CLEAR_AFTER = 45 # seconds, same default as the `pass` password manager
|
|
14
|
+
CLEAR_AFTER_ENV = "TIMO_CLEAR_AFTER"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def copy(text):
|
|
18
|
+
try:
|
|
19
|
+
import pyperclip
|
|
20
|
+
pyperclip.copy(text)
|
|
21
|
+
return True
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def schedule_clear(code, seconds):
|
|
27
|
+
try:
|
|
28
|
+
proc = subprocess.Popen(
|
|
29
|
+
[sys.executable, os.path.abspath(__file__), str(seconds)],
|
|
30
|
+
stdin=subprocess.PIPE,
|
|
31
|
+
stdout=subprocess.DEVNULL,
|
|
32
|
+
stderr=subprocess.DEVNULL,
|
|
33
|
+
start_new_session=True, # survives timo exiting and Ctrl-C in the terminal
|
|
34
|
+
close_fds=True,
|
|
35
|
+
)
|
|
36
|
+
proc.stdin.write(code.encode())
|
|
37
|
+
proc.stdin.close()
|
|
38
|
+
return True
|
|
39
|
+
except Exception:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def clear_if_unchanged(code, seconds, paste, copy, sleep=time.sleep):
|
|
44
|
+
sleep(seconds)
|
|
45
|
+
try:
|
|
46
|
+
if paste() == code:
|
|
47
|
+
copy("")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
code = sys.stdin.read()
|
|
54
|
+
import pyperclip
|
|
55
|
+
clear_if_unchanged(code, float(sys.argv[1]), pyperclip.paste, pyperclip.copy)
|