vw-keystroke 0.1.0__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.
- vw_keystroke-0.1.0/LICENSE +21 -0
- vw_keystroke-0.1.0/PKG-INFO +123 -0
- vw_keystroke-0.1.0/README.md +102 -0
- vw_keystroke-0.1.0/pyproject.toml +36 -0
- vw_keystroke-0.1.0/setup.cfg +4 -0
- vw_keystroke-0.1.0/vw_keystroke.egg-info/PKG-INFO +123 -0
- vw_keystroke-0.1.0/vw_keystroke.egg-info/SOURCES.txt +13 -0
- vw_keystroke-0.1.0/vw_keystroke.egg-info/dependency_links.txt +1 -0
- vw_keystroke-0.1.0/vw_keystroke.egg-info/entry_points.txt +6 -0
- vw_keystroke-0.1.0/vw_keystroke.egg-info/requires.txt +1 -0
- vw_keystroke-0.1.0/vw_keystroke.egg-info/top_level.txt +1 -0
- vw_keystroke-0.1.0/vwkeystroke/__init__.py +2 -0
- vw_keystroke-0.1.0/vwkeystroke/cli.py +76 -0
- vw_keystroke-0.1.0/vwkeystroke/core.py +347 -0
- vw_keystroke-0.1.0/vwkeystroke/gui.py +252 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PolyDevHouse LLC
|
|
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.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vw-keystroke
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop a secret once β into Vaultwarden/Bitwarden and a local 600 env file.
|
|
5
|
+
Author: PolyDevHouse LLC
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/PolyDevHouse/vw-keystroke
|
|
8
|
+
Project-URL: Issues, https://github.com/PolyDevHouse/vw-keystroke/issues
|
|
9
|
+
Keywords: vaultwarden,bitwarden,secrets,api-keys,dotenv,keyring
|
|
10
|
+
Classifier: Environment :: X11 Applications
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: keyring>=24
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
<p align="center">
|
|
23
|
+
<img src="assets/rendered/vw-keystroke-256.png" width="120" alt="VW Keystroke">
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<h1 align="center">VW Keystroke</h1>
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<img src="https://img.shields.io/github/license/PolyDevHouse/vw-keystroke" alt="License: MIT">
|
|
30
|
+
<img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
|
|
31
|
+
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey" alt="Platforms">
|
|
32
|
+
<img src="https://img.shields.io/badge/vault-Vaultwarden%20%7C%20Bitwarden-175DDC" alt="Vaultwarden / Bitwarden">
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
**Paste a secret once β it lands in your [Vaultwarden](https://github.com/dani-garcia/vaultwarden)/Bitwarden vault *and* a local `600` env file, in one move.**
|
|
36
|
+
|
|
37
|
+
Built for the "hot potato" moment: you just generated an API key and you want it
|
|
38
|
+
*off your clipboard and safely stored* without a five-step ritual β so it doesn't
|
|
39
|
+
end up lingering in a scratch file. Name it, paste it, done.
|
|
40
|
+
|
|
41
|
+
- ποΈ Saves to your vault as a **Login** item (username = the env var, password = the value, with the env-file path recorded as a custom field).
|
|
42
|
+
- π Writes `~/.config/<project>.env` (`chmod 600`, updated in place β never duplicated).
|
|
43
|
+
- π **Zero-knowledge**: your master password never leaves your machine; all vault crypto is done by the official `bw` CLI.
|
|
44
|
+
- β‘ Caches the unlocked session in your **OS keyring** (Windows Credential Locker / macOS Keychain / Linux libsecret) so daily use needs no password.
|
|
45
|
+
- π₯οΈ **GUI** and **CLI**, on **Windows / Linux / macOS**.
|
|
46
|
+
|
|
47
|
+
> The secret value is never passed on the command line and never echoed β only
|
|
48
|
+
> the `600` env file and your vault ever see it.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
1. **[Bitwarden CLI](https://bitwarden.com/help/cli/)** β `npm install -g @bitwarden/cli` (provides `bw`).
|
|
55
|
+
2. **Python 3.9+** (Tkinter ships with the standard python.org installers).
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# From GitHub (works today):
|
|
61
|
+
pipx install git+https://github.com/PolyDevHouse/vw-keystroke
|
|
62
|
+
# or
|
|
63
|
+
pip install --user git+https://github.com/PolyDevHouse/vw-keystroke
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> _PyPI release coming soon β then `pipx install vw-keystroke` will Just Work._
|
|
67
|
+
|
|
68
|
+
This gives you three commands: `vwks` (CLI), `keydrop` (alias), and `vwks-gui` (GUI).
|
|
69
|
+
|
|
70
|
+
### First-time setup
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# point bw at your server (self-hosted Vaultwarden, or Bitwarden cloud)
|
|
74
|
+
bw config server https://vault.example.com
|
|
75
|
+
bw login # email + master password + 2FA (once)
|
|
76
|
+
|
|
77
|
+
# optional config
|
|
78
|
+
mkdir -p ~/.config
|
|
79
|
+
cp vw-keystroke.conf.example ~/.config/vw-keystroke.conf && chmod 600 ~/.config/vw-keystroke.conf
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Use
|
|
83
|
+
|
|
84
|
+
**GUI:** launch **VW Keystroke** from your app menu (Linux `.desktop` in
|
|
85
|
+
[`packaging/`](packaging/)), or run `vwks-gui`.
|
|
86
|
+
|
|
87
|
+
**CLI:**
|
|
88
|
+
```bash
|
|
89
|
+
vwks OPENAI_API_KEY # prompts (hidden) for the value
|
|
90
|
+
vwks OPENAI_API_KEY --project openai # -> ~/.config/openai.env
|
|
91
|
+
echo -n "$SECRET" | vwks --var FOO --project bar # piped / non-interactive
|
|
92
|
+
vwks --lock # forget the cached session + lock now
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
`~/.config/vw-keystroke.conf` (all optional β see [`vw-keystroke.conf.example`](vw-keystroke.conf.example)):
|
|
98
|
+
|
|
99
|
+
| Key | Meaning | Default |
|
|
100
|
+
|-----|---------|---------|
|
|
101
|
+
| `BW_SERVER` | Vault server URL | current `bw` config |
|
|
102
|
+
| `DEFAULT_PROJECT` | env-file basename | `keys` |
|
|
103
|
+
| `VAULT_FOLDER` | folder for new items | `API Keys` |
|
|
104
|
+
| `KEYRING_TTL_DAYS` | cached-session lifetime (`0` = never cache) | `30` |
|
|
105
|
+
| `ENV_DIR` | where env files go | `~/.config` |
|
|
106
|
+
| `BW_CLIENTID` / `BW_CLIENTSECRET` | optional API-key login | β |
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
`bw` handles login and all encryption. On a drop, VW Keystroke unlocks (or reuses a
|
|
111
|
+
cached session), then creates/updates the vault item and the env file. If the vault
|
|
112
|
+
is locked it asks for your master password once; the resulting session key is stored
|
|
113
|
+
in your OS keyring with a TTL and refreshed on use.
|
|
114
|
+
|
|
115
|
+
## Security notes
|
|
116
|
+
|
|
117
|
+
- Nothing sensitive is stored by this tool except the `600` env file you asked for and the keyring session (same protection as your desktop login).
|
|
118
|
+
- A cached session means anyone at your already-unlocked machine can drop/read keys β the same exposure as a logged-in browser vault extension. Set `KEYRING_TTL_DAYS=0` to disable caching.
|
|
119
|
+
- This project ships **no** credentials, servers, or keys of its own.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT Β© PolyDevHouse LLC
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/rendered/vw-keystroke-256.png" width="120" alt="VW Keystroke">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">VW Keystroke</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="https://img.shields.io/github/license/PolyDevHouse/vw-keystroke" alt="License: MIT">
|
|
9
|
+
<img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
|
|
10
|
+
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey" alt="Platforms">
|
|
11
|
+
<img src="https://img.shields.io/badge/vault-Vaultwarden%20%7C%20Bitwarden-175DDC" alt="Vaultwarden / Bitwarden">
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
**Paste a secret once β it lands in your [Vaultwarden](https://github.com/dani-garcia/vaultwarden)/Bitwarden vault *and* a local `600` env file, in one move.**
|
|
15
|
+
|
|
16
|
+
Built for the "hot potato" moment: you just generated an API key and you want it
|
|
17
|
+
*off your clipboard and safely stored* without a five-step ritual β so it doesn't
|
|
18
|
+
end up lingering in a scratch file. Name it, paste it, done.
|
|
19
|
+
|
|
20
|
+
- ποΈ Saves to your vault as a **Login** item (username = the env var, password = the value, with the env-file path recorded as a custom field).
|
|
21
|
+
- π Writes `~/.config/<project>.env` (`chmod 600`, updated in place β never duplicated).
|
|
22
|
+
- π **Zero-knowledge**: your master password never leaves your machine; all vault crypto is done by the official `bw` CLI.
|
|
23
|
+
- β‘ Caches the unlocked session in your **OS keyring** (Windows Credential Locker / macOS Keychain / Linux libsecret) so daily use needs no password.
|
|
24
|
+
- π₯οΈ **GUI** and **CLI**, on **Windows / Linux / macOS**.
|
|
25
|
+
|
|
26
|
+
> The secret value is never passed on the command line and never echoed β only
|
|
27
|
+
> the `600` env file and your vault ever see it.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Requirements
|
|
32
|
+
|
|
33
|
+
1. **[Bitwarden CLI](https://bitwarden.com/help/cli/)** β `npm install -g @bitwarden/cli` (provides `bw`).
|
|
34
|
+
2. **Python 3.9+** (Tkinter ships with the standard python.org installers).
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# From GitHub (works today):
|
|
40
|
+
pipx install git+https://github.com/PolyDevHouse/vw-keystroke
|
|
41
|
+
# or
|
|
42
|
+
pip install --user git+https://github.com/PolyDevHouse/vw-keystroke
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
> _PyPI release coming soon β then `pipx install vw-keystroke` will Just Work._
|
|
46
|
+
|
|
47
|
+
This gives you three commands: `vwks` (CLI), `keydrop` (alias), and `vwks-gui` (GUI).
|
|
48
|
+
|
|
49
|
+
### First-time setup
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# point bw at your server (self-hosted Vaultwarden, or Bitwarden cloud)
|
|
53
|
+
bw config server https://vault.example.com
|
|
54
|
+
bw login # email + master password + 2FA (once)
|
|
55
|
+
|
|
56
|
+
# optional config
|
|
57
|
+
mkdir -p ~/.config
|
|
58
|
+
cp vw-keystroke.conf.example ~/.config/vw-keystroke.conf && chmod 600 ~/.config/vw-keystroke.conf
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Use
|
|
62
|
+
|
|
63
|
+
**GUI:** launch **VW Keystroke** from your app menu (Linux `.desktop` in
|
|
64
|
+
[`packaging/`](packaging/)), or run `vwks-gui`.
|
|
65
|
+
|
|
66
|
+
**CLI:**
|
|
67
|
+
```bash
|
|
68
|
+
vwks OPENAI_API_KEY # prompts (hidden) for the value
|
|
69
|
+
vwks OPENAI_API_KEY --project openai # -> ~/.config/openai.env
|
|
70
|
+
echo -n "$SECRET" | vwks --var FOO --project bar # piped / non-interactive
|
|
71
|
+
vwks --lock # forget the cached session + lock now
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
`~/.config/vw-keystroke.conf` (all optional β see [`vw-keystroke.conf.example`](vw-keystroke.conf.example)):
|
|
77
|
+
|
|
78
|
+
| Key | Meaning | Default |
|
|
79
|
+
|-----|---------|---------|
|
|
80
|
+
| `BW_SERVER` | Vault server URL | current `bw` config |
|
|
81
|
+
| `DEFAULT_PROJECT` | env-file basename | `keys` |
|
|
82
|
+
| `VAULT_FOLDER` | folder for new items | `API Keys` |
|
|
83
|
+
| `KEYRING_TTL_DAYS` | cached-session lifetime (`0` = never cache) | `30` |
|
|
84
|
+
| `ENV_DIR` | where env files go | `~/.config` |
|
|
85
|
+
| `BW_CLIENTID` / `BW_CLIENTSECRET` | optional API-key login | β |
|
|
86
|
+
|
|
87
|
+
## How it works
|
|
88
|
+
|
|
89
|
+
`bw` handles login and all encryption. On a drop, VW Keystroke unlocks (or reuses a
|
|
90
|
+
cached session), then creates/updates the vault item and the env file. If the vault
|
|
91
|
+
is locked it asks for your master password once; the resulting session key is stored
|
|
92
|
+
in your OS keyring with a TTL and refreshed on use.
|
|
93
|
+
|
|
94
|
+
## Security notes
|
|
95
|
+
|
|
96
|
+
- Nothing sensitive is stored by this tool except the `600` env file you asked for and the keyring session (same protection as your desktop login).
|
|
97
|
+
- A cached session means anyone at your already-unlocked machine can drop/read keys β the same exposure as a logged-in browser vault extension. Set `KEYRING_TTL_DAYS=0` to disable caching.
|
|
98
|
+
- This project ships **no** credentials, servers, or keys of its own.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT Β© PolyDevHouse LLC
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vw-keystroke"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Drop a secret once β into Vaultwarden/Bitwarden and a local 600 env file."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "PolyDevHouse LLC" }]
|
|
13
|
+
keywords = ["vaultwarden", "bitwarden", "secrets", "api-keys", "dotenv", "keyring"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: X11 Applications",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
"Topic :: Utilities",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["keyring>=24"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/PolyDevHouse/vw-keystroke"
|
|
26
|
+
Issues = "https://github.com/PolyDevHouse/vw-keystroke/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
vwks = "vwkeystroke.cli:main"
|
|
30
|
+
keydrop = "vwkeystroke.cli:main"
|
|
31
|
+
|
|
32
|
+
[project.gui-scripts]
|
|
33
|
+
vwks-gui = "vwkeystroke.gui:main"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools]
|
|
36
|
+
packages = ["vwkeystroke"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vw-keystroke
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop a secret once β into Vaultwarden/Bitwarden and a local 600 env file.
|
|
5
|
+
Author: PolyDevHouse LLC
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/PolyDevHouse/vw-keystroke
|
|
8
|
+
Project-URL: Issues, https://github.com/PolyDevHouse/vw-keystroke/issues
|
|
9
|
+
Keywords: vaultwarden,bitwarden,secrets,api-keys,dotenv,keyring
|
|
10
|
+
Classifier: Environment :: X11 Applications
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Topic :: Security
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: keyring>=24
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
<p align="center">
|
|
23
|
+
<img src="assets/rendered/vw-keystroke-256.png" width="120" alt="VW Keystroke">
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<h1 align="center">VW Keystroke</h1>
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<img src="https://img.shields.io/github/license/PolyDevHouse/vw-keystroke" alt="License: MIT">
|
|
30
|
+
<img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
|
|
31
|
+
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey" alt="Platforms">
|
|
32
|
+
<img src="https://img.shields.io/badge/vault-Vaultwarden%20%7C%20Bitwarden-175DDC" alt="Vaultwarden / Bitwarden">
|
|
33
|
+
</p>
|
|
34
|
+
|
|
35
|
+
**Paste a secret once β it lands in your [Vaultwarden](https://github.com/dani-garcia/vaultwarden)/Bitwarden vault *and* a local `600` env file, in one move.**
|
|
36
|
+
|
|
37
|
+
Built for the "hot potato" moment: you just generated an API key and you want it
|
|
38
|
+
*off your clipboard and safely stored* without a five-step ritual β so it doesn't
|
|
39
|
+
end up lingering in a scratch file. Name it, paste it, done.
|
|
40
|
+
|
|
41
|
+
- ποΈ Saves to your vault as a **Login** item (username = the env var, password = the value, with the env-file path recorded as a custom field).
|
|
42
|
+
- π Writes `~/.config/<project>.env` (`chmod 600`, updated in place β never duplicated).
|
|
43
|
+
- π **Zero-knowledge**: your master password never leaves your machine; all vault crypto is done by the official `bw` CLI.
|
|
44
|
+
- β‘ Caches the unlocked session in your **OS keyring** (Windows Credential Locker / macOS Keychain / Linux libsecret) so daily use needs no password.
|
|
45
|
+
- π₯οΈ **GUI** and **CLI**, on **Windows / Linux / macOS**.
|
|
46
|
+
|
|
47
|
+
> The secret value is never passed on the command line and never echoed β only
|
|
48
|
+
> the `600` env file and your vault ever see it.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
1. **[Bitwarden CLI](https://bitwarden.com/help/cli/)** β `npm install -g @bitwarden/cli` (provides `bw`).
|
|
55
|
+
2. **Python 3.9+** (Tkinter ships with the standard python.org installers).
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# From GitHub (works today):
|
|
61
|
+
pipx install git+https://github.com/PolyDevHouse/vw-keystroke
|
|
62
|
+
# or
|
|
63
|
+
pip install --user git+https://github.com/PolyDevHouse/vw-keystroke
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> _PyPI release coming soon β then `pipx install vw-keystroke` will Just Work._
|
|
67
|
+
|
|
68
|
+
This gives you three commands: `vwks` (CLI), `keydrop` (alias), and `vwks-gui` (GUI).
|
|
69
|
+
|
|
70
|
+
### First-time setup
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# point bw at your server (self-hosted Vaultwarden, or Bitwarden cloud)
|
|
74
|
+
bw config server https://vault.example.com
|
|
75
|
+
bw login # email + master password + 2FA (once)
|
|
76
|
+
|
|
77
|
+
# optional config
|
|
78
|
+
mkdir -p ~/.config
|
|
79
|
+
cp vw-keystroke.conf.example ~/.config/vw-keystroke.conf && chmod 600 ~/.config/vw-keystroke.conf
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Use
|
|
83
|
+
|
|
84
|
+
**GUI:** launch **VW Keystroke** from your app menu (Linux `.desktop` in
|
|
85
|
+
[`packaging/`](packaging/)), or run `vwks-gui`.
|
|
86
|
+
|
|
87
|
+
**CLI:**
|
|
88
|
+
```bash
|
|
89
|
+
vwks OPENAI_API_KEY # prompts (hidden) for the value
|
|
90
|
+
vwks OPENAI_API_KEY --project openai # -> ~/.config/openai.env
|
|
91
|
+
echo -n "$SECRET" | vwks --var FOO --project bar # piped / non-interactive
|
|
92
|
+
vwks --lock # forget the cached session + lock now
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Configuration
|
|
96
|
+
|
|
97
|
+
`~/.config/vw-keystroke.conf` (all optional β see [`vw-keystroke.conf.example`](vw-keystroke.conf.example)):
|
|
98
|
+
|
|
99
|
+
| Key | Meaning | Default |
|
|
100
|
+
|-----|---------|---------|
|
|
101
|
+
| `BW_SERVER` | Vault server URL | current `bw` config |
|
|
102
|
+
| `DEFAULT_PROJECT` | env-file basename | `keys` |
|
|
103
|
+
| `VAULT_FOLDER` | folder for new items | `API Keys` |
|
|
104
|
+
| `KEYRING_TTL_DAYS` | cached-session lifetime (`0` = never cache) | `30` |
|
|
105
|
+
| `ENV_DIR` | where env files go | `~/.config` |
|
|
106
|
+
| `BW_CLIENTID` / `BW_CLIENTSECRET` | optional API-key login | β |
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
`bw` handles login and all encryption. On a drop, VW Keystroke unlocks (or reuses a
|
|
111
|
+
cached session), then creates/updates the vault item and the env file. If the vault
|
|
112
|
+
is locked it asks for your master password once; the resulting session key is stored
|
|
113
|
+
in your OS keyring with a TTL and refreshed on use.
|
|
114
|
+
|
|
115
|
+
## Security notes
|
|
116
|
+
|
|
117
|
+
- Nothing sensitive is stored by this tool except the `600` env file you asked for and the keyring session (same protection as your desktop login).
|
|
118
|
+
- A cached session means anyone at your already-unlocked machine can drop/read keys β the same exposure as a logged-in browser vault extension. Set `KEYRING_TTL_DAYS=0` to disable caching.
|
|
119
|
+
- This project ships **no** credentials, servers, or keys of its own.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT Β© PolyDevHouse LLC
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
vw_keystroke.egg-info/PKG-INFO
|
|
5
|
+
vw_keystroke.egg-info/SOURCES.txt
|
|
6
|
+
vw_keystroke.egg-info/dependency_links.txt
|
|
7
|
+
vw_keystroke.egg-info/entry_points.txt
|
|
8
|
+
vw_keystroke.egg-info/requires.txt
|
|
9
|
+
vw_keystroke.egg-info/top_level.txt
|
|
10
|
+
vwkeystroke/__init__.py
|
|
11
|
+
vwkeystroke/cli.py
|
|
12
|
+
vwkeystroke/core.py
|
|
13
|
+
vwkeystroke/gui.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keyring>=24
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vwkeystroke
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""VW Keystroke CLI β `vwks` (alias `keydrop`).
|
|
3
|
+
|
|
4
|
+
vwks OPENAI_API_KEY # positional var, prompts for value
|
|
5
|
+
vwks --var FOO --project bar
|
|
6
|
+
echo -n "$SECRET" | vwks --var FOO --project bar # non-interactive
|
|
7
|
+
vwks --lock # forget cached session + lock
|
|
8
|
+
"""
|
|
9
|
+
import argparse, sys
|
|
10
|
+
from getpass import getpass
|
|
11
|
+
from . import core
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_value():
|
|
15
|
+
if not sys.stdin.isatty():
|
|
16
|
+
v = sys.stdin.readline().rstrip("\n")
|
|
17
|
+
else:
|
|
18
|
+
v = getpass("Paste value (hidden): ")
|
|
19
|
+
if not v:
|
|
20
|
+
sys.exit("vwks: empty value β nothing to store")
|
|
21
|
+
return v
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv=None):
|
|
25
|
+
ap = argparse.ArgumentParser(prog="vwks",
|
|
26
|
+
description="Store a secret in a 600 env file + Vaultwarden/Bitwarden.")
|
|
27
|
+
ap.add_argument("var_pos", nargs="?", help="ENV var name (positional shortcut)")
|
|
28
|
+
ap.add_argument("--var")
|
|
29
|
+
ap.add_argument("--project")
|
|
30
|
+
ap.add_argument("--name", help="vault item name (default: the ENV var name)")
|
|
31
|
+
ap.add_argument("--folder")
|
|
32
|
+
ap.add_argument("--env-dir")
|
|
33
|
+
ap.add_argument("--no-vault", action="store_true", help="write env file only")
|
|
34
|
+
ap.add_argument("--lock", action="store_true", help="forget cached session + lock, then exit")
|
|
35
|
+
a = ap.parse_args(argv)
|
|
36
|
+
|
|
37
|
+
if a.lock:
|
|
38
|
+
core.lock()
|
|
39
|
+
print("β cached session cleared; vault locked")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
conf = core.load_conf()
|
|
43
|
+
interactive = sys.stdin.isatty()
|
|
44
|
+
var = a.var or a.var_pos or (input("Env var name: ").strip() if interactive else "")
|
|
45
|
+
if not core.valid_var(var):
|
|
46
|
+
sys.exit(f"vwks: invalid/empty env var name: {var!r}")
|
|
47
|
+
proj_default = core.default_project(conf)
|
|
48
|
+
project = a.project or (input(f"Project [{proj_default}]: ").strip() if interactive else "") or proj_default
|
|
49
|
+
|
|
50
|
+
value = _read_value()
|
|
51
|
+
|
|
52
|
+
mp = None
|
|
53
|
+
while True:
|
|
54
|
+
try:
|
|
55
|
+
res = core.drop(var, value, project=project, name=a.name, folder=a.folder,
|
|
56
|
+
env_dir_=a.env_dir, no_vault=a.no_vault, master_password=mp, conf=conf)
|
|
57
|
+
break
|
|
58
|
+
except core.NeedUnlock:
|
|
59
|
+
if not interactive:
|
|
60
|
+
sys.exit("vwks: vault locked and no TTY to prompt for master password")
|
|
61
|
+
mp = getpass("Vault master password (hidden): ")
|
|
62
|
+
except core.NeedLogin as e:
|
|
63
|
+
sys.exit(f"vwks: {e}")
|
|
64
|
+
except core.VwksError as e:
|
|
65
|
+
sys.exit(f"vwks: {e}")
|
|
66
|
+
|
|
67
|
+
print(f"β {res['env_action']} {var} in {res['env_path']} (600)")
|
|
68
|
+
if res["vault"]:
|
|
69
|
+
print(f'β Vaultwarden: {res["vault"]} login "{a.name or var}" in folder "{res["folder"]}" β')
|
|
70
|
+
else:
|
|
71
|
+
print("β vault skipped (--no-vault)")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""VW Keystroke β shared core for the CLI and GUI.
|
|
2
|
+
|
|
3
|
+
Paste a secret once; it lands in a 600 env file AND in your Vaultwarden (or
|
|
4
|
+
Bitwarden) vault. Wraps the `bw` CLI (which handles all the vault crypto). The
|
|
5
|
+
unlocked session is cached in the OS keyring (Windows Credential Locker / macOS
|
|
6
|
+
Keychain / libsecret) so day-to-day use is just: name it, paste it.
|
|
7
|
+
|
|
8
|
+
No secret value is ever passed on argv or written anywhere but the env file
|
|
9
|
+
(600) and the vault.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import base64, json, os, re, shutil, subprocess, tempfile, time
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import keyring as _keyring # cross-platform session cache
|
|
16
|
+
except Exception: # pragma: no cover - optional
|
|
17
|
+
_keyring = None
|
|
18
|
+
|
|
19
|
+
APP = "vw-keystroke"
|
|
20
|
+
KR_SERVICE = "vw-keystroke"
|
|
21
|
+
KR_ACCOUNT = "bw-session"
|
|
22
|
+
|
|
23
|
+
# Config search order: explicit env var, XDG-ish path, then the legacy keydrop name.
|
|
24
|
+
CONFIG_PATHS = [
|
|
25
|
+
os.environ.get("VWKS_CONFIG"),
|
|
26
|
+
os.path.expanduser("~/.config/vw-keystroke.conf"),
|
|
27
|
+
os.path.expanduser("~/.config/keydrop.conf"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _dbg(msg: str):
|
|
32
|
+
if not os.environ.get("VWKS_DEBUG"):
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
with open("/tmp/vwks-debug.log", "a", encoding="utf-8") as f:
|
|
36
|
+
f.write(f"{time.time():.0f} {msg}\n")
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class VwksError(Exception):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class NeedUnlock(VwksError):
|
|
46
|
+
"""Vault is logged in but locked β a master password is required."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NeedLogin(VwksError):
|
|
50
|
+
"""Not logged in and no API key configured β run `bw login` once."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------- config ----------
|
|
54
|
+
def config_path() -> str:
|
|
55
|
+
for p in CONFIG_PATHS:
|
|
56
|
+
if p and os.path.exists(p):
|
|
57
|
+
return p
|
|
58
|
+
return os.path.expanduser("~/.config/vw-keystroke.conf")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_conf() -> dict:
|
|
62
|
+
conf = {}
|
|
63
|
+
p = config_path()
|
|
64
|
+
if os.path.exists(p):
|
|
65
|
+
for ln in open(p, encoding="utf-8"):
|
|
66
|
+
ln = ln.strip()
|
|
67
|
+
if ln and not ln.startswith("#") and "=" in ln:
|
|
68
|
+
k, v = ln.split("=", 1)
|
|
69
|
+
conf[k.strip()] = v.strip().strip('"').strip("'")
|
|
70
|
+
return conf
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def env_dir(conf: dict | None = None) -> str:
|
|
74
|
+
conf = conf or {}
|
|
75
|
+
return os.path.expanduser(conf.get("ENV_DIR") or "~/.config")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def default_project(conf: dict) -> str:
|
|
79
|
+
return conf.get("DEFAULT_PROJECT", "keys")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def list_projects(edir: str) -> list[str]:
|
|
83
|
+
try:
|
|
84
|
+
return sorted(f[:-4] for f in os.listdir(edir) if f.endswith(".env"))
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------- env file ----------
|
|
90
|
+
def valid_var(var: str) -> bool:
|
|
91
|
+
return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", var or ""))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_env(edir: str, project: str, var: str, value: str) -> tuple[str, str]:
|
|
95
|
+
path = os.path.join(os.path.expanduser(edir), f"{project}.env")
|
|
96
|
+
lines = open(path, encoding="utf-8").read().splitlines() if os.path.exists(path) else []
|
|
97
|
+
out, found = [], False
|
|
98
|
+
pat = re.compile(rf"^\s*{re.escape(var)}\s*=")
|
|
99
|
+
for ln in lines:
|
|
100
|
+
if pat.match(ln):
|
|
101
|
+
out.append(f"{var}={value}"); found = True
|
|
102
|
+
else:
|
|
103
|
+
out.append(ln)
|
|
104
|
+
if not found:
|
|
105
|
+
out.append(f"{var}={value}")
|
|
106
|
+
d = os.path.dirname(path) or "."
|
|
107
|
+
os.makedirs(d, exist_ok=True)
|
|
108
|
+
fd, tmp = tempfile.mkstemp(dir=d)
|
|
109
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
110
|
+
f.write("\n".join(out) + "\n")
|
|
111
|
+
try:
|
|
112
|
+
os.chmod(tmp, 0o600)
|
|
113
|
+
except OSError:
|
|
114
|
+
pass # Windows: perms are a no-op
|
|
115
|
+
os.replace(tmp, path)
|
|
116
|
+
try:
|
|
117
|
+
os.chmod(path, 0o600)
|
|
118
|
+
except OSError:
|
|
119
|
+
pass
|
|
120
|
+
return path, ("updated" if found else "added")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------- bw plumbing ----------
|
|
124
|
+
def find_bw() -> str:
|
|
125
|
+
b = shutil.which("bw")
|
|
126
|
+
if b:
|
|
127
|
+
return b
|
|
128
|
+
for cand in [
|
|
129
|
+
os.path.expanduser("~/.npm-global/bin/bw"),
|
|
130
|
+
"/usr/local/bin/bw",
|
|
131
|
+
os.path.expandvars(r"%APPDATA%\npm\bw.cmd"),
|
|
132
|
+
]:
|
|
133
|
+
if cand and os.path.exists(cand):
|
|
134
|
+
return cand
|
|
135
|
+
raise VwksError("bw CLI not found. Install it: npm install -g @bitwarden/cli")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _run(bw, args, env, check=True):
|
|
139
|
+
r = subprocess.run([bw] + args, capture_output=True, text=True, env=env)
|
|
140
|
+
if check and r.returncode != 0:
|
|
141
|
+
raise VwksError(f"bw {args[0]} failed: {r.stderr.strip()[:200]}")
|
|
142
|
+
return r
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def bw_status(bw, env) -> str:
|
|
146
|
+
r = _run(bw, ["status"], env, check=False)
|
|
147
|
+
try:
|
|
148
|
+
return json.loads(r.stdout).get("status", "unknown")
|
|
149
|
+
except Exception:
|
|
150
|
+
return "unknown"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _base_env(conf) -> dict:
|
|
154
|
+
return dict(os.environ)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _ensure_server(bw, conf, env):
|
|
158
|
+
url = conf.get("BW_SERVER")
|
|
159
|
+
if url:
|
|
160
|
+
_run(bw, ["config", "server", url], env, check=False)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------- keyring session cache ----------
|
|
164
|
+
def _ttl_days(conf) -> int:
|
|
165
|
+
try:
|
|
166
|
+
return int(conf.get("KEYRING_TTL_DAYS", "30") or 0)
|
|
167
|
+
except ValueError:
|
|
168
|
+
return 30
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def kr_get() -> str | None:
|
|
172
|
+
if not _keyring:
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
raw = _keyring.get_password(KR_SERVICE, KR_ACCOUNT)
|
|
176
|
+
except Exception:
|
|
177
|
+
return None
|
|
178
|
+
if not raw:
|
|
179
|
+
return None
|
|
180
|
+
try:
|
|
181
|
+
d = json.loads(raw)
|
|
182
|
+
except Exception:
|
|
183
|
+
return None
|
|
184
|
+
if d.get("exp", 0) < time.time():
|
|
185
|
+
kr_clear()
|
|
186
|
+
return None
|
|
187
|
+
return d.get("session")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def kr_store(session: str, ttl_days: int):
|
|
191
|
+
if not _keyring or ttl_days <= 0 or not session:
|
|
192
|
+
return
|
|
193
|
+
try:
|
|
194
|
+
_keyring.set_password(KR_SERVICE, KR_ACCOUNT,
|
|
195
|
+
json.dumps({"session": session, "exp": time.time() + ttl_days * 86400}))
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def kr_clear():
|
|
201
|
+
if not _keyring:
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
_keyring.delete_password(KR_SERVICE, KR_ACCOUNT)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _session_ok(bw, base_env, sess) -> bool:
|
|
210
|
+
"""Validate a session by running a real op that requires an unlocked vault.
|
|
211
|
+
`bw status` is unreliable here β recent bw versions report the on-disk lock
|
|
212
|
+
state and return "locked" even with a valid session in the environment, so we
|
|
213
|
+
probe with `bw list folders` instead. stdin is closed so an invalid session
|
|
214
|
+
fails fast (returns non-zero) rather than dropping into an interactive prompt."""
|
|
215
|
+
if not sess:
|
|
216
|
+
return False
|
|
217
|
+
try:
|
|
218
|
+
r = subprocess.run([bw, "list", "folders", "--session", sess],
|
|
219
|
+
env=dict(base_env, BW_SESSION=sess),
|
|
220
|
+
capture_output=True, text=True,
|
|
221
|
+
stdin=subprocess.DEVNULL, timeout=30)
|
|
222
|
+
except Exception:
|
|
223
|
+
return False
|
|
224
|
+
return r.returncode == 0 and r.stdout.strip().startswith("[")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def session_state(conf: dict | None = None) -> str:
|
|
228
|
+
"""'ready' (cached session), 'locked' (need master pw), 'apikey' (can auto-login),
|
|
229
|
+
'login' (need one-time `bw login`)."""
|
|
230
|
+
conf = conf or load_conf()
|
|
231
|
+
try:
|
|
232
|
+
bw = find_bw()
|
|
233
|
+
except VwksError:
|
|
234
|
+
return "login"
|
|
235
|
+
base = _base_env(conf)
|
|
236
|
+
if _session_ok(bw, base, kr_get()):
|
|
237
|
+
return "ready"
|
|
238
|
+
if bw_status(bw, base) == "unauthenticated":
|
|
239
|
+
cid, csec = conf.get("BW_CLIENTID"), conf.get("BW_CLIENTSECRET")
|
|
240
|
+
return "apikey" if (cid and csec and "xxxx" not in (cid + csec)) else "login"
|
|
241
|
+
return "locked"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ensure_session(bw, base_env, conf, master_password=None) -> str:
|
|
245
|
+
sess = kr_get()
|
|
246
|
+
_dbg(f"ensure: kr_get -> {'HIT' if sess else 'miss'}; keyring={bool(_keyring)}")
|
|
247
|
+
if _session_ok(bw, base_env, sess):
|
|
248
|
+
_dbg("ensure: cached session VALID -> reuse")
|
|
249
|
+
kr_store(sess, _ttl_days(conf)) # idle-refresh
|
|
250
|
+
return sess
|
|
251
|
+
st = bw_status(bw, base_env)
|
|
252
|
+
_dbg(f"ensure: cache miss/invalid; bw status(no-session)={st}")
|
|
253
|
+
if st == "unauthenticated":
|
|
254
|
+
_ensure_server(bw, conf, base_env)
|
|
255
|
+
cid, csec = conf.get("BW_CLIENTID"), conf.get("BW_CLIENTSECRET")
|
|
256
|
+
if cid and csec and "xxxx" not in (cid + csec):
|
|
257
|
+
_run(bw, ["login", "--apikey"], dict(base_env, BW_CLIENTID=cid, BW_CLIENTSECRET=csec))
|
|
258
|
+
else:
|
|
259
|
+
raise NeedLogin("Not logged in. Run `bw login` once in a terminal, then retry.")
|
|
260
|
+
if master_password is None:
|
|
261
|
+
_dbg("ensure: NeedUnlock (no master password provided)")
|
|
262
|
+
raise NeedUnlock("Vault is locked β a master password is required.")
|
|
263
|
+
r = _run(bw, ["unlock", "--passwordenv", "BW_PASSWORD", "--raw"],
|
|
264
|
+
dict(base_env, BW_PASSWORD=master_password))
|
|
265
|
+
sess = r.stdout.strip()
|
|
266
|
+
_dbg(f"ensure: unlock done; session_len={len(sess)}")
|
|
267
|
+
if not sess:
|
|
268
|
+
raise VwksError("unlock returned no session (wrong master password?)")
|
|
269
|
+
kr_store(sess, _ttl_days(conf))
|
|
270
|
+
back = kr_get()
|
|
271
|
+
_dbg(f"ensure: kr_store done; readback={'OK' if back == sess else 'MISMATCH/none'}; "
|
|
272
|
+
f"revalidate={_session_ok(bw, base_env, sess)}")
|
|
273
|
+
return sess
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------- vault item upsert ----------
|
|
277
|
+
def _folder_id(bw, env, name):
|
|
278
|
+
if not name:
|
|
279
|
+
return None
|
|
280
|
+
r = _run(bw, ["list", "folders", "--search", name], env)
|
|
281
|
+
for f in json.loads(r.stdout or "[]"):
|
|
282
|
+
if f.get("name") == name:
|
|
283
|
+
return f["id"]
|
|
284
|
+
enc = base64.b64encode(json.dumps({"name": name}).encode()).decode()
|
|
285
|
+
return json.loads(_run(bw, ["create", "folder", enc], env).stdout)["id"]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _find_item(bw, env, name, var):
|
|
289
|
+
r = _run(bw, ["list", "items", "--search", name], env)
|
|
290
|
+
for it in json.loads(r.stdout or "[]"):
|
|
291
|
+
if it.get("name") == name and (it.get("login") or {}).get("username") in (var, None, ""):
|
|
292
|
+
return it
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _vault_upsert(bw, env, name, var, value, env_path, fid):
|
|
297
|
+
field = {"name": "env_file", "value": env_path, "type": 0, "linkedId": None}
|
|
298
|
+
existing = _find_item(bw, env, name, var)
|
|
299
|
+
if existing:
|
|
300
|
+
it = existing
|
|
301
|
+
it["login"] = {**(it.get("login") or {}), "username": var, "password": value}
|
|
302
|
+
it["fields"] = [f for f in (it.get("fields") or []) if f.get("name") != "env_file"] + [field]
|
|
303
|
+
it["folderId"] = it.get("folderId") or fid
|
|
304
|
+
enc = base64.b64encode(json.dumps(it).encode()).decode()
|
|
305
|
+
_run(bw, ["edit", "item", it["id"], enc], env)
|
|
306
|
+
return "updated"
|
|
307
|
+
it = {"organizationId": None, "collectionIds": None, "folderId": fid, "type": 1,
|
|
308
|
+
"name": name, "notes": None, "favorite": False, "fields": [field],
|
|
309
|
+
"login": {"username": var, "password": value, "totp": None, "uris": []}, "reprompt": 0}
|
|
310
|
+
enc = base64.b64encode(json.dumps(it).encode()).decode()
|
|
311
|
+
_run(bw, ["create", "item", enc], env)
|
|
312
|
+
return "created"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------- top-level ----------
|
|
316
|
+
def drop(var, value, *, project, name=None, folder=None, env_dir_=None,
|
|
317
|
+
no_vault=False, master_password=None, conf=None) -> dict:
|
|
318
|
+
"""Write the secret to the env file and (unless no_vault) upsert it into the vault.
|
|
319
|
+
May raise NeedUnlock / NeedLogin so the caller can prompt and retry."""
|
|
320
|
+
conf = conf if conf is not None else load_conf()
|
|
321
|
+
if not valid_var(var):
|
|
322
|
+
raise VwksError(f"invalid env var name: {var!r}")
|
|
323
|
+
edir = env_dir_ or env_dir(conf)
|
|
324
|
+
path, action = write_env(edir, project, var, value)
|
|
325
|
+
result = {"env_path": path, "env_action": action, "vault": None, "folder": None}
|
|
326
|
+
if no_vault:
|
|
327
|
+
return result
|
|
328
|
+
bw = find_bw()
|
|
329
|
+
base_env = _base_env(conf)
|
|
330
|
+
sess = _ensure_session(bw, base_env, conf, master_password)
|
|
331
|
+
venv = dict(base_env, BW_SESSION=sess)
|
|
332
|
+
_run(bw, ["sync"], venv, check=False)
|
|
333
|
+
folder = folder or conf.get("VAULT_FOLDER", "API Keys")
|
|
334
|
+
fid = _folder_id(bw, venv, folder)
|
|
335
|
+
result["vault"] = _vault_upsert(bw, venv, name or var, var, value, path, fid)
|
|
336
|
+
result["folder"] = folder
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def lock():
|
|
341
|
+
"""Forget the cached session and lock the vault."""
|
|
342
|
+
kr_clear()
|
|
343
|
+
try:
|
|
344
|
+
bw = find_bw()
|
|
345
|
+
_run(bw, ["lock"], _base_env(load_conf()), check=False)
|
|
346
|
+
except VwksError:
|
|
347
|
+
pass
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""VW Keystroke GUI β a small dark Tkinter window: name it, paste it, drop it.
|
|
3
|
+
|
|
4
|
+
Writes the secret to a 600 env file and (optionally) upserts it into Vaultwarden/
|
|
5
|
+
Bitwarden. Vault work runs on a worker thread so the UI never freezes.
|
|
6
|
+
"""
|
|
7
|
+
import threading
|
|
8
|
+
import tkinter as tk
|
|
9
|
+
from tkinter import ttk, messagebox, simpledialog
|
|
10
|
+
from . import core
|
|
11
|
+
|
|
12
|
+
BG = "#0f1420"
|
|
13
|
+
CARD = "#161c28"
|
|
14
|
+
FIELD = "#0b0f17"
|
|
15
|
+
FG = "#e6edf3"
|
|
16
|
+
MUTED = "#8b97a7"
|
|
17
|
+
ACCENT = "#2fbf71"
|
|
18
|
+
ACCENT_HI = "#3ad684"
|
|
19
|
+
DANGER = "#e5534b"
|
|
20
|
+
BORDER = "#243044"
|
|
21
|
+
FONT = ("Segoe UI", 11) if tk.TkVersion else ("sans-serif", 11)
|
|
22
|
+
MONO = ("JetBrains Mono", 10)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class App(tk.Tk):
|
|
26
|
+
def __init__(self):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.title("VW Keystroke")
|
|
29
|
+
self.configure(bg=BG)
|
|
30
|
+
self.resizable(False, False)
|
|
31
|
+
try:
|
|
32
|
+
self.tk.call("tk", "scaling", 1.25)
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
self.conf = core.load_conf()
|
|
36
|
+
self._busy = False
|
|
37
|
+
self._build()
|
|
38
|
+
self._refresh_status()
|
|
39
|
+
|
|
40
|
+
# ---------- UI ----------
|
|
41
|
+
def _label(self, parent, text):
|
|
42
|
+
return tk.Label(parent, text=text, bg=CARD, fg=MUTED,
|
|
43
|
+
font=("Segoe UI", 9, "bold"), anchor="w")
|
|
44
|
+
|
|
45
|
+
def _entry(self, parent, show=None, textvariable=None):
|
|
46
|
+
e = tk.Entry(parent, bg=FIELD, fg=FG, insertbackground=ACCENT,
|
|
47
|
+
relief="flat", font=("Segoe UI", 11), show=show or "",
|
|
48
|
+
highlightthickness=1, highlightbackground=BORDER,
|
|
49
|
+
highlightcolor=ACCENT, textvariable=textvariable)
|
|
50
|
+
e.configure(bd=6)
|
|
51
|
+
return e
|
|
52
|
+
|
|
53
|
+
def _build(self):
|
|
54
|
+
pad = dict(padx=22)
|
|
55
|
+
# header
|
|
56
|
+
head = tk.Frame(self, bg=BG)
|
|
57
|
+
head.pack(fill="x", pady=(18, 6), **pad)
|
|
58
|
+
tk.Label(head, text="π VW Keystroke", bg=BG, fg=FG,
|
|
59
|
+
font=("Segoe UI", 17, "bold")).pack(side="left")
|
|
60
|
+
self.status_pill = tk.Label(head, text="β¦", bg=BG, fg=MUTED,
|
|
61
|
+
font=("Segoe UI", 9, "bold"))
|
|
62
|
+
self.status_pill.pack(side="right", pady=(6, 0))
|
|
63
|
+
tk.Label(self, text="Paste a secret β local env file + your vault.",
|
|
64
|
+
bg=BG, fg=MUTED, font=("Segoe UI", 10)).pack(anchor="w", **pad)
|
|
65
|
+
|
|
66
|
+
card = tk.Frame(self, bg=CARD, highlightthickness=1,
|
|
67
|
+
highlightbackground=BORDER)
|
|
68
|
+
card.pack(fill="both", expand=True, padx=18, pady=14)
|
|
69
|
+
inner = tk.Frame(card, bg=CARD)
|
|
70
|
+
inner.pack(fill="both", expand=True, padx=18, pady=16)
|
|
71
|
+
|
|
72
|
+
# env var
|
|
73
|
+
self._label(inner, "ENV VARIABLE NAME").pack(fill="x")
|
|
74
|
+
self.var_e = self._entry(inner)
|
|
75
|
+
self.var_e.pack(fill="x", pady=(3, 12))
|
|
76
|
+
|
|
77
|
+
# project (combobox of existing + free text)
|
|
78
|
+
self._label(inner, "PROJECT β ~/.config/<project>.env").pack(fill="x")
|
|
79
|
+
edir = core.env_dir(self.conf)
|
|
80
|
+
projects = core.list_projects(edir)
|
|
81
|
+
self.proj_v = tk.StringVar(value=core.default_project(self.conf))
|
|
82
|
+
self.option_add("*TCombobox*Listbox.background", FIELD)
|
|
83
|
+
self.option_add("*TCombobox*Listbox.foreground", FG)
|
|
84
|
+
self.option_add("*TCombobox*Listbox.selectBackground", ACCENT)
|
|
85
|
+
style = ttk.Style(self)
|
|
86
|
+
try:
|
|
87
|
+
style.theme_use("clam")
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
style.configure("VW.TCombobox", fieldbackground=FIELD, background=FIELD,
|
|
91
|
+
foreground=FG, arrowcolor=MUTED, bordercolor=BORDER,
|
|
92
|
+
lightcolor=BORDER, darkcolor=BORDER)
|
|
93
|
+
self.proj_e = ttk.Combobox(inner, textvariable=self.proj_v, values=projects,
|
|
94
|
+
style="VW.TCombobox", font=("Segoe UI", 11))
|
|
95
|
+
self.proj_e.pack(fill="x", pady=(3, 12), ipady=3)
|
|
96
|
+
|
|
97
|
+
# vault item name (optional)
|
|
98
|
+
self._label(inner, "VAULT ITEM NAME (blank = same as variable)").pack(fill="x")
|
|
99
|
+
self.name_e = self._entry(inner)
|
|
100
|
+
self.name_e.pack(fill="x", pady=(3, 12))
|
|
101
|
+
|
|
102
|
+
# secret
|
|
103
|
+
self._label(inner, "SECRET VALUE").pack(fill="x")
|
|
104
|
+
row = tk.Frame(inner, bg=CARD)
|
|
105
|
+
row.pack(fill="x", pady=(3, 6))
|
|
106
|
+
self.secret_e = self._entry(row, show="β’")
|
|
107
|
+
self.secret_e.pack(side="left", fill="x", expand=True)
|
|
108
|
+
self.show_v = tk.IntVar(value=0)
|
|
109
|
+
tk.Checkbutton(row, text="show", variable=self.show_v, command=self._toggle_show,
|
|
110
|
+
bg=CARD, fg=MUTED, selectcolor=FIELD, activebackground=CARD,
|
|
111
|
+
activeforeground=FG, font=("Segoe UI", 9), bd=0,
|
|
112
|
+
highlightthickness=0).pack(side="left", padx=(8, 0))
|
|
113
|
+
|
|
114
|
+
# also-to-vault toggle
|
|
115
|
+
self.vault_v = tk.IntVar(value=1)
|
|
116
|
+
tk.Checkbutton(inner, text="Also save to Vaultwarden", variable=self.vault_v,
|
|
117
|
+
bg=CARD, fg=FG, selectcolor=FIELD, activebackground=CARD,
|
|
118
|
+
activeforeground=FG, font=("Segoe UI", 10), bd=0,
|
|
119
|
+
highlightthickness=0).pack(anchor="w", pady=(6, 10))
|
|
120
|
+
|
|
121
|
+
# buttons
|
|
122
|
+
btns = tk.Frame(inner, bg=CARD)
|
|
123
|
+
btns.pack(fill="x")
|
|
124
|
+
self.drop_b = tk.Button(btns, text="Drop it β", command=self.on_drop,
|
|
125
|
+
bg=ACCENT, fg="#062a17", activebackground=ACCENT_HI,
|
|
126
|
+
activeforeground="#062a17", relief="flat",
|
|
127
|
+
font=("Segoe UI", 11, "bold"), bd=0, padx=16, pady=8,
|
|
128
|
+
cursor="hand2")
|
|
129
|
+
self.drop_b.pack(side="left")
|
|
130
|
+
tk.Button(btns, text="Lock", command=self.on_lock, bg=CARD, fg=MUTED,
|
|
131
|
+
activebackground=CARD, activeforeground=DANGER, relief="flat",
|
|
132
|
+
font=("Segoe UI", 10), bd=0, padx=10, pady=8, cursor="hand2"
|
|
133
|
+
).pack(side="right")
|
|
134
|
+
|
|
135
|
+
# status line
|
|
136
|
+
self.status = tk.Label(self, text="", bg=BG, fg=MUTED, font=("Segoe UI", 10),
|
|
137
|
+
wraplength=420, justify="left", anchor="w")
|
|
138
|
+
self.status.pack(fill="x", padx=22, pady=(0, 16))
|
|
139
|
+
|
|
140
|
+
self.bind("<Return>", lambda e: self.on_drop())
|
|
141
|
+
self.var_e.focus_set()
|
|
142
|
+
|
|
143
|
+
def _toggle_show(self):
|
|
144
|
+
self.secret_e.configure(show="" if self.show_v.get() else "β’")
|
|
145
|
+
|
|
146
|
+
def _set_status(self, text, color=MUTED):
|
|
147
|
+
self.status.configure(text=text, fg=color)
|
|
148
|
+
|
|
149
|
+
def _refresh_status(self):
|
|
150
|
+
def work():
|
|
151
|
+
try:
|
|
152
|
+
st = core.session_state(self.conf)
|
|
153
|
+
except Exception:
|
|
154
|
+
st = "login"
|
|
155
|
+
self.after(0, lambda: self._show_pill(st))
|
|
156
|
+
threading.Thread(target=work, daemon=True).start()
|
|
157
|
+
|
|
158
|
+
def _show_pill(self, st):
|
|
159
|
+
m = {"ready": ("π unlocked", ACCENT), "apikey": ("π api-key", ACCENT),
|
|
160
|
+
"locked": ("π locked", MUTED), "login": ("β needs login", DANGER)}
|
|
161
|
+
text, color = m.get(st, ("β¦", MUTED))
|
|
162
|
+
self.status_pill.configure(text=text, fg=color)
|
|
163
|
+
|
|
164
|
+
# ---------- actions ----------
|
|
165
|
+
def on_lock(self):
|
|
166
|
+
core.lock()
|
|
167
|
+
self._set_status("Vault locked; cached session cleared.", MUTED)
|
|
168
|
+
self._refresh_status()
|
|
169
|
+
|
|
170
|
+
def on_drop(self):
|
|
171
|
+
if self._busy:
|
|
172
|
+
return
|
|
173
|
+
var = self.var_e.get().strip()
|
|
174
|
+
if not core.valid_var(var):
|
|
175
|
+
self._set_status("Enter a valid ENV variable name (letters, digits, _).", DANGER)
|
|
176
|
+
return
|
|
177
|
+
secret = self.secret_e.get()
|
|
178
|
+
if not secret:
|
|
179
|
+
self._set_status("Paste a secret value first.", DANGER)
|
|
180
|
+
return
|
|
181
|
+
project = (self.proj_v.get().strip() or core.default_project(self.conf))
|
|
182
|
+
name = self.name_e.get().strip() or None
|
|
183
|
+
no_vault = not self.vault_v.get()
|
|
184
|
+
|
|
185
|
+
mp = None
|
|
186
|
+
if not no_vault:
|
|
187
|
+
state = core.session_state(self.conf)
|
|
188
|
+
if state == "login":
|
|
189
|
+
messagebox.showinfo(
|
|
190
|
+
"One-time login",
|
|
191
|
+
"Not logged in yet. Run this once in a terminal:\n\n bw login\n\n"
|
|
192
|
+
"(email + master password + 2FA). Then come back and Drop it.")
|
|
193
|
+
return
|
|
194
|
+
if state == "locked":
|
|
195
|
+
mp = simpledialog.askstring("Unlock vault", "Vault master password:",
|
|
196
|
+
show="β’", parent=self)
|
|
197
|
+
if not mp:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
self._busy = True
|
|
201
|
+
self.drop_b.configure(state="disabled", text="Workingβ¦")
|
|
202
|
+
self._set_status("Workingβ¦", MUTED)
|
|
203
|
+
|
|
204
|
+
def work():
|
|
205
|
+
try:
|
|
206
|
+
res = core.drop(var, secret, project=project, name=name,
|
|
207
|
+
no_vault=no_vault, master_password=mp, conf=self.conf)
|
|
208
|
+
self.after(0, lambda: self._done(res))
|
|
209
|
+
except core.NeedUnlock:
|
|
210
|
+
self.after(0, lambda: self._fail("Wrong or missing master password."))
|
|
211
|
+
except core.NeedLogin as e:
|
|
212
|
+
self.after(0, lambda: self._fail(str(e)))
|
|
213
|
+
except core.VwksError as e:
|
|
214
|
+
self.after(0, lambda: self._fail(str(e)))
|
|
215
|
+
except Exception as e: # pragma: no cover
|
|
216
|
+
self.after(0, lambda: self._fail(f"Unexpected error: {e}"))
|
|
217
|
+
|
|
218
|
+
threading.Thread(target=work, daemon=True).start()
|
|
219
|
+
|
|
220
|
+
def _done(self, res):
|
|
221
|
+
self._busy = False
|
|
222
|
+
self.drop_b.configure(state="normal", text="Drop it β")
|
|
223
|
+
msg = f"β {res['env_action']} in {res['env_path']}"
|
|
224
|
+
if res["vault"]:
|
|
225
|
+
msg += f"\nβ vault: {res['vault']} in β{res['folder']}β"
|
|
226
|
+
self._set_status(msg, ACCENT)
|
|
227
|
+
self.secret_e.delete(0, "end")
|
|
228
|
+
self.var_e.delete(0, "end")
|
|
229
|
+
self.show_v.set(0)
|
|
230
|
+
self._toggle_show()
|
|
231
|
+
# refresh project dropdown + pill
|
|
232
|
+
self.proj_e.configure(values=core.list_projects(core.env_dir(self.conf)))
|
|
233
|
+
self.var_e.focus_set()
|
|
234
|
+
self._refresh_status()
|
|
235
|
+
|
|
236
|
+
def _fail(self, text):
|
|
237
|
+
self._busy = False
|
|
238
|
+
self.drop_b.configure(state="normal", text="Drop it β")
|
|
239
|
+
self._set_status("β " + text, DANGER)
|
|
240
|
+
self._refresh_status()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main():
|
|
244
|
+
try:
|
|
245
|
+
app = App()
|
|
246
|
+
except tk.TclError as e:
|
|
247
|
+
raise SystemExit(f"VW Keystroke GUI needs a display: {e}")
|
|
248
|
+
app.mainloop()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|