ESP32からAWSのRestAPIを呼び出します。
これを実現するために、2つの山を越える必要があります。
1. AWSとHTTPSで通信する必要がある。
2. ESP32内で、AWS SigV4署名プロトコルを実装する必要がある
「1.AWSとHTTPSで通信する必要がある」は、ESP32はHTTPS通信するにはメモリが少なく、不安定になることが多いです。そこで、HTTPをHTTPSに中継するサーバを中間に立ててそこからはHTTPS通信することとし、ESP32からは中継サーバまではHTTP通信で済むようにします。
これは以前実現しているので、そちらを参考にしてください。今回はこれを少し拡張します。
「2. ESP32内で、AWS SigV4署名プロトコルを実装する必要がある」は、そこまで複雑ではないので、今回頑張って実装しました。
もちろん、中継サーバを立てるので、そこで署名を生成する方法もありますが、署名生成するためのAWSアクセスキーIDやAWSシークレットアクセスキーの管理が面倒なので、ちゃんとESP32内で保持して署名だけを外部に出力されるようにしました。
AWS SigV4署名プロトコル
以下を見れば、おおよそイメージがつきます。図がわかりやすいです。
署名するために、あらかじめAWSからAWSアクセスキーIDやAWSシークレットアクセスキーを払い出しておきます。時限付きにしたい場合は、セッショントークン付きで払い出しておきます。
署名生成には、ESP32内で、SHA256ハッシュ生成や、SHA256を使ったHMAC演算が必要です。
それについては、以下を参照してください。
これを組み合わせると、こんな感じのソースコードになりました。
static AwsAuthorizationResult makeAwsAuthorization(const char *method, const char *host, const char *canonicalUri, const char *canonicalQuerystring,
const char *canonicalHeaders,const char *canonicalHeaderNames, const unsigned char *payload, int payload_length,
const char *service, const char *region, const char *accessKeyId, const char *secretAccessKey)
{
AwsAuthorizationResult amzResult;
amzResult.result = -1;
amzResult.authorization = NULL;
time_t now = time(nullptr);
struct tm* utcTime = gmtime(&now);
if( utcTime->tm_year == 70 )
return amzResult;
char dateStamp[9] = "19000101";
sprintf(dateStamp, "%04d%02d%02d", 1900 + utcTime->tm_year, utcTime->tm_mon + 1, utcTime->tm_mday);
sprintf(amzResult.amzDate, "%04d%02d%02dT%02d%02d%02dZ", 1900 + utcTime->tm_year, utcTime->tm_mon + 1, utcTime->tm_mday, utcTime->tm_hour, utcTime->tm_min, utcTime->tm_sec);
uint8_t payloadHash[32];
hashCreate(payload, payload_length, payloadHash);
toHexStr(sizeof(payloadHash), payloadHash, amzResult.payloadHash);
String signedHeaderNames = String(signedHeaderNamesBase);
if( canonicalHeaderNames != NULL && strlen(canonicalHeaderNames) > 0)
signedHeaderNames += String(";") + canonicalHeaderNames;
String headers = String("host:") + host + "\n" + "x-amz-content-sha256:" + amzResult.payloadHash + "\n" + "x-amz-date:" + amzResult.amzDate + "\n";
if( canonicalHeaders != NULL )
headers += String(canonicalHeaders);
int canonicalRequest_len = strlen(method) + 1 + strlen(canonicalUri) + 1 + (canonicalQuerystring != NULL ? strlen(canonicalQuerystring) : 0) + 1 + headers.length() + 1 + signedHeaderNames.length() + 1 + strlen(amzResult.payloadHash);
char *canonicalRequest = (char*)malloc(canonicalRequest_len + 1);
sprintf(canonicalRequest, "%s\n%s\n%s\n%s\n%s\n%s", method, canonicalUri, (canonicalQuerystring != NULL ? canonicalQuerystring : ""), headers.c_str(), signedHeaderNames.c_str(), amzResult.payloadHash);
uint8_t hashCanonicalRequest[32];
hashCreate((uint8_t*)canonicalRequest, strlen(canonicalRequest), hashCanonicalRequest);
free(canonicalRequest);
char hashCanonicalRequest_Hex[sizeof(hashCanonicalRequest) * 2 + 1];
toHexStr(sizeof(hashCanonicalRequest), hashCanonicalRequest, hashCanonicalRequest_Hex);
int scope_len = strlen(dateStamp) + 1 + strlen(region) + 1 + strlen(service) + 1 + strlen(aws4_request);
char *credentialScope = (char*)malloc(scope_len + 1);
sprintf(credentialScope, "%s/%s/%s/%s", dateStamp, region, service, aws4_request);
const char *algorithm = "AWS4-HMAC-SHA256";
int stringToSign_len = strlen(algorithm) + 1 + strlen(amzResult.amzDate) + 1 + strlen(credentialScope) + 1 + strlen(hashCanonicalRequest_Hex);
char *stringToSign = (char*)malloc(stringToSign_len + 1);
sprintf(stringToSign, "%s\n%s\n%s\n%s", algorithm, amzResult.amzDate, credentialScope, hashCanonicalRequest_Hex);
uint8_t signingKey[32];
getSignatureKey(secretAccessKey, dateStamp, region, service, signingKey);
char signingKey_Hex[sizeof(signingKey) * 2 + 1];
toHexStr(sizeof(signingKey), signingKey, signingKey_Hex);
uint8_t signature[32];
hmacCreate(signingKey, sizeof(signingKey), (const uint8_t*)stringToSign, strlen(stringToSign), signature);
free(stringToSign);
char signature_Hex[sizeof(signature) * 2 + 1];
toHexStr(sizeof(signature), signature, signature_Hex);
int authorization_len = strlen(algorithm) + strlen(" Credential=") + strlen(accessKeyId) + 1 + strlen(credentialScope) + strlen(", SignedHeaders=") + signedHeaderNames.length() + strlen(", Signature=") + strlen(signature_Hex);
char *authorization = (char*)malloc(authorization_len + 1);
sprintf(authorization, "%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", algorithm, accessKeyId, credentialScope, signedHeaderNames.c_str(), signature_Hex);
free(credentialScope);
amzResult.authorization = authorization;
amzResult.result = 0;
return amzResult;
}
補助関数はこちら
#include <mbedtls/md.h>
static long hmacCreate(const uint8_t *p_key, int key_len, const uint8_t *p_input, int input_len, uint8_t *p_result)
{
mbedtls_md_context_t context;
mbedtls_md_init(&context);
mbedtls_md_setup(&context, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1);
mbedtls_md_hmac_starts(&context, p_key, key_len);
if( p_input != NULL )
mbedtls_md_hmac_update(&context, p_input, input_len);
mbedtls_md_hmac_finish(&context, p_result); // 32 bytes
mbedtls_md_free(&context);
return 0;
}
static long hashCreate(const uint8_t *p_input, int length, uint8_t *p_result)
{
mbedtls_md_context_t context;
mbedtls_md_init(&context);
mbedtls_md_setup(&context, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 1);
if( p_input != NULL )
mbedtls_md_update(&context, (const unsigned char*)p_input, length);
mbedtls_md_finish(&context, p_result); // 32 bytes
return 0;
}
static char tohex(int i){
if( i < 10 )
return '0' + i;
else if( i < 16 )
return 'a' + (i - 10);
else
return '0';
}
static void toHexStr(int len, const uint8_t *p_bin, char *p_hex){
for( int i = 0 ; i < len ; i++ ){
p_hex[i * 2] = tohex((p_bin[i] >> 4 ) & 0x0f);
p_hex[i * 2 + 1] = tohex(p_bin[i] & 0x0f);
}
p_hex[len * 2] = '\0';
}
static void getSignatureKey(const char *key, const char *dateStamp, const char *regionName, const char *serviceName, uint8_t *kSigning){
int key_len = strlen("AWS4") + strlen(key);
char *temp_key = (char*)malloc(key_len + 1);
sprintf(temp_key, "%s%s", "AWS4", key);
uint8_t kDate[32];
hmacCreate((const uint8_t*)temp_key, strlen(temp_key), (const uint8_t*)dateStamp, strlen(dateStamp), kDate);
free(temp_key);
uint8_t kRegion[32];
hmacCreate(kDate, sizeof(kDate), (const uint8_t*)regionName, strlen(regionName), kRegion);
uint8_t kService[32];
hmacCreate(kRegion, sizeof(kRegion), (const uint8_t*)serviceName, strlen(serviceName), kService);
hmacCreate(kService, sizeof(kService), (const uint8_t*)aws4_request, strlen(aws4_request), kSigning);
}
署名生成に必要な引数
署名生成の関数「makeAwsAuthorization」の引数を見ていきます。
static AwsAuthorizationResult makeAwsAuthorization(const char *method, const char *host, const char *canonicalUri, const char *canonicalQuerystring,
const char *canonicalHeaders, const char *canonicalHeaderNames, const unsigned char *payload, int payload_length,
const char *service, const char *region, const char *accessKeyId, const char *secretAccessKey);
methodは、HTTPでよく出てくるやつです。GETとかPOSTとかPUTとかDELETEとか。呼び出したいAWS Rest APIのActionごとに定義されているものを選択します。
のちほど、呼び出し例を紹介します。
hostは、これから呼び出したいAWS側のエンドポイントです。S3やDynamoDBやAPI Gatewayなど、マネージドサービスごとにルールがありますのでそれに従います。
canonicalUriは、呼び出したいURLのうちのpathの部分です。
https://hogehoge/index.html としたときの「/index.html」の部分です。これも、マネージドサービスごとに決まりがあります。
canonicalQuerystringは、文字通りクエリ文字列です。各クエリ文字列を”&”で結合しておきます。
canonicalHeadersは、HTTPS呼び出し時のヘッダーです。複数のヘッダーを”:”と”\n”で結合しておきます。必須ではありませんが、これも、マネージドサービスごとに決まりがあります。
canonicalHeaderNamesは、前述のcanonicalHeadersで指定したヘッダに含めた各ヘッダ名を指定します。複数のヘッダー名を”;”で結合しておきます。
payloadは、HTTPS呼び出し時のボディーに相当します。payload_lengthはその長さです。必須ではありません。
serviceは、呼び出したいマネージドサービスの名前です。これも、マネージドサービスごとに決まりがあります。
regionは、AWSのリージョンです。必須ではありませんが、指定されたなかった場合は、”ap-northeast-1“が指定されたものとして扱います。
accessKeyIdとsecretAccessKeyが、署名のためのクレデンシャルです。アクセスキーIDとシークレットアクセスキーからなります。
署名が完了すると以下の値が返ってきます。
typedef struct _AwsAuthorizationResult {
char amzDate[16 + 1];
char payloadHash[32 * 2 + 1];
char *authorization;
long result;
} AwsAuthorizationResult;
amzDateは、署名に生成したときの日時を含めていますのでそれを返しています。
payloadHashは、署名時に使ったpayloadのハッシュ値です。
authorizationが最終的に生成された署名です。
これら3つをAWSへの呼び出し時に含めて呼び出します。
resultは署名生成結果で、0の場合に生成成功を示しています。
AWS呼び出し環境
AWS呼び出しは、以下のESP32で動作するJavascriptから行うようにしました。
(参考) ESP32で動作するJavascript実行環境
ESP32で動作するJavascript実行環境を公開しています。
「電子書籍:M5StackとJavascriptではじめるIoTデバイス制御」
サポートサイト
AWS呼び出し例
AWSを呼び出すためのパラメータ情報は以下を参考にします。
S3の場合
https://docs.aws.amazon.com/AmazonS3/latest/API/API_Operations_Amazon_Simple_Storage_Service.html
DynamoDBの場合
https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Operations_Amazon_DynamoDB.html
署名生成処理は、呼び出す関数内で自動的に処理しますので、呼び出し側は意識する必要がないようにしています。
S3:GetObject
ここに指定するパラメータの説明があります。
import * as http from "Http";
var params = {
service: "s3",
host: "バケット名.s3.ap-northeast-1.amazonaws.com",
canonicalUri: "/ファイル名",
method: "GET",
};
var result = http.fetchAws(params );
console.log(result);
serviceの部分は、S3の場合は”s3”を指定します。
hostの部分は、上記ページのHostに該当します。バケット名が含まれます。
canonicalUriは、上記ページの1行目の”/”以降のクエリ文字列を含まない部分です。バケット内のダウンロードしたいフォルダとファイル名に該当します。
methodは、このGetObjectではGETのようですね。
※ちなみに、バイナリファイルはまだ対応してないです。。。
S3:PutObject
ここに指定するパラメータの説明があります。
import * as http from "Http";
var params = {
service: "s3",
host: "バケット名.s3.ap-northeast-1.amazonaws.com",
canonicalUri: "/ファイル名",
method: "PUT",
payload: "Hello World",
contentType: "text/plain"
};
var result = http.fetchAws(params );
console.log(result);
service、host、canonicalUriの部分は、GetObjectと同様です。
payloadにファイルとして書き込みたい内容を指定します。
contentTypeに、ファイルの種類であるMimetypeを指定します。
methodは、このPutObjectではPUTのようですね。
※ちなみに、バイナリファイルはまだ対応してないです。。。
DynamoDB:GetItem
ここに指定するパラメータの説明があります。
import * as http from "Http";
var params = {
service: "dynamodb",
host: "dynamodb.ap-northeast-1.amazonaws.com",
canonicalUri: "/",
method: "POST",
contentType: "application/x-amz-json-1.0",
canonicalHeaders: "x-amz-target:DynamoDB_20120810.GetItem\n",
canonicalHeaderNames: "x-amz-target",
payload: JSON.stringify({
"TableName": "テーブル名",
"Key": {
"idm": { "S": "hello" }
}
})
};
var result = http.fetchAws(params );
console.log(result);
serviceの部分は、DynamoDBの場合は” dynamodb”を指定します。
hostの部分は、上記ページのHostに該当します。
canonicalUriは、上記ページの1行目の”/”の部分です。
contentTypeは、上記ページを見ると「application/x-amz-json-1.0」ですね。
canonicalHeadersには、追加で指定するヘッダーを指定します。GetItemでは、「X-Amz-Target: DynamoDB_20120810.GetItem」を指定するようにあります。ヘッダ名は小文字にし、改行付きで指定します。
canonicalHeaderNamesには、さきほどヘッダとして「x-amz-target」を指定しました。
payloadには、上記ページのボディー部分を指定します。取得したい対象を使用に従って指定します。GetItemではテーブル名や検索対象を指定します。
methodは、このGetItemではPOSTのようですね。
API Gateway:呼び出し
単に、SigV4付きで呼び出したいのであれば、以下でよさそうです。
var params = {
service: "execute-api",
host: "XXXXXXX.execute-api.ap-northeast-1.amazonaws.com",
canonicalUri: "/リソース名",
method: "GET",
};
var result = http.fetchAws(params );
console.log(result);
serviceの部分は、API Gatewayの場合は”execute-api”を指定します。
hostの部分は、API GatewayでAPIを作成したときに払い出されるURLです。
canonicalUriは、API Gatewayにおけるリソースに該当します。
中継サーバ
中継サーバでやっていることを抜粋しておきます。
const parse = require('parse-headers');
app.post('/aws', async (req, res) => {
console.log('/aws called');
console.log("body=" + JSON.stringify(req.body));
try{
req.body.headers = parse(req.body.headers);
var response = await fetchAwsRequest(req.body);
response.body.pipe(res);
}catch(error){
console.error(error);
res.status(500);
res.json({errorMessage: error.toString() });
}
});
// params = { host, method, canonicalUri, headers, canonicalQueryString?, payload?, content_type }
async function fetchAwsRequest(params) {
var input = {
url: "https://" + params.host + params.canonicalUri + (params.canonicalQuerystring ? "?" + params.canonicalQuerystring : ""),
method: params.method,
headers: params.headers,
response_type: "raw",
content_type: params.content_type,
body: params.payload
}
var result = await do_http(input);
console.log("fetchAws OK");
return result;
}
以上