More updates and beginning the user model. Added Argon2 support.
This commit is contained in:
parent
3e0e1690fe
commit
e172d782b3
11 changed files with 613 additions and 20 deletions
10
build.sbt
10
build.sbt
|
@ -79,6 +79,12 @@ lazy val deps = new {
|
||||||
|
|
||||||
val CatsEffect: ModuleID =
|
val CatsEffect: ModuleID =
|
||||||
"org.typelevel" %% "cats-effect" % "3.5.2"
|
"org.typelevel" %% "cats-effect" % "3.5.2"
|
||||||
|
|
||||||
|
val JUG: ModuleID =
|
||||||
|
"com.fasterxml.uuid" % "java-uuid-generator" % "4.1.1"
|
||||||
|
|
||||||
|
val BouncyCastle: ModuleID =
|
||||||
|
"org.bouncycastle" % "bcprov-jdk18on" % "1.76"
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy val testDeps = new {
|
lazy val testDeps = new {
|
||||||
|
@ -114,7 +120,7 @@ lazy val uuid = project
|
||||||
.settings(testSettings)
|
.settings(testSettings)
|
||||||
.settings(
|
.settings(
|
||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
"com.fasterxml.uuid" % "java-uuid-generator" % "4.1.1"
|
deps.JUG
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -136,6 +142,8 @@ lazy val crypto = project
|
||||||
.settings(testSettings)
|
.settings(testSettings)
|
||||||
.settings(
|
.settings(
|
||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
|
deps.BouncyCastle,
|
||||||
|
deps.CatsEffect
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
package gs.shortform.crypto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an opaque encoded credential. This type does not track the type
|
||||||
|
* of encoding used.
|
||||||
|
*/
|
||||||
|
opaque type EncodedCredential = String
|
||||||
|
|
||||||
|
object EncodedCredential:
|
||||||
|
def apply(credential: String): EncodedCredential = credential
|
||||||
|
|
||||||
|
extension (credential: EncodedCredential)
|
||||||
|
def render(): String = credential
|
||||||
|
def str(): String = credential
|
||||||
|
|
||||||
|
end EncodedCredential
|
|
@ -0,0 +1,264 @@
|
||||||
|
package gs.shortform.crypto.argon2
|
||||||
|
|
||||||
|
import gs.shortform.crypto.EncodedCredential
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.Base64
|
||||||
|
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
|
||||||
|
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Argon2id support based on BouncyCastle. For typical use cases please refer
|
||||||
|
* to `Argon2.defaultInstance()`.
|
||||||
|
*
|
||||||
|
* This class does not support other Argon2 flavors.
|
||||||
|
*
|
||||||
|
* @param saltLength
|
||||||
|
* The salt length for hashing.
|
||||||
|
* @param hashLength
|
||||||
|
* The overall hash length
|
||||||
|
* @param memoryInKb
|
||||||
|
* Memory in KB to use for Argon2.
|
||||||
|
* @param iterations
|
||||||
|
* Number of iterations.
|
||||||
|
* @param parallelism
|
||||||
|
* Allowed parallelism (lanes).
|
||||||
|
* @param rng
|
||||||
|
* Secure random number generator.
|
||||||
|
*/
|
||||||
|
final class Argon2(
|
||||||
|
val saltLength: Int,
|
||||||
|
val hashLength: Int,
|
||||||
|
val memoryInKb: Int,
|
||||||
|
val iterations: Int,
|
||||||
|
val parallelism: Int,
|
||||||
|
val rng: SecureRandom
|
||||||
|
):
|
||||||
|
|
||||||
|
def hashCredential(input: String): EncodedCredential = encode(hash(input))
|
||||||
|
|
||||||
|
/** Hash the given input using Argon2id.
|
||||||
|
*
|
||||||
|
* @param input
|
||||||
|
* The input to hash.
|
||||||
|
* @return
|
||||||
|
* The output, containing the argon2id hash and argon2 parameters.
|
||||||
|
*/
|
||||||
|
def hash(input: String): Argon2Output =
|
||||||
|
val params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
|
||||||
|
.withMemoryAsKB(memoryInKb)
|
||||||
|
.withIterations(iterations)
|
||||||
|
.withParallelism(parallelism)
|
||||||
|
.withSalt(generateSalt(saltLength))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val generator = new Argon2BytesGenerator()
|
||||||
|
val _ = generator.init(params)
|
||||||
|
val out = new Array[Byte](hashLength)
|
||||||
|
val _ = generator.generateBytes(input.getBytes(StandardCharsets.UTF_8), out)
|
||||||
|
Argon2Output(out, params)
|
||||||
|
|
||||||
|
private def generateSalt(length: Int): Array[Byte] =
|
||||||
|
val bytes = new Array[Byte](length)
|
||||||
|
val _ = rng.nextBytes(bytes)
|
||||||
|
bytes
|
||||||
|
|
||||||
|
/** Test whether some input matches some target encoded credential. The target
|
||||||
|
* must be encoded using the standard Argon2 encoding (produced by the
|
||||||
|
* `encode` function of this class).
|
||||||
|
*
|
||||||
|
* @param candidate
|
||||||
|
* The candidate to test.
|
||||||
|
* @param target
|
||||||
|
* The target encoded credential to match against.
|
||||||
|
* @return
|
||||||
|
* True if the candidate matches the credential, false otherwise.
|
||||||
|
*/
|
||||||
|
def matches(
|
||||||
|
candidate: String,
|
||||||
|
target: EncodedCredential
|
||||||
|
): Boolean =
|
||||||
|
decode(target)
|
||||||
|
.map { output =>
|
||||||
|
val bytes = new Array[Byte](output.hash.length)
|
||||||
|
val generator = new Argon2BytesGenerator
|
||||||
|
val _ = generator.init(output.parameters)
|
||||||
|
val _ = generator.generateBytes(
|
||||||
|
candidate.getBytes(StandardCharsets.UTF_8),
|
||||||
|
bytes
|
||||||
|
)
|
||||||
|
output.hash.sameElements(bytes)
|
||||||
|
}
|
||||||
|
.getOrElse(false)
|
||||||
|
|
||||||
|
/** Encode an Argon2id hash according to the standard format defined in the
|
||||||
|
* Argon2 reference implementation:
|
||||||
|
*
|
||||||
|
* $argon2<T>[$v=<num>]$m=<num>,t=<num>,p=<num>$<bin>$<bin>
|
||||||
|
*
|
||||||
|
* - `v` = version
|
||||||
|
* - `m` = memory
|
||||||
|
* - `t` = iterations
|
||||||
|
* - `p` = lanes (parallelism)
|
||||||
|
*
|
||||||
|
* Quoted from the reference:
|
||||||
|
*
|
||||||
|
* "The last two binary chunks (encoded in Base64) are, in that order, the
|
||||||
|
* salt and the output. Both are required. The binary salt length and the
|
||||||
|
* output length must be in the allowed ranges defined in argon2."
|
||||||
|
*
|
||||||
|
* The reference explicitly disallows padding characters in the Base64
|
||||||
|
* encoding.
|
||||||
|
*
|
||||||
|
* ### Implementation Notes
|
||||||
|
*
|
||||||
|
* This implementation is specific to Argon2id and requires a version.
|
||||||
|
*
|
||||||
|
* @param argon2
|
||||||
|
* The hash and parameters.
|
||||||
|
* @return
|
||||||
|
* The string encoding of the Argon2id hash.
|
||||||
|
*/
|
||||||
|
def encode(output: Argon2Output): EncodedCredential =
|
||||||
|
val builder = new java.lang.StringBuilder
|
||||||
|
builder
|
||||||
|
.append("$")
|
||||||
|
.append(Argon2.Algorithm)
|
||||||
|
.append("$v=")
|
||||||
|
.append(output.parameters.getVersion())
|
||||||
|
.append("$m=")
|
||||||
|
.append(output.parameters.getMemory())
|
||||||
|
.append(",t=")
|
||||||
|
.append(output.parameters.getIterations())
|
||||||
|
.append(",p=")
|
||||||
|
.append(output.parameters.getLanes())
|
||||||
|
|
||||||
|
Option(output.parameters.getSalt())
|
||||||
|
.foreach(salt =>
|
||||||
|
builder.append("$").append(Argon2.b64e.encodeToString(salt))
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.append("$").append(Argon2.b64e.encodeToString(output.hash))
|
||||||
|
|
||||||
|
EncodedCredential(builder.toString())
|
||||||
|
|
||||||
|
/** Decode an encoded Argon2 credential. If successful, unpacks the encoded
|
||||||
|
* form as [[Argon2Output]].
|
||||||
|
*
|
||||||
|
* @param credential
|
||||||
|
* The encoded credential to decode.
|
||||||
|
* @return
|
||||||
|
* The decoded [[Argon2Output]], or `None` if the input is invalid.
|
||||||
|
*/
|
||||||
|
def decode(credential: EncodedCredential): Option[Argon2Output] =
|
||||||
|
val parts = credential.str().split("\\$")
|
||||||
|
if parts.length != 6 then None
|
||||||
|
else decodeParts(parts(1), parts(2), parts(3), parts(4), parts(5))
|
||||||
|
|
||||||
|
private def decodeParts(
|
||||||
|
algorithmPart: String,
|
||||||
|
versionPart: String,
|
||||||
|
performancePart: String,
|
||||||
|
saltPart: String,
|
||||||
|
hashPart: String
|
||||||
|
): Option[Argon2Output] =
|
||||||
|
decodeAlgorithm(algorithmPart).flatMap { builder =>
|
||||||
|
for
|
||||||
|
version <- decodeVersion(versionPart)
|
||||||
|
(mem, iter, lanes) <- decodePerformance(performancePart)
|
||||||
|
yield Argon2Output(
|
||||||
|
hash = Argon2.b64d.decode(hashPart),
|
||||||
|
parameters = builder
|
||||||
|
.withVersion(version)
|
||||||
|
.withMemoryAsKB(mem)
|
||||||
|
.withIterations(iter)
|
||||||
|
.withParallelism(lanes)
|
||||||
|
.withSalt(Argon2.b64d.decode(saltPart))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def decodeAlgorithm(
|
||||||
|
candidate: String
|
||||||
|
): Option[Argon2Parameters.Builder] =
|
||||||
|
if candidate.equalsIgnoreCase(Argon2.Algorithm) then
|
||||||
|
Some(new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id))
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def decodeVersion(
|
||||||
|
candidate: String
|
||||||
|
): Option[Int] =
|
||||||
|
if candidate.startsWith("v=") then
|
||||||
|
Try(candidate.substring(2).toInt).toOption
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def decodePerformance(performancePart: String)
|
||||||
|
: Option[(Int, Int, Int)] =
|
||||||
|
val parts = performancePart.split(",")
|
||||||
|
if parts.length == 3 then
|
||||||
|
for
|
||||||
|
mem <- decodeMemory(parts(0))
|
||||||
|
iter <- decodeIterations(parts(1))
|
||||||
|
lanes <- decodeLanes(parts(2))
|
||||||
|
yield (mem, iter, lanes)
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def decodeMemory(
|
||||||
|
candidate: String
|
||||||
|
): Option[Int] =
|
||||||
|
if candidate.startsWith("m=") then
|
||||||
|
Try(candidate.substring(2).toInt).toOption
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def decodeIterations(
|
||||||
|
candidate: String
|
||||||
|
): Option[Int] =
|
||||||
|
if candidate.startsWith("t=") then
|
||||||
|
Try(candidate.substring(2).toInt).toOption
|
||||||
|
else None
|
||||||
|
|
||||||
|
private def decodeLanes(
|
||||||
|
candidate: String
|
||||||
|
): Option[Int] =
|
||||||
|
if candidate.startsWith("p=") then
|
||||||
|
Try(candidate.substring(2).toInt).toOption
|
||||||
|
else None
|
||||||
|
|
||||||
|
object Argon2:
|
||||||
|
|
||||||
|
/** The formal algorithm name: `argon2id`
|
||||||
|
*/
|
||||||
|
val Algorithm: String = "argon2id"
|
||||||
|
|
||||||
|
/** Instantiate an instance of the Argon2 algorithm with default parameters.
|
||||||
|
*
|
||||||
|
* @param rng
|
||||||
|
* The secure random number generator to use for salts.
|
||||||
|
* @return
|
||||||
|
* The new [[Argon2]] instance.
|
||||||
|
*/
|
||||||
|
def defaultInstance(rng: SecureRandom = new SecureRandom()): Argon2 =
|
||||||
|
new Argon2(
|
||||||
|
saltLength = Defaults.SaltLength,
|
||||||
|
hashLength = Defaults.HashLength,
|
||||||
|
memoryInKb = Defaults.Memory,
|
||||||
|
iterations = Defaults.Iterations,
|
||||||
|
parallelism = Defaults.Parallelism,
|
||||||
|
rng = rng
|
||||||
|
)
|
||||||
|
|
||||||
|
/** According to the OWASP Cheat Sheet: Use Argon2id with a minimum
|
||||||
|
* configuration of 19 MiB of memory, an iteration count of 2, and 1 degree
|
||||||
|
* of parallelism.
|
||||||
|
*/
|
||||||
|
object Defaults:
|
||||||
|
val SaltLength: Int = 16
|
||||||
|
val HashLength: Int = 32
|
||||||
|
val Memory: Int = 19456
|
||||||
|
val Iterations: Int = 2
|
||||||
|
val Parallelism: Int = 1
|
||||||
|
end Defaults
|
||||||
|
|
||||||
|
private val b64e = Base64.getEncoder().withoutPadding()
|
||||||
|
private val b64d = Base64.getDecoder()
|
||||||
|
end Argon2
|
|
@ -0,0 +1,15 @@
|
||||||
|
package gs.shortform.crypto.argon2
|
||||||
|
|
||||||
|
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||||
|
|
||||||
|
/** Represents the output of [[Argon2]] being applied to some input.
|
||||||
|
*
|
||||||
|
* @param hash
|
||||||
|
* The hashed representation of the data.
|
||||||
|
* @param parameters
|
||||||
|
* The Argon2 parameters used to produce the hash.
|
||||||
|
*/
|
||||||
|
case class Argon2Output(
|
||||||
|
hash: Array[Byte],
|
||||||
|
parameters: Argon2Parameters
|
||||||
|
)
|
|
@ -0,0 +1,178 @@
|
||||||
|
package gs.shortform.crypto.argon2
|
||||||
|
|
||||||
|
import gs.shortform.crypto.EncodedCredential
|
||||||
|
import java.util.UUID
|
||||||
|
import munit.*
|
||||||
|
import org.bouncycastle.crypto.params.Argon2Parameters
|
||||||
|
|
||||||
|
class Argon2Tests extends FunSuite:
|
||||||
|
import Argon2Tests.TestData
|
||||||
|
|
||||||
|
val argon2 = Argon2.defaultInstance()
|
||||||
|
|
||||||
|
test("should hash some value, encode the hash, and decode the encoded form") {
|
||||||
|
val data = UUID.randomUUID().toString()
|
||||||
|
val hash = argon2.hash(data)
|
||||||
|
val encoded = argon2.encode(hash)
|
||||||
|
val decoded = argon2.decode(encoded)
|
||||||
|
|
||||||
|
// Ensure the decoded hash has the same bytes as the original hash.
|
||||||
|
assertEquals(decoded.isDefined, true)
|
||||||
|
decoded.foreach { d =>
|
||||||
|
assertEquals(d.hash.sameElements(hash.hash), true)
|
||||||
|
assertEquals(
|
||||||
|
d.parameters.getSalt().sameElements(hash.parameters.getSalt()),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
assertEquals(d.parameters.getVersion(), hash.parameters.getVersion())
|
||||||
|
assertEquals(d.parameters.getMemory(), hash.parameters.getMemory())
|
||||||
|
assertEquals(
|
||||||
|
d.parameters.getIterations(),
|
||||||
|
hash.parameters.getIterations()
|
||||||
|
)
|
||||||
|
assertEquals(d.parameters.getLanes(), hash.parameters.getLanes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should decode a valid hash") {
|
||||||
|
val hash = EncodedCredential(TestData.Valid)
|
||||||
|
val decoded = argon2.decode(hash)
|
||||||
|
assertEquals(decoded.isDefined, true)
|
||||||
|
decoded.foreach { d =>
|
||||||
|
assertEquals(
|
||||||
|
d.parameters.getVersion(),
|
||||||
|
Argon2Parameters.ARGON2_VERSION_13
|
||||||
|
)
|
||||||
|
assertEquals(d.parameters.getMemory(), Argon2.Defaults.Memory)
|
||||||
|
assertEquals(d.parameters.getIterations(), Argon2.Defaults.Iterations)
|
||||||
|
assertEquals(d.parameters.getLanes(), Argon2.Defaults.Parallelism)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should refuse to decode an encoded hash with a bad algorithm") {
|
||||||
|
val credential = EncodedCredential(TestData.BadAlgorithm)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should refuse to decode an encoded hash with a bad version") {
|
||||||
|
val credential = EncodedCredential(TestData.BadVersion)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should refuse to decode an encoded hash with bad memory") {
|
||||||
|
val credential = EncodedCredential(TestData.BadMemory)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should refuse to decode an encoded hash with bad iterations") {
|
||||||
|
val credential = EncodedCredential(TestData.BadIterations)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should refuse to decode an encoded hash with bad parallelism") {
|
||||||
|
val credential = EncodedCredential(TestData.BadParallelism)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should refuse to decode an encoded hash with an invalid number of parts"
|
||||||
|
) {
|
||||||
|
val credential = EncodedCredential(TestData.WrongNumberOfParts)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should refuse to decode an encoded hash with an invalid number of performance parts"
|
||||||
|
) {
|
||||||
|
val credential = EncodedCredential(TestData.WrongNumberOfPerformanceParts)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should refuse to decode an encoded hash with an invalid version prefix"
|
||||||
|
) {
|
||||||
|
val credential = EncodedCredential(TestData.BadVersionPrefix)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should refuse to decode an encoded hash with an invalid memory prefix"
|
||||||
|
) {
|
||||||
|
val credential = EncodedCredential(TestData.BadMemoryPrefix)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should refuse to decode an encoded hash with an invalid iterations prefix"
|
||||||
|
) {
|
||||||
|
val credential = EncodedCredential(TestData.BadIterationsPrefix)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should refuse to decode an encoded hash with an invalid parallelism prefix"
|
||||||
|
) {
|
||||||
|
val credential = EncodedCredential(TestData.BadParallelismPrefix)
|
||||||
|
assertEquals(argon2.decode(credential).isEmpty, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should determine that equal inputs have matching hashes"
|
||||||
|
) {
|
||||||
|
val data = UUID.randomUUID().toString()
|
||||||
|
val output = argon2.hash(data)
|
||||||
|
val encoded = argon2.encode(output)
|
||||||
|
assertEquals(argon2.matches(data, encoded), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
"should determine that non-equal inputs do not have matching hashes"
|
||||||
|
) {
|
||||||
|
val data = UUID.randomUUID().toString()
|
||||||
|
val output = argon2.hash(data)
|
||||||
|
val encoded = argon2.encode(output)
|
||||||
|
assertEquals(argon2.matches("foo", encoded), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
object Argon2Tests:
|
||||||
|
|
||||||
|
object TestData:
|
||||||
|
|
||||||
|
val Valid: String =
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadAlgorithm: String =
|
||||||
|
"$argon2$v=19$m=19456,t=2,p=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadVersion: String =
|
||||||
|
"$argon2id$v=XYZ$m=19456,t=2,p=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadMemory: String =
|
||||||
|
"$argon2id$v=19$m=XYZ,t=2,p=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadIterations: String =
|
||||||
|
"$argon2id$v=19$m=19456,t=XYZ,p=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadParallelism: String =
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=XYZ$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val WrongNumberOfParts: String = "$argon2id$v=19$m=19456,t=2,p=1"
|
||||||
|
|
||||||
|
val WrongNumberOfPerformanceParts: String =
|
||||||
|
"$argon2id$v=19$m=19456$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadVersionPrefix: String =
|
||||||
|
"$argon2id$Z=19$m=19456,t=2,p=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadMemoryPrefix: String =
|
||||||
|
"$argon2id$v=19$Z=19456,t=2,p=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadIterationsPrefix: String =
|
||||||
|
"$argon2id$v=19$m=19456,Z=2,p=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
val BadParallelismPrefix: String =
|
||||||
|
"$argon2id$v=19$m=19456,t=2,Z=1$/Uz9Rqt/b6SN53LfdNmfYA$v1Nscv0zqsMSvBnh6DlhubjCrmcx5dZTrOOnImPiOZ4"
|
||||||
|
|
||||||
|
end TestData
|
||||||
|
|
||||||
|
end Argon2Tests
|
|
@ -19,7 +19,7 @@ import gs.shortform.crypto.Hash
|
||||||
case class Content(
|
case class Content(
|
||||||
externalId: UUID,
|
externalId: UUID,
|
||||||
createdAt: CreatedAt,
|
createdAt: CreatedAt,
|
||||||
createdBy: CreatedBy,
|
createdBy: Username,
|
||||||
title: Title,
|
title: Title,
|
||||||
hash: Hash
|
hash: Hash
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
package gs.shortform.model
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Username of the user who created some resource.
|
|
||||||
*/
|
|
||||||
opaque type CreatedBy = String
|
|
||||||
|
|
||||||
object CreatedBy:
|
|
||||||
|
|
||||||
// TODO: Create from username.
|
|
||||||
def apply(value: String): CreatedBy = value
|
|
||||||
|
|
||||||
given CanEqual[CreatedBy, CreatedBy] = CanEqual.derived
|
|
||||||
|
|
||||||
extension (createdBy: CreatedBy)
|
|
||||||
def str(): String = createdBy
|
|
||||||
|
|
||||||
end CreatedBy
|
|
41
modules/model/src/main/scala/gs/shortform/model/Role.scala
Normal file
41
modules/model/src/main/scala/gs/shortform/model/Role.scala
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package gs.shortform.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration of roles for users.
|
||||||
|
*
|
||||||
|
* - `poster`: Allowed to upload files and post content.
|
||||||
|
* - `commenter`: Allowed to post comments and engage in discussion.
|
||||||
|
*
|
||||||
|
* @param name The unique name of the Role.
|
||||||
|
*/
|
||||||
|
sealed abstract class Role(val name: String)
|
||||||
|
|
||||||
|
object Role:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role for users allowed to upload files and post content.
|
||||||
|
*/
|
||||||
|
case object Poster extends Role("poster")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role for users allowed to post comments.
|
||||||
|
*/
|
||||||
|
case object Commenter extends Role("commenter")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of all supported roles.
|
||||||
|
*/
|
||||||
|
val All: List[Role] = List(Poster, Commenter)
|
||||||
|
|
||||||
|
given CanEqual[Role, Role] = CanEqual.derived
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given some string, select the appropriate [[Role]].
|
||||||
|
*
|
||||||
|
* @param name The name to parse.
|
||||||
|
* @return The role with the given name, or `None` if no such role exists.
|
||||||
|
*/
|
||||||
|
def parse(name: String): Option[Role] =
|
||||||
|
All.find(_.name.equalsIgnoreCase(name))
|
||||||
|
|
||||||
|
end Role
|
21
modules/model/src/main/scala/gs/shortform/model/User.scala
Normal file
21
modules/model/src/main/scala/gs/shortform/model/User.scala
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
package gs.shortform.model
|
||||||
|
|
||||||
|
import gs.shortform.crypto.EncodedCredential
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a ShortForm user. Users are uniquely identified by their
|
||||||
|
* _username_.
|
||||||
|
*
|
||||||
|
* @param username The user's unique identifier.
|
||||||
|
* @param password The user's hashed, encoded password.
|
||||||
|
* @param role This user's [[Role]].
|
||||||
|
* @param status The current [[UserStatus]] of the user.
|
||||||
|
* @param createdAt The instant this user account was created.
|
||||||
|
*/
|
||||||
|
case class User(
|
||||||
|
username: Username,
|
||||||
|
password: EncodedCredential,
|
||||||
|
role: Role,
|
||||||
|
status: UserStatus,
|
||||||
|
createdAt: CreatedAt
|
||||||
|
)
|
|
@ -0,0 +1,49 @@
|
||||||
|
package gs.shortform.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration of statuses for user accounts:
|
||||||
|
*
|
||||||
|
* - `active`: The account is active and working normally.
|
||||||
|
* - `locked`: The account cannot be used.
|
||||||
|
* - `initializing`: The account requires a password.
|
||||||
|
*
|
||||||
|
* @param name The unique name of the `UserStatus`.
|
||||||
|
*/
|
||||||
|
sealed abstract class UserStatus(val name: String)
|
||||||
|
|
||||||
|
object UserStatus:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regular user accounts
|
||||||
|
*/
|
||||||
|
case object Active extends UserStatus("active")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locked user accounts -- the account cannot be used at all. Login will fail
|
||||||
|
* and any existing sessions will reject the user.
|
||||||
|
*/
|
||||||
|
case object Locked extends UserStatus("locked")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New accounts which require a password.
|
||||||
|
*/
|
||||||
|
case object Initializing extends UserStatus("initializing")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of all supported statuses.
|
||||||
|
*/
|
||||||
|
val All: List[UserStatus] = List(Active, Locked, Initializing)
|
||||||
|
|
||||||
|
given CanEqual[UserStatus, UserStatus] = CanEqual.derived
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given some string, select the appropriate [[UserStatus]].
|
||||||
|
*
|
||||||
|
* @param name The name to parse.
|
||||||
|
* @return The status with the given name, or `None` if no such status exists.
|
||||||
|
*/
|
||||||
|
def parse(name: String): Option[UserStatus] =
|
||||||
|
All.find(_.name.equalsIgnoreCase(name))
|
||||||
|
|
||||||
|
end UserStatus
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package gs.shortform.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique name for a user.
|
||||||
|
*/
|
||||||
|
opaque type Username = String
|
||||||
|
|
||||||
|
object Username:
|
||||||
|
|
||||||
|
def apply(value: String): Username = value
|
||||||
|
|
||||||
|
given CanEqual[Username, Username] = CanEqual.derived
|
||||||
|
|
||||||
|
extension (title: Username)
|
||||||
|
def str(): String = title
|
||||||
|
|
||||||
|
end Username
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue