Add Argon2 for password hashing, starting with emergency (NS API outage) admin login
authorLanius Trolling <lanius@laniustrolling.dev>
Sun, 22 Dec 2024 01:06:10 +0000 (20:06 -0500)
committerLanius Trolling <lanius@laniustrolling.dev>
Sun, 22 Dec 2024 01:06:10 +0000 (20:06 -0500)
14 files changed:
build.gradle.kts
src/main/kotlin/info/mechyrdia/Configuration.kt
src/main/kotlin/info/mechyrdia/auth/PasswordHashers.kt [new file with mode: 0644]
src/main/kotlin/info/mechyrdia/data/ViewsFiles.kt
src/main/kotlin/info/mechyrdia/data/Xml.kt
src/main/kotlin/info/mechyrdia/lore/FontAssets.kt
src/main/kotlin/info/mechyrdia/lore/ParserPreprocessInclude.kt
src/main/kotlin/info/mechyrdia/lore/ParserPreprocessJson.kt
src/main/kotlin/info/mechyrdia/lore/ViewNav.kt
src/main/kotlin/info/mechyrdia/lore/ViewsQuote.kt
src/main/kotlin/info/mechyrdia/robot/RobotFiles.kt
src/main/kotlin/info/mechyrdia/robot/RobotService.kt
src/main/kotlin/info/mechyrdia/route/ResourceTypes.kt
src/main/kotlin/info/mechyrdia/route/ResourceWebDav.kt

index eb667d076a31980df626730f8631633e85c9ff6d..d94067f587ca39b3abff043d7aa335ed743fb72c 100644 (file)
@@ -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")
index 5238203615d4c4081045389ff9b75159d5a00cdd..673d442c1068e2c7ba13e4a42e4cacd11dc6f96d 100644 (file)
@@ -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<NationData>
        get() = Id(Configuration.Current.ownerNation)
 
 const val MainDomainName = "https://mechyrdia.info"
+
+inline val Utf8: Charset
+       get() = Charsets.UTF_8
+
+object StoredPasswordConfigJsonSerializer : KSerializer<StoredPassword> {
+       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 (file)
index 0000000..fe10350
--- /dev/null
@@ -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
+}
index dc1eb4959aa33f56cfd755e798487f40c8c2c17a..9e2630d41ce4a8374ce7ef43bfa8f24b2227e0c4 100644 (file)
@@ -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<String, StoredFileType>.sortedAsFiles() = toList()
index 2e06317774ccb825a7d8cabeb6746629ae8a75ab..c04f9a7cc58462531d17352ab169e02c6140a17c 100644 (file)
@@ -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 <T, C : XmlTagConsumer<T>> C.root(name: String, attributes: Map<String, String> = 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>.() -> String) {
-       respondText(createXml(prettyPrint).block(), contentType.withCharsetIfNeeded(Charsets.UTF_8), status)
+       respondText(createXml(prettyPrint).block(), contentType.withCharsetIfNeeded(Utf8), status)
 }
index 55b864c31d785595880d1b2a80ad7725594d3138..e9f0f41ff6db256d9036dd9cfb5666de7fb2803d 100644 (file)
@@ -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)
 }
index e6b85d9a20b4d33203e6d222a7e113bf57b86a6e..22993690f9a1a662a576c00e7c7cfa99011a2bbb 100644 (file)
@@ -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<String, ParserTree>): 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))
                                }
                        }
                }
index f19ca507785d47beefc1a8a9ff40f6051c1922a1..918aa9aabd4bcb0c88228733142bb007835b4538 100644 (file)
@@ -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<String>): 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<String>): Map<String, ParserTree> {
@@ -82,7 +83,7 @@ object FactbookLoader {
        suspend fun loadFactbook(lorePath: List<String>): 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)))
        }
 }
index a5675688a18e8972722edea35b712f5d7925c4e3..ea7f749a6009c6cb66f7a856548c8cda9a89438b 100644 (file)
@@ -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<NavItem> {
        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()
index ce0c53bb12c5a17b55b698afa08a7fca0d32b4bb..26b94d2a16fb9566faeefa02497905ca5d4ca6bc 100644 (file)
@@ -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))
        }
 }
 
index 6b3ad8afa86b48b50e9b5adaa64418a19c5f1079..34a0a9d598316f621e02c9e75fab800501506927 100644 (file)
@@ -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<String> {
                return if (contentType.match(ContentType.Text.Any))
-                       listOf(String(content))
+                       listOf(String(content, Utf8))
                else emptyList()
        }
 }
index 383ebc900693767745c7f136bae1660a395787c5..11899704abca9e277274a75ac93b669f67d17176 100644 (file)
@@ -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
index 3bc18db9b9bbd2dedbcdd72a282524bfed116e7d..1db9c1c806615f55b2db8ce3398b6b00e3edea4f 100644 (file)
@@ -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
index d9fd4844f9570bf475e4bab91f774ba908bdfcc8..95725b204b70bdde10fb95948b50828e6dfc3284 100644 (file)
@@ -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<String, String>? {
        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 }
 }