PinVault/README.md
Jarian Cottingham a69c40bc84 Fix startup crashes and harden crypto
- Define missing dict_factory (orphaned fragment left app unable to
  return DB rows; every endpoint crashed on first query)
- Skip backup when PINVAULT_NAS_BACKUP_DIR is empty (os.makedirs('')
  raised in bootstrap and killed gunicorn at import; compose default)
- Generate PINs with secrets.randbelow instead of random.randint
- Per-row random HMAC salts for PINs and recovery codes (single shared
  hardcoded salt defeated the precomputation protection); constant-time
  comparisons; legacy rows keep the historic salt
- Atomic backup restore (temp file + os.replace) to avoid torn DB
- Validate lock_days type, guard backup loop, move re import to module
  level
- Replace weak unittest stubs with 34-test pytest suite (sqlcipher
  shimmed with sqlite3 so tests run without the native lib)
- Add README, LICENSE, pyproject, .env.example; ruff clean
2026-08-20 22:52:28 +00:00

108 lines
4.5 KiB
Markdown

# 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/<id>/access` | Reveal PIN. Body: `{}` after lock expiry, or `{"bypass_code": "..."}` to unlock early. 423 while locked, 429 when rate-limited |
| DELETE | `/api/pins/<id>` | 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/<filename>/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).