Encrypt credentials (#6296)

* Encrypt credentials

changelog_begin
changelog_end

* Move key aquisition and write a big fat warning

* Only write encrypted token to DB.
This commit is contained in:
Shayne Fletcher 2020-06-10 17:14:29 -04:00 committed by GitHub
parent ad16f563b4
commit 07f24c0d93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 115 additions and 38 deletions

View File

@ -38,7 +38,6 @@ import com.daml.lf.engine.trigger.Response._
import com.daml.daml_lf_dev.DamlLf
import com.daml.grpc.adapter.{AkkaExecutionSequencerPool, ExecutionSequencerFactory}
import com.daml.platform.services.time.TimeProviderType
import com.daml.ledger.api.refinements.ApiTypes.Party
import scalaz.syntax.traverse._
import scala.concurrent.{Await, ExecutionContext, Future}
@ -58,7 +57,7 @@ case class LedgerConfig(
commandTtl: Duration,
)
final case class UserCredentials(token: String)
final case class UserCredentials(token: EncryptedToken)
final case class RunningTrigger(
triggerInstance: UUID,
@ -194,6 +193,18 @@ object Server {
jdbcConfig: Option[JdbcConfig],
): Behavior[Message] = Behaviors.setup { ctx =>
val triggerDao = jdbcConfig.map(TriggerDao(_)(ctx.system.executionContext))
val key =
sys.env.get("TRIGGER_SERVICE_SECRET_KEY") match {
case Some(key) => key
case None => {
val logMsg =
"WARNING : The environment variable 'TRIGGER_SERVICE_SECRET_KEY' is not defined. It is highly recommended that a non-empty value for this variable be set. If the service startup parameters do not include the '--no-secret-key' option, the service will now terminate."
ctx.log.info(logMsg)
"secret key"
}
}
val server = new Server(dar, triggerDao)
// http doesn't know about akka typed so provide untyped system
@ -215,7 +226,7 @@ object Server {
triggerName: Identifier): Either[String, JsValue] = {
for {
trigger <- Trigger.fromIdentifier(server.compiledPackages, triggerName).right
party = Party(TokenManagement.decodeCredentials(credentials)._1);
party = TokenManagement.decodeCredentials(key, credentials)._1
triggerInstance = UUID.randomUUID
_ = ctx.spawn(
TriggerRunner(
@ -277,10 +288,9 @@ object Server {
entity(as[StartParams]) {
params =>
TokenManagement
.findCredentials(request)
.findCredentials(key, request)
.fold(
unauthorized =>
complete(errorResponse(StatusCodes.Unauthorized, unauthorized.message)),
message => complete(errorResponse(StatusCodes.Unauthorized, message)),
credentials =>
startTrigger(credentials, params.triggerName) match {
case Left(err) =>
@ -335,10 +345,9 @@ object Server {
extractRequest {
request =>
TokenManagement
.findCredentials(request)
.findCredentials(key, request)
.fold(
unauthorized =>
complete(errorResponse(StatusCodes.Unauthorized, unauthorized.message)),
message => complete(errorResponse(StatusCodes.Unauthorized, message)),
credentials =>
listTriggers(credentials) match {
case Left(err) =>
@ -361,10 +370,9 @@ object Server {
extractRequest {
request =>
TokenManagement
.findCredentials(request)
.findCredentials(key, request)
.fold(
unauthorized =>
complete(errorResponse(StatusCodes.Unauthorized, unauthorized.message)),
message => complete(errorResponse(StatusCodes.Unauthorized, message)),
credentials =>
stopTrigger(uuid, credentials) match {
case Left(err) =>

View File

@ -3,42 +3,94 @@
package com.daml.lf.engine.trigger
import com.daml.lf.data.Ref.Party
import akka.http.scaladsl.model.headers.{Authorization, BasicHttpCredentials}
import akka.http.scaladsl.model.HttpRequest
import scalaz.syntax.std.option._
import scalaz.{\/}
import java.nio.charset.StandardCharsets
import java.util
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
case class Unauthorized(message: String) extends Error(message)
case class EncryptedToken(token: String)
case class UnencryptedToken(token: String)
object TokenManagement {
// Utility to get the username and password out of a basic auth
// token. By construction we ensure that there will always be two
// components (see 'findCredentials'). We use the first component to
// identify parties.
def decodeCredentials(credentials: UserCredentials): (String, String) = {
val token = credentials.token
val bytes = java.util.Base64.getDecoder.decode(token.getBytes())
val components = new String(bytes, StandardCharsets.UTF_8).split(":")
(components(0), components(1))
// TL;DR You can store the SALT in plaintext without any form of
// obfuscation or encryption, but don't just give it out to anyone
// that wants it.
private val SALT = "jMhKlOuJnM34G6NHkqo9V010GhLAqOpF0BePojHgh1HgNg8^72k"
// Given 'key', use 'SALT' to produce an AES (Advanced Encryption
// Standard) secret key specification. This utility is called from
// the 'encrypt' and 'decrypt' functions.
private def keyToSpec(key: String): SecretKeySpec = {
var keyBytes: Array[Byte] = (SALT + key).getBytes("UTF-8")
val sha: MessageDigest = MessageDigest.getInstance("SHA-1")
keyBytes = sha.digest(keyBytes)
keyBytes = util.Arrays.copyOf(keyBytes, 16)
new SecretKeySpec(keyBytes, "AES")
}
/*
User : alice
Password : &alC2l3SDS*V
curl -X GET localhost:8080/hello -H "Authorization: Basic YWxpY2U6JmFsQzJsM1NEUypW"
*/
def findCredentials(req: HttpRequest): Unauthorized \/ UserCredentials = {
// AES encrypt 'value' given 'key'. Proceed by first encrypting the
// value and then base64 encode the result (the resulting string
// consists of characters strictly in the set [a-z], [A-Z], [0-9] +
// and /.
private def encrypt(key: String, value: UnencryptedToken): EncryptedToken = {
val cipher: Cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, keyToSpec(key))
val bytes = java.util.Base64.getEncoder
.encode(cipher.doFinal(value.token.getBytes("UTF-8")))
EncryptedToken(new String(bytes, StandardCharsets.UTF_8))
}
// AES decrypt 'value' given 'key'. Proceed by first decoding from
// base64 then decrypt the result.
private def decrypt(key: String, value: EncryptedToken): UnencryptedToken = {
val cipher: Cipher = Cipher.getInstance("AES/ECB/PKCS5PADDING")
cipher.init(Cipher.DECRYPT_MODE, keyToSpec(key))
UnencryptedToken(
new String(
cipher.doFinal(java.util.Base64.getDecoder.decode(value.token)),
StandardCharsets.UTF_8))
}
// Utility to get the username and password out of a basic auth
// token. By construction we ensure that there will always be two
// components and that the first component is a syntactically valid
// party identifier (see 'findCredentials').
def decodeCredentials(
key: String,
credentials: UserCredentials): (com.daml.ledger.api.refinements.ApiTypes.Party, String) = {
val components = decrypt(key, credentials.token).token.split(":")
(com.daml.ledger.api.refinements.ApiTypes.Party(components(0)), components(1))
}
// Parse the user credentials out of a request's headers.
def findCredentials(key: String, req: HttpRequest): Either[String, UserCredentials] = {
req.headers
.collectFirst {
case Authorization(c @ BasicHttpCredentials(username, password)) => {
UserCredentials(c.token())
val token = c.token()
val bytes = java.util.Base64.getDecoder.decode(token.getBytes())
UserCredentials(encrypt(key, UnencryptedToken(new String(bytes, StandardCharsets.UTF_8))))
}
}
.toRightDisjunction(Unauthorized("missing Authorization header with Basic Token"))
} match {
// Check the given username conforms to the syntactic
// requirements of a party identifier.
case Some(credentials) =>
decodeCredentials(key, credentials) match {
case (party, _) =>
val ident = party.toString()
if (Party.fromString(ident).isRight) {
Right(credentials)
} else {
Left("invalid party identifier '" + ident + "'")
}
}
case None => Left("missing Authorization header with Basic Token")
}
}
}

View File

@ -81,7 +81,9 @@ object TriggerDao {
}
def addRunningTrigger(t: RunningTrigger): ConnectionIO[Unit] = {
val partyToken = t.credentials.token
val partyToken: String = t.credentials match {
case UserCredentials(EncryptedToken(token)) => token
}
val fullTriggerName = t.triggerName.toString
val insertTrigger: Fragment = Fragment.const(
s"insert into running_triggers values ('${t.triggerInstance}', '$partyToken', '$fullTriggerName')"
@ -100,7 +102,9 @@ object TriggerDao {
}
def getTriggersForParty(credentials: UserCredentials): ConnectionIO[Vector[UUID]] = {
val partyToken = credentials.token
val partyToken: String = credentials match {
case UserCredentials(EncryptedToken(token)) => token
}
val select = Fragment.const("select trigger_instance from running_triggers")
val where = Fragment.const(s" where party_token = '${partyToken}'")
val order = Fragment.const(" order by running_triggers")

View File

@ -460,7 +460,20 @@ class ServiceTest extends AsyncFlatSpec with Eventually with Matchers with Postg
} yield succeed
}
it should "give a 'not found' response for a stop request with unparseable UUID" in withTriggerServiceAndDb(
it should "give an 'unauthorized' response for a start request with an invalid party identifier" in withTriggerServiceAndDb(
Some(dar)) { (uri: Uri, client: LedgerClient, ledgerProxy: Proxy) =>
for {
resp <- startTrigger(uri, s"$testPkgId:TestTrigger:trigger", User("Alice-!", "&alC2l3SDS*V"))
_ <- resp.status should equal(StatusCodes.Unauthorized)
body <- responseBodyToString(resp)
JsObject(fields) = body.parseJson
_ <- fields.get("status") should equal(Some(JsNumber(StatusCodes.Unauthorized.intValue)))
_ <- fields.get("errors") should equal(
Some(JsArray(JsString("invalid party identifier 'Alice-!'"))))
} yield succeed
}
it should "give a 'not found' response for a stop request with an unparseable UUID" in withTriggerServiceAndDb(
None) { (uri: Uri, client: LedgerClient, ledgerProxy: Proxy) =>
val uuid: String = "No More Mr Nice Guy"
val req = HttpRequest(