From e172d782b38e39aae48f7a45aa3bdbaf0ebbbbd9 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Tue, 28 Nov 2023 20:06:56 -0600 Subject: [PATCH] More updates and beginning the user model. Added Argon2 support. --- build.sbt | 10 +- .../shortform/crypto/EncodedCredential.scala | 16 ++ .../gs/shortform/crypto/argon2/Argon2.scala | 264 ++++++++++++++++++ .../crypto/argon2/Argon2Output.scala | 15 + .../shortform/crypto/argon2/Argon2Tests.scala | 178 ++++++++++++ .../scala/gs/shortform/model/Content.scala | 2 +- .../scala/gs/shortform/model/CreatedBy.scala | 18 -- .../main/scala/gs/shortform/model/Role.scala | 41 +++ .../main/scala/gs/shortform/model/User.scala | 21 ++ .../scala/gs/shortform/model/UserStatus.scala | 49 ++++ .../scala/gs/shortform/model/Username.scala | 19 ++ 11 files changed, 613 insertions(+), 20 deletions(-) create mode 100644 modules/crypto/src/main/scala/gs/shortform/crypto/EncodedCredential.scala create mode 100644 modules/crypto/src/main/scala/gs/shortform/crypto/argon2/Argon2.scala create mode 100644 modules/crypto/src/main/scala/gs/shortform/crypto/argon2/Argon2Output.scala create mode 100644 modules/crypto/src/test/scala/gs/shortform/crypto/argon2/Argon2Tests.scala delete mode 100644 modules/model/src/main/scala/gs/shortform/model/CreatedBy.scala create mode 100644 modules/model/src/main/scala/gs/shortform/model/Role.scala create mode 100644 modules/model/src/main/scala/gs/shortform/model/User.scala create mode 100644 modules/model/src/main/scala/gs/shortform/model/UserStatus.scala create mode 100644 modules/model/src/main/scala/gs/shortform/model/Username.scala diff --git a/build.sbt b/build.sbt index 78e0c98..35ecea6 100644 --- a/build.sbt +++ b/build.sbt @@ -79,6 +79,12 @@ lazy val deps = new { val CatsEffect: ModuleID = "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 { @@ -114,7 +120,7 @@ lazy val uuid = project .settings(testSettings) .settings( libraryDependencies ++= Seq( - "com.fasterxml.uuid" % "java-uuid-generator" % "4.1.1" + deps.JUG ) ) @@ -136,6 +142,8 @@ lazy val crypto = project .settings(testSettings) .settings( libraryDependencies ++= Seq( + deps.BouncyCastle, + deps.CatsEffect ) ) diff --git a/modules/crypto/src/main/scala/gs/shortform/crypto/EncodedCredential.scala b/modules/crypto/src/main/scala/gs/shortform/crypto/EncodedCredential.scala new file mode 100644 index 0000000..e52bd64 --- /dev/null +++ b/modules/crypto/src/main/scala/gs/shortform/crypto/EncodedCredential.scala @@ -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 diff --git a/modules/crypto/src/main/scala/gs/shortform/crypto/argon2/Argon2.scala b/modules/crypto/src/main/scala/gs/shortform/crypto/argon2/Argon2.scala new file mode 100644 index 0000000..ddc60f5 --- /dev/null +++ b/modules/crypto/src/main/scala/gs/shortform/crypto/argon2/Argon2.scala @@ -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[$v=]$m=,t=,p=$$ + * + * - `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 diff --git a/modules/crypto/src/main/scala/gs/shortform/crypto/argon2/Argon2Output.scala b/modules/crypto/src/main/scala/gs/shortform/crypto/argon2/Argon2Output.scala new file mode 100644 index 0000000..c7ccda9 --- /dev/null +++ b/modules/crypto/src/main/scala/gs/shortform/crypto/argon2/Argon2Output.scala @@ -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 +) diff --git a/modules/crypto/src/test/scala/gs/shortform/crypto/argon2/Argon2Tests.scala b/modules/crypto/src/test/scala/gs/shortform/crypto/argon2/Argon2Tests.scala new file mode 100644 index 0000000..2d9f8f6 --- /dev/null +++ b/modules/crypto/src/test/scala/gs/shortform/crypto/argon2/Argon2Tests.scala @@ -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 diff --git a/modules/model/src/main/scala/gs/shortform/model/Content.scala b/modules/model/src/main/scala/gs/shortform/model/Content.scala index 0934aa2..cdecb10 100644 --- a/modules/model/src/main/scala/gs/shortform/model/Content.scala +++ b/modules/model/src/main/scala/gs/shortform/model/Content.scala @@ -19,7 +19,7 @@ import gs.shortform.crypto.Hash case class Content( externalId: UUID, createdAt: CreatedAt, - createdBy: CreatedBy, + createdBy: Username, title: Title, hash: Hash ) diff --git a/modules/model/src/main/scala/gs/shortform/model/CreatedBy.scala b/modules/model/src/main/scala/gs/shortform/model/CreatedBy.scala deleted file mode 100644 index 8856d9f..0000000 --- a/modules/model/src/main/scala/gs/shortform/model/CreatedBy.scala +++ /dev/null @@ -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 diff --git a/modules/model/src/main/scala/gs/shortform/model/Role.scala b/modules/model/src/main/scala/gs/shortform/model/Role.scala new file mode 100644 index 0000000..4cd0784 --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/Role.scala @@ -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 diff --git a/modules/model/src/main/scala/gs/shortform/model/User.scala b/modules/model/src/main/scala/gs/shortform/model/User.scala new file mode 100644 index 0000000..bbc24cd --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/User.scala @@ -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 +) diff --git a/modules/model/src/main/scala/gs/shortform/model/UserStatus.scala b/modules/model/src/main/scala/gs/shortform/model/UserStatus.scala new file mode 100644 index 0000000..b512461 --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/UserStatus.scala @@ -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 + diff --git a/modules/model/src/main/scala/gs/shortform/model/Username.scala b/modules/model/src/main/scala/gs/shortform/model/Username.scala new file mode 100644 index 0000000..3c8a32e --- /dev/null +++ b/modules/model/src/main/scala/gs/shortform/model/Username.scala @@ -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 + +