はじめに
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
{
"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 に記録します。
// 手元のパソコンで実行する「署名」スクリプト。
// 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)
// 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(クリックで開く)
# 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(クリックで開く)
#!/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 つを自動で確かめます。
- 手元で契約書に署名する
- Lambda でそのまま検証すると「正しい」
- 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(クリックで開く)
#!/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(クリックで開く)
#!/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)。