0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ESP32からAWSのローレベルRestAPIを呼び出す

Last updated at Posted at 2024-12-25

ESP32からAWSのRestAPIを呼び出します。
これを実現するために、2つの山を越える必要があります。

1. AWSとHTTPSで通信する必要がある。
2. ESP32内で、AWS SigV4署名プロトコルを実装する必要がある

「1.AWSとHTTPSで通信する必要がある」は、ESP32はHTTPS通信するにはメモリが少なく、不安定になることが多いです。そこで、HTTPをHTTPSに中継するサーバを中間に立ててそこからはHTTPS通信することとし、ESP32からは中継サーバまではHTTP通信で済むようにします。
これは以前実現しているので、そちらを参考にしてください。今回はこれを少し拡張します。

ESP32で気兼ねなくHTTPS通信する

「2. ESP32内で、AWS SigV4署名プロトコルを実装する必要がある」は、そこまで複雑ではないので、今回頑張って実装しました。
もちろん、中継サーバを立てるので、そこで署名を生成する方法もありますが、署名生成するためのAWSアクセスキーIDやAWSシークレットアクセスキーの管理が面倒なので、ちゃんとESP32内で保持して署名だけを外部に出力されるようにしました。

AWS SigV4署名プロトコル

以下を見れば、おおよそイメージがつきます。図がわかりやすいです。

署名するために、あらかじめAWSからAWSアクセスキーIDやAWSシークレットアクセスキーを払い出しておきます。時限付きにしたい場合は、セッショントークン付きで払い出しておきます。
署名生成には、ESP32内で、SHA256ハッシュ生成や、SHA256を使ったHMAC演算が必要です。
それについては、以下を参照してください。

ESP32でAES暗号/ハッシュ生成する

これを組み合わせると、こんな感じのソースコードになりました。

module_http.cpp
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;
}

補助関数はこちら

module_http.cpp
#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」の引数を見ていきます。

module_http.cpp
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とシークレットアクセスキーからなります。

署名が完了すると以下の値が返ってきます。

module_http.cpp
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;
}

以上

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?