feat: column-level encryption at rest with AES-256-GCM (#98)

- Add EncryptionService (AES-256-GCM) with passthrough when no key set
- Flyway V3: enable pgcrypto extension + widen name columns to TEXT
- DatabaseFactory: init EncryptionService from BOLLWERK_DB_ENCRYPTION_KEY,
  run migrateEncryptData() to encrypt existing plaintext rows on startup
- InventoryRepository: encrypt on write, decrypt on read for
  items.name, items.notes, categories.name, locations.name, settings.value
- MessageRepository: encrypt body on write, decrypt on read
- docker-compose.yml: document BOLLWERK_DB_ENCRYPTION_KEY env var
- docker-compose-vps.yml: pass BOLLWERK_DB_ENCRYPTION_KEY from .env
- .env.example: add key generation template
- .gitignore: add .env to ignore list

Closes #98
This commit is contained in:
Jens Reinemann 2026-05-17 22:17:10 +02:00
parent 045a4b7674
commit 90cfac70a0
9 changed files with 195 additions and 27 deletions

17
.env.example Normal file
View file

@ -0,0 +1,17 @@
# Bollwerk Server Umgebungsvariablen
# Kopiere diese Datei als `.env` auf den VPS und trage echte Werte ein.
# WARNUNG: .env darf NIEMALS ins Git-Repository committed werden (steht in .gitignore).
# Datenbank-Passwort
BOLLWERK_DB_PASSWORD=change-me-to-a-strong-password
# Initialer Admin-Passwort (nur beim ersten Start genutzt)
BOLLWERK_ADMIN_PASSWORD=change-me-to-a-strong-admin-password
# JWT-Signing-Key (min. 32 Zeichen)
BOLLWERK_JWT_SECRET=change-me-to-a-secure-jwt-secret-at-least-32-chars
# Column-Level Encryption Key (AES-256, base64-kodiert)
# Generieren mit: openssl rand -base64 32
# WICHTIG: Diesen Key sicher aufbewahren ohne ihn sind die verschlüsselten Daten nicht lesbar!
BOLLWERK_DB_ENCRYPTION_KEY=

4
.gitignore vendored
View file

@ -32,3 +32,7 @@ server/data/
# Copilot memories (session-only)
memories/session/
# Environment secrets (never commit)
.env

View file

@ -23,6 +23,10 @@ services:
- BOLLWERK_DB_URL=jdbc:postgresql://db:5432/bollwerk
- BOLLWERK_DB_USER=bollwerk
- BOLLWERK_DB_PASSWORD=bollwerk
# Set BOLLWERK_DB_ENCRYPTION_KEY to enable column-level encryption.
# Generate with: openssl rand -base64 32
# Leave empty to disable encryption (passthrough mode).
# - BOLLWERK_DB_ENCRYPTION_KEY=${BOLLWERK_DB_ENCRYPTION_KEY}
volumes:
- backup_data:/backups:ro
depends_on:

View file

@ -25,6 +25,7 @@ internal object DatabaseFactory {
dbUser: String = System.getenv("BOLLWERK_DB_USER") ?: "bollwerk",
dbPassword: String = System.getenv("BOLLWERK_DB_PASSWORD") ?: "bollwerk",
adminPassword: String? = System.getenv("BOLLWERK_ADMIN_PASSWORD"),
encryptionKey: String? = System.getenv("BOLLWERK_DB_ENCRYPTION_KEY"),
usePool: Boolean = !driver.contains("h2", ignoreCase = true)
) {
if (usePool) {
@ -49,6 +50,8 @@ internal object DatabaseFactory {
SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages, DeletedItems)
}
migrateUserInventories()
EncryptionService.init(encryptionKey)
migrateEncryptData()
seedAdmin(adminPassword)
}
@ -96,6 +99,66 @@ internal object DatabaseFactory {
}
}
/**
* Encrypts existing plaintext rows in all sensitive columns.
* Skips rows that are already encrypted (detected by the ENC: prefix).
* No-op if encryption is disabled (no key configured).
*/
private fun migrateEncryptData() {
if (!EncryptionService.isEnabled) return
logger.info("Starting encryption migration for existing plaintext data...")
transaction {
Items.selectAll().forEach { row ->
val id = row[Items.id]
val name = row[Items.name]
val notes = row[Items.notes]
if (!EncryptionService.isEncrypted(name) || !EncryptionService.isEncrypted(notes)) {
Items.update({ Items.id eq id }) {
if (!EncryptionService.isEncrypted(name)) it[Items.name] = EncryptionService.encrypt(name)
if (!EncryptionService.isEncrypted(notes)) it[Items.notes] = EncryptionService.encrypt(notes)
}
}
}
Categories.selectAll().forEach { row ->
val pk = row[Categories.pk]
val name = row[Categories.name]
if (!EncryptionService.isEncrypted(name)) {
Categories.update({ Categories.pk eq pk }) {
it[Categories.name] = EncryptionService.encrypt(name)
}
}
}
Locations.selectAll().forEach { row ->
val pk = row[Locations.pk]
val name = row[Locations.name]
if (!EncryptionService.isEncrypted(name)) {
Locations.update({ Locations.pk eq pk }) {
it[Locations.name] = EncryptionService.encrypt(name)
}
}
}
Settings.selectAll().forEach { row ->
val id = row[Settings.id]
val value = row[Settings.value]
if (!EncryptionService.isEncrypted(value)) {
Settings.update({ Settings.id eq id }) {
it[Settings.value] = EncryptionService.encrypt(value)
}
}
}
Messages.selectAll().forEach { row ->
val id = row[Messages.id]
val body = row[Messages.body]
if (!EncryptionService.isEncrypted(body)) {
Messages.update({ Messages.id eq id }) {
it[Messages.body] = EncryptionService.encrypt(body)
}
}
}
}
logger.info("Encryption migration complete.")
}
private fun seedAdmin(adminPassword: String?) {
transaction {
val adminExists = Users.selectAll().where { Users.username eq "admin" }.count() > 0

View file

@ -0,0 +1,67 @@
package de.bollwerk.server.db
import java.security.SecureRandom
import java.util.Base64
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* AES-256-GCM column-level encryption service.
*
* Key is loaded from BOLLWERK_DB_ENCRYPTION_KEY (base64-encoded 32 bytes,
* generated with: openssl rand -base64 32).
*
* If no key is configured, encrypt/decrypt are transparent pass-throughs
* so existing deployments and unit tests work without changes.
*
* Ciphertext format: "ENC:" + Base64(12-byte-IV || ciphertext || 16-byte-GCM-tag)
*/
internal object EncryptionService {
private const val ENC_PREFIX = "ENC:"
private const val ALGORITHM = "AES/GCM/NoPadding"
private const val KEY_ALGORITHM = "AES"
private const val IV_LENGTH_BYTES = 12
private const val TAG_LENGTH_BITS = 128
private var secretKey: SecretKey? = null
val isEnabled: Boolean get() = secretKey != null
fun init(keyBase64: String?) {
if (keyBase64.isNullOrBlank()) {
secretKey = null
return
}
val keyBytes = Base64.getDecoder().decode(keyBase64)
require(keyBytes.size == 32) {
"BOLLWERK_DB_ENCRYPTION_KEY must decode to exactly 32 bytes. Generate with: openssl rand -base64 32"
}
secretKey = SecretKeySpec(keyBytes, KEY_ALGORITHM)
}
fun encrypt(plaintext: String): String {
val key = secretKey ?: return plaintext
val iv = ByteArray(IV_LENGTH_BYTES).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance(ALGORITHM)
cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH_BITS, iv))
val cipherBytes = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val combined = iv + cipherBytes
return ENC_PREFIX + Base64.getEncoder().encodeToString(combined)
}
fun decrypt(value: String): String {
if (!value.startsWith(ENC_PREFIX)) return value
val key = secretKey ?: return value
val combined = Base64.getDecoder().decode(value.removePrefix(ENC_PREFIX))
val iv = combined.sliceArray(0 until IV_LENGTH_BYTES)
val cipherBytes = combined.sliceArray(IV_LENGTH_BYTES until combined.size)
val cipher = Cipher.getInstance(ALGORITHM)
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_LENGTH_BITS, iv))
return cipher.doFinal(cipherBytes).toString(Charsets.UTF_8)
}
fun isEncrypted(value: String): Boolean = value.startsWith(ENC_PREFIX)
}

View file

@ -23,7 +23,7 @@ internal object Users : Table("users") {
internal object Categories : Table("categories") {
val pk = integer("pk").autoIncrement()
val id = integer("id")
val name = varchar("name", 255)
val name = text("name")
val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(pk)
@ -32,7 +32,7 @@ internal object Categories : Table("categories") {
internal object Locations : Table("locations") {
val pk = integer("pk").autoIncrement()
val id = integer("id")
val name = varchar("name", 255)
val name = text("name")
val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(pk)
@ -40,7 +40,7 @@ internal object Locations : Table("locations") {
internal object Items : Table("items") {
val id = varchar("id", 36)
val name = varchar("name", 255)
val name = text("name")
val categoryId = integer("category_id")
val quantity = double("quantity")
val unit = varchar("unit", 50)

View file

@ -2,6 +2,7 @@ package de.bollwerk.server.repository
import de.bollwerk.server.db.Categories
import de.bollwerk.server.db.DeletedItems
import de.bollwerk.server.db.EncryptionService
import de.bollwerk.server.db.Inventories
import de.bollwerk.server.db.Items
import de.bollwerk.server.db.Locations
@ -160,7 +161,7 @@ internal class InventoryRepository {
for (category in inventory.categories) {
Categories.insert {
it[id] = category.id
it[name] = category.name
it[name] = EncryptionService.encrypt(category.name)
it[Categories.inventoryId] = inventoryId
}
}
@ -168,7 +169,7 @@ internal class InventoryRepository {
for (location in inventory.locations) {
Locations.insert {
it[id] = location.id
it[name] = location.name
it[name] = EncryptionService.encrypt(location.name)
it[Locations.inventoryId] = inventoryId
}
}
@ -176,7 +177,7 @@ internal class InventoryRepository {
for (item in inventory.items) {
Items.insert {
it[id] = item.id
it[name] = item.name
it[name] = EncryptionService.encrypt(item.name)
it[categoryId] = item.categoryId
it[quantity] = item.quantity
it[unit] = item.unit
@ -184,7 +185,7 @@ internal class InventoryRepository {
it[kcalPerUnit] = item.kcalPerUnit
it[expiryDate] = item.expiryDate
it[locationId] = item.locationId
it[notes] = item.notes
it[notes] = EncryptionService.encrypt(item.notes)
it[lastUpdated] = item.lastUpdated
it[Items.inventoryId] = inventoryId
}
@ -193,7 +194,7 @@ internal class InventoryRepository {
for (setting in inventory.settings) {
Settings.insert {
it[key] = setting.key
it[value] = setting.value
it[value] = EncryptionService.encrypt(setting.value)
it[Settings.inventoryId] = inventoryId
}
}
@ -204,18 +205,18 @@ internal class InventoryRepository {
return transaction {
val categories = Categories.selectAll()
.where { Categories.inventoryId eq inventoryId }
.map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) }
.map { CategoryDto(id = it[Categories.id], name = EncryptionService.decrypt(it[Categories.name])) }
val locations = Locations.selectAll()
.where { Locations.inventoryId eq inventoryId }
.map { LocationDto(id = it[Locations.id], name = it[Locations.name]) }
.map { LocationDto(id = it[Locations.id], name = EncryptionService.decrypt(it[Locations.name])) }
val items = Items.selectAll()
.where { Items.inventoryId eq inventoryId }
.map {
ItemDto(
id = it[Items.id],
name = it[Items.name],
name = EncryptionService.decrypt(it[Items.name]),
categoryId = it[Items.categoryId],
quantity = it[Items.quantity],
unit = it[Items.unit],
@ -223,14 +224,14 @@ internal class InventoryRepository {
kcalPerUnit = it[Items.kcalPerUnit],
expiryDate = it[Items.expiryDate],
locationId = it[Items.locationId],
notes = it[Items.notes],
notes = EncryptionService.decrypt(it[Items.notes]),
lastUpdated = it[Items.lastUpdated]
)
}
val settings = Settings.selectAll()
.where { Settings.inventoryId eq inventoryId }
.map { SettingDto(key = it[Settings.key], value = it[Settings.value]) }
.map { SettingDto(key = it[Settings.key], value = EncryptionService.decrypt(it[Settings.value])) }
InventoryDto(categories = categories, locations = locations, items = items, settings = settings)
}
@ -241,7 +242,7 @@ internal class InventoryRepository {
val updated = Items.update({
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
}) {
it[name] = item.name
it[name] = EncryptionService.encrypt(item.name)
it[categoryId] = item.categoryId
it[quantity] = item.quantity
it[unit] = item.unit
@ -249,7 +250,7 @@ internal class InventoryRepository {
it[kcalPerUnit] = item.kcalPerUnit
it[expiryDate] = item.expiryDate
it[locationId] = item.locationId
it[notes] = item.notes
it[notes] = EncryptionService.encrypt(item.notes)
it[lastUpdated] = item.lastUpdated
}
updated > 0
@ -270,7 +271,7 @@ internal class InventoryRepository {
Items.update({
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
}) { stmt ->
if ("name" in fields) stmt[name] = fields["name"]!!.jsonPrimitive.content
if ("name" in fields) stmt[name] = EncryptionService.encrypt(fields["name"]!!.jsonPrimitive.content)
if ("categoryId" in fields) stmt[categoryId] = fields["categoryId"]!!.jsonPrimitive.intOrNull ?: 0
if ("quantity" in fields) stmt[quantity] = fields["quantity"]!!.jsonPrimitive.doubleOrNull ?: 0.0
if ("unit" in fields) stmt[unit] = fields["unit"]!!.jsonPrimitive.content
@ -278,7 +279,7 @@ internal class InventoryRepository {
if ("kcalPerUnit" in fields) stmt[kcalPerUnit] = fields["kcalPerUnit"]!!.jsonPrimitive.intOrNull
if ("expiryDate" in fields) stmt[expiryDate] = fields["expiryDate"]!!.jsonPrimitive.contentOrNull
if ("locationId" in fields) stmt[locationId] = fields["locationId"]!!.jsonPrimitive.intOrNull ?: 0
if ("notes" in fields) stmt[notes] = fields["notes"]!!.jsonPrimitive.content
if ("notes" in fields) stmt[notes] = EncryptionService.encrypt(fields["notes"]!!.jsonPrimitive.content)
if ("lastUpdated" in fields) stmt[lastUpdated] = fields["lastUpdated"]!!.jsonPrimitive.longOrNull ?: System.currentTimeMillis()
}
true
@ -292,7 +293,7 @@ internal class InventoryRepository {
.map {
ItemDto(
id = it[Items.id],
name = it[Items.name],
name = EncryptionService.decrypt(it[Items.name]),
categoryId = it[Items.categoryId],
quantity = it[Items.quantity],
unit = it[Items.unit],
@ -300,7 +301,7 @@ internal class InventoryRepository {
kcalPerUnit = it[Items.kcalPerUnit],
expiryDate = it[Items.expiryDate],
locationId = it[Items.locationId],
notes = it[Items.notes],
notes = EncryptionService.decrypt(it[Items.notes]),
lastUpdated = it[Items.lastUpdated]
)
}
@ -328,18 +329,18 @@ internal class InventoryRepository {
return transaction {
val categories = Categories.selectAll()
.where { Categories.inventoryId eq inventoryId }
.map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) }
.map { CategoryDto(id = it[Categories.id], name = EncryptionService.decrypt(it[Categories.name])) }
val locations = Locations.selectAll()
.where { Locations.inventoryId eq inventoryId }
.map { LocationDto(id = it[Locations.id], name = it[Locations.name]) }
.map { LocationDto(id = it[Locations.id], name = EncryptionService.decrypt(it[Locations.name])) }
val items = Items.selectAll()
.where { (Items.inventoryId eq inventoryId) and (Items.lastUpdated greater since) }
.map {
ItemDto(
id = it[Items.id],
name = it[Items.name],
name = EncryptionService.decrypt(it[Items.name]),
categoryId = it[Items.categoryId],
quantity = it[Items.quantity],
unit = it[Items.unit],
@ -347,14 +348,14 @@ internal class InventoryRepository {
kcalPerUnit = it[Items.kcalPerUnit],
expiryDate = it[Items.expiryDate],
locationId = it[Items.locationId],
notes = it[Items.notes],
notes = EncryptionService.decrypt(it[Items.notes]),
lastUpdated = it[Items.lastUpdated]
)
}
val settings = Settings.selectAll()
.where { Settings.inventoryId eq inventoryId }
.map { SettingDto(key = it[Settings.key], value = it[Settings.value]) }
.map { SettingDto(key = it[Settings.key], value = EncryptionService.decrypt(it[Settings.value])) }
val deletedItemIds = DeletedItems.selectAll()
.where { (DeletedItems.inventoryId eq inventoryId) and (DeletedItems.deletedAt greater since) }

View file

@ -1,5 +1,6 @@
package de.bollwerk.server.repository
import de.bollwerk.server.db.EncryptionService
import de.bollwerk.server.db.Messages
import de.bollwerk.server.db.Users
import de.bollwerk.shared.model.MessageDto
@ -28,7 +29,7 @@ internal class MessageRepository {
it[Messages.id] = id
it[Messages.senderId] = senderId
it[Messages.receiverId] = receiverId
it[Messages.body] = body
it[Messages.body] = EncryptionService.encrypt(body)
it[Messages.sentAt] = sentAt
it[Messages.deliveredAt] = null
}
@ -54,7 +55,7 @@ internal class MessageRepository {
senderId = row[Messages.senderId],
senderUsername = row.getOrNull(Users.username) ?: "",
receiverId = row[Messages.receiverId],
body = row[Messages.body],
body = EncryptionService.decrypt(row[Messages.body]),
sentAt = row[Messages.sentAt],
deliveredAt = row[Messages.deliveredAt]
)
@ -93,7 +94,7 @@ internal class MessageRepository {
senderId = row[Messages.senderId],
senderUsername = row.getOrNull(Users.username) ?: "",
receiverId = row[Messages.receiverId],
body = row[Messages.body],
body = EncryptionService.decrypt(row[Messages.body]),
sentAt = row[Messages.sentAt],
deliveredAt = row[Messages.deliveredAt]
)

View file

@ -0,0 +1,11 @@
-- V3: Enable pgcrypto extension for column-level encryption support.
-- Widen name columns from VARCHAR(255) to TEXT to accommodate base64-encoded ciphertext
-- (encrypted ciphertext for a 255-char plaintext is ~384 chars, exceeds VARCHAR(255)).
-- Encryption itself is handled in the Ktor application layer (EncryptionService.kt).
-- Existing data migration (encrypt plaintext rows) is performed in DatabaseFactory.migrateEncryptData().
CREATE EXTENSION IF NOT EXISTS pgcrypto;
ALTER TABLE items ALTER COLUMN name TYPE TEXT;
ALTER TABLE categories ALTER COLUMN name TYPE TEXT;
ALTER TABLE locations ALTER COLUMN name TYPE TEXT;