(patch) Initial commit with common types and two algorithms.
All checks were successful
/ Build and Release Library (push) Successful in 2m34s

This commit is contained in:
Pat Garrity 2026-02-13 21:17:59 -06:00
commit 9bef6a92ff
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
30 changed files with 1782 additions and 0 deletions

View file

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

View file

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

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
target/
project/target/
project/project/
modules/core/target/
.version

16
.pre-commit-config.yaml Normal file
View file

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

72
.scalafmt.conf Normal file
View file

@ -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/**"
]
}

9
LICENSE Normal file
View file

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

34
README.md Normal file
View file

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

99
build.sbt Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
package gs.crypto.v0
trait Decoder[A]:
def decode(input: A): Array[Byte]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

1
project/build.properties Normal file
View file

@ -0,0 +1 @@
sbt.version=1.12.2

33
project/plugins.sbt Normal file
View file

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