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. 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
secretsmodule). 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
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:
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.
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
secretsmodule. - PIN access is rate-limited per PIN (5 attempts / 15 min).