rsa works

This commit is contained in:
Pat Garrity 2026-01-31 12:35:07 -06:00
parent a8ba253ea1
commit a7e2185204
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
8 changed files with 504 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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))

View file

@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
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
tXWSfxJaqr5e0HQj1LbBa+HOO4AvOQ==
-----END PRIVATE KEY-----

View file

@ -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-----

View file

@ -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