diff --git a/build.sbt b/build.sbt index 8f477e79..013e7714 100644 --- a/build.sbt +++ b/build.sbt @@ -335,6 +335,20 @@ val query = Dependencies.scalaJsStubs ) +val totp = project + .in(file("modules/totp")) + .disablePlugins(RevolverPlugin) + .settings(sharedSettings) + .settings(testSettingsMUnit) + .settings( + name := "docspell-totp", + libraryDependencies ++= + Dependencies.javaOtp ++ + Dependencies.scodecBits ++ + Dependencies.fs2 ++ + Dependencies.circe + ) + val store = project .in(file("modules/store")) .disablePlugins(RevolverPlugin) @@ -676,7 +690,8 @@ val root = project restapi, restserver, query.jvm, - query.js + query.js, + totp ) // --- Helpers diff --git a/modules/totp/src/main/scala/docspell/totp/Key.scala b/modules/totp/src/main/scala/docspell/totp/Key.scala new file mode 100644 index 00000000..5c4b2e72 --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/Key.scala @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import java.security.{Key => JKey} +import javax.crypto.KeyGenerator +import javax.crypto.spec.SecretKeySpec + +import cats.effect._ + +import io.circe.generic.semiauto +import io.circe.{Decoder, Encoder} +import scodec.bits.ByteVector + +final case class Key(data: ByteVector, mac: Mac) { + def toJavaKey: JKey = + new SecretKeySpec(data.toArray, mac.identifier) + + /** Renders the mac and data into one string which can be consumed by `fromString` */ + def asString: String = + s"${mac.identifier}::${data.toBase32}" +} + +object Key { + + def fromSecretKey(sk: JKey): Either[String, Key] = + for { + mac <- Mac.fromString(sk.getAlgorithm) + key = Key(ByteVector.view(sk.getEncoded), mac) + } yield key + + def generate[F[_]: Sync](mac: Mac): F[Key] = Sync[F].delay { + val jkey = generateJavaKey(mac) + Key(ByteVector.view(jkey.getEncoded), mac) + } + + def fromString(str: String): Either[String, Key] = { + val (macStr, dataStr) = str.span(_ != ':') + if (dataStr.isEmpty) Left(s"No separator found in key string: $str") + else + for { + mac <- Mac.fromString(macStr) + data <- ByteVector.fromBase32Descriptive(dataStr.dropWhile(_ == ':')) + } yield Key(data, mac) + } + + def unsafeFromString(str: String): Key = + fromString(str).fold(sys.error, identity) + + private[totp] def generateJavaKey(mac: Mac): JKey = { + val keyGen = KeyGenerator.getInstance(mac.identifier) + keyGen.init(mac.keyLengthBits) + keyGen.generateKey() + } + + implicit val jsonEncoder: Encoder[Key] = + Codec.jsonEncoder + + implicit val jsonDecoder: Decoder[Key] = + Codec.jsonDecoder + + private object Codec { + implicit val byteVectorEncoder: Encoder[ByteVector] = + Encoder.encodeString.contramap(_.toBase32) + + implicit val byteVectorDecoder: Decoder[ByteVector] = + Decoder.decodeString.emap(s => ByteVector.fromBase32Descriptive(s)) + + val jsonEncoder: Encoder[Key] = + semiauto.deriveEncoder[Key] + + val jsonDecoder: Decoder[Key] = + semiauto.deriveDecoder[Key] + } +} diff --git a/modules/totp/src/main/scala/docspell/totp/Mac.scala b/modules/totp/src/main/scala/docspell/totp/Mac.scala new file mode 100644 index 00000000..49a185b3 --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/Mac.scala @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import cats.data.NonEmptyList + +import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator +import io.circe.{Decoder, Encoder} + +sealed trait Mac { + def identifier: String + def keyLengthBits: Int +} +object Mac { + case object Sha1 extends Mac { + val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA1 + val keyLengthBits = 160 + } + case object Sha256 extends Mac { + val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA256 + val keyLengthBits = 256 + } + case object Sha512 extends Mac { + val identifier = TimeBasedOneTimePasswordGenerator.TOTP_ALGORITHM_HMAC_SHA512 + val keyLengthBits = 512 + } + + val all: NonEmptyList[Mac] = + NonEmptyList.of(Sha1, Sha256, Sha512) + + def fromString(str: String): Either[String, Mac] = + str.toLowerCase match { + case "hmacsha1" => Right(Sha1) + case "hmacsha256" => Right(Sha256) + case "hmacsha512" => Right(Sha512) + case _ => Left(s"Unknown mac name: $str") + } + + def unsafeFromString(str: String): Mac = + fromString(str).fold(sys.error, identity) + + implicit val jsonEncoder: Encoder[Mac] = + Encoder.encodeString.contramap(_.identifier) + + implicit val jsonDecoder: Decoder[Mac] = + Decoder.decodeString.emap(fromString) +} diff --git a/modules/totp/src/main/scala/docspell/totp/OnetimePassword.scala b/modules/totp/src/main/scala/docspell/totp/OnetimePassword.scala new file mode 100644 index 00000000..9ce2355f --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/OnetimePassword.scala @@ -0,0 +1,28 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import io.circe.{Decoder, Encoder} + +final class OnetimePassword(val pass: String) extends AnyVal { + override def toString: String = "***" +} + +object OnetimePassword { + + def apply(pass: String): OnetimePassword = + new OnetimePassword(pass) + + def unapply(op: OnetimePassword): Option[String] = + Some(op.pass) + + implicit val jsonEncoder: Encoder[OnetimePassword] = + Encoder.encodeString.contramap(_.pass) + + implicit val jsonDecoder: Decoder[OnetimePassword] = + Decoder.decodeString.map(OnetimePassword.apply) +} diff --git a/modules/totp/src/main/scala/docspell/totp/PassLength.scala b/modules/totp/src/main/scala/docspell/totp/PassLength.scala new file mode 100644 index 00000000..b47ca0df --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/PassLength.scala @@ -0,0 +1,43 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import cats.data.NonEmptyList + +import io.circe.{Decoder, Encoder} + +sealed trait PassLength { + def toInt: Int +} + +object PassLength { + case object Chars6 extends PassLength { + val toInt = 6 + } + case object Chars8 extends PassLength { + val toInt = 8 + } + + val all: NonEmptyList[PassLength] = + NonEmptyList.of(Chars6, Chars8) + + def fromInt(n: Int): Either[String, PassLength] = + n match { + case 6 => Right(Chars6) + case 8 => Right(Chars8) + case _ => Left(s"Invalid length: $n! Must be either 6 or 8") + } + + def unsafeFromInt(n: Int): PassLength = + fromInt(n).fold(sys.error, identity) + + implicit val jsonEncoder: Encoder[PassLength] = + Encoder.encodeInt.contramap(_.toInt) + + implicit val jsonDecoder: Decoder[PassLength] = + Decoder.decodeInt.emap(fromInt) +} diff --git a/modules/totp/src/main/scala/docspell/totp/Settings.scala b/modules/totp/src/main/scala/docspell/totp/Settings.scala new file mode 100644 index 00000000..343a099b --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/Settings.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import java.util.concurrent.TimeUnit + +import scala.concurrent.duration._ + +import io.circe.generic.semiauto +import io.circe.{Decoder, Encoder} + +case class Settings(mac: Mac, passLength: PassLength, duration: FiniteDuration) + +object Settings { + val default = + Settings(Mac.Sha1, PassLength.Chars6, 30.seconds) + + implicit val jsonEncoder: Encoder[Settings] = + Codec.jsonEncoder + + implicit val jsonDecoder: Decoder[Settings] = + Codec.jsonDecoder + + private object Codec { + + implicit val durationEncoder: Encoder[FiniteDuration] = + Encoder.encodeLong.contramap(_.toSeconds) + + implicit val durationDecoder: Decoder[FiniteDuration] = + Decoder.decodeLong.map(secs => FiniteDuration(secs, TimeUnit.SECONDS)) + + val jsonEncoder: Encoder[Settings] = + semiauto.deriveEncoder[Settings] + + val jsonDecoder: Decoder[Settings] = + semiauto.deriveDecoder[Settings] + } +} diff --git a/modules/totp/src/main/scala/docspell/totp/Totp.scala b/modules/totp/src/main/scala/docspell/totp/Totp.scala new file mode 100644 index 00000000..fa5b5d5b --- /dev/null +++ b/modules/totp/src/main/scala/docspell/totp/Totp.scala @@ -0,0 +1,66 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import java.time.Instant + +import fs2.Stream + +import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator + +/** Generator for time based one time passwords. */ +trait Totp { + + /** The settings used to generate passwords. */ + def settings: Settings + + /** Generate the password for the given key and time. */ + def generate(key: Key, time: Instant): OnetimePassword + + /** Generate a stream of passwords using the given key and starting at the given time. + */ + def generateStream[F[_]](key: Key, time: Instant): Stream[F, OnetimePassword] + + /** Checks whether the given password matches using the current time. */ + def checkPassword(key: Key, otp: OnetimePassword, time: Instant): Boolean +} + +object Totp { + + val default: Totp = + Totp(Settings.default) + + def apply(setts: Settings): Totp = + new Totp { + + val settings = setts + private val generator = makeGenerator(setts) + + def generate(key: Key, time: Instant): OnetimePassword = + OnetimePassword(generator.generateOneTimePasswordString(key.toJavaKey, time)) + + def generateStream[F[_]](key: Key, time: Instant): Stream[F, OnetimePassword] = + Stream.emit(generate(key, time)) ++ generateStream( + key, + time.plus(generator.getTimeStep) + ) + + def checkPassword(key: Key, given: OnetimePassword, time: Instant): Boolean = { + val pass = generate(key, time) + pass == given + } + } + + private def makeGenerator(settings: Settings): TimeBasedOneTimePasswordGenerator = { + val duration = java.time.Duration.ofNanos(settings.duration.toNanos) + new TimeBasedOneTimePasswordGenerator( + duration, + settings.passLength.toInt, + settings.mac.identifier + ) + } +} diff --git a/modules/totp/src/test/scala/docspell/totp/KeyTest.scala b/modules/totp/src/test/scala/docspell/totp/KeyTest.scala new file mode 100644 index 00000000..01121b22 --- /dev/null +++ b/modules/totp/src/test/scala/docspell/totp/KeyTest.scala @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import cats.effect._ +import cats.effect.unsafe.implicits._ + +import docspell.totp.{Key, Mac} + +import io.circe.syntax._ +import munit._ + +class KeyTest extends FunSuite { + + test("generate and read in key") { + val jkey = Key.generateJavaKey(Mac.Sha1) + val key = Key.fromSecretKey(jkey).fold(sys.error, identity) + assertEquals(jkey, key.toJavaKey) + } + + test("generate key") { + for (mac <- Mac.all.toList) { + val key = Key.generate[IO](mac).unsafeRunSync() + assertEquals(key.data.length.toInt * 8, key.mac.keyLengthBits) + } + } + + test("encode/decode json") { + val key = Key.generate[IO](Mac.Sha1).unsafeRunSync() + val keyJson = key.asJson + val newKey = keyJson.as[Key].fold(throw _, identity) + assertEquals(key, newKey) + } +} diff --git a/modules/totp/src/test/scala/docspell/totp/TotpTest.scala b/modules/totp/src/test/scala/docspell/totp/TotpTest.scala new file mode 100644 index 00000000..207553cb --- /dev/null +++ b/modules/totp/src/test/scala/docspell/totp/TotpTest.scala @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Docspell Contributors + * + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package docspell.totp + +import java.time.Instant + +import scala.concurrent.duration._ + +import cats.Id +import cats.effect._ +import cats.effect.unsafe.implicits._ + +import munit._ +import scodec.bits.ByteVector + +class TotpTest extends FunSuite { + + val totp = Totp.default + val key = Key(ByteVector.fromValidBase64("GGFWIWYnHB8F5Dp87iS2HP86k4A="), Mac.Sha1) + val time = Instant.parse("2021-08-29T18:42:00Z") + + test("generate password") { + val otp = totp.generate(key, time) + assertEquals("410352", otp.pass) + } + + test("generate stream") { + val otp3 = totp.generateStream[Id](key, time).take(3).compile.toList + assertEquals(otp3.map(_.pass), List("410352", "557347", "512023")) + } + + for { + mac <- Mac.all.toList + plen <- PassLength.all.toList + } test(s"generate ${mac.identifier} with ${plen.toInt} characters") { + val key = Key.generate[IO](mac).unsafeRunSync() + val totp = Totp(Settings(mac, plen, 30.seconds)) + val otp = totp.generate(key, time) + assertEquals(otp.pass.length, plen.toInt) + } + + test("check password at same time") { + assert(totp.checkPassword(key, OnetimePassword("410352"), time)) + } + + test("check password 15s later") { + assert(totp.checkPassword(key, OnetimePassword("410352"),time.plusSeconds(15))) + } + + test("check password 29s later") { + assert(totp.checkPassword(key, OnetimePassword("410352"), time.plusSeconds(29))) + } + + test("check password 31s later (too late)") { + assert(!totp.checkPassword(key, OnetimePassword("410352"),time.plusSeconds(31))) + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 200849a7..f1a48c42 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -21,6 +21,7 @@ object Dependencies { val H2Version = "1.4.200" val Http4sVersion = "0.23.1" val Icu4jVersion = "69.1" + val javaOtpVersion = "0.3.0" val JsoupVersion = "1.14.2" val KindProjectorVersion = "0.10.3" val KittensVersion = "2.3.2" @@ -36,6 +37,7 @@ object Dependencies { val PostgresVersion = "42.2.23" val PureConfigVersion = "0.16.0" val ScalaJavaTimeVersion = "2.3.0" + val ScodecBitsVersion = "1.1.27" val Slf4jVersion = "1.7.32" val StanfordNlpVersion = "4.2.2" val TikaVersion = "2.1.0" @@ -46,6 +48,14 @@ object Dependencies { val JQueryVersion = "3.5.1" val ViewerJSVersion = "0.5.9" + val scodecBits = Seq( + "org.scodec" %% "scodec-bits" % ScodecBitsVersion + ) + + val javaOtp = Seq( + "com.eatthepath" % "java-otp" % "0.3.0" + ) + val testContainer = Seq( "com.dimafeng" %% "testcontainers-scala-munit" % TestContainerVersion, "com.dimafeng" %% "testcontainers-scala-mariadb" % TestContainerVersion,