Add a simple module for totps

This is a wrapper around the nice and minimal java-otp library [0].

[0] https://github.com/jchambers/java-otp
This commit is contained in:
eikek 2021-08-29 21:27:56 +02:00
parent 60c5120785
commit 2b46cc7970
10 changed files with 434 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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