basic-password-manager 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.
- basic_password_manager-0.1.0/LICENSE +21 -0
- basic_password_manager-0.1.0/PKG-INFO +183 -0
- basic_password_manager-0.1.0/README.md +170 -0
- basic_password_manager-0.1.0/basic_password_manager.egg-info/PKG-INFO +183 -0
- basic_password_manager-0.1.0/basic_password_manager.egg-info/SOURCES.txt +13 -0
- basic_password_manager-0.1.0/basic_password_manager.egg-info/dependency_links.txt +1 -0
- basic_password_manager-0.1.0/basic_password_manager.egg-info/entry_points.txt +2 -0
- basic_password_manager-0.1.0/basic_password_manager.egg-info/requires.txt +2 -0
- basic_password_manager-0.1.0/basic_password_manager.egg-info/top_level.txt +1 -0
- basic_password_manager-0.1.0/pw_manager/__init__.py +0 -0
- basic_password_manager-0.1.0/pw_manager/cli.py +174 -0
- basic_password_manager-0.1.0/pw_manager/crypto.py +34 -0
- basic_password_manager-0.1.0/pw_manager/vault.py +51 -0
- basic_password_manager-0.1.0/pyproject.toml +21 -0
- basic_password_manager-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Darsh Thakur (Daisentaur)
|
|
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,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: basic-password-manager
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local encrypted password manager that lives in your terminal
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Daisentaur/password-manager
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: argon2-cffi
|
|
11
|
+
Requires-Dist: cryptography
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# pw-manager
|
|
15
|
+
|
|
16
|
+
A local, encrypted password manager that lives in your terminal. No browser,
|
|
17
|
+
no cloud, no daemon — one encrypted file on disk and a small Python CLI in
|
|
18
|
+
front of it.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$ python pw.py get github
|
|
22
|
+
master password:
|
|
23
|
+
username: dt@example.com
|
|
24
|
+
password copied to clipboard
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
From PyPI (recommended — [pipx](https://pipx.pypa.io) keeps CLI tools isolated):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install basic-password-manager # or: pip install basic-password-manager
|
|
33
|
+
pw init
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
From source:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone https://github.com/Daisentaur/password-manager && cd password-manager
|
|
40
|
+
pipx install .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
| Command | What it does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `pw init` | create a new empty vault |
|
|
50
|
+
| `pw add github` | store an entry (prompts for username/password/notes) |
|
|
51
|
+
| `pw add github --gen` | same, but generates a strong random password |
|
|
52
|
+
| `pw get github` | copy the password to the clipboard, show the username |
|
|
53
|
+
| `pw ls` | list entry names |
|
|
54
|
+
| `pw rm github` | delete an entry |
|
|
55
|
+
| `pw find bank` | search entry names, usernames, and notes |
|
|
56
|
+
| `pw gen -l 32` | just print a random password |
|
|
57
|
+
| `pw import passwords.csv` | import a browser CSV export (see below) |
|
|
58
|
+
|
|
59
|
+
The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
|
|
60
|
+
environment variable to put it somewhere else.
|
|
61
|
+
|
|
62
|
+
The `notes` field takes any text you want kept secret alongside the password —
|
|
63
|
+
recovery codes, PINs, the answer to "what was your first pet".
|
|
64
|
+
|
|
65
|
+
### Moving off browser password storage
|
|
66
|
+
|
|
67
|
+
1. Export: Chrome → `chrome://password-manager/settings` → "Export passwords".
|
|
68
|
+
Firefox → `about:logins` → ⋯ menu → "Export passwords". Both produce a CSV.
|
|
69
|
+
2. `pw import passwords.csv`
|
|
70
|
+
3. **Delete the CSV** — it is every password you own in plaintext:
|
|
71
|
+
`shred -u passwords.csv`
|
|
72
|
+
4. Turn off saving and delete saved passwords in the browser settings.
|
|
73
|
+
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
Three layers, ~120 lines total. Read them in this order:
|
|
77
|
+
|
|
78
|
+
### 1. `crypto.py` — password → key, key → ciphertext
|
|
79
|
+
|
|
80
|
+
An AES key must be 32 unpredictable bytes; your master password is neither.
|
|
81
|
+
**Argon2id** bridges the gap: `derive_key(password, salt)` always returns the
|
|
82
|
+
same 32 bytes for the same inputs — that's what makes unlocking possible.
|
|
83
|
+
It is deliberately slow and memory-hungry (64 MiB per guess), so an attacker
|
|
84
|
+
who steals the vault file can't brute-force short passwords on a GPU the way
|
|
85
|
+
they could against a plain hash.
|
|
86
|
+
|
|
87
|
+
The **salt** is 16 random bytes stored *unencrypted* in the vault file. It's
|
|
88
|
+
not a secret: its job is making your derived key unique so precomputed
|
|
89
|
+
attack tables are useless.
|
|
90
|
+
|
|
91
|
+
Encryption is **AES-256-GCM**, which is *authenticated*: decrypting with the
|
|
92
|
+
wrong key, or decrypting data that was modified by even one bit, fails loudly
|
|
93
|
+
instead of returning garbage. Each encryption uses a fresh random 12-byte
|
|
94
|
+
**nonce** (also stored unencrypted — also not a secret). The one iron rule of
|
|
95
|
+
GCM is that a (key, nonce) pair must never repeat, which is why it's random
|
|
96
|
+
every time.
|
|
97
|
+
|
|
98
|
+
### 2. `vault.py` — the file format
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
[4 bytes "PWV1"][16-byte salt][12-byte nonce][ciphertext...]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The ciphertext is just your entries as JSON, encrypted. Load = read →
|
|
105
|
+
derive key → decrypt → `json.loads`. Save = the reverse, with a fresh salt
|
|
106
|
+
and nonce, written to a temp file and atomically renamed so a crash mid-write
|
|
107
|
+
can't destroy the vault.
|
|
108
|
+
|
|
109
|
+
Your master password is never stored anywhere, in any form. "Wrong password"
|
|
110
|
+
is detected purely by GCM authentication failing.
|
|
111
|
+
|
|
112
|
+
### 3. `cli.py` — the CLI
|
|
113
|
+
|
|
114
|
+
argparse subcommands over the two modules above. Passwords are copied to the
|
|
115
|
+
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
116
|
+
so they don't sit in your terminal scrollback. `find` searches names,
|
|
117
|
+
usernames, and notes — deliberately *not* passwords, so fragments of secrets
|
|
118
|
+
never land in your shell history.
|
|
119
|
+
|
|
120
|
+
## Backups
|
|
121
|
+
|
|
122
|
+
Every save automatically keeps the previous version as `vault.bak` next to
|
|
123
|
+
the vault — one-step undo if a save goes wrong or you delete the wrong entry.
|
|
124
|
+
|
|
125
|
+
That protects against mistakes, not against the disk dying. For that, the
|
|
126
|
+
vault is one file: copy it anywhere — another disk, a USB stick, even
|
|
127
|
+
somewhere untrusted, since it's useless without the master password.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cp ~/.local/share/pw-manager/vault /some/backup/location/
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Troubleshooting
|
|
134
|
+
|
|
135
|
+
**"I forgot the master password."** The data is gone. Not "gone until support
|
|
136
|
+
resets it" — mathematically gone; that's the entire design. This is why you
|
|
137
|
+
keep the master password memorable (a long passphrase beats `Tr0ub4dor&3`)
|
|
138
|
+
and why backups protect you from file loss but not from forgetting.
|
|
139
|
+
|
|
140
|
+
**"wrong master password (or the vault file is corrupted)"** — 99% of the
|
|
141
|
+
time it's a typo'd password. If you're *certain* the password is right, the
|
|
142
|
+
file was damaged; restore the automatic backup
|
|
143
|
+
(`cp ~/.local/share/pw-manager/vault.bak ~/.local/share/pw-manager/vault`)
|
|
144
|
+
or an off-machine copy.
|
|
145
|
+
|
|
146
|
+
**Deleted or overwrote an entry by mistake** — the state before your last
|
|
147
|
+
save is in `vault.bak`; restore it as above. Only one generation is kept, so
|
|
148
|
+
do it before the next save.
|
|
149
|
+
|
|
150
|
+
**"not a vault file (bad magic bytes)"** — the file at the vault path isn't
|
|
151
|
+
one of ours (truncated, overwritten, or wrong path). Check `echo $PW_VAULT`
|
|
152
|
+
and restore from a backup.
|
|
153
|
+
|
|
154
|
+
**"no vault at ..."** — run `pw init`, or `PW_VAULT` points somewhere
|
|
155
|
+
unexpected.
|
|
156
|
+
|
|
157
|
+
**Password gets printed instead of copied** — no clipboard tool was found.
|
|
158
|
+
Install one: `sudo apt install wl-clipboard` (Wayland) or `xclip` (X11).
|
|
159
|
+
|
|
160
|
+
**Unlock feels slow (~1s)** — that's Argon2 doing its job; the delay is the
|
|
161
|
+
brute-force resistance. It's per-command, not per-keystroke.
|
|
162
|
+
|
|
163
|
+
**`MemoryError` from Argon2** — the KDF needs 64 MiB free; something is
|
|
164
|
+
eating your RAM.
|
|
165
|
+
|
|
166
|
+
**Import brought in junk entries** — browser CSVs include everything, even
|
|
167
|
+
ancient accounts. `pw rm <name>` the ones you don't want, or edit the CSV
|
|
168
|
+
before importing.
|
|
169
|
+
|
|
170
|
+
## Honest limitations
|
|
171
|
+
|
|
172
|
+
- No clipboard auto-clear: the password stays on the clipboard until you copy
|
|
173
|
+
over it. Copy something else when you're done.
|
|
174
|
+
- While a command runs, decrypted data briefly exists in process memory.
|
|
175
|
+
Malware already on your machine could read it — true of every password
|
|
176
|
+
manager; the vault protects the file at rest, not a compromised machine.
|
|
177
|
+
- Single file, no sync. Syncing is your problem (and `cp` is a fine answer).
|
|
178
|
+
|
|
179
|
+
## Running the checks
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
./venv/bin/python test_vault.py
|
|
183
|
+
```
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# pw-manager
|
|
2
|
+
|
|
3
|
+
A local, encrypted password manager that lives in your terminal. No browser,
|
|
4
|
+
no cloud, no daemon — one encrypted file on disk and a small Python CLI in
|
|
5
|
+
front of it.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
$ python pw.py get github
|
|
9
|
+
master password:
|
|
10
|
+
username: dt@example.com
|
|
11
|
+
password copied to clipboard
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
From PyPI (recommended — [pipx](https://pipx.pypa.io) keeps CLI tools isolated):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pipx install basic-password-manager # or: pip install basic-password-manager
|
|
20
|
+
pw init
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
From source:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/Daisentaur/password-manager && cd password-manager
|
|
27
|
+
pipx install .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
| Command | What it does |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `pw init` | create a new empty vault |
|
|
37
|
+
| `pw add github` | store an entry (prompts for username/password/notes) |
|
|
38
|
+
| `pw add github --gen` | same, but generates a strong random password |
|
|
39
|
+
| `pw get github` | copy the password to the clipboard, show the username |
|
|
40
|
+
| `pw ls` | list entry names |
|
|
41
|
+
| `pw rm github` | delete an entry |
|
|
42
|
+
| `pw find bank` | search entry names, usernames, and notes |
|
|
43
|
+
| `pw gen -l 32` | just print a random password |
|
|
44
|
+
| `pw import passwords.csv` | import a browser CSV export (see below) |
|
|
45
|
+
|
|
46
|
+
The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
|
|
47
|
+
environment variable to put it somewhere else.
|
|
48
|
+
|
|
49
|
+
The `notes` field takes any text you want kept secret alongside the password —
|
|
50
|
+
recovery codes, PINs, the answer to "what was your first pet".
|
|
51
|
+
|
|
52
|
+
### Moving off browser password storage
|
|
53
|
+
|
|
54
|
+
1. Export: Chrome → `chrome://password-manager/settings` → "Export passwords".
|
|
55
|
+
Firefox → `about:logins` → ⋯ menu → "Export passwords". Both produce a CSV.
|
|
56
|
+
2. `pw import passwords.csv`
|
|
57
|
+
3. **Delete the CSV** — it is every password you own in plaintext:
|
|
58
|
+
`shred -u passwords.csv`
|
|
59
|
+
4. Turn off saving and delete saved passwords in the browser settings.
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
Three layers, ~120 lines total. Read them in this order:
|
|
64
|
+
|
|
65
|
+
### 1. `crypto.py` — password → key, key → ciphertext
|
|
66
|
+
|
|
67
|
+
An AES key must be 32 unpredictable bytes; your master password is neither.
|
|
68
|
+
**Argon2id** bridges the gap: `derive_key(password, salt)` always returns the
|
|
69
|
+
same 32 bytes for the same inputs — that's what makes unlocking possible.
|
|
70
|
+
It is deliberately slow and memory-hungry (64 MiB per guess), so an attacker
|
|
71
|
+
who steals the vault file can't brute-force short passwords on a GPU the way
|
|
72
|
+
they could against a plain hash.
|
|
73
|
+
|
|
74
|
+
The **salt** is 16 random bytes stored *unencrypted* in the vault file. It's
|
|
75
|
+
not a secret: its job is making your derived key unique so precomputed
|
|
76
|
+
attack tables are useless.
|
|
77
|
+
|
|
78
|
+
Encryption is **AES-256-GCM**, which is *authenticated*: decrypting with the
|
|
79
|
+
wrong key, or decrypting data that was modified by even one bit, fails loudly
|
|
80
|
+
instead of returning garbage. Each encryption uses a fresh random 12-byte
|
|
81
|
+
**nonce** (also stored unencrypted — also not a secret). The one iron rule of
|
|
82
|
+
GCM is that a (key, nonce) pair must never repeat, which is why it's random
|
|
83
|
+
every time.
|
|
84
|
+
|
|
85
|
+
### 2. `vault.py` — the file format
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
[4 bytes "PWV1"][16-byte salt][12-byte nonce][ciphertext...]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The ciphertext is just your entries as JSON, encrypted. Load = read →
|
|
92
|
+
derive key → decrypt → `json.loads`. Save = the reverse, with a fresh salt
|
|
93
|
+
and nonce, written to a temp file and atomically renamed so a crash mid-write
|
|
94
|
+
can't destroy the vault.
|
|
95
|
+
|
|
96
|
+
Your master password is never stored anywhere, in any form. "Wrong password"
|
|
97
|
+
is detected purely by GCM authentication failing.
|
|
98
|
+
|
|
99
|
+
### 3. `cli.py` — the CLI
|
|
100
|
+
|
|
101
|
+
argparse subcommands over the two modules above. Passwords are copied to the
|
|
102
|
+
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
103
|
+
so they don't sit in your terminal scrollback. `find` searches names,
|
|
104
|
+
usernames, and notes — deliberately *not* passwords, so fragments of secrets
|
|
105
|
+
never land in your shell history.
|
|
106
|
+
|
|
107
|
+
## Backups
|
|
108
|
+
|
|
109
|
+
Every save automatically keeps the previous version as `vault.bak` next to
|
|
110
|
+
the vault — one-step undo if a save goes wrong or you delete the wrong entry.
|
|
111
|
+
|
|
112
|
+
That protects against mistakes, not against the disk dying. For that, the
|
|
113
|
+
vault is one file: copy it anywhere — another disk, a USB stick, even
|
|
114
|
+
somewhere untrusted, since it's useless without the master password.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
cp ~/.local/share/pw-manager/vault /some/backup/location/
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Troubleshooting
|
|
121
|
+
|
|
122
|
+
**"I forgot the master password."** The data is gone. Not "gone until support
|
|
123
|
+
resets it" — mathematically gone; that's the entire design. This is why you
|
|
124
|
+
keep the master password memorable (a long passphrase beats `Tr0ub4dor&3`)
|
|
125
|
+
and why backups protect you from file loss but not from forgetting.
|
|
126
|
+
|
|
127
|
+
**"wrong master password (or the vault file is corrupted)"** — 99% of the
|
|
128
|
+
time it's a typo'd password. If you're *certain* the password is right, the
|
|
129
|
+
file was damaged; restore the automatic backup
|
|
130
|
+
(`cp ~/.local/share/pw-manager/vault.bak ~/.local/share/pw-manager/vault`)
|
|
131
|
+
or an off-machine copy.
|
|
132
|
+
|
|
133
|
+
**Deleted or overwrote an entry by mistake** — the state before your last
|
|
134
|
+
save is in `vault.bak`; restore it as above. Only one generation is kept, so
|
|
135
|
+
do it before the next save.
|
|
136
|
+
|
|
137
|
+
**"not a vault file (bad magic bytes)"** — the file at the vault path isn't
|
|
138
|
+
one of ours (truncated, overwritten, or wrong path). Check `echo $PW_VAULT`
|
|
139
|
+
and restore from a backup.
|
|
140
|
+
|
|
141
|
+
**"no vault at ..."** — run `pw init`, or `PW_VAULT` points somewhere
|
|
142
|
+
unexpected.
|
|
143
|
+
|
|
144
|
+
**Password gets printed instead of copied** — no clipboard tool was found.
|
|
145
|
+
Install one: `sudo apt install wl-clipboard` (Wayland) or `xclip` (X11).
|
|
146
|
+
|
|
147
|
+
**Unlock feels slow (~1s)** — that's Argon2 doing its job; the delay is the
|
|
148
|
+
brute-force resistance. It's per-command, not per-keystroke.
|
|
149
|
+
|
|
150
|
+
**`MemoryError` from Argon2** — the KDF needs 64 MiB free; something is
|
|
151
|
+
eating your RAM.
|
|
152
|
+
|
|
153
|
+
**Import brought in junk entries** — browser CSVs include everything, even
|
|
154
|
+
ancient accounts. `pw rm <name>` the ones you don't want, or edit the CSV
|
|
155
|
+
before importing.
|
|
156
|
+
|
|
157
|
+
## Honest limitations
|
|
158
|
+
|
|
159
|
+
- No clipboard auto-clear: the password stays on the clipboard until you copy
|
|
160
|
+
over it. Copy something else when you're done.
|
|
161
|
+
- While a command runs, decrypted data briefly exists in process memory.
|
|
162
|
+
Malware already on your machine could read it — true of every password
|
|
163
|
+
manager; the vault protects the file at rest, not a compromised machine.
|
|
164
|
+
- Single file, no sync. Syncing is your problem (and `cp` is a fine answer).
|
|
165
|
+
|
|
166
|
+
## Running the checks
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
./venv/bin/python test_vault.py
|
|
170
|
+
```
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: basic-password-manager
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local encrypted password manager that lives in your terminal
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/Daisentaur/password-manager
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: argon2-cffi
|
|
11
|
+
Requires-Dist: cryptography
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# pw-manager
|
|
15
|
+
|
|
16
|
+
A local, encrypted password manager that lives in your terminal. No browser,
|
|
17
|
+
no cloud, no daemon — one encrypted file on disk and a small Python CLI in
|
|
18
|
+
front of it.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$ python pw.py get github
|
|
22
|
+
master password:
|
|
23
|
+
username: dt@example.com
|
|
24
|
+
password copied to clipboard
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
From PyPI (recommended — [pipx](https://pipx.pypa.io) keeps CLI tools isolated):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pipx install basic-password-manager # or: pip install basic-password-manager
|
|
33
|
+
pw init
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
From source:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone https://github.com/Daisentaur/password-manager && cd password-manager
|
|
40
|
+
pipx install .
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For development: `python3 -m venv venv && ./venv/bin/pip install -e .`
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
| Command | What it does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `pw init` | create a new empty vault |
|
|
50
|
+
| `pw add github` | store an entry (prompts for username/password/notes) |
|
|
51
|
+
| `pw add github --gen` | same, but generates a strong random password |
|
|
52
|
+
| `pw get github` | copy the password to the clipboard, show the username |
|
|
53
|
+
| `pw ls` | list entry names |
|
|
54
|
+
| `pw rm github` | delete an entry |
|
|
55
|
+
| `pw find bank` | search entry names, usernames, and notes |
|
|
56
|
+
| `pw gen -l 32` | just print a random password |
|
|
57
|
+
| `pw import passwords.csv` | import a browser CSV export (see below) |
|
|
58
|
+
|
|
59
|
+
The vault lives at `~/.local/share/pw-manager/vault`. Set the `PW_VAULT`
|
|
60
|
+
environment variable to put it somewhere else.
|
|
61
|
+
|
|
62
|
+
The `notes` field takes any text you want kept secret alongside the password —
|
|
63
|
+
recovery codes, PINs, the answer to "what was your first pet".
|
|
64
|
+
|
|
65
|
+
### Moving off browser password storage
|
|
66
|
+
|
|
67
|
+
1. Export: Chrome → `chrome://password-manager/settings` → "Export passwords".
|
|
68
|
+
Firefox → `about:logins` → ⋯ menu → "Export passwords". Both produce a CSV.
|
|
69
|
+
2. `pw import passwords.csv`
|
|
70
|
+
3. **Delete the CSV** — it is every password you own in plaintext:
|
|
71
|
+
`shred -u passwords.csv`
|
|
72
|
+
4. Turn off saving and delete saved passwords in the browser settings.
|
|
73
|
+
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
Three layers, ~120 lines total. Read them in this order:
|
|
77
|
+
|
|
78
|
+
### 1. `crypto.py` — password → key, key → ciphertext
|
|
79
|
+
|
|
80
|
+
An AES key must be 32 unpredictable bytes; your master password is neither.
|
|
81
|
+
**Argon2id** bridges the gap: `derive_key(password, salt)` always returns the
|
|
82
|
+
same 32 bytes for the same inputs — that's what makes unlocking possible.
|
|
83
|
+
It is deliberately slow and memory-hungry (64 MiB per guess), so an attacker
|
|
84
|
+
who steals the vault file can't brute-force short passwords on a GPU the way
|
|
85
|
+
they could against a plain hash.
|
|
86
|
+
|
|
87
|
+
The **salt** is 16 random bytes stored *unencrypted* in the vault file. It's
|
|
88
|
+
not a secret: its job is making your derived key unique so precomputed
|
|
89
|
+
attack tables are useless.
|
|
90
|
+
|
|
91
|
+
Encryption is **AES-256-GCM**, which is *authenticated*: decrypting with the
|
|
92
|
+
wrong key, or decrypting data that was modified by even one bit, fails loudly
|
|
93
|
+
instead of returning garbage. Each encryption uses a fresh random 12-byte
|
|
94
|
+
**nonce** (also stored unencrypted — also not a secret). The one iron rule of
|
|
95
|
+
GCM is that a (key, nonce) pair must never repeat, which is why it's random
|
|
96
|
+
every time.
|
|
97
|
+
|
|
98
|
+
### 2. `vault.py` — the file format
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
[4 bytes "PWV1"][16-byte salt][12-byte nonce][ciphertext...]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The ciphertext is just your entries as JSON, encrypted. Load = read →
|
|
105
|
+
derive key → decrypt → `json.loads`. Save = the reverse, with a fresh salt
|
|
106
|
+
and nonce, written to a temp file and atomically renamed so a crash mid-write
|
|
107
|
+
can't destroy the vault.
|
|
108
|
+
|
|
109
|
+
Your master password is never stored anywhere, in any form. "Wrong password"
|
|
110
|
+
is detected purely by GCM authentication failing.
|
|
111
|
+
|
|
112
|
+
### 3. `cli.py` — the CLI
|
|
113
|
+
|
|
114
|
+
argparse subcommands over the two modules above. Passwords are copied to the
|
|
115
|
+
clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists) rather than printed,
|
|
116
|
+
so they don't sit in your terminal scrollback. `find` searches names,
|
|
117
|
+
usernames, and notes — deliberately *not* passwords, so fragments of secrets
|
|
118
|
+
never land in your shell history.
|
|
119
|
+
|
|
120
|
+
## Backups
|
|
121
|
+
|
|
122
|
+
Every save automatically keeps the previous version as `vault.bak` next to
|
|
123
|
+
the vault — one-step undo if a save goes wrong or you delete the wrong entry.
|
|
124
|
+
|
|
125
|
+
That protects against mistakes, not against the disk dying. For that, the
|
|
126
|
+
vault is one file: copy it anywhere — another disk, a USB stick, even
|
|
127
|
+
somewhere untrusted, since it's useless without the master password.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
cp ~/.local/share/pw-manager/vault /some/backup/location/
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Troubleshooting
|
|
134
|
+
|
|
135
|
+
**"I forgot the master password."** The data is gone. Not "gone until support
|
|
136
|
+
resets it" — mathematically gone; that's the entire design. This is why you
|
|
137
|
+
keep the master password memorable (a long passphrase beats `Tr0ub4dor&3`)
|
|
138
|
+
and why backups protect you from file loss but not from forgetting.
|
|
139
|
+
|
|
140
|
+
**"wrong master password (or the vault file is corrupted)"** — 99% of the
|
|
141
|
+
time it's a typo'd password. If you're *certain* the password is right, the
|
|
142
|
+
file was damaged; restore the automatic backup
|
|
143
|
+
(`cp ~/.local/share/pw-manager/vault.bak ~/.local/share/pw-manager/vault`)
|
|
144
|
+
or an off-machine copy.
|
|
145
|
+
|
|
146
|
+
**Deleted or overwrote an entry by mistake** — the state before your last
|
|
147
|
+
save is in `vault.bak`; restore it as above. Only one generation is kept, so
|
|
148
|
+
do it before the next save.
|
|
149
|
+
|
|
150
|
+
**"not a vault file (bad magic bytes)"** — the file at the vault path isn't
|
|
151
|
+
one of ours (truncated, overwritten, or wrong path). Check `echo $PW_VAULT`
|
|
152
|
+
and restore from a backup.
|
|
153
|
+
|
|
154
|
+
**"no vault at ..."** — run `pw init`, or `PW_VAULT` points somewhere
|
|
155
|
+
unexpected.
|
|
156
|
+
|
|
157
|
+
**Password gets printed instead of copied** — no clipboard tool was found.
|
|
158
|
+
Install one: `sudo apt install wl-clipboard` (Wayland) or `xclip` (X11).
|
|
159
|
+
|
|
160
|
+
**Unlock feels slow (~1s)** — that's Argon2 doing its job; the delay is the
|
|
161
|
+
brute-force resistance. It's per-command, not per-keystroke.
|
|
162
|
+
|
|
163
|
+
**`MemoryError` from Argon2** — the KDF needs 64 MiB free; something is
|
|
164
|
+
eating your RAM.
|
|
165
|
+
|
|
166
|
+
**Import brought in junk entries** — browser CSVs include everything, even
|
|
167
|
+
ancient accounts. `pw rm <name>` the ones you don't want, or edit the CSV
|
|
168
|
+
before importing.
|
|
169
|
+
|
|
170
|
+
## Honest limitations
|
|
171
|
+
|
|
172
|
+
- No clipboard auto-clear: the password stays on the clipboard until you copy
|
|
173
|
+
over it. Copy something else when you're done.
|
|
174
|
+
- While a command runs, decrypted data briefly exists in process memory.
|
|
175
|
+
Malware already on your machine could read it — true of every password
|
|
176
|
+
manager; the vault protects the file at rest, not a compromised machine.
|
|
177
|
+
- Single file, no sync. Syncing is your problem (and `cp` is a fine answer).
|
|
178
|
+
|
|
179
|
+
## Running the checks
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
./venv/bin/python test_vault.py
|
|
183
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
basic_password_manager.egg-info/PKG-INFO
|
|
5
|
+
basic_password_manager.egg-info/SOURCES.txt
|
|
6
|
+
basic_password_manager.egg-info/dependency_links.txt
|
|
7
|
+
basic_password_manager.egg-info/entry_points.txt
|
|
8
|
+
basic_password_manager.egg-info/requires.txt
|
|
9
|
+
basic_password_manager.egg-info/top_level.txt
|
|
10
|
+
pw_manager/__init__.py
|
|
11
|
+
pw_manager/cli.py
|
|
12
|
+
pw_manager/crypto.py
|
|
13
|
+
pw_manager/vault.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pw_manager
|
|
File without changes
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""CLI for the vault. Installed as the `pw` command."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
import getpass
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import shutil
|
|
9
|
+
import string
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
from . import vault
|
|
15
|
+
|
|
16
|
+
VAULT_PATH = os.environ.get(
|
|
17
|
+
"PW_VAULT", os.path.expanduser("~/.local/share/pw-manager/vault")
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate(length: int = 20) -> str:
|
|
22
|
+
alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_=+"
|
|
23
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_clipboard(text: str) -> bool:
|
|
27
|
+
# ponytail: no auto-clear timer; clear manually or copy something else over it
|
|
28
|
+
for cmd in (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "-b"]):
|
|
29
|
+
if shutil.which(cmd[0]):
|
|
30
|
+
subprocess.run(cmd, input=text.encode(), check=True)
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def ask_master(confirm: bool = False) -> str:
|
|
36
|
+
pw = getpass.getpass("master password: ")
|
|
37
|
+
if confirm:
|
|
38
|
+
if getpass.getpass("confirm master password: ") != pw:
|
|
39
|
+
sys.exit("passwords do not match")
|
|
40
|
+
if len(pw) < 8:
|
|
41
|
+
sys.exit("master password must be at least 8 characters")
|
|
42
|
+
return pw
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_init(args):
|
|
46
|
+
if os.path.exists(VAULT_PATH):
|
|
47
|
+
sys.exit(f"vault already exists at {VAULT_PATH}")
|
|
48
|
+
os.makedirs(os.path.dirname(VAULT_PATH), exist_ok=True)
|
|
49
|
+
vault.save(VAULT_PATH, ask_master(confirm=True), {})
|
|
50
|
+
print(f"empty vault created at {VAULT_PATH}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_add(args):
|
|
54
|
+
master = ask_master()
|
|
55
|
+
entries = vault.load(VAULT_PATH, master)
|
|
56
|
+
if args.name in entries and input(f"'{args.name}' exists, overwrite? [y/N] ").lower() != "y":
|
|
57
|
+
sys.exit("aborted")
|
|
58
|
+
user = input("username: ")
|
|
59
|
+
if args.gen:
|
|
60
|
+
pw = generate()
|
|
61
|
+
print("generated a 20-character password")
|
|
62
|
+
else:
|
|
63
|
+
pw = getpass.getpass("password: ")
|
|
64
|
+
notes = input("notes (optional): ")
|
|
65
|
+
entries[args.name] = {"user": user, "pw": pw, "notes": notes}
|
|
66
|
+
vault.save(VAULT_PATH, master, entries)
|
|
67
|
+
print(f"stored '{args.name}'")
|
|
68
|
+
if args.gen and to_clipboard(pw):
|
|
69
|
+
print("password copied to clipboard")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_get(args):
|
|
73
|
+
entries = vault.load(VAULT_PATH, ask_master())
|
|
74
|
+
if args.name not in entries:
|
|
75
|
+
sys.exit(f"no entry '{args.name}' — try 'ls'")
|
|
76
|
+
e = entries[args.name]
|
|
77
|
+
print(f"username: {e['user']}")
|
|
78
|
+
if e.get("notes"):
|
|
79
|
+
print(f"notes: {e['notes']}")
|
|
80
|
+
if to_clipboard(e["pw"]):
|
|
81
|
+
print("password copied to clipboard")
|
|
82
|
+
else:
|
|
83
|
+
# no clipboard tool found; showing it is the only option left
|
|
84
|
+
print(f"password: {e['pw']}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_ls(args):
|
|
88
|
+
entries = vault.load(VAULT_PATH, ask_master())
|
|
89
|
+
for name in sorted(entries):
|
|
90
|
+
print(name)
|
|
91
|
+
if not entries:
|
|
92
|
+
print("(vault is empty)")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_rm(args):
|
|
96
|
+
master = ask_master()
|
|
97
|
+
entries = vault.load(VAULT_PATH, master)
|
|
98
|
+
if args.name not in entries:
|
|
99
|
+
sys.exit(f"no entry '{args.name}'")
|
|
100
|
+
del entries[args.name]
|
|
101
|
+
vault.save(VAULT_PATH, master, entries)
|
|
102
|
+
print(f"removed '{args.name}'")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_gen(args):
|
|
106
|
+
print(generate(args.length))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_find(args):
|
|
110
|
+
"""Substring search over names, usernames, notes — deliberately not over
|
|
111
|
+
passwords: a password fragment typed as an argument lands in shell history."""
|
|
112
|
+
entries = vault.load(VAULT_PATH, ask_master())
|
|
113
|
+
term = args.term.lower()
|
|
114
|
+
hits = {
|
|
115
|
+
name: e
|
|
116
|
+
for name, e in sorted(entries.items())
|
|
117
|
+
if term in name.lower()
|
|
118
|
+
or term in e["user"].lower()
|
|
119
|
+
or term in e.get("notes", "").lower()
|
|
120
|
+
}
|
|
121
|
+
for name, e in hits.items():
|
|
122
|
+
print(f"{name} ({e['user']})")
|
|
123
|
+
if not hits:
|
|
124
|
+
sys.exit(f"nothing matches '{args.term}'")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_import(args):
|
|
128
|
+
"""Browser CSV export (Chrome: name,url,username,password / Firefox: url,...)."""
|
|
129
|
+
master = ask_master()
|
|
130
|
+
entries = vault.load(VAULT_PATH, master)
|
|
131
|
+
added = 0
|
|
132
|
+
with open(args.csv_file, newline="") as f:
|
|
133
|
+
for row in csv.DictReader(f):
|
|
134
|
+
name = row.get("name") or urlparse(row.get("url", "")).netloc
|
|
135
|
+
pw = row.get("password")
|
|
136
|
+
if not name or not pw:
|
|
137
|
+
continue
|
|
138
|
+
entries[name] = {"user": row.get("username", ""), "pw": pw, "notes": ""}
|
|
139
|
+
added += 1
|
|
140
|
+
vault.save(VAULT_PATH, master, entries)
|
|
141
|
+
print(f"imported {added} entries — now delete {args.csv_file} securely")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main():
|
|
145
|
+
p = argparse.ArgumentParser(description="local encrypted password manager")
|
|
146
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
147
|
+
|
|
148
|
+
sub.add_parser("init", help="create a new empty vault")
|
|
149
|
+
a = sub.add_parser("add", help="add or update an entry")
|
|
150
|
+
a.add_argument("name")
|
|
151
|
+
a.add_argument("--gen", action="store_true", help="generate the password")
|
|
152
|
+
g = sub.add_parser("get", help="copy an entry's password to clipboard")
|
|
153
|
+
g.add_argument("name")
|
|
154
|
+
sub.add_parser("ls", help="list entry names")
|
|
155
|
+
r = sub.add_parser("rm", help="delete an entry")
|
|
156
|
+
r.add_argument("name")
|
|
157
|
+
n = sub.add_parser("gen", help="print a random password")
|
|
158
|
+
n.add_argument("-l", "--length", type=int, default=20)
|
|
159
|
+
f = sub.add_parser("find", help="search entry names, usernames, notes")
|
|
160
|
+
f.add_argument("term")
|
|
161
|
+
i = sub.add_parser("import", help="import a browser CSV export")
|
|
162
|
+
i.add_argument("csv_file")
|
|
163
|
+
|
|
164
|
+
args = p.parse_args()
|
|
165
|
+
try:
|
|
166
|
+
globals()[f"cmd_{args.command}"](args)
|
|
167
|
+
except vault.VaultError as e:
|
|
168
|
+
sys.exit(str(e))
|
|
169
|
+
except KeyboardInterrupt:
|
|
170
|
+
sys.exit("\naborted")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Key derivation and authenticated encryption. No vault knowledge here."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from argon2.low_level import Type, hash_secret_raw
|
|
6
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
7
|
+
|
|
8
|
+
SALT_LEN = 16
|
|
9
|
+
NONCE_LEN = 12 # AES-GCM standard nonce size
|
|
10
|
+
KEY_LEN = 32 # AES-256
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def derive_key(password: str, salt: bytes) -> bytes:
|
|
14
|
+
"""Stretch a master password into a 32-byte key. Same inputs -> same key."""
|
|
15
|
+
return hash_secret_raw(
|
|
16
|
+
secret=password.encode(),
|
|
17
|
+
salt=salt,
|
|
18
|
+
time_cost=3,
|
|
19
|
+
memory_cost=64 * 1024, # 64 MiB — what makes GPU brute-force expensive
|
|
20
|
+
parallelism=4,
|
|
21
|
+
hash_len=KEY_LEN,
|
|
22
|
+
type=Type.ID, # Argon2id
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def encrypt(key: bytes, plaintext: bytes) -> bytes:
|
|
27
|
+
"""Returns nonce + ciphertext. Fresh random nonce every call — reuse breaks GCM."""
|
|
28
|
+
nonce = os.urandom(NONCE_LEN)
|
|
29
|
+
return nonce + AESGCM(key).encrypt(nonce, plaintext, None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decrypt(key: bytes, blob: bytes) -> bytes:
|
|
33
|
+
"""Raises cryptography.exceptions.InvalidTag on wrong key or tampered data."""
|
|
34
|
+
return AESGCM(key).decrypt(blob[:NONCE_LEN], blob[NONCE_LEN:], None)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Vault file: [4-byte magic "PWV1"][16-byte salt][12-byte nonce + ciphertext].
|
|
2
|
+
|
|
3
|
+
The ciphertext is JSON: {"entry name": {"user": ..., "pw": ..., "notes": ...}}.
|
|
4
|
+
Salt and nonce are not secrets; everything sensitive is inside the ciphertext.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
|
|
11
|
+
from cryptography.exceptions import InvalidTag
|
|
12
|
+
|
|
13
|
+
from . import crypto
|
|
14
|
+
|
|
15
|
+
MAGIC = b"PWV1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VaultError(Exception):
|
|
19
|
+
"""Anything wrong with opening or reading a vault, with a human message."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load(path: str, password: str) -> dict:
|
|
23
|
+
try:
|
|
24
|
+
with open(path, "rb") as f:
|
|
25
|
+
raw = f.read()
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
raise VaultError(f"no vault at {path} — run 'init' first")
|
|
28
|
+
if raw[:4] != MAGIC:
|
|
29
|
+
raise VaultError(f"{path} is not a vault file (bad magic bytes)")
|
|
30
|
+
salt = raw[4 : 4 + crypto.SALT_LEN]
|
|
31
|
+
key = crypto.derive_key(password, salt)
|
|
32
|
+
try:
|
|
33
|
+
plaintext = crypto.decrypt(key, raw[4 + crypto.SALT_LEN :])
|
|
34
|
+
except InvalidTag:
|
|
35
|
+
raise VaultError("wrong master password (or the vault file is corrupted)")
|
|
36
|
+
return json.loads(plaintext)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save(path: str, password: str, entries: dict) -> None:
|
|
40
|
+
salt = os.urandom(crypto.SALT_LEN)
|
|
41
|
+
key = crypto.derive_key(password, salt)
|
|
42
|
+
blob = MAGIC + salt + crypto.encrypt(key, json.dumps(entries).encode())
|
|
43
|
+
# Keep the previous version as .bak: one-step undo for corruption or mistakes.
|
|
44
|
+
if os.path.exists(path):
|
|
45
|
+
shutil.copy2(path, path + ".bak")
|
|
46
|
+
# Write to a temp file then rename: a crash mid-write can't destroy the vault.
|
|
47
|
+
tmp = path + ".tmp"
|
|
48
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
49
|
+
with os.fdopen(fd, "wb") as f:
|
|
50
|
+
f.write(blob)
|
|
51
|
+
os.replace(tmp, path)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "basic-password-manager"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local encrypted password manager that lives in your terminal"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"argon2-cffi",
|
|
14
|
+
"cryptography",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
pw = "pw_manager.cli:main"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Repository = "https://github.com/Daisentaur/password-manager"
|