This commit is contained in:
Minecon724 2025-10-27 20:13:00 +01:00
commit 286b9e3bdd
Signed by untrusted user who does not match committer: m724
GPG key ID: A02E6E67AB961189
10 changed files with 255 additions and 152 deletions

View file

@ -1,5 +1,7 @@
plugins {
kotlin("multiplatform") version "2.2.21"
id("io.kotest") version "6.0.4"
id("com.google.devtools.ksp") version "2.3.0"
}
repositories {
@ -36,7 +38,15 @@ kotlin {
nativeMain.dependencies {
}
nativeTest.dependencies {
implementation("io.kotest:kotest-framework-engine:6.0.4")
implementation("io.kotest:kotest-assertions-core:6.0.4")
implementation("io.kotest:kotest-property:6.0.4")
}
}
}
// note the documentation said "temporarily" so idk https://kotest.io/docs/framework/project-setup.html#re-running-tests
tasks.withType<Test>().configureEach {
logger.lifecycle("UP-TO-DATE check for $name is disabled, forcing it to run.")
outputs.upToDateWhen { false }
}

View file

@ -1,20 +0,0 @@
package dn42.m724.auth
sealed interface AuthenticationMethod {
data class Pgp(val fingerprint: String) : AuthenticationMethod {
init {
// TODO validate
}
}
data class Ssh(val type: KeyType, val fingerprint: String) : AuthenticationMethod {
init {
// TODO validate
}
enum class KeyType {
Rsa,
Ed25519
}
}
}

View file

@ -1,26 +1,5 @@
package dn42.m724.auth
import dn42.m724.auth.pgp.PgpSignatureChecker
// This is actually a library, below code is just showcase
fun main() {
print("Message to sign (single line): ")
val message = readln()
print("Key fingerprint:")
val fingerprint = readln()
println("Please input signature below:")
val lines = mutableListOf<String>()
while (lines.lastOrNull() != "-----END PGP SIGNATURE-----") {
print(" ")
lines.add(readln())
}
val signature = lines.joinToString("\n")
val checker = PgpSignatureChecker()
checker.verify(message, fingerprint, signature)
println("Signature is valid")
TODO("usage example or something here")
}

View file

@ -1,4 +1,4 @@
package dn42.m724.auth
package dn42.m724.auth.checker
interface SignatureChecker {
/**

View file

@ -1,16 +1,47 @@
package dn42.m724.auth.pgp
package dn42.m724.auth.checker.pgp
import dn42.m724.auth.SignatureChecker
import gpgme.*
import kotlinx.cinterop.*
import dn42.m724.auth.checker.SignatureChecker
import gpgme.GPGME_PROTOCOL_OPENPGP
import gpgme.GPGME_SIG_STAT_GOOD
import gpgme.GPG_ERR_NO_ERROR
import gpgme.SEEK_SET
import gpgme._gpgme_op_verify_result
import gpgme._gpgme_signature
import gpgme.gpg_err_code
import gpgme.gpgme_check_version
import gpgme.gpgme_ctx_t
import gpgme.gpgme_ctx_tVar
import gpgme.gpgme_data_new
import gpgme.gpgme_data_new_from_mem
import gpgme.gpgme_data_read
import gpgme.gpgme_data_release
import gpgme.gpgme_data_seek
import gpgme.gpgme_data_tVar
import gpgme.gpgme_error_from_errno
import gpgme.gpgme_new
import gpgme.gpgme_op_verify
import gpgme.gpgme_op_verify_result
import gpgme.gpgme_release
import gpgme.gpgme_set_armor
import gpgme.gpgme_set_protocol
import gpgme.gpgme_strerror
import kotlinx.cinterop.ByteVar
import kotlinx.cinterop.CPointer
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.MemScope
import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.pointed
import kotlinx.cinterop.ptr
import kotlinx.cinterop.toKString
import kotlinx.cinterop.value
import platform.posix.errno
@OptIn(ExperimentalForeignApi::class)
class PgpSignatureChecker : SignatureChecker {
override fun verify(sourcePlainText: String, keyFingerprint: String, signature: String) {
gpgme_check_version(null)?.let {
println("GPGME version: ${it.toKString()}")
} ?: throw RuntimeException("Failed to get GPGME version")
gpgme_check_version(null) ?: throw RuntimeException("Failed to get GPGME version")
withGpgContextMemScoped { ctx ->
gpgme_set_protocol(ctx, GPGME_PROTOCOL_OPENPGP.toUInt())
@ -41,11 +72,10 @@ class PgpSignatureChecker : SignatureChecker {
result.pointed.signatures?.let { initialPointer ->
var signaturePointer: CPointer<_gpgme_signature>? = initialPointer
println("pointer is null: ${signaturePointer == null}")
while (signaturePointer != null) {
val signature = signaturePointer.pointed
println("Found signature with timestamp ${signature.timestamp} and fingerprint ${signature.fpr?.toKString()}")
//println("Found signature with timestamp ${signature.timestamp} and fingerprint ${signature.fpr?.toKString()}")
assertGpg(signature.status) { "Failed to check signature" }
@ -74,7 +104,12 @@ class PgpSignatureChecker : SignatureChecker {
try {
assertGpg(
gpgme_data_new_from_mem(sigData.ptr, signedMessage, signedMessage.length.toULong(), 1) // TODO idk if the 1 is good
gpgme_data_new_from_mem(
sigData.ptr,
signedMessage,
signedMessage.length.toULong(),
1
) // TODO idk if the 1 is good
) { "Failed to read signature" }
assertGpg(
@ -101,9 +136,6 @@ class PgpSignatureChecker : SignatureChecker {
}
}
println(plainText.encodeToByteArray().toHexString())
println(sourcePlainText.encodeToByteArray().toHexString())
check(plainText.trim() == sourcePlainText.trim()) { "Plain text message doesn't match" }
}
} finally {

View file

@ -0,0 +1,9 @@
package dn42.m724.auth.checker.ssh
import dn42.m724.auth.checker.SignatureChecker
class SshSignatureChecker : SignatureChecker {
override fun verify(sourcePlainText: String, keyFingerprint: String, signature: String) {
TODO("SSH signature verification not yet implemented")
}
}

View file

@ -0,0 +1,39 @@
package dn42.m724.auth.model
sealed interface AuthenticationMethod {
data class Pgp(val fingerprint: String) : AuthenticationMethod {
init {
// TODO validate
}
}
data class Ssh(val algorithm: Algorithm, val fingerprint: String) : AuthenticationMethod {
init {
// TODO validate
}
enum class Algorithm {
Rsa,
Ed25519
}
}
companion object {
fun fromString(value: String): AuthenticationMethod {
val (keyType, fingerprint) = value.split(" ")
val authenticationMethod = if (keyType == "pgp-fingerprint") {
val bytes = fingerprint.hexToByteArray()
check(bytes.size == 20) { "Fingerprint is expected to be a 20-byte HEX-encoded string" }
Pgp(fingerprint)
} else if (keyType.startsWith("ssh-")) {
TODO("SSH key parsing not yet implemented")
} else {
throw IllegalArgumentException("Invalid signature key type: $keyType")
}
return authenticationMethod
}
}
}

View file

@ -0,0 +1,119 @@
package dn42.m724.auth.checker.pgp
import dn42.m724.auth.checker.SignatureChecker
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
private const val fingerprint = "2DE81640E41DD84A9ED9B8F8FD4D7C6652E72E7B"
private const val invalidFingerprint = "139F1460BC66A19A2F880D8D47BA020D8EBCC05E"
private val clearTextSignature = """
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
simple message
-----BEGIN PGP SIGNATURE-----
iJEEARYKADkWIQQt6BZA5B3YSp7ZuPj9TXxmUucuewUCaP4wchsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMSwyLDIACgkQ/U18ZlLnLnuM3AD+L0QBrn2pnGAemcJZDh+p
6oJnuTgeSMiMRkkbMgTOMFMBAPzhLKgyzx4YJcgrIinvZsgPowR9Pf0ryzxwQ5mo
qUwJ
=FYVj
-----END PGP SIGNATURE-----
""".trimIndent()
val detachedSignature = """
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQQt6BZA5B3YSp7ZuPj9TXxmUucuewUCaP4wWxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMSwyLDIACgkQ/U18ZlLnLnsaEgEA15uncZNWN6zV952vO6rG
mMIAG9X54X9Cfp5DEfG3hPcBAJypNGnlyHivk+ZKYDlKSZB2S53vKrA3q3J2yDqb
0SUF
=12FC
-----END PGP SIGNATURE-----
""".trimIndent()
class PgpSignatureVerificationTest: FunSpec({
val checker: SignatureChecker = PgpSignatureChecker() // stateless (for now?)
val successfulCases = listOf(
TestCase(
description = "valid clear-text signature",
plainText = "simple message",
keyFingerprint = fingerprint,
signature = clearTextSignature
),
TestCase(
description = "valid detached signature",
plainText = "simple message",
keyFingerprint = fingerprint,
signature = detachedSignature
)
)
withData(
nameFn = { "verify should succeed for ${it.description}" },
successfulCases
) { (_, sourcePlainText, keyFingerprint, signature) ->
shouldNotThrowAny {
checker.verify(sourcePlainText, keyFingerprint, signature)
}
}
val failureCases = listOf(
TestCase(
description = "not matching message (clear-text)",
plainText = "totally different",
keyFingerprint = fingerprint,
signature = clearTextSignature
),
TestCase(
description = "not matching message (detached)",
plainText = "totally different",
keyFingerprint = fingerprint,
signature = detachedSignature
),
TestCase(
description = "not matching fingerprint",
plainText = "simple message",
keyFingerprint = invalidFingerprint,
signature = detachedSignature
),
TestCase(
description = "blank signature",
plainText = "simple message",
keyFingerprint = fingerprint,
signature = ""
),
TestCase(
description = "blank fingerprint",
plainText = "simple message",
keyFingerprint = "",
signature = detachedSignature
),
TestCase(
description = "key ID instead of full fingerprint",
plainText = "simple message",
keyFingerprint = fingerprint.takeLast(16), // Assuming this is an invalid case
signature = detachedSignature
)
)
withData(
nameFn = { "verify should throw for ${it.description}" },
failureCases
) { (_, sourcePlainText, keyFingerprint, signature) ->
shouldThrow<Exception> { // TODO we should check the exception type when we start using exception types
checker.verify(sourcePlainText, keyFingerprint, signature)
}
}
})
private data class TestCase(
val description: String,
val plainText: String,
val keyFingerprint: String,
val signature: String
)

View file

@ -0,0 +1,31 @@
package dn42.m724.auth.model
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import kotlin.test.assertEquals
class AuthenticationMethodTest: FunSpec({
withData(
nameFn = { authenticationMethodString -> "fromString should throw for invalid input: $authenticationMethodString" },
"pgp-fingerprint 2DE81640E41D",
"pgp 2DE81640E41DD84A9ED9B8F8FD4D7C6652E72E7B",
"pgp-fingerprint 2DE81640E41DD84A9ED9B8F8FD4xveTes7C6652E72E7B",
"pgp-fingerprint"
) { authenticationMethodString ->
shouldThrow<Exception> {
AuthenticationMethod.fromString(authenticationMethodString)
}
}
withData(
nameFn = { (authenticationMethodString, expected) -> "fromString should return $expected for input: $authenticationMethodString" },
Pair("pgp-fingerprint 2DE81640E41DD84A9ED9B8F8FD4D7C6652E72E7B", AuthenticationMethod.Pgp("2DE81640E41DD84A9ED9B8F8FD4D7C6652E72E7B")),
Pair("pgp-fingerprint 2DE81640E41DD84A9ED9B8F8FD4D7C6652E72E7B this is ignored", AuthenticationMethod.Pgp("2DE81640E41DD84A9ED9B8F8FD4D7C6652E72E7B"))
) { (authenticationMethodString, expected) ->
assertEquals(
expected = expected,
actual = AuthenticationMethod.fromString(authenticationMethodString)
)
}
})

View file

@ -1,96 +0,0 @@
package dn42.m724.auth.pgp
import dn42.m724.auth.SignatureChecker
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.assertions.throwables.shouldThrow
import kotlin.experimental.ExperimentalNativeApi
import kotlin.test.BeforeTest
import kotlin.test.Test
private const val fingerprint = "2DE81640E41DD84A9ED9B8F8FD4D7C6652E72E7B"
private const val invalidFingerprint = "139F1460BC66A19A2F880D8D47BA020D8EBCC05E"
private val clearTextSignature = """
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
simple message
-----BEGIN PGP SIGNATURE-----
iJEEARYKADkWIQQt6BZA5B3YSp7ZuPj9TXxmUucuewUCaP4wchsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMSwyLDIACgkQ/U18ZlLnLnuM3AD+L0QBrn2pnGAemcJZDh+p
6oJnuTgeSMiMRkkbMgTOMFMBAPzhLKgyzx4YJcgrIinvZsgPowR9Pf0ryzxwQ5mo
qUwJ
=FYVj
-----END PGP SIGNATURE-----
""".trimIndent()
val detachedSignature = """
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQQt6BZA5B3YSp7ZuPj9TXxmUucuewUCaP4wWxsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMSwyLDIACgkQ/U18ZlLnLnsaEgEA15uncZNWN6zV952vO6rG
mMIAG9X54X9Cfp5DEfG3hPcBAJypNGnlyHivk+ZKYDlKSZB2S53vKrA3q3J2yDqb
0SUF
=12FC
-----END PGP SIGNATURE-----
""".trimIndent()
@OptIn(ExperimentalNativeApi::class)
class PgpSignatureVerificationTest {
private lateinit var checker: SignatureChecker
@BeforeTest
fun beforeTest() {
checker = PgpSignatureChecker()
}
@Test
fun `verify should succeed for valid signature`() {
shouldNotThrowAny {
checker.verify ("simple message", fingerprint, clearTextSignature)
}
}
@Test
fun `verify should succeed for valid detached signature`() {
shouldNotThrowAny {
checker.verify("simple message", fingerprint, detachedSignature)
}
}
@Test
fun `verify should fail for not matching message`() {
shouldThrow<Exception> { // "Plain text message doesn't match"
checker.verify("totally different", fingerprint, clearTextSignature)
}
}
@Test
fun `verify should fail for not matching detached message`() {
shouldThrow<Exception> { // "Failed to check signature: Bad signature"
checker.verify("totally different", fingerprint, detachedSignature)
}
}
@Test
fun `verify should fail for not matching fingerprint`() {
shouldThrow<Exception> { // Did not find any signature with matching key fingerprint
checker.verify("simple message", invalidFingerprint, detachedSignature)
}
}
@Test
fun `verify should fail for empty input data`() {
shouldThrow<Exception> {
checker.verify("simple message", invalidFingerprint, "")
}
}
@Test
fun `verify should fail for empty fingerprint data`() {
shouldThrow<Exception> {
checker.verify("simple message", "", detachedSignature)
}
}
}