Reported by @tgmct: every fresh v0.9.10 install where deploy.sh runs Step 5
(master-key bootstrap) results in pegaprox.service failing to start with
`PermissionError: [Errno 13] Permission denied: '/etc/pegaprox/secret.key'`.
Root cause: deploy.sh Step 5 wrote /etc/pegaprox/secret.key with mode
0600 root:pegaprox. The systemd unit runs as the `pegaprox` service user.
Owner is root, group is pegaprox, but mode 0600 grants read only to the
*owner* — the service user (a group member, not the owner) cannot read
its own master key. So every fresh install boot-loops.
Three-part fix:
(1) deploy.sh Step 5: master key now created at 0640 root:$SERVICE_GROUP
(group-readable so the service can load it). Key directory is now
0750 to match.
(2) deploy.sh Step 5: existing v0.9.10 / v0.9.10.1 / v0.9.10.2 installs
self-heal on the next `update.sh` — if /etc/pegaprox/secret.key is
found at mode 0600 or 0400, deploy.sh bumps it to 0640 and logs a
`(#417 repair)` print_info. A key already at 0640 / 0440 is left
untouched.
(3) pegaprox/core/keystore.py `_enforce_perms` was relaxed from "must be
0600" to "must be 0600 OR 0640". Anything with group-write,
group-exec, or any other-perm bit set is still rejected hard — the
function raises RuntimeError, not silent skip, so an accidentally
world-readable key never becomes the active key.
Hard rejection mask is now exactly `S_IWGRP | S_IXGRP | S_IRWXO`.
Accepted modes: 0600, 0400, 0640, 0440. Verified with 8-case unit pass.
docs/SECURITY.md tier table updated: Tier 4 row now states "chmod 0640
root:pegaprox — group-read required" and explains why 0600 root:pegaprox
is unreadable for the service. The "loose-perms" paragraph corrected
from "skipped" to "rejected" (matches the actual code behaviour).
Workaround for existing installs that hit the bug *before* this update
can be applied (i.e. operators stuck at boot-loop on v0.9.10/.1/.2):
sudo chmod 640 /etc/pegaprox/secret.key
sudo systemctl restart pegaprox
Files touched:
- deploy.sh (Step 5: 3x chmod 600 -> 640, dir 700 -> 750, repair-on-upgrade)
- pegaprox/core/keystore.py (_enforce_perms accepts S_IRGRP; docstring)
- docs/SECURITY.md (tier table + loader rejection wording)
- version.json, pegaprox/constants.py, web/src/constants.js, README.md
(version bump to 0.9.10.3 / build 2026.05.15)
- web/index.html (frontend rebuild for new version constant)
12 KiB
PegaProx — Encryption Architecture (Operator Guide)
For vulnerability reporting see
SECURITY.mdin the repo root. This document covers the technical design: how keys are loaded, where the DB is encrypted, what to do when things go wrong.
PegaProx persists secrets in two places:
- Application database (
config/pegaprox.db) — users, sessions, audit log, cluster credentials, scheduler state. - Configuration files under
config/— TLS certificates, cluster JSON, plugin state, etc.
Both rely on a single 32-byte master key. Where that key lives is the biggest determinant of how much an attacker who steals a backup actually gets.
1. Master-key loader (pegaprox/core/keystore.py)
The keystore resolves the master key from a priority-ordered list and caches the first hit for the lifetime of the process.
| Tier | Source | Use case | Notes |
|---|---|---|---|
| 1 | PEGAPROX_DB_KEY env var |
Docker secrets, k8s, CI | Accepts urlsafe-base64 (44 chars) or hex (64 chars) |
| 2 | ${CREDENTIALS_DIRECTORY}/db-key |
systemd LoadCredentialEncrypted= |
TPM2- or host-key-bound. Strongest at-rest option |
| 3 | PEGAPROX_KEY_FILE env var → file |
Custom path, e.g. NFS mount | chmod 0600 (or 0640 root:pegaprox) |
| 4 | /etc/pegaprox/secret.key |
System-service install default | chmod 0640 root:pegaprox — group-read required so the systemd unit (running as pegaprox) can load it. 0600 root:pegaprox is unreadable for the service. |
| 5 | ~/.config/pegaprox/secret.key |
Single-user / dev install | chmod 0600 (owner = user running PegaProx) |
| 6 | config/.pegaprox.key (CONFIG_DIR) |
Legacy — pre-0.9.9.3 | Triggers deprecation warning on every boot |
The loader rejects any file-based tier whose permissions have group-write,
group-exec, or any other-user bits set — an accidentally chmod-755'd key file
never becomes the active key silently. The rejection is logged. Group-read is
allowed so the system-service install pattern (root:pegaprox 0640) works.
Why the order matters
The 0.9.9.2 audit flagged the legacy default (Tier 6) for storing the key
next to the encrypted data. With backups typically including the whole
config/ directory, key and DB travel together, defeating at-rest encryption.
Tiers 1–5 break that coupling: the key lives somewhere a config/ snapshot
won't capture. Tier 2 is the strongest because the key is only
available to the running service unit and is wrapped against the host's TPM2
chip or host key — not even root can read it directly without unsealing.
Inspecting the active tier
sudo -u pegaprox python3 pegaprox_multi_cluster.py --keystore-status
JSON output:
{
"keystore": {
"ok": true,
"source": "/etc/pegaprox/secret.key",
"source_path": "/etc/pegaprox/secret.key",
"is_legacy": false,
"tier": 4,
"recommendation": "Key is outside the DB directory which is good. Consider upgrading to systemd LoadCredentialEncrypted..."
},
"db": {
"backend": "sqlcipher",
"encrypted_at_rest": true,
"cipher": "AES-256-CBC / HMAC-SHA512 (SQLCipher v4)"
}
}
A green pill in the admin UI's Security card consumes the same data via
GET /api/security/keystore-status.
2. SQLCipher full-DB encryption (pegaprox/core/dbcrypto.py)
When the sqlcipher3 Python module is importable, every connection acquired
through dbcrypto.connect() runs the SQLCipher v4 PRAGMA handshake before
the first query. The DB header is encrypted, all pages are AES-256-CBC, and
each page carries an HMAC-SHA512 tag.
Platform matrix
| Platform | sqlcipher3-binary wheel | Encryption status |
|---|---|---|
| Linux x86_64 | ✅ shipped | Full DB encryption |
| Linux aarch64 | ❌ no wheel | Plain SQLite + field-level Fernet |
| macOS (any) | ❌ no wheel | Plain SQLite + field-level Fernet |
| Windows | ❌ no wheel | Plain SQLite + field-level Fernet |
Docker python:3.12-slim |
✅ (x86_64 base image) | Full DB encryption |
The fallback is not unencrypted — sensitive fields (cluster passwords, 2FA secrets, OIDC client secrets, API tokens) are still individually Fernet-encrypted with the same master key. The fallback gives up on metadata-at-rest (table names, audit-log timestamps, session IDs).
You can force-disable SQLCipher by uninstalling sqlcipher3-binary and
restarting — the connection layer detects the missing module and falls
back transparently. Note: any DB already encrypted will become
unreadable until the module is reinstalled — there is no automatic
decrypt-back-to-plain step.
Cipher parameters
PRAGMA cipher_compatibility = 4
PRAGMA key = x'<64-hex-chars>'
We pass the key as a 32-byte hex literal so SQLCipher skips its
PBKDF2 key-derivation — the master key already has full entropy and
running 256k PBKDF2 rounds on every connection acquire is pointless
overhead. This matches the documented cipher_default_kdf_iter = 0
posture for high-entropy raw keys.
3. Migrating an existing install (plain → encrypted)
PegaProx 0.9.9.3+ auto-encrypts on first boot when:
sqlcipher3is importable (Linux x86_64 default), and- the master key resolves to a usable tier, and
- the existing DB is in the
plainstate.
The same four-step process runs in-process during startup, gated by an
on-disk lock file (<db>.migration.lock, flock(2)) so a systemd
restart-storm cannot double-migrate. Boot output:
✓ DB auto-encrypted (8121 rows, 8.7s)
backup: config/pegaprox.db.plain.bak.1778613855
The plain backup is retained — delete it manually once you've verified the encrypted DB works for a day or two.
Opt-out
Set PEGAPROX_DISABLE_AUTO_ENCRYPT=1 in the service environment to skip
the auto-encrypt step on boot. Useful for: taking your own backup first,
running custom pre-flight checks, or staying on plain SQLite for a
debugging window. With opt-out enabled, the field-level Fernet
encryption still applies and you can run the manual CLI tool whenever
you're ready.
Manual / explicit path
The --migrate-db CLI remains as the explicit / manual control:
Pre-flight
- Stop the service:
sudo systemctl stop pegaprox - Verify the keystore resolves a key:
sudo -u pegaprox python3 pegaprox_multi_cluster.py --keystore-status - Verify
sqlcipher3is importable:python3 -c 'import sqlcipher3; print(sqlcipher3.dbapi2.version)'
Dry-run
sudo -u pegaprox python3 pegaprox_multi_cluster.py --migrate-db --dry-run
Reports which DB will be encrypted, the current size, and the per-table row counts that will be verified post-migration. No files are touched.
Execute
sudo -u pegaprox python3 pegaprox_multi_cluster.py --migrate-db --yes
The four-step process:
- Backup —
config/pegaprox.db→config/pegaprox.db.plain.bak.<timestamp> - Encrypt — new file
config/pegaprox.db.encbuilt via SQLite'sATTACH … KEY … AS encrypted; SELECT sqlcipher_export('encrypted'); - Verify — every table's row count is compared between the plain backup and the encrypted target. Any mismatch aborts the migration before the swap.
- Atomic swap —
os.replace(pegaprox.db.enc, pegaprox.db). POSIXrename(2)is atomic on the same filesystem.
The .plain.bak.<timestamp> file is kept indefinitely — clean it up
manually once you've confirmed the encrypted DB works end-to-end. Do
not delete it the same day unless you have your own backups; this is
the only path back if the encrypted DB becomes inaccessible.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Migration successful, encrypted DB is now active |
| 2 | sqlcipher3 not installed — install sqlcipher3-binary first |
| 3 | Existing DB already encrypted with a different key (refuses to clobber) |
| 4 | Source DB is corrupt — repair before retrying |
| 5 | Row-count verification failed — encrypted DB not swapped in |
4. Recovery / disaster scenarios
The master key is non-recoverable. PegaProx does not implement key escrow or shamir-shared rebuilds. If the key is lost and the DB is encrypted, the DB is permanently unreadable.
Backup discipline
If you take backups of config/, also back up the master key — but
to a different location and with different access controls. Examples:
- DB → encrypted nightly snapshot to S3 (versioned, KMS-encrypted bucket).
- Master key → printed once, sealed in a tamper-evident envelope, kept in the on-call lead's safe. Plus: stored in your password manager's shared vault under "Infra / PegaProx / master-key".
If both live in the same backup tarball, encrypting the DB bought you nothing against backup theft.
Lost master key, encrypted DB
There is no path back. Restore from the most recent .plain.bak.*
emitted by the migrator, or from a pre-migration full backup.
Corrupted SQLCipher DB
pegaprox_multi_cluster.py --keystore-status will report
"db": {"backend": "sqlcipher", "encrypted_at_rest": true} even on a
corrupt DB — corruption manifests as OperationalError: file is not a database at first query. Use --migrate-db --dry-run to detect the
corrupt state.
If you can still get an old backup readable: stop PegaProx, swap the
corrupt DB out, restore the backup, re-run --migrate-db.
5. systemd LoadCredentialEncrypted (Tier 2) — strongest at-rest
This is the recommended posture for production Linux deployments.
Prerequisites
systemd >= 250(Debian 12+, Ubuntu 22.04+, RHEL 9+).- Either a TPM2 chip (
systemd-cryptenroll --tpm2-device=listsucceeds) or the default host-key (any systemd ≥ 250 has it).
Setup
-
Generate the key (one-time):
python3 -c 'import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())' \ | sudo tee /tmp/pegaprox-key.txt -
Wrap it with systemd-creds:
# Host-key bound (works everywhere): sudo systemd-creds encrypt --name=db-key /tmp/pegaprox-key.txt \ /etc/pegaprox/secret.key.cred # TPM2-bound (stronger, requires TPM2 chip): sudo systemd-creds encrypt --name=db-key --with-key=tpm2 \ /tmp/pegaprox-key.txt /etc/pegaprox/secret.key.cred -
Shred the plaintext copy:
sudo shred -u /tmp/pegaprox-key.txt -
Edit
/etc/systemd/system/pegaprox.serviceand add to[Service]:LoadCredentialEncrypted=db-key:/etc/pegaprox/secret.key.cred -
Reload + restart:
sudo systemctl daemon-reload sudo systemctl restart pegaprox -
Verify:
journalctl -u pegaprox -n 50 | grep '\[KEYSTORE\]' # → [KEYSTORE] master key source: systemd-credential (/run/credentials/pegaprox.service/db-key) -
Once verified, delete the legacy file if you migrated from Tier 6:
sudo shred -u /etc/pegaprox/secret.key # or config/.pegaprox.key
Recovering from a TPM2 reset
A motherboard swap, BIOS reset, or tpm2_clear will permanently
invalidate a TPM2-bound credential. Keep an offline copy of the
plaintext key (Tier 1-style backup) in your password vault for
exactly this case.
6. Known limitations
- No automatic key rotation. Auto-migration encrypts a plain DB once;
rotating an existing encryption key still means: stop service, re-encrypt
with new key via
--migrate-db --rotate-from=<old-key-file>, restart. The--rotate-fromflag is on the roadmap; for now it's manual ATTACH +sqlcipher_export. - Field-level Fernet keys are not separately rotated. Master key rotation does not re-wrap existing Fernet-encrypted fields. This is acceptable since both rely on the same master key — when the master key changes, both layers change together.
- No second-factor at the DB layer. A compromised host running PegaProx can read the DB. Compartmentalization (running clusters in separate VMs with separate keys) is the only mitigation.
- Plugin compatibility. First-party plugins use
dbcrypto.connect()and work transparently. Third-party community plugins that callsqlite3.connect()directly will not be able to read an encrypted DB — they need to be updated.
Maintainers: NS / MK. Last revised on the 0.9.9.3 SQLCipher rollout.