diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Base64.scala b/modules/auth/src/main/scala/gs/smolban/auth/Base64.scala new file mode 100644 index 0000000..68006d4 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/Base64.scala @@ -0,0 +1,48 @@ +package gs.smolban.auth + +import cats.Eq +import cats.Show + +/** Opaque type for a Base64-encoded String. + */ +opaque type Base64 = String + +object Base64: + + given CanEqual[Base64, Base64] = CanEqual.derived + + given Show[Base64] = b64 => b64 + + given Eq[Base64] = ( + x, + y + ) => x == y + + private lazy val encoder = java.util.Base64.getEncoder() + private lazy val decoder = java.util.Base64.getDecoder() + + /** Instantiate a new [[Base64]] instance by encoding the given bytes. + * + * @param input + * The input bytes. + * @return + * The encoded string representation of the given bytes. + */ + def encode(input: Array[Byte]): Base64 = + encoder.encodeToString(input) + + def decodeUnsafe(input: String): Array[Byte] = + decoder.decode(input) + + extension (b64: Base64) + /** @return + * The decoded byte array that this string represents. + */ + def decode(): Array[Byte] = decoder.decode(b64) + + /** @return + * The underlying string value. + */ + def unwrap(): String = b64 + +end Base64 diff --git a/modules/auth/src/main/scala/gs/smolban/auth/Rsa.scala b/modules/auth/src/main/scala/gs/smolban/auth/Rsa.scala new file mode 100644 index 0000000..472c1a3 --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/Rsa.scala @@ -0,0 +1,180 @@ +package gs.smolban.auth + +import cats.effect.Sync +import cats.effect.kernel.Resource +import cats.syntax.all.* +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.PublicKey +import java.security.spec.PKCS8EncodedKeySpec +import java.security.spec.X509EncodedKeySpec +import scala.io.Source + +/** Support for the RSA asymmetric encryption algorithm. + * + * See: + * + * - [[RsaEncryption]] + * - [[RsaDecryption]] + * - [[RsaEncryptedBytes]] + * + * Use the following functions to get encryption and decryption tools: + * + * - `initializeEncryption` + * - `initializeEncryptionFromFile` + * - `initializeDecryption` + * - `initializeDecryptionFromFile` + */ +object Rsa: + + val CipherName: String = "RSA/ECB/PKCS1Padding" + val Algorithm: String = "RSA" + + /** Initialize a new instance of [[RsaEncryption]] for the given public key. + * + * This function assumes that the provided bytes do not need any further + * processing - they are already decoded from any encoded form. + * + * This function accepts X509 public keys. + * + * @param publicKeyRawBytes + * The raw bytes that constitute the public key. + * @return + * The new instance of [[RsaEncryption]]. + */ + def initializeEncryption[F[_]: Sync]( + publicKeyRawBytes: Array[Byte] + ): F[RsaEncryption[F]] = + loadPublicKey(publicKeyRawBytes).map(new RsaEncryption(_)) + + /** Initialize a new instance of [[RsaEncryption]] for the given public key by + * loading that key from disk. + * + * @param publicKeyPath + * The path to the public key on local disk. + * @return + * The new instance of [[RsaDecryption]]. + */ + def initializeEncryptionFromFile[F[_]: Sync]( + publicKeyPath: Path + ): F[RsaEncryption[F]] = + loadPublicKeyFromFile(publicKeyPath).map(new RsaEncryption(_)) + + def initializeEncryptionFromResource[F[_]: Sync]( + resourceName: String + ): F[RsaEncryption[F]] = + loadPublicKeyFromResource(resourceName).map(new RsaEncryption(_)) + + /** Initialize a new instance of [[RsaDecryption]] for the given private key. + * + * This function assumes that the provided bytes do not need any further + * processing - they are already decoded from any encoded form. + * + * This function accepts PKCS8 private keys. + * + * @param privateKeyRawBytes + * The raw bytes that constitute the private key. + * @return + * The new instance of [[RsaDecryption]]. + */ + def initializeDecryption[F[_]: Sync]( + privateKeyRawBytes: Array[Byte] + ): F[RsaDecryption[F]] = + loadPrivateKey(privateKeyRawBytes).map(new RsaDecryption(_)) + + /** Initialize a new instance of [[RsaDecryption]] for the given private key + * by loading that key from disk. + * + * @param privateKeyPath + * The path to the private key on local disk. + * @return + * The new instance of [[RsaDecryption]]. + */ + def initializeDecryptionFromFile[F[_]: Sync]( + privateKeyPath: Path + ): F[RsaDecryption[F]] = + loadPrivateKeyFromFile(privateKeyPath).map(new RsaDecryption(_)) + + def initializeDecryptionFromResource[F[_]: Sync]( + privateKeyResourceName: String + ): F[RsaDecryption[F]] = + loadPrivateKeyFromResource(privateKeyResourceName).map(new RsaDecryption(_)) + + def loadPublicKey[F[_]: Sync]( + publicKeyRawBytes: Array[Byte] + ): F[PublicKey] = + Sync[F].delay { + val spec = new X509EncodedKeySpec(publicKeyRawBytes) + val keyFactory = KeyFactory.getInstance(Rsa.Algorithm) + keyFactory.generatePublic(spec) + } + + def loadPublicKeyFromFile[F[_]: Sync]( + publicKeyPath: Path + ): F[PublicKey] = + Sync[F] + .delay(Files.readString(publicKeyPath, StandardCharsets.UTF_8)) + .map(preparePublicKey) + .flatMap(loadPublicKey[F]) + + def loadPublicKeyFromResource[F[_]: Sync]( + resourceName: String + ): F[PublicKey] = + Resource + .make(Sync[F].delay(Source.fromResource(resourceName)))(source => + Sync[F].delay(source.close()) + ) + .use { source => + loadPublicKey(preparePublicKey(source.getLines().mkString)) + } + + def loadPrivateKey[F[_]: Sync]( + privateKeyRawBytes: Array[Byte] + ): F[PrivateKey] = + Sync[F].delay { + val spec = new PKCS8EncodedKeySpec(privateKeyRawBytes) + val keyFactory = KeyFactory.getInstance(Rsa.Algorithm) + keyFactory.generatePrivate(spec) + } + + def loadPrivateKeyFromFile[F[_]: Sync]( + privateKeyPath: Path + ): F[PrivateKey] = + Sync[F] + .delay(Files.readString(privateKeyPath, StandardCharsets.UTF_8)) + .map(preparePrivateKey) + .flatMap(loadPrivateKey[F]) + + def loadPrivateKeyFromResource[F[_]: Sync]( + resourceName: String + ): F[PrivateKey] = + Resource + .make(Sync[F].delay(Source.fromResource(resourceName)))(source => + Sync[F].delay(source.close()) + ) + .use { source => + loadPrivateKey(preparePrivateKey(source.getLines().mkString)) + } + + private def preparePublicKey(base: String): Array[Byte] = + Base64.decodeUnsafe( + base + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("\n", "") + .trim() + ) + + private def preparePrivateKey(base: String): Array[Byte] = + Base64.decodeUnsafe( + base + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\n", "") + .trim() + ) + +end Rsa diff --git a/modules/auth/src/main/scala/gs/smolban/auth/RsaDecryption.scala b/modules/auth/src/main/scala/gs/smolban/auth/RsaDecryption.scala new file mode 100644 index 0000000..48f2a5b --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/RsaDecryption.scala @@ -0,0 +1,58 @@ +package gs.smolban.auth + +import cats.effect.Sync +import cats.syntax.all.* +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.security.PrivateKey +import javax.crypto.Cipher + +/** Utility for private key decryption using RSA. + * + * Intended to be used in conjunction with [[RsaEncryption]]. + * + * @param privateKey + * The private key used to decrypt data. + */ +final class RsaDecryption[F[_]: Sync](privateKey: PrivateKey): + + /** Decrypt the given bytes. These bytes have no guarantees regarding whether + * they were produced using RSA. + * + * @param input + * The input bytes to decrypt. + * @return + * The decrypted bytes. Throws an exception if decryption fails. + */ + def decryptUnsafe(input: Array[Byte]): F[Array[Byte]] = + Sync[F].delay { + val cipher: Cipher = Cipher.getInstance(Rsa.CipherName) + val _ = cipher.init(Cipher.DECRYPT_MODE, privateKey) + cipher.doFinal(input) + } + + /** Decrypt the given bytes. + * + * @param input + * The encrypted bytes. + * @return + * The decrypted bytes. + */ + def decrypt(input: RsaEncryptedBytes): F[Array[Byte]] = + decryptUnsafe(input.bytes) + + /** Decrypt the given bytes, expressing the result as a string. + * + * @param input + * The encrypted bytes. + * @param charset + * The character set used to express the decrypted bytes. Defaults to + * UTF-8. + * @return + * The decrypted string. + */ + def decryptToString( + input: RsaEncryptedBytes, + charset: Charset = StandardCharsets.UTF_8 + ): F[String] = + decrypt(input).map(bytes => new String(bytes, charset)) diff --git a/modules/auth/src/main/scala/gs/smolban/auth/RsaEncryptedBytes.scala b/modules/auth/src/main/scala/gs/smolban/auth/RsaEncryptedBytes.scala new file mode 100644 index 0000000..8562afa --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/RsaEncryptedBytes.scala @@ -0,0 +1,43 @@ +package gs.smolban.auth + +/** Represents arbitrary bytes that were encrypted using RSA. + * + * See: + * + * - [[RsaEncryption]] + * - [[RsaDecryption]] + * + * @param bytes + * The bytes encrypted using an RSA public key. + */ +final class RsaEncryptedBytes(val bytes: Array[Byte]): + /** @return + * These encrypted bytes, encoded using Base64. + */ + def encode(): Base64 = Base64.encode(bytes) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: RsaEncryptedBytes => bytes.sameElements(other.bytes) + case _ => false + + /** @inheritDocs + */ + override def hashCode(): Int = bytes.hashCode() + +object RsaEncryptedBytes: + + /** Instantiate a new instance of [[RsaEncryptedBytes]] by base64-decoding the + * given input. + * + * @param value + * The value to decode. + * @return + * The new [[RsaEncryptedBytes]]. + */ + def decode(value: Base64): RsaEncryptedBytes = + new RsaEncryptedBytes(value.decode()) + +end RsaEncryptedBytes diff --git a/modules/auth/src/main/scala/gs/smolban/auth/RsaEncryption.scala b/modules/auth/src/main/scala/gs/smolban/auth/RsaEncryption.scala new file mode 100644 index 0000000..c1356aa --- /dev/null +++ b/modules/auth/src/main/scala/gs/smolban/auth/RsaEncryption.scala @@ -0,0 +1,46 @@ +package gs.smolban.auth + +import cats.effect.Sync +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.security.PublicKey +import javax.crypto.Cipher + +/** Utility for public key encryption using RSA. + * + * Intended to be used in conjunction with [[RsaDecryption]]. + * + * @param publicKey + * The public key used to encrypt data. + */ +final class RsaEncryption[F[_]: Sync](publicKey: PublicKey): + + /** Encrypt the given bytes. + * + * @param input + * The data to encrypt. + * @return + * The encrypted representation of the input bytes. + */ + def encrypt(input: Array[Byte]): F[RsaEncryptedBytes] = + Sync[F].delay { + val cipher: Cipher = Cipher.getInstance(Rsa.CipherName) + val _ = cipher.init(Cipher.ENCRYPT_MODE, publicKey) + new RsaEncryptedBytes(cipher.doFinal(input)) + } + + /** Encrypt the given string. + * + * @param input + * The data to encrypt. + * @param charset + * The character set used to extract bytes from the strong. Defaults to + * UTF-8. + * @return + * The encrypted representation of the input string. + */ + def encrypt( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): F[RsaEncryptedBytes] = + encrypt(input.getBytes(charset)) diff --git a/modules/auth/src/test/resources/rsa4096-private-key.key b/modules/auth/src/test/resources/rsa4096-private-key.key new file mode 100644 index 0000000..88094a5 --- /dev/null +++ b/modules/auth/src/test/resources/rsa4096-private-key.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +tXWSfxJaqr5e0HQj1LbBa+HOO4AvOQ== +-----END PRIVATE KEY----- diff --git a/modules/auth/src/test/resources/rsa4096-public-key.crt b/modules/auth/src/test/resources/rsa4096-public-key.crt new file mode 100644 index 0000000..e5598a1 --- /dev/null +++ b/modules/auth/src/test/resources/rsa4096-public-key.crt @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtJTVkd8pysM18PmvOUI9 +KqT6XdwklfmyWBrFxCWboyP1q2L0X1xZLql0hstiRlORsoQAM6Q/qlDkkiE7ekt4 +T8yI8rKiHjiYi+tK1mlurkgH85+LOXuupB2mgKCg9EPLAdFdzMEOGKp4xXiEfxbV +MdjX2oLig+SZ/wZRtuJ/6slILXk1AXCxHl2+zJ/HOUCN8440r4uO9QgCo/ksZoHo +IxtNQOSVzPsiY/OduqQ5nBx9t5or2zYcPZu58HhUG26r1fEx0mjEtw7Ex7OWjhXm +vpQMU91jX4V1Tzu/6EbjPVp0afE1JyppSXFnlKoBFyPWbzzU7j9aBz1yeGGxhT+r +YSFBFv7oF6Bc++Lh4eVBoUdUAnGMCbnGVqpWv8y5khf7Etva2Is5SMSqEeuj0XIs +zpdaPUmHhq9LkG2ZFqqCWq1Wg/rxUnexmoPClE+evxw9rM7As2ENXY6lrd3/IwLu +UlH51SOCQl24xirNDtTJ5n1DF1hFPvucDQMdMrHP2PpyV6JPTz1eIzQRZu5VYtqe +oMhVh7nHrP5HwywlLYadIg28PWpJADRZGyYF3ArhWcn3nByDFmoIhBmi8PMdHAyH +qo86u85HQO0Br3PRf2xDtk7zcLQuX9cv5uOlgAfzeluiaSGW++wG08dOOnuVbay6 +he8SwcpI1Hjw3ccmxQiZ2A0CAwEAAQ== +-----END PUBLIC KEY----- diff --git a/modules/auth/src/test/scala/gs/smolban/auth/RsaTests.scala b/modules/auth/src/test/scala/gs/smolban/auth/RsaTests.scala new file mode 100644 index 0000000..d338ff8 --- /dev/null +++ b/modules/auth/src/test/scala/gs/smolban/auth/RsaTests.scala @@ -0,0 +1,63 @@ +package gs.smolban.auth + +import cats.effect.IO +import cats.effect.unsafe.IORuntime +import java.nio.file.Path +import munit.Location + +class RsaTests extends munit.FunSuite: + import RsaTests.Resources + + given IORuntime = IORuntime.global + + def iotest( + name: String + )( + f: => IO[Unit] + )( + using + Location + ): Unit = + test(name)(f.unsafeRunSync()) + + iotest( + "should encrypt and decrypt data, using keys sourced from a resource" + ) { + val data = gs.uuid.v0.UUID.v7().withoutDashes() + for + encryption <- Rsa.initializeEncryptionFromResource[IO]( + Resources.PublicKey + ) + decryption <- Rsa.initializeDecryptionFromResource[IO]( + Resources.PrivateKey + ) + encrypted <- encryption.encrypt(data) + decrypted <- decryption.decryptToString(encrypted) + yield assertEquals(decrypted, data) + } + + iotest("should encrypt and decrypt data, using keys sourced from file") { + val data = gs.uuid.v0.UUID.v7().withoutDashes() + for + encryption <- Rsa.initializeEncryptionFromFile[IO]( + Path.of(Resources.PublicKeyFile) + ) + decryption <- Rsa.initializeDecryptionFromFile[IO]( + Path.of(Resources.PrivateKeyFile) + ) + encrypted <- encryption.encrypt(data) + decrypted <- decryption.decryptToString(encrypted) + yield assertEquals(decrypted, data) + } + +object RsaTests: + + object Resources: + val BasePath: String = "modules/auth/src/test/resources" + val PublicKey: String = "rsa4096-public-key.crt" + val PublicKeyFile: String = s"$BasePath/$PublicKey" + val PrivateKey: String = "rsa4096-private-key.key" + val PrivateKeyFile: String = s"$BasePath/$PrivateKey" + end Resources + +end RsaTests