LoginSignup
2

More than 5 years have passed since last update.

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

Posted at

はじめに

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2