Skip to content

Commit 45d8a23

Browse files
authored
Merge pull request #1288 from typelevel/topic/use-js-native-scram-impl-for-jvm
Remove dependency on scram client in favor of native implementation
2 parents f8d25b3 + 0fc3d1c commit 45d8a23

6 files changed

Lines changed: 107 additions & 149 deletions

File tree

build.sbt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ ThisBuild / libraryDependencySchemes ++= Seq(
5353

5454
import com.typesafe.tools.mima.core._
5555
ThisBuild / mimaBinaryIssueFilters ++= List(
56-
ProblemFilters.exclude[DirectMissingMethodProblem]("skunk.net.BitVectorSocket.fromSocket")
56+
ProblemFilters.exclude[DirectMissingMethodProblem]("skunk.net.BitVectorSocket.fromSocket"),
57+
ProblemFilters.exclude[MissingTypesProblem]("skunk.net.protocol.Startup$"),
58+
ProblemFilters.exclude[DirectMissingMethodProblem]("skunk.net.protocol.Startup.authenticationSASL"),
59+
ProblemFilters.exclude[MissingClassProblem]("skunk.net.protocol.StartupCompanionPlatform"),
5760
)
5861

5962
ThisBuild / tlFatalWarnings := false
@@ -128,14 +131,12 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform)
128131
"org.typelevel" %%% "otel4s-semconv-metrics" % otel4sVersion,
129132
"org.tpolecat" %%% "sourcepos" % "1.2.0",
130133
"org.typelevel" %%% "twiddles-core" % "1.0.0",
134+
"com.armanbilge" %%% "saslprep" % "0.1.2",
131135
) ++ Seq(
132136
"com.beachape" %%% "enumeratum" % "1.9.0",
133137
).filterNot(_ => tlIsScala3.value)
134-
).jvmSettings(
135-
libraryDependencies += "com.ongres.scram" % "client" % "2.1",
136138
).platformsSettings(JSPlatform, NativePlatform)(
137139
libraryDependencies ++= Seq(
138-
"com.armanbilge" %%% "saslprep" % "0.1.2",
139140
"io.github.cquiroz" %%% "scala-java-time" % "2.6.0",
140141
"io.github.cquiroz" %%% "locales-minimal-en_us-db" % "1.5.4"
141142
),

modules/core/js-native/src/main/scala/protocol/StartupPlatform.scala

Lines changed: 0 additions & 64 deletions
This file was deleted.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright (c) 2018-2024 by Rob Norris and Contributors
2+
// This software is licensed under the MIT License (MIT).
3+
// For more information see LICENSE or https://opensource.org/licenses/MIT
4+
5+
package skunk.net.message
6+
7+
import scodec.bits.ByteVector
8+
9+
import java.security.SecureRandom
10+
import javax.crypto.{Mac, SecretKeyFactory}
11+
import javax.crypto.spec.{PBEKeySpec, SecretKeySpec}
12+
13+
private[message] trait ScramPlatform { this: Scram.type =>
14+
15+
def clientFirstBareWithRandomNonce: ByteVector = {
16+
val random = new SecureRandom()
17+
val nonceBytes = new Array[Byte](32)
18+
random.nextBytes(nonceBytes)
19+
val nonce = ByteVector.view(nonceBytes).toBase64
20+
clientFirstBareWithNonce(nonce)
21+
}
22+
23+
private[message] def HMAC(key: ByteVector, str: ByteVector): ByteVector = {
24+
val mac = Mac.getInstance("HmacSHA256")
25+
val keySpec = new SecretKeySpec(key.toArray, "HmacSHA256")
26+
mac.init(keySpec)
27+
ByteVector.view(mac.doFinal(str.toArray))
28+
}
29+
30+
private[message] def H(input: ByteVector): ByteVector =
31+
input.sha256
32+
33+
private[message] def Hi(str: String, salt: ByteVector, iterations: Int): ByteVector = {
34+
val spec = new PBEKeySpec(str.toCharArray, salt.toArray, iterations, 256)
35+
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
36+
val key = factory.generateSecret(spec)
37+
ByteVector.view(key.getEncoded).take(32)
38+
}
39+
}

modules/core/jvm/src/main/scala/net/protocol/StartupPlatform.scala

Lines changed: 0 additions & 64 deletions
This file was deleted.

modules/core/js-native/src/main/scala/message/Scram.scala renamed to modules/core/shared/src/main/scala/net/message/Scram.scala

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import scodec.codecs.utf8
1010

1111
/**
1212
* Partial implementation of [RFC5802](https://tools.ietf.org/html/rfc5802), as needed by PostgreSQL.
13-
*
13+
*
1414
* That is, only features used by PostgreSQL are implemented -- e.g., channel binding is not supported and
1515
* optional message fields omitted by PostgreSQL are not supported.
1616
*/
@@ -34,7 +34,7 @@ private[skunk] object Scram extends ScramPlatform {
3434
utf8.decodeValue(bytes.bits).toOption.flatMap {
3535
case Pattern(r, s, i) =>
3636
Some(ServerFirst(r, ByteVector.fromValidBase64(s), i.toInt))
37-
case _ =>
37+
case _ =>
3838
None
3939
}
4040
}
@@ -56,7 +56,7 @@ private[skunk] object Scram extends ScramPlatform {
5656
utf8.decodeValue(bytes.bits).toOption.flatMap {
5757
case Pattern(v) =>
5858
Some(ServerFinal(Verifier(ByteVector.fromValidBase64(v))))
59-
case _ =>
59+
case _ =>
6060
None
6161
}
6262
}
@@ -78,21 +78,21 @@ private[skunk] object Scram extends ScramPlatform {
7878
SASLInitialResponse(SaslMechanism, channelBinding ++ clientFirstBare)
7979

8080
def saslChallenge(
81-
password: String,
82-
channelBinding: ByteVector,
83-
serverFirst: ServerFirst,
84-
clientFirstBare: ByteVector,
81+
password: String,
82+
channelBinding: ByteVector,
83+
serverFirst: ServerFirst,
84+
clientFirstBare: ByteVector,
8585
serverFirstBytes: ByteVector
8686
): (SASLResponse, Verifier) = {
8787
val clientFinalMessageWithoutProof = ClientFinalWithoutProof(channelBinding.toBase64, serverFirst.nonce)
88-
val (clientProof, expectedVerifier) =
88+
val (clientProof, expectedVerifier) =
8989
makeClientProofAndServerSignature(
90-
password,
91-
serverFirst.salt,
92-
serverFirst.iterations,
93-
clientFirstBare,
94-
serverFirstBytes,
90+
password,
91+
serverFirst.salt,
92+
serverFirst.iterations,
93+
clientFirstBare,
94+
serverFirstBytes,
9595
clientFinalMessageWithoutProof.encode)
9696
(SASLResponse(clientFinalMessageWithoutProof.encodeWithProof(clientProof)), expectedVerifier)
9797
}
98-
}
98+
}

modules/core/shared/src/main/scala/net/protocol/Startup.scala

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
package skunk.net.protocol
66

7-
import cats.{ApplicativeError, MonadError}
7+
import cats.{ApplicativeError, MonadError, MonadThrow}
88
import cats.syntax.all._
99
import org.typelevel.otel4s.Attribute
1010
import org.typelevel.otel4s.trace.Span
@@ -18,7 +18,8 @@ import skunk.exception.{
1818
SCRAMProtocolException,
1919
StartupException,
2020
SkunkException,
21-
UnsupportedAuthenticationSchemeException
21+
UnsupportedAuthenticationSchemeException,
22+
UnsupportedSASLMechanismsException
2223
}
2324
import org.typelevel.otel4s.metrics.Histogram
2425
import cats.effect.MonadCancel
@@ -27,7 +28,7 @@ trait Startup[F[_]] {
2728
def apply(user: String, database: String, password: Option[String], parameters: Map[String, String]): F[Unit]
2829
}
2930

30-
object Startup extends StartupCompanionPlatform {
31+
object Startup {
3132

3233
def apply[F[_]: Exchange: MessageSocket: Tracer](opDuration: Histogram[F, Double])(
3334
implicit ev: MonadCancel[F, Throwable]
@@ -93,6 +94,51 @@ object Startup extends StartupCompanionPlatform {
9394
}
9495
}
9596

97+
private def authenticationSASL[F[_]: MonadThrow: MessageSocket: Tracer](
98+
sm: StartupMessage,
99+
password: Option[String],
100+
mechanisms: List[String]
101+
): F[Unit] =
102+
Tracer[F].span("authenticationSASL").surround {
103+
if (mechanisms.contains(Scram.SaslMechanism)) {
104+
for {
105+
pw <- requirePassword[F](sm, password)
106+
channelBinding = Scram.NoChannelBinding
107+
clientFirstBare = Scram.clientFirstBareWithRandomNonce
108+
_ <- send(Scram.saslInitialResponse(channelBinding, clientFirstBare))
109+
serverFirstBytes <- flatExpectStartup(sm) {
110+
case AuthenticationSASLContinue(serverFirstBytes) => serverFirstBytes.pure[F]
111+
}
112+
serverFirst <- Scram.ServerFirst.decode(serverFirstBytes) match {
113+
case Some(serverFirst) => serverFirst.pure[F]
114+
case None =>
115+
new SCRAMProtocolException(
116+
s"Failed to parse server-first-message in SASLInitialResponse: ${serverFirstBytes.toHex}."
117+
).raiseError[F, Scram.ServerFirst]
118+
}
119+
(response, expectedVerifier) = Scram.saslChallenge(pw, channelBinding, serverFirst, clientFirstBare, serverFirstBytes)
120+
_ <- send(response)
121+
serverFinalBytes <- flatExpectStartup(sm) {
122+
case AuthenticationSASLFinal(serverFinalBytes) => serverFinalBytes.pure[F]
123+
}
124+
_ <- Scram.ServerFinal.decode(serverFinalBytes) match {
125+
case Some(serverFinal) =>
126+
if (serverFinal.verifier == expectedVerifier) ().pure[F]
127+
else new SCRAMProtocolException(
128+
s"Expected verifier ${expectedVerifier.value.toHex} but received ${serverFinal.verifier.value.toHex}."
129+
).raiseError[F, Unit]
130+
case None =>
131+
new SCRAMProtocolException(
132+
s"Failed to parse server-final-message in AuthenticationSASLFinal: ${serverFinalBytes.toHex}."
133+
).raiseError[F, Unit]
134+
}
135+
_ <- flatExpectStartup(sm) { case AuthenticationOk => ().pure[F] }
136+
} yield ()
137+
} else {
138+
new UnsupportedSASLMechanismsException(mechanisms).raiseError[F, Unit]
139+
}
140+
}
141+
96142
private[protocol] def requirePassword[F[_]](sm: StartupMessage, password: Option[String])(
97143
implicit ev: ApplicativeError[F, Throwable]
98144
): F[String] =

0 commit comments

Comments
 (0)