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?

AWS Emulator の Lambda は本物の Node.js ではなかった:電子署名アプリで分かった 6 つの落とし穴

0
Last updated at Posted at 2026-11-13

はじめに

2026 年 11 月 11 日に公開された AWS Emulator は、AWS の API を自分のパソコンの中で再現する公式ツールです。AWS アカウントなし・追加料金なしで、S3 や DynamoDB、Lambda などを試せます。

この記事では、前回の記事に引き続き、AWS Emulator の上で 小さなアプリ を作ってテストします。作るのは 「ファイルの改ざんを見抜く電子署名アプリ」 です。

== 1. 手元で署名する(sign.mjs)
{"action":"sign","key":"contract.txt","size":53,"sha256":"b0c7cdb6…","signatureBytes":71,…}
== 2. Lambda でそのまま検証する
{"action":"verify","key":"contract.txt","valid":true,"reason":"署名したときのままです",…}
  合格: 書き換えていないファイルは「正しい」と判定される
== 3. S3 のファイルの金額を書き換えてから、Lambda で検証する
{"action":"verify","key":"contract.txt","valid":false,"reason":"署名したあとに書き換えられています",…}
  合格: 書き換えたファイルは「改ざんされている」と判定される
すべて合格(エミュレータ)

契約書の「100,000 円」を「900,000 円」に書き換えると、ちゃんと見抜かれます。

そして今回、作りながら 「エミュレータの Lambda は、本物の Node.js ではない」 ことが分かりました。どこでつまずき、どう乗り越えたかも、後半で正直に書きます。

AWS Emulator で CDK や Terraform のスタックがどこまで動くか は、Nana_777 さんの記事が詳しく検証しています。忠実度スコアの集計や、既知の問題との突き合わせもあり、とても参考になります。

https://qiita.com/Nana_777/items/3f3c71525d867b22c9f2

この記事では、そこでは扱われていない KMS(電子署名)と、Lambda の中の暗号の機能 に絞って確かめました。

この記事で使った環境

項目 バージョン
AWS Emulator 1.0.338(2026-11-12 リリース)
AWS CLI 2.40.2
Node.js 22.22.0
AWS SDK for JavaScript 3.1168.0
OS Ubuntu 24.04(x86_64)

電子署名のきほん

ハッシュ値は「ファイルの指紋」

ハッシュ値 は、ファイルの中身から計算する短い値です。SHA-256 なら、どんなに大きなファイルでも 64 文字(32 バイト)になります。中身が 1 文字でも変わると、まったく別の値になります。

甲は乙に金 100,000 円を支払う。 → b0c7cdb6bf496d41…
甲は乙に金 900,000 円を支払う。 → aefebeec6267f458…

ハッシュの方式には SHA-1 もありますが、SHA-1 は安全ではない とされ、今は使われなくなっています。この記事では SHA-256 を使います。

ハッシュ値だけでは足りない

「署名したときのハッシュ値を保存しておき、あとで計算し直して比べる」だけでも、書き換えは見つかりそうです。ところが、ファイルを書き換えた人が、保存してあるハッシュ値まで書き換えてしまえば 見抜けません。

そこで使うのが 電子署名 です。

使う鍵 できる人
署名する 秘密鍵 鍵の持ち主だけ
検証する 公開鍵 誰でも(公開鍵は人に見せてよい)

秘密鍵を持っていない人は、正しい署名を作れません。だから、ファイルとハッシュ値を両方書き換えても、署名が合わなくなって見抜けます(この記事の後半で、実際にこの攻撃を試します)。

署名の方式は、広く使われている ECDSA(楕円曲線を使った署名、P-256)を使います。

秘密鍵は AWS KMS に預ける

秘密鍵をファイルで持っていると、盗まれたら終わりです。AWS には KMS(Key Management Service)という、鍵を預かるサービスがあります。

  • 秘密鍵は KMS の外に一度も出ません
  • 「このハッシュ値に署名して」と頼むと、署名だけが返ってきます
  • 公開鍵は取り出せます

作るもの

役割 どこで動くか 使うもの
署名する(sign.mjs) 手元のパソコン の Node.js S3(ファイルを置く)、KMS(署名する)、DynamoDB(署名を記録する)
検証する(Lambda 関数 doc-signer-verify) Lambda S3(ファイルを読む)、DynamoDB(署名を読む)、公開鍵
[手元のパソコン]  sign.mjs ──① ファイルを置く──▶ [S3]
                     │
                     ├──② ハッシュ値に署名して──▶ [KMS](秘密鍵はここから出ない)
                     │
                     └──③ ハッシュ値と署名を記録──▶ [DynamoDB]

[Lambda] doc-signer-verify ── S3 のファイルと DynamoDB の署名を読み、公開鍵で検証する

検証用の Lambda 関数には 「読む」権限しか与えず、KMS の権限も与えません。この関数は検証はできても、署名は絶対にできません。必要な権限だけを与える、という AWS の基本(最小権限)の練習にもなります。

「署名も Lambda でやればいいのでは?」と思った人は鋭いです。最初はそう作りましたが、エミュレータでは動きませんでした。理由は後半の「つまずいたところ」で説明します。

準備

次のものを使います。

もの 用途
AWS Emulator AWS の代わり。インストール方法は下記
AWS CLI(バージョン 2) AWS(エミュレータ)をコマンドで操作する
Node.js 22 署名のスクリプトと、Lambda に送るコードのビルド
zip コマンド Lambda に送るファイルを固める
bash デプロイとテストのスクリプト

スクリプトは bash で書いています。Windows の人は、WSL 2(Windows 上で動く Ubuntu)の中で作業する のがおすすめです。WSL の Ubuntu なら、Linux 用のパッケージでエミュレータを入れられます(筆者は WSL では試していません)。

Linux(Debian・Ubuntu、x86_64)なら、エミュレータは次の 2 行で入ります。ほかの OS や Docker で動かす方法は、公式ドキュメントにあります。

curl -fsSLo aws-emulator.deb https://downloads.aws.com/aws-emulator/packages/aws-emulator/LTS/latest/aws-emulator_amd64.deb
sudo apt-get install -y ./aws-emulator.deb

エミュレータを別のターミナルで起動します。EMULATOR_TELEMETRY=off は、初期設定で有効になっている「匿名の使用状況の送信」を止める設定です。

EMULATOR_TELEMETRY=off aws-emulator

作業するターミナルでは、AWS CLI と SDK の接続先をエミュレータに切り替えます(公式ドキュメントのとおりです)。

export AWS_ENDPOINT_URL=https://127.0.0.1:9297
export AWS_SHARED_CREDENTIALS_FILE=~/.aws/emulator/9297/emulator_credentials
export AWS_CONFIG_FILE=~/.aws/emulator/9297/config
export AWS_CA_BUNDLE=~/.aws/emulator/9297/ca.pem
export AWS_EC2_METADATA_DISABLED=true
export AWS_DISABLE_HOST_PREFIX_INJECTION=true
export AWS_REGION=us-east-1
export AWS_DEFAULT_REGION=us-east-1
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE

最後に、本物の AWS につながっていないこと を確かめます。アカウントが 000000000000(0 が 12 個)ならエミュレータです。

aws sts get-caller-identity --query Account
"000000000000"

ファイル構成

app/
├── package.json   … 使うライブラリ
├── sign.mjs       … 署名する(手元で動かす)
├── verify.mjs     … 検証する(Lambda で動かす)
├── common.sh      … 共通の設定(名前・接続先の確認)
├── deploy.sh      … AWS に必要なものを作る
├── test.sh        … 動かして確かめる
└── cleanup.sh     … 作ったものを全部消す

最初に、ライブラリを入れておきます。

npm install
package.json
{
  "name": "doc-signer",
  "private": true,
  "type": "module",
  "description": "ファイルに KMS の鍵で署名し(sign.mjs)、Lambda で改ざんを検知する(verify.mjs)",
  "scripts": {
    "build": "esbuild verify.mjs --bundle --platform=node --target=node22 --format=esm --external:@aws-sdk/* --outfile=dist/index.mjs --log-level=warning"
  },
  "dependencies": {
    "@aws-sdk/client-dynamodb": "3.1168.0",
    "@aws-sdk/client-kms": "3.1168.0",
    "@aws-sdk/client-s3": "3.1168.0",
    "@noble/curves": "2.4.0"
  },
  "devDependencies": {
    "esbuild": "0.28.2"
  }
}

コードを読む

署名する:sign.mjs

手元のパソコンで動かすスクリプトです。ファイルを S3 に置き、ハッシュ値に KMS で署名し、DynamoDB に記録します。

sign.mjs
// 手元のパソコンで実行する「署名」スクリプト。
//   node sign.mjs <ファイル>
// 1. ファイルを S3 にアップロードする
// 2. ファイルの SHA-256 を求め、KMS の鍵で署名する(秘密鍵は KMS の外に出ない)
// 3. ハッシュ値と署名を DynamoDB に記録する
//
// 接続先(エミュレータか本物の AWS か)は、AWS CLI と同じ環境変数で決まる。
// BUCKET_NAME・TABLE_NAME・KEY_ALIAS は common.sh が設定する。

import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { createHash } from "node:crypto";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { KMSClient, SignCommand } from "@aws-sdk/client-kms";
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";

const { BUCKET_NAME, TABLE_NAME, KEY_ALIAS } = process.env;
const SIGNING_ALGORITHM = "ECDSA_SHA_256";

const file = process.argv[2];
if (!file || !BUCKET_NAME || !TABLE_NAME || !KEY_ALIAS) {
  console.error("使い方: node sign.mjs <ファイル>(BUCKET_NAME・TABLE_NAME・KEY_ALIAS は common.sh が設定します)");
  process.exit(1);
}

const s3 = new S3Client({});
const kms = new KMSClient({});
const ddb = new DynamoDBClient({});

const bytes = await readFile(file);
const key = basename(file);
const digest = createHash("sha256").update(bytes).digest();

await s3.send(new PutObjectCommand({ Bucket: BUCKET_NAME, Key: key, Body: bytes }));

// ファイル全体ではなく、ハッシュ値(DIGEST)に署名する。大きなファイルでも KMS に送るのは 32 バイトだけ。
const signed = await kms.send(new SignCommand({
  KeyId: KEY_ALIAS,
  Message: digest,
  MessageType: "DIGEST",
  SigningAlgorithm: SIGNING_ALGORITHM,
}));

const signedAt = new Date().toISOString();
await ddb.send(new PutItemCommand({
  TableName: TABLE_NAME,
  Item: {
    fileKey: { S: key },
    sha256: { S: digest.toString("hex") },
    signature: { S: Buffer.from(signed.Signature).toString("base64") },
    keyId: { S: signed.KeyId },
    algorithm: { S: SIGNING_ALGORITHM },
    signedAt: { S: signedAt },
  },
}));

console.log(JSON.stringify({
  action: "sign",
  key,
  size: bytes.length,
  sha256: digest.toString("hex"),
  signatureBytes: signed.Signature.length,
  signedAt,
}));

ポイントは MessageType: "DIGEST" です。ファイルそのものではなく ハッシュ値(32 バイト)に署名 するので、大きなファイルでも KMS に送るデータは 32 バイトだけです。

AWS SDK には、接続先も認証情報も書いていません。AWS CLI と同じ環境変数を読むので、同じコードがエミュレータでも本物でも動きます。

検証する:verify.mjs(Lambda)

verify.mjs
// Lambda で動かす「検証」関数 doc-signer-verify。
//   呼び出し方: { "key": "contract.txt" }
// S3 にある今のファイルが、sign.mjs で署名したときのままかを、公開鍵だけで確かめる。
// KMS は呼ばない(この関数には署名する権限がなく、検証しかできない)。
//
// 環境変数(deploy.sh が設定する):
//   BUCKET_NAME … ファイルを置く S3 バケット
//   TABLE_NAME  … 署名を記録した DynamoDB テーブル
//   PUBLIC_KEY  … KMS の鍵の公開鍵(SPKI 形式の DER を Base64 にしたもの)

import { createHash } from "node:crypto";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
// ECDSA の検証は、JavaScript だけで書かれたライブラリで行う。
// AWS Emulator の Lambda 実行環境には、署名を検証する API(node:crypto の verify や Web Crypto)がないため。
import { p256 } from "@noble/curves/nist.js";

const s3 = new S3Client({});
const ddb = new DynamoDBClient({});

const { BUCKET_NAME, TABLE_NAME, PUBLIC_KEY } = process.env;

// P-256 の公開鍵は、SPKI 形式の末尾 65 バイト(非圧縮の点)
const publicKey = Buffer.from(PUBLIC_KEY, "base64").subarray(-65);

export const handler = async (event) => {
  const key = event?.key;
  if (!key) throw new Error("key(S3 のファイル名)を指定してください");

  const record = await ddb.send(new GetItemCommand({ TableName: TABLE_NAME, Key: { fileKey: { S: key } } }));
  if (!record.Item) {
    return { action: "verify", key, valid: false, reason: "このファイルの署名の記録がありません" };
  }

  const obj = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
  const bytes = await obj.Body.transformToByteArray();
  const digest = createHash("sha256").update(bytes).digest();

  // KMS の署名は DER 形式。ハッシュ値はもう求めてあるので prehash: false
  const signature = Buffer.from(record.Item.signature.S, "base64");
  const valid = p256.verify(signature, digest, publicKey, { prehash: false, format: "der", lowS: false });

  return {
    action: "verify",
    key,
    valid,
    reason: valid ? "署名したときのままです" : "署名したあとに書き換えられています",
    sha256Now: digest.toString("hex"),
    sha256Signed: record.Item.sha256.S,
    signedAt: record.Item.signedAt.S,
  };
};
  • 検証に使うのは 公開鍵だけ です。KMS は呼びません
  • ECDSA の検証には、JavaScript だけで書かれたライブラリ @noble/curves を使います(理由は後半で説明します)

Lambda に送るときは、esbuild で @noble/curves ごと 1 つのファイルにまとめます。AWS SDK は Lambda に最初から入っているものを使うので、まとめる対象から外しています(--external:@aws-sdk/*)。

デプロイする:deploy.sh

deploy.sh は、次の 5 つを順番に作ります。何度実行しても大丈夫 なように、すでにあるものは作り直しません。

順番 作るもの ポイント
1 S3 バケット 名前にアカウント ID を入れる(本物の S3 ではバケット名が世界中で重複できないため)
2 DynamoDB テーブル 作成直後は使えないので待つ(本物の AWS と同じで、作成中のテーブルに書き込むとエラーになる)
3 KMS の鍵 署名用の ECDSA(P-256)の鍵。公開鍵も取り出しておく
4 IAM ロール 検証用の関数に「S3 を読む」「DynamoDB を読む」だけを許可する
5 Lambda 関数 コードをまとめて送る。公開鍵は環境変数で渡す
./deploy.sh
接続先: エミュレータ / リージョン: us-east-1
1/5 S3 バケット: doc-signer-000000000000-us-east-1
2/5 DynamoDB テーブル: doc-signatures
3/5 KMS の鍵: alias/doc-signer
4/5 IAM ロール: doc-signer-verify-role
5/5 Lambda 関数: doc-signer-verify
完了しました(エミュレータ)
  バケット: doc-signer-000000000000-us-east-1
  鍵:       alias/doc-signer(0619e110-6051-4531-a6fd-70b6e0da3777)
  関数:     doc-signer-verify

エミュレータなら 15 秒ほどで終わりました。

接続先が本物の AWS のときは、実行する前に「本物の AWS に対して実行します。よろしいですか?」と確認する ようにしてあります(common.sh)。エミュレータ向けの環境変数を設定し忘れたまま実行して、うっかり本物に作ってしまう事故を防ぐためです。

common.sh(クリックで開く)
common.sh
# deploy.sh / test.sh / cleanup.sh で共通に使う設定。
# エミュレータでも本物の AWS でも、同じ名前のリソースを作る。

set -euo pipefail

REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-}}"
if [ -z "$REGION" ]; then
  echo "AWS_REGION を設定してください(例: export AWS_REGION=us-east-1)" >&2
  exit 1
fi

# 今どこに繋がっているかを確かめる。エミュレータならアカウントは 000000000000
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
if [ "$ACCOUNT_ID" = "000000000000" ]; then
  TARGET="エミュレータ"
else
  TARGET="本物の AWS(アカウント ${ACCOUNT_ID})"
fi

APP=doc-signer
export BUCKET_NAME="${APP}-${ACCOUNT_ID}-${REGION}"  # S3 のバケット名は世界中で重複できないので、アカウント ID を入れる
export TABLE_NAME=doc-signatures
export KEY_ALIAS="alias/${APP}"
FUNCTION_NAME="${APP}-verify"
ROLE_NAME="${APP}-verify-role"

# Node.js の AWS SDK(sign.mjs)は AWS_CA_BUNDLE を読まないので、
# エミュレータの証明書を NODE_EXTRA_CA_CERTS でも信用させる
if [ -n "${AWS_CA_BUNDLE:-}" ]; then
  export NODE_EXTRA_CA_CERTS="$AWS_CA_BUNDLE"
fi

# 本物の AWS に対して実行するときは、念のため確認する(CONFIRM=yes で確認を省略)
confirm_target() {
  echo "接続先: ${TARGET} / リージョン: ${REGION}"
  if [ "$ACCOUNT_ID" != "000000000000" ] && [ "${CONFIRM:-}" != "yes" ]; then
    read -r -p "本物の AWS に対して実行します。よろしいですか? [yes/no] " answer
    [ "$answer" = "yes" ] || { echo "中止しました"; exit 1; }
  fi
}
deploy.sh(クリックで開く)
deploy.sh
#!/usr/bin/env bash
# doc-signer に必要なものを作る: S3 バケット、DynamoDB テーブル、KMS の鍵、IAM ロール、検証用の Lambda 関数。
# 何度実行しても大丈夫なように、すでにあるものは作り直さない(Lambda のコードと設定だけは更新する)。

cd "$(dirname "$0")"
. ./common.sh
confirm_target

echo "1/5 S3 バケット: ${BUCKET_NAME}"
if ! aws s3api head-bucket --bucket "$BUCKET_NAME" 2>/dev/null; then
  if [ "$REGION" = "us-east-1" ]; then
    aws s3api create-bucket --bucket "$BUCKET_NAME" > /dev/null
  else
    aws s3api create-bucket --bucket "$BUCKET_NAME" \
      --create-bucket-configuration "LocationConstraint=${REGION}" > /dev/null
  fi
fi

echo "2/5 DynamoDB テーブル: ${TABLE_NAME}"
if ! aws dynamodb describe-table --table-name "$TABLE_NAME" > /dev/null 2>&1; then
  aws dynamodb create-table --table-name "$TABLE_NAME" \
    --attribute-definitions AttributeName=fileKey,AttributeType=S \
    --key-schema AttributeName=fileKey,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST > /dev/null
fi
aws dynamodb wait table-exists --table-name "$TABLE_NAME"  # 作成直後は使えないので待つ

echo "3/5 KMS の鍵: ${KEY_ALIAS}"
if ! KEY_ID="$(aws kms describe-key --key-id "$KEY_ALIAS" --query KeyMetadata.KeyId --output text 2>/dev/null)"; then
  KEY_ID="$(aws kms create-key --key-usage SIGN_VERIFY --key-spec ECC_NIST_P256 \
    --description "doc-signer の署名用の鍵" --query KeyMetadata.KeyId --output text)"
  aws kms create-alias --alias-name "$KEY_ALIAS" --target-key-id "$KEY_ID"
fi
# 検証に使う公開鍵。公開鍵は人に見せても問題ないので、Lambda の環境変数に入れる
PUBLIC_KEY="$(aws kms get-public-key --key-id "$KEY_ID" --query PublicKey --output text)"

echo "4/5 IAM ロール: ${ROLE_NAME}"
if ! aws iam get-role --role-name "$ROLE_NAME" > /dev/null 2>&1; then
  aws iam create-role --role-name "$ROLE_NAME" --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{ "Effect": "Allow", "Principal": { "Service": "lambda.amazonaws.com" }, "Action": "sts:AssumeRole" }]
  }' > /dev/null
fi
# 検証用の関数に許可するのは「読むこと」だけ。KMS の権限はないので、この関数は署名できない
aws iam put-role-policy --role-name "$ROLE_NAME" --policy-name "${APP}-verify-policy" --policy-document "{
  \"Version\": \"2012-10-17\",
  \"Statement\": [
    { \"Effect\": \"Allow\", \"Action\": \"s3:GetObject\", \"Resource\": \"arn:aws:s3:::${BUCKET_NAME}/*\" },
    { \"Effect\": \"Allow\", \"Action\": \"dynamodb:GetItem\",
      \"Resource\": \"arn:aws:dynamodb:${REGION}:${ACCOUNT_ID}:table/${TABLE_NAME}\" },
    { \"Effect\": \"Allow\", \"Action\": [\"logs:CreateLogGroup\", \"logs:CreateLogStream\", \"logs:PutLogEvents\"], \"Resource\": \"*\" }
  ]
}"
ROLE_ARN="$(aws iam get-role --role-name "$ROLE_NAME" --query Role.Arn --output text)"

echo "5/5 Lambda 関数: ${FUNCTION_NAME}"
# 検証のコード(verify.mjs)を、使うライブラリごと 1 つのファイル(dist/index.mjs)にまとめる。
# AWS SDK は Lambda に最初から入っているものを使う(--external)。
npm ci --no-audit --no-fund > /dev/null
npm run -s build > /dev/null
rm -f function.zip
(cd dist && zip -q ../function.zip index.mjs)
ENV_VARS="Variables={BUCKET_NAME=${BUCKET_NAME},TABLE_NAME=${TABLE_NAME},PUBLIC_KEY=${PUBLIC_KEY}}"
if aws lambda get-function --function-name "$FUNCTION_NAME" > /dev/null 2>&1; then
  aws lambda update-function-code --function-name "$FUNCTION_NAME" --zip-file fileb://function.zip > /dev/null
  aws lambda wait function-updated-v2 --function-name "$FUNCTION_NAME"
  aws lambda update-function-configuration --function-name "$FUNCTION_NAME" --environment "$ENV_VARS" > /dev/null
else
  # 本物の AWS では、作ったばかりの IAM ロールが使えるようになるまで数秒かかることがあるので、少し待ってやり直す
  for attempt in 1 2 3 4 5 6; do
    if aws lambda create-function --function-name "$FUNCTION_NAME" \
        --runtime nodejs22.x --handler index.handler --role "$ROLE_ARN" \
        --zip-file fileb://function.zip --timeout 30 --environment "$ENV_VARS" > /dev/null 2> create-error.log; then
      break
    fi
    if [ "$attempt" = 6 ] || ! grep -q "cannot be assumed" create-error.log; then
      cat create-error.log >&2
      exit 1
    fi
    echo "  IAM ロールの準備を待っています(${attempt}/5)"
    sleep 5
  done
  rm -f create-error.log
fi
aws lambda wait function-active-v2 --function-name "$FUNCTION_NAME"
aws lambda wait function-updated-v2 --function-name "$FUNCTION_NAME"

echo "完了しました(${TARGET})"
echo "  バケット: ${BUCKET_NAME}"
echo "  鍵:       ${KEY_ALIAS}(${KEY_ID})"
echo "  関数:     ${FUNCTION_NAME}"

テストする:test.sh

test.sh は、冒頭の 3 つを自動で確かめます。

  1. 手元で契約書に署名する
  2. Lambda でそのまま検証すると「正しい」
  3. S3 の契約書の金額を書き換えてから検証すると「改ざんされている」
./test.sh
接続先: エミュレータ / リージョン: us-east-1
== 1. 手元で署名する(sign.mjs)
{"action":"sign","key":"contract.txt","size":53,"sha256":"b0c7cdb6bf496d41ec00174185dcdbad01e87a8f47f555e3e7e54c9758734527","signatureBytes":70,"signedAt":"2026-11-13T00:05:50.536Z"}
== 2. Lambda でそのまま検証する
{"action":"verify","key":"contract.txt","valid":true,"reason":"署名したときのままです","sha256Now":"b0c7cdb6bf496d41ec00174185dcdbad01e87a8f47f555e3e7e54c9758734527","sha256Signed":"b0c7cdb6bf496d41ec00174185dcdbad01e87a8f47f555e3e7e54c9758734527","signedAt":"2026-11-13T00:05:50.536Z"}
  合格: 書き換えていないファイルは「正しい」と判定される
== 3. S3 のファイルの金額を書き換えてから、Lambda で検証する
{"action":"verify","key":"contract.txt","valid":false,"reason":"署名したあとに書き換えられています","sha256Now":"aefebeec6267f458ea125d93d8f981df22d519bb3aeed1fef40f16c95daf9899","sha256Signed":"b0c7cdb6bf496d41ec00174185dcdbad01e87a8f47f555e3e7e54c9758734527","signedAt":"2026-11-13T00:05:50.536Z"}
  合格: 書き換えたファイルは「改ざんされている」と判定される
すべて合格(エミュレータ)

sha256Now(今のファイル)と sha256Signed(署名したとき)が違う ので、書き換えられたことが分かります。期待どおりなら「すべて合格」と出て、終了コード 0 で終わります。

test.sh(クリックで開く)
test.sh
#!/usr/bin/env bash
# doc-signer を動かして確かめる:
#   1. 手元でファイルに署名する(sign.mjs)
#   2. Lambda でそのまま検証すると「正しい」
#   3. S3 のファイルを書き換えてから検証すると「改ざんされている」
# 期待どおりなら最後に「すべて合格」と出て、終了コード 0 で終わる。

cd "$(dirname "$0")"
. ./common.sh
confirm_target

FILE_KEY=contract.txt
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

invoke() {
  aws lambda invoke --function-name "$FUNCTION_NAME" \
    --cli-binary-format raw-in-base64-out --payload "$1" "$WORK/out.json" > "$WORK/meta.json"
  if grep -q FunctionError "$WORK/meta.json"; then
    echo "Lambda 関数がエラーを返しました:" >&2
    cat "$WORK/out.json" >&2
    exit 1
  fi
  cat "$WORK/out.json"
}

check() {  # check <説明> <期待する valid の値>
  local valid
  valid="$(sed -n 's/.*"valid":\(true\|false\).*/\1/p' "$WORK/out.json")"
  if [ "$valid" = "$2" ]; then
    echo "  合格: $1"
  else
    echo "  不合格: $1(valid が ${2} のはずが ${valid:-なし})" >&2
    exit 1
  fi
}

echo "== 1. 手元で署名する(sign.mjs)"
printf '契約書\n甲は乙に金 100,000 円を支払う。\n' > "$WORK/$FILE_KEY"
node sign.mjs "$WORK/$FILE_KEY"

echo "== 2. Lambda でそのまま検証する"
invoke "{\"key\":\"${FILE_KEY}\"}"
echo
check "書き換えていないファイルは「正しい」と判定される" true

echo "== 3. S3 のファイルの金額を書き換えてから、Lambda で検証する"
printf '契約書\n甲は乙に金 900,000 円を支払う。\n' > "$WORK/$FILE_KEY"
aws s3 cp "$WORK/$FILE_KEY" "s3://${BUCKET_NAME}/${FILE_KEY}" > /dev/null
invoke "{\"key\":\"${FILE_KEY}\"}"
echo
check "書き換えたファイルは「改ざんされている」と判定される" false

echo "すべて合格(${TARGET})"

ハッシュ値まで書き換えられたら?

最初に書いた「ハッシュ値だけでは足りない」を確かめます。攻撃者になったつもりで、DynamoDB に記録したハッシュ値を、書き換えたファイルのハッシュ値に差し替えます。

aws dynamodb update-item --table-name doc-signatures \
  --key '{"fileKey":{"S":"contract.txt"}}' \
  --update-expression "SET sha256 = :h" \
  --expression-attribute-values '{":h":{"S":"aefebeec6267f458ea125d93d8f981df22d519bb3aeed1fef40f16c95daf9899"}}'

aws lambda invoke --function-name doc-signer-verify \
  --cli-binary-format raw-in-base64-out --payload '{"key":"contract.txt"}' out.json
cat out.json
{"action":"verify","key":"contract.txt","valid":false,"reason":"署名したあとに書き換えられています","sha256Now":"aefebeec6267f458…","sha256Signed":"aefebeec6267f458…",…}

sha256Now と sha256Signed が 同じ になりました。ハッシュ値を比べるだけの仕組みなら、ここで「正しい」と判定されてしまいます。でも 署名が合わないので、valid は false のままです。秘密鍵(KMS の中)がない限り、攻撃者は正しい署名を作れません。

片付ける:cleanup.sh

./cleanup.sh
接続先: エミュレータ / リージョン: us-east-1
Lambda 関数を削除
IAM ロールを削除
DynamoDB テーブルを削除
S3 バケットを中身ごと削除
KMS の鍵を削除予約(7 日後に削除)
  削除予定日時: 2026-11-20T00:05:57.503000+00:00
完了しました(エミュレータ)

KMS の鍵だけは すぐには消せず、最短 7 日後に消える予約 になります。間違えて消したときに取り戻せるようにするための、AWS の仕組みです。本物の AWS では、KMS の鍵は持っているだけで料金がかかるので、消し忘れに注意が必要です。

cleanup.sh(クリックで開く)
cleanup.sh
#!/usr/bin/env bash
# deploy.sh で作ったものをすべて消す。本物の AWS では、消し忘れると料金がかかり続けるものがある。
# KMS の鍵だけは、すぐには消せない(最短 7 日後に削除される予約になる)。

cd "$(dirname "$0")"
. ./common.sh
confirm_target

echo "Lambda 関数を削除"
aws lambda delete-function --function-name "$FUNCTION_NAME" > /dev/null 2>&1 || true

echo "IAM ロールを削除"
aws iam delete-role-policy --role-name "$ROLE_NAME" --policy-name "${APP}-verify-policy" 2>/dev/null || true
aws iam delete-role --role-name "$ROLE_NAME" 2>/dev/null || true

echo "DynamoDB テーブルを削除"
aws dynamodb delete-table --table-name "$TABLE_NAME" > /dev/null 2>&1 || true

echo "S3 バケットを中身ごと削除"
aws s3 rb "s3://${BUCKET_NAME}" --force > /dev/null 2>&1 || true

echo "KMS の鍵を削除予約(7 日後に削除)"
if KEY_ID="$(aws kms describe-key --key-id "$KEY_ALIAS" --query KeyMetadata.KeyId --output text 2>/dev/null)"; then
  aws kms delete-alias --alias-name "$KEY_ALIAS"
  aws kms schedule-key-deletion --key-id "$KEY_ID" --pending-window-in-days 7 \
    --query DeletionDate --output text | sed 's/^/  削除予定日時: /'
fi

echo "完了しました(${TARGET})"

つまずいたところ:エミュレータの Lambda は「本物の Node.js」ではない

ここからが、今回いちばん伝えたいところです。

最初は、署名も検証も Lambda 関数 1 つで行う 作りにしていました。ところが、エミュレータでは何度も失敗しました。順番に書きます。

# やったこと エミュレータでのエラー
1 Lambda に最初から入っている AWS SDK で KMS に署名を頼む Could not find export 'SignCommand' in module '…/@aws-sdk/client-kms'
2 AWS SDK を自分で関数に同梱する Could not find export 'S3Client' in module '…/client-s3/dist-cjs/index'
3 読み込み方を変える Cannot find module '@aws-sdk/checksums/flexible-checksums'
4 esbuild で 1 つのファイルにまとめる Cannot find module 'node:process'
5 検証を Node.js の crypto.verify で行う Could not find export 'createPublicKey' in module 'node:crypto'
6 検証を Web Crypto(crypto.subtle)で行う TypeError: not a function

5 と 6 のコードは、手元の本物の Node.js 22 では正しく動き、改ざんも見抜けました。1〜4 も、本物の Lambda ではよく使われる書き方です(本物の Lambda で動くかは、この記事では確かめていません)。

調べてみると、エミュレータの Lambda で動いているのは、Node.js の真似をする別の JavaScript の実行環境 でした。process.version は v22.0.0 と答えますが、

  • 最初から入っている AWS SDK のうち、KMS は中身がほぼ空(KMSClient はあるが、SignCommand などの命令がない)。S3 と DynamoDB は使える
  • node:crypto には ハッシュや乱数はあるが、署名の検証はない
  • パッケージの読み込みの仕組みも、本物の Node.js と違う

という状態でした(エミュレータ 1.0.338 の場合)。

Nana_777 さんの記事(エミュレータ 1.0.322)でも、Lambda の process.version が v22.0.0 と答えること、CommonJS 形式(exports.handler)のコードが exports is not defined で動かないことが報告されています。別の人が別の方法で確かめても、「本物の Node.js とは違う」という同じ結論になっています。

そこで、次のように作り直しました。

問題 対策
Lambda から KMS に署名を頼めない 署名は手元のパソコンで行う(手元からなら、エミュレータの KMS で署名できる)
Lambda で署名を検証する API がない JavaScript だけで書かれたライブラリ(@noble/curves)で検証する
自分で同梱した AWS SDK が動かない AWS SDK は Lambda に最初から入っているものを使う(S3 と DynamoDB は使える)

結果として、「秘密鍵を持つ側が署名し、検証は公開鍵で誰でもできる」という、電子署名の本来の形に近い設計になりました。

自分で同梱した AWS SDK を使う作りは、エミュレータの中で 本物の AWS に接続しにいく危険 もあります。エミュレータの Lambda の中では、接続先を決める環境変数(AWS_ENDPOINT_URL)が設定されていなかったからです。エミュレータが用意した SDK を使うのが安全です。

権限はちゃんとチェックされた

もう 1 つ、意外だったことがあります。公式ドキュメントには「エミュレータは IAM のポリシーを AWS と同じようには評価しない」とあります。ところが今回試した範囲では、Lambda 関数の権限はチェックされました。

  • 権限を付けずに DynamoDB を読もうとすると、AccessDeniedException(権限がない)で拒否された
  • 権限の対象を 別のテーブル にしても、拒否された

権限の付け忘れは、AWS の初心者がいちばんつまずくところです。それをローカルで練習できるのはありがたいところです。ただし公式ドキュメントのとおり、セキュリティの確認をエミュレータだけで済ませてはいけません。

エミュレータは「API の練習場」と考える

今回分かったことをまとめると、こうなります。

エミュレータの再現度
AWS の API(CLI や手元の SDK から呼ぶもの) 高い。エラーまで本物どおり
Lambda の 中で動くコードの実行環境 本物とは別物。使えない機能がある

エミュレータで動いたからといって、本物でも動くとは限りません。逆に、エミュレータで動かなくても、本物では動くことがあります。だからこそ、最後は本物の AWS で同じテストを流して確かめる ことが大切です。

まとめ

  • 「改ざんを見抜く電子署名アプリ」を、S3・KMS・DynamoDB・Lambda で作った
  • 署名は 秘密鍵を KMS に預けたまま、手元で行う。検証は 公開鍵だけ で Lambda が行う
  • ハッシュ値まで書き換える攻撃も、署名で見抜ける ことを確かめた
  • デプロイ・テスト・片付けは、エミュレータでも本物でも同じスクリプト で動く
  • エミュレータの Lambda の中は本物の Node.js ではないので、最後は本物で確かめる必要がある

この記事の deploy.sh・test.sh・cleanup.sh は、接続先を本物の AWS に向ければ、そのまま本物でも使えるように作ってあります(本物に対して実行するときは、実行前に確認を求めます)。また、KMS は 耐量子暗号(ML-DSA)の署名 の鍵も作れますが、エミュレータ 1.0.338 では署名がまだ実装されていませんでした(NotImplemented)。

参考

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?