Amazon API Gateway の Lambda オーソライザーで SORACOM Beam の署名検証 (Python 2.7)

[SORACOM アドベントカレンダー](https://qiita.com/advent-calendar/2018/soracom2) 12/3 担当の Max こと松下です。

Amazon API Gateway の API 呼び出しの認証(エンドポイントセキュリティ)に `x-api-key` のみを使う方法は、[公式にも書いてある通り推奨されていません](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-api-usage-plans.html#apigateway-usage-plans-best-practices)。

ここでは、Amazon API Gateway の Lambda オーソライザーで SORACOM Beam の署名検証を行う話です。

![Lambda オーソライザー使用時 / lambda_authorizer / after](https://docs.google.com/drawings/d/e/2PACX-1vR8673x5gpojFzwr9123BaiZn62RfzU1Yo-p4iQKyGDnzrwvItyUvAbQGtRhUlNIdo-p5vWGctQLd75/pub?w=569&h=431)

<h1 id="setting">設定手順</h1>

## AWS / Lambda 関数の作成

Lambda オーソライザーは設計図(Blueprint) に入っているので、これを流用します。今回は "api-gatewayauthorizer-python" を使います。(関数名は `soracom_beam_sign_validator_func` としました)

Blueprint との diff は以下の通りです。

```diff:by_blueprint.diff
--- blueprint.py	2018-11-28 09:06:12.833433300 +0900
+++ soracom_beam_signature_validator.py	2018-11-28 09:06:00.735645400 +0900
@@ -2,10 +2,26 @@
 
 import re
 
+import os
+SHARED_PSK = os.environ['SHARED_PSK']
 
 def lambda_handler(event, context):
-    print("Client token: " + event['authorizationToken'])
-    print("Method ARN: " + event['methodArn'])
+    s = ''
+    for header in ['x-soracom-imei', 'x-soracom-imsi', 'x-soracom-timestamp']:
+        try:
+            s += '{}={}'.format(header, event['headers'][header])
+        except:
+            pass
+    print(s) # to CloudWatch Log
+
+    import hashlib
+    h = hashlib.sha256(SHARED_PSK + s).hexdigest()
+    print(h) # to CloudWatch Log
+    print(event['headers']['x-soracom-signature']) # to CloudWatch Log
+    
+    if h != event['headers']['x-soracom-signature']:
+        raise Exception('Unauthorized')
 
     '''
     Validate the incoming token and produce the principal user identifier
@@ -15,7 +31,7 @@
     2. Decode a JWT token inline
     3. Lookup in a self-managed DB
     '''
-    principalId = 'user|a1b2c3d4'
+    principalId = s
 
     '''
     You can send a 401 Unauthorized response to the client by failing like so:
@@ -45,7 +61,8 @@
     policy.restApiId = apiGatewayArnTmp[0]
     policy.region = tmp[3]
     policy.stage = apiGatewayArnTmp[1]
-    policy.denyAllMethods()
+    #policy.denyAllMethods()
+    policy.allowAllMethods()
     #policy.allowMethod(HttpVerb.GET, '/pets/*')
 
     # Finally, build the policy
@@ -54,6 +71,7 @@
     # new! -- add additional key-value pairs associated with the authenticated principal
     # these are made available by APIGW like so: $context.authorizer.<key>
     # additional context is cached
+    '''
     context = {
         'key': 'value',  # $context.authorizer.key -> value
         'number': 1,
@@ -63,6 +81,7 @@
     # context['obj'] = {'foo':'bar'} <- also invalid
 
     authResponse['context'] = context
+    '''
 
     return authResponse
```

このコードでは事前共有鍵として利用する文字列を環境変数 `SHARED_PSK` から読み出すようにしています。
値は任意ですが、後述する SORACOM の認証情報ストアに設定する文字列と同じにしてください。(例では `_YOUR_SECRET_KEY_` としています)
![image.png](https://qiita-image-store.s3.amazonaws.com/0/22754/79baaed3-3c3a-4df7-717e-dc999ae6cd7a.png)

その他、コード解説;

* `x-soracom-signature` を検証
* `principalId` は _The principal user identification associated with the token sent by the client._ ということなので、識別できる値として署名生成元文字列を入れています
* 認証後の認可として allow all を返すようにしています
* `context` には何も入れないようにコメントアウトしています

### テスト

```json
{
  "methodArn": "arn:aws:execute-api:ap-northeast-1:123456789012:example/prod/POST/{proxy+}",
  "headers": {
    "x-soracom-imsi": "440XXXXXXXXXX91",
    "x-soracom-imei": "35XXXXXXXXXX195",
    "x-soracom-timestamp": "1542029454636",
    "x-soracom-signature": "21820f94db77f56c5d90c35b6fe06f64f185cb0133bf9b48d41ced4715c26ca7"
  }
}
```

値を適当に編集して署名検証に失敗することも確認してください。

## AWS / Amazon API Gateway のオーソライザーの設定

* 名前: `soracom_beam_sign_validator`
* タイプ: _Lambda_
* Lambda 関数: `soracom_beam_sign_validator_func` (先ほど作ったやつ。リージョンまたぎ可能)
* Lambda 呼び出しロール: (空で OK。後から付けてくれる)
* Lambda イベントペイロード: リクエスト
* ID ソース: (行を全て削除)
* 認証のキャッシュ: _無効_

![image.png](https://qiita-image-store.s3.amazonaws.com/0/22754/55ee31b1-2cac-18f0-c24e-584bb8fe7067.png)

### テスト

* _ヘッダー_ / `x-soracom-signature`
    * `21820f94db77f56c5d90c35b6fe06f64f185cb0133bf9b48d41ced4715c26ca7`
* _ヘッダー_ / `x-soracom-timestamp`
    * `1542029454636`
* _ヘッダー_ / `x-soracom-imsi`
    * `440XXXXXXXXXX91`
* _ヘッダー_ / `x-soracom-imei`
    * `35XXXXXXXXXX195`

![image.png](https://qiita-image-store.s3.amazonaws.com/0/22754/8d529f8e-fb04-469c-fab8-ee10d0e4f300.png)

値を適当に編集して署名検証に失敗することも確認してください。

## API Gateway / メソッドリクエストの設定

メソッドリクエストの設定で **認証** に `soracom_beam_sign_validator` を指定してください。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/22754/75638677-053a-21c3-d47f-fb795ab478fa.png)

### API Gateway それ以降の設定

残りは;

* 統合リクエストの設定
* 統合リクエストから呼び出される Lambda 関数の作成
* API のデプロイ

以上の作業となりますが、この辺は解説しません。ごめんなさい。

### テスト

API のデプロイまで終われば curl 等で検証できます。

```console
$ curl -v -X POST \
  -H "x-soracom-signature: 21820f94db77f56c5d90c35b6fe06f64f185cb0133bf9b48d41ced4715c26ca7" \
  -H "x-soracom-timestamp: 1542029454636" \
  -H "x-soracom-imsi: 440XXXXXXXXXX91" \
  -H "x-soracom-imei: 35XXXXXXXXXX195" \
  https://as2eqedq7d.execute-api.ap-northeast-1.amazonaws.com/prod/beam
```

## SORACOM / 認証情報ストアを作成

SORACOM 上で [セキュリティ] > [認証情報ストア] で **事前共有鍵** で認証情報を作成します。

* 認証情報 ID: `shared-psk` (任意)
* 概要: `SORACOM Beam 署名生成用 共有鍵` (任意)
* 種別: _事前共有鍵_
* 事前共有鍵: `_YOUR_SECRET_KEY_` (任意ですが Lambda オーソライザーの環境変数に設定した値と同じ文字列にしてください)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/22754/afaac9f0-24e4-13b2-4c38-167671a03dbe.png)

## SORACOM / SORACOM Beam の設定

基本的な設定手順は [SORACOM Beam の設定](https://dev.soracom.io/jp/start/beam/#group_settin) に記載されています。
ここでは SORACOM Beam の転送設定の内容を解説します。

HTTP エントリーポイントを選び、以下の設定をします。 

* エントリーポイント
    * 設定名: `API-Gateway` (任意)
    * パス: `/` (任意)
* 転送先
    * プロトコル: _HTTPS_
    * ホスト名: <API Gateway のエンドポイントのホスト部分>
    * ポート番号: (空)
    * パス: <API Gateway のエンドポイントのパス部分>
* ヘッダ操作
    * IMSI ヘッダ: _ON_
    * IMEI ヘッダ: _ON_
    * 署名ヘッダ付与: _ON_
    * 事前共有鍵: (認証情報ストアで作成したエントリーを選択)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/22754/9592386b-089e-43e3-d665-5595ce10cc08.png)

以上です。

**NOTE:**

IMEI は環境によっては取得できない場合があります。その場合は IMSI だけでも問題ありません。(Lambda 関数側も対応できるようにコーディングされています)

### テスト

このテストが最終です。
SORACOM Beam が有効になっている SORACOM Air SIM の回線から curl コマンドでテストできます。

```console
$ curl -v -X POST beam.soracom.io:8888
```

<h1 id="desc">いろいろ解説します</h1>

## Amazon API Gateway の Lambda オーソライザー

Amazon API Gateway (以下、API Gateway) には [Lambda オーソライザー](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html)※という、認証処理を AWS Lambda に実装する機能があります。
※カスタムオーソライザーと言われてました

Lambda オーソライザーを使う事で;

* 実処理を行う Lambda 関数の実装がシンプル化が可能
* Lambda 関数を分けられるため、認証処理・実処理それぞれ別の言語が選択可能
* 実処理に影響無く後から有効化＆無効化が可能なので、特に初期における開発が容易

以上の利点があります。

**従来;**

![従来 / lambda_authorizer / before](https://docs.google.com/drawings/d/e/2PACX-1vTLP47R_cZgN_b6It0CNU9QuncoRGc7k3RcLHURvT2SgYlDln7rGkFSfVpJmG0_g-sNw-ymkxh7ehtt/pub?w=569&h=431)

**Lambda オーソライザー使用時;**

![Lambda オーソライザー使用時 / lambda_authorizer / after](https://docs.google.com/drawings/d/e/2PACX-1vR8673x5gpojFzwr9123BaiZn62RfzU1Yo-p4iQKyGDnzrwvItyUvAbQGtRhUlNIdo-p5vWGctQLd75/pub?w=569&h=431)

## SORACOM Beam の署名機能

[SORACOM Beam](https://soracom.jp/services/beam/) は「データ転送支援サービス」です。
SORACOM の SIM を挿したデバイスでセルラー通信をすると `beam.soracom.io` というアドレスが見えるようになります。ここへ対してデータを HTTP/TCP/UDP で送信すると、SORACOM Beam 上の設定に従ってデータを送信します。

要するに Proxy サーバっぽく振舞うのですが、データ転送の際、SORACOM Beam はペイロードに対して SHA256 で署名することが可能です。
この署名を受付先のサーバで検証することで SORACOM Beam から送信されたのか否かを確認することが可能となります。

この機能を使うと API サーバの保護が簡単になるので、特に本番においては是非とも使っていただきたいわけですが、この署名検証を Lambda オーソライザーでやろうというのが今回の主旨です。

**署名無しの場合;**

![lambda_authorizer / Beam none-signature](https://docs.google.com/drawings/d/e/2PACX-1vSE2_QQLZRsqasSk3VBJGXv2bLDVY3_JsP1YFnFjFGzLhj8i_NLrUXqsQSkyIlAPDPbU_RMEYLwMseh/pub?w=653&h=381)

**署名ありの場合;**

![lambda_authorizer / Beam signature](https://docs.google.com/drawings/d/e/2PACX-1vS42dziybowk78qi3dSviFYXc3x0CVgEIiiugXVlSg4G201Rak4LVKrFhoHPHXIj9o3cbyc_be0gM_Z/pub?w=653&h=381)

※そのほかの方法としては送信先 IP アドレスによる ACL ベース認証があります。それを実現するためのサービスとして SORACOM の [VPG 固定グローバル IP アドレスオプション](https://soracom.jp/services/vpg_fixed_address_option/) もあります。

## TIPS;

### ヘッダの case-insensitive

結論、小文字に集約されます。
`X-FOO-BAR` で API Gateway に送信しても、Lambda 内では `event['header']['x-foo-bar']` で取得できます。

そのため、Lambda 関数でのテストや、オーソライザー上のテストにおいてはすべて小文字で行ってください。

### Lambda オーソライザー上の認証のキャッシュ

認証結果をキャッシュする機能です。キーは ID ソースになります。

SORACOM Beam が生成する署名は SORACOM が付与する `x-soracom-timestamp` を基に算出されているため、都度違う署名になることが確定します。
そのため、キャッシュ機能を有効活用できないので無効にしています。

一方、都度 Lambda 関数が起動しますので、回数や実行時間については注意するようにしてください。

### Lambda オーソライザーで得たデータを実処理の Lambda 関数への値の引き渡し

TIPS というか実装検討ポイントになります。

オーソライザーとして起動した Lambda 関数内で `context` というキーにマップ(Key-Value)を渡し return することで、実処理を担当する後続の Lambda 関数と連携することができます。

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-lambda-authorizer-output.html

ですが、これですと密結合になってしまうのでせっかく別実装できるようになった Lambda の運用が面倒くさくなるという課題を抱えますので、ご利用は計画的に。

### Amazon API Gateway / Lambda オーソライザーの `event` に引き渡されるオブジェクト

Lambda オーソライザーの設定で "Lambda イベントペイロード" を *リクエスト* に設定した時に、オーソライザーとして指定された Lambda 関数の `event` に引き渡されるオブジェクトは以下の通りです。

```json
{
    "methodArn": "arn:aws:execute-api:REGION:ACCOUNT_ID:API-GATEWAY/prod/POST/beam",
    "resource": "/beam",
    "requestContext": {
        "requestTime": "12/Nov/2018:13:30:54 +0000",
        "protocol": "HTTP/1.1",
        "domainName": "API-GATEWAY.execute-api.REGION.amazonaws.com",
        "resourceId": "XXXXXXX",
        "apiId": "API-GATEWAY",
        "resourcePath": "/beam",
        "httpMethod": "POST",
        "domainPrefix": "API-GATEWAY",
        "requestId": "XX-XX-XX-XX",
        "extendedRequestId": "XXXXXXXXXXXXX=",
        "path": "/prod/beam",
        "stage": "prod",
        "requestTimeEpoch": 1542029454651,
        "identity": {
            "userArn": null,
            "cognitoAuthenticationType": null,
            "accessKey": null,
            "caller": null,
            "userAgent": "curl/7.58.0",
            "user": null,
            "cognitoIdentityPoolId": null,
            "cognitoIdentityId": null,
            "cognitoAuthenticationProvider": null,
            "sourceIp": "X.X.X.X",
            "accountId": null
        },
        "accountId": "ACCOUNT_ID"
    },
    "queryStringParameters": {},
    "httpMethod": "POST",
    "multiValueQueryStringParameters": {},
    "pathParameters": {},
    "headers": {
        "Content-Length": "7",
        "x-soracom-signature": "21820f94db77f56c5d90c35b6fe06f64f185cb0133bf9b48d41ced4715c26ca7",
        "x-soracom-imsi": "440XXXXXXXXXX91",
        "X-Forwarded-Port": "443",
        "X-Forwarded-For": "X.X.X.X",
        "accept": "*/*",
        "user-agent": "curl/7.58.0",
        "X-Amzn-Trace-Id": "Root=1-TRACE-ID",
        "Host": "API-GATEWAY.execute-api.REGION.amazonaws.com",
        "X-Forwarded-Proto": "https",
        "x-soracom-signature-version": "20151001",
        "x-soracom-imei": "35XXXXXXXXXX195",
        "x-soracom-timestamp": "1542029454636",
        "content-type": "application/json"
    },
    "multiValueHeaders": {
        "Content-Length": [
            "7"
        ],
        "x-soracom-signature": [
            "21820f94db77f56c5d90c35b6fe06f64f185cb0133bf9b48d41ced4715c26ca7"
        ],
        "x-soracom-imsi": [
            "440XXXXXXXXXX91"
        ],
        "X-Forwarded-Port": [
            "443"
        ],
        "X-Forwarded-For": [
            "X.X.X.X"
        ],
        "accept": [
            "*/*"
        ],
        "user-agent": [
            "curl/7.58.0"
        ],
        "X-Amzn-Trace-Id": [
            "Root=1-TRACE-ID"
        ],
        "Host": [
            "API-GATEWAY.execute-api.REGION.amazonaws.com"
        ],
        "X-Forwarded-Proto": [
            "https"
        ],
        "x-soracom-signature-version": [
            "20151001"
        ],
        "x-soracom-imei": [
            "35XXXXXXXXXX195"
        ],
        "x-soracom-timestamp": [
            "1542029454636"
        ],
        "content-type": [
            "application/json"
        ]
    },
    "stageVariables": {},
    "path": "/beam",
    "type": "REQUEST"
}
```

### SORACOM Beam の署名の仕様

署名は以下の値を基に SHA256 で行われます。

* `事前共有鍵文字列 (shared_key_string)` (任意の文字列)
* `IMEI` (モデムに割り当てられている一意の番号)
* `IMSI` (SIM に割り当てられている一意の番号)
* `TIMESTAMP` は SORACOM 側で自動的に付与します

※IMSI もしくは IMEI は and/or です

署名は各種プログラム言語で生成可能です。たとえば Python による実装は以下の通りで、 hashlib を使えば一撃で算出できます。これと同じことを Lambda オーソライザー内で行ったのが今回の実装です。

```python
shared_key_string = '_YOUR_SECRET_KEY_'
imei = '35XXXXXXXXXX195'
imsi = '440XXXXXXXXXX91'
timestamp = '1542029454636'

s = "{}x-soracom-imei={}x-soracom-imsi={}x-soracom-timestamp={}".format(shared_key_string, imei, imsi, timestamp).encode('UTF-8')
import hashlib
h = hashlib.sha256(s).hexdigest()
print(h)
#=> 21820f94db77f56c5d90c35b6fe06f64f185cb0133bf9b48d41ced4715c26ca7
```

※ 詳しくは [SORACOM Beam / Beamが付与する署名ヘッダと事前共有鍵について](https://dev.soracom.io/jp/beam/send-data-using-beam/#signature) をご覧ください。

# あとがき

やっと＼(^o^)／ｵﾜﾀ

EoT