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:
parent
045a4b7674
commit
90cfac70a0
9 changed files with 195 additions and 27 deletions
17
.env.example
Normal file
17
.env.example
Normal 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
4
.gitignore
vendored
|
|
@ -32,3 +32,7 @@ server/data/
|
||||||
|
|
||||||
# Copilot memories (session-only)
|
# Copilot memories (session-only)
|
||||||
memories/session/
|
memories/session/
|
||||||
|
|
||||||
|
# Environment secrets (never commit)
|
||||||
|
.env
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ services:
|
||||||
- BOLLWERK_DB_URL=jdbc:postgresql://db:5432/bollwerk
|
- BOLLWERK_DB_URL=jdbc:postgresql://db:5432/bollwerk
|
||||||
- BOLLWERK_DB_USER=bollwerk
|
- BOLLWERK_DB_USER=bollwerk
|
||||||
- BOLLWERK_DB_PASSWORD=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:
|
volumes:
|
||||||
- backup_data:/backups:ro
|
- backup_data:/backups:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ internal object DatabaseFactory {
|
||||||
dbUser: String = System.getenv("BOLLWERK_DB_USER") ?: "bollwerk",
|
dbUser: String = System.getenv("BOLLWERK_DB_USER") ?: "bollwerk",
|
||||||
dbPassword: String = System.getenv("BOLLWERK_DB_PASSWORD") ?: "bollwerk",
|
dbPassword: String = System.getenv("BOLLWERK_DB_PASSWORD") ?: "bollwerk",
|
||||||
adminPassword: String? = System.getenv("BOLLWERK_ADMIN_PASSWORD"),
|
adminPassword: String? = System.getenv("BOLLWERK_ADMIN_PASSWORD"),
|
||||||
|
encryptionKey: String? = System.getenv("BOLLWERK_DB_ENCRYPTION_KEY"),
|
||||||
usePool: Boolean = !driver.contains("h2", ignoreCase = true)
|
usePool: Boolean = !driver.contains("h2", ignoreCase = true)
|
||||||
) {
|
) {
|
||||||
if (usePool) {
|
if (usePool) {
|
||||||
|
|
@ -49,6 +50,8 @@ internal object DatabaseFactory {
|
||||||
SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages, DeletedItems)
|
SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages, DeletedItems)
|
||||||
}
|
}
|
||||||
migrateUserInventories()
|
migrateUserInventories()
|
||||||
|
EncryptionService.init(encryptionKey)
|
||||||
|
migrateEncryptData()
|
||||||
seedAdmin(adminPassword)
|
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?) {
|
private fun seedAdmin(adminPassword: String?) {
|
||||||
transaction {
|
transaction {
|
||||||
val adminExists = Users.selectAll().where { Users.username eq "admin" }.count() > 0
|
val adminExists = Users.selectAll().where { Users.username eq "admin" }.count() > 0
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ internal object Users : Table("users") {
|
||||||
internal object Categories : Table("categories") {
|
internal object Categories : Table("categories") {
|
||||||
val pk = integer("pk").autoIncrement()
|
val pk = integer("pk").autoIncrement()
|
||||||
val id = integer("id")
|
val id = integer("id")
|
||||||
val name = varchar("name", 255)
|
val name = text("name")
|
||||||
val inventoryId = varchar("inventory_id", 36).nullable()
|
val inventoryId = varchar("inventory_id", 36).nullable()
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(pk)
|
override val primaryKey = PrimaryKey(pk)
|
||||||
|
|
@ -32,7 +32,7 @@ internal object Categories : Table("categories") {
|
||||||
internal object Locations : Table("locations") {
|
internal object Locations : Table("locations") {
|
||||||
val pk = integer("pk").autoIncrement()
|
val pk = integer("pk").autoIncrement()
|
||||||
val id = integer("id")
|
val id = integer("id")
|
||||||
val name = varchar("name", 255)
|
val name = text("name")
|
||||||
val inventoryId = varchar("inventory_id", 36).nullable()
|
val inventoryId = varchar("inventory_id", 36).nullable()
|
||||||
|
|
||||||
override val primaryKey = PrimaryKey(pk)
|
override val primaryKey = PrimaryKey(pk)
|
||||||
|
|
@ -40,7 +40,7 @@ internal object Locations : Table("locations") {
|
||||||
|
|
||||||
internal object Items : Table("items") {
|
internal object Items : Table("items") {
|
||||||
val id = varchar("id", 36)
|
val id = varchar("id", 36)
|
||||||
val name = varchar("name", 255)
|
val name = text("name")
|
||||||
val categoryId = integer("category_id")
|
val categoryId = integer("category_id")
|
||||||
val quantity = double("quantity")
|
val quantity = double("quantity")
|
||||||
val unit = varchar("unit", 50)
|
val unit = varchar("unit", 50)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package de.bollwerk.server.repository
|
||||||
|
|
||||||
import de.bollwerk.server.db.Categories
|
import de.bollwerk.server.db.Categories
|
||||||
import de.bollwerk.server.db.DeletedItems
|
import de.bollwerk.server.db.DeletedItems
|
||||||
|
import de.bollwerk.server.db.EncryptionService
|
||||||
import de.bollwerk.server.db.Inventories
|
import de.bollwerk.server.db.Inventories
|
||||||
import de.bollwerk.server.db.Items
|
import de.bollwerk.server.db.Items
|
||||||
import de.bollwerk.server.db.Locations
|
import de.bollwerk.server.db.Locations
|
||||||
|
|
@ -160,7 +161,7 @@ internal class InventoryRepository {
|
||||||
for (category in inventory.categories) {
|
for (category in inventory.categories) {
|
||||||
Categories.insert {
|
Categories.insert {
|
||||||
it[id] = category.id
|
it[id] = category.id
|
||||||
it[name] = category.name
|
it[name] = EncryptionService.encrypt(category.name)
|
||||||
it[Categories.inventoryId] = inventoryId
|
it[Categories.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,7 +169,7 @@ internal class InventoryRepository {
|
||||||
for (location in inventory.locations) {
|
for (location in inventory.locations) {
|
||||||
Locations.insert {
|
Locations.insert {
|
||||||
it[id] = location.id
|
it[id] = location.id
|
||||||
it[name] = location.name
|
it[name] = EncryptionService.encrypt(location.name)
|
||||||
it[Locations.inventoryId] = inventoryId
|
it[Locations.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +177,7 @@ internal class InventoryRepository {
|
||||||
for (item in inventory.items) {
|
for (item in inventory.items) {
|
||||||
Items.insert {
|
Items.insert {
|
||||||
it[id] = item.id
|
it[id] = item.id
|
||||||
it[name] = item.name
|
it[name] = EncryptionService.encrypt(item.name)
|
||||||
it[categoryId] = item.categoryId
|
it[categoryId] = item.categoryId
|
||||||
it[quantity] = item.quantity
|
it[quantity] = item.quantity
|
||||||
it[unit] = item.unit
|
it[unit] = item.unit
|
||||||
|
|
@ -184,7 +185,7 @@ internal class InventoryRepository {
|
||||||
it[kcalPerUnit] = item.kcalPerUnit
|
it[kcalPerUnit] = item.kcalPerUnit
|
||||||
it[expiryDate] = item.expiryDate
|
it[expiryDate] = item.expiryDate
|
||||||
it[locationId] = item.locationId
|
it[locationId] = item.locationId
|
||||||
it[notes] = item.notes
|
it[notes] = EncryptionService.encrypt(item.notes)
|
||||||
it[lastUpdated] = item.lastUpdated
|
it[lastUpdated] = item.lastUpdated
|
||||||
it[Items.inventoryId] = inventoryId
|
it[Items.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +194,7 @@ internal class InventoryRepository {
|
||||||
for (setting in inventory.settings) {
|
for (setting in inventory.settings) {
|
||||||
Settings.insert {
|
Settings.insert {
|
||||||
it[key] = setting.key
|
it[key] = setting.key
|
||||||
it[value] = setting.value
|
it[value] = EncryptionService.encrypt(setting.value)
|
||||||
it[Settings.inventoryId] = inventoryId
|
it[Settings.inventoryId] = inventoryId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,18 +205,18 @@ internal class InventoryRepository {
|
||||||
return transaction {
|
return transaction {
|
||||||
val categories = Categories.selectAll()
|
val categories = Categories.selectAll()
|
||||||
.where { Categories.inventoryId eq inventoryId }
|
.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()
|
val locations = Locations.selectAll()
|
||||||
.where { Locations.inventoryId eq inventoryId }
|
.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()
|
val items = Items.selectAll()
|
||||||
.where { Items.inventoryId eq inventoryId }
|
.where { Items.inventoryId eq inventoryId }
|
||||||
.map {
|
.map {
|
||||||
ItemDto(
|
ItemDto(
|
||||||
id = it[Items.id],
|
id = it[Items.id],
|
||||||
name = it[Items.name],
|
name = EncryptionService.decrypt(it[Items.name]),
|
||||||
categoryId = it[Items.categoryId],
|
categoryId = it[Items.categoryId],
|
||||||
quantity = it[Items.quantity],
|
quantity = it[Items.quantity],
|
||||||
unit = it[Items.unit],
|
unit = it[Items.unit],
|
||||||
|
|
@ -223,14 +224,14 @@ internal class InventoryRepository {
|
||||||
kcalPerUnit = it[Items.kcalPerUnit],
|
kcalPerUnit = it[Items.kcalPerUnit],
|
||||||
expiryDate = it[Items.expiryDate],
|
expiryDate = it[Items.expiryDate],
|
||||||
locationId = it[Items.locationId],
|
locationId = it[Items.locationId],
|
||||||
notes = it[Items.notes],
|
notes = EncryptionService.decrypt(it[Items.notes]),
|
||||||
lastUpdated = it[Items.lastUpdated]
|
lastUpdated = it[Items.lastUpdated]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val settings = Settings.selectAll()
|
val settings = Settings.selectAll()
|
||||||
.where { Settings.inventoryId eq inventoryId }
|
.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)
|
InventoryDto(categories = categories, locations = locations, items = items, settings = settings)
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +242,7 @@ internal class InventoryRepository {
|
||||||
val updated = Items.update({
|
val updated = Items.update({
|
||||||
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
|
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
|
||||||
}) {
|
}) {
|
||||||
it[name] = item.name
|
it[name] = EncryptionService.encrypt(item.name)
|
||||||
it[categoryId] = item.categoryId
|
it[categoryId] = item.categoryId
|
||||||
it[quantity] = item.quantity
|
it[quantity] = item.quantity
|
||||||
it[unit] = item.unit
|
it[unit] = item.unit
|
||||||
|
|
@ -249,7 +250,7 @@ internal class InventoryRepository {
|
||||||
it[kcalPerUnit] = item.kcalPerUnit
|
it[kcalPerUnit] = item.kcalPerUnit
|
||||||
it[expiryDate] = item.expiryDate
|
it[expiryDate] = item.expiryDate
|
||||||
it[locationId] = item.locationId
|
it[locationId] = item.locationId
|
||||||
it[notes] = item.notes
|
it[notes] = EncryptionService.encrypt(item.notes)
|
||||||
it[lastUpdated] = item.lastUpdated
|
it[lastUpdated] = item.lastUpdated
|
||||||
}
|
}
|
||||||
updated > 0
|
updated > 0
|
||||||
|
|
@ -270,7 +271,7 @@ internal class InventoryRepository {
|
||||||
Items.update({
|
Items.update({
|
||||||
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
|
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
|
||||||
}) { stmt ->
|
}) { 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 ("categoryId" in fields) stmt[categoryId] = fields["categoryId"]!!.jsonPrimitive.intOrNull ?: 0
|
||||||
if ("quantity" in fields) stmt[quantity] = fields["quantity"]!!.jsonPrimitive.doubleOrNull ?: 0.0
|
if ("quantity" in fields) stmt[quantity] = fields["quantity"]!!.jsonPrimitive.doubleOrNull ?: 0.0
|
||||||
if ("unit" in fields) stmt[unit] = fields["unit"]!!.jsonPrimitive.content
|
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 ("kcalPerUnit" in fields) stmt[kcalPerUnit] = fields["kcalPerUnit"]!!.jsonPrimitive.intOrNull
|
||||||
if ("expiryDate" in fields) stmt[expiryDate] = fields["expiryDate"]!!.jsonPrimitive.contentOrNull
|
if ("expiryDate" in fields) stmt[expiryDate] = fields["expiryDate"]!!.jsonPrimitive.contentOrNull
|
||||||
if ("locationId" in fields) stmt[locationId] = fields["locationId"]!!.jsonPrimitive.intOrNull ?: 0
|
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()
|
if ("lastUpdated" in fields) stmt[lastUpdated] = fields["lastUpdated"]!!.jsonPrimitive.longOrNull ?: System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
|
|
@ -292,7 +293,7 @@ internal class InventoryRepository {
|
||||||
.map {
|
.map {
|
||||||
ItemDto(
|
ItemDto(
|
||||||
id = it[Items.id],
|
id = it[Items.id],
|
||||||
name = it[Items.name],
|
name = EncryptionService.decrypt(it[Items.name]),
|
||||||
categoryId = it[Items.categoryId],
|
categoryId = it[Items.categoryId],
|
||||||
quantity = it[Items.quantity],
|
quantity = it[Items.quantity],
|
||||||
unit = it[Items.unit],
|
unit = it[Items.unit],
|
||||||
|
|
@ -300,7 +301,7 @@ internal class InventoryRepository {
|
||||||
kcalPerUnit = it[Items.kcalPerUnit],
|
kcalPerUnit = it[Items.kcalPerUnit],
|
||||||
expiryDate = it[Items.expiryDate],
|
expiryDate = it[Items.expiryDate],
|
||||||
locationId = it[Items.locationId],
|
locationId = it[Items.locationId],
|
||||||
notes = it[Items.notes],
|
notes = EncryptionService.decrypt(it[Items.notes]),
|
||||||
lastUpdated = it[Items.lastUpdated]
|
lastUpdated = it[Items.lastUpdated]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -328,18 +329,18 @@ internal class InventoryRepository {
|
||||||
return transaction {
|
return transaction {
|
||||||
val categories = Categories.selectAll()
|
val categories = Categories.selectAll()
|
||||||
.where { Categories.inventoryId eq inventoryId }
|
.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()
|
val locations = Locations.selectAll()
|
||||||
.where { Locations.inventoryId eq inventoryId }
|
.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()
|
val items = Items.selectAll()
|
||||||
.where { (Items.inventoryId eq inventoryId) and (Items.lastUpdated greater since) }
|
.where { (Items.inventoryId eq inventoryId) and (Items.lastUpdated greater since) }
|
||||||
.map {
|
.map {
|
||||||
ItemDto(
|
ItemDto(
|
||||||
id = it[Items.id],
|
id = it[Items.id],
|
||||||
name = it[Items.name],
|
name = EncryptionService.decrypt(it[Items.name]),
|
||||||
categoryId = it[Items.categoryId],
|
categoryId = it[Items.categoryId],
|
||||||
quantity = it[Items.quantity],
|
quantity = it[Items.quantity],
|
||||||
unit = it[Items.unit],
|
unit = it[Items.unit],
|
||||||
|
|
@ -347,14 +348,14 @@ internal class InventoryRepository {
|
||||||
kcalPerUnit = it[Items.kcalPerUnit],
|
kcalPerUnit = it[Items.kcalPerUnit],
|
||||||
expiryDate = it[Items.expiryDate],
|
expiryDate = it[Items.expiryDate],
|
||||||
locationId = it[Items.locationId],
|
locationId = it[Items.locationId],
|
||||||
notes = it[Items.notes],
|
notes = EncryptionService.decrypt(it[Items.notes]),
|
||||||
lastUpdated = it[Items.lastUpdated]
|
lastUpdated = it[Items.lastUpdated]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val settings = Settings.selectAll()
|
val settings = Settings.selectAll()
|
||||||
.where { Settings.inventoryId eq inventoryId }
|
.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()
|
val deletedItemIds = DeletedItems.selectAll()
|
||||||
.where { (DeletedItems.inventoryId eq inventoryId) and (DeletedItems.deletedAt greater since) }
|
.where { (DeletedItems.inventoryId eq inventoryId) and (DeletedItems.deletedAt greater since) }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.bollwerk.server.repository
|
package de.bollwerk.server.repository
|
||||||
|
|
||||||
|
import de.bollwerk.server.db.EncryptionService
|
||||||
import de.bollwerk.server.db.Messages
|
import de.bollwerk.server.db.Messages
|
||||||
import de.bollwerk.server.db.Users
|
import de.bollwerk.server.db.Users
|
||||||
import de.bollwerk.shared.model.MessageDto
|
import de.bollwerk.shared.model.MessageDto
|
||||||
|
|
@ -28,7 +29,7 @@ internal class MessageRepository {
|
||||||
it[Messages.id] = id
|
it[Messages.id] = id
|
||||||
it[Messages.senderId] = senderId
|
it[Messages.senderId] = senderId
|
||||||
it[Messages.receiverId] = receiverId
|
it[Messages.receiverId] = receiverId
|
||||||
it[Messages.body] = body
|
it[Messages.body] = EncryptionService.encrypt(body)
|
||||||
it[Messages.sentAt] = sentAt
|
it[Messages.sentAt] = sentAt
|
||||||
it[Messages.deliveredAt] = null
|
it[Messages.deliveredAt] = null
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +55,7 @@ internal class MessageRepository {
|
||||||
senderId = row[Messages.senderId],
|
senderId = row[Messages.senderId],
|
||||||
senderUsername = row.getOrNull(Users.username) ?: "",
|
senderUsername = row.getOrNull(Users.username) ?: "",
|
||||||
receiverId = row[Messages.receiverId],
|
receiverId = row[Messages.receiverId],
|
||||||
body = row[Messages.body],
|
body = EncryptionService.decrypt(row[Messages.body]),
|
||||||
sentAt = row[Messages.sentAt],
|
sentAt = row[Messages.sentAt],
|
||||||
deliveredAt = row[Messages.deliveredAt]
|
deliveredAt = row[Messages.deliveredAt]
|
||||||
)
|
)
|
||||||
|
|
@ -93,7 +94,7 @@ internal class MessageRepository {
|
||||||
senderId = row[Messages.senderId],
|
senderId = row[Messages.senderId],
|
||||||
senderUsername = row.getOrNull(Users.username) ?: "",
|
senderUsername = row.getOrNull(Users.username) ?: "",
|
||||||
receiverId = row[Messages.receiverId],
|
receiverId = row[Messages.receiverId],
|
||||||
body = row[Messages.body],
|
body = EncryptionService.decrypt(row[Messages.body]),
|
||||||
sentAt = row[Messages.sentAt],
|
sentAt = row[Messages.sentAt],
|
||||||
deliveredAt = row[Messages.deliveredAt]
|
deliveredAt = row[Messages.deliveredAt]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
11
server/src/main/resources/db/migration/V3__pgcrypto.sql
Normal file
11
server/src/main/resources/db/migration/V3__pgcrypto.sql
Normal 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;
|
||||||
Loading…
Reference in a new issue