Jarian Cottingham a6a4ffdf7c
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions
Merge pull request 'Fix startup crashes and harden crypto' (#12) from improve/v1 into main
Reviewed-on: https://git.example.com/jarianc/PinVault/pulls/12
2026-08-20 18:07:06 -05:00
2026-07-03 01:14:04 +00:00
2026-06-30 15:02:15 +00:00
2026-08-20 22:52:28 +00:00
2026-07-03 01:14:04 +00:00
2026-06-30 15:02:15 +00:00
2026-08-20 22:52:28 +00:00
2026-06-30 15:02:15 +00:00

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 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

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 secrets module.
  • PIN access is rate-limited per PIN (5 attempts / 15 min).
Description
Time-locked PIN vault: store door codes and safe combinations behind a timed lock with one-time recovery codes, on a SQLCipher-encrypted database.
Readme 74 KiB
Languages
Python 60.6%
JavaScript 24.6%
CSS 10.5%
HTML 3.5%
Dockerfile 0.8%