# PinVault Encrypted PIN lock-vault. Store 4-digit PINs (door codes, safe combinations, router admin PINs, ...) behind a timed lock. Each PIN is sealed for a configurable lock period; after the lock expires — or immediately, using one of the one-time recovery codes — the PIN is revealed via the API or web UI. The database is encrypted with [SQLCipher](https://www.zetetic.net/sqlcipher/). The SQLCipher key is derived from a bcrypt master hash via PBKDF2-SHA256 (100k iterations); the plaintext master password is never stored. ## Design notes - **PINs are a 10^4 space on purpose.** "Recovery" of a PIN means exhaustive re-derivation (≤10,000 HMAC checks). The security model is *time-locked access + one-time bypass codes + brute-force rate limiting*, not PIN secrecy. Do not store high-entropy secrets here — use a password manager. - **Per-row salts.** Every PIN and every recovery code is HMAC-SHA256-hashed with its own random 16-byte salt, stored next to the hash. Precomputed tables do not transfer across rows or installations. - **One-time codes.** Each PIN ships with 4 recovery codes (64 chars, generated with the `secrets` module). A code is single-use: it unlocks the PIN, is marked used, and the bypass is counted. - **Rate limiting.** 5 failed access attempts per PIN → 15-minute lockout (in-process). - **Backups.** The DB is copied to a local backup dir hourly (configurable) and, if configured, mirrored to a NAS path. A rolling window keeps the most recent N backups. If the primary DB is unreadable at startup, the most recent valid backup is auto-restored. Restores are atomic (temp file + rename), so a crash mid-restore cannot tear the database. ## API All endpoints require `Authorization: Bearer $PINVAULT_API_KEY` (except the web UI at `/`). | Method | Path | Description | | ------ | ---- | ----------- | | GET | `/api/status` | Vault status (`locked` = master key not loaded) | | GET | `/api/pins` | List PINs (never includes PINs or codes) | | POST | `/api/pins` | Create PIN. Body: `{"label": "...", "lock_days": 30}`. Returns the PIN and 4 recovery codes **once** | | POST | `/api/pins//access` | Reveal PIN. Body: `{}` after lock expiry, or `{"bypass_code": "..."}` to unlock early. 423 while locked, 429 when rate-limited | | DELETE | `/api/pins/` | Delete a revealed PIN (409 if never revealed) | | GET | `/api/backups` | List backup files (local + NAS) | | POST | `/api/backups` | Trigger an immediate backup | | POST | `/api/backups//restore` | Restore a backup (strict filename validation, key-verified before swap) | ## Configuration Environment variables (see `.env.example`): | Variable | Default | Required | | -------- | ------- | -------- | | `PINVAULT_MASTER_HASH` | — | **yes** (bcrypt hash of the master password) | | `PINVAULT_API_KEY` | random per process | recommended | | `PINVAULT_DB` | `/data/pinvault.db` | no | | `PINVAULT_LOCAL_BACKUP_DIR` | `/data/backups` | no | | `PINVAULT_NAS_BACKUP_DIR` | *(empty = local only)* | no | | `PINVAULT_BACKUP_INTERVAL` | `3600` (seconds) | no | | `PINVAULT_MAX_BACKUPS` | `168` | no | ## Run ### Docker ```sh cp .env.example .env # fill in PINVAULT_MASTER_HASH docker compose up -d --build ``` The compose file mounts `./data` for the DB/backups and `/mnt/aidata` as an optional NAS target — point `PINVAULT_NAS_BACKUP_DIR` at it to enable off-box backups. ### Bare metal (Python ≥3.10) Requires `libsqlcipher-dev` (or equivalent) so `pysqlcipher3` can build: ```sh python3 -m venv .venv && . .venv/bin/activate pip install -r requirements.txt export PINVAULT_MASTER_HASH='$(python3 -c "import bcrypt; print(bcrypt.hashpw(b\"secret\", bcrypt.gensalt()).decode())")' gunicorn -b 0.0.0.0:8765 --timeout 120 app:app ``` ## Tests The suite runs without the SQLCipher native library: when `pysqlcipher3` is not installed, `tests/conftest.py` shims it with plain `sqlite3` (swallowing the `PRAGMA key` statement) so all endpoint logic is still exercised. ```sh pip install -e ".[dev]" pytest tests/ -v ruff check . ``` ## Security notes - The master password itself is never stored, only its bcrypt hash; the SQLCipher key is PBKDF2-derived from that hash. - Recovery codes and PIN hashes use per-row random salts; comparisons are constant-time (`hmac.compare_digest`). - Backup filenames are strictly validated before use, and restore paths are confined to the configured backup directories. - PIN generation uses the `secrets` module. - PIN access is rate-limited per PIN (5 attempts / 15 min).