commit 9bef6a92ffd50c543adc693d8805eb6c15514a14 Author: Pat Garrity Date: Fri Feb 13 21:17:59 2026 -0600 (patch) Initial commit with common types and two algorithms. diff --git a/.forgejo/workflows/pull_request.yaml b/.forgejo/workflows/pull_request.yaml new file mode 100644 index 0000000..a98df61 --- /dev/null +++ b/.forgejo/workflows/pull_request.yaml @@ -0,0 +1,68 @@ +on: + pull_request: + types: [opened, synchronize, reopened] + +defaults: + run: + shell: bash + +jobs: + library_snapshot: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Test Library Snapshot' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + if [[ "$latest_commit_message" == *"(major)"* ]]; then + export GS_RELEASE_TYPE="major" + elif [[ "$latest_commit_message" == *"(minor)"* ]]; then + export GS_RELEASE_TYPE="minor" + elif [[ "$latest_commit_message" == *"(patch)"* ]]; then + export GS_RELEASE_TYPE="patch" + elif [[ "$latest_commit_message" == *"(docs)"* ]]; then + export GS_RELEASE_TYPE="norelease" + elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then + export GS_RELEASE_TYPE="norelease" + else + export GS_RELEASE_TYPE="norelease" + fi + echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message ($GS_RELEASE_TYPE) (SNAPSHOT)" + if [ "$GS_RELEASE_TYPE" = "norelease" ]; then + sbtn -Dsnapshot=true -Drelease="patch" semVerInfo + else + sbtn -Dsnapshot=true -Drelease="$GS_RELEASE_TYPE" semVerInfo + fi + - name: 'Unit Tests and Code Coverage' + run: | + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + - name: 'Publish Snapshot' + run: | + echo "Testing env var propagation = ${{ env.GS_RELEASE_TYPE }}" + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping publish due to GS_RELEASE_TYPE=norelease" + else + sbtn coverageOff + sbtn clean + sbtn compile + sbtn publish + fi diff --git a/.forgejo/workflows/release.yaml b/.forgejo/workflows/release.yaml new file mode 100644 index 0000000..72fac7d --- /dev/null +++ b/.forgejo/workflows/release.yaml @@ -0,0 +1,84 @@ +on: + push: + branches: + - main + +defaults: + run: + shell: bash + +jobs: + library_release: + runs-on: docker + container: + image: registry.garrity.co:8443/gs/ci-scala:latest + name: 'Build and Release Library' + env: + GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }} + GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }} + steps: + - uses: actions/checkout@v4 + name: 'Checkout Repository' + with: + fetch-depth: 0 + - name: 'Pre-Commit' + run: | + pre-commit install + pre-commit run --all-files + - name: 'Prepare Versioned Build' + run: | + latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')" + latest_commit_message="$(git show -s --format=%s HEAD)" + if [[ "$latest_commit_message" == *"(major)"* ]]; then + export GS_RELEASE_TYPE="major" + elif [[ "$latest_commit_message" == *"(minor)"* ]]; then + export GS_RELEASE_TYPE="minor" + elif [[ "$latest_commit_message" == *"(patch)"* ]]; then + export GS_RELEASE_TYPE="patch" + elif [[ "$latest_commit_message" == *"(docs)"* ]]; then + export GS_RELEASE_TYPE="norelease" + elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then + export GS_RELEASE_TYPE="norelease" + else + export GS_RELEASE_TYPE="norelease" + fi + + echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV + echo "Previous Git Tag: $latest_git_tag" + echo "Latest Commit: $latest_commit_message" + echo "Selected Release Type: '$GS_RELEASE_TYPE'" + + if [ "$GS_RELEASE_TYPE" = "norelease" ]; then + echo "Skipping all versioning for 'norelease' commit." + else + sbtn -Drelease="$GS_RELEASE_TYPE" semVerInfo + fi + - name: 'Unit Tests and Code Coverage' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping build/test for 'norelease' commit." + else + sbtn clean + sbtn coverage + sbtn test + sbtn coverageReport + fi + - name: 'Publish Release' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping publish for 'norelease' commit." + else + sbtn coverageOff + sbtn clean + sbtn semVerWriteVersionToFile + sbtn publish + fi + - name: 'Create Git Tag' + run: | + if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then + echo "Skipping Git tag for 'norelease' commit." + else + selected_version="$(cat .version)" + git tag "$selected_version" + git push origin "$selected_version" + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7af9867 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +project/target/ +project/project/ +modules/core/target/ +.version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fbcf79c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: fix-byte-order-marker + - id: mixed-line-ending + args: ['--fix=lf'] + description: Enforces using only 'LF' line endings. + - id: trailing-whitespace + - repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala + rev: v1.0.1 + hooks: + - id: scalafmt diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..9c7929b --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,72 @@ +// See: https://github.com/scalameta/scalafmt/tags for the latest tags. +version = 3.8.1 +runner.dialect = scala3 +maxColumn = 80 + +rewrite { + rules = [RedundantBraces, RedundantParens, Imports, SortModifiers] + imports.expand = true + imports.sort = scalastyle + redundantBraces.ifElseExpressions = true + redundantBraces.stringInterpolation = true +} + +indent { + main = 2 + callSite = 2 + defnSite = 2 + extendSite = 4 + withSiteRelativeToExtends = 2 + commaSiteRelativeToExtends = 2 +} + +align { + preset = more + openParenCallSite = false + openParenDefnSite = false +} + +newlines { + implicitParamListModifierForce = [before,after] + topLevelStatementBlankLines = [ + { + blanks = 1 + } + ] + afterCurlyLambdaParams = squash +} + +danglingParentheses { + defnSite = true + callSite = true + ctrlSite = true + exclude = [] +} + +verticalMultiline { + atDefnSite = true + arityThreshold = 2 + newlineAfterOpenParen = true +} + +comments { + wrap = standalone +} + +docstrings { + style = "SpaceAsterisk" + oneline = unfold + wrap = yes + forceBlankLineBefore = true +} + +project { + excludePaths = [ + "glob:**target/**", + "glob:**.metals/**", + "glob:**.bloop/**", + "glob:**.bsp/**", + "glob:**metals.sbt", + "glob:**.git/**" + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f2b2735 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright Patrick Garrity + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..27bb7e8 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# gs-crypto + +[GS Open Source](https://garrity.co/oss.html) | +[License (MIT)](./LICENSE) + +Cryptography library for Scala 3. Based on JCA and BouncyCastle. This library +does NOT re-implement cryptography algorithms - it provides convenient types and +wrappers for working with those algorithms. + +- [Usage](#usage) + - [Dependency](#dependency) +- [Donate](#donate) + +## Usage + +### Dependency + +This artifact is available in the Garrity Software Maven repository. + +```scala +externalResolvers += + "Garrity Software Releases" at "https://maven.garrity.co/gs" + +val GsCrypto = new { + val Core: ModuleID = "gs" %% "gs-crypto-core-v0" % "$VERSION" + val Argon2: ModuleID = "gs" %% "gs-crypto-argon2-v0" % "$VERSION" + val Rsa: ModuleID = "gs" %% "gs-crypto-rsa-v0" % "$VERSION" +} +``` + +## Donate + +Enjoy this project or want to help me achieve my [goals](https://garrity.co)? +Consider [Donating to Pat on Ko-fi](https://ko-fi.com/gspfm). diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..38ee008 --- /dev/null +++ b/build.sbt @@ -0,0 +1,99 @@ +val scala3: String = "3.8.1" + +ThisBuild / scalaVersion := scala3 +ThisBuild / versionScheme := Some("semver-spec") +ThisBuild / gsProjectName := "gs-crypto" + +ThisBuild / externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +ThisBuild / licenses := Seq( + "MIT" -> url( + "https://git.garrity.co/garrity-software/gs-crypto/src/branch/main/LICENSE" + ) +) + +// Note: Scala 3 code coverage is busted :( +val sharedSettings = Seq( + scalaVersion := scala3, + version := semVerSelected.value, + coverageFailOnMinimum := true + /* coverageMinimumStmtTotal := 100, coverageMinimumBranchTotal := 100 */ +) + +val noPublishSettings = Seq( + publish := {} +) + +val Deps = new { + val Cats = new { + val Effect: ModuleID = "org.typelevel" %% "cats-effect" % "3.6.3" + } + + val Gs = new { + val Datagen: ModuleID = "gs" %% "gs-datagen-core-v0" % "0.1.1" + } + + val BouncyCastle = new { + val Provider: ModuleID = "org.bouncycastle" % "bcprov-jdk18on" % "1.83" + } + + val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.2.2" +} + +lazy val testSettings = Seq( + libraryDependencies ++= Seq( + Deps.MUnit % Test, + Deps.Gs.Datagen % Test + ) +) + +lazy val `gs-crypto` = project + .in(file(".")) + .aggregate(core, argon2, rsa) + .settings(noPublishSettings) + .settings(name := s"${gsProjectName.value}-v${semVerMajor.value}") + +lazy val core = project + .in(file("modules/core")) + .settings(sharedSettings) + .settings(testSettings) + .settings( + name := s"${gsProjectName.value}-core-v${semVerMajor.value}" + ) + .settings( + libraryDependencies ++= Seq( + Deps.Cats.Effect + ) + ) + +lazy val argon2 = project + .in(file("modules/argon2")) + .dependsOn(core) + .settings(sharedSettings) + .settings(testSettings) + .settings( + name := s"${gsProjectName.value}-argon2-v${semVerMajor.value}" + ) + .settings( + libraryDependencies ++= Seq( + Deps.Cats.Effect, + Deps.BouncyCastle.Provider + ) + ) + +lazy val rsa = project + .in(file("modules/rsa")) + .dependsOn(core) + .settings(sharedSettings) + .settings(testSettings) + .settings( + name := s"${gsProjectName.value}-rsa-v${semVerMajor.value}" + ) + .settings( + libraryDependencies ++= Seq( + Deps.Cats.Effect + ) + ) diff --git a/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2.scala b/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2.scala new file mode 100644 index 0000000..a976306 --- /dev/null +++ b/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2.scala @@ -0,0 +1,166 @@ +package gs.crypto.v0.argon2 + +import cats.effect.Sync +import cats.syntax.all.* +import gs.crypto.v0.RandomByteProvider +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters + +/** Argon2 hashing support based on the Bouncy Castle library. + * + * @param config + * Algorithm and configuration parameters. + * @param secret + * The secret used to generate hashes. + * @param randomByteProvider + * Used to generate random bytes. + */ +final class Argon2[F[_]: Sync]( + val config: Argon2.Config, + val secret: Argon2Secret, + val randomByteProvider: RandomByteProvider[F] +): + + /** Calculate a new hash for some input. + * + * @param input + * The input string to hash. + * @return + * The calculated hash. + */ + def calculateHash( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): F[Argon2Hash] = + calculateHash(input.getBytes(charset)) + + def calculateHash(input: Array[Byte]): F[Argon2Hash] = + randomSalt().map { salt => + val ap = buildAlgorithmParams(salt) + val a2 = new Argon2BytesGenerator() + val _ = a2.init(ap) + val bytes = new Array[Byte](config.hashLengthInBytes) + val _ = a2.generateBytes(input, bytes) + new Argon2Hash( + algorithmVersion = config.algorithmVersion, + algorithmType = config.algorithmType, + iterations = config.iterations, + parallelism = config.parallelism, + memoryInKb = config.memoryInKb, + salt = salt, + hash = bytes + ) + } + + /** Given some input, determine if that input matches a stored hash. + * + * @param input + * The input to compare. + * @param stored + * The stored hash. + * @return + * True if the input matches the hash, false otherwise. + */ + def doesInputMatch( + input: String, + stored: Argon2Hash + ): F[Boolean] = + Sync[F].delay { + // Use the stored hash as the parameter source. + val ap = buildAlgorithmParams(stored) + + // Hash the input using the stored parameters. + val a2 = new Argon2BytesGenerator() + val _ = a2.init(ap) + val bytes = new Array[Byte](stored.hash.length) + val _ = a2.generateBytes(input.getBytes(StandardCharsets.UTF_8), bytes) + + // Compare the bytes to determine if the input matches the stored hash. + bytes.sameElements(stored.hash) + } + + private def randomSalt(): F[Array[Byte]] = + randomByteProvider.generateBytes(config.saltLengthInBytes) + + private def buildAlgorithmParams(salt: Array[Byte]): Argon2Parameters = + new Argon2Parameters.Builder(config.algorithmType) + .withIterations(config.iterations) + .withMemoryAsKB(config.memoryInKb) + .withParallelism(config.parallelism) + .withSalt(salt) + .withSecret(secret.bytes) + .withVersion(config.algorithmVersion) + .build() + + private def buildAlgorithmParams(hash: Argon2Hash): Argon2Parameters = + new Argon2Parameters.Builder(hash.algorithmType) + .withVersion(hash.algorithmVersion) + .withIterations(hash.iterations) + .withParallelism(hash.parallelism) + .withMemoryAsKB(hash.memoryInKb) + .withSalt(hash.salt) + .withSecret(secret.bytes) + .build() + +object Argon2: + val Algorithm: Int = Argon2Parameters.ARGON2_id + + /** Includes all parameters needed to support Argon2 for Smolban. Includes all + * standard algorithm parameters, along with additional information such as + * the number of bytes that Smolban uses to produce hashes. + * + * @param algorithmVersion + * The algorithm version used to hash bytes. + * @param algorithmType + * The Argon2 variant used to hash bytes. + * @param iterations + * Number of algorithm iterations. + * @param parallelism + * Amount of algorithm parallelism. + * @param memoryInKb + * Memory, in KB, used by the algorithm. + * @param saltLengthInBytes + * Number of bytes Smolban uses for salts. + * @param hashLengthInBytes + * Number of bytes Smolban uses for output hashes. + */ + case class Config( + algorithmVersion: Int, + algorithmType: Int, + iterations: Int, + parallelism: Int, + memoryInKb: Int, + saltLengthInBytes: Int, + hashLengthInBytes: Int + ) + + /** @return + * [[Argon2.Params]] with default settings. Suitable for most cases. + */ + def defaultConfig(): Config = + Config( + algorithmVersion = Defaults.AlgorithmVersion, + algorithmType = Defaults.AlgorithmType, + iterations = Defaults.Iterations, + parallelism = Defaults.Parallelism, + memoryInKb = Defaults.MemoryInKB, + saltLengthInBytes = Defaults.SaltLengthInBytes, + hashLengthInBytes = Defaults.HashLengthInBytes + ) + + object Defaults: + + val AlgorithmVersion: Int = Argon2Parameters.ARGON2_VERSION_13 + val AlgorithmType: Int = Argon2Parameters.ARGON2_id + val Iterations: Int = 3 + val Parallelism: Int = 2 + val SaltLengthInBytes: Int = 16 + val MemoryInKB: Int = 1024 + val HashLengthInBytes: Int = 32 + val KeyLengthInBytes: Int = 32 + + end Defaults + +end Argon2 diff --git a/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2Hash.scala b/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2Hash.scala new file mode 100644 index 0000000..322cda7 --- /dev/null +++ b/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2Hash.scala @@ -0,0 +1,172 @@ +package gs.crypto.v0.argon2 + +import gs.crypto.v0.B64 +import gs.crypto.v0.Base64Encoder +import java.util.Base64 +import java.util.Objects +import scala.util.Try + +/** Represents an Argon2 hash packed with the parameters that produced it. + * + * @param algorithmVersion + * The Argon2 algorithm version. + * @param algorithmType + * The Argon2 type. + * @param iterations + * The Argon2 number of iterations. + * @param parallelism + * The Argon2 parallelism factor. + * @param memoryInKb + * The Argon2 memory, expressed in KB. + * @param salt + * The unencoded salt. + * @param hash + * The unencoded hash. + */ +final class Argon2Hash( + val algorithmVersion: Int, + val algorithmType: Int, + val iterations: Int, + val parallelism: Int, + val memoryInKb: Int, + val salt: Array[Byte], + val hash: Array[Byte] +): + + override def equals(obj: Any): Boolean = + obj match + case other: Argon2Hash => + (algorithmVersion == other.algorithmVersion) + && (algorithmType == other.algorithmType) + && (iterations == other.iterations) + && (parallelism == other.parallelism) + && (memoryInKb == other.memoryInKb) + && (salt.sameElements(other.salt)) + && (hash.sameElements(other.hash)) + + override def hashCode(): Int = + Objects.hash( + algorithmVersion, + algorithmType, + iterations, + parallelism, + memoryInKb, + salt, + hash + ) + + override def toString(): String = encode() + + /** Encode this hash as a '$' delimited string that includes all parameters. + * This string can be parsed by using the `decode` function. + * + * @return + * The encoded hash string. + */ + def encode(): String = + s"v=$algorithmVersion$$t=$algorithmType$$i=$iterations$$p=$parallelism$$m=$memoryInKb$$${encodedSalt()}$$${encodedHash()}" + + private def encodedSalt(): B64 = + Base64Encoder.encode(salt) + + private def encodedHash(): B64 = + Base64Encoder.encode(hash) + +object Argon2Hash: + + /** Decode a string produced by the [[Argon2Hash]] encode function. + * + * @param input + * The encoded string. + * @return + * The parsed hash, or `None` if the input is not a valid hash. + */ + def decode(input: String): Option[Argon2Hash] = + val parts = input.split("\\$") + if parts.length != 7 then None + else + for + v <- parseInt(parts(0)) + t <- parseInt(parts(1)) + i <- parseInt(parts(2)) + p <- parseInt(parts(3)) + m <- parseInt(parts(4)) + s <- parseBytes(parts(5)) + h <- parseBytes(parts(6)) + out <- new Builder() + .withAlgorithmVersion(v) + .withAlgorithmType(t) + .withIterations(i) + .withParallelism(p) + .withMemoryInKb(m) + .withSalt(s) + .withHash(h) + .build() + yield out + + private def parseInt(input: String): Option[Int] = + val parts = input.split("\\=") + if parts.length != 2 then None + else parts(1).toIntOption + + private def parseBytes(input: String): Option[Array[Byte]] = + Try(Base64.getDecoder().decode(input)).toOption + + private class Builder( + var algorithmVersion: Option[Int] = None, + var algorithmType: Option[Int] = None, + var iterations: Option[Int] = None, + var parallelism: Option[Int] = None, + var memoryInKb: Option[Int] = None, + var salt: Option[Array[Byte]] = None, + var hash: Option[Array[Byte]] = None + ): + + def withAlgorithmVersion(input: Int): Builder = + algorithmVersion = Some(input) + this + + def withAlgorithmType(input: Int): Builder = + algorithmType = Some(input) + this + + def withIterations(input: Int): Builder = + iterations = Some(input) + this + + def withParallelism(input: Int): Builder = + parallelism = Some(input) + this + + def withMemoryInKb(input: Int): Builder = + memoryInKb = Some(input) + this + + def withSalt(input: Array[Byte]): Builder = + salt = Some(input) + this + + def withHash(input: Array[Byte]): Builder = + hash = Some(input) + this + + def build(): Option[Argon2Hash] = + for + v <- algorithmVersion + t <- algorithmType + i <- iterations + p <- parallelism + m <- memoryInKb + s <- salt + h <- hash + yield new Argon2Hash( + algorithmVersion = v, + algorithmType = t, + iterations = i, + parallelism = p, + memoryInKb = m, + salt = s, + hash = h + ) + +end Argon2Hash diff --git a/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2Secret.scala b/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2Secret.scala new file mode 100644 index 0000000..1df546d --- /dev/null +++ b/modules/argon2/src/main/scala/gs/crypto/v0/argon2/Argon2Secret.scala @@ -0,0 +1,108 @@ +package gs.crypto.v0.argon2 + +import cats.Applicative +import cats.effect.Sync +import cats.syntax.all.* +import gs.crypto.v0.RandomByteProvider +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.util.Base64 + +/** Secret intended for use with [[Argon2]]. Random array of bytes. + * + * @param bytes + * The underlying bytes that represent the secret. + */ +final class Argon2Secret(val bytes: Array[Byte]): + + override def equals(obj: Any): Boolean = + obj match + case other: Argon2Secret => bytes.sameElements(other.bytes) + case _ => false + + override def hashCode(): Int = bytes.hashCode() + + /** @return + * The value of this secret, encoded using Base64. + */ + def encodeBase64(): String = Base64.getEncoder().encodeToString(bytes) + + /** Write this secret to a local file. + * + * @param path + * The path to write. Any existing file will be truncated. + * @return + * An effect which writes the secret. + */ + def writeToLocalFile[F[_]: Sync](path: Path): F[Unit] = + Argon2Secret.writeToLocalFile[F](path, this) + +object Argon2Secret: + + /** Decode the given Base64 string as an [[Argon2Secret]]. + * + * @param input + * The string that houses encoded bytes. + * @return + * The new [[Argon2Secret]] instance, backed by the input bytes. + */ + def decode(input: String): Argon2Secret = + new Argon2Secret(Base64.getDecoder().decode(input)) + + /** Load a secret from a local file. The following assumptions are made: + * + * - The file contains nothing but the base64-encoded bytes. + * - The file has no newlines. + * + * @param path + * The path to the local file. + * @return + * The loaded secret. + */ + def loadFromLocalFile[F[_]: Sync](path: Path): F[Argon2Secret] = + Sync[F] + .delay( + Files.readString(path, StandardCharsets.UTF_8) + ) + .map(decode) + + /** Write a secret to a local file. The resultant file will contain the + * base64-encoded bytes of the given secret. + * + * @param path + * The path for the local file. This file will be truncated if it exists. + * @param secret + * The secret to write. + * @return + * An effect which writes the secret. + */ + def writeToLocalFile[F[_]: Sync]( + path: Path, + secret: Argon2Secret + ): F[Unit] = + Sync[F].delay( + Files.writeString( + path, + secret.encodeBase64(), + StandardOpenOption.TRUNCATE_EXISTING + ) + ) + + /** Generate a new random secret. + * + * @param size + * The number of bytes in the secret value. + * @param randomByteProvider + * The [[RandomByteProvider]]. + * @return + * The new [[Argon2Secret]]. + */ + def generate[F[_]: Applicative]( + size: Int, + randomByteProvider: RandomByteProvider[F] + ): F[Argon2Secret] = + randomByteProvider.generateBytes(size).map(new Argon2Secret(_)) + +end Argon2Secret diff --git a/modules/argon2/src/test/scala/gs/crypto/v0/argon2/Argon2Tests.scala b/modules/argon2/src/test/scala/gs/crypto/v0/argon2/Argon2Tests.scala new file mode 100644 index 0000000..8bcbb5f --- /dev/null +++ b/modules/argon2/src/test/scala/gs/crypto/v0/argon2/Argon2Tests.scala @@ -0,0 +1,71 @@ +package gs.crypto.v0.argon2 + +import cats.effect.IO +import cats.effect.unsafe.IORuntime +import gs.crypto.v0.RandomByteProvider +import munit.Location +import org.bouncycastle.crypto.params.Argon2Parameters + +class Argon2Tests extends munit.FunSuite: + given IORuntime = IORuntime.global + + def iotest( + name: String + )( + f: => IO[Unit] + )( + using + Location + ): Unit = + test(name)(f.unsafeRunSync()) + + val rng: RandomByteProvider[IO] = RandomByteProvider.secureRandom[IO] + + val altConfig: Argon2.Config = Argon2.Config( + algorithmVersion = Argon2Parameters.ARGON2_VERSION_10, + algorithmType = Argon2Parameters.ARGON2_i, + iterations = 2, + parallelism = 1, + memoryInKb = 1024, + saltLengthInBytes = 8, + hashLengthInBytes = 64 + ) + + iotest("should calculate a hash and verify against that hash") { + val input = "some Complex password!1" + for + secret <- Argon2Secret.generate(32, rng) + argon2 <- IO(new Argon2[IO](Argon2.defaultConfig(), secret, rng)) + hash <- argon2.calculateHash(input) + matched <- argon2.doesInputMatch(input, hash) + encoded <- IO(hash.encode()) + decoded <- IO(Argon2Hash.decode(encoded)) + yield + assertEquals(matched, true) + assertEquals(hash.algorithmType, Argon2.Defaults.AlgorithmType) + assertEquals(hash.algorithmVersion, Argon2.Defaults.AlgorithmVersion) + assertEquals(hash.iterations, Argon2.Defaults.Iterations) + assertEquals(hash.parallelism, Argon2.Defaults.Parallelism) + assertEquals(hash.memoryInKb, Argon2.Defaults.MemoryInKB) + assertEquals(Some(hash), decoded) + } + + iotest("should match using stored params, not configured params") { + val input = "Another super s3cr3t pass@" + for + secret <- Argon2Secret.generate(32, rng) + altArgon2 <- IO(new Argon2[IO](altConfig, secret, rng)) + defArgon2 <- IO(new Argon2[IO](Argon2.defaultConfig(), secret, rng)) + altHash <- altArgon2.calculateHash(input) + // We're using the default configuration to run the match, and it should + // still match because we have the same secret and the params are + // extracted from the hash rather than the configuration. + matched <- defArgon2.doesInputMatch(input, altHash) + yield + assertEquals(matched, true) + assertEquals(altHash.algorithmType, altConfig.algorithmType) + assertEquals(altHash.algorithmVersion, altConfig.algorithmVersion) + assertEquals(altHash.iterations, altConfig.iterations) + assertEquals(altHash.parallelism, altConfig.parallelism) + assertEquals(altHash.memoryInKb, altConfig.memoryInKb) + } diff --git a/modules/core/src/main/scala/gs/crypto/v0/Base64Decoder.scala b/modules/core/src/main/scala/gs/crypto/v0/Base64Decoder.scala new file mode 100644 index 0000000..97680c6 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/Base64Decoder.scala @@ -0,0 +1,23 @@ +package gs.crypto.v0 + +import java.{util => ju} + +/** Implementation of [[Decoder]] for Base64 strings. + */ +object Base64Decoder extends Decoder[B64]: + private lazy val d: ju.Base64.Decoder = ju.Base64.getDecoder() + + /** @inheritDocs + */ + override def decode(input: B64): Array[Byte] = + d.decode(input.data) + + /** Decode some arbitrary string data. + * + * @param input + * The data to decode. + * @return + * The decoded bytes. + */ + def decodeUnsafe(input: String): Array[Byte] = + d.decode(input) diff --git a/modules/core/src/main/scala/gs/crypto/v0/Base64Encoder.scala b/modules/core/src/main/scala/gs/crypto/v0/Base64Encoder.scala new file mode 100644 index 0000000..525fc72 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/Base64Encoder.scala @@ -0,0 +1,23 @@ +package gs.crypto.v0 + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.Base64 + +/** Implementation of [[Encoder]] for Base64. + */ +object Base64Encoder extends Encoder[B64]: + private lazy val e: Base64.Encoder = Base64.getEncoder() + + /** @inheritDocs + */ + override def encode(input: Array[Byte]): B64 = + B64(e.encodeToString(input)) + + /** @inheritDocs + */ + override def encode( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): B64 = + encode(input.getBytes(charset)) diff --git a/modules/core/src/main/scala/gs/crypto/v0/Decoder.scala b/modules/core/src/main/scala/gs/crypto/v0/Decoder.scala new file mode 100644 index 0000000..c56b944 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/Decoder.scala @@ -0,0 +1,4 @@ +package gs.crypto.v0 + +trait Decoder[A]: + def decode(input: A): Array[Byte] diff --git a/modules/core/src/main/scala/gs/crypto/v0/Encode.scala b/modules/core/src/main/scala/gs/crypto/v0/Encode.scala new file mode 100644 index 0000000..a630c9e --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/Encode.scala @@ -0,0 +1,58 @@ +package gs.crypto.v0 + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** Helper functions for encoding data. + */ +object Encode: + + /** Encode an array of bytes using base64. + * + * @param input + * The bytes to encode. + * @return + * The base64 string. + */ + def base64(input: Array[Byte]): B64 = Base64Encoder.encode(input) + + /** Encode a string using base64. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The base64 string. + */ + def base64( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): B64 = + Base64Encoder.encode(input, charset) + + /** Encode an array of bytes using hexadecimal. + * + * @param input + * The bytes to encode. + * @return + * The hexadecimal string. + */ + def hex(input: Array[Byte]): Hex = HexEncoder.encode(input) + + /** Encode a string using hexadecimal. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The hexadecimal string. + */ + def hex( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): Hex = + HexEncoder.encode(input, charset) + +end Encode diff --git a/modules/core/src/main/scala/gs/crypto/v0/Encoded.scala b/modules/core/src/main/scala/gs/crypto/v0/Encoded.scala new file mode 100644 index 0000000..a80629e --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/Encoded.scala @@ -0,0 +1,109 @@ +package gs.crypto.v0 + +/** Represents encoded data. + * + * See: + * - [[B64]] + * - [[Hex]] + */ +trait Encoded: + /** @return + * The encoded data (expressed as a string). + */ + def data: String + + /** @return + * Decode the data to a byte array. + */ + def decode(): Array[Byte] + + /** Represents Base64-encoded data. + * + * @param data + * The encoded data. + */ + +final class B64( + val data: String +) extends Encoded: + /** @inheritDocs + */ + def decode(): Array[Byte] = Base64Decoder.decode(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: B64 => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object B64: + + /** Instantiate [[B64]] from the given string. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[B64]] instance. + */ + def apply( + data: String + ): B64 = new B64(data) + + given CanEqual[B64, B64] = CanEqual.derived + +end B64 + +/** Represents Hex-encoded data. + * + * @param data + * The encoded data. + */ +final class Hex( + val data: String +) extends Encoded: + /** @inheritDocs + */ + def decode(): Array[Byte] = HexDecoder.decode(this) + + /** @inheritDocs + */ + override def equals(obj: Any): Boolean = + obj match + case other: Hex => data == other.data + + /** @inheritDocs + */ + override def hashCode(): Int = data.hashCode() + + /** @inheritDocs + */ + override def toString(): String = data + +object Hex: + + /** Instantiate [[Hex]] from the given string. + * + * This function does NOT validate the input. + * + * @param data + * The encoded data. + * @return + * The new [[Hex]] instance. + */ + def apply( + data: String + ): Hex = new Hex(data) + + given CanEqual[Hex, Hex] = CanEqual.derived + +end Hex diff --git a/modules/core/src/main/scala/gs/crypto/v0/Encoder.scala b/modules/core/src/main/scala/gs/crypto/v0/Encoder.scala new file mode 100644 index 0000000..8e09a0e --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/Encoder.scala @@ -0,0 +1,44 @@ +package gs.crypto.v0 + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets + +/** Interface for byte encoding to String formats. + */ +trait Encoder[+A <: Encoded]: + /** Encode an array of bytes. + * + * @param input + * The bytes to encode. + * @return + * The encoded string. + */ + def encode(input: Array[Byte]): A + + /** Encode a string. + * + * @param input + * The string to encode. + * @param charset + * The character set of the input string. + * @return + * The encoded string. + */ + def encode( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): A + +object Encoder: + + /** @return + * The [[Base64Encoder]], typed to `Encoder[Encoded]`. + */ + def base64(): Encoder[Encoded] = Base64Encoder + + /** @return + * The [[HexEncoder]], typed to `Encoder[Encoded]`. + */ + def hex(): Encoder[Encoded] = HexEncoder + +end Encoder diff --git a/modules/core/src/main/scala/gs/crypto/v0/HexDecoder.scala b/modules/core/src/main/scala/gs/crypto/v0/HexDecoder.scala new file mode 100644 index 0000000..f8dd1a5 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/HexDecoder.scala @@ -0,0 +1,23 @@ +package gs.crypto.v0 + +import java.util.HexFormat + +/** Implementation of [[Decoder]] for Hexadecimal strings. + */ +object HexDecoder extends Decoder[Hex]: + private lazy val h: HexFormat = HexFormat.of() + + /** @inheritDocs + */ + override def decode(input: Hex): Array[Byte] = + h.parseHex(input.data) + + /** Decode some arbitrary string data. + * + * @param input + * The data to decode. + * @return + * The decoded bytes. + */ + def decodeUnsafe(input: String): Array[Byte] = + h.parseHex(input) diff --git a/modules/core/src/main/scala/gs/crypto/v0/HexEncoder.scala b/modules/core/src/main/scala/gs/crypto/v0/HexEncoder.scala new file mode 100644 index 0000000..4f48226 --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/HexEncoder.scala @@ -0,0 +1,23 @@ +package gs.crypto.v0 + +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.HexFormat + +/** Implementation of [[Encoder]] for Hexadecimal strings. + */ +object HexEncoder extends Encoder[Hex]: + private lazy val h: HexFormat = HexFormat.of() + + /** @inheritDocs + */ + override def encode(input: Array[Byte]): Hex = + Hex(h.formatHex(input)) + + /** @inheritDocs + */ + override def encode( + input: String, + charset: Charset = StandardCharsets.UTF_8 + ): Hex = + encode(input.getBytes(charset)) diff --git a/modules/core/src/main/scala/gs/crypto/v0/RandomByteProvider.scala b/modules/core/src/main/scala/gs/crypto/v0/RandomByteProvider.scala new file mode 100644 index 0000000..9901cdb --- /dev/null +++ b/modules/core/src/main/scala/gs/crypto/v0/RandomByteProvider.scala @@ -0,0 +1,77 @@ +package gs.crypto.v0 + +import cats.Applicative +import cats.effect.Sync +import java.security.SecureRandom + +/** Utility which produces random bytes. Used for cryptographic support. + */ +trait RandomByteProvider[F[_]]: + /** Generate the specified number of random bytes. + * + * @param numberOfBytes + * The number of bytes to generate. + * @return + * New array with the specified number of random bytes. + */ + def generateBytes(numberOfBytes: Int): F[Array[Byte]] + +object RandomByteProvider: + + /** Default random number generator. + */ + lazy val RNG: SecureRandom = new SecureRandom + + /** New [[RandomByteProvider]] that uses the default (secure) random number + * generator to produce bytes. + * + * @return + * The new provider instance. + */ + def secureRandom[F[_]: Sync]: RandomByteProvider[F] = + new SecureRandomProvider[F](RNG) + + /** New [[RandomByteProvider]] that uses the given `SecureRandom` instance to + * produce bytes. + * + * @param random + * The `SecureRandom` instance. + * @return + * The new provider. + */ + def secureRandom[F[_]: Sync](random: SecureRandom): RandomByteProvider[F] = + new SecureRandomProvider[F](random) + + /** @return + * New [[RandomByteProvider]] that returns an array of the null byte. + */ + def zero[F[_]: Applicative]: RandomByteProvider[F] = + new Zero[F] + + /** Implementation of [[RandomByteProvider]] that uses `SecureRandom`. + * + * @param random + * The random number generator. + */ + final class SecureRandomProvider[F[_]: Sync]( + val random: SecureRandom + ) extends RandomByteProvider[F]: + + /** @inheritDocs + */ + override def generateBytes(numberOfBytes: Int): F[Array[Byte]] = + Sync[F].delay { + val b = new Array[Byte](numberOfBytes) + random.nextBytes(b) + b + } + + /** Implementation of [[RandomByteProvider]] that always returns an array of + * the null byte. + */ + final class Zero[F[_]: Applicative] extends RandomByteProvider[F]: + + override def generateBytes(numberOfBytes: Int): F[Array[Byte]] = + Applicative[F].pure(Array.fill(numberOfBytes)(0)) + +end RandomByteProvider diff --git a/modules/rsa/src/main/scala/gs/crypto/v0/rsa/Rsa.scala b/modules/rsa/src/main/scala/gs/crypto/v0/rsa/Rsa.scala new file mode 100644 index 0000000..2ea1f6e --- /dev/null +++ b/modules/rsa/src/main/scala/gs/crypto/v0/rsa/Rsa.scala @@ -0,0 +1,181 @@ +package gs.crypto.v0.rsa + +import cats.effect.Sync +import cats.effect.kernel.Resource +import cats.syntax.all.* +import gs.crypto.v0.Base64Decoder +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] = + Base64Decoder.decodeUnsafe( + base + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("\n", "") + .trim() + ) + + private def preparePrivateKey(base: String): Array[Byte] = + Base64Decoder.decodeUnsafe( + base + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\n", "") + .trim() + ) + +end Rsa diff --git a/modules/rsa/src/main/scala/gs/crypto/v0/rsa/RsaDecryption.scala b/modules/rsa/src/main/scala/gs/crypto/v0/rsa/RsaDecryption.scala new file mode 100644 index 0000000..fc3dbe3 --- /dev/null +++ b/modules/rsa/src/main/scala/gs/crypto/v0/rsa/RsaDecryption.scala @@ -0,0 +1,58 @@ +package gs.crypto.v0.rsa + +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/rsa/src/main/scala/gs/crypto/v0/rsa/RsaEncryptedBytes.scala b/modules/rsa/src/main/scala/gs/crypto/v0/rsa/RsaEncryptedBytes.scala new file mode 100644 index 0000000..890c0d4 --- /dev/null +++ b/modules/rsa/src/main/scala/gs/crypto/v0/rsa/RsaEncryptedBytes.scala @@ -0,0 +1,45 @@ +package gs.crypto.v0.rsa + +import gs.crypto.v0.Encoded +import gs.crypto.v0.Encoder + +/** 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]): + + def encode(encoder: Encoder[Encoded] = Encoder.base64()): Encoded = + encoder.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: Encoded): RsaEncryptedBytes = + new RsaEncryptedBytes(value.decode()) + +end RsaEncryptedBytes diff --git a/modules/rsa/src/main/scala/gs/crypto/v0/rsa/RsaEncryption.scala b/modules/rsa/src/main/scala/gs/crypto/v0/rsa/RsaEncryption.scala new file mode 100644 index 0000000..3f5fea3 --- /dev/null +++ b/modules/rsa/src/main/scala/gs/crypto/v0/rsa/RsaEncryption.scala @@ -0,0 +1,46 @@ +package gs.crypto.v0.rsa + +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/rsa/src/test/resources/rsa4096-private-key.key b/modules/rsa/src/test/resources/rsa4096-private-key.key new file mode 100644 index 0000000..88094a5 --- /dev/null +++ b/modules/rsa/src/test/resources/rsa4096-private-key.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC0lNWR3ynKwzXw ++a85Qj0qpPpd3CSV+bJYGsXEJZujI/WrYvRfXFkuqXSGy2JGU5GyhAAzpD+qUOSS +ITt6S3hPzIjysqIeOJiL60rWaW6uSAfzn4s5e66kHaaAoKD0Q8sB0V3MwQ4YqnjF +eIR/FtUx2NfaguKD5Jn/BlG24n/qyUgteTUBcLEeXb7Mn8c5QI3zjjSvi471CAKj ++SxmgegjG01A5JXM+yJj8526pDmcHH23mivbNhw9m7nweFQbbqvV8THSaMS3DsTH +s5aOFea+lAxT3WNfhXVPO7/oRuM9WnRp8TUnKmlJcWeUqgEXI9ZvPNTuP1oHPXJ4 +YbGFP6thIUEW/ugXoFz74uHh5UGhR1QCcYwJucZWqla/zLmSF/sS29rYizlIxKoR +66PRcizOl1o9SYeGr0uQbZkWqoJarVaD+vFSd7Gag8KUT56/HD2szsCzYQ1djqWt +3f8jAu5SUfnVI4JCXbjGKs0O1MnmfUMXWEU++5wNAx0ysc/Y+nJXok9PPV4jNBFm +7lVi2p6gyFWHuces/kfDLCUthp0iDbw9akkANFkbJgXcCuFZyfecHIMWagiEGaLw +8x0cDIeqjzq7zkdA7QGvc9F/bEO2TvNwtC5f1y/m46WAB/N6W6JpIZb77AbTx046 +e5VtrLqF7xLBykjUePDdxybFCJnYDQIDAQABAoICACmgFDHVjfHf+SdkuqwZdXWG +xXKMy/8pKV/PPg6Wd6+NmrPIsdlodWNA6uwmZi8dVNygOlatEgLdti5sDCSG0IMe +e+Pr4txSAfHgySWu9HUmg/S3rlVQCgPpFMgaHrfnh5xR6UwJJUlwxDmKrAoKlpaw +rCMBoBq0f33udDgSsldJ0gIvahU8p4s/IzvSSc9L7ty5RzI+2nNnhwpKpd40LDEp +ei+O8WvoaLc/APj0oZX3aFBB8MGNUcmuw5fneMXBB0mf2TLt9QhYVmpNHpN+f2un +P0c2pVEvt4iN1pEBhCCQoPyJvg2a5F2qTyzQ2kL9/xAxpsiLYGKCWshehpfXQxbS +0Jano6y4THOfYTeirK59dTLRtaeYRv/uLoyqos7WkBhZTW46yVF5LP6p1xxnu+N1 +aDglfxpqBJ8zIeCv6tWgCY9mrD2pmpFjawvvFYsLVQyDPvSnxIrZu+ZCMFxeuZ10 +T5eB3UXRonWGqqlrJOtwdUeW9zyidPedcRxKTTyqpb5svmw+NZn8P+ehMrxqRZcu +mLmvbhXsQcVkOaEdAmjWAYk3efYw83uje+ULGGfJ3dHsQxvb9yoCAImZR12NWBrv +bCk4F0Q5AteIKE5c3eLmTjreV6R4KPVgGAUYBkt4DGhs9DZODFZ/31vtwHfX/S6F +tpJVNpY2rf52iZfJ8XMRAoIBAQD2iz33v6/ewhP1Ni+HmNK28Hc4I1Cr0K8H4zwd +kAy4YucR08djDi4Hha7Ab0iM+vgM6gwqaZewwHI+ky/IFQe3FA7Im8wEIYZnpgIF +YxbcBZKiQONkgGZkQNpR7XVDp+Z/vsCShgV6qldsYUnpsS05PITvdqvc58rOJKWt +DGoQqJ/526JbVsa4+z11S+XufHu06IFY9E3QgPErgJhCgAbs5GRxEDEMhlZtdn6A +4xkAUEGYnwYaYkKVdGOKd3sN25Y2NVhpBOg/lz/EorT9tRsQnmsM97uFZmAQmw7m +BzIP/9xFwuGbrrUKqXyRnHH7bAvISMnm1gDyUUahIm+/p1R9AoIBAQC7gevZ5upV +qx/rytoRbj0N42Jvxtg6XGUy3Jl6W9JT49qLATLrE7XSJKGvg/TxGlBLEupjpZae +Vwn4n/dELhj6mmaEcrWI0m1BPMATdrdhrouQPXhjdfR4gbpeNxg42tpclg7x+tdG +kUQm6VaPdHoM7RJOQWDb3fucc9Vzqp7Z9BB9UGGRcKxsEv/Wu/Db2R3VHvmbUrlW +u2RU9Sw+pNrlSdKx3hKoT0c0yoPiSHY9Tfd1tfFkSyiXEDADfgZJUA2pe8vn+Q/i +llJVb1aFpnuvyKnQD88U/Xyn5ds+aUSq22RbD76L5hSorMtrCAaCWCYgUF77x7xc +PfQHhG1bMbbRAoIBAAehD23XNK4D+3IfFyFvDTY0Arxt+1UVxBTOZ1HS31HlXZkj +oIvkKHB7Jok16FzUd1CO/Ylicxs5GU/uZhAe9non2L1EdO+7ydjzPiTEiDSOx5bV +wzOc9Y4so5TdcD+DtpJFaNgf5ZOCKepkqFDe9rNKuCJg3bicQ55Va/sK401Yqnqk +3UVOTh/zRleW3aqfl4RlnXsPNEk7dDsQY6XLKGu0NZd6FMp6bbo9bHS4klF8Kkt6 +wEmYuM6/J0VlpR0sql1LEU1OpZEyMPr4vfkL3aaKAG4KTHc4T8izw6ZCmr38AOj3 +utuCcH+/9ubanHxXP5YXCohmHulgsnrSAftARlECggEAIqC4tLIfXpjOuVXp9cQd +BF6UxD29mvGLQtxYf69LZXCz4G3lQGKQdnGLZoWBC7GnWGXy4VooOa+rSL4KBQ5a +UJWJDza77bumr6CPfEi1TxXT8lxXyk5zSnnyuAmGsKFCKE0SD4Aal46mPmVjNfT1 +wUNa2Rbb017oY5lEtyqwUWHwVaQtkJV1UjQkCT0GGyO6jaw9voCFd839lm78r8j0 +H9oFThHL8kdJyCcKOhTVuTaX16Y1ISd8JIG5zDtO3+Un0L/rBTkKxPar19lK6j23 +o9vz+FejD6ZMihk55wm7w63ml6aNsvpXoFrg6jA+O34Z9GfDUs4tK//I/EZph6jj +sQKCAQEArpOdpfVIWk9yLCvj1DRm6E4/KxppJf0jduoDPCLqGKtIPFNQVFvpqhnT +Q8Safkb3fdtgFMtxemF5iESZrlZapLzeOiWcWDkha29/RmlOS9BlXNdYTe4BIW5L +p3+c57CNh+iCe3Y+ZiUbRECqbxfDGcqClydJBjic8RExR2914R9JFC91nfDGfOJC +9da9bNZIFM+VC0bARC6Zg6Fdg+DIdrj3U+Zf7qG43ucU876dRHSvE1WiswcPK7c9 +pW+C9vUoSm2k1rFBzgMl2S5hLEa6wBVGg9um/VRBENLg1SepEiXVa0zoeViwRkZr +tXWSfxJaqr5e0HQj1LbBa+HOO4AvOQ== +-----END PRIVATE KEY----- diff --git a/modules/rsa/src/test/resources/rsa4096-public-key.crt b/modules/rsa/src/test/resources/rsa4096-public-key.crt new file mode 100644 index 0000000..e5598a1 --- /dev/null +++ b/modules/rsa/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/rsa/src/test/scala/gs/crypto/v0/rsa/RsaTests.scala b/modules/rsa/src/test/scala/gs/crypto/v0/rsa/RsaTests.scala new file mode 100644 index 0000000..8748dd2 --- /dev/null +++ b/modules/rsa/src/test/scala/gs/crypto/v0/rsa/RsaTests.scala @@ -0,0 +1,64 @@ +package gs.crypto.v0.rsa + +import cats.effect.IO +import cats.effect.unsafe.IORuntime +import java.nio.file.Path +import java.util.UUID +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 = UUID.randomUUID().toString() + 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 = UUID.randomUUID().toString() + 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/rsa/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 diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..4d6c567 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.12.2 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..1db1aad --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,33 @@ +def selectCredentials(): Credentials = + if ((Path.userHome / ".sbt" / ".credentials").exists()) + Credentials(Path.userHome / ".sbt" / ".credentials") + else + Credentials.apply( + realm = "Reposilite", + host = "maven.garrity.co", + userName = sys.env + .get("GS_MAVEN_USER") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_USER environment variable." + ) + ), + passwd = sys.env + .get("GS_MAVEN_TOKEN") + .getOrElse( + throw new RuntimeException( + "You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_TOKEN environment variable." + ) + ) + ) + +credentials += selectCredentials() + +externalResolvers := Seq( + "Garrity Software Mirror" at "https://maven.garrity.co/releases", + "Garrity Software Releases" at "https://maven.garrity.co/gs" +) + +addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.4.4") +addSbtPlugin("gs" % "sbt-garrity-software" % "0.7.0") +addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0")