お断り: 本記事は AWS Lambda、Amazon API Gateway REST API、AWS WAF、AWS KMS、c2pa-python の公式ドキュメントとソースコードを筆者が読解して整理した、ハンズオン記事です。Terraform / AWS SDK / c2pa-python のバージョンによって挙動が変わる可能性があるので、実機で試す際は最新版のドキュメントとソースをご確認ください。記事に誤りや古くなった箇所を見つけられた場合は、記事末尾のフィードバック枠よりお知らせいただけると助かります。
はじめに
前回の記事では、c2patool の external signer 経由で AWS KMS に署名処理を委譲し、Root CA の秘密鍵まで KMS に閉じ込める構成をハンズオンしました。あれは「コマンドラインから 1 ファイルを署名する」という単発の話でした。
この記事では、その「KMS で署名する関数」を Lambda の中に持ち込み、ブラウザからファイルを投げ込めば C2PA 署名済みアセットが返ってきて、別タブから検証もできる、という End-to-End のミニ SaaS を AWS サーバーレスで組み立てます。署名鍵は KMS、コンピュートは Lambda、配信は CloudFront、メタデータは DynamoDB、IaC は Terraform。フロントエンドは Vite + React + shadcn/ui で組みます。
リポジトリは TechThanks/c2pa-aws-serverless-demo に公開してあります。本記事の手順とコードはすべてこのリポジトリと対応しています。
なお、AWS の基本的な概念(IAM、S3、Lambda、Terraform プロバイダ)と前回記事で扱った C2PA 署名の流れは前提として進めます。AWS の入門解説そのものや、署名アルゴリズムの細部は扱いません。
何を作るか
最終的な構成はこんな形です。
SPA + signed origin"] end subgraph WAFAPI["WAF v2 REGIONAL scope"] APIGW["API Gateway REST API"] end subgraph Compute["Lambda functions"] LU["Lambda upload
sync sign"] LV["Lambda verify"] LL["Lambda list-assets"] LD["Lambda delete-asset"] end subgraph Data["Storage"] S3FE[("S3 frontend")] S3S[("S3 signed")] S3C[("S3 certs")] DDB[("DynamoDB
assets metadata")] end subgraph KMS["KMS asymmetric keys P-256"] KSign["KMS leaf signer"] KCA["KMS Root CA"] end end User -->|"1. GET / over HTTPS"| CF CF -->|"page bundle"| S3FE User -->|"2. GET /signed/*"| CF CF -->|"/signed/* via OAC"| S3S User -->|"3. POST /uploads file body <=10MB"| APIGW APIGW --> LU LU -.->|"GetObject chain.pem"| S3C LU -->|"KMS.Sign ECDSA_SHA_256"| KSign LU -->|"PutObject signed"| S3S LU -->|"PutItem status=signed"| DDB User -->|"4. POST /verify file body"| APIGW APIGW --> LV LV -.->|"GetObject ca.pem"| S3C User -->|"5. GET /assets"| APIGW APIGW --> LL LL --> DDB User -->|"6. DELETE /assets/:id"| APIGW APIGW --> LD LD -->|"DeleteItem"| DDB LD -.->|"DeleteObject"| S3S KCA -. issues .-> KSign
ざっくり 6 種類のサービスが登場します。
| サービス | 役割 |
|---|---|
| CloudFront | SPA(フロントエンド)と署名済みアセットを配信。AWS WAF(CLOUDFRONT scope)で IP 制限 |
| API Gateway REST API | 4 つの API ルート(upload / verify / list / delete)を持つ。AWS WAF(REGIONAL scope)で IP 制限 |
| Lambda(4 個) | upload / verify / list-assets / delete-asset |
| S3(3 個) | frontend / signed / certs(検証用 ca.pem と署名用 chain.pem を配布) |
| DynamoDB | アセットのメタデータ(asset_id / 状態 / S3 キー / 署名時刻) |
| KMS | Root CA 鍵 + leaf 署名鍵(前回と同じ ECC_NIST_P256) |
全体フロー
立ち上げの手順は大きく 3 段です。
Terraform でインフラを立てる"] A1["KMS / S3 / DynamoDB
Lambda / API Gateway
CloudFront / WAF"] B["Step 2
証明書を発行して S3 に配布"] B1["ca.pem 自己署名 Root
kms-signer.pem EE
chain.pem"] C["Step 3
フロントエンドをビルドして S3 に sync"] C1["dist/ を S3 frontend に sync
CloudFront invalidate"] A --> B --> C A -. 作るもの .-> A1 B -. 作るもの .-> B1 C -. 作るもの .-> C1
各ステップは .mise.toml のタスクに紐付いていて、mise run tf-apply / mise run certs / mise run front-deploy で順に流すだけです。
実行手順
リポジトリは TechThanks/c2pa-aws-serverless-demo です。clone して mise を入れれば以下のコマンドだけで動かせます。各コマンドは .mise.toml の [tasks.xxx] セクションを呼んでいる薄いラッパーです。
0. SSO プロファイルで AWS にログイン
前回記事と同じ手順です。aws configure sso で c2pa プロファイルを作って、export AWS_PROFILE=c2pa してから aws sts get-caller-identity で想定アカウントが返ってくることを確認します。
1. ツール導入と依存関係
mise trust .
mise install # Terraform / Python / uv / Node を一気にインストール
mise run install # uv sync + npm install
.mise.toml で terraform 1.10 / python 3.12 / uv latest / node 22 を固定し、Python 側は pyproject.toml + uv.lock、フロントエンド側は frontend/package-lock.json から uv / npm がそれぞれ依存を入れます。
2. allowed_ips を設定する
terraform/terraform.tfvars.example を terraform/terraform.tfvars にコピーして、自分の IP を CIDR で書き込みます。
cp terraform/terraform.tfvars.example terraform/terraform.tfvars
# エディタで開いて allowed_ips に自分の IP を /32 で追記する
3. Lambda Layer をビルド
mise run layer-build
c2pa-python / cryptography / asn1crypto を manylinux wheel から uv pip install --python-platform x86_64-manylinux_2_28 で取り、zip にまとめて build/layer.zip を作成します。
4. Terraform でリソースを作成
mise run tf-init
mise run tf-apply
KMS / S3 / DynamoDB / Lambda / API Gateway / CloudFront / WAF を一気に作ります。WAF の CLOUDFRONT scope は us-east-1 専用ですが、AWS Provider v6 のリソース個別 region 指定で、プロバイダエイリアスを足さずに済ませています。
CloudFront の作成と、CloudFront WAF の関連付けで合計 3〜4 分くらいかかります。
5. 証明書を発行して certs バケットへ配布
mise run certs
前回記事の scripts/issue-certs.py をそのまま流用しています。Root CA(自己署名)と leaf 証明書を KMS で発行して、ca.pem / kms-signer.pem / chain.pem を certs バケットに aws s3 cp でアップロードします。
6. フロントエンドをビルドしてデプロイ
mise run front-build
mise run front-deploy
front-deploy は scripts/deploy-frontend.sh で、Terraform outputs から VITE_API_URL と VITE_CDN_URL を引っ張ってきて環境変数に渡し、npm run build → aws s3 sync → CloudFront invalidate を回します。
7. 動作確認
tf-apply 直後の outputs に CloudFront ドメイン(例: https://dXXXXXX.cloudfront.net)が出ているので、ブラウザで開きます。Upload タブで JPEG を投げると、asset_id が即返ってきて、Assets タブに status = signed で出ます。Verify タブに同じファイルを投げると validation_state: Trusted になります。
別の C2PA 署名済みファイル(他のシステムで署名されたもの)を Verify に投げると validation_state: Valid で帰ってきます。「署名は構造的に正しいけれど、私たちの Root CA は知らない」状態です。
8. 後片付け
mise run tf-destroy
CloudFront の削除に 3 分前後かかります。KMS 鍵は最低 7 日の PendingDeletion 期間が入る点は前回と同じです。
実装のポイント解説
upload Lambda は KMS にコールバックを渡す
lambda/upload/handler.py の中身は次の流れです。
- API Gateway から渡された
event.bodyを base64 デコードしてバイト列にする - certs バケットから
chain.pemを起動時に 1 度だけ取得(Lambda コンテナ単位でキャッシュ) Builder(manifest_json)で C2PA マニフェスト定義を作り、Signer.from_callback(...)に「KMS.Sign を呼ぶコールバック関数」を渡して署名する- 署名済みバイト列を signed バケットに
PutObject、メタデータを DynamoDB にPutItem
KMS 連携の心臓部はここです。
def _kms_sign_callback(data: bytes) -> bytes:
response = kms.sign(
KeyId=KMS_KEY_ID,
Message=data,
MessageType="RAW",
SigningAlgorithm="ECDSA_SHA_256",
)
r, s = decode_dss_signature(response["Signature"])
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
KMS の Sign API は ECDSA 署名を ASN.1 DER エンコード(SEQUENCE { r, s })で返してきます。一方 c2pa-python は raw r||s(P-256 なら 64 バイト)を期待するので、cryptography.hazmat.primitives.asymmetric.utils.decode_dss_signature で DER をパースして 32 バイトずつ big-endian に揃えています。前回記事の kms-signer.py と同じ変換です。
verify Lambda は trust anchor を起動時に S3 から読む
lambda/verify/handler.py は、リクエストごとに c2pa-python の Reader で検証を走らせるシンプルな関数です。1 点だけポイントがあって、Root CA(ca.pem)を c2pa-python の trust anchor として登録するのは、Lambda コンテナの起動時に 1 度だけ、load_settings(...) 越しに行います。
ca_pem = s3.get_object(Bucket=CERTS_BUCKET, Key=CA_KEY)["Body"].read().decode("utf-8")
load_settings(json.dumps({
"trust": {"trust_anchors": ca_pem, "verify_after_sign": False},
"verify": {"verify_trust": True},
}))
どこまでがこのデモのスコープか
意図的に省いている要素を明記しておきます。後続の記事で 1 つずつ掘り下げる予定です。
- 認証(Cognito / SSO)。今回は IP 制限のみ
- Manifest Repository
- leaf 証明書のローテーション運用 / TSA 連携(過去アセットを長期に Trusted で保つ仕組み)
- 動画 / Live Video(10MB 制限を超える領域)
- 動作パフォーマンス(スケール戦略・コールドスタート最適化など)
逆に「今回のデモで触れていること」は次のとおりです。
- 自前 KMS で Root CA + leaf 鍵を持ち、署名チェーンを
ca.pem/chain.pemとして配布 - API Gateway REST API + Lambda の最小構成で署名と検証を行う
- DynamoDB に asset_id ベースの最低限のメタデータを残す
- CloudFront distribution で SPA と署名済みアセットを配信
- AWS WAF v2 を CloudFront と REST API の両方に貼る IP 制限
おわりに
C2PA の署名・検証を「コマンドラインの単発デモ」から「ブラウザで触れるサービス」まで持ち上げました。鍵は KMS に閉じ込めたまま、ブラウザから 1 ファイルを投げるだけで署名済みアセットが返ってきて、別タブで検証もできる、という最小単位の SaaS が手に入った形です。
最小構成として実装してみると、組み合わせとしてはむしろシンプルで、改めて解説するべきことはあまり残りませんでした。一方で、この構成を起点に「実際のサービスとして育てる」段になると考えるべき論点が一気に増えます。leaf 証明書を期限切れまで放置するわけにはいかないので TSA を入れて長期検証に耐えるようにする、過去アセットの正当性を保つために Manifest Repository を別リソースに切り出す、IP 制限から Cognito ベースのマルチテナント認証に切り替える、動画や Live Video のように 10MB を超えるペイロードを扱うために非同期パイプラインを敷き直す。どれも独立した記事に値する深さがあるので、本シリーズで 1 本ずつ取り上げる予定です。
C2PA は仕様が大きい割に、署名と検証の本質は「鍵をどう持って、誰がそれをどう呼ぶか」だけです。今回の構成で「KMS + Lambda + API Gateway」の組み合わせ感が掴めれば、別のクラウド(GCP の Cloud KMS + Cloud Run、Azure の Key Vault + Functions)に置き換えるのも、AWS 内で SQS / Step Functions に分解するのも、設計を一段ずつ動かすだけの話になります。本記事がその「最初の足場」として読み手の手元に残ってくれたら嬉しいです。続編にもぜひ期待してください。