· ~10 min read

I audited my own password manager, then rewrote it

My first version used Argon2id and AES-256-GCM and still had nine security defects. Here is what I found, how version 2 is designed, and five bugs that only showed up when I actually ran it.

In March I wrote a small password manager in Python. It derived a key from the master password with Argon2id, encrypted each password with AES-256-GCM, and stored everything in SQLite. Those are the right primitives, and the README said so with some pride.

In October I read it again, this time as if someone else had written it and I had been paid to break it. That changed what I saw.

What the audit found

FindingWhy it matters
A "pepper" constant hardcoded in the sourceThe repository was public. A secret that ships in public source code is just a constant.
KDF parameters not stored with the vaultWhen I raised Argon2's memory cost in the code, my own existing vault could no longer be opened. In a real vault, that is total data loss.
Site names and usernames in plaintextAnyone with the file learns where you have accounts and under which names. The README called it zero-knowledge. It was not.
Each row encrypted on its own, without associated dataCiphertexts could be swapped between rows, or rows deleted, without detection. Each blob was individually valid.
Master password read with input()Echoed to the screen, to scrollback, and to anyone watching a screen share.
A "strong" master-passphrase generator with a 30-word listSix words from 30 is about 229; with the suffix, roughly 39 bits. Far too weak for the key to everything.
Clipboard handlingThe message said 15 seconds, the timer used 10; it wiped whatever was on the clipboard, even text copied later; and Windows clipboard history kept the password anyway.
VACUUM presented as forensic deletionOn SSDs, with journals and snapshots, it guarantees nothing of the kind.
A compiled .exe committed to the repositoryA binary nobody can verify, built from no particular commit.

Lesson one: good primitives are the easy part. Every one of these defects lives in how the primitives were put together, stored, parsed and shown on screen.

The version 2 design

One encrypted file instead of a database

Per-row encryption in SQLite leaks structure: the number of entries, their sizes, which ones changed. A personal vault holds a few hundred entries, so encrypting the whole thing as one blob costs microseconds. Everything is in the ciphertext, including titles, usernames, URLs and the entry count, and the plaintext is padded to 1 KiB boundaries so the file size reveals little.

A key hierarchy

master password ─┐
                 ├─► Argon2id ─► KEK ─unwraps─► vault key ─decrypts─► entries
keyfile (opt.) ──┘   (params in header)        (random 256-bit)    (AES-256-GCM)

The vault key is random rather than derived from the password, which leaves room for more ways to unlock: a keyfile today, a recovery key or a hardware token later. The keyfile goes into Argon2id's dedicated secret input, which is what the old "pepper" should have been.

The header is authenticated

The file starts with a small plaintext header: the format version, the Argon2id parameters and the wrapped vault key. The entire header is the associated data of the payload's AES-GCM encryption, so changing any byte of it, a parameter included, is detected. I wrote a format specification precise enough for someone else to implement.

Parameters travel with the file, within limits

Argon2id is calibrated on each machine (memory up to 1 GiB, about one second to unlock), and the parameters are stored in the header. That fixes the lockout bug, but it opens a new question: what if someone swaps your vault for one with deliberately cheap parameters, so that the moment you type your password it is processed into something easy to crack? So there is a policy floor, RFC 9106's 64 MiB × 3 passes, checked before the KDF ever sees the password. A ceiling stops a hostile file from exhausting memory.

Changing the password rotates the key

With a key hierarchy, it is tempting to make a password change cheap by re-wrapping the same vault key. But suppose the old password leaked: anyone holding an old copy of the file could unwrap the unchanged vault key and read every future version. So a password change generates a new vault key, re-encrypts everything, and deletes the backups encrypted under the old one. There is a test that tries exactly that attack.

Strict parsing before authentication

The header is parsed before anything is authenticated, so the parser is strict: duplicate JSON keys, unknown fields, NaN, booleans posing as integers and oversized lengths are all rejected. Two parsers that disagree about what a file means are where parser-differential bugs live.

Five bugs that only appeared when I ran it

The design passed review on paper. These five did not show up until I ran the code on a real Windows machine, with real input.

1. A Greek password derived the wrong key

On Windows, Python's pipes default to the ANSI code page (cp1252), not UTF-8. A master password with Greek characters piped into the tool decoded to different characters than the same password typed at the console, so it derived a different key. That is a lockout bug, not a display bug. Separately, the same visible accented letter can be one code point or two (NFC vs NFD) depending on the OS and keyboard. The fix: force UTF-8 on redirected streams and NFC-normalize passwords before the KDF.

2. Invisible characters in my own source code

The function that strips terminal escape sequences and Unicode bidi overrides from output had its \u202e-style escapes turned into the literal invisible characters by a tool in my editing pipeline. The code still worked, but a reviewer reading the diff on a web page would not see them. That is the Trojan Source pattern (CVE-2021-42574). The linter caught it; CI now blocks it.

3. A race between auto-lock and save

The interactive mode locks the vault after a few idle minutes, from a timer thread. If the timer fired at the instant a save began, the key could be zeroed while it was being used, and the vault written under an all-zero key. Unlikely, but the result is data loss. Locking and saving now share a mutex, and the idle timer cannot fire once a command has started.

4. The packaged app cleared the clipboard before I could paste

After copying a password, a small detached helper process clears it 20 seconds later. It only clears it if the clipboard still holds that password, compared by hash. From source it worked. In the single-file Windows build, the copy command did not return until the helper had already cleared the clipboard. A one-file PyInstaller app that launches its own executable makes the child share its extraction directory, and the parent waits for it. One environment variable makes the helper an independent process. I only found this because I built the release binary locally and tested it the way a user would.

5. A fuzzy match plus --yes could delete the wrong entry

Commands accept a unique search term instead of an exact title, which is convenient for get. For rm --yes, it meant rm bank --yes could silently delete "Bank of Example". Deletion now requires an exact title or ID.

How it is tested

What it does not protect against

I think this section matters as much as the rest. Malware running as you while the vault is unlocked can read it; no password manager running as a normal user can stop that. Python cannot guarantee that secrets are erased from memory. Someone with write access can roll the file back to an older, genuine version, and no offline format can detect that. The project's threat model lists all of these explicitly. A security tool that overstates its guarantees is worse than one that states its limits.

Takeaways

  1. Choosing AES-GCM and Argon2id is the beginning, not the end. Composition, storage, parsing and UI are where the bugs were.
  2. Store parameters with the data, and validate them before use.
  3. Run it on the real platform, with real input. Encoding, packaging and threading bugs do not show up in a design review.
  4. Write down what you do not protect against.

Want the configs or code from this lab, or have a question? Email me at kostaspapas1023@gmail.com

← Back to home