SORACOM Beamの事前共有鍵をLambdaで検証する（API Gatewayを添えて）

SORACOM Beamの事前共有鍵を使ってLambdaで署名を検証してみました。AWS側では事前共有鍵をKMSをつかって保護します。

# 必要なもの

* AWSアカウント
* SoracomのアカウントとSoracom Air SIM
* Soraocm Air SIMを挿入できるWI-FIルータなど
* 上記WI-FIルータに接続するPC
* AWS CLI

# KMS

## KMSマスターキーの作成

ManagementConsoleから作成します。[IAM]->[暗号化キー]を選択します。
[キーの作成]をクリックする前に、”フィルター”でターゲットとするリージョンを選択しておきます。（右上のリージョン選択は”グローバル”しか選べません）

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/5b0f4651-841c-50e3-0068-a4f2246c138d.png)

* エイリアスと説明には任意の文字列を入力します。
* キー管理アクセス許可・キー使用アクセス許可は、現在ManagementConsoleにログインしているユーザーを選択します。（商用環境で利用するときは適切にRole/Groupなどを使用しましょう）
* Lambdaからのアクセス許可はLambda側につけるRoleで設定するので、ここでは不要です。

aws-cliから確認するとこのようになっているはずです。

```shell-session
$ aws kms list-aliases
{
    "Aliases": [
        {
            "AliasArn": "arn:aws:kms:ap-northeast-1:xxxxxxxxxxxx:alias/soracom-key", 
            "AliasName": "alias/soracom-key", 
            "TargetKeyId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
        }
    ]
}
```

## 暗号化

aws-cliで暗号化を行います。`--key-id`に先ほどの`list-aliases`の結果の"AliaseArn"を使用します。

```shell-session
$ aws kms encrypt --key-id arn:aws:kms:ap-northeast-1:xxxxxxxxxxxx:alias/soracom-key --plaintext 'monamukey'
{
    "KeyId": "arn:aws:kms:ap-northeast-1:xxxxxxxxxxxx:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", 
    "CiphertextBlob": "CiAQ4VcW2JOGZBKI5ovHIsjw/......"
}
```

レスポンスの”CiphertextBlob”をLambdaで使用します。


# 環境作成（CloudFormationでLambda）

IAMロールを作ったりの説明を端折るために、CloudFormationで一気にLambdaとIAMRoleを作ります。
Parameterの"Ciphertext"に`kms encrypt`の結果の"CiphertextBlob"を入力してください。
"KeyArn"は`aws kms list-keys`でaliasとKeyIdが一致するキーの"KeyArn"を入力してください。

```json
{

  "AWSTemplateFormatVersion":"2010-09-09",
  "Description":"Sample template",
  "Parameters":{
    "Ciphertext":{
      "Type":"String",
      "MinLength":1
    },
    "KeyArn":{
      "Type":"String",
      "MinLength":1
    }
  },
  "Resources":{
    "Function":{
      "Type":"AWS::Lambda::Function",
      "Properties":{
        "Handler": "index.handler",
        "MemorySize": "128",
        "Runtime": "nodejs",
        "Timeout": "300",
        "Role": {
          "Fn::GetAtt": ["FunctionRole","Arn"]
        },
        "Code": {
          "ZipFile": {
            "Fn::Join": ["",[
                "var ciphertext='" , {"Ref":"Ciphertext"} , "';","\n",
                "exports.handler = function(ev,cx){","\n",
                "    var AWS = require('aws-sdk');","\n",
                "    var kms = new AWS.KMS();","\n",
                "    var param = {","\n",
                "        CiphertextBlob:new Buffer(ciphertext,'base64')","\n",
                "    };","\n",
                "    kms.decrypt(param,","\n",
                "        function(err,data){","\n",
                "            if(err){cx.fail(err);return;}","\n",
                "            var secret = new Buffer(data.Plaintext).toString();","\n",
                "            console.log('secret:%s',secret);","\n",
                "            if(verifySign(ev.imsi,ev.imei,ev.timestamp,ev.signature,secret)){","\n",
                "                cx.succeed({message:'SUCCESS'});","\n",
                "            }else{","\n",
                "                cx.fail(new Error('InvalidRequest'));","\n",
                "            }","\n",
                "        }","\n",
                "    );","\n",
                "}","\n",
                "function verifySign(imsi,imei,timestamp,signature,secret){","\n",
                "    var crypto=require('crypto');    ","\n",
                "    var shasum = crypto.createHash('sha256');","\n",
                "    var stringtosign = secret","\n",
                "        + 'x-soracom-imei=' + imei","\n",
                "        + 'x-soracom-imsi=' + imsi","\n",
                "        + 'x-soracom-timestamp=' + timestamp;","\n",
                "        ","\n",
                "    console.log('<<string to sign>>:%s',stringtosign);","\n",
                "    shasum.update(stringtosign);","\n",
                "    var digest = shasum.digest('hex');","\n",
                "    console.log('<<signature>>:%s',signature);","\n",
                "    console.log('<<digest>>:%s',digest);","\n",
                "    return (signature === digest);","\n",
                "}","\n",
                ""
            ]]
          }
        }
      }
    },
    "FunctionRole":{
      "Type":"AWS::IAM::Role",
      "Properties":{
        "Path":{"Fn::Join":["",["/" ]]},
        "AssumeRolePolicyDocument":{
          "Version": "2012-10-17",
          "Statement": [
            {
              "Action": "sts:AssumeRole",
              "Principal": {
                "Service": "lambda.amazonaws.com"
              },
              "Effect": "Allow"
            }
          ]
        },
        "ManagedPolicyArns":[
          "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
        ],
        "Policies":[
          {"PolicyName":"default",
            "PolicyDocument":{
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": ["kms:Decrypt"],
                  "Resource": {"Ref":"KeyArn"}
                }
              ],
              "Version": "2012-10-17"
            }
          }
        ]
      }
    }
  },
  "Outputs":{
  }
}
```

# 環境作成（APIGateway）

作成したLambdaFunctionを呼び出すREST APIを作成します。

### LambdaFunctionを選択します。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/d5db25e7-0cfa-c14b-7a68-3bbf4dec8132.png)

### "Method Request"で"HTTP Request Headers"を追加します。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/0ca61b91-838b-afcc-459d-f07b461ebda3.png)

### "Integration Request"で"Content-Type"に"application/json"追加し、Templateを入力します。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/69c65190-28a6-1c20-12d8-9d63ac720a5d.png)

Templateの内容は以下のとおりです。

```json
{
    "payload":$input.json('$.payload'),
    "timestamp":"$input.params().header.get('X-Soracom-Timestamp')",
    "imsi":"$input.params().header.get('X-Soracom-IMSI')",
    "imei":"$input.params().header.get('X-Soracom-IMEI')",
    "signature":"$input.params().header.get('X-Soracom-Signature')"
}
```

"payload"は今回の記事とは関係ありません。

### "Method Response"で"HTTP Status"に400を追加します。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/70f8c95f-a156-7c27-9d91-885fbe37684a.png)

"Response Headers for 400"、"Response Models for 400"は今回は何も設定しません。


### "Integration Response"で"Method response status"に400を返す設定を追加します。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/8ee84c49-edc9-af95-8d38-e6a9775cbf6d.png)

ここまでできたらDeployを行います。StageNameを"prod"としました。

## 試しに呼んでみる

Postmanを使って呼び出してみます。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/f2027c7c-d76b-e2f6-d2b0-dbbaef80ac1e.png)

署名などがついていないので、Requestが400エラーとなりました。


# 環境作成（SORACOM Beam）

### グループを作成し、以下のBeam定義を追加します。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/f9054d6f-dcd7-4143-18e3-6a33d7bb9d09.png)
![image](https://qiita-image-store.s3.amazonaws.com/0/117772/71b663a8-bd70-f285-0414-aaa66b1cfbd1.png)

IMSIまたはIMEIヘッダのいずれかをOFFにした場合、Lambdaでの検証時の署名対象の文字列を生成するところを変更する必要があります（多分）。

```js
    var stringtosign = secret
        + 'x-soracom-imei=' + imei
        + 'x-soracom-imsi=' + imsi
        + 'x-soracom-timestamp=' + timestamp;

```

### 対象のSIMをグループに所属させます。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/3be8545a-fdba-c5d5-3a99-e3b1f7a3bac5.png)

# 確認

Soracom経由で通信している端末から"http://beam.soracom.io:8888" にPOSTリクエストを投げます。
![image](https://qiita-image-store.s3.amazonaws.com/0/117772/bcec8628-c52b-14d2-4162-4739a2001c2e.png)

見事に成功しています。

SORACOM BeamからAPIGatwayにどのようにデータが渡ってきているかをCloudWatch Logで見てみます。

![image](https://qiita-image-store.s3.amazonaws.com/0/117772/342693bd-9d54-0fec-b43b-ccf62be72f87.png)

# LambdaFunctionでやっていること

```js
    var param = {
        CiphertextBlob:new Buffer(ciphertext,'base64')
    };
    kms.decrypt(param,
        function(err,data){
            if(err){cx.fail(err);return;}
            var secret = new Buffer(data.Plaintext).toString();
```

ここでKMSを使って復号しています。`ciphertext`はKMSで暗号化した結果です。

```js
function verifySign(imsi,imei,timestamp,signature,secret){
    var crypto=require('crypto');    
    var shasum = crypto.createHash('sha256');
    var stringtosign = secret
        + 'x-soracom-imei=' + imei
        + 'x-soracom-imsi=' + imsi
        + 'x-soracom-timestamp=' + timestamp; // <- 1
        
    console.log('<<string to sign>>:%s',stringtosign);
    shasum.update(stringtosign);
    var digest = shasum.digest('hex'); // <- 2
    console.log('<<signature>>:%s',signature);
    console.log('<<digest>>:%s',digest);
    return (signature === digest); // <- 3
}
```

1. 署名対象となる文字列を作ります。SORACOM側と同じ組み立て方をしなければいけないのですが、今のところドキュメントに記載は無いようです。[stack overflowでのQ&A](http://ja.stackoverflow.com/questions/17976/beam%E3%81%AE%E7%BD%B2%E5%90%8D%E3%83%98%E3%83%83%E3%83%80%E6%A4%9C%E8%A8%BC%E3%81%AE%E4%BB%95%E6%A7%98%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)
2. sha256でハッシュ値を求めます。
3. BeamがHTTP Headerに付けたSignatureと2で求めたハッシュ値を比較します。


# まとめ

* 事前共有鍵を使うと、特定のSORACOMグループからのみリクエストを受け付けるということが簡単に実装できる
* 事前共有鍵が漏洩しなければ安全。
* AWS側はKMSを使えば、どこにも事前共有鍵が見えない。
* SORACOM側は今のところBeamの設定権限があると事前共有鍵が見えてしまうので、今後の機能拡張に期待。
* 今回のLambdaは検証用だから事前共有鍵をログに出しているんですよ（念のため）

# あとかたづけ

* KMSの鍵はいきなり削除できません。7〜30日後に削除されるようにスケジュールしてください。
* CloudFormationのStackを削除するとLambdaFunction、IAMRoleが削除されます。
* APIGatewayは手動で削除してください。
* SORACOMのグループは手動で削除してください。

# 参考

[KMSで認証情報を暗号化しLambda実行時に復号化する(DevelopersIO)](http://dev.classmethod.jp/cloud/decrypt-sensitive-data-with-kms-on-lambda-invocation/) 

[stack overflowでのQ&A](http://ja.stackoverflow.com/questions/17976/beam%E3%81%AE%E7%BD%B2%E5%90%8D%E3%83%98%E3%83%83%E3%83%80%E6%A4%9C%E8%A8%BC%E3%81%AE%E4%BB%95%E6%A7%98%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)