Skip to content

Encrypt stored WireGuard configs with a Keystore key - #16

Merged
grifmo merged 2 commits into
mainfrom
security/key-storage
Oct 10, 2026
Merged

grifmo merged 2 commits into
mainfrom
security/key-storage

Conversation

@grifmo

@grifmo grifmo commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Fixes #13. Stacked on #14 (it needs VpnProfileDao.update() from that branch); GitHub will retarget this PR to main once #14 is merged.

What changed

  • ConfigCipher: AES-256-GCM with a non-exportable Android Keystore key. Stored format enc:v1: + Base64(IV + ciphertext); a fresh IV per write.
  • VpnProfileDao is now an abstract class that encrypts on insert/update and decrypts on read. Every caller keeps using the same methods, so no call site can forget to encrypt.
  • LegacyConfigEncryption runs once inside AppDatabase.get() before Room opens the file: it opens the database without WAL (which checkpoints and removes the old WAL), rewrites plaintext rows with secure_delete on, and runs VACUUM.
  • Unencrypted rows are still readable, so nothing breaks if that step fails. If decryption ever fails (e.g. the Keystore key is lost), the config reads as empty and the tunnel reports a start failure rather than crashing.
  • SECURITY.md and the README roadmap updated; SQLCipher is no longer planned.

Why not SQLCipher: it adds ~7 MB of native libraries and still needs its passphrase protected by a Keystore key.

Testing

Instrumented (Android TV emulator, API 30), ConfigEncryptionTest: round trip with fresh IVs; the database holds only ciphertext (checked with raw SQL) while the DAO returns the plain config; legacy plaintext rows stay readable; the legacy conversion leaves no PrivateKey bytes in the db, WAL or journal file; corrupt ciphertext reads as empty. Full suite: 18 instrumented + 14 unit tests pass.

Real upgrade on the emulator: pre-encryption build running with three plaintext profiles (PrivateKey 6 times across univpn.db and its live WAL) → install this build → Encrypted 3 stored configs, the database file and WAL contain PrivateKey 0 times, and tunnels start from the decrypted configs.

Note: the file-level test also passes without secure_delete/VACUUM; closing the test's connection already removes its WAL. The real upgrade above is the case that left plaintext behind before this approach (an earlier version converted rows through Room and left the old pages in the WAL).

🤖 Generated with Claude Code

grifmo and others added 2 commits October 10, 2026 10:17
Profile configs, including WireGuard private keys, were stored in plain
text in the Room database. VpnProfileDao now encrypts configContent with
AES-256-GCM on the way in and decrypts it on the way out, so callers
are unchanged and the database only holds ciphertext. The key is
generated in the Android Keystore and can't be exported.

Configs saved by earlier versions are encrypted before Room opens the
database: with no other connection open the WAL is checkpointed away,
rows are overwritten with secure_delete on, and the file is vacuumed,
so no plaintext key is left on disk. Unencrypted rows remain readable
in case that step fails.

Fixes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	univpn/src/main/kotlin/com/univpn/app/data/db/AppDatabase.kt
@grifmo
grifmo deleted the branch main October 10, 2026 08:48
@grifmo grifmo closed this Oct 10, 2026
@grifmo grifmo reopened this Oct 10, 2026
@grifmo
grifmo changed the base branch from fix/tunnel-state to main October 10, 2026 08:49
@grifmo
grifmo merged commit 6d10555 into main Oct 10, 2026
3 checks passed
@grifmo
grifmo deleted the security/key-storage branch October 10, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WireGuard private keys are stored in plain text in the profile database

1 participant