Scala
PlayFramework
playframework2.6

PlayFramework2.6で暗号化された送受信を行ってみた

はじめに

ScalaとPlayFrameworkの勉強を兼ねて、少し実用的なものを作ってみようと思い立ち、今回の実装を行いました。

作成したのは、httpのbodyを使って暗号化された情報を送受信するものです。
用途としては、ゲーム等でプレイヤーに対して送受信情報を隠蔽したい場合を想定しています。

なお、暗号化の手法自身は今回取り扱っていません。現実に使用する場合は、暗号化以外にも改竄防止のHMAC乗っけたりとか、セッショントークンくっつけたりとかする必要があると思います。

また、筆者はScalaもPlayFrameworkも初心者なので、こんなのありえんとか、プププとか、頭の中Z80じゃねえのとかのご意見があれば大変喜びます。以上、よろしくお願いいたします。

実装

前提

今回は、暗号化代わりに単純にJSONを圧縮(Inflate)したデータを使用します。また、通信プロトコルもhttpを使用します。
また、JSONのエンコード・デコードには、circeを使用しました。

今回使用した各種バージョンは以下の通りです。

名前 バージョン
Scala 2.12.4
PlayFramework 2.6.7
circe 0.8.0

送受信データ

今回、送受信するデータは、以下の通りとします。

送信データ
{"method":"hogehoge","param":"parameter"}
受信データ
{"status":0}

build.sbt

JSONパーザとしてcirceを使用するので、build.sbtにライブラリを追加します。

build.sbt
val circeVersion = "0.8.0"
libraryDependencies ++= Seq(
  "io.circe" %% "circe-core",
  "io.circe" %% "circe-parser",
  "io.circe" %% "circe-generic"
).map(_ % circeVersion)

データオブジェクト

送受信データに対応するcase classを作成します。

リクエスト用データオブジェクト。

controllers/packet/RequestHoge.scala
package controllers.packet
case class RequestHoge(method: String, param: String)

レスポンス用データオブジェクト。

controllers/packet/ResponseHoge.scala
package controllers.packet
case class ResponseHoge(status: Int)

デコード

デコードはBodyParserをカスタマイズした独自パーザで行うことにします。このパーザでは、httpリクエストのbodyで来たデータをJSONに直した後、circeを使って上記のデータオブジェクトに変換するとこまで行います。

なお、実装は、公式ドキュメントやソースを参考にして作成しました。

デコードに使用するBodyParserは、リクエスト用データオブジェクトを型パラメータとして持ち、リクエスト毎にController内で作成することにします。そして、BodyParserを生成するためのクラスはDIを使ってControllerに渡すことにしました。

services/BodyParserMaker.scala
package services

import java.io.ByteArrayInputStream
import java.util.zip._
import javax.inject.{Inject, Singleton}

import akka.util.ByteString
import io.circe._
import io.circe.parser._
import play.api.mvc.{BodyParser, PlayBodyParsers, RawBuffer}

import scala.concurrent.ExecutionContext
import scala.io.Source

/**
  * DIで挿入されるtrait
  */
trait BodyParserMaker {
  def createBodyParser[A]()(implicit decoder: Decoder[A]): BodyParser[Either[String, A]]
}

/**
  * 本体
  */
@Singleton
class EncryptedBodyParserMaker @Inject() (ec: ExecutionContext, p: PlayBodyParsers)
  extends BodyParserMaker
{
  override def createBodyParser[A]()(implicit decoder: Decoder[A]): BodyParser[Either[String, A]] =
    p.raw.map[Either[String, A]](convert[A])(ec)

  /**
    * PlayBodyParsers.rawへのmap関数として渡される関数
    */
  private def convert[A](data: RawBuffer)(implicit decoder: Decoder[A]): Either[String, A] = {
    data.asBytes() match {
      case Some(bytes) => parseBytes[A](bytes)
      case None =>        Left("No data")
    }
  }

  /**
    * 入力バイト列を復号し、入力ストリームを作成
    */
  private def parseBytes[A](bytes: ByteString): Either[String, InputStream] = {
    try {
      val byteArray: Array[Byte] = bytes.toArray
      // 本当はここに暗号を復号する処理が入る
      val text = Source.fromInputStream(InflaterInputStream(new ByteArrayInputStream(byteArray))).mkString
      parse(text) match {
        case Right(x) => x match {
          case json => parseJson[A](json)
        }
        case Left(x) => Left(x.getMessage())
      }
    } catch {
      case ex: Exception => Left(ex.getMessage)
    }
  }

  private def parseJson[A](obj: Json)(implicit decoder: Decoder[A]): Either[String, A] = {
    decoder.decodeJson(obj) match {
      case Right(result) => Right(result)
      case Left(failure) => Left(failure.getMessage())
    }
  }
}

BodyParserMakerとEncryptedBodyParserMakerを登録します。

Module.scala
import com.google.inject.AbstractModule
import services.{BodyParserMaker, EncryptedBodyParserMaker}

class Module extends AbstractModule {

  override def configure(): Unit = {
    bind(classOf[BodyParserMaker]).to(classOf[EncryptedBodyParserMaker])
  }
}

後はControllerからカスタムBodyParserを生成して呼び出せばok。

controllers/HogeController.scala
package controllers

import javax.inject._

import controllers.packet.{RequestHoge, ResponseHoge}
import play.api.mvc._
import io.circe.generic.auto._
import io.circe.syntax._
import services.BodyParserMaker

@Singleton
class HogeController @Inject()(cc: ControllerComponents, bpm: BodyParserMaker) extends AbstractController(cc) {
  def hogehoge = Action(bpm.createBodyParser[RequestHoge]()) { request =>
    request.body match {
      case Right(x) => {        // xはRequestHoge
        // なにか処理をして、値を返す
        val response = x.method match {
          case "hogehoge" => ResponseHoge(666)
          case _          => ResponseHoge(0) 
        }
        Ok(response.asJson.noSpaces)
      }
      case _ => InternalServerError
    }
  }
}

カスタムBodyParser作るのに、PlayBodyParsersのraw BodyParserを使っているけど、これで良いのだろうか・・?

エンコード

エンコードはフィルター機能を使って実装することにしました。

なお、上記のHogeControllerにあるように、レスポンス用データオブジェクトを作成したのち、それをJSON→テキスト化を手動(.asJson.noSpaces)で行った後でフィルタリングしています。
本当は、Ok(response)とやりたかったのですが、どうしてもうまくいかなかったため、こうなっています。何かいい方法ありますかね?

フィルターの実装は、以下の通りです。

filters/EncryptionFilter.scala
package filters

import javax.inject.Inject
import java.io.ByteArrayOutputStream
import java.util.zip._

import akka.stream._
import akka.stream.scaladsl.{Flow, Source}
import akka.stream.stage.{GraphStage, GraphStageLogic, InHandler, OutHandler}
import akka.util.ByteString
import play.api.http.{ContentTypes, HttpEntity}
import play.api.libs.streams.Accumulator
import play.api.mvc._

import scala.concurrent.{ExecutionContext, Future}

class EncryptionFilter @Inject()(implicit val mat: Materializer)
  extends EssentialFilter
{
  /**
    * map, mapFoldで参照される暗黙的パラメータ
    */
  implicit val ec: ExecutionContext = mat.executionContext

  /**
    * フィルター本体
    * @param nextFilter
    * @return
    */
  override def apply(nextFilter: EssentialAction) = EssentialAction { requestHeader =>
    val accumulator: Accumulator[ByteString, Result] =
      if (isEncryptedRequest(requestHeader)) {
        // 既存のフィルター処理後、暗号化を行う
        nextFilter(requestHeader).mapFuture[Result](handleResult)
      } else {
        nextFilter(requestHeader)
      }
    accumulator
  }

  /**
    * 暗号化すべきリクエストかどうかの判定
    * @param requestHeader
    * @return
    */
  private def isEncryptedRequest(requestHeader: RequestHeader) =
    // ここではリクエストにContent-Encoding: encryptedがついていた場合のみ、レスポンスも暗号化して返すことにしている
    requestHeader.headers.get("Content-Encoding") match {
      case Some(x: String) if x == "encrypted" => true
      case _ => false
    }

  /**
    * 暗号化処理を行い、Bodyをリプレイスして返す。
    * なお、現在はHttpEntity.Strictしか対応していない。大丈夫?
    * @param result
    * @return
    */
  private def handleResult(result: Result): Future[Result] = {
    result.body match {
      case HttpEntity.Strict(_, _) =>
        cryptStrict(result.body.dataStream).map(entity => result.copy(body = entity))
      case _ =>
        Future.failed(new Exception) // TODO: Exception
    }
  }

  /**
    * 暗号化用のFlowを適用し、新しいHttpEntityを返す
    * @param source
    * @return
    */
  private def cryptStrict(source: Source[ByteString, Any]) = {
    source.via(createCryptFlow()).runFold(ByteString.empty)(_ ++ _)
      .map(data => HttpEntity.Strict(data, Some(ContentTypes.BINARY))) //  "application/octet-stream")))
  }

  /**
    * 暗号化用Flowの作成
    * @return
    */
  private def createCryptFlow(): Flow[ByteString, ByteString, _] = {
    Flow.fromGraph {
      new GraphStage[FlowShape[ByteString, ByteString]] {
        private val in = Inlet[ByteString]("CryptGraph.in")
        private val out = Outlet[ByteString]("CryptGraph.out")
        private val stream = new DeflaterOutputStream(new ByteArrayOutputStream(2048))

        override def shape: FlowShape[ByteString, ByteString] = FlowShape.of(in, out)

        override def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
          new GraphStageLogic(shape) with InHandler with OutHandler {
            override def onPush(): Unit = {
              val data = grab(in)
              if (data.nonEmpty)
                stream.write(data.toArray)
              else
                pull(in)
            }

            override def onPull(): Unit = {
              pull(in)
            }

            override def onUpstreamFinish(): Unit = {
              stream.close()
              val compressed = stream.toByteArray                      // 圧縮したデータ
              // 本当はここに暗号化処理を書く
              push(out, ByteString.fromArray(compressed))              // 結果の出力
              super.onUpstreamFinish()
            }

            setHandlers(in, out, this)
          }
      }
    }
  }
}

Akka周りがよく分からなかったので、いろんなソースみたりしながら試行錯誤した結果。本当にこれでいいんだろうか感ありあり。
なお、ストリームサイズがマジックナンバーだったり、エラーハンドリングが雑だったりするので、このままでは使えないかな。

フィルターに関しては、application.confに記載しました。

application.conf
play.filters.enabled += filters.EncryptionFilter