From 8e392649921612045167742584da18e3cdd8558c Mon Sep 17 00:00:00 2001
From: kanywst <45947799+kanywst@users.noreply.github.com>
Date: Sun, 8 Nov 2026 11:21:10 +0900
Subject: [PATCH 1/5] feat(signing): add a Signing chapter with a live
SigV4/SigV4a signer
Bring the WebCrypto SigV4 and SigV4a signer from caller-identity, with its
AWS test vectors, and build a Signing chapter on it: a signature lab for a
long-term key, the AssumeRole call itself and temporary credentials, a
presigned URL builder, SigV4a key derivation and a Bedrock API key bearer
call. Fold the wire-level facts from caller-identity's overlapping paths into
the EC2 and GitHub Actions paths and the trust chapter, move its research
notes to docs/signing/, and stop deferring signing to the sibling project.
---
README.ja.md | 13 +-
README.md | 13 +-
docs/README.md | 1 +
docs/signing/01-credential-map.ja.md | 101 +++++
docs/signing/01-credential-map.md | 107 +++++
docs/signing/02-sigv4.ja.md | 116 ++++++
docs/signing/02-sigv4.md | 124 ++++++
docs/signing/03-federation.ja.md | 147 +++++++
docs/signing/03-federation.md | 155 +++++++
docs/signing/04-variants.ja.md | 122 ++++++
docs/signing/04-variants.md | 128 ++++++
docs/signing/05-verification.ja.md | 104 +++++
docs/signing/05-verification.md | 115 ++++++
docs/signing/README.ja.md | 65 +++
docs/signing/README.md | 69 ++++
package-lock.json | 28 ++
package.json | 1 +
src/app/App.tsx | 5 +
src/data/nav.ts | 1 +
src/features/paths/paths.ts | 24 +-
src/features/signing/PresignLab.tsx | 129 ++++++
src/features/signing/SigV4aLab.tsx | 151 +++++++
src/features/signing/SignatureLab.tsx | 298 ++++++++++++++
src/features/signing/SigningSection.test.tsx | 59 +++
src/features/signing/SigningSection.tsx | 191 +++++++++
src/features/signing/Wire.tsx | 99 +++++
src/features/signing/demos.test.ts | 38 ++
src/features/signing/demos.ts | 382 +++++++++++++++++
src/features/signing/sigv4.test.ts | 412 +++++++++++++++++++
src/features/signing/sigv4.ts | 334 +++++++++++++++
src/features/signing/sigv4a.test.ts | 215 ++++++++++
src/features/signing/sigv4a.ts | 234 +++++++++++
src/features/trust/TrustSection.tsx | 6 +
src/features/why/WhySection.tsx | 12 +-
34 files changed, 3970 insertions(+), 29 deletions(-)
create mode 100644 docs/signing/01-credential-map.ja.md
create mode 100644 docs/signing/01-credential-map.md
create mode 100644 docs/signing/02-sigv4.ja.md
create mode 100644 docs/signing/02-sigv4.md
create mode 100644 docs/signing/03-federation.ja.md
create mode 100644 docs/signing/03-federation.md
create mode 100644 docs/signing/04-variants.ja.md
create mode 100644 docs/signing/04-variants.md
create mode 100644 docs/signing/05-verification.ja.md
create mode 100644 docs/signing/05-verification.md
create mode 100644 docs/signing/README.ja.md
create mode 100644 docs/signing/README.md
create mode 100644 src/features/signing/PresignLab.tsx
create mode 100644 src/features/signing/SigV4aLab.tsx
create mode 100644 src/features/signing/SignatureLab.tsx
create mode 100644 src/features/signing/SigningSection.test.tsx
create mode 100644 src/features/signing/SigningSection.tsx
create mode 100644 src/features/signing/Wire.tsx
create mode 100644 src/features/signing/demos.test.ts
create mode 100644 src/features/signing/demos.ts
create mode 100644 src/features/signing/sigv4.test.ts
create mode 100644 src/features/signing/sigv4.ts
create mode 100644 src/features/signing/sigv4a.test.ts
create mode 100644 src/features/signing/sigv4a.ts
diff --git a/README.ja.md b/README.ja.md
index 92c251a..b376c09 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -18,15 +18,16 @@ AWS STS を図解するサイトです。英語と日本語に対応したイン
| 2 | エンドポイント | `sts.amazonaws.com` やリージョナルのホスト名を呼んだとき、どこで処理され、どこに記録され、どの枠でスロットリングされるかを 2025 年 4 月の変更前後で比べるシミュレーター。全パーティションのホスト名ビルダー、SDK の既定値エクスプローラー、商用 34 リージョンの一覧ボード |
| 3 | 全 API | 「何を持っているか」から 11 個の操作を引くピッカー、CLI 例付きの詳細、期間ルーラー、比較表、アクセスキーのプレフィックス判定 |
| 4 | 取得経路 | EC2、Lambda、ECS、IRSA、Pod Identity、Roles Anywhere、GitHub Actions、Identity Center、`aws login`、SAML、Cognito、AssumeRoot、アウトバウンドフェデレーションのシーケンス図をステップ実行。認証情報プロバイダーチェーンのラボ |
-| 5 | セッション | 実効権限のゲート、ロールチェーンのビルダー (1 時間上限、推移タグ、ソースアイデンティティ)、セッショントークンのサイズメーター、セッション失効のタイムライン |
-| 6 | 信頼ポリシー | 2 つのゲートのラボ、GitHub OIDC の信頼ポリシー評価、混乱した代理問題のウォークスルー、条件キーのカタログ |
-| 7 | ログと上限 | 注釈付き CloudTrail レコード、600 RPS クォータのラボ、脅威カードと強化チェックリスト |
-| 8 | エラー | エラーメッセージを貼ると原因と対処を表示 |
-| 9–10 | 年表、用語集 | 2011〜2026 年の STS の歴史、EN/JA の用語 43 個 |
+| 5 | 署名 | 本物の SigV4 署名処理 (長期キー、AssumeRole の呼び出しそのもの、一時認証情報) で正規リクエスト・署名対象文字列・キー導出・署名を表示し、値を書き換えて壊せるラボ。presigned URL のビルダー、SigV4a の鍵ペア導出と複数リージョン署名、Bedrock API キーの bearer 呼び出し |
+| 6 | セッション | 実効権限のゲート、ロールチェーンのビルダー (1 時間上限、推移タグ、ソースアイデンティティ)、セッショントークンのサイズメーター、セッション失効のタイムライン |
+| 7 | 信頼ポリシー | 2 つのゲートのラボ、GitHub OIDC の信頼ポリシー評価、混乱した代理問題のウォークスルー、条件キーのカタログ |
+| 8 | ログと上限 | 注釈付き CloudTrail レコード、600 RPS クォータのラボ、脅威カードと強化チェックリスト |
+| 9 | エラー | エラーメッセージを貼ると原因と対処を表示 |
+| 10–11 | 年表、用語集 | 2011〜2026 年の STS の歴史、EN/JA の用語 43 個 |
数値の裏付けは [`docs/`](docs/README.md) に出典付きでまとめています。2026-10-10 時点の AWS ドキュメント、What's New、AWS SDK のエンドポイントデータ、GitHub の changelog で確認しました。AWS の情報源同士で食い違う箇所は、その旨を書いています。
-SigV4 署名そのものは姉妹プロジェクト [caller-identity](https://github.com/0-draft/caller-identity) で図解しています。こちらは STS が主役です。
+署名の章では、本物の SigV4 / SigV4a 実装 (WebCrypto と `@noble/curves`) をブラウザ内で動かします。テストでは AWS が公開している署名の計算例とバイト単位で照合しています。その確かめ方と、途中で見つけた AWS ドキュメントの誤りは [`docs/signing/`](docs/signing/README.ja.md) にあります。この章とノートは、2026-11-08 に統合した旧 `caller-identity` プロジェクトから移したものです。
## 開発
diff --git a/README.md b/README.md
index d9f29db..2a65178 100644
--- a/README.md
+++ b/README.md
@@ -18,15 +18,16 @@ AWS STS, drawn. An interactive, bilingual (English / 日本語) explainer of the
| 2 | Endpoints | Request router: where a call to `sts.amazonaws.com` or a regional hostname is served, logged and throttled, before and after the April 2025 change; hostname builder for every partition and family; SDK default explorer; board of all 34 commercial Regions |
| 3 | Every API | "What are you holding?" picker over all 11 operations, detail passes with CLI examples, duration ruler, comparison matrix, access-key prefix decoder |
| 4 | Credential paths | Step-through sequence diagrams for EC2, Lambda, ECS, IRSA, Pod Identity, Roles Anywhere, GitHub Actions, Identity Center, `aws login`, SAML, Cognito, AssumeRoot, outbound federation; credential provider chain lab |
-| 5 | Sessions | Effective-permissions gates, role-chaining builder (1 h cap, transitive tags, source identity), session-token size meter, revoke-sessions timeline |
-| 6 | Trust policies | Two-gates lab, GitHub OIDC trust policy evaluator, confused-deputy walkthrough, condition-key catalogue |
-| 7 | Logs & limits | Annotated CloudTrail records, 600 RPS quota lab, threat cards and hardening checklist |
-| 8 | Errors | Paste an error message and get the cause and the fix |
-| 9–10 | Timeline, glossary | STS history 2011–2026, 43 terms in EN/JA |
+| 5 | Signing | Live SigV4 signer (long-term key, the AssumeRole call itself, temporary credentials) showing the canonical request, string to sign, key derivation and signature, with a "break it" panel; presigned URL builder; SigV4a keypair derivation and multi-Region signature; Bedrock API key bearer call |
+| 6 | Sessions | Effective-permissions gates, role-chaining builder (1 h cap, transitive tags, source identity), session-token size meter, revoke-sessions timeline |
+| 7 | Trust policies | Two-gates lab, GitHub OIDC trust policy evaluator, confused-deputy walkthrough, condition-key catalogue |
+| 8 | Logs & limits | Annotated CloudTrail records, 600 RPS quota lab, threat cards and hardening checklist |
+| 9 | Errors | Paste an error message and get the cause and the fix |
+| 10–11 | Timeline, glossary | STS history 2011–2026, 43 terms in EN/JA |
The research behind every number lives in [`docs/`](docs/README.md), with sources. Facts were checked against AWS documentation, What's New posts, the AWS SDK endpoint data and the GitHub changelog on 2026-10-10. Where AWS sources disagree, the docs say so.
-The SigV4 signature itself is drawn step by step in the sibling project [caller-identity](https://github.com/0-draft/caller-identity); this one is about STS.
+The Signing chapter runs a real SigV4 and SigV4a implementation in the browser (WebCrypto plus `@noble/curves`). Its tests assert it byte for byte against the signature examples AWS publishes; how that was checked, and an error found in the AWS documentation along the way, is in [`docs/signing/`](docs/signing/README.md). That chapter and its notes came from the former `caller-identity` project, merged in on 2026-11-08.
## Development
diff --git a/docs/README.md b/docs/README.md
index 66f186e..a4c9720 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -11,5 +11,6 @@ The research behind the site, one topic per file. Every non-obvious claim links
| [05-credential-paths.md](05-credential-paths.md) | How EC2, Lambda, ECS, EKS, Roles Anywhere, CI/CD OIDC, Identity Center, `aws login`, SAML, brokers, Cognito, AssumeRoot and outbound federation get credentials; SDK provider chains |
| [06-operations-and-security.md](06-operations-and-security.md) | CloudTrail, quotas and throttling, pricing, error catalogue, threats, best practices |
| [07-timeline.md](07-timeline.md) | STS history from 2011 to 2026 |
+| [signing/](signing/README.md) | SigV4 in full, presigned URLs and SigV4a, the credential map, federation notes, and how the signer was verified against AWS's published examples (EN and JA) |
Where official AWS sources contradict each other (for example the SDK defaults table versus the 2025-07-31 release announcements), the notes say which source was followed and why.
diff --git a/docs/signing/01-credential-map.ja.md b/docs/signing/01-credential-map.ja.md
new file mode 100644
index 0000000..2c6efab
--- /dev/null
+++ b/docs/signing/01-credential-map.ja.md
@@ -0,0 +1,101 @@
+[English](01-credential-map.md) | **日本語**
+
+# クレデンシャルの地図
+
+[ノートに戻る](README.ja.md)
+
+## 何がクレデンシャルなのか
+
+AWS の API を呼ぶという目的に限れば、形は2つだけ。それに最近増えた、どちらにも当てはまらない3つ目
+が加わる。
+
+| 形 | 構成要素 | 発行元 | 期限 |
+| --- | --- | --- | --- |
+| 長期キー | `AKIA...` + secret | `iam:CreateAccessKey` | 無し |
+| 一時クレデンシャル | `ASIA...` + secret + session token | STS | 必ずある |
+| bearer token | 不透明な文字列1本 | Bedrock, CloudWatch | 種類による |
+
+長期キーは、期限を持たない唯一の AWS API クレデンシャル。このページに書く残り全部は、その1点から
+派生している。
+
+## 入り口の問題
+
+STS も普通の AWS サービスなので、呼ぶには署名が要り、署名にはクレデンシャルが要る。これは循環して
+いて、フェデレーションの仕組みは全部この循環を断つために存在する。
+
+```mermaid
+flowchart LR
+ akia["AKIA... 長期"] -->|"SigV4 署名付き
+ {t({
+ en: "604800 seconds is seven days, the ceiling S3 allows, because the signing key itself is only valid that long.",
+ ja: "604800 秒は 7 日で、S3 が認める上限です。署名キー自体がその期間しか有効でないためです。",
+ })}
+
+ {t({
+ en: "Nothing else is needed to use it: no header, no SDK, no key. Whoever holds this string is the caller until it expires, which is why a presigned URL in a log or a chat message is a credential leak.",
+ ja: "使うのに他は何も要りません。ヘッダーも SDK もキーも不要です。この文字列を持っている人が期限まで呼び出し元になります。ログやチャットに貼られた presigned URL がそのまま認証情報の漏洩になるのはそのためです。",
+ })}
+
+ {rich(
+ t({
+ en: "The last line is the literal `UNSIGNED-PAYLOAD`, because at signing time nobody knows what will be uploaded. A presigned PUT therefore pins the destination, not the bytes.",
+ ja: "最終行は文字列 `UNSIGNED-PAYLOAD` そのものです。署名の時点では何がアップロードされるか分からないからです。つまり presigned な PUT が保証するのは宛先であって、中身ではありません。",
+ }),
+ )}
+
+ {rich(
+ t({
+ en: "With temporary credentials the session token goes into the query string as `X-Amz-Security-Token` and is signed there, not as a header.",
+ ja: "一時認証情報の場合、セッショントークンはヘッダーではなくクエリ文字列の `X-Amz-Security-Token` に入り、そこで署名対象になります。",
+ }),
+ )}
+
+ {rich(
+ t({
+ en: "No Region. SigV4 would read `20260919/us-east-1/s3/aws4_request` here; SigV4a states the Regions in the `X-Amz-Region-Set` header instead, which is itself signed.",
+ ja: "リージョンがありません。SigV4 ならここは `20260919/us-east-1/s3/aws4_request` になります。SigV4a ではリージョンを `X-Amz-Region-Set` ヘッダーで示し、そのヘッダー自体が署名対象になります。",
+ }),
+ )}
+
+ {t({
+ en: "The secret key is run through a counter-mode KDF until the result lands inside the P-256 group order. A 256-bit value can exceed the order, so the counter is not decorative, though in practice the first candidate works. No new credential is issued: AWS derives the same public key on its side when it checks a signature.",
+ ja: "シークレットキーを counter モードの KDF に通し、結果が P-256 の位数に収まるまで繰り返します。256 ビットの値は位数を超えることがあるので counter は飾りではありませんが、実際には最初の候補で通ります。新しい認証情報は発行されません。AWS は署名を検証するとき、自分の側で同じ公開鍵を導出します。",
+ })}
+
+ {t({
+ en: "Reproducible here because this implementation uses the RFC 6979 deterministic nonce. AWS SDKs sign through libcrypto and produce different bytes for the same request. Both verify; only one is reproducible.",
+ ja: "この実装は RFC 6979 の決定的 nonce を使うので、毎回同じ結果になります。AWS SDK は libcrypto 経由で署名するため、同じリクエストでも毎回バイト列が変わります。どちらも検証は通り、再現できるのは片方だけです。",
+ })}
+ {rich(t(note))} {rich(t(n.detail))} {rich(t(demo.blurb))}
+ {t({
+ en: "Date, Region and service are baked into the key. A leaked signing key works for that day, that Region and that service, and nothing else.",
+ ja: "日付・リージョン・サービスがキーに焼き込まれます。署名キーが漏れても、その日・そのリージョン・そのサービスにしか使えません。",
+ })}
+
+ {{
+ en: "Every credential passes through four stages: **issue** (IAM or STS hands it out), **sign** (your code turns it into a signature), **verify** (the service recomputes the signature) and **authorize** (only then are policies evaluated). The unsigned `AssumeRoleWithWebIdentity` and `AssumeRoleWithSAML` calls never reach the sign stage: the token in the body is the proof. A bearer token skips issue and sign both.",
+ ja: "認証情報は 4 つの段階を通ります。**発行** (IAM か STS が渡す)、**署名** (自分のコードが署名に変える)、**検証** (サービスが署名を計算し直す)、**認可** (ここで初めてポリシーが評価される) です。署名のない `AssumeRoleWithWebIdentity` と `AssumeRoleWithSAML` は署名の段階を通りません。ボディのトークンが証明そのものです。bearer トークンは発行と署名の両方を飛ばします。",
+ }}
+
+ {{
+ en: "A long-term key is the only AWS credential with no expiration. IAM stores the secret so it can recompute signatures later, and shows it to you exactly once. An IAM user can hold two access keys at a time, Active or Inactive; asking for a third returns `LimitExceeded`. Two is what makes rotation possible: create the new one, move traffic, delete the old one.",
+ ja: "長期キーは有効期限のない唯一の AWS 認証情報です。IAM は後で署名を再計算するためにシークレットを保管し、利用者には一度だけ表示します。IAM ユーザーが同時に持てるアクセスキーは Active・Inactive を問わず 2 本までで、3 本目を求めると `LimitExceeded` が返ります。2 本あるからローテーションが成り立ちます。新しいキーを作り、トラフィックを移し、古いキーを消します。",
+ }}
+
+ {{
+ en: "A **presigned URL** is the same signature moved out of the headers and into the query string. That turns it into a bearer token: whoever holds the URL can make that one request until it expires, with no key and no IAM identity of their own.",
+ ja: "**presigned URL** は、同じ署名をヘッダーからクエリ文字列に移したものです。その結果、bearer トークンになります。URL を持っている人は、キーも自分の IAM ID もなしに、期限までその 1 つのリクエストを実行できます。",
+ }}
+
+ {{
+ en: "A SigV4 signing key is scoped to one Region by construction. **SigV4a** moves that scoping elsewhere so one signature can be valid in several Regions, which is what S3 Multi-Region Access Points need; SDKs and the CLI switch to it automatically for them. It signs with an ECDSA P-256 key derived from the same secret, so verifying needs only the public half, and that can be replicated freely. SigV4 verification needs the shared secret, so every Region that verifies must hold it.",
+ ja: "SigV4 の署名キーは構造上 1 つのリージョンに固定されます。**SigV4a** はその固定を別の場所に移し、1 つの署名を複数のリージョンで有効にします。S3 マルチリージョンアクセスポイントに必要な仕組みで、SDK と CLI はその宛先に対して自動で切り替えます。同じシークレットから導出した ECDSA P-256 鍵で署名するので、検証には公開鍵だけあればよく、それは自由に複製できます。SigV4 の検証には共有シークレットが要るので、検証するリージョンはすべてそれを持つ必要があります。",
+ }}
+
+ {{
+ en: "**Amazon Bedrock API keys** are the one AWS path that skips SigV4 entirely: one `Authorization: Bearer` header, no canonical request, no signing key, no SDK required. They exist for tools that expect an API key and cannot be taught to sign. Short-term keys last up to 12 hours (or the session, if shorter) and work only in the Region they were generated in; long-term keys last until a configured expiry and are tied to an IAM user.",
+ ja: "**Amazon Bedrock の API キー** は、SigV4 を丸ごと飛ばす AWS で唯一の経路です。`Authorization: Bearer` ヘッダー 1 つだけで、正規リクエストも署名キーも SDK も要りません。API キーを前提にしていて署名を組み込めないツールのためにあります。短期キーは最長 12 時間 (セッションの方が短ければそれまで) で、生成したリージョンでしか使えません。長期キーは設定した期限まで有効で、IAM ユーザーに紐づきます。",
+ }}
+
+ {{
+ en: "How the signer is checked: CI asserts it byte for byte against two worked examples AWS publishes, the Glacier Create Vault signature (canonical request, string to sign and signature) and the S3 presigned `test.txt` URL. SigV4a has no AWS-published vector, so its derived keys are cross-checked against an independent implementation of the published pseudocode instead, which is weaker evidence. One of the Glacier page's own examples does not reproduce; the notes record how that was ruled out.",
+ ja: "署名処理の確かめ方: CI で、AWS が公開している 2 つの計算例とバイト単位で照合しています。Glacier の Create Vault の署名 (正規リクエスト・署名対象文字列・署名) と、S3 の presigned `test.txt` の URL です。SigV4a には AWS 公開のテストベクタがないので、公開された疑似コードを別に実装したものと導出結果を突き合わせています。こちらは証拠としては弱めです。Glacier のページにある別の例は再現できず、その切り分けの経緯は調査メモに残しています。",
+ }}
+
+
+ {rich(t(picked.note))}
+
sts:AssumeRole"| asia["ASIA... 一時"]
+ asia -->|"SigV4 + session token"| api["AWS API"]
+
+ oidc["OIDC ID token"] -->|"未署名
AssumeRoleWithWebIdentity"| asia
+ saml["SAML assertion"] -->|"未署名
AssumeRoleWithSAML"| asia
+ x509["X.509 証明書"] -->|"rolesanywhere:CreateSession"| asia
+ meta["IMDS / ECS エンドポイント"] -->|"素の HTTP、リンクローカル"| asia
+
+ style akia stroke:#ff6b6b
+ style oidc stroke:#7ee787
+ style saml stroke:#7ee787
+ style meta stroke:#7ee787
+```
+
+`AssumeRoleWithWebIdentity` と `AssumeRoleWithSAML` は、AWS クレデンシャルを一切持たずに呼べる唯一の
+STS API。未署名の HTTP リクエストで、ボディに「他人が署名した assertion」を載せる。仕掛けはそれだけ
+で、CI パイプラインに `AKIA` が1本も要らなくなる理由もこれ。
+
+IMDS は別のやり方で循環を断つ。プラットフォームが既に AssumeRole を済ませ、その結果をリンクローカル
+アドレスに置いている。
+
+## 各経路の役割
+
+- **長期キー。** 基準線であり、消す対象。IAM user が同時に持てるキーは Active/Inactive を問わず2本
+ まで。3本目は `LimitExceeded`。2本あることがローテーションを成立させている。作る、切り替える、
+ 消す。
+- **AssumeRole。** クロスアカウントと権限分離。既にクレデンシャルが必要なので、入り口ではなく連鎖の
+ 途中の1ステップ。
+- **OIDC フェデレーション。** CI から長期キーを消す。仕事をしているのは trust policy で、そこを
+ 間違える話は [フェデレーション](03-federation.ja.md) で扱う。
+- **IMDS。** EC2 や Lambda のディスクに何も置かなくてよい理由。IMDSv2 の `PUT` は趣味ではない。SSRF
+ は脆弱なアプリから `GET` を引き出せても、カスタムヘッダ付きの `PUT` はまず引き出せない。
+- **bearer token。** API キーを期待していて署名を教え込めないツール向け。AWS 自身の案内も「STS が
+ 不可能な場合に限れ」。
+
+## 3つの層
+
+混乱のほとんどは、SigV4 と STS をセットだと思うところから来る。セットではなく、別の層にいる。
+
+```mermaid
+flowchart TD
+ L1["第1層: クレデンシャルの出所
IAM · STS · IMDS · 外部 IdP"]
+ L2["第2層: リクエストの認証方法
SigV4 · SigV4a · bearer"]
+ L3["第3層: 呼び出し元に何が許されるか
identity policy · resource policy · SCP · session policy"]
+
+ L1 --> L2 --> L3
+```
+
+長期キーも一時クレデンシャルも、同じアルゴリズムの SigV4 で署名する。ワイヤー上の違いは
+`X-Amz-Security-Token` ヘッダが増えることだけで、そのヘッダは `SignedHeaders` にも入れる必要がある。
+自前実装で `SignatureDoesNotMatch` が出る最頻出の原因がこの入れ忘れ。
+
+## エラーの読み方
+
+層に順番があるので、エラーがどの層まで到達したかを教えてくれる。
+
+| エラー | 到達した層 | 意味 |
+| --- | --- | --- |
+| `InvalidClientTokenId` | 2 | `ASIA...` を session token 無しで送った |
+| `SignatureDoesNotMatch` | 2 | 署名を再計算したら違った |
+| `RequestTimeTooSkewed` | 2 | タイムスタンプが許容窓の外 |
+| `ExpiredToken` | 2 | `Expiration` を過ぎている |
+| `AccessDenied` | 3 | 署名は通り、ポリシーが拒否した |
+
+`AccessDenied` は第2層については良い知らせ。クレデンシャルが問題ならそこまで到達していない。
+
+## API 用ではないクレデンシャルもある
+
+ここまでは全部「AWS の API を呼ぶ」話。データプレーンに対する認証は別の一族で、上のルールに従わない。
+RDS のマスターパスワード、IAM database authentication、Redshift の `GetClusterCredentials`、secret key
+から導出する SES の SMTP credential、CodeCommit と Keyspaces の service-specific credential、IoT の
+X.509 クライアント証明書、MSK の SASL/SCRAM secret、Cognito の user pool token、API Gateway の API
+キー、CloudFront の signed URL。ここでは扱わないが、「クレデンシャルなら上の3つの形のどれかだ」と
+結論する前に、存在だけは知っておく価値がある。
diff --git a/docs/signing/01-credential-map.md b/docs/signing/01-credential-map.md
new file mode 100644
index 0000000..fb7ec99
--- /dev/null
+++ b/docs/signing/01-credential-map.md
@@ -0,0 +1,107 @@
+**English** | [日本語](01-credential-map.ja.md)
+
+# The credential map
+
+[Back to notes](README.md)
+
+## What counts as a credential
+
+For calling an AWS API there are exactly two shapes, plus a third that arrived recently
+and does not fit either.
+
+| Shape | Components | Issued by | Expires |
+| --- | --- | --- | --- |
+| Long-term key | `AKIA...` + secret | `iam:CreateAccessKey` | Never |
+| Temporary credential | `ASIA...` + secret + session token | STS | Yes, always |
+| Bearer token | One opaque string | Bedrock, CloudWatch | Depends |
+
+The long-term key is the only AWS API credential with no expiry. That single property is
+the reason for everything else on this page.
+
+## The entry problem
+
+STS is an ordinary AWS service, so calling it requires a signature, which requires a
+credential. That is circular, and every federation mechanism exists to break the circle.
+
+```mermaid
+flowchart LR
+ akia["AKIA... long-term"] -->|"SigV4-signed
sts:AssumeRole"| asia["ASIA... temporary"]
+ asia -->|"SigV4 + session token"| api["AWS API"]
+
+ oidc["OIDC ID token"] -->|"unsigned
AssumeRoleWithWebIdentity"| asia
+ saml["SAML assertion"] -->|"unsigned
AssumeRoleWithSAML"| asia
+ x509["X.509 certificate"] -->|"rolesanywhere:CreateSession"| asia
+ meta["IMDS / ECS endpoint"] -->|"plain HTTP, link-local"| asia
+
+ style akia stroke:#ff6b6b
+ style oidc stroke:#7ee787
+ style saml stroke:#7ee787
+ style meta stroke:#7ee787
+```
+
+`AssumeRoleWithWebIdentity` and `AssumeRoleWithSAML` are the only STS APIs callable with
+no AWS credential at all. They take an unsigned HTTP request whose body carries an
+assertion signed by somebody else. That is the whole trick, and it is why a CI pipeline
+needs no `AKIA` anywhere.
+
+IMDS breaks the circle differently: the platform has already done the AssumeRole and
+leaves the result on a link-local address.
+
+## What each path is actually for
+
+- **Long-term key.** The baseline, and the thing to remove. An IAM user may hold two keys
+ at a time, Active or Inactive, and a third request returns `LimitExceeded`. Two is what
+ makes rotation possible at all: create, cut over, delete.
+- **AssumeRole.** Cross-account access and privilege separation. Needs credentials
+ already, so it is a step in a chain rather than an entry point.
+- **OIDC federation.** Ends long-term keys in CI. The trust policy does the work, and
+ getting it wrong is the subject of [Federation](03-federation.md).
+- **IMDS.** Why an EC2 instance or a Lambda needs nothing on disk. The `PUT` in IMDSv2 is
+ not stylistic: server-side request forgery can usually coerce a `GET` out of a
+ vulnerable application, rarely a `PUT` carrying a custom header.
+- **Bearer tokens.** For tools that expect an API key and cannot be taught to sign. AWS's
+ own guidance is to use them only where STS is impossible.
+
+## The three layers
+
+Most confusion comes from treating SigV4 and STS as a pair. They are not; they sit in
+different layers.
+
+```mermaid
+flowchart TD
+ L1["Layer 1: where the credential comes from
IAM · STS · IMDS · external IdP"]
+ L2["Layer 2: how the request is authenticated
SigV4 · SigV4a · bearer"]
+ L3["Layer 3: what the caller may do
identity policy · resource policy · SCP · session policy"]
+
+ L1 --> L2 --> L3
+```
+
+A long-term key and a temporary credential both sign with SigV4, using the identical
+algorithm. The only difference on the wire is the extra `X-Amz-Security-Token` header,
+which must also appear in `SignedHeaders`. Omitting it is the most common cause of
+`SignatureDoesNotMatch` in hand-rolled clients.
+
+## Reading the error
+
+Because the layers are ordered, the error names the layer.
+
+| Error | Layer reached | What it means |
+| --- | --- | --- |
+| `InvalidClientTokenId` | 2 | An `ASIA...` key sent without its session token |
+| `SignatureDoesNotMatch` | 2 | Signature recomputed and differed |
+| `RequestTimeTooSkewed` | 2 | Timestamp outside the accepted window |
+| `ExpiredToken` | 2 | Past `Expiration` |
+| `AccessDenied` | 3 | Signature verified, policy said no |
+
+An `AccessDenied` is good news about layer 2. If credentials were the problem you would
+never have got that far.
+
+## Not all credentials are for the API
+
+Everything above is about calling AWS APIs. A separate family authenticates to data
+planes and follows none of these rules: RDS master passwords, IAM database
+authentication, Redshift `GetClusterCredentials`, SES SMTP credentials derived from the
+secret key, CodeCommit and Keyspaces service-specific credentials, IoT X.509 client
+certificates, MSK SASL/SCRAM secrets, Cognito user-pool tokens, API Gateway API keys,
+CloudFront signed URLs. They are out of scope here, but worth knowing they exist before
+concluding that a credential must be one of the three shapes above.
diff --git a/docs/signing/02-sigv4.ja.md b/docs/signing/02-sigv4.ja.md
new file mode 100644
index 0000000..305c096
--- /dev/null
+++ b/docs/signing/02-sigv4.ja.md
@@ -0,0 +1,116 @@
+[English](02-sigv4.md) | **日本語**
+
+# SigV4
+
+[ノートに戻る](README.ja.md)
+
+## 何であって何でないか
+
+SigV4 は署名の層。STS と紐づいていないし、クレデンシャルの出所によって変わることもない。長期の
+`AKIA` キーでも一時的な `ASIA` クレデンシャルでも、走るアルゴリズムは同一で、一時の場合にヘッダが
+1つ増えるだけ。
+
+対称鍵方式。両側が同じ secret を持ち、両側が同じ HMAC を計算する。secret 自体は一度も移動しない。
+
+```mermaid
+sequenceDiagram
+ participant C as 自分のプロセス
+ participant S as AWS サービス
+
+ Note over C: secret access key はここから出ない
+ C->>C: canonical request を組む
+ C->>C: ハッシュを取る
+ C->>C: secret から kSigning を導出
+ C->>C: HMAC(kSigning, string to sign)
+ C->>S: リクエスト + Authorization: ...Signature=abc123
+ Note over S: secret はワイヤーを渡っていない
+ S->>S: canonical request を組み立て直す
+ S->>S: key id から secret を引く
+ S->>S: 再計算する
+ alt 署名が一致
+ S->>S: principal 確定、ポリシー評価へ
+ S-->>C: 200 または AccessDenied
+ else 不一致
+ S-->>C: SignatureDoesNotMatch
+ end
+```
+
+## ワイヤーに乗るもの
+
+一時クレデンシャルの3要素のうち、2つは移動し、1つは移動しない。
+
+| 要素 | リクエストに乗るか | どこに |
+| --- | --- | --- |
+| `AccessKeyId` | 乗る | `Credential=ASIA.../20260919/us-east-1/s3/aws4_request` |
+| `SessionToken` | 乗る | `X-Amz-Security-Token` ヘッダ |
+| `SecretAccessKey` | **絶対に乗らない** | HMAC の入力としてローカルで使うだけ |
+
+この非対称性が、署名と bearer token の違いそのもの。署名済みリクエストを傍受しても手に入るのは、
+既に使い終わったその1本だけ。bearer token を傍受すると、クレデンシャルそのものが手に入る。
+
+## canonical request
+
+両側がこの文字列を組み立てる。1バイトでも違えば一致しない。
+
+```text
+GET
+/key.txt
+
+host:my-bucket.s3.us-east-1.amazonaws.com
+x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+x-amz-date:20260919T120000Z
+x-amz-security-token:IQoJb3JpZ2luX2Vj...
+
+host;x-amz-content-sha256;x-amz-date;x-amz-security-token
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+```
+
+上から、メソッド、パス、クエリ文字列、canonical headers、署名対象ヘッダ名の一覧、ペイロードハッシュ。
+
+自前実装が失敗する原因はだいたい次の3つ。
+
+1. **URI エンコードが `encodeURIComponent` より厳しい。** unreserved は `A-Za-z0-9-_.~` だけ。
+ `encodeURIComponent` は `!*'()` を残すが AWS は残さない。自分で書くこと。
+2. **S3 はパスを二重エンコードしない。** 他のサービスは canonicalize の際にパスをもう一度 URI
+ エンコードする。これを逆にすると、予約文字を含むパスのときだけ不一致になる。つまりテストでは通り、
+ 本番で落ちる。
+3. **`x-amz-security-token` を `SignedHeaders` に入れる。** ヘッダだけ送って一覧に入れないと、両側が
+ 別の文字列を canonicalize することになる。
+
+## signing key の連鎖
+
+```mermaid
+flowchart LR
+ secret["AWS4 + secret"] -->|HMAC 日付| kDate
+ kDate -->|HMAC リージョン| kRegion
+ kRegion -->|HMAC サービス| kService
+ kService -->|HMAC aws4_request| kSigning
+ kSigning -->|HMAC string-to-sign| sig["Signature"]
+```
+
+日付・リージョン・サービスは、鍵の横に併記されるのではなく鍵に焼き込まれる。`kSigning` が漏れても、
+その日・そのリージョン・そのサービスにしか使えない。意図的な被害範囲の制御であり、credential scope が
+2箇所に出てくる理由でもある。鍵の中に1回、そしてサーバがどの鍵を導出すべきか知るために `Credential=`
+パラメータに1回。
+
+## 署名が守る範囲
+
+canonical request に入っているもの全部。思っているより広い。
+
+- メソッド、パス、クエリ文字列
+- `SignedHeaders` に挙げた全ヘッダ。session token も含む
+- **ボディ**。SHA-256 経由で。1バイト変えれば署名は無効になる
+
+つまり SigV4 は認証と完全性を同時に提供する。機密性は提供しない。それは TLS の仕事。
+
+タイムスタンプがリプレイを限定する。S3 は自分の時計から15分以上ずれたリクエストを
+`RequestTimeTooSkewed` で拒否し、AWS の一般的な案内はさらに厳しく、多くの場合5分以内に到達する必要が
+ある。
+
+## 実装の検証方法
+
+AWS は期待値付きの計算例を公開している。このリポジトリのテストが使っているのは Amazon Glacier の
+Create Vault の例で、公開されたアクセスキー、secret、タイムスタンプ、ヘッダに対して、canonical
+request、string to sign、最終的な署名が全て明記されている。3つとも再現できれば実装は正しい。
+
+同じページにもう1つ例があるが、そちらの署名値は再現できない。[検証](05-verification.ja.md) を参照。
diff --git a/docs/signing/02-sigv4.md b/docs/signing/02-sigv4.md
new file mode 100644
index 0000000..abb7e94
--- /dev/null
+++ b/docs/signing/02-sigv4.md
@@ -0,0 +1,124 @@
+**English** | [日本語](02-sigv4.ja.md)
+
+# SigV4
+
+[Back to notes](README.md)
+
+## What it is and is not
+
+SigV4 is the signing layer. It is not tied to STS, and it does not change depending on
+where the credential came from. A long-term `AKIA` key and a temporary `ASIA` credential
+run the identical algorithm; the temporary case adds one header.
+
+It is a symmetric scheme. Both sides hold the same secret and both compute the same HMAC.
+The secret itself never travels.
+
+```mermaid
+sequenceDiagram
+ participant C as Your process
+ participant S as AWS service
+
+ Note over C: secret access key stays here
+ C->>C: build canonical request
+ C->>C: hash it
+ C->>C: derive kSigning from secret
+ C->>C: HMAC(kSigning, string to sign)
+ C->>S: request + Authorization: ...Signature=abc123
+ Note over S: secret never crossed the wire
+ S->>S: rebuild the canonical request
+ S->>S: look up the secret for that key id
+ S->>S: recompute
+ alt signatures match
+ S->>S: principal established, evaluate policy
+ S-->>C: 200 or AccessDenied
+ else differ
+ S-->>C: SignatureDoesNotMatch
+ end
+```
+
+## What goes on the wire
+
+Of the three components of a temporary credential, two travel and one does not.
+
+| Component | On the request? | Where |
+| --- | --- | --- |
+| `AccessKeyId` | Yes | `Credential=ASIA.../20260919/us-east-1/s3/aws4_request` |
+| `SessionToken` | Yes | `X-Amz-Security-Token` header |
+| `SecretAccessKey` | **Never** | Used locally as HMAC input only |
+
+That asymmetry is the difference between a signature and a bearer token. Intercepting a
+signed request gives you that one request, already spent. Intercepting a bearer token
+gives you the credential.
+
+## The canonical request
+
+Both sides build this string. It has to match byte for byte.
+
+```text
+GET
+/key.txt
+
+host:my-bucket.s3.us-east-1.amazonaws.com
+x-amz-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+x-amz-date:20260919T120000Z
+x-amz-security-token:IQoJb3JpZ2luX2Vj...
+
+host;x-amz-content-sha256;x-amz-date;x-amz-security-token
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+```
+
+Line by line: method, path, query string, canonical headers, the list of signed header
+names, and the payload hash.
+
+Three details cause most hand-rolled failures.
+
+1. **URI encoding is stricter than `encodeURIComponent`.** Only `A-Za-z0-9-_.~` are
+ unreserved. `encodeURIComponent` leaves `!*'()` alone and AWS does not. Write your own.
+2. **S3 does not double-encode the path.** Every other service URI-encodes the path a
+ second time during canonicalisation. Getting this backwards produces a mismatch only
+ for paths containing reserved characters, so it passes in testing and fails in
+ production.
+3. **`x-amz-security-token` must be in `SignedHeaders`.** Sending the header without
+ listing it means the two sides canonicalise different things.
+
+## The signing key chain
+
+```mermaid
+flowchart LR
+ secret["AWS4 + secret"] -->|HMAC date| kDate
+ kDate -->|HMAC region| kRegion
+ kRegion -->|HMAC service| kService
+ kService -->|HMAC aws4_request| kSigning
+ kSigning -->|HMAC string-to-sign| sig["Signature"]
+```
+
+The date, region and service are baked into the key rather than merely stated alongside
+it. A leaked `kSigning` works for that day, that region and that service, and nothing
+else. That is a deliberate blast-radius control, and it is why the credential scope
+appears twice: once inside the key, once in the `Credential=` parameter so the server
+knows which key to derive.
+
+## What the signature covers
+
+Everything in the canonical request, which is more than people expect.
+
+- The method, the path and the query string.
+- Every header named in `SignedHeaders`, including the session token.
+- **The body**, through its SHA-256. Changing one byte invalidates the signature.
+
+So SigV4 provides authentication and integrity together. It does not provide
+confidentiality; that is TLS's job.
+
+The timestamp bounds replay. S3 rejects a request more than 15 minutes from its own clock
+with `RequestTimeTooSkewed`, and AWS's general guidance is tighter still: in most cases a
+signed request must arrive within five minutes.
+
+## Verifying your implementation
+
+AWS publishes worked examples with expected values. The one used in this repository's
+tests is the Amazon Glacier Create Vault example: given the published access key, secret,
+timestamp and headers, the canonical request, the string to sign and the final signature
+are all stated. If your implementation reproduces all three you are done.
+
+There is a second example on that page whose published signature is not reproducible. See
+[Verification](05-verification.md).
diff --git a/docs/signing/03-federation.ja.md b/docs/signing/03-federation.ja.md
new file mode 100644
index 0000000..33ec835
--- /dev/null
+++ b/docs/signing/03-federation.ja.md
@@ -0,0 +1,147 @@
+[English](03-federation.md) | **日本語**
+
+# フェデレーション
+
+[ノートに戻る](README.ja.md)
+
+## 6つの発行 API
+
+STS は単一の関数ではない。クレデンシャルを返すのは次の操作。
+
+| API | 入り口の条件 | 上限 |
+| --- | --- | --- |
+| `AssumeRole` | 既存の署名可能なクレデンシャル | ロールの `MaxSessionDuration` |
+| `AssumeRoleWithSAML` | SAML assertion、**AWS クレデンシャル不要** | 同上 |
+| `AssumeRoleWithWebIdentity` | OIDC ID token、**AWS クレデンシャル不要** | 同上 |
+| `AssumeRoot` | 管理アカウント | 固定15分 |
+| `GetSessionToken` | IAM user のキー、必要なら MFA | 12時間 |
+| `GetFederationToken` | IAM user のキー | 12時間 |
+
+`AssumeRole` の `DurationSeconds` は900秒以上で、ロールの `MaxSessionDuration` が上限。その
+`MaxSessionDuration` 自体は1〜12時間で設定できる。既定は3600秒。assume したロールからさらに assume
+(role chaining) すると、設定に関係なく上限が1時間に落ちる。「設定より早くセッションが切れる」の典型的
+な原因がこれ。
+
+`GetSessionToken` は新しい権限を与えない。IAM user の長期キーを、同じ権限を持つ一時クレデンシャルに
+昇格させるだけで、必要なら `aws:MultiFactorAuthPresent` が立つ。名前が助けになるより誤解を招く回数の
+方が多い。
+
+session policy と session tag は session token の中に入るので、その分 token が大きくなる。2026年9月、STS は session token サイズの上限と、渡したパラメータの上限を別々に持つのをやめた。代わりに [session token サイズ 4,096 byte という単一の上限](https://aws.amazon.com/about-aws/whats-new/2026/09/aws-sts/)になった。入力そのものの平文の制限は残っていて、たとえば inline session policy は 2,048 文字まで。[`AssumeRole` のレスポンス](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html)は byte 単位の `SessionTokenSize` と、上限に対する割合の `SessionTokenUtilization` を返すようになり、`PackedPolicySize` は後者に置き換わる形で非推奨になった。任意のリクエストパラメータ `MinimumSessionTokenSize` は token を指定サイズまで水増しする。本番で大きな token を受け取る前に、クライアントが扱えるか確認するためのパラメータである。
+
+## なぜ2つだけ未署名なのか
+
+STS を呼ぶには普通は署名が要り、署名にはクレデンシャルが要る。`AssumeRoleWithWebIdentity` と
+`AssumeRoleWithSAML` は `Authorization` ヘッダ無しで呼べる。リクエストが「他人が署名した assertion」
+を運び、AWS はそちらの署名を検証する。
+
+```mermaid
+sequenceDiagram
+ participant R as GitHub runner
+ participant G as GitHub OIDC provider
+ participant S as AWS STS
+ participant A as S3
+
+ R->>G: GET .../oidctoken?...&audience=sts.amazonaws.com
+ Note right of R: Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN
+ G-->>R: repo と ref を名指しした署名付き JWT
+
+ R->>S: POST / (AssumeRoleWithWebIdentity)
+ Note right of R: Authorization ヘッダ無し
ボディが JWT を運ぶ
+ S->>G: /.well-known/jwks.json を取得
+ G-->>S: 検証鍵
+ S->>S: 署名、exp、aud を検証
+ S->>S: ロールの trust policy を評価
+ S-->>R: ASIA... + secret + session token
+
+ R->>A: GET /key.txt、SigV4 署名付き
+ A-->>R: 200
+```
+
+AWS と GitHub の間に共有秘密はどの時点でも存在しない。AWS は「信頼せよと言われた issuer」を信頼し、
+その issuer から取得した鍵で署名を検証するだけ。
+
+## access token ではなく ID token
+
+パラメータ名は `WebIdentityToken` で、API リファレンスは「OAuth 2.0 access token または OpenID
+Connect ID token」と書いている。両方受け付けるのは、この API が設計されたソーシャルログイン時代の
+名残。今どきの用途はどれも OIDC の **ID token** を入れる。
+
+この区別が効くのは、2つが別の問いに答えているから。
+
+| | ID token | access token |
+| --- | --- | --- |
+| 主張する内容 | 呼び出し元が誰か | 呼び出し元に何が許されるか |
+| audience | 要求したクライアント | リソースサーバ |
+| 形式 | 必ず JWT | 規定なし、opaque なことも多い |
+| 検証できる者 | JWKS があれば誰でも | 発行者だけ、ということが多い |
+
+STS が欲しいのは身元の主張なので、ID token が正しい形。この非対称性はレスポンスにも残る。`Provider`
+には、ID token なら `iss` の値が、access token 経路ならリクエストで渡した `ProviderId` が入る。
+
+## 制御は trust policy が全部握っている
+
+ここまでは仕組みの話。セキュリティ上の判断は1つのドキュメントに集約される。
+
+```json
+{
+ "Effect": "Allow",
+ "Principal": {
+ "Federated": "arn:aws:iam::111111111111:oidc-provider/token.actions.githubusercontent.com"
+ },
+ "Action": "sts:AssumeRoleWithWebIdentity",
+ "Condition": {
+ "StringEquals": {
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
+ },
+ "StringLike": {
+ "token.actions.githubusercontent.com:sub": "repo:0-draft/caller-identity:ref:refs/heads/main"
+ }
+ }
+}
+```
+
+繰り返し現れる間違いは4つ。出現頻度の高い順。
+
+1. **`sub` を全く絞っていない。** `aud` だけ見て終わっている。`aud` はインターネット上の全 GitHub
+ Actions ワークフローで `sts.amazonaws.com` なので、その provider に到達できるリポジトリなら何でも
+ このロールを assume できる。
+2. **`repo:*` や裸の `*`。** 制約に見える。GitHub 上の全リポジトリに一致する。
+3. **`repo:my-org/*`。** これは狭いが、特定の意味でまだ間違っている。後から org に追加された
+ リポジトリを含み、乗っ取られたアカウントが作ったものも含み、pull request と fork を区別しない。
+4. **`Action` に `sts:AssumeRole` と書く。** 呼び出す API 名がそのまま policy に書くべき `Action` 名
+ になる。OIDC なら `sts:AssumeRoleWithWebIdentity`、SAML なら `sts:AssumeRoleWithSAML`。これは
+ fail closed なので、4つの中では一番害が小さい。
+
+サイトの trust policy パネルは、編集可能なトークンに対してこの4つを実際に評価する。読むのではなく
+自分で起こせる。
+
+### `sub` 以外で絞る
+
+2026年1月、STS は GitHub、Google、CircleCI、Oracle Cloud Infrastructure の [provider 固有 claim を condition key として検証する](https://aws.amazon.com/about-aws/whats-new/2026/01/aws-sts-supports-validation-identity-provider-claims/)ようになった。制約を `sub` の文字列に押し込む必要はもう無い。GitHub なら `repository`、`repository_id`、`repository_owner_id`、`actor`、`actor_id`、`ref`、`environment`、`workflow`、`job_workflow_ref`、`enterprise_id` があり、`token.actions.githubusercontent.com:
body carries the JWT
+ S->>G: fetch /.well-known/jwks.json
+ G-->>S: verification keys
+ S->>S: verify signature, exp, aud
+ S->>S: evaluate the role trust policy
+ S-->>R: ASIA... + secret + session token
+
+ R->>A: GET /key.txt, SigV4-signed
+ A-->>R: 200
+```
+
+No shared secret exists between AWS and GitHub at any point. AWS trusts an issuer it was
+told to trust, and checks a signature against keys it fetches from that issuer.
+
+## ID token, not access token
+
+The parameter is called `WebIdentityToken`, and the API reference describes it as "the
+OAuth 2.0 access token or OpenID Connect ID token". Both are accepted, which is a leftover
+from the social-login era the API was designed for. Every modern use puts an OIDC **ID
+token** there.
+
+The distinction matters because the two answer different questions.
+
+| | ID token | Access token |
+| --- | --- | --- |
+| Asserts | Who the caller is | What the caller may do |
+| Audience | The client that asked for it | A resource server |
+| Format | Always a JWT | Unspecified, often opaque |
+| Verifiable by | Anyone, via JWKS | Often only the issuer |
+
+STS needs an identity assertion, so the ID token is the right shape. The asymmetry
+survives into the response: `Provider` holds the `iss` value for an ID token, but the
+`ProviderId` you sent for an access token.
+
+## The trust policy is the whole control
+
+Everything above is mechanism. The security decision lives in one document.
+
+```json
+{
+ "Effect": "Allow",
+ "Principal": {
+ "Federated": "arn:aws:iam::111111111111:oidc-provider/token.actions.githubusercontent.com"
+ },
+ "Action": "sts:AssumeRoleWithWebIdentity",
+ "Condition": {
+ "StringEquals": {
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
+ },
+ "StringLike": {
+ "token.actions.githubusercontent.com:sub": "repo:0-draft/caller-identity:ref:refs/heads/main"
+ }
+ }
+}
+```
+
+Four recurring mistakes, in rough order of how often they appear.
+
+1. **`sub` not constrained at all.** The policy checks `aud` and stops. `aud` is
+ `sts.amazonaws.com` for every GitHub Actions workflow on the internet, so the role
+ becomes assumable by any repository that can reach the provider.
+2. **`repo:*` or a bare `*`.** Looks like a constraint. Matches every repository on
+ GitHub.
+3. **`repo:my-org/*`.** Narrower, and still wrong in a specific way: it covers
+ repositories added to the org later, including ones created by a compromised account,
+ and it does not distinguish a pull request from a fork.
+4. **`sts:AssumeRole` written as the `Action`.** The API you call is the `Action` the
+ policy must name. OIDC needs `sts:AssumeRoleWithWebIdentity`, SAML needs
+ `sts:AssumeRoleWithSAML`. This one fails closed, which makes it the least dangerous of
+ the four.
+
+The site's trust-policy panel evaluates all four against an editable token, so the failure
+is something to cause rather than read about.
+
+### Matching on more than `sub`
+
+In January 2026 STS [began validating provider-specific claims](https://aws.amazon.com/about-aws/whats-new/2026/01/aws-sts-supports-validation-identity-provider-claims/) as condition keys for GitHub, Google, CircleCI and Oracle Cloud Infrastructure, so a constraint no longer has to be squeezed into the `sub` string. For GitHub they include `repository`, `repository_id`, `repository_owner_id`, `actor`, `actor_id`, `ref`, `environment`, `workflow`, `job_workflow_ref` and `enterprise_id`, each written as `token.actions.githubusercontent.com:
クレデンシャルを持つ"] -->|ローカルで署名| url["URL 1本"]
+ url --> anyone["誰でも
AWS 身元は不要"]
+ anyone -->|"素の GET"| s3["S3"]
+ s3 -->|"CloudTrail に残るのは自分"| log["自分の principal"]
+
+ style url stroke:#ff9900
+ style log stroke:#ff6b6b
+```
+
+そこから直接導かれる結論が3つ。直接導かれるのに、それでも驚かれる。
+
+- **個別に失効させられない。** 署名は AWS 側に何も登録しない。1本の presigned URL を止めるには、
+ 署名に使ったクレデンシャルをローテートするか無効化するしかない。
+- **CloudTrail には自分が記録される。** URL を使った相手ではない。
+- **URL はごく普通の経路で漏れる。** ブラウザ履歴、referrer ヘッダ、プロキシログ、チャット。チケットに
+ 貼られた presigned URL は、チケットに貼られたクレデンシャル。
+
+被害を限定しているのは `X-Amz-Expires` だけ。S3 の上限は604800秒 (7日) で、signing key 自体がその
+期間しか有効でないため。
+
+## SigV4a
+
+1つの署名を複数リージョンで有効にする必要がある場合 (S3 Multi-Region Access Point など) のための
+方式。対称署名では、検証する全リージョンに共有秘密を配らない限り実現できない。だから非対称にする。
+
+### 鍵の導出
+
+鍵ペアは、既に持っている secret access key から作る。
+
+```text
+input_key = "AWS4A" || secret
+label = "AWS4-ECDSA-P256-SHA256"
+context = access_key_id || counter (counter は1バイト)
+
+key = KDF(input_key, label, context, 256) NIST SP 800-108 counter モード、HMAC-SHA256
+c = Oct2Int(key)
+if c > n - 2: counter を増やしてやり直す
+else: k = c + 1 秘密鍵
+ Q = k * G 公開鍵
+```
+
+リトライは飾りではない。一様乱数の256bit整数は P-256 の位数を超えうるし、範囲外のスカラーは鍵として
+無効。実際には最初の候補で通る。
+
+KDF の1ブロックを展開するとこうなる。
+
+```text
+HMAC(input_key, uint32be(1) || label || 0x00 || access_key_id || counter || uint32be(256))
+```
+
+### 何が変わるか
+
+```mermaid
+flowchart TB
+ subgraph v4["SigV4"]
+ a1["scope: date/region/service/aws4_request"]
+ a2["HMAC、共有秘密"]
+ a3["検証する全リージョンが secret を必要とする"]
+ a1 --> a2 --> a3
+ end
+
+ subgraph v4a["SigV4a"]
+ b1["scope: date/service/aws4_request
リージョン無し"]
+ b2["ECDSA P-256、鍵ペア"]
+ b3["検証側は公開鍵だけあればよい"]
+ b1 --> b2 --> b3
+ end
+```
+
+リージョンは credential scope から出て `X-Amz-Region-Set` に現れる。このヘッダ自体が署名対象なので、
+「この署名がどのリージョンで有効だと主張しているか」も署名に守られる。ワイルドカードも使える。
+`us-west-*` や `*`。
+
+鍵が日付・リージョン・サービスで区切られなくなるため、SigV4 が持っていた被害範囲の制御は失われる。
+同じ鍵ペアが全てに署名する。複数リージョンで有効になることの代償。
+
+### 実装上の注意
+
+WebCrypto だけでは実装できない。公開鍵の導出には「自分で選んだスカラーでベースポイントを倍する」
+処理が要るが、`SubtleCrypto` にスカラー倍の API は無い。曲線演算はライブラリから持ってくるしかない。
+このリポジトリは `@noble/curves` を使っている。
+
+1つ書いておく価値のある帰結。`@noble/curves` は RFC 6979 の決定的 nonce が既定なので、ここでは同じ
+リクエストに2回署名すると同じバイト列になる。AWS SDK は libcrypto 経由なのでそうならない。どちらも
+検証は通り、再現するのは一方だけ。SigV4a の署名に固定テストベクタを書こうとすると、ここが効いてくる。
diff --git a/docs/signing/04-variants.md b/docs/signing/04-variants.md
new file mode 100644
index 0000000..8cb2bbc
--- /dev/null
+++ b/docs/signing/04-variants.md
@@ -0,0 +1,128 @@
+**English** | [日本語](04-variants.ja.md)
+
+# Presigned URLs and SigV4a
+
+[Back to notes](README.md)
+
+Two variations on the same signature. One moves it into a URL, the other makes it
+asymmetric. Both are worth understanding because both change what the signature protects.
+
+## Presigned URLs
+
+The auth moves from the `Authorization` header into the query string. Everything else
+about the canonical request is unchanged.
+
+```text
+https://examplebucket.s3.amazonaws.com/test.txt
+ ?X-Amz-Algorithm=AWS4-HMAC-SHA256
+ &X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request
+ &X-Amz-Date=20130524T000000Z
+ &X-Amz-Expires=86400
+ &X-Amz-SignedHeaders=host
+ &X-Amz-Signature=aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404
+```
+
+Two differences from header auth carry the lesson.
+
+**The payload hash is the literal `UNSIGNED-PAYLOAD`.** At signing time nobody knows what
+will be uploaded, so the body cannot be covered. A presigned `PUT` therefore authorises a
+destination, not a set of bytes: whoever holds the URL chooses the content.
+
+**The session token is a query parameter**, part of the canonical request, rather than a
+signed header.
+
+### What it actually is
+
+A bearer credential with an expiry.
+
+```mermaid
+flowchart LR
+ you["You
hold the credential"] -->|sign locally| url["A URL"]
+ url --> anyone["Anyone at all
no AWS identity needed"]
+ anyone -->|"plain GET"| s3["S3"]
+ s3 -->|"CloudTrail records you"| log["Your principal"]
+
+ style url stroke:#ff9900
+ style log stroke:#ff6b6b
+```
+
+Three consequences that follow directly and surprise people anyway.
+
+- **It cannot be revoked individually.** Signing registers nothing with AWS. Killing one
+ presigned URL means rotating or disabling the credential that signed it.
+- **CloudTrail attributes the call to you**, not to whoever used the URL.
+- **URLs leak in ordinary ways.** Browser history, referrer headers, proxy logs, chat
+ messages. A presigned URL pasted into a ticket is a credential in a ticket.
+
+`X-Amz-Expires` is the only thing bounding the damage. S3 caps it at 604800 seconds,
+seven days, because the signing key itself is valid only that long.
+
+## SigV4a
+
+For a signature that must be valid in more than one region, such as an S3 Multi-Region
+Access Point. Symmetric signing cannot do this without shipping the shared secret to every
+region that verifies, so the scheme goes asymmetric.
+
+### Key derivation
+
+The keypair comes from the secret access key you already have.
+
+```text
+input_key = "AWS4A" || secret
+label = "AWS4-ECDSA-P256-SHA256"
+context = access_key_id || counter (counter is one byte)
+
+key = KDF(input_key, label, context, 256) NIST SP 800-108, counter mode, HMAC-SHA256
+c = Oct2Int(key)
+if c > n - 2: counter++ and retry
+else: k = c + 1 private key
+ Q = k * G public key
+```
+
+The retry loop is not decoration. A uniformly random 256-bit integer can land above the
+P-256 group order, and a scalar out of range is not a valid key. In practice the first
+candidate works.
+
+The single KDF block expands to:
+
+```text
+HMAC(input_key, uint32be(1) || label || 0x00 || access_key_id || counter || uint32be(256))
+```
+
+### What changes
+
+```mermaid
+flowchart TB
+ subgraph v4["SigV4"]
+ a1["scope: date/region/service/aws4_request"]
+ a2["HMAC, shared secret"]
+ a3["every verifying region needs the secret"]
+ a1 --> a2 --> a3
+ end
+
+ subgraph v4a["SigV4a"]
+ b1["scope: date/service/aws4_request
no region"]
+ b2["ECDSA P-256, keypair"]
+ b3["verifying regions need only the public key"]
+ b1 --> b2 --> b3
+ end
+```
+
+The region leaves the credential scope and reappears in `X-Amz-Region-Set`, which is
+itself a signed header, so the set of regions a signature claims to be valid in is covered
+by the signature. Wildcards are allowed: `us-west-*`, or `*`.
+
+Because the key is no longer scoped by date, region or service, SigV4's blast-radius
+control is gone. The same keypair signs everything. That is the trade for multi-region
+validity.
+
+### A practical note
+
+WebCrypto cannot implement this alone. Deriving the public key means multiplying the base
+point by a scalar you chose, and `SubtleCrypto` exposes no scalar multiplication, so the
+curve arithmetic has to come from a library. This repository uses `@noble/curves`.
+
+One consequence worth stating: `@noble/curves` defaults to the RFC 6979 deterministic
+nonce, so signing the same request twice here gives identical bytes. AWS SDKs sign through
+libcrypto and will not. Both verify; only one is reproducible, which matters if you ever
+try to write a fixed test vector for a SigV4a signature.
diff --git a/docs/signing/05-verification.ja.md b/docs/signing/05-verification.ja.md
new file mode 100644
index 0000000..f55fe4b
--- /dev/null
+++ b/docs/signing/05-verification.ja.md
@@ -0,0 +1,104 @@
+[English](05-verification.md) | **日本語**
+
+# 検証
+
+[ノートに戻る](README.ja.md)
+
+このリポジトリの主張をどう検証したか、そして何がまだ仮定のままか。これを書き出す理由は、「検証済み」
+には幅があり、その幅が重要だから。
+
+## 確度のはしご
+
+全部が同じ強度ではないので、どの段にいるかを明示する。
+
+| 段 | 根拠 | 適用先 |
+| --- | --- | --- |
+| 最強 | AWS が期待値付きの計算例を公開しており、それを再現できる | SigV4 ヘッダ認証、presigned URL |
+| 強 | AWS がリファレンスで規則を明記しており、挙動がそこから導ける | キー本数、有効期間、condition の意味論 |
+| 中 | 公開された擬似コードの独立実装2つが一致する | SigV4a の鍵導出 |
+| 弱 | 仕組みからの推論のみ。ベンダーの言明なし | session token の内部的な扱い |
+
+最下段のものはサイトの文言でも「事実」ではなく「仕組み」として書いている。たとえば session token は
+「AWS が復号する」ではなく「AWS がセッションの secret に解決する」と書く。AWS がフォーマットを公開
+していないため。
+
+## 完全に再現するもの
+
+AWS 公開の計算例2件を、CI で1バイト単位で照合している。
+
+**Glacier の Create Vault**。リクエスト署名のリファレンスから。公開されたキー、secret、タイムスタンプ、
+ヘッダに対して、ページが canonical request、string to sign、署名を明記している。3つとも一致する。
+
+**S3 presigned の `test.txt`**。クエリパラメータ認証のリファレンスから。ページ自身が「テストケースと
+して使える」と明言している。canonical request、そのハッシュ、署名、完成した URL の全てが一致する。
+
+どちらかが壊れればサイトが表示する署名は全部嘘になるので、両方とも独立した CI ジョブで走らせている。
+
+## AWS ドキュメントの誤り
+
+Glacier の署名ページには2つ目の例、streaming の `Upload Archive` がある。そこに載っている署名値が、
+同じページが印字している canonical request から再現できない。
+
+```text
+ページの canonical request → 完全に再現する
+ページ記載の署名値: b092397439375d59119072764a1e9a144677c43d9906fd98a5742c57a2855de6
+この実装: e8ba379a747bc294584102fd2430f7a563696740882e149c87b15754e7c10a89
+独立した Python 実装: e8ba379a747bc294584102fd2430f7a563696740882e149c87b15754e7c10a89
+```
+
+分かりやすい説明の否定もしておく。ページのリクエスト構文には `x-amz-archive-description` と
+`x-amz-sha256-tree-hash` が現れるが、canonical request には現れない。この2つを足すと3つ目の値
+`9ba94dab5c...` になるので、それも説明にならない。
+
+ちなみに同ページのペイロードハッシュは正しい。`sha256("Welcome to Amazon Glacier.")` は記載通り
+`726e392cb4d09924dbad1cc0ba3b00c3643d03d14cb4b823e2f041cff612a628` になる。最終的な署名値だけが
+繋がらない。
+
+そこでテストは、canonical request をドキュメントに対して、署名値を相互検算に対して検証し、どちらが
+どちらかをコメントに残す形にした。落ちるケースを黙って消すのは簡単で、間違った選択だった。
+
+## 根拠が弱い箇所
+
+**SigV4a の鍵導出。** AWS は導出を擬似コードとしては公開しているが、期待される鍵を示した計算例は
+公開していない。つまりベンダーのベクタが存在しない。導出した鍵は、TypeScript ではなくドキュメントから
+書き起こした別の Python 実装と突き合わせている。同じ文章の実装2つが一致することは書き写しミスを
+排除するが、両方が同じ読み違いをした可能性は排除しない。
+
+**SigV4a の署名。** 原理的に固定できない。ECDSA は乱数を使う。代わりに、署名が導出した公開鍵で検証
+できることを assert している。これは AWS が実際に行う検証と同じ。(この実装は `@noble/curves` の既定に
+より RFC 6979 で決定的になるが、AWS 側はそうではない。)
+
+## レビューで見つかった誤り
+
+ドキュメントに当て直した結果、既に書いてあった内容から6件の誤りが出た。個別の修正より、パターンの方が
+役に立つので列挙する。
+
+1. `CreateAccessKey` のレスポンスに `CreateDate` が抜けていた。
+2. 「1ユーザーにつきアクティブキー2本」は誤り。上限は Active / Inactive を問わず同時2本。
+3. `AssumeRole` の有効期間で、`DurationSeconds` (900秒以上) と `MaxSessionDuration` の設定値
+ (1〜12時間) を混同し、role chaining の1時間上限も抜けていた。
+4. session token を「復号される」と書いていた。AWS はフォーマットを公開していない。
+5. 時刻ずれを15分と書いていた。それは S3 の値で、AWS の一般的な案内は5分。
+6. GitHub のトークン URL は既にクエリ文字列を持つので、audience は `?` ではなく `&` で足す。
+
+どれも書いた時点ではもっともらしく、推測で書いたものは1つも無く、6件とも間違っていた。
+
+## テストが捕まえた2つの思い込み
+
+ドキュメントの問題ではないので、別に記録しておく。
+
+改ざんテストが、string to sign の中の region set を書き換えていた。しかしそこに region set は現れない。
+region set が署名に届くのは canonical request のハッシュ経由だけなので、書き換えは何も変えず、署名は
+通り続けた。間違っていたのはコードではなくテスト。
+
+もう1つは「2回署名すればバイト列が変わる」という assert。ECDSA は乱数を使うから、という理屈だった。
+`@noble/curves` は RFC 6979 の決定的 nonce が既定なので、変わらない。
+
+## 全部を再現する
+
+```bash
+npm ci
+npm test
+```
+
+各チェックは CI でも独立したジョブとして走る。typecheck、lint、フォーマット、markdown lint と並んで。
diff --git a/docs/signing/05-verification.md b/docs/signing/05-verification.md
new file mode 100644
index 0000000..36516aa
--- /dev/null
+++ b/docs/signing/05-verification.md
@@ -0,0 +1,115 @@
+**English** | [日本語](05-verification.ja.md)
+
+# Verification
+
+[Back to notes](README.md)
+
+How the claims in this repository were checked, and what is still assumed. The point of
+writing this down is that "verified" covers a range, and the range matters.
+
+## The ladder
+
+Not all of it is equally solid, so it is worth saying which rung each piece sits on.
+
+| Rung | Evidence | Used for |
+| --- | --- | --- |
+| Strongest | AWS publishes a worked example with expected values, and we reproduce it | SigV4 header auth, presigned URLs |
+| Strong | AWS states the rule in reference documentation, and behaviour follows from it | Key limits, durations, condition semantics |
+| Medium | Two independent implementations of published pseudocode agree | SigV4a key derivation |
+| Weak | Reasoning from mechanism, no vendor statement | Internal handling of the session token |
+
+Anything on the bottom rung is phrased as mechanism rather than fact in the site copy. For
+example, the session token is described as something AWS resolves to a session secret, not
+as something AWS decrypts, because AWS does not document the format.
+
+## What reproduces exactly
+
+Two AWS-published examples are asserted in CI, byte for byte.
+
+**Glacier Create Vault**, from the request signing reference. Given the published key,
+secret, timestamp and headers, the page states the canonical request, the string to sign
+and the signature. All three match.
+
+**S3 presigned `test.txt`**, from the query-parameter auth reference, which explicitly
+offers itself as a test case. The canonical request, its hash, the signature and the
+complete URL all match.
+
+If either breaks, every signature the site renders is wrong, so both run as their own CI
+job.
+
+## An error in the AWS documentation
+
+The Glacier signing page publishes a second example, for the streaming `Upload Archive`
+call. Its published signature is not reproducible from the canonical request the same page
+prints.
+
+```text
+page's canonical request → reproduces exactly
+page's published signature: b092397439375d59119072764a1e9a144677c43d9906fd98a5742c57a2855de6
+this implementation: e8ba379a747bc294584102fd2430f7a563696740882e149c87b15754e7c10a89
+independent Python: e8ba379a747bc294584102fd2430f7a563696740882e149c87b15754e7c10a89
+```
+
+Ruling out the obvious explanation: the page's request syntax shows
+`x-amz-archive-description` and `x-amz-sha256-tree-hash` headers that its canonical request
+omits. Adding them gives a third value, `9ba94dab5c...`, so that is not it either.
+
+The payload hash on that page is correct, incidentally:
+`sha256("Welcome to Amazon Glacier.")` is the stated
+`726e392cb4d09924dbad1cc0ba3b00c3643d03d14cb4b823e2f041cff612a628`. Only the final
+signature does not follow.
+
+The test therefore asserts the canonical request against the documentation, and the
+signature against the cross-check, with a comment explaining which is which. Silently
+deleting the failing case would have been the easier option and the wrong one.
+
+## Where the evidence is weaker
+
+**SigV4a key derivation.** AWS publishes the derivation as pseudocode but not as a worked
+example with an expected key, so there is no vendor vector. The derived key is
+cross-checked against a separate Python implementation written from the documentation
+rather than from the TypeScript. Two implementations of the same prose agreeing rules out
+transcription slips; it does not rule out both having read the prose the same wrong way.
+
+**SigV4a signatures.** Not pinnable in principle: ECDSA is randomised. What is asserted
+instead is that a signature verifies against the derived public key, which is the check
+AWS performs. (This implementation happens to be deterministic because `@noble/curves`
+defaults to RFC 6979; AWS's is not.)
+
+## Corrections found while reviewing
+
+Reviewing against the documentation turned up six errors in material already written.
+Listed because the pattern is more useful than the individual fixes.
+
+1. The `CreateAccessKey` response was missing `CreateDate`.
+2. "Two active keys per user" is wrong: the limit is two at a time, Active or Inactive.
+3. `AssumeRole` expiry conflated `DurationSeconds` (900s upward) with the
+ `MaxSessionDuration` setting (1 to 12 hours), and omitted the one-hour ceiling on role
+ chaining.
+4. The session token was described as being decrypted. AWS does not document the format.
+5. Clock skew was given as 15 minutes. That is S3's figure; AWS's general guidance is five.
+6. GitHub's token URL already carries a query string, so the audience is appended with `&`,
+ not `?`.
+
+Most were plausible, none were guesses at the time of writing, and all six were wrong.
+
+## Two assumptions the tests caught
+
+Worth recording separately because neither was a documentation problem.
+
+A tamper test altered the region set inside the string to sign, where it does not appear.
+The region set reaches the signature only through the canonical request hash, so the edit
+changed nothing and the signature still verified. The test was wrong, not the code.
+
+Another asserted that signing twice produces different bytes, on the grounds that ECDSA is
+randomised. `@noble/curves` defaults to the deterministic RFC 6979 nonce, so it does not.
+
+## Reproducing all of it
+
+```bash
+npm ci
+npm test
+```
+
+Each check also runs as its own CI job, alongside typecheck, lint, formatting and
+markdown lint.
diff --git a/docs/signing/README.ja.md b/docs/signing/README.ja.md
new file mode 100644
index 0000000..83a016d
--- /dev/null
+++ b/docs/signing/README.ja.md
@@ -0,0 +1,65 @@
+[English](README.md) | **日本語**
+
+# ノート
+
+このノートは[署名の章](https://0-draft.github.io/aws-credentials/?lang=ja#signing)の裏付けです。もとは `caller-identity` プロジェクト向けに書いたもので、2026-11-08 にこのリポジトリへ統合した際に移しました。
+
+サイトがインタラクティブに見せている内容の、長い版。パネルに収まらない部分をここに置く。各仕組みが
+なぜその形なのか、ドキュメントと実装がどこで食い違うのか、そしてここに書いた主張をどう検証したか。
+
+## 目次
+
+| ページ | 扱う内容 |
+| --- | --- |
+| [クレデンシャルの地図](01-credential-map.ja.md) | リクエストが身元を得る全経路と、それらが共有する3つの層 |
+| [SigV4](02-sigv4.ja.md) | 署名の全工程。canonical request、鍵の連鎖、署名が守る範囲 |
+| [フェデレーション](03-federation.ja.md) | STS の発行 API、OIDC、そして繰り返される trust policy の間違い |
+| [presigned URL と SigV4a](04-variants.ja.md) | 同じ署名を URL に移したものと、非対称な複数リージョン版 |
+| [検証](05-verification.ja.md) | 各主張の検証方法と、AWS ドキュメントで見つかった誤り |
+
+## 短い版
+
+層は3つ。混乱のほとんどは、この3つを一緒くたにすることから来る。
+
+```mermaid
+flowchart TD
+ subgraph issue["クレデンシャルの出所"]
+ iam["IAM
CreateAccessKey"]
+ sts["STS
6つの発行 API"]
+ imds["IMDS / コンテナエンドポイント
プラットフォームが注入"]
+ end
+
+ subgraph sign["送信者を証明する方法"]
+ sigv4["SigV4
HMAC、共有秘密"]
+ sigv4a["SigV4a
ECDSA、公開鍵"]
+ bearer["bearer token
署名そのものが無い"]
+ end
+
+ subgraph decide["AWS 側の処理"]
+ verify["再計算して照合
ここで principal が確定"]
+ authz["ポリシー評価
identity / resource / SCP / session"]
+ end
+
+ iam --> sigv4
+ sts --> sigv4
+ sts --> sigv4a
+ imds --> sigv4
+ iam -. "API キー作成が
IAM user を生む" .-> bearer
+
+ sigv4 --> verify
+ sigv4a --> verify
+ bearer --> verify
+ verify --> authz
+
+ style bearer stroke-dasharray: 4 4
+```
+
+SigV4 は STS と紐づいていない。SigV4 は署名の層であり、長期キーも一時クレデンシャルも同じ層を
+そのまま使う。STS はその層に供給する発行元の1つでしかない。bearer token は署名の層を丸ごと飛ばす。
+
+一番多くを説明する事実はこれ。**`AccessDenied` が返ってきた時点で署名は既に通っている。** 認証と認可
+は別のステップで、その順番であり、エラーがどちらを通過済みかを教えてくれる。
+
+## 表記について
+
+ここに出てくるキーは全て AWS が自身のドキュメントで公開しているサンプル値で、本物の秘密は含まない。
diff --git a/docs/signing/README.md b/docs/signing/README.md
new file mode 100644
index 0000000..7a8feb7
--- /dev/null
+++ b/docs/signing/README.md
@@ -0,0 +1,69 @@
+**English** | [日本語](README.ja.md)
+
+# Signing notes
+
+These notes back the [Signing chapter](https://0-draft.github.io/aws-credentials/#signing). They were written for the former `caller-identity` project and moved here when it was merged into this one on 2026-11-08.
+
+The long-form version of what the site shows interactively. These pages carry the parts
+that do not fit in a panel: why each mechanism is shaped the way it is, where the
+documentation and the implementations disagree, and how every claim here was checked.
+
+## Contents
+
+| Page | What it covers |
+| --- | --- |
+| [The credential map](01-credential-map.md) | Every way a request gets an identity, and the three layers they share |
+| [SigV4](02-sigv4.md) | The signature in full: canonical request, key chain, what the signature covers |
+| [Federation](03-federation.md) | STS issuance APIs, OIDC, and the trust policy mistakes that keep recurring |
+| [Presigned URLs and SigV4a](04-variants.md) | The same signature moved into a URL, and the asymmetric multi-region variant |
+| [Verification](05-verification.md) | How each claim was checked, and an error in the AWS documentation |
+
+## The short version
+
+Three layers, and most confusion comes from collapsing them.
+
+```mermaid
+flowchart TD
+ subgraph issue["Where credentials come from"]
+ iam["IAM
CreateAccessKey"]
+ sts["STS
six issuance APIs"]
+ imds["IMDS / container endpoint
injected by the platform"]
+ end
+
+ subgraph sign["How a request proves who sent it"]
+ sigv4["SigV4
HMAC, shared secret"]
+ sigv4a["SigV4a
ECDSA, public key"]
+ bearer["Bearer token
no signature at all"]
+ end
+
+ subgraph decide["What AWS does with it"]
+ verify["Recompute and compare
principal is now known"]
+ authz["Evaluate policies
identity, resource, SCP, session"]
+ end
+
+ iam --> sigv4
+ sts --> sigv4
+ sts --> sigv4a
+ imds --> sigv4
+ iam -. "API key creation
makes an IAM user" .-> bearer
+
+ sigv4 --> verify
+ sigv4a --> verify
+ bearer --> verify
+ verify --> authz
+
+ style bearer stroke-dasharray: 4 4
+```
+
+SigV4 is not tied to STS. It is the signing layer, and both long-term keys and temporary
+credentials use it unchanged. STS is one of several issuers feeding that layer. Bearer
+tokens skip the signing layer entirely.
+
+The single fact that explains the most: **an `AccessDenied` means the signature already
+verified.** Authentication and authorisation are separate steps, in that order, and the
+error tells you which one you are past.
+
+## Conventions
+
+Every key in these pages is an example value AWS publishes in its own documentation.
+Nothing here is a real secret.
diff --git a/package-lock.json b/package-lock.json
index 0e6751f..79769d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
+ "@noble/curves": "^2.4.0",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
@@ -841,6 +842,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@noble/curves": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
+ "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.4.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
diff --git a/package.json b/package.json
index d394a67..f1c12ae 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
+ "@noble/curves": "^2.4.0",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
diff --git a/src/app/App.tsx b/src/app/App.tsx
index fe47658..2abe013 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -17,6 +17,10 @@ const EndpointsSection = named(
);
const ApiSection = named(import("@/features/api/ApiSection"), "ApiSection");
const PathsSection = named(import("@/features/paths/PathsSection"), "PathsSection");
+const SigningSection = named(
+ import("@/features/signing/SigningSection"),
+ "SigningSection",
+);
const SessionsSection = named(
import("@/features/sessions/SessionsSection"),
"SessionsSection",
@@ -68,6 +72,7 @@ export default function App() {
+ {t({ en: "The URL", ja: "できた URL" })}
+
+
+ {r.url.replace(/&/g, "\n &")}
+
+
+ {t({ en: "Canonical request", ja: "正規リクエスト" })}
+
+
+ {r.canonicalRequest}
+
+ Credential scope
+
+ {r.credentialScope}
+
+
+ {t({ en: "Derived keypair (P-256)", ja: "導出された鍵ペア (P-256)" })}
+
+
+ {`private ${r.keyPair.privateKeyHex}\npublic ${r.keyPair.publicKeyHex}\ncounter ${r.keyPair.counter}`}
+
+ String to sign
+
+ {r.stringToSign}
+
+
+ {t({ en: "Signature (DER, hex)", ja: "署名 (DER, hex)" })}
+
+
+ {t(title)}
+ {note &&
+ {body}
+
+
+ {t({ en: "On the AWS side", ja: "AWS 側で起きること" })}
+
+
+ {notes.map((n) => (
+
+
+ {t(n.title)}
+
+
+ {t({ en: "3. Signing key derivation", ja: "3. 署名キーの導出" })}
+
+
+ {live.derivation.map((s) => (
+
+
+ {t({ en: "4. Signature", ja: "4. 署名" })}
+
+
+
+ {annotate(text, annotations, active, setActive)}
+
+ {picked && (
+
{t({
- en: "How the signature itself is computed is drawn step by step in the sibling project ",
- ja: "署名そのものの計算は、姉妹プロジェクト ",
+ en: "How the signature itself is computed, and broken, is worked through live in chapter ",
+ ja: "署名そのものの計算 (と壊し方) は、第 ",
})}
-
{t({ - en: "604800 seconds is seven days, the ceiling S3 allows, because the signing key itself is only valid that long.", - ja: "604800 秒は 7 日で、S3 が認める上限です。署名キー自体がその期間しか有効でないためです。", + en: "604800 seconds is seven days, the most the CLI and SDKs allow. A URL signed with temporary credentials stops working when they expire anyway, whatever X-Amz-Expires says.", + ja: "604800 秒は 7 日で、CLI と SDK で設定できる上限です。一時認証情報で署名した URL は、X-Amz-Expires に関係なく、その認証情報が失効した時点で使えなくなります。", })}
)} diff --git a/src/features/signing/sigv4.ts b/src/features/signing/sigv4.ts index e257f80..59258af 100644 --- a/src/features/signing/sigv4.ts +++ b/src/features/signing/sigv4.ts @@ -214,7 +214,7 @@ async function finishSignature( } export interface PresignOptions extends SigV4Options { - /** Seconds the URL stays valid. S3 caps this at 604800 (seven days). */ + /** Seconds the URL stays valid. The CLI and SDKs allow up to 604800 (seven days). */ expiresIn: number; } From 26dc64de112f3c3c4eef7873d03c5a782767a31a Mon Sep 17 00:00:00 2001 From: kanywst <45947799+kanywst@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:30:15 +0900 Subject: [PATCH 3/5] docs(signing): correct Lambda delivery, scope the no-expiry claim and soften unsourced claims --- docs/signing/01-credential-map.ja.md | 6 +++--- docs/signing/01-credential-map.md | 10 ++++++---- docs/signing/03-federation.ja.md | 2 +- docs/signing/03-federation.md | 3 ++- docs/signing/04-variants.ja.md | 2 +- docs/signing/04-variants.md | 7 ++++--- docs/signing/05-verification.ja.md | 2 +- docs/signing/05-verification.md | 2 +- src/features/signing/SigV4aLab.tsx | 4 ++-- src/features/signing/sigv4a.test.ts | 8 ++++---- 10 files changed, 25 insertions(+), 21 deletions(-) diff --git a/docs/signing/01-credential-map.ja.md b/docs/signing/01-credential-map.ja.md index 2c6efab..1d49510 100644 --- a/docs/signing/01-credential-map.ja.md +++ b/docs/signing/01-credential-map.ja.md @@ -13,9 +13,9 @@ AWS の API を呼ぶという目的に限れば、形は2つだけ。それに | --- | --- | --- | --- | | 長期キー | `AKIA...` + secret | `iam:CreateAccessKey` | 無し | | 一時クレデンシャル | `ASIA...` + secret + session token | STS | 必ずある | -| bearer token | 不透明な文字列1本 | Bedrock, CloudWatch | 種類による | +| bearer token | 不透明な文字列1本 | Bedrock の API キー | 種類による | -長期キーは、期限を持たない唯一の AWS API クレデンシャル。このページに書く残り全部は、その1点から +SigV4 のキーペアの中で、期限を持たないのは長期キーだけ (長期の Bedrock API キーは作成時に決めた期限まで有効で、専用の IAM user に紐づく)。このページに書く残り全部は、その1点から 派生している。 ## 入り口の問題 @@ -55,7 +55,7 @@ IMDS は別のやり方で循環を断つ。プラットフォームが既に As 途中の1ステップ。 - **OIDC フェデレーション。** CI から長期キーを消す。仕事をしているのは trust policy で、そこを 間違える話は [フェデレーション](03-federation.ja.md) で扱う。 -- **IMDS。** EC2 や Lambda のディスクに何も置かなくてよい理由。IMDSv2 の `PUT` は趣味ではない。SSRF +- **IMDS。** EC2 のディスクに何も置かなくてよい理由 (Lambda は同じ 3 つの値を環境変数で、ECS や EKS Pod Identity はコンテナ用エンドポイントで受け取る)。IMDSv2 の `PUT` は趣味ではない。SSRF は脆弱なアプリから `GET` を引き出せても、カスタムヘッダ付きの `PUT` はまず引き出せない。 - **bearer token。** API キーを期待していて署名を教え込めないツール向け。AWS 自身の案内も「STS が 不可能な場合に限れ」。 diff --git a/docs/signing/01-credential-map.md b/docs/signing/01-credential-map.md index fb7ec99..141c611 100644 --- a/docs/signing/01-credential-map.md +++ b/docs/signing/01-credential-map.md @@ -13,10 +13,11 @@ and does not fit either. | --- | --- | --- | --- | | Long-term key | `AKIA...` + secret | `iam:CreateAccessKey` | Never | | Temporary credential | `ASIA...` + secret + session token | STS | Yes, always | -| Bearer token | One opaque string | Bedrock, CloudWatch | Depends | +| Bearer token | One opaque string | Bedrock API keys | Depends | -The long-term key is the only AWS API credential with no expiry. That single property is -the reason for everything else on this page. +Among the SigV4 key pairs, the long-term key is the only one with no expiry. That single +property is the reason for everything else on this page. (A long-term Bedrock API key lasts +until the expiry configured at creation, and is tied to an IAM user of its own.) ## The entry problem @@ -56,7 +57,8 @@ leaves the result on a link-local address. already, so it is a step in a chain rather than an entry point. - **OIDC federation.** Ends long-term keys in CI. The trust policy does the work, and getting it wrong is the subject of [Federation](03-federation.md). -- **IMDS.** Why an EC2 instance or a Lambda needs nothing on disk. The `PUT` in IMDSv2 is +- **IMDS.** Why an EC2 instance needs nothing on disk. (Lambda gets the same three values + as environment variables instead, and ECS and EKS Pod Identity from a container endpoint.) The `PUT` in IMDSv2 is not stylistic: server-side request forgery can usually coerce a `GET` out of a vulnerable application, rarely a `PUT` carrying a custom header. - **Bearer tokens.** For tools that expect an API key and cannot be taught to sign. AWS's diff --git a/docs/signing/03-federation.ja.md b/docs/signing/03-federation.ja.md index 33ec835..a2885ea 100644 --- a/docs/signing/03-federation.ja.md +++ b/docs/signing/03-federation.ja.md @@ -107,7 +107,7 @@ STS が欲しいのは身元の主張なので、ID token が正しい形。こ このロールを assume できる。 2. **`repo:*` や裸の `*`。** 制約に見える。GitHub 上の全リポジトリに一致する。 3. **`repo:my-org/*`。** これは狭いが、特定の意味でまだ間違っている。後から org に追加された - リポジトリを含み、乗っ取られたアカウントが作ったものも含み、pull request と fork を区別しない。 + リポジトリを含み、乗っ取られたアカウントが作ったものも含み、トリガーも区別しない。`pull_request` の実行 (`sub` が `:pull_request` で終わる) が `main` への push (`:ref:refs/heads/main`) と同じロールを得る。 4. **`Action` に `sts:AssumeRole` と書く。** 呼び出す API 名がそのまま policy に書くべき `Action` 名 になる。OIDC なら `sts:AssumeRoleWithWebIdentity`、SAML なら `sts:AssumeRoleWithSAML`。これは fail closed なので、4つの中では一番害が小さい。 diff --git a/docs/signing/03-federation.md b/docs/signing/03-federation.md index 3d4721e..da9a039 100644 --- a/docs/signing/03-federation.md +++ b/docs/signing/03-federation.md @@ -113,7 +113,8 @@ Four recurring mistakes, in rough order of how often they appear. GitHub. 3. **`repo:my-org/*`.** Narrower, and still wrong in a specific way: it covers repositories added to the org later, including ones created by a compromised account, - and it does not distinguish a pull request from a fork. + and it matches every trigger, so a `pull_request` run (`sub` ending in `:pull_request`) + gets the same role as a push to `main` (`:ref:refs/heads/main`). 4. **`sts:AssumeRole` written as the `Action`.** The API you call is the `Action` the policy must name. OIDC needs `sts:AssumeRoleWithWebIdentity`, SAML needs `sts:AssumeRoleWithSAML`. This one fails closed, which makes it the least dangerous of diff --git a/docs/signing/04-variants.ja.md b/docs/signing/04-variants.ja.md index 46ec932..22dda3f 100644 --- a/docs/signing/04-variants.ja.md +++ b/docs/signing/04-variants.ja.md @@ -120,5 +120,5 @@ WebCrypto だけでは実装できない。公開鍵の導出には「自分で このリポジトリは `@noble/curves` を使っている。 1つ書いておく価値のある帰結。`@noble/curves` は RFC 6979 の決定的 nonce が既定なので、ここでは同じ -リクエストに2回署名すると同じバイト列になる。AWS SDK は libcrypto 経由なのでそうならない。どちらも +リクエストに2回署名すると同じバイト列になる。SDK の署名処理は一般に乱数の nonce を使うので、そうはならない (SDK ごとには未検証)。どちらも 検証は通り、再現するのは一方だけ。SigV4a の署名に固定テストベクタを書こうとすると、ここが効いてくる。 diff --git a/docs/signing/04-variants.md b/docs/signing/04-variants.md index 63b033b..b9ac920 100644 --- a/docs/signing/04-variants.md +++ b/docs/signing/04-variants.md @@ -127,6 +127,7 @@ point by a scalar you chose, and `SubtleCrypto` exposes no scalar multiplication curve arithmetic has to come from a library. This repository uses `@noble/curves`. One consequence worth stating: `@noble/curves` defaults to the RFC 6979 deterministic -nonce, so signing the same request twice here gives identical bytes. AWS SDKs sign through -libcrypto and will not. Both verify; only one is reproducible, which matters if you ever -try to write a fixed test vector for a SigV4a signature. +nonce, so signing the same request twice here gives identical bytes. SDK signers typically +use a randomised nonce and will not (unverified per SDK). Both verify; only one is +reproducible, which matters if you ever try to write a fixed test vector for a SigV4a +signature. diff --git a/docs/signing/05-verification.ja.md b/docs/signing/05-verification.ja.md index cc4e1e8..d1a304e 100644 --- a/docs/signing/05-verification.ja.md +++ b/docs/signing/05-verification.ja.md @@ -66,7 +66,7 @@ Glacier の署名ページには2つ目の例、streaming の `Upload Archive` **SigV4a の署名。** 原理的に固定できない。ECDSA は乱数を使う。代わりに、署名が導出した公開鍵で検証 できることを assert している。これは AWS が実際に行う検証と同じ。(この実装は `@noble/curves` の既定に -より RFC 6979 で決定的になるが、AWS 側はそうではない。) +より RFC 6979 で決定的になる。SDK 側は一般にそうではないが、SDK ごとには未検証。) ## レビューで見つかった誤り diff --git a/docs/signing/05-verification.md b/docs/signing/05-verification.md index 5419312..864267d 100644 --- a/docs/signing/05-verification.md +++ b/docs/signing/05-verification.md @@ -74,7 +74,7 @@ transcription slips; it does not rule out both having read the prose the same wr **SigV4a signatures.** Not pinnable in principle: ECDSA is randomised. What is asserted instead is that a signature verifies against the derived public key, which is the check AWS performs. (This implementation happens to be deterministic because `@noble/curves` -defaults to RFC 6979; AWS's is not.) +defaults to RFC 6979; SDK signers typically are not, though that is unverified per SDK.) ## Corrections found while reviewing diff --git a/src/features/signing/SigV4aLab.tsx b/src/features/signing/SigV4aLab.tsx index 884876c..384a226 100644 --- a/src/features/signing/SigV4aLab.tsx +++ b/src/features/signing/SigV4aLab.tsx @@ -136,8 +136,8 @@ export function SigV4aLab() {{t({ - en: "Reproducible here because this implementation uses the RFC 6979 deterministic nonce. AWS SDKs sign through libcrypto and produce different bytes for the same request. Both verify; only one is reproducible.", - ja: "この実装は RFC 6979 の決定的 nonce を使うので、毎回同じ結果になります。AWS SDK は libcrypto 経由で署名するため、同じリクエストでも毎回バイト列が変わります。どちらも検証は通り、再現できるのは片方だけです。", + en: "Reproducible here because this implementation uses the RFC 6979 deterministic nonce. SDK signers typically use a randomised nonce, so the same request gives different bytes each time. Both verify; only one is reproducible.", + ja: "この実装は RFC 6979 の決定的 nonce を使うので、毎回同じ結果になります。SDK の署名処理は一般に乱数の nonce を使うので、同じリクエストでも毎回バイト列が変わります。どちらも検証は通り、再現できるのは片方だけです。", })}
diff --git a/src/features/signing/sigv4a.test.ts b/src/features/signing/sigv4a.test.ts index 4a6baad..5b2c909 100644 --- a/src/features/signing/sigv4a.test.ts +++ b/src/features/signing/sigv4a.test.ts @@ -23,8 +23,8 @@ import { * The signature is a separate matter. ECDSA is randomised in general, so a * SigV4a signature is not something a vendor could publish as a fixed vector * anyway. @noble/curves happens to default to the deterministic RFC 6979 nonce, - * which makes ours reproducible, but AWS's own implementations sign through - * libcrypto and theirs will not be. What is portable across both is that the + * which makes ours reproducible, while SDK signers typically use a randomised + * nonce (unverified per SDK). What is portable across both is that the * signature verifies against the derived public key, which is the check AWS * actually performs. */ @@ -205,8 +205,8 @@ describe("signing", () => { const b = await signSigV4a(canonicalRequest, AWS_EXAMPLE, "host", options); // Worth stating rather than assuming: ECDSA is randomised in general, and - // AWS's own implementations sign through libcrypto, so the same request - // signed twice by an AWS SDK will not produce identical bytes. @noble's + // SDK signers typically use a randomised nonce, so the same request signed + // twice by an SDK is not expected to produce identical bytes. @noble's // default is the deterministic RFC 6979 nonce, so ours does. Both verify; // only one is reproducible. expect(a.signature).toBe(b.signature); From e812574ab98f31963e077411371a4e01db3f50b1 Mon Sep 17 00:00:00 2001 From: kanywst <45947799+kanywst@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:34:14 +0900 Subject: [PATCH 4/5] docs(signing): fix GetSessionToken/GetFederationToken ceilings and drop the unsourced SDK nonce claim --- docs/signing/03-federation.ja.md | 8 ++++---- docs/signing/03-federation.md | 8 ++++---- docs/signing/04-variants.ja.md | 2 +- docs/signing/04-variants.md | 8 ++++---- docs/signing/05-verification.ja.md | 2 +- docs/signing/05-verification.md | 2 +- docs/signing/README.ja.md | 4 ++-- docs/signing/README.md | 2 +- src/features/signing/SigV4aLab.tsx | 4 ++-- src/features/signing/sigv4a.test.ts | 11 ++++------- 10 files changed, 24 insertions(+), 27 deletions(-) diff --git a/docs/signing/03-federation.ja.md b/docs/signing/03-federation.ja.md index a2885ea..bc50dd6 100644 --- a/docs/signing/03-federation.ja.md +++ b/docs/signing/03-federation.ja.md @@ -4,9 +4,9 @@ [ノートに戻る](README.ja.md) -## 6つの発行 API +## 古典的な 6 つの発行 API -STS は単一の関数ではない。クレデンシャルを返すのは次の操作。 +STS は単一の関数ではない。従来型の 3 点セットのクレデンシャルを返すのは次の 6 操作。アウトバウンドフェデレーション用の `GetWebIdentityToken` を含む STS の全 11 操作は [API operations](../03-api-operations.md) にある。 | API | 入り口の条件 | 上限 | | --- | --- | --- | @@ -14,8 +14,8 @@ STS は単一の関数ではない。クレデンシャルを返すのは次の | `AssumeRoleWithSAML` | SAML assertion、**AWS クレデンシャル不要** | 同上 | | `AssumeRoleWithWebIdentity` | OIDC ID token、**AWS クレデンシャル不要** | 同上 | | `AssumeRoot` | 管理アカウント | 固定15分 | -| `GetSessionToken` | IAM user のキー、必要なら MFA | 12時間 | -| `GetFederationToken` | IAM user のキー | 12時間 | +| `GetSessionToken` | IAM user のキー、必要なら MFA | 36時間 (既定12時間、root user は1時間) | +| `GetFederationToken` | IAM user のキー | 36時間 (既定12時間、root user は1時間) | `AssumeRole` の `DurationSeconds` は900秒以上で、ロールの `MaxSessionDuration` が上限。その `MaxSessionDuration` 自体は1〜12時間で設定できる。既定は3600秒。assume したロールからさらに assume diff --git a/docs/signing/03-federation.md b/docs/signing/03-federation.md index da9a039..0595e31 100644 --- a/docs/signing/03-federation.md +++ b/docs/signing/03-federation.md @@ -4,9 +4,9 @@ [Back to notes](README.md) -## The six issuance APIs +## The six classic issuance APIs -STS is not one function. These are the operations that hand back credentials. +STS is not one function. These six are the operations that hand back the classic three-part credential; the full list of all 11 STS operations, including `GetWebIdentityToken` for outbound federation, is in [API operations](../03-api-operations.md). | API | Entry requirement | Ceiling | | --- | --- | --- | @@ -14,8 +14,8 @@ STS is not one function. These are the operations that hand back credentials. | `AssumeRoleWithSAML` | A SAML assertion, **no AWS credential** | Same | | `AssumeRoleWithWebIdentity` | An OIDC ID token, **no AWS credential** | Same | | `AssumeRoot` | Management account | Fixed 15 minutes | -| `GetSessionToken` | An IAM user key, optionally with MFA | 12 hours | -| `GetFederationToken` | An IAM user key | 12 hours | +| `GetSessionToken` | An IAM user key, optionally with MFA | 36 hours (default 12; root user 1 hour) | +| `GetFederationToken` | An IAM user key | 36 hours (default 12; root user 1 hour) | `DurationSeconds` on `AssumeRole` accepts 900 seconds upward, bounded by the role's `MaxSessionDuration`, which is itself settable between 1 and 12 hours. The default is diff --git a/docs/signing/04-variants.ja.md b/docs/signing/04-variants.ja.md index 22dda3f..167ee81 100644 --- a/docs/signing/04-variants.ja.md +++ b/docs/signing/04-variants.ja.md @@ -120,5 +120,5 @@ WebCrypto だけでは実装できない。公開鍵の導出には「自分で このリポジトリは `@noble/curves` を使っている。 1つ書いておく価値のある帰結。`@noble/curves` は RFC 6979 の決定的 nonce が既定なので、ここでは同じ -リクエストに2回署名すると同じバイト列になる。SDK の署名処理は一般に乱数の nonce を使うので、そうはならない (SDK ごとには未検証)。どちらも +リクエストに2回署名すると同じバイト列になる。これはこの実装の性質で、SigV4a の性質ではない。ECDSA は乱数の nonce も許すので、そういう署名処理なら毎回違うバイト列になり、それでも同じように検証は通る。どちらも 検証は通り、再現するのは一方だけ。SigV4a の署名に固定テストベクタを書こうとすると、ここが効いてくる。 diff --git a/docs/signing/04-variants.md b/docs/signing/04-variants.md index b9ac920..9e9ff06 100644 --- a/docs/signing/04-variants.md +++ b/docs/signing/04-variants.md @@ -127,7 +127,7 @@ point by a scalar you chose, and `SubtleCrypto` exposes no scalar multiplication curve arithmetic has to come from a library. This repository uses `@noble/curves`. One consequence worth stating: `@noble/curves` defaults to the RFC 6979 deterministic -nonce, so signing the same request twice here gives identical bytes. SDK signers typically -use a randomised nonce and will not (unverified per SDK). Both verify; only one is -reproducible, which matters if you ever try to write a fixed test vector for a SigV4a -signature. +nonce, so signing the same request twice here gives identical bytes. That is a property of +this implementation, not of SigV4a: ECDSA permits a random nonce, and a signer that uses +one produces different bytes that verify just as well. It matters if you ever try to write +a fixed test vector for a SigV4a signature. diff --git a/docs/signing/05-verification.ja.md b/docs/signing/05-verification.ja.md index d1a304e..f922086 100644 --- a/docs/signing/05-verification.ja.md +++ b/docs/signing/05-verification.ja.md @@ -66,7 +66,7 @@ Glacier の署名ページには2つ目の例、streaming の `Upload Archive` **SigV4a の署名。** 原理的に固定できない。ECDSA は乱数を使う。代わりに、署名が導出した公開鍵で検証 できることを assert している。これは AWS が実際に行う検証と同じ。(この実装は `@noble/curves` の既定に -より RFC 6979 で決定的になる。SDK 側は一般にそうではないが、SDK ごとには未検証。) +より RFC 6979 で決定的になる。別の署名処理は乱数の nonce を使うかもしれない。) ## レビューで見つかった誤り diff --git a/docs/signing/05-verification.md b/docs/signing/05-verification.md index 864267d..cc8e6d6 100644 --- a/docs/signing/05-verification.md +++ b/docs/signing/05-verification.md @@ -74,7 +74,7 @@ transcription slips; it does not rule out both having read the prose the same wr **SigV4a signatures.** Not pinnable in principle: ECDSA is randomised. What is asserted instead is that a signature verifies against the derived public key, which is the check AWS performs. (This implementation happens to be deterministic because `@noble/curves` -defaults to RFC 6979; SDK signers typically are not, though that is unverified per SDK.) +defaults to RFC 6979; another signer may use a random nonce.) ## Corrections found while reviewing diff --git a/docs/signing/README.ja.md b/docs/signing/README.ja.md index 83a016d..48e34eb 100644 --- a/docs/signing/README.ja.md +++ b/docs/signing/README.ja.md @@ -2,7 +2,7 @@ # ノート -このノートは[署名の章](https://0-draft.github.io/aws-credentials/?lang=ja#signing)の裏付けです。もとは `caller-identity` プロジェクト向けに書いたもので、2026-11-08 にこのリポジトリへ統合した際に移しました。 +このノートは[署名の章](https://0-draft.github.io/aws-credentials/?lang=ja#signing)の裏付け。もとは `caller-identity` プロジェクト向けに書いたもので、2026-11-08 にこのリポジトリへ統合した際に移した。 サイトがインタラクティブに見せている内容の、長い版。パネルに収まらない部分をここに置く。各仕組みが なぜその形なのか、ドキュメントと実装がどこで食い違うのか、そしてここに書いた主張をどう検証したか。 @@ -25,7 +25,7 @@ flowchart TD subgraph issue["クレデンシャルの出所"] iam["IAM{t({ - en: "Reproducible here because this implementation uses the RFC 6979 deterministic nonce. SDK signers typically use a randomised nonce, so the same request gives different bytes each time. Both verify; only one is reproducible.", - ja: "この実装は RFC 6979 の決定的 nonce を使うので、毎回同じ結果になります。SDK の署名処理は一般に乱数の nonce を使うので、同じリクエストでも毎回バイト列が変わります。どちらも検証は通り、再現できるのは片方だけです。", + en: "Reproducible here because this implementation uses the RFC 6979 deterministic nonce. A signer that uses a random nonce, which ECDSA allows, gives different bytes for the same request, and they verify just as well.", + ja: "この実装は RFC 6979 の決定的 nonce を使うので、毎回同じ結果になります。ECDSA は乱数の nonce も許すので、そういう署名処理なら同じリクエストでも毎回バイト列が変わり、それでも同じように検証が通ります。", })}
diff --git a/src/features/signing/sigv4a.test.ts b/src/features/signing/sigv4a.test.ts index 5b2c909..e1093bc 100644 --- a/src/features/signing/sigv4a.test.ts +++ b/src/features/signing/sigv4a.test.ts @@ -23,8 +23,7 @@ import { * The signature is a separate matter. ECDSA is randomised in general, so a * SigV4a signature is not something a vendor could publish as a fixed vector * anyway. @noble/curves happens to default to the deterministic RFC 6979 nonce, - * which makes ours reproducible, while SDK signers typically use a randomised - * nonce (unverified per SDK). What is portable across both is that the + * which makes ours reproducible; a signer using a random nonce would not be. What is portable across both is that the * signature verifies against the derived public key, which is the check AWS * actually performs. */ @@ -204,11 +203,9 @@ describe("signing", () => { const a = await signSigV4a(canonicalRequest, AWS_EXAMPLE, "host", options); const b = await signSigV4a(canonicalRequest, AWS_EXAMPLE, "host", options); - // Worth stating rather than assuming: ECDSA is randomised in general, and - // SDK signers typically use a randomised nonce, so the same request signed - // twice by an SDK is not expected to produce identical bytes. @noble's - // default is the deterministic RFC 6979 nonce, so ours does. Both verify; - // only one is reproducible. + // Worth stating rather than assuming: ECDSA permits a random nonce, and a + // signer using one would produce different bytes each time. @noble's + // default is the deterministic RFC 6979 nonce, so ours does not. expect(a.signature).toBe(b.signature); expect(verifySigV4a(a.signature, a.stringToSign, a.keyPair.publicKeyHex)).toBe(true); }); From 7c2228a81229ef2a73a15c1b8d56cce8eed028ab Mon Sep 17 00:00:00 2001 From: kanywst <45947799+kanywst@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:37:06 +0900 Subject: [PATCH 5/5] docs(signing): state the replay window precisely and realign the JA variants note --- docs/signing/02-sigv4.ja.md | 3 ++- docs/signing/02-sigv4.md | 5 +++-- docs/signing/04-variants.ja.md | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/signing/02-sigv4.ja.md b/docs/signing/02-sigv4.ja.md index 305c096..32a4787 100644 --- a/docs/signing/02-sigv4.ja.md +++ b/docs/signing/02-sigv4.ja.md @@ -46,7 +46,8 @@ sequenceDiagram | `SecretAccessKey` | **絶対に乗らない** | HMAC の入力としてローカルで使うだけ | この非対称性が、署名と bearer token の違いそのもの。署名済みリクエストを傍受しても手に入るのは、 -既に使い終わったその1本だけ。bearer token を傍受すると、クレデンシャルそのものが手に入る。 +その1本だけで、再送できるのも時刻ずれの許容幅の中で、同じメソッド・パス・ボディのままに限られる。 +bearer token を傍受すると、クレデンシャルそのものが手に入る。 ## canonical request diff --git a/docs/signing/02-sigv4.md b/docs/signing/02-sigv4.md index abb7e94..be25f8b 100644 --- a/docs/signing/02-sigv4.md +++ b/docs/signing/02-sigv4.md @@ -47,8 +47,9 @@ Of the three components of a temporary credential, two travel and one does not. | `SecretAccessKey` | **Never** | Used locally as HMAC input only | That asymmetry is the difference between a signature and a bearer token. Intercepting a -signed request gives you that one request, already spent. Intercepting a bearer token -gives you the credential. +signed request gives you that one request, replayable only inside the clock-skew window +and only with the same method, path and body. Intercepting a bearer token gives you the +credential. ## The canonical request diff --git a/docs/signing/04-variants.ja.md b/docs/signing/04-variants.ja.md index 167ee81..7e7f9dc 100644 --- a/docs/signing/04-variants.ja.md +++ b/docs/signing/04-variants.ja.md @@ -120,5 +120,6 @@ WebCrypto だけでは実装できない。公開鍵の導出には「自分で このリポジトリは `@noble/curves` を使っている。 1つ書いておく価値のある帰結。`@noble/curves` は RFC 6979 の決定的 nonce が既定なので、ここでは同じ -リクエストに2回署名すると同じバイト列になる。これはこの実装の性質で、SigV4a の性質ではない。ECDSA は乱数の nonce も許すので、そういう署名処理なら毎回違うバイト列になり、それでも同じように検証は通る。どちらも -検証は通り、再現するのは一方だけ。SigV4a の署名に固定テストベクタを書こうとすると、ここが効いてくる。 +リクエストに2回署名すると同じバイト列になる。これはこの実装の性質で、SigV4a の性質ではない。ECDSA +は乱数の nonce も許すので、そういう署名処理なら毎回違うバイト列になり、それでも同じように検証は通る。 +SigV4a の署名に固定テストベクタを書こうとすると、ここが効いてくる。