Commit
This commit is contained in:
		
					parent
					
						
							
								5a3e48d070
							
						
					
				
			
			
				commit
				
					
						286b9e3bdd
					
				
			
		
					 10 changed files with 255 additions and 152 deletions
				
			
		|  | @ -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 } | ||||
| } | ||||
|  | @ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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") | ||||
| } | ||||
|  | @ -1,4 +1,4 @@ | |||
| package dn42.m724.auth | ||||
| package dn42.m724.auth.checker | ||||
| 
 | ||||
| interface SignatureChecker { | ||||
|     /** | ||||
|  | @ -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 { | ||||
|  | @ -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") | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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 | ||||
| ) | ||||
|  | @ -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) | ||||
|         ) | ||||
|     } | ||||
| }) | ||||
|  | @ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Minecon724
				Minecon724