diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33f58c5 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 5396c4c..67fe3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ server/data/ # Copilot memories (session-only) memories/session/ + +# Environment secrets (never commit) +.env + diff --git a/docker-compose.yml b/docker-compose.yml index 11c2b8c..655f766 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/server/src/main/kotlin/de/bollwerk/server/db/DatabaseFactory.kt b/server/src/main/kotlin/de/bollwerk/server/db/DatabaseFactory.kt index aa3d89f..8af74da 100644 --- a/server/src/main/kotlin/de/bollwerk/server/db/DatabaseFactory.kt +++ b/server/src/main/kotlin/de/bollwerk/server/db/DatabaseFactory.kt @@ -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 diff --git a/server/src/main/kotlin/de/bollwerk/server/db/EncryptionService.kt b/server/src/main/kotlin/de/bollwerk/server/db/EncryptionService.kt new file mode 100644 index 0000000..b1019fd --- /dev/null +++ b/server/src/main/kotlin/de/bollwerk/server/db/EncryptionService.kt @@ -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) +} diff --git a/server/src/main/kotlin/de/bollwerk/server/db/Tables.kt b/server/src/main/kotlin/de/bollwerk/server/db/Tables.kt index c84e8a5..d123b5a 100644 --- a/server/src/main/kotlin/de/bollwerk/server/db/Tables.kt +++ b/server/src/main/kotlin/de/bollwerk/server/db/Tables.kt @@ -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) diff --git a/server/src/main/kotlin/de/bollwerk/server/repository/InventoryRepository.kt b/server/src/main/kotlin/de/bollwerk/server/repository/InventoryRepository.kt index 544069e..069f550 100644 --- a/server/src/main/kotlin/de/bollwerk/server/repository/InventoryRepository.kt +++ b/server/src/main/kotlin/de/bollwerk/server/repository/InventoryRepository.kt @@ -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) } diff --git a/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt b/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt index 57bed79..8edf24c 100644 --- a/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt +++ b/server/src/main/kotlin/de/bollwerk/server/repository/MessageRepository.kt @@ -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] ) diff --git a/server/src/main/resources/db/migration/V3__pgcrypto.sql b/server/src/main/resources/db/migration/V3__pgcrypto.sql new file mode 100644 index 0000000..052b51a --- /dev/null +++ b/server/src/main/resources/db/migration/V3__pgcrypto.sql @@ -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;