diff --git a/build.sbt b/build.sbt index 38ee008..4a54b68 100644 --- a/build.sbt +++ b/build.sbt @@ -52,7 +52,7 @@ lazy val testSettings = Seq( lazy val `gs-crypto` = project .in(file(".")) - .aggregate(core, argon2, rsa) + .aggregate(core, argon2, rsa, eddsa) .settings(noPublishSettings) .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") @@ -97,3 +97,18 @@ lazy val rsa = project Deps.Cats.Effect ) ) + +lazy val eddsa = project + .in(file("modules/eddsa")) + .dependsOn(core) + .settings(sharedSettings) + .settings(testSettings) + .settings( + name := s"${gsProjectName.value}-eddsa-v${semVerMajor.value}" + ) + .settings( + libraryDependencies ++= Seq( + Deps.Cats.Effect, + Deps.BouncyCastle.Provider + ) + ) diff --git a/modules/core/src/main/scala/gs/crypto/v0/Base64Decoder.scala b/modules/core/src/main/scala/gs/crypto/v0/Base64Decoder.scala index 97680c6..a05f95e 100644 --- a/modules/core/src/main/scala/gs/crypto/v0/Base64Decoder.scala +++ b/modules/core/src/main/scala/gs/crypto/v0/Base64Decoder.scala @@ -3,9 +3,12 @@ package gs.crypto.v0 import java.{util => ju} /** Implementation of [[Decoder]] for Base64 strings. + * + * Supports base64-url decoding as well. */ object Base64Decoder extends Decoder[B64]: private lazy val d: ju.Base64.Decoder = ju.Base64.getDecoder() + private lazy val du: ju.Base64.Decoder = ju.Base64.getUrlDecoder() /** @inheritDocs */ @@ -21,3 +24,12 @@ object Base64Decoder extends Decoder[B64]: */ def decodeUnsafe(input: String): Array[Byte] = d.decode(input) + + /** + * Decode the base64-url encoded input. + * + * @param input The base64-url encoded data. + * @return The decoded bytes. + */ + def decodeUrl(input: B64Url): Array[Byte] = + du.decode(input.data) diff --git a/modules/core/src/main/scala/gs/crypto/v0/Base64Encoder.scala b/modules/core/src/main/scala/gs/crypto/v0/Base64Encoder.scala index 525fc72..5102187 100644 --- a/modules/core/src/main/scala/gs/crypto/v0/Base64Encoder.scala +++ b/modules/core/src/main/scala/gs/crypto/v0/Base64Encoder.scala @@ -5,9 +5,12 @@ import java.nio.charset.StandardCharsets import java.util.Base64 /** Implementation of [[Encoder]] for Base64. + * + * Supports base64-url encoding as well. */ object Base64Encoder extends Encoder[B64]: private lazy val e: Base64.Encoder = Base64.getEncoder() + private lazy val eu: Base64.Encoder = Base64.getUrlEncoder() /** @inheritDocs */ @@ -21,3 +24,25 @@ object Base64Encoder extends Encoder[B64]: charset: Charset = StandardCharsets.UTF_8 ): B64 = encode(input.getBytes(charset)) + + /** + * Encode the given bytes using base64-url. + * + * @param input The input data. + * @return The base64-url-encoded string. + */ + def encodeUrl(input: Array[Byte]): B64Url = + B64Url(eu.encodeToString(input)) + + /** + * Encode the given bytes using base64-url. + * + * @param input The input data. + * @param charset The character set of the input data. + * @return The base64-url-encoded string. + */ + def encodeUrl( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): B64Url = + encodeUrl(input.getBytes(charset)) diff --git a/modules/core/src/main/scala/gs/crypto/v0/BaseKeyLoader.scala b/modules/core/src/main/scala/gs/crypto/v0/BaseKeyLoader.scala new file mode 100644 index 0000000..1bc42e8 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/BaseKeyLoader.scala @@ -0,0 +1,89 @@ +package gs.crypto.v0 + +/** + * Base implementation for [[KeyLoader]] that provides standard parsing + * functionality. + */ +abstract class BaseKeyLoader[F[_]] extends KeyLoader[F]: + /** + * @return The key loader configuration. + */ + def config: BaseKeyLoader.Config + + protected def prepareKey(base: String): Either[KeyLoadError, Array[Byte]] = + scala.util.Try { + config.decoder.decode(trim(collapse(unwrap(base)))) + }.toEither.left.map(_ => KeyLoadError.DecodingFailure(config.encodingName)) + + private def unwrap(base: String): String = + config.wrappers.foldLeft(base) { (acc, w) => acc.replace(w, "") } + + private def collapse(base: String): String = + if config.shouldCollapse then base.replace("\n", "") else base + + private def trim(base: String): String = + if config.shouldTrim then base.trim() else base + +object BaseKeyLoader: + + /** + * Configuration for loading keys. + * + * @param wrappers Values that wrap keys and must be removed. + * @param shouldCollapse Whether newlines should be collapsed. + * @param shouldTrim Whether the string data should be trimmed. + * @param encodingName The name of the encoding. + * @param decoder The decoder to be used on the pre-processed input. + */ + case class Config( + wrappers: List[String], + shouldCollapse: Boolean, + shouldTrim: Boolean, + encodingName: String, + decoder: Decoder[String] + ) + + /** + * Default configuration values that work in most cases. + */ + object Defaults: + + /** + * By default, generic begin/end public/private key wrappers are included. + */ + val Wrappers: List[String] = List( + "-----BEGIN PUBLIC KEY-----", + "-----END PUBLIC KEY-----", + "-----BEGIN PRIVATE KEY-----", + "-----END PRIVATE KEY-----", + ) + + /** + * By default, stringified key data has newlines removed. + */ + val ShouldCollapse: Boolean = true + + /** + * By default, stringified key data is trimmed. + */ + val ShouldTrim: Boolean = true + + /** + * The default encoding name is 'base64'. + */ + val EncodingName: String = "base64" + + /** + * The default configuration handles standard wrapped, base64-encoded keys. + */ + val Config: BaseKeyLoader.Config = BaseKeyLoader.Config( + wrappers = Wrappers, + shouldCollapse = ShouldCollapse, + shouldTrim = ShouldTrim, + encodingName = EncodingName, + decoder = s => Base64Decoder.decodeUnsafe(s) + ) + + end Defaults + +end BaseKeyLoader diff --git a/modules/core/src/main/scala/gs/crypto/v0/Encoded.scala b/modules/core/src/main/scala/gs/crypto/v0/Encoded.scala index a80629e..98c615d 100644 --- a/modules/core/src/main/scala/gs/crypto/v0/Encoded.scala +++ b/modules/core/src/main/scala/gs/crypto/v0/Encoded.scala @@ -23,6 +23,11 @@ trait Encoded: * The encoded data. */ +/** Represents Base64-encoded data. + * + * @param data + * The encoded data. + */ final class B64( val data: String ) extends Encoded: @@ -63,6 +68,51 @@ object B64: end B64 +/** Represents Base64-url-encoded data. + * + * @param data + * The encoded data. + */ +final class B64Url( + val data: String +) extends Encoded: + /** @inheritDocs + */ + def decode(): Array[Byte] = Base64Decoder.decodeUrl(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: B64Url => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object B64Url: + + /** Instantiate [[B64Url]] from the given string. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[B64Url]] instance. + */ + def apply( + data: String + ): B64Url = new B64Url(data) + + given CanEqual[B64Url, B64Url] = CanEqual.derived + +end B64Url + /** Represents Hex-encoded data. * * @param data diff --git a/modules/core/src/main/scala/gs/crypto/v0/FileKeyLoader.scala b/modules/core/src/main/scala/gs/crypto/v0/FileKeyLoader.scala new file mode 100644 index 0000000..147dacd --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/FileKeyLoader.scala @@ -0,0 +1,30 @@ +package gs.crypto.v0 + +import cats.syntax.all.* +import cats.effect.Sync +import java.nio.file.Files +import java.nio.file.Path +import java.nio.charset.Charset + +/** + * Implementation of [[KeyLoader]] that loads keys from encoded string files. + * + * @param config The key loader configuration. + * @param charset The expected character set for the loaded file. + */ +class FileKeyLoader[F[_]: Sync]( + val config: BaseKeyLoader.Config, + val charset: Charset +) extends BaseKeyLoader[F]: + + /** @inheritDocs */ + override def loadKey( + location: String + ): F[Either[KeyLoadError, Array[Byte]]] = + val p = Path.of(location) + Sync[F].delay(Files.isRegularFile(p)).flatMap { + case false => + Sync[F].pure(Left(KeyLoadError.PathNotRegularFile(p, location))) + case true => + Sync[F].delay(Files.readString(p, charset)).map(prepareKey) + } diff --git a/modules/core/src/main/scala/gs/crypto/v0/KeyLoadError.scala b/modules/core/src/main/scala/gs/crypto/v0/KeyLoadError.scala new file mode 100644 index 0000000..c3c23b6 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/KeyLoadError.scala @@ -0,0 +1,12 @@ +package gs.crypto.v0 + +import java.nio.file.Path + +sealed trait KeyLoadError + +object KeyLoadError: + + case class PathNotRegularFile(path: Path, location: String) extends KeyLoadError + case class DecodingFailure(encodedFormat: String) extends KeyLoadError + +end KeyLoadError diff --git a/modules/core/src/main/scala/gs/crypto/v0/KeyLoader.scala b/modules/core/src/main/scala/gs/crypto/v0/KeyLoader.scala new file mode 100644 index 0000000..b7297e2 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/KeyLoader.scala @@ -0,0 +1,38 @@ +package gs.crypto.v0 + +import cats.Applicative +import cats.effect.Sync +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** + * Interface for loading keys (e.g. public/private keys) into memory. + */ +trait KeyLoader[F[_]]: + /** + * Load the key from the given location. + * + * @param location The location of the key. + * @return The loaded key. + */ + def loadKey(location: String): F[Either[KeyLoadError, Array[Byte]]] + +object KeyLoader: + + def stringLoader[F[_]: Applicative]( + config: BaseKeyLoader.Config + ): StringKeyLoader[F] = + new StringKeyLoader[F](config) + + def fileLoader[F[_]: Sync]( + config: BaseKeyLoader.Config, + charset: Charset = StandardCharsets.UTF_8 + ): FileKeyLoader[F] = + new FileKeyLoader(config, charset) + + def resourceLoader[F[_]: Sync]( + config: BaseKeyLoader.Config + ): ResourceKeyLoader[F] = + new ResourceKeyLoader(config) + +end KeyLoader diff --git a/modules/core/src/main/scala/gs/crypto/v0/ResourceKeyLoader.scala b/modules/core/src/main/scala/gs/crypto/v0/ResourceKeyLoader.scala new file mode 100644 index 0000000..029183b --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/ResourceKeyLoader.scala @@ -0,0 +1,27 @@ +package gs.crypto.v0 + +import cats.effect.Sync +import cats.effect.Resource +import scala.io.Source + +/** + * Implementation of [[KeyLoader]] that loads keys from encoded string files + * that exist as project resources. + * + * @param config The key loader configuration. + */ +class ResourceKeyLoader[F[_]: Sync]( + val config: BaseKeyLoader.Config +) extends BaseKeyLoader[F]: + + /** @inheritDocs */ + override def loadKey( + location: String + ): F[Either[KeyLoadError, Array[Byte]]] = + Resource + .make(Sync[F].delay(Source.fromResource(location)))(source => + Sync[F].delay(source.close()) + ) + .use { source => + Sync[F].delay(prepareKey(source.getLines().mkString)) + } diff --git a/modules/core/src/main/scala/gs/crypto/v0/Signature.scala b/modules/core/src/main/scala/gs/crypto/v0/Signature.scala new file mode 100644 index 0000000..841ab5d --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/Signature.scala @@ -0,0 +1,50 @@ +package gs.crypto.v0 + +/** + * Represents a cryptographic signature. Opaque type for an array of bytes. + */ +opaque type Signature = Array[Byte] + +object Signature: + + /** + * Instantiate a new signature from the given byte array. + * + * @param value The signature value. + * @return The signature bytes. + */ + def apply(value: Array[Byte]): Signature = value + + /** + * Instantiate a new signature from the given encoded string. + * + * @param value The input data. + * @return The new signature. + */ + def fromEncoded(value: Encoded): Signature = + value.decode() + + given CanEqual[Signature, Signature] = CanEqual.derived + + extension (sig: Signature) + /** + * @return The underlying byte array. + */ + def unwrap(): Array[Byte] = sig + + /** + * @return This signature, encoded using base64. + */ + def toBase64(): B64 = Base64Encoder.encode(sig) + + /** + * @return This signature, encoded using base64-url. + */ + def toBase64Url(): B64Url = Base64Encoder.encodeUrl(sig) + + /** + * @return This signature, encoded using hex. + */ + def toHex(): Hex = HexEncoder.encode(sig) + +end Signature diff --git a/modules/core/src/main/scala/gs/crypto/v0/SignatureValidity.scala b/modules/core/src/main/scala/gs/crypto/v0/SignatureValidity.scala new file mode 100644 index 0000000..fec6c38 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/SignatureValidity.scala @@ -0,0 +1,47 @@ +package gs.crypto.v0 + +/** + * Used to communicate the calculated validity of some [[Signature]]. + * + * @param name The enumeration name. + */ +sealed abstract class SignatureValidity(val name: String): + /** @inheritDocs */ + override def equals(obj: Any): Boolean = + obj match + case other: SignatureValidity => name == other.name + case _ => false + + /** @inheritDocs */ + override def hashCode(): Int = name.hashCode() + + /** @inheritDocs */ + override def toString(): String = name + +object SignatureValidity: + + /** + * Map a Boolean value to validity. + * + * - True: Valid + * - False: Invalid + * + * @param isValid Whether the signature is valid or not. + * @return The [[SignatureValidity]] selected for the given validity. + */ + def apply(isValid: Boolean): SignatureValidity = + if isValid then Valid else Invalid + + given CanEqual[SignatureValidity, SignatureValidity] = CanEqual.derived + + /** + * The [[Signature]] is valid. + */ + case object Valid extends SignatureValidity("valid") + + /** + * The [[Signature]] is invalid. + */ + case object Invalid extends SignatureValidity("invalid") + +end SignatureValidity diff --git a/modules/core/src/main/scala/gs/crypto/v0/SignatureVerifier.scala b/modules/core/src/main/scala/gs/crypto/v0/SignatureVerifier.scala new file mode 100644 index 0000000..4975236 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/SignatureVerifier.scala @@ -0,0 +1,7 @@ +package gs.crypto.v0 + +/** + * Used to verify cryptographic signatures. + */ +trait SignatureVerifier[F[_]]: + def verify(signature: Signature): F[SignatureValidity] diff --git a/modules/core/src/main/scala/gs/crypto/v0/Signer.scala b/modules/core/src/main/scala/gs/crypto/v0/Signer.scala new file mode 100644 index 0000000..873d223 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/Signer.scala @@ -0,0 +1,25 @@ +package gs.crypto.v0 + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** + * Used to calculate cryptographic signatures. + */ +trait Signer[F[_]]: + /** + * Calculate a signature for the given data. + * + * @param data The input data. + * @return The calculated signature. + */ + def sign(data: Array[Byte]): F[Signature] + + /** + * Calculate a signature for the given data. + * + * @param data The input data. + * @param charset The character set of the input data. + * @return The calculated signature. + */ + def sign(data: String, charset: Charset = StandardCharsets.UTF_8): F[Signature] diff --git a/modules/core/src/main/scala/gs/crypto/v0/StringKeyLoader.scala b/modules/core/src/main/scala/gs/crypto/v0/StringKeyLoader.scala new file mode 100644 index 0000000..e9251c7 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/StringKeyLoader.scala @@ -0,0 +1,18 @@ +package gs.crypto.v0 + +import cats.Applicative + +/** + * Implementation of [[KeyLoader]] that loads keys directly from input strings. + * + * @param config The key loader configuration. + */ +class StringKeyLoader[F[_]: Applicative]( + val config: BaseKeyLoader.Config +) extends BaseKeyLoader[F]: + + /** @inheritDocs */ + override def loadKey( + location: String + ): F[Either[KeyLoadError, Array[Byte]]] = + Applicative[F].pure(prepareKey(location)) diff --git a/modules/eddsa/src/main/scala/gs/crypto/v0/eddsa/Ed25519Signer.scala b/modules/eddsa/src/main/scala/gs/crypto/v0/eddsa/Ed25519Signer.scala new file mode 100644 index 0000000..268c117 --- /dev/null +++ b/modules/eddsa/src/main/scala/gs/crypto/v0/eddsa/Ed25519Signer.scala @@ -0,0 +1,37 @@ +package gs.crypto.v0.eddsa + +import cats.effect.Sync +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import gs.crypto.v0.Signature +import gs.crypto.v0.Signer +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** + * Implementation of [[Signer]] that uses the Ed25519 system to calculate + * signatures. + * + * See: [[Ed25519Verifier]] + * + * @param privateKey The private key. + */ +final class Ed25519Signer[F[_]: Sync]( + privateKey: Array[Byte] +) extends Signer[F]: + private lazy val params = new Ed25519PrivateKeyParameters(privateKey) + + /** @inheritDocs */ + override def sign(data: Array[Byte]): F[Signature] = + Sync[F].delay { + val s = new org.bouncycastle.crypto.signers.Ed25519Signer() + val _ = s.init(true, params) + val _ = s.update(data, 0, data.length) + Signature(s.generateSignature()) + } + + /** @inheritDocs */ + override def sign( + data: String, + charset: Charset = StandardCharsets.UTF_8 + ): F[Signature] = + sign(data.getBytes(charset)) diff --git a/modules/eddsa/src/main/scala/gs/crypto/v0/eddsa/Ed25519Verifier.scala b/modules/eddsa/src/main/scala/gs/crypto/v0/eddsa/Ed25519Verifier.scala new file mode 100644 index 0000000..3057e85 --- /dev/null +++ b/modules/eddsa/src/main/scala/gs/crypto/v0/eddsa/Ed25519Verifier.scala @@ -0,0 +1,28 @@ +package gs.crypto.v0.eddsa + +import cats.effect.Sync +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import gs.crypto.v0.Signature +import gs.crypto.v0.SignatureVerifier +import gs.crypto.v0.SignatureValidity + +/** + * Implementation of [[SignatureVerifier]] that uses the Ed25519 system to + * verify signatures. + * + * See: [[Ed25519Signer]] + * + * @param publicKey The public key. + */ +final class Ed25519Verifier[F[_]: Sync]( + publicKey: Array[Byte] +) extends SignatureVerifier[F]: + private lazy val params = new Ed25519PublicKeyParameters(publicKey) + + /** @inheritDocs */ + override def verify(signature: Signature): F[SignatureValidity] = + Sync[F].delay { + val s = new org.bouncycastle.crypto.signers.Ed25519Signer() + val _ = s.init(false, params) + SignatureValidity(s.verifySignature(signature.unwrap())) + } diff --git a/modules/eddsa/src/test/resources/private.pem b/modules/eddsa/src/test/resources/private.pem new file mode 100644 index 0000000..6afc843 --- /dev/null +++ b/modules/eddsa/src/test/resources/private.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIPkucDMoZPkyltc200nNyyfJKWP7cu3Tr9gdsFB6PeqN +-----END PRIVATE KEY----- diff --git a/modules/eddsa/src/test/resources/public.pem b/modules/eddsa/src/test/resources/public.pem new file mode 100644 index 0000000..d457336 --- /dev/null +++ b/modules/eddsa/src/test/resources/public.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAYNI09OpzbZ9GiKhZzNEbwWYISkR2ihfvWEsZx62b+bY= +-----END PUBLIC KEY-----