android in-app Billingのサーバ側チェック

2011/08/27:当記事末尾に補足追加。
androidアプリでアプリ内課金をするのにin-app Billingを使おうとしたが、Google先生ご提供のDungeonsのサンプルではアプリ内で署名チェックをしている。

この方法だとクライアント側を改ざんすれば正規に購入しなくても購入したように振る舞えるし、アプリ内に公開鍵を埋め込むのも嫌だと言うことで、サーバ側で署名チェックしつつ、署名チェックの時のみデータをダウンロードするサンプルを書いてみた。productId以外にsignedDataとsignatureを送るようにしている。

実際には払い戻しをした場合や課金はしたけれどダウンロードに失敗した場合などに対応する必要があるが、大まかな構造はこれでよいはず。

中にTODOが書いてあるのでそれらも対応する必要あり。開発中に毎回払い戻しをするのは嫌なので暫定的にJSONと署名に"DEV_DUMMY"と入れた場合には署名チェックをスルーするロジックが入っている。当然、本番では抜かなければならない。
あと、データファイルについては$basedirの「non_public_dir」で若干主張しているように、Webからアクセスできないところに置かないと無意味。

Dungeons側ではSecurity#verifyPurchaseの「boolean verified = false;」直下のif文を除いてverifiedをtrueにしておけばOK。nonceチェックなどはそのままクライアントでやらせる形でよし。

まあ、いろいろ適当に書いているコードなのでいろんなところをきちんとして使う必要あり。ログとかもね。

※$pkfileで示されるファイルにはAndroidMarket開発者アカウントのところに表示されている公開鍵をペタンと貼り付けておけばOK。viで貼り付けた場合は末尾に改行が入ってる可能性大なので「:set binary」+「:set noeol」とかやってから保存するように。
※ログファイルの出力で怒られた場合は予め$logfileをtouchで作って権限設定してみるよろし。

<?php
$basedir = '/home/inappbilling/non_public_dir/';
$pkfile = $basedir . 'public_key';
$logfile = '/var/log/inappbilling.log';

# TODO:We must check $id and productId in $signedData are same value.
# TODO:We must check purchaseTime in $signedData is not expired.
# TODO:We must check purchaseState in $signedData is '0 (purchased)'.
# TODO:Remove DEV_DUMMY for product.
# TODO:Log file.

function handleRequest() {
  global $basedir, $pkfile, $logfile;

  $tstamp = tstamp();
  $id = $_POST['id'];
  $signedData = $_POST['signedData'];
  $signature = $_POST['signature'];

  elog("[$tstamp][$id][$signedData][$signature]");
  if (preg_match('/^【IDの形式チェック】$/', $id)) {
    if (!verify($signedData, $signature)) {
      header('HTTP/1.0 403 Forbidden');
      echo '403 Forbidden';
      elog("[403]\n");
      return;
    }
    $file = $basedir . $id . '.dat';
    if (file_exists($file)) {
      header('Content-Type: application/octet-stream');
      @readfile($file);
      elog("[200]\n");
    } else {
      header('HTTP/1.0 404 Not Found');
      echo '404 Not Found';
      elog("[404]\n");
    }
  } else {
    header('HTTP/1.0 400 Bad Request');
    echo '400 Bad Request';
    elog("[400]\n");
  }
}

function verify($signedData, $signature) {
  global $basedir, $pkfile, $logfile;

  if (empty($signedData) || empty($signature)) {
    return false;
  }
  if (!strcmp($signedData, 'DEV_DUMMY') && !strcmp($signature, 'DEV_DUMMY')) {
    // TODO:This code is for development.
    return true;
  }
  $pk = @file_get_contents($pkfile);
  $pkbin = openssl_get_publickey(pk2pem($pk));
  if (empty($pkbin)) {
    return false;
  }
  $result = openssl_verify($signedData, base64_decode($signature), $pkbin);
  openssl_free_key($pkbin);
  return ($result == 1);
}

function pk2pem($pk_data) {
  global $basedir, $pkfile, $logfile;

  $pem = chunk_split($pk_data, 64, "\n");
  $pem = "-----BEGIN PUBLIC KEY-----\n" . $pem . "-----END PUBLIC KEY-----\n";
  return $pem;
}

function elog($msg) {
  global $basedir, $pkfile, $logfile;

  error_log($msg, 3, $logfile);
}

function tstamp() {
  global $basedir, $pkfile, $logfile;

  return date('Y/m/d H:i:s');
}

handleRequest();

# If you find the newline character in binary file,
# you might have put the newline character before php tag or after php tag.
?>

2011/08/27:以下、補足01
公開鍵暗号方式のキモは「端点Aと端点Bの間に信頼できない経路が存在するときに、お互いにやりとりする情報の『送信元』と『改ざんされていないこと』を保証できる」ということだと思う。
で、上記の方法は端点A/Bを「決済手段と紐づいたAndroidMarket」/「守らなければならないコンテンツの提供元(=サーバ)」と定義しなおしていることになる。
一方、Google先生ご提供のDungeonsのサンプルは端点A/Bを「決済手段と紐づいたAndroidMarket」/「アプリ」と定義していることになる。

2011/08/27:以下、補足02

# TODO:We must check purchaseTime in $signedData is not expired.

と記したが、purchaseTimeは購入時のタイムスタンプであり、Managedなアイテムの場合はリストアトランザクションの時にとても古いpurchaseTimeの通知を返してくることがあるし、In-app Billingはそこそこ通知が遅れてくることがあるので、purchaseTimeを再送チェック用に使うことはできない。
と、いうことでちゃんとリプレイ攻撃の対策をしたい場合はnonceもサーバに送って逐一記録し、過去分と比べて重複チェックをする方法ぐらいしかなさそう。
なお、リストアトランザクション時には複数の購入情報が単一の通知にまとまって送られてくるが、nonce自体は通知に対して一個しかついてこないので、nonceをそのまま「ダウンロードする権利などを示すチケット」としては使えない。
サーバ側での署名チェックのタイミングと実際のコンテンツのダウンロードのタイミングをずらしたい場合、署名チェックについては通知が来たときにリアルタイムで行い、そのレスポンスとして通知に含まれる購入情報の個数分だけ「ダウンロードする権利などを示すチケット」を返すとよい。ただし、つくりを工夫しないとチケットを貯めて不正にダウンロードする権利を獲得する裏技が可能になる。