{"id":51272883,"url":"https://github.com/arcsymer/password-manager","last_synced_at":"2026-06-29T19:30:30.346Z","repository":{"id":366662956,"uuid":"1277106508","full_name":"arcsymer/password-manager","owner":"arcsymer","description":"Desktop password manager (C++17) — libsodium Argon2id+secretbox vault, RFC 6238 TOTP 2FA, search/filter. Learning/portfolio project, not security-audited","archived":false,"fork":false,"pushed_at":"2026-06-22T19:07:51.000Z","size":56,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-22T21:08:34.975Z","etag":null,"topics":["2fa","cmake","cplusplus","cpp","cryptography","libsodium","portfolio","totp"],"latest_commit_sha":null,"homepage":null,"language":"C++","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/arcsymer.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-22T15:31:16.000Z","updated_at":"2026-06-22T19:07:56.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/arcsymer/password-manager","commit_stats":null,"previous_names":["arcsymer/password-manager"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/arcsymer/password-manager","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arcsymer%2Fpassword-manager","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arcsymer%2Fpassword-manager/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arcsymer%2Fpassword-manager/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arcsymer%2Fpassword-manager/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/arcsymer","download_url":"https://codeload.github.com/arcsymer/password-manager/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/arcsymer%2Fpassword-manager/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34941025,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-29T02:00:05.398Z","response_time":58,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["2fa","cmake","cplusplus","cpp","cryptography","libsodium","portfolio","totp"],"created_at":"2026-06-29T19:30:26.767Z","updated_at":"2026-06-29T19:30:30.333Z","avatar_url":"https://github.com/arcsymer.png","language":"C++","funding_links":[],"categories":[],"sub_categories":[],"readme":"# pwman — Password Manager\n\n[![CI](https://github.com/arcsymer/password-manager/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/arcsymer/password-manager/actions/workflows/ci.yml)\n![tests: 82 passing](https://img.shields.io/badge/tests-82%20passing-brightgreen)\n\nThis is a learning and portfolio project. It hasn't been through a security audit, so\nplease don't use it for real secrets.\n\nA command-line password manager written in C++17. It uses\n[libsodium](https://libsodium.org/) for authenticated encryption (Argon2id + XSalsa20-Poly1305)\nand RFC 6238 TOTP, keeps the library and CLI separate, and writes no custom crypto of its own.\n\n---\n\n## Problem / Solution\n\n**Problem:** Storing credentials on disk needs both strong key derivation (to resist offline\nbrute-force) and authenticated encryption (to detect tampering or a wrong password without\nrevealing plaintext). TOTP two-factor tokens have to match the RFC 6238 standard exactly.\n\n**Solution:**\n- Argon2id (libsodium `crypto_pwhash`) derives a 256-bit key from the master password and a\n  random 16-byte salt, with MODERATE memory/ops limits (~256 MiB, ~3 passes) — a deliberate\n  step up from INTERACTIVE to make offline brute-force materially more expensive.\n- XSalsa20-Poly1305 (`crypto_secretbox_easy`) encrypts the serialised vault with a random 24-byte\n  nonce. The MAC catches any wrong-password or corruption case before any plaintext is returned.\n- TOTP uses libsodium `crypto_auth_hmacsha256` to implement RFC 4226/6238 deterministically,\n  checked against the official SHA-256 test vectors in Appendix B of RFC 6238.\n  libsodium intentionally does not expose HMAC-SHA1, and RFC 6238 permits SHA-256 as the PRF.\n\n---\n\n## Security design\n\n### Cryptographic primitives\n\n| Layer        | Primitive                                | Library call                        |\n|--------------|------------------------------------------|-------------------------------------|\n| KDF          | Argon2id (MODERATE ops/mem)              | `crypto_pwhash`                     |\n| Encryption   | XSalsa20-Poly1305 (authenticated)        | `crypto_secretbox_easy`             |\n| TOTP MAC     | HMAC-SHA256                              | `crypto_auth_hmacsha256`            |\n| CSPRNG       | OS-seeded                                | `randombytes_buf` / `randombytes_uniform` |\n| Key wipe     | Compiler-resistant zero                  | `sodium_memzero`                    |\n\nNo custom cryptography: every primitive comes straight from libsodium.\n\n### KDF parameters\n\n| Parameter    | Value                               | Rationale                                    |\n|--------------|-------------------------------------|----------------------------------------------|\n| Algorithm    | Argon2id                            | Resists both GPU and side-channel attacks    |\n| `opslimit`   | `crypto_pwhash_OPSLIMIT_MODERATE`   | ~3 passes; raises offline cracking cost     |\n| `memlimit`   | `crypto_pwhash_MEMLIMIT_MODERATE`   | ~256 MiB memory hard                        |\n| Key length   | 32 bytes (256 bits)                 | Matches XSalsa20-Poly1305 key size          |\n| Salt         | 16 bytes, random per save           | Prevents precomputed (rainbow-table) attacks |\n\n### Threat model\n\n| Threat                                           | Mitigated? | How                                              |\n|--------------------------------------------------|------------|--------------------------------------------------|\n| Offline brute-force (attacker gets vault file)   | Yes        | Argon2id with ~256 MiB memory cost per guess (MODERATE) |\n| Ciphertext tampering / file corruption           | Yes        | Poly1305 MAC; decryption aborts on any mismatch |\n| Wrong-password oracle leaking plaintext          | Yes        | `decrypt_vault` throws before any plaintext returned |\n| Nonce reuse                                      | Yes        | New random 24-byte nonce on every `save_vault`  |\n| Key reuse across vaults                          | Yes        | Random salt regenerated on every `save_vault`   |\n| Vault corruption on crash mid-save               | Yes        | `save_vault` writes to a temp file and `rename()`s it into place atomically; a crash leaves either the old or the fully-written new file |\n| Overwriting a real vault on wrong password (`add`) | Yes      | `add` only starts a fresh vault for a missing/empty file; a wrong master password or corrupt file raises `DecryptionError`/`FormatError` instead of clobbering the existing vault |\n| Memory disclosure of master password (CLI)       | Partial    | `sodium_memzero` wipes the derived key, and `secure_clear` wipes the master/export password `std::string`s on every exit path; copies made internally by libsodium are outside our control |\n| In-process plaintext in memory                   | Not mitigated | Decrypted entries live in `std::vector\u003cEntry\u003e` on the heap; no locked/guarded allocator is used |\n| Side-channel timing on MAC verification          | Yes        | libsodium's `crypto_secretbox_open_easy` uses constant-time comparison |\n| Plaintext buffer lingering in memory after decrypt | Partial  | `sodium_memzero` wipes the serialised plaintext vector before it is freed; individual `Entry` strings on the heap are not locked |\n| Supply-chain attack on libsodium                 | Not mitigated | Relies on the system or MSYS2 libsodium package integrity |\n\n### What is NOT protected\n\n- The **process address space** after decryption: plaintext passwords live in normal heap memory.\n  An attacker with a process memory dump can read them.\n- The **terminal**: passwords passed via `--password \u003cpass\u003e` are visible in `ps` output and\n  shell history. Use `--password -` and pipe from a secure source to mitigate.\n- **Swap / page file**: the OS may page decrypted data to disk. `mlock` / `VirtualLock` are\n  not used (would require platform-specific code and elevated privileges on most systems).\n- **Clipboard**: the optional `--copy` feature is not implemented. If you paste a password\n  from terminal output, the clipboard is outside this tool's control.\n\n### Why HMAC-SHA256 for TOTP (not SHA-1)\n\nRFC 6238 §1.2 lists SHA-1, SHA-256, and SHA-512 as valid PRFs. libsodium deliberately\nomits HMAC-SHA1 because SHA-1 is considered cryptographically weak in general (though it\nis not broken in the HMAC context specifically). Using HMAC-SHA256 keeps libsodium as the\nonly dependency and avoids SHA-1 entirely. The trade-off is that codes produced here will\n**not match** codes from consumer authenticator apps (Google Authenticator, Authy, etc.)\nthat default to HMAC-SHA1 — this is a known, intentional constraint of this portfolio project.\n\n---\n\n## Vault file format\n\n### Byte layout\n\n```mermaid\npacket-beta\ntitle Vault file on disk\n0-15: \"salt (16 bytes, random, crypto_pwhash_SALTBYTES)\"\n16-39: \"nonce (24 bytes, random, crypto_secretbox_NONCEBYTES)\"\n40-55: \"Poly1305 MAC (16 bytes, crypto_secretbox_MACBYTES)\"\n56-999: \"ciphertext (variable: serialised vault plaintext)\"\n```\n\n```\nOffset 0              16             40             56\n       +--------------+--------------+------+------------ ...\n       | salt         | nonce        | MAC  | ciphertext\n       | 16 bytes     | 24 bytes     | 16 B | len(plaintext) bytes\n       +--------------+--------------+------+------------ ...\n```\n\n### Serialised plaintext format (inside the ciphertext)\n\n```\n[PWMV1\\x00\\x00\\x00]           8 bytes magic\n[entry_count]                  4 bytes little-endian uint32\nFor each entry:\n  [0x1F] [id_decimal]          field sep + id as ASCII decimal\n  [0x1F] [name]\n  [0x1F] [username]\n  [0x1F] [url]\n  [0x1F] [password]\n  [0x1F] [notes]\n  [0x1F] [tag1 0x1E tag2 ...]  tags joined with Record Separator\n  [0x1D]                       Group Separator = end of entry\n```\n\nDelimiter bytes (`0x1F` Unit Separator, `0x1E` Record Separator, `0x1D` Group Separator)\nare ASCII control characters that never appear in normal UTF-8 user data, so no escaping\nis needed.\n\n### Encrypt / decrypt flow\n\n```mermaid\nflowchart TD\n    A([Master password + Vault]) --\u003e B[serialize Vault to plaintext bytes]\n    B --\u003e C[randombytes_buf: generate salt + nonce]\n    C --\u003e D[\"crypto_pwhash(Argon2id)\\nkey = KDF(password, salt, 64 MiB)\"]\n    D --\u003e E[\"crypto_secretbox_easy\\nciphertext = Enc(key, nonce, plaintext)\"]\n    E --\u003e F[sodium_memzero wipe key]\n    F --\u003e G([File: salt || nonce || ciphertext])\n\n    G2([File: salt || nonce || ciphertext]) --\u003e H[Read salt, nonce, ciphertext]\n    H --\u003e I[\"crypto_pwhash(Argon2id)\\nkey = KDF(password, salt, 64 MiB)\"]\n    I --\u003e J[\"crypto_secretbox_open_easy\\nverify MAC + decrypt\"]\n    J -- MAC ok --\u003e K[deserialize plaintext to Vault]\n    J -- MAC fail --\u003e L([throw DecryptionError])\n    K --\u003e M[sodium_memzero wipe key]\n    M --\u003e N([Vault in memory])\n```\n\n---\n\n## Architecture\n\n```\npassword-manager/\n├── core/                  # pwman-core (static library)\n│   ├── include/pwman/\n│   │   ├── entry.hpp      # Entry struct (id, name, username, url, tags, password, notes)\n│   │   ├── vault.hpp      # Vault: add/remove/find/find_by_name/search/update\n│   │   ├── crypto.hpp     # serialize/deserialize + encrypt/decrypt + file I/O\n│   │   │                  # + export_vault/import_vault + change_password\n│   │   ├── totp.hpp       # totp() + totp_string() + base32_encode/decode\n│   │   └── generator.hpp  # generate_password() + estimate_strength()\n│   └── src/               # Implementations\n├── cli/                   # pwman-cli (executable)\n│   └── src/main.cpp       # Argument parser + command dispatch\n├── tests/                 # pwman-tests (Catch2 v3, via FetchContent)\n│   ├── test_totp.cpp          # RFC 6238 vectors\n│   ├── test_base32.cpp        # RFC 4648 vectors\n│   ├── test_crypto.cpp        # Round-trip + wrong-password + tamper\n│   ├── test_vault.cpp         # add/remove/find/search\n│   ├── test_vault_update.cpp  # update + find_by_name\n│   ├── test_strength.cpp      # estimate_strength across all levels\n│   └── test_export_import.cpp # export/import round-trip + change_password\n├── IMPROVEMENT_PLAN.md    # Gap analysis and implementation plan\n├── scripts/demo.sh        # Non-interactive CI demo\n└── .github/workflows/ci.yml\n```\n\n**Dependencies:**\n- libsodium (system, `libsodium-dev` on Ubuntu)\n- Catch2 v3.5.4 (fetched by CMake FetchContent, no system install required)\n\n---\n\n## Build\n\n```bash\n# Ubuntu / Debian\nsudo apt-get install -y libsodium-dev cmake build-essential pkg-config\n\ncmake -B build -DCMAKE_BUILD_TYPE=Release\ncmake --build build --parallel\n```\n\n### Windows (MSYS2 / MinGW-w64)\n\nThe project builds natively on Windows with the MinGW-w64 toolchain and a native\nlibsodium from MSYS2 (no MSVC required). From an MSYS2 MinGW64 shell:\n\n```bash\n# One-time: install toolchain + libsodium\npacman -S --noconfirm \\\n  mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake \\\n  mingw-w64-x86_64-ninja mingw-w64-x86_64-pkgconf \\\n  mingw-w64-x86_64-libsodium\n\n# Configure + build (Ninja generator)\ncmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release\ncmake --build build --parallel\n# Produces build/cli/pwman-cli.exe\n```\n\nlibsodium is located via pkg-config, so no manual paths are needed when building\nfrom the MinGW64 shell. Both Linux and Windows are exercised in CI.\n\n---\n\n## Tests\n\n```bash\nctest --test-dir build --output-on-failure\n```\n\n82 test cases across 11 files:\n\n| File                         | Count | What is covered                                                               |\n|------------------------------|-------|-------------------------------------------------------------------------------|\n| test_totp.cpp                | 3     | RFC 6238 SHA-256 vectors (6 timesteps), default params, invalid arg throws    |\n| test_base32.cpp              | 5     | RFC 4648 encode + decode vectors, case-insensitive decode, error, round-trip  |\n| test_crypto.cpp              | 7     | Serialise round-trip, empty vault, minimal entry, encrypt/decrypt, wrong password, truncated input, random salt uniqueness |\n| test_vault.cpp               | 13    | add (ids), find, remove, search by name/username/url/tags, case-insensitivity, empty vault, no matches |\n| test_vault_update.cpp        | 8     | update (fields, id preserved, other entries unaffected, empty vault), find_by_name (match, case-insensitive, absent, exact-only) |\n| test_strength.cpp            | 9     | estimate_strength across all five levels, label strings, entropy monotonicity  |\n| test_export_import.cpp       | 7     | export/import round-trip, wrong export password, empty vault, random nonce, separate export password, change_password happy path, wrong old password |\n| test_format_errors.cpp       | 7     | Wrong magic, truncated header, corrupt entry id → FormatError; bit-flipped ciphertext → DecryptionError; boundary size; zeroization path |\n| test_totp_verify.cpp         | 9     | totp_verify: window=0 strict, window=1 accepts ±1 step, rejects ±2 steps, wrong code, near-epoch underflow safety, 6-digit/60s period |\n| test_generator_ambiguous.cpp | 8     | exclude_ambiguous: full charset, length, no-digits combo, no-symbols combo, lowercase-only, digits-only, all-chars valid, default still emits ambiguous |\n| test_hardening.cpp           | 4     | Atomic `save_vault`: filesystem round-trip, no leftover `.tmp` on success, atomic overwrite of an existing vault, `secure_clear` wipes/empties secret strings |\n\n### RFC 6238 SHA-256 test vectors (Appendix B)\n\nKey: raw bytes of ASCII `\"12345678901234567890123456789012\"` (32 bytes), digits=8, period=30:\n\n| Unix time       | Expected code |\n|-----------------|---------------|\n| 59              | 46119246      |\n| 1111111109      | 68084774      |\n| 1111111111      | 67062674      |\n| 1234567890      | 91819424      |\n| 2000000000      | 90698825      |\n| 20000000000     | 77737706      |\n\n---\n\n## Usage\n\n### Vault operations\n\n```bash\n# Create vault and add entries\npwman-cli --vault my.vault --password masterpass add \\\n    --name \"GitHub\" --username \"alice@example.com\" \\\n    --url \"https://github.com\" --password-entry \"s3cr3t\" --tags \"dev,work\"\n\n# List all entries (short form — password and notes omitted)\npwman-cli --vault my.vault --password masterpass list\n\n# Get full details for a single entry (including password and notes)\npwman-cli --vault my.vault --password masterpass get 1\n\n# Search (case-insensitive, matches name/username/url/tags)\npwman-cli --vault my.vault --password masterpass search dev\n\n# Update specific fields of an existing entry (only supplied flags are changed)\npwman-cli --vault my.vault --password masterpass update 1 \\\n    --username \"newalice@example.com\" --tags \"dev,personal\"\n\n# Remove by numeric id\npwman-cli --vault my.vault --password masterpass remove 1\n\n# Remove by name (case-insensitive exact match)\npwman-cli --vault my.vault --password masterpass remove-by-name \"GitHub\"\n\n# Change master password (re-encrypts vault)\npwman-cli --vault my.vault --password masterpass \\\n    change-password --new-password newsecret\n\n# Verify master password\npwman-cli --vault my.vault --password masterpass unlock\n```\n\n### Export / import\n\n```bash\n# Export vault to a portable encrypted bundle with a separate export password\npwman-cli --vault my.vault --password masterpass \\\n    export --out backup.bundle --export-password backupsecret\n\n# Import bundle into a new vault file\npwman-cli import --in backup.bundle --export-password backupsecret \\\n    --vault restored.vault --password newmaster\n```\n\n### Password strength\n\n```bash\n# Estimate the strength of any password string\npwman-cli strength \"hunter2\"\n# → VERY_WEAK (20 bits)\n\npwman-cli strength \"Tr0ub4dor\u00263!Xy9@ZpQ#\"\n# → VERY_STRONG (131 bits)\n```\n\n### TOTP\n\n```bash\n# Decode a Base32 TOTP secret and generate current code\npwman-cli totp --secret JBSWY3DPEHPK3PXP --digits 6 --period 30\n\n# With fixed time (for testing — SHA-256 vector from RFC 6238 Appendix B)\npwman-cli totp --secret GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ --digits 8 --time 59\n# → 46119246  (HMAC-SHA256; differs from SHA-1 authenticators by design)\n```\n\n### Password generator\n\n```bash\npwman-cli generate --length 20\n# Example output:\n# Tr0ub4dor\u00263!Xy9@ZpQ#\n# strength: VERY_STRONG (131 bits)\n\npwman-cli generate --length 16 --no-symbols\npwman-cli generate --length 12 --no-symbols --no-digits\n# Exclude visually ambiguous characters (0, O, I, l, 1) — useful for typed passwords\npwman-cli generate --length 20 --no-ambiguous\n```\n\n---\n\n## CI demo session\n\nCaptured verbatim from the `demo` step of a GitHub Actions run\n(`scripts/demo.sh`, Ubuntu, libsodium):\n\n```text\n========================================\n pwman-cli  —  demo session\n========================================\n\n[1] Adding synthetic entries...\nOK: added entry id=1\nOK: added entry id=2\nOK: added entry id=3\n\n[2] Listing all entries...\n3 entries:\n[1] GitHub Demo  user=demouser@example.com  url=https://github.com  tags=dev,work\n[2] Email Demo  user=demouser@example.com  url=https://mail.example.com  tags=personal\n[3] Jira Demo  user=demouser  url=https://jira.example.com  tags=work\n\n[3] Searching for 'demo'...\n3 result(s) for \"demo\":\n[1] GitHub Demo  user=demouser@example.com  url=https://github.com  tags=dev,work\n[2] Email Demo  user=demouser@example.com  url=https://mail.example.com  tags=personal\n[3] Jira Demo  user=demouser  url=https://jira.example.com  tags=work\n\n[4] Searching for 'work'...\n2 result(s) for \"work\":\n[1] GitHub Demo  user=demouser@example.com  url=https://github.com  tags=dev,work\n[3] Jira Demo  user=demouser  url=https://jira.example.com  tags=work\n\n[5] Searching for 'nonexistent'...\n0 result(s) for \"nonexistent\":\n\n[6] Unlocking vault with correct password...\nOK: vault unlocked, 3 entries.\n\n[7] Attempting unlock with wrong password (expect error)...\nERROR: decryption failed: wrong password or corrupt data\nExpected error: decryption failed.\n\n[8] TOTP code at T=59 (HMAC-SHA256, deterministic 8-digit)...\n32247374\n\n[9] TOTP at T=1234567890 (HMAC-SHA256, deterministic 8-digit)...\n42829826\n\n[10] Generating a random 24-char password...\nZ0i1Za.U;-h%-hVz([$ktDnW\nstrength: VERY_STRONG (157 bits)\n\n[11] Generating alphanumeric-only password (no symbols)...\nsKwo0LMXuxO3bJUu\nstrength: STRONG (95 bits)\n\n========================================\n Demo complete.\n========================================\n```\n\n---\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farcsymer%2Fpassword-manager","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Farcsymer%2Fpassword-manager","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Farcsymer%2Fpassword-manager/lists"}