- 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
108 lines
4.5 KiB
Markdown
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).
|