From 015ee247b5cf7a5dbbf667801ea6ed5b44e93c9d Mon Sep 17 00:00:00 2001 From: Lanius Trolling Date: Sat, 21 Dec 2024 20:06:10 -0500 Subject: [PATCH] Add Argon2 for password hashing, starting with emergency (NS API outage) admin login --- build.gradle.kts | 2 + .../kotlin/info/mechyrdia/Configuration.kt | 80 ++++++++++++++++++- .../info/mechyrdia/auth/PasswordHashers.kt | 26 ++++++ .../kotlin/info/mechyrdia/data/ViewsFiles.kt | 2 - src/main/kotlin/info/mechyrdia/data/Xml.kt | 3 +- .../kotlin/info/mechyrdia/lore/FontAssets.kt | 3 +- .../mechyrdia/lore/ParserPreprocessInclude.kt | 5 +- .../mechyrdia/lore/ParserPreprocessJson.kt | 5 +- .../kotlin/info/mechyrdia/lore/ViewNav.kt | 3 +- .../kotlin/info/mechyrdia/lore/ViewsQuote.kt | 3 +- .../kotlin/info/mechyrdia/robot/RobotFiles.kt | 3 +- .../info/mechyrdia/robot/RobotService.kt | 5 +- .../info/mechyrdia/route/ResourceTypes.kt | 1 - .../info/mechyrdia/route/ResourceWebDav.kt | 3 +- 14 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/info/mechyrdia/auth/PasswordHashers.kt diff --git a/build.gradle.kts b/build.gradle.kts index eb667d0..d94067f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -87,6 +87,8 @@ dependencies { implementation(files("libs/nsapi4j.jar")) + implementation("de.mkammerer:argon2-jvm:2.11") + implementation("org.apache.groovy:groovy-jsr223:4.0.22") implementation("io.ktor:ktor-client-core:3.0.2") diff --git a/src/main/kotlin/info/mechyrdia/Configuration.kt b/src/main/kotlin/info/mechyrdia/Configuration.kt index 5238203..673d442 100644 --- a/src/main/kotlin/info/mechyrdia/Configuration.kt +++ b/src/main/kotlin/info/mechyrdia/Configuration.kt @@ -1,10 +1,22 @@ package info.mechyrdia +import info.mechyrdia.auth.Argon2Hasher import info.mechyrdia.data.Id import info.mechyrdia.data.NationData +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PolymorphicKind +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive import java.io.File +import java.nio.charset.Charset @Serializable sealed class FileStorageConfig { @@ -17,6 +29,31 @@ sealed class FileStorageConfig { data object GridFs : FileStorageConfig() } +@Serializable +sealed class StoredPassword { + abstract fun verify(attempt: String): Boolean + + open fun toCanonical(): StoredPassword? = null + + @Serializable + @SerialName("plaintext") + data class InPlaintext(val password: String) : StoredPassword() { + override fun verify(attempt: String): Boolean { + return password == attempt + } + + override fun toCanonical(): StoredPassword = HashedArgon2(Argon2Hasher.createHash(password)) + } + + @Serializable + @SerialName("argon2") + data class HashedArgon2(val hash: String) : StoredPassword() { + override fun verify(attempt: String): Boolean { + return Argon2Hasher.verifyHash(hash, attempt) + } + } +} + @Serializable data class OpenAiConfig( val token: String, @@ -35,16 +72,21 @@ data class Configuration( val isDevMode: Boolean = false, - val storage: FileStorageConfig = FileStorageConfig.Flat(".."), + val storage: FileStorageConfig = FileStorageConfig.GridFs, val dbName: String = "nslore", val dbConn: String = "mongodb://localhost:27017", val ownerNation: String = "mechyrdia", - val emergencyPassword: String? = null, + @Serializable(with = StoredPasswordConfigJsonSerializer::class) + val emergencyPassword: StoredPassword? = null, val openAi: OpenAiConfig? = null, ) { + fun toCanonical() = emergencyPassword?.toCanonical()?.let { canonicalEmergencyPassword -> + copy(emergencyPassword = canonicalEmergencyPassword) + } + companion object { val Current: Configuration by lazy { val file = File(System.getProperty("info.mechyrdia.configpath", "./config.json")) @@ -52,10 +94,14 @@ data class Configuration( if (file.exists()) file.deleteRecursively() - file.writeText(JsonFileCodec.encodeToString(serializer(), Configuration()), Charsets.UTF_8) + file.writeText(JsonFileCodec.encodeToString(serializer(), Configuration()), Utf8) } - JsonFileCodec.decodeFromString(serializer(), file.readText(Charsets.UTF_8)) + val config = JsonFileCodec.decodeFromString(serializer(), file.readText(Utf8)) + + config.toCanonical()?.also { newConfig -> + file.writeText(JsonFileCodec.encodeToString(serializer(), newConfig), Utf8) + } ?: config } } } @@ -64,3 +110,29 @@ val OwnerNationId: Id get() = Id(Configuration.Current.ownerNation) const val MainDomainName = "https://mechyrdia.info" + +inline val Utf8: Charset + get() = Charsets.UTF_8 + +object StoredPasswordConfigJsonSerializer : KSerializer { + private val defaultSerializer = StoredPassword.serializer() + + @OptIn(ExperimentalSerializationApi::class, InternalSerializationApi::class) + override val descriptor: SerialDescriptor + get() = buildSerialDescriptor("StoredPasswordConfigJsonSerializer", PolymorphicKind.SEALED) + + override fun serialize(encoder: Encoder, value: StoredPassword) { + defaultSerializer.serialize(encoder, value) + } + + override fun deserialize(decoder: Decoder): StoredPassword { + return if (decoder is JsonDecoder) { + val element = decoder.decodeJsonElement() + if (element is JsonPrimitive && element.isString) + StoredPassword.InPlaintext(element.content) + else + decoder.json.decodeFromJsonElement(defaultSerializer, element) + } else + defaultSerializer.deserialize(decoder) + } +} diff --git a/src/main/kotlin/info/mechyrdia/auth/PasswordHashers.kt b/src/main/kotlin/info/mechyrdia/auth/PasswordHashers.kt new file mode 100644 index 0000000..fe10350 --- /dev/null +++ b/src/main/kotlin/info/mechyrdia/auth/PasswordHashers.kt @@ -0,0 +1,26 @@ +package info.mechyrdia.auth + +import de.mkammerer.argon2.Argon2 +import de.mkammerer.argon2.Argon2Advanced +import de.mkammerer.argon2.Argon2Factory +import info.mechyrdia.Utf8 + +private val argon2Instance = Argon2Factory.createAdvanced() + +object Argon2Hasher { + const val ITERATIONS = 3 + const val MEMORY = 22 + const val PARALLELISM = 1 + + fun createHash(plaintext: String): String { + return Instance.hash(ITERATIONS, MEMORY, PARALLELISM, plaintext.toByteArray(Utf8)) + } + + fun verifyHash(hash: String, attempt: String): Boolean { + return Instance.verify(hash, attempt.toByteArray(Utf8)) + } + + object Instance : Argon2 by argon2Instance + + object Advanced : Argon2Advanced by argon2Instance +} diff --git a/src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt b/src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt index dc1eb49..9e2630d 100644 --- a/src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt +++ b/src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt @@ -11,14 +11,12 @@ import info.mechyrdia.route.href import info.mechyrdia.route.installCsrfToken import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode -import io.ktor.http.content.PartData import io.ktor.http.defaultForFileExtension import io.ktor.server.application.ApplicationCall import io.ktor.server.html.respondHtml import io.ktor.server.plugins.MissingRequestParameterException import io.ktor.server.response.respond import io.ktor.server.response.respondBytes -import io.ktor.utils.io.toByteArray import kotlinx.html.* fun Map.sortedAsFiles() = toList() diff --git a/src/main/kotlin/info/mechyrdia/data/Xml.kt b/src/main/kotlin/info/mechyrdia/data/Xml.kt index 2e06317..c04f9a7 100644 --- a/src/main/kotlin/info/mechyrdia/data/Xml.kt +++ b/src/main/kotlin/info/mechyrdia/data/Xml.kt @@ -1,5 +1,6 @@ package info.mechyrdia.data +import info.mechyrdia.Utf8 import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.withCharsetIfNeeded @@ -125,5 +126,5 @@ private val emptyBlock: XmlTag.() -> Unit = {} fun > C.root(name: String, attributes: Map = emptyMap(), namespace: String? = null, block: (XmlTag.() -> Unit)? = null) = XmlTag(name, this, attributes, namespace, false, block == null).visitAndFinalize(this, block ?: emptyBlock) suspend fun ApplicationCall.respondXml(status: HttpStatusCode? = null, contentType: ContentType = ContentType.Text.Xml, prettyPrint: Boolean = true, block: XmlTagConsumer.() -> String) { - respondText(createXml(prettyPrint).block(), contentType.withCharsetIfNeeded(Charsets.UTF_8), status) + respondText(createXml(prettyPrint).block(), contentType.withCharsetIfNeeded(Utf8), status) } diff --git a/src/main/kotlin/info/mechyrdia/lore/FontAssets.kt b/src/main/kotlin/info/mechyrdia/lore/FontAssets.kt index 55b864c..e9f0f41 100644 --- a/src/main/kotlin/info/mechyrdia/lore/FontAssets.kt +++ b/src/main/kotlin/info/mechyrdia/lore/FontAssets.kt @@ -1,6 +1,7 @@ package info.mechyrdia.lore import info.mechyrdia.JsonFileCodec +import info.mechyrdia.Utf8 import info.mechyrdia.concatenated import info.mechyrdia.data.FileStorage import info.mechyrdia.data.StoragePath @@ -64,7 +65,7 @@ private val fontsJsonPath = StoragePath.Root / "customFonts.json" suspend fun loadFontsJson(): FontAssetsManifest { val fontsFile = FileStorage.instance.readFile(fontsJsonPath) ?: return FontAssetsManifest(emptyMap()) - val fontsJson = String(fontsFile) + val fontsJson = String(fontsFile, Utf8) val fontsMap = JsonFileCodec.decodeFromString(MapSerializer(String.serializer(), FontAssetInfo.serializer()), fontsJson) return FontAssetsManifest(fontsMap) } diff --git a/src/main/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt b/src/main/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt index e6b85d9..2299369 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt @@ -1,6 +1,7 @@ package info.mechyrdia.lore import info.mechyrdia.JsonStorageCodec +import info.mechyrdia.Utf8 import info.mechyrdia.data.FileStorage import info.mechyrdia.data.StoragePath import io.ktor.util.hex @@ -34,7 +35,7 @@ object PreProcessorTemplateLoader { suspend fun loadTemplate(name: String): ParserTree { val templateFile = StoragePath.templateDir / "$name.tpl" val template = FileStorage.instance.readFile(templateFile) ?: return emptyList() - return ParserState.parseText(String(template)) + return ParserState.parseText(String(template, Utf8)) } suspend fun runTemplateWith(name: String, args: Map): ParserTree { @@ -61,7 +62,7 @@ object PreProcessorScriptLoader { cache.getOrPut(digest) { scriptEngineSync.withLock { - (scriptEngine as Compilable).compile(String(script)) + (scriptEngine as Compilable).compile(String(script, Utf8)) } } } diff --git a/src/main/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt b/src/main/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt index f19ca50..918aa9a 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt @@ -1,6 +1,7 @@ package info.mechyrdia.lore import info.mechyrdia.JsonStorageCodec +import info.mechyrdia.Utf8 import info.mechyrdia.concat import info.mechyrdia.data.FileStorage import info.mechyrdia.data.StoragePath @@ -72,7 +73,7 @@ object FactbookLoader { private suspend fun loadJsonData(lorePath: List): JsonObject { val jsonPath = lorePath.dropLast(1) + listOf("${lorePath.last()}.json") val bytes = FileStorage.instance.readFile(StoragePath.jsonDocDir / jsonPath) ?: return JsonObject(emptyMap()) - return JsonStorageCodec.parseToJsonElement(String(bytes)) as JsonObject + return JsonStorageCodec.parseToJsonElement(String(bytes, Utf8)) as JsonObject } suspend fun loadFactbookContext(lorePath: List): Map { @@ -82,7 +83,7 @@ object FactbookLoader { suspend fun loadFactbook(lorePath: List): ParserTree? { val filePath = StoragePath.articleDir / lorePath val bytes = FileStorage.instance.readFile(filePath) ?: return null - val inputTree = ParserState.parseText(String(bytes)) + val inputTree = ParserState.parseText(String(bytes, Utf8)) return inputTree.preProcess(PreProcessorContext(loadFactbookContext(lorePath) + PreProcessorContext.defaults(lorePath))) } } diff --git a/src/main/kotlin/info/mechyrdia/lore/ViewNav.kt b/src/main/kotlin/info/mechyrdia/lore/ViewNav.kt index a567568..ea7f749 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ViewNav.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ViewNav.kt @@ -2,6 +2,7 @@ package info.mechyrdia.lore import info.mechyrdia.JsonFileCodec import info.mechyrdia.OwnerNationId +import info.mechyrdia.Utf8 import info.mechyrdia.auth.createCsrfToken import info.mechyrdia.data.FileStorage import info.mechyrdia.data.StoragePath @@ -28,7 +29,7 @@ private val extraLinksPath = StoragePath.Root / "externalLinks.json" suspend fun loadExternalLinks(): List { val extraLinksFile = FileStorage.instance.readFile(extraLinksPath) ?: return emptyList() - val extraLinksJson = String(extraLinksFile) + val extraLinksJson = String(extraLinksFile, Utf8) val extraLinks = JsonFileCodec.decodeFromString(ListSerializer(ExternalLink.serializer()), extraLinksJson) return if (extraLinks.isEmpty()) emptyList() diff --git a/src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt b/src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt index ce0c53b..26b94d2 100644 --- a/src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt +++ b/src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt @@ -2,6 +2,7 @@ package info.mechyrdia.lore import info.mechyrdia.JsonFileCodec import info.mechyrdia.MainDomainName +import info.mechyrdia.Utf8 import info.mechyrdia.data.FileStorage import info.mechyrdia.data.StoragePath import info.mechyrdia.data.XmlTagConsumer @@ -42,7 +43,7 @@ data class Quote( private val quotesListGetter by storedData(StoragePath("quotes.json")) { jsonPath -> FileStorage.instance.readFile(jsonPath)?.let { - JsonFileCodec.decodeFromString(ListSerializer(Quote.serializer()), String(it)) + JsonFileCodec.decodeFromString(ListSerializer(Quote.serializer()), String(it, Utf8)) } } diff --git a/src/main/kotlin/info/mechyrdia/robot/RobotFiles.kt b/src/main/kotlin/info/mechyrdia/robot/RobotFiles.kt index 6b3ad8a..34a0a9d 100644 --- a/src/main/kotlin/info/mechyrdia/robot/RobotFiles.kt +++ b/src/main/kotlin/info/mechyrdia/robot/RobotFiles.kt @@ -1,5 +1,6 @@ package info.mechyrdia.robot +import info.mechyrdia.Utf8 import io.ktor.client.request.forms.FormBuilder import io.ktor.http.ContentType import io.ktor.http.Headers @@ -13,7 +14,7 @@ class FileUpload( ) : Tokenizable { override fun getTexts(): List { return if (contentType.match(ContentType.Text.Any)) - listOf(String(content)) + listOf(String(content, Utf8)) else emptyList() } } diff --git a/src/main/kotlin/info/mechyrdia/robot/RobotService.kt b/src/main/kotlin/info/mechyrdia/robot/RobotService.kt index 383ebc9..1189970 100644 --- a/src/main/kotlin/info/mechyrdia/robot/RobotService.kt +++ b/src/main/kotlin/info/mechyrdia/robot/RobotService.kt @@ -3,6 +3,7 @@ package info.mechyrdia.robot import info.mechyrdia.Configuration import info.mechyrdia.MainDomainName import info.mechyrdia.OpenAiConfig +import info.mechyrdia.Utf8 import info.mechyrdia.concat import info.mechyrdia.data.DataDocument import info.mechyrdia.data.DocumentTable @@ -197,8 +198,8 @@ class RobotService( val newId = robotClient.uploadFile( "assistants", FileUpload( - text.toByteArray(), - ContentType.Text.Plain.withCharset(Charsets.UTF_8), + text.toByteArray(Utf8), + ContentType.Text.Plain.withCharset(Utf8), name.toOpenAiName() ) ).id diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt b/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt index 3bc18db..1db9c1c 100644 --- a/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt +++ b/src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt @@ -65,7 +65,6 @@ import info.mechyrdia.robot.robotPage import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode -import io.ktor.http.content.PartData import io.ktor.resources.Resource import io.ktor.server.application.ApplicationCall import io.ktor.server.html.respondHtml diff --git a/src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt b/src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt index d9fd484..95725b2 100644 --- a/src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt +++ b/src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt @@ -1,5 +1,6 @@ package info.mechyrdia.route +import info.mechyrdia.Utf8 import info.mechyrdia.auth.WebDavToken import info.mechyrdia.auth.toNationId import info.mechyrdia.concat @@ -199,7 +200,7 @@ fun ApplicationRequest.basicAuth(): Pair? { val auth = authorization() ?: return null if (!auth.startsWith("Basic ")) return null val basic = auth.substring(6) - return String(base64Decoder.decode(basic)) + return String(base64Decoder.decode(basic), Utf8) .split(':', limit = 2) .let { (user, pass) -> user to pass } } -- 2.25.1