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 署名付き
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:` の形で書く。trust policy と resource control policy で使え、[condition key のリファレンス](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html)ではどれもセッションでは利用不可とされている。 + +数値 ID の claim は、`sub` では塞げない穴を塞ぐ。`sub` は名前から組み立てられていて、GitHub ではリネームや削除で空いたリポジトリ名・org 名を別人が取得できる。同じリファレンスも、名前だけに頼らず `repository_id`、`repository_owner_id`、`actor_id` のような不変の ID で照合するよう勧めている。下の ID は placeholder。 + +```json +"StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:repository_owner_id": "123456", + "token.actions.githubusercontent.com:repository_id": "1296269", + "token.actions.githubusercontent.com:ref": "refs/heads/main" +} +``` + +## outbound federation、鏡像 + +2025年後半から矢印は逆方向にも向く。`sts:GetWebIdentityToken` が AWS の身元を、外部サービスが検証 +できる短命の署名付き JWT に交換する。これで AWS のワークロードは、相手の API キーを保管せずに +サードパーティへ認証できる。 + +| 向き | API | JWT に署名するのは | 検証するのは | +| --- | --- | --- | --- | +| AWS へ | `sts:AssumeRoleWithWebIdentity` | GitHub、Google、EKS | AWS STS | +| AWS から | `sts:GetWebIdentityToken` | AWS STS | 外部サービス | + +有効化するとアカウント専用の issuer URL が払い出され、`/.well-known/openid-configuration` と +`/.well-known/jwks.json` をホストする。発行されるトークンの `sub` はロールの ARN。有効期間は60〜3600 +秒で既定は300秒。`Audience`、`DurationSeconds`、`SigningAlgorithm` は開けっ放しにせず IAM policy の +condition で縛るべき。 diff --git a/docs/signing/03-federation.md b/docs/signing/03-federation.md new file mode 100644 index 0000000..3d4721e --- /dev/null +++ b/docs/signing/03-federation.md @@ -0,0 +1,155 @@ +**English** | [日本語](03-federation.ja.md) + +# Federation + +[Back to notes](README.md) + +## The six issuance APIs + +STS is not one function. These are the operations that hand back credentials. + +| API | Entry requirement | Ceiling | +| --- | --- | --- | +| `AssumeRole` | An existing signed credential | Role's `MaxSessionDuration` | +| `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 | + +`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 +3600. Assume a role from an already-assumed role and the ceiling drops to one hour +regardless of any setting, which is the usual explanation for a session that expires +sooner than configured. + +`GetSessionToken` does not grant new permissions. It upgrades an IAM user's long-term key +to a temporary credential carrying the same permissions, optionally with +`aws:MultiFactorAuthPresent` set. The name misleads more often than it helps. + +Session policies and session tags are carried inside the session token, so they make it bigger. In September 2026 STS replaced its separate limits on session token size and on the passed-in parameters with a [single 4,096-byte session token size](https://aws.amazon.com/about-aws/whats-new/2026/09/aws-sts/). The plaintext limits on the inputs themselves still apply, such as 2,048 characters for an inline session policy. The [`AssumeRole` response](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html) now reports `SessionTokenSize` in bytes and `SessionTokenUtilization` as a percentage of the maximum, and marks `PackedPolicySize` as deprecated in favour of the latter. The optional `MinimumSessionTokenSize` request parameter pads the token up to a given size, so a client can check that it copes with a large token before production hands it one. + +## Why two of them are unsigned + +Calling STS normally requires a signature, which requires a credential. Both +`AssumeRoleWithWebIdentity` and `AssumeRoleWithSAML` are callable with no `Authorization` +header at all. The request carries an assertion signed by somebody else, and AWS verifies +that signature instead. + +```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: signed JWT naming repo and ref + + R->>S: POST / (AssumeRoleWithWebIdentity) + Note right of R: no Authorization header
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:`. They are usable in trust policies and resource control policies, and the [condition key reference](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html) lists none of them as available in the session. + +The numeric ones answer a weakness that `sub` cannot. `sub` is built from names, and on GitHub a repository or organization name freed by a rename or deletion can be claimed by someone else. The same reference recommends matching on immutable identifiers such as `repository_id`, `repository_owner_id` and `actor_id` rather than on names alone. The IDs below are placeholders. + +```json +"StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", + "token.actions.githubusercontent.com:repository_owner_id": "123456", + "token.actions.githubusercontent.com:repository_id": "1296269", + "token.actions.githubusercontent.com:ref": "refs/heads/main" +} +``` + +## Outbound federation, the mirror image + +Since late 2025 the arrow also points the other way. `sts:GetWebIdentityToken` exchanges +an AWS identity for a short-lived signed JWT that external services can verify, which lets +an AWS workload authenticate to a third party without storing that party's API key. + +| Direction | API | Who signs the JWT | Who verifies | +| --- | --- | --- | --- | +| Into AWS | `sts:AssumeRoleWithWebIdentity` | GitHub, Google, EKS | AWS STS | +| Out of AWS | `sts:GetWebIdentityToken` | AWS STS | The external service | + +Enabling it mints an issuer URL for the account, hosting the usual +`/.well-known/openid-configuration` and `/.well-known/jwks.json`. The `sub` of the issued +token is the role ARN. Tokens last 60 to 3600 seconds and default to 300, and the +`Audience`, `DurationSeconds` and `SigningAlgorithm` should be constrained by IAM policy +conditions rather than left open. diff --git a/docs/signing/04-variants.ja.md b/docs/signing/04-variants.ja.md new file mode 100644 index 0000000..8de5eba --- /dev/null +++ b/docs/signing/04-variants.ja.md @@ -0,0 +1,122 @@ +[English](04-variants.md) | **日本語** + +# presigned URL と SigV4a + +[ノートに戻る](README.ja.md) + +同じ署名の変種が2つ。片方は署名を URL に移し、もう片方は非対称にする。どちらも「署名が何を守って +いるか」を変えるので、理解しておく価値がある。 + +## presigned URL + +認証情報が `Authorization` ヘッダからクエリ文字列に移る。canonical request の他の部分は変わらない。 + +```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 +``` + +ヘッダ方式との違いは2つで、そこに要点がある。 + +**ペイロードハッシュが文字列 `UNSIGNED-PAYLOAD` そのもの。** 署名時点では何がアップロードされるか +分からないので、ボディを署名対象にできない。つまり presigned な `PUT` が認可しているのは「宛先」で +あって「バイト列」ではない。中身を決めるのは URL を持っている側。 + +**session token はクエリパラメータ**として canonical request の一部になる。署名対象ヘッダではない。 + +### 実質的に何なのか + +期限付きの bearer credential。 + +```mermaid +flowchart LR + you["自分
クレデンシャルを持つ"] -->|ローカルで署名| 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() { + diff --git a/src/data/nav.ts b/src/data/nav.ts index e1adfaf..bfb9baa 100644 --- a/src/data/nav.ts +++ b/src/data/nav.ts @@ -6,6 +6,7 @@ export const NAV: { id: string; label: L }[] = [ { id: "endpoints", label: { en: "Endpoints", ja: "エンドポイント" } }, { id: "api", label: { en: "Every API", ja: "全 API" } }, { id: "paths", label: { en: "Credential paths", ja: "取得経路" } }, + { id: "signing", label: { en: "Signing", ja: "署名" } }, { id: "sessions", label: { en: "Sessions", ja: "セッション" } }, { id: "trust", label: { en: "Trust policies", ja: "信頼ポリシー" } }, { id: "ops", label: { en: "Logs & limits", ja: "ログと上限" } }, diff --git a/src/features/paths/paths.ts b/src/features/paths/paths.ts index 983e583..7e4afc0 100644 --- a/src/features/paths/paths.ts +++ b/src/features/paths/paths.ts @@ -130,8 +130,8 @@ export const PATHS: PathDef[] = [ "imds", "PUT /latest/api/token", { - en: "IMDSv2 starts with a PUT for a session token (TTL 1 s to 21600 s). The PUT response only survives `HttpPutResponseHopLimit` IP hops, which is what keeps bridged containers out when the limit is 1.", - ja: "IMDSv2 はまずセッショントークンを PUT で取ります (TTL 1〜21600 秒)。PUT の応答は `HttpPutResponseHopLimit` 分の IP ホップしか届きません。上限 1 ならブリッジ接続のコンテナからは取れません。", + en: "IMDSv2 starts with a PUT for a session token (TTL 1 s to 21600 s). The PUT response only survives `HttpPutResponseHopLimit` IP hops, which is what keeps bridged containers out when the limit is 1. The PUT with a custom header is the defence: an SSRF bug can usually coerce a GET out of a vulnerable app, rarely a PUT with a custom header, and IMDSv1's bare GET is what turned SSRF into credential theft. None of this leaves the host, so there is no SigV4 and no CloudTrail entry.", + ja: "IMDSv2 はまずセッショントークンを PUT で取ります (TTL 1〜21600 秒)。PUT の応答は `HttpPutResponseHopLimit` 分の IP ホップしか届きません。上限 1 ならブリッジ接続のコンテナからは取れません。カスタムヘッダー付きの PUT であること自体が防御です。SSRF の脆弱性は GET なら引き出せても、カスタムヘッダー付きの PUT はまず引き出せません。IMDSv1 の素の GET が、SSRF を認証情報の窃取に変えていました。この通信はホストの外に出ないので、SigV4 も CloudTrail の記録もありません。", }, { payload: { @@ -147,8 +147,8 @@ X-aws-ec2-metadata-token-ttl-seconds: 21600`, "imds", "GET …/iam/security-credentials/", { - en: "With the token in a header, the SDK reads the role's credentials. Policies can tell IMDSv1 from v2 deliveries via `ec2:RoleDelivery` (`1.0` / `2.0`).", - ja: "SDK はトークンをヘッダーに付けてロールの認証情報を読みます。ポリシーでは `ec2:RoleDelivery` (`1.0` / `2.0`) で、IMDSv1 と v2 のどちらで渡されたかを区別できます。", + en: "With the token in a header, the SDK reads the role's credentials. Policies can tell IMDSv1 from v2 deliveries via `ec2:RoleDelivery` (`1.0` / `2.0`). The JSON calls the session token `Token`; the SDK maps it to what `AWS_SESSION_TOKEN` would hold.", + ja: "SDK はトークンをヘッダーに付けてロールの認証情報を読みます。ポリシーでは `ec2:RoleDelivery` (`1.0` / `2.0`) で、IMDSv1 と v2 のどちらで渡されたかを区別できます。JSON ではセッショントークンの名前が `Token` で、SDK はこれを `AWS_SESSION_TOKEN` に当たる値として扱います。", }, { payload: { @@ -669,8 +669,8 @@ AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE=/var/run/secrets/pods.eks.amazonaws.com/s "gh", "request ID token (aud sts.amazonaws.com)", { - en: "The job needs `permissions: id-token: write`. `aws-actions/configure-aws-credentials` (v6) asks for audience `sts.amazonaws.com`.", - ja: "ジョブに `permissions: id-token: write` が必要です。`aws-actions/configure-aws-credentials` (v6) が audience `sts.amazonaws.com` でトークンを要求します。", + en: "The job needs `permissions: id-token: write`. `aws-actions/configure-aws-credentials` (v6) asks for audience `sts.amazonaws.com` by appending `&audience=sts.amazonaws.com` to `ACTIONS_ID_TOKEN_REQUEST_URL` (which already has a query string) and authenticating with `ACTIONS_ID_TOKEN_REQUEST_TOKEN`. Both variables exist only when the job has `id-token: write`. Without an audience, `aud` defaults to the repository owner's URL, which no AWS trust policy expects.", + ja: "ジョブに `permissions: id-token: write` が必要です。`aws-actions/configure-aws-credentials` (v6) が audience `sts.amazonaws.com` でトークンを要求します。`ACTIONS_ID_TOKEN_REQUEST_URL` (すでにクエリ文字列を持つ) に `&audience=sts.amazonaws.com` を付け、`ACTIONS_ID_TOKEN_REQUEST_TOKEN` で認証します。どちらの変数も `id-token: write` があるジョブにしか入りません。audience を指定しないと `aud` はリポジトリ所有者の URL になり、それを期待する AWS の信頼ポリシーはありません。", }, { payload: { @@ -709,20 +709,20 @@ steps: }, ), m("runner", "sts", "AssumeRoleWithWebIdentity", { - en: "Unsigned call. The default role session name is `GitHubActions`.", - ja: "署名なしの呼び出しです。既定のロールセッション名は `GitHubActions` です。", + en: "Unsigned call: there is no `Authorization` header at all, and the JWT in `WebIdentityToken` (an OIDC ID token, despite the parameter name) is the only proof of identity. The default role session name is `GitHubActions`.", + ja: "署名なしの呼び出しです。`Authorization` ヘッダーそのものがなく、`WebIdentityToken` の JWT (パラメータ名に反して OIDC の ID トークン) だけが身元の証明です。既定のロールセッション名は `GitHubActions` です。", }), m("sts", "iam", "match iss, aud, sub against trust policy", { - en: "STS checks `aud` against the OIDC provider's client ID list, verifies the signature (TLS checked against trusted root CAs, so no thumbprint needed), then evaluates `token.actions.githubusercontent.com:sub`. IAM refuses to save a trust policy for this provider without a non-wildcard `sub` condition.", - ja: "STS は `aud` を OIDC プロバイダのクライアント ID リストと照合し、署名を検証し (TLS は信頼済みルート CA で検証するのでサムプリント不要)、`token.actions.githubusercontent.com:sub` を評価します。IAM はワイルドカードだけでない `sub` 条件がないと、このプロバイダ向けの信頼ポリシーを保存させません。", + en: "STS checks `aud` against the OIDC provider's client ID list, verifies the signature against the keys from the issuer's JWKS (TLS checked against trusted root CAs, so no thumbprint needed), then evaluates `token.actions.githubusercontent.com:sub`. IAM refuses to save a trust policy for this provider without a non-wildcard `sub` condition.", + ja: "STS は `aud` を OIDC プロバイダのクライアント ID リストと照合し、発行者の JWKS の鍵で署名を検証し (TLS は信頼済みルート CA で検証するのでサムプリント不要)、`token.actions.githubusercontent.com:sub` を評価します。IAM はワイルドカードだけでない `sub` 条件がないと、このプロバイダ向けの信頼ポリシーを保存させません。", }), m( "sts", "runner", "assumed-role/deploy/GitHubActions", { - en: "Credentials are exported for the following steps. If `DurationSeconds` exceeds the role max, STS rejects the call instead of clamping it.", - ja: "認証情報が後続ステップに export されます。`DurationSeconds` がロール上限を超えると、STS は切り詰めずにエラーにします。", + en: "Credentials are exported for the following steps. If `DurationSeconds` exceeds the role max, STS rejects the call instead of clamping it. The result echoes `SubjectFromWebIdentityToken`, `Audience` and `Provider` (the `iss` for an OIDC ID token); the credentials themselves carry no trace of which repository asked.", + ja: "認証情報が後続ステップに export されます。`DurationSeconds` がロール上限を超えると、STS は切り詰めずにエラーにします。結果には `SubjectFromWebIdentityToken`・`Audience`・`Provider` (OIDC の ID トークンなら `iss` の値) が返りますが、認証情報そのものにはどのリポジトリが要求したかの痕跡は残りません。", }, { reply: true, diff --git a/src/features/signing/PresignLab.tsx b/src/features/signing/PresignLab.tsx new file mode 100644 index 0000000..54feb14 --- /dev/null +++ b/src/features/signing/PresignLab.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from "react"; +import { Figure, Segmented } from "@/components/ui"; +import type { L } from "@/i18n/lang"; +import { useLang } from "@/i18n/useLang"; +import { rich } from "@/lib/rich"; +import { DATETIME, DEMO_SECRET, TEMP_KEY } from "./demos"; +import { presignUrl, type PresignResult } from "./sigv4"; + +const CREDS = { + accessKeyId: TEMP_KEY, + secretAccessKey: DEMO_SECRET, + sessionToken: "IQoJb3JpZ2luX2VjEBYaCXVzLWVhc3QtMSJHMEUCIQDEXAMPLE", +}; + +/** Seconds; 604800 (seven days) is the S3 ceiling. */ +const EXPIRY_CHOICES = ["60", "300", "900", "86400", "604800"] as const; +type Expiry = (typeof EXPIRY_CHOICES)[number]; + +function expiryLabel(seconds: number): L { + if (seconds >= 86400) { + const d = seconds / 86400; + return { en: `${d} day${d > 1 ? "s" : ""}`, ja: `${d} 日` }; + } + if (seconds >= 60) return { en: `${seconds / 60} min`, ja: `${seconds / 60} 分` }; + return { en: `${seconds} s`, ja: `${seconds} 秒` }; +} + +export function PresignLab() { + const { t } = useLang(); + const [expiry, setExpiry] = useState("900"); + const [result, setResult] = useState<{ expiry: Expiry; r: PresignResult } | null>(null); + + useEffect(() => { + let cancelled = false; + void presignUrl( + { + method: "GET", + path: "/key.txt", + headers: { host: "my-bucket.s3.us-east-1.amazonaws.com" }, + }, + CREDS, + { + region: "us-east-1", + service: "s3", + datetime: DATETIME, + expiresIn: Number(expiry), + doubleEncodePath: false, + }, + ).then((r) => { + if (!cancelled) setResult({ expiry, r }); + }); + return () => { + cancelled = true; + }; + }, [expiry]); + + const r = result?.expiry === expiry ? result.r : null; + const pre = + "scroll-x rounded-lg border border-[var(--line)] bg-[var(--sunk)] px-3 py-2 font-mono text-[0.78rem] leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]"; + + return ( +
+ ({ value: s, label: expiryLabel(Number(s)) }))} + /> + {expiry === "604800" && ( +

+ {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 が認める上限です。署名キー自体がその期間しか有効でないためです。", + })} +

+ )} + {r ? ( +
+
+

+ {t({ en: "The URL", ja: "できた URL" })} +

+
+              {r.url.replace(/&/g, "\n  &")}
+            
+

+ {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 がそのまま認証情報の漏洩になるのはそのためです。", + })} +

+
+
+

+ {t({ en: "Canonical request", ja: "正規リクエスト" })} +

+
+              {r.canonicalRequest}
+            
+

+ {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` に入り、そこで署名対象になります。", + }), + )} +

+
+
+ ) : ( +
+ )} +
+ ); +} diff --git a/src/features/signing/SigV4aLab.tsx b/src/features/signing/SigV4aLab.tsx new file mode 100644 index 0000000..884876c --- /dev/null +++ b/src/features/signing/SigV4aLab.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react"; +import { Figure, Segmented } from "@/components/ui"; +import { useLang } from "@/i18n/useLang"; +import { rich } from "@/lib/rich"; +import { DATETIME } from "./demos"; +import { signSigV4a, verifySigV4a, type SigV4aResult } from "./sigv4a"; + +const CREDS = { + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", +}; + +const REGION_SETS = ["us-east-1", "us-east-1,us-west-2", "us-west-*", "*"] as const; +type RegionSet = (typeof REGION_SETS)[number]; +const SIGNED = "host;x-amz-date;x-amz-region-set"; + +function sigV4aCanonicalRequest(regionSet: string): string { + return [ + "GET", + "/key.txt", + "", + "host:my-bucket.accesspoint.s3-global.amazonaws.com", + `x-amz-date:${DATETIME}`, + `x-amz-region-set:${regionSet}`, + "", + SIGNED, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ].join("\n"); +} + +export function SigV4aLab() { + const { t } = useLang(); + const [set, setSet] = useState("us-east-1,us-west-2"); + const [state, setState] = useState<{ set: RegionSet; r: SigV4aResult } | null>(null); + + useEffect(() => { + let cancelled = false; + void signSigV4a(sigV4aCanonicalRequest(set), CREDS, SIGNED, { + service: "s3", + datetime: DATETIME, + regionSet: set.split(","), + }).then((r) => { + if (!cancelled) setState({ set, r }); + }); + return () => { + cancelled = true; + }; + }, [set]); + + const r = state?.set === set ? state.r : null; + const ok = r + ? verifySigV4a(r.signature, r.stringToSign, r.keyPair.publicKeyHex) + : false; + const pre = + "scroll-x rounded-lg border border-[var(--line)] bg-[var(--sunk)] px-3 py-2 font-mono text-[0.78rem] leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]"; + + return ( +
+ ({ value: s, label: { en: s, ja: s } }))} + /> + {r ? ( +
+
+
+

Credential scope

+
+                {r.credentialScope}
+              
+

+ {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: "Derived keypair (P-256)", ja: "導出された鍵ペア (P-256)" })} +

+
+                {`private  ${r.keyPair.privateKeyHex}\npublic   ${r.keyPair.publicKeyHex}\ncounter  ${r.keyPair.counter}`}
+              
+

+ {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 は署名を検証するとき、自分の側で同じ公開鍵を導出します。", + })} +

+
+
+
+
+

String to sign

+
+                {r.stringToSign}
+              
+
+
+

+ {t({ en: "Signature (DER, hex)", ja: "署名 (DER, hex)" })} +

+ + {r.signature} + + {ok + ? t({ + en: "✓ Verifies against the public key alone", + ja: "✓ 公開鍵だけで検証が通る", + }) + : t({ en: "✗ Does not verify", ja: "✗ 検証が通らない" })} + + +

+ {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 経由で署名するため、同じリクエストでも毎回バイト列が変わります。どちらも検証は通り、再現できるのは片方だけです。", + })} +

+
+
+
+ ) : ( +
+ )} +
+ ); +} diff --git a/src/features/signing/SignatureLab.tsx b/src/features/signing/SignatureLab.tsx new file mode 100644 index 0000000..81b82c3 --- /dev/null +++ b/src/features/signing/SignatureLab.tsx @@ -0,0 +1,298 @@ +import { useEffect, useMemo, useState } from "react"; +import { Figure, Segmented } from "@/components/ui"; +import type { L } from "@/i18n/lang"; +import { useLang } from "@/i18n/useLang"; +import { rich } from "@/lib/rich"; +import { SIGNING_DEMOS, TONE_VAR, type ServerNote, type SigningDemo } from "./demos"; +import { signRequest, type SigV4Result } from "./sigv4"; +import { Wire } from "./Wire"; + +function Block({ title, note, body }: { title: L; note?: L; body: string }) { + const { t } = useLang(); + return ( +
+

{t(title)}

+ {note &&

{rich(t(note))}

} +
+        {body}
+      
+
+ ); +} + +export function ServerNotes({ notes }: { notes: ServerNote[] }) { + const { t } = useLang(); + return ( +
+

+ {t({ en: "On the AWS side", ja: "AWS 側で起きること" })} +

+
    + {notes.map((n) => ( +
  1. +
    + {t(n.title)} +
    +

    {rich(t(n.detail))}

    +
  2. + ))} +
+
+ ); +} + +/** Signs `demo` with the given edits; the result for stale inputs is dropped. */ +function useSignature( + demo: SigningDemo, + edits: { method: string; path: string; headers: Record }, + omitToken: boolean, +): SigV4Result | null { + const [state, setState] = useState<{ key: string; result: SigV4Result } | null>(null); + const key = JSON.stringify([demo.id, edits, omitToken]); + useEffect(() => { + let cancelled = false; + const credentials = omitToken + ? { ...demo.credentials, sessionToken: undefined } + : demo.credentials; + void signRequest({ ...demo.request, ...edits }, credentials, demo.options).then( + (result) => { + if (!cancelled) setState({ key, result }); + }, + ); + return () => { + cancelled = true; + }; + }, [demo, edits, omitToken, key]); + return state?.key === key ? state.result : null; +} + +function Lab({ demo }: { demo: SigningDemo }) { + const { t } = useLang(); + const pristineEdits = useMemo( + () => ({ + method: demo.request.method, + path: demo.request.path, + headers: demo.request.headers, + }), + [demo], + ); + const [edits, setEdits] = useState(pristineEdits); + const [omitToken, setOmitToken] = useState(false); + const pristine = useSignature(demo, pristineEdits, false); + const live = useSignature(demo, edits, omitToken); + const hasToken = Boolean(demo.credentials.sessionToken); + + const auth = useMemo>( + () => (pristine ? [["Authorization", pristine.authorizationHeader]] : []), + [pristine], + ); + + if (!pristine || !live) return
; + const broken = pristine.signature !== live.signature; + const field = + "min-h-10 w-full rounded-lg border border-[var(--line)] bg-[var(--surface)] px-2 font-mono text-sm"; + + return ( +
+
+

{rich(t(demo.blurb))}

+ + +
+ +
+ + +
+

+ {t({ en: "3. Signing key derivation", ja: "3. 署名キーの導出" })} +

+

+ {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: "日付・リージョン・サービスがキーに焼き込まれます。署名キーが漏れても、その日・そのリージョン・そのサービスにしか使えません。", + })} +

+
    + {live.derivation.map((s) => ( +
  1. + {s.label} + {s.keyHex} +
  2. + ))} +
+
+
+

+ {t({ en: "4. Signature", ja: "4. 署名" })} +

+ + {live.signature} + + {broken + ? t({ + en: "✗ SignatureDoesNotMatch: AWS would reject this", + ja: "✗ SignatureDoesNotMatch: AWS はこれを拒否します", + }) + : t({ en: "✓ Matches the original", ja: "✓ 元の署名と一致" })} + + +
+ +
+ + {t({ en: "Break it", ja: "壊してみる" })} + +

+ {t({ + en: "Edit anything. The signature is recomputed on every keystroke; AWS would reject every value that differs from the original.", + ja: "どれでも書き換えてください。署名は入力のたびに再計算されます。元と違う値はすべて AWS に拒否されます。", + })} +

+
+ + setEdits({ ...edits, method: e.target.value })} + className={field} + /> + + setEdits({ ...edits, path: e.target.value })} + className={field} + /> + {Object.entries(edits.headers).map(([name, value]) => ( +
+ + + setEdits({ + ...edits, + headers: { ...edits.headers, [name]: e.target.value }, + }) + } + className={field} + /> +
+ ))} +
+ {hasToken && ( + + )} + {(broken || omitToken) && ( + + )} +
+
+
+ ); +} + +/** Live SigV4: pick a request, read every intermediate value, then break it. */ +export function SignatureLab() { + const [id, setId] = useState(SIGNING_DEMOS[0].id); + const demo = SIGNING_DEMOS.find((d) => d.id === id) ?? SIGNING_DEMOS[0]; + return ( +
+ ({ value: d.id, label: d.label }))} + /> + {/* Keyed so the edit fields start from the chosen request. */} + +
+ ); +} diff --git a/src/features/signing/SigningSection.test.tsx b/src/features/signing/SigningSection.test.tsx new file mode 100644 index 0000000..d823075 --- /dev/null +++ b/src/features/signing/SigningSection.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LangProvider } from "@/i18n/LangContext"; +import { SigningSection } from "./SigningSection"; + +const setup = (initial: "en" | "ja" = "en") => + render( + + + , + ); + +describe("SigningSection", () => { + it("signs live and flags a tampered request", async () => { + setup(); + const user = userEvent.setup(); + await screen.findByText("✓ Matches the original"); + await user.type(screen.getByLabelText("path"), "x"); + await screen.findByText(/SignatureDoesNotMatch: AWS would reject this/); + await user.click(screen.getByRole("button", { name: "Reset" })); + await screen.findByText("✓ Matches the original"); + }); + + it("breaks the signature when the session token is left unsigned", async () => { + setup(); + const user = userEvent.setup(); + await user.click(screen.getByRole("radio", { name: "Temporary credentials → S3" })); + const box = await screen.findByRole("checkbox", { + name: /Leave x-amz-security-token out of SignedHeaders/, + }); + await user.click(box); + await screen.findByText(/SignatureDoesNotMatch: AWS would reject this/); + }); + + it("puts the session token in a presigned URL and verifies SigV4a", async () => { + setup(); + const url = await screen.findByLabelText("presigned URL"); + expect(url.textContent).toContain("X-Amz-Security-Token="); + expect(url.textContent).toContain("X-Amz-Expires=900"); + await screen.findByText("✓ Verifies against the public key alone"); + const scope = screen.getByLabelText("credential scope"); + await waitFor(() => expect(scope.textContent).toBe("20260919/s3/aws4_request")); + }); + + it("opens a wire annotation", async () => { + setup("ja"); + const user = userEvent.setup(); + const bearer = screen.getByRole("button", { + name: "Bearer ABSKQmVkcm9ja0FQSUtleS1leGFtcGxl...", + }); + await user.click(bearer); + expect(bearer).toHaveAttribute("aria-pressed", "true"); + expect( + within(bearer.closest("div")!).getByText( + /この文字列を持っている人が呼び出し元です/, + ), + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/signing/SigningSection.tsx b/src/features/signing/SigningSection.tsx new file mode 100644 index 0000000..967bd52 --- /dev/null +++ b/src/features/signing/SigningSection.tsx @@ -0,0 +1,191 @@ +import { Callout, Figure, More, P, Section, Src } from "@/components/ui"; +import { chapter } from "@/data/nav"; +import { useLang } from "@/i18n/useLang"; +import { BEDROCK_BEARER, BEDROCK_NOTES, CREATE_ACCESS_KEY } from "./demos"; +import { PresignLab } from "./PresignLab"; +import { ServerNotes, SignatureLab } from "./SignatureLab"; +import { SigV4aLab } from "./SigV4aLab"; +import { Wire } from "./Wire"; + +const REPO = "https://github.com/0-draft/aws-credentials/blob/main"; + +export function SigningSection() { + const { t } = useLang(); + return ( +
+

+ {{ + 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: "S3 signs the path exactly as sent; every other service URI-encodes it a second time in the canonical request. Hand-rolled signers that get this wrong work against one and fail against the other.", + ja: "S3 はパスを送ったとおりに署名しますが、それ以外のサービスは正規リクエストの中でパスをもう一度 URI エンコードします。ここを間違えた手書きの署名処理は、片方では動いてもう片方では失敗します。", + }} + + + {{ + en: "Exporting `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN` is the quickest way to hand temporary credentials to a signer, and the one that bites: switch back to a long-term key without unsetting `AWS_SESSION_TOKEN` and the stale token rides along with a key it does not belong to, so every request fails with `InvalidClientTokenId`. Pasting the values into SDK constructor arguments has the opposite problem: static credentials never refresh and die at `Expiration`.", + ja: "`AWS_ACCESS_KEY_ID`・`AWS_SECRET_ACCESS_KEY`・`AWS_SESSION_TOKEN` を export するのは、一時認証情報を署名側に渡すいちばん手早い方法で、いちばん事故が起きる方法です。`AWS_SESSION_TOKEN` を unset せずに長期キーに戻すと、無関係なキーに古いトークンが付いたままになり、すべてのリクエストが `InvalidClientTokenId` で失敗します。値を SDK のコンストラクタ引数に貼り付けると逆の問題が起きます。静的な認証情報は更新されず、`Expiration` の時刻に使えなくなります。", + }} + + + +

+ {{ + 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: "CloudTrail still logs the Bedrock API calls, but the API key travels as an authorization header and is not logged.", + ja: "Bedrock の API 呼び出しは CloudTrail に記録されますが、API キー自体は authorization ヘッダーで送られるので記録されません。", + }} + + +

+ {{ + 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 のページにある別の例は再現できず、その切り分けの経緯は調査メモに残しています。", + }} +

+ +

+ + AWS Signature Version 4 for API requests + + {", "} + + Create a signed AWS API request + + {", "} + + Glacier: example signature calculation + + {", "} + + S3 presigned URLs + + {", "} + + Multi-Region Access Point requests + + {", "} + + Manage access keys for IAM users + + {", "} + + Amazon Bedrock API keys + + {", "} + + {t({ en: "how the signer was verified", ja: "署名処理の検証メモ" })} + +

+
+ ); +} diff --git a/src/features/signing/Wire.tsx b/src/features/signing/Wire.tsx new file mode 100644 index 0000000..fba528e --- /dev/null +++ b/src/features/signing/Wire.tsx @@ -0,0 +1,99 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { useLang } from "@/i18n/useLang"; +import { rich } from "@/lib/rich"; +import { TONE_VAR, wireText, type Annotation, type WireMessage } from "./demos"; + +/** + * Splits the wire text on the annotated substrings and wraps each one in a + * button, so the literal bytes stay readable while every interesting token can + * be opened. Each annotation marks its first occurrence only. + */ +function annotate( + text: string, + annotations: Annotation[], + active: string | null, + onPick: (match: string | null) => void, +): ReactNode[] { + const found = annotations + .map((a) => ({ a, index: text.indexOf(a.match) })) + .filter((hit) => hit.index >= 0) + .sort((x, y) => x.index - y.index); + + const out: ReactNode[] = []; + let cursor = 0; + for (const { a, index } of found) { + if (index < cursor) continue; + if (index > cursor) out.push(text.slice(cursor, index)); + const on = active === a.match; + const color = TONE_VAR[a.tone ?? "info"]; + out.push( + , + ); + cursor = index + a.match.length; + } + if (cursor < text.length) out.push(text.slice(cursor)); + return out; +} + +/** An HTTP message as it goes on the wire, with clickable annotations. */ +export function Wire({ + label, + message, + extraHeaders = [], +}: { + label: string; + message: WireMessage; + /** Injected live, e.g. the Authorization header the signer just produced. */ + extraHeaders?: Array<[string, string]>; +}) { + const { t } = useLang(); + const [active, setActive] = useState(null); + const text = useMemo(() => wireText(message, extraHeaders), [message, extraHeaders]); + const annotations = message.annotations ?? []; + const picked = annotations.find((a) => a.match === active); + + return ( +
+
+ {label} + {annotations.length > 0 && ( + + {t({ + en: `${annotations.length} annotated: click to open`, + ja: `注釈 ${annotations.length} 件 (クリックで表示)`, + })} + + )} +
+
+        {annotate(text, annotations, active, setActive)}
+      
+ {picked && ( +

+ {rich(t(picked.note))} +

+ )} +
+ ); +} diff --git a/src/features/signing/demos.test.ts b/src/features/signing/demos.test.ts new file mode 100644 index 0000000..355d313 --- /dev/null +++ b/src/features/signing/demos.test.ts @@ -0,0 +1,38 @@ +import { BEDROCK_BEARER, CREATE_ACCESS_KEY, SIGNING_DEMOS, wireText } from "./demos"; +import { signRequest } from "./sigv4"; + +describe("signing demos", () => { + it("annotate only text that is actually on the wire", () => { + const messages = [ + ...SIGNING_DEMOS.map((d) => d.wire), + CREATE_ACCESS_KEY.request, + CREATE_ACCESS_KEY.response, + BEDROCK_BEARER, + ]; + for (const m of messages) { + const text = wireText(m); + for (const a of m.annotations ?? []) expect(text).toContain(a.match); + } + }); + + it("wire headers match what the signer signs", () => { + for (const d of SIGNING_DEMOS) { + const wire = Object.fromEntries( + d.wire.headers.map(([k, v]) => [k.toLowerCase(), v]), + ); + for (const [k, v] of Object.entries(d.request.headers)) expect(wire[k]).toBe(v); + expect(d.wire.body).toBe(d.request.body); + } + }); + + it("signs the session token only for temporary credentials", async () => { + const byId = Object.fromEntries(SIGNING_DEMOS.map((d) => [d.id, d])); + const sign = (id: string) => + signRequest(byId[id].request, byId[id].credentials, byId[id].options); + expect((await sign("long-term")).signedHeaders).not.toContain("x-amz-security-token"); + expect((await sign("temporary")).signedHeaders).toContain("x-amz-security-token"); + const sts = await sign("assume-role"); + expect(sts.credentialScope).toBe("20260919/us-east-1/sts/aws4_request"); + expect(sts.authorizationHeader).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIA/); + }); +}); diff --git a/src/features/signing/demos.ts b/src/features/signing/demos.ts new file mode 100644 index 0000000..97e4334 --- /dev/null +++ b/src/features/signing/demos.ts @@ -0,0 +1,382 @@ +import type { L } from "@/i18n/lang"; +import { + EMPTY_PAYLOAD_SHA256, + type SigV4Credentials, + type SigV4Options, + type SigV4Request, +} from "./sigv4"; + +/** + * The signed requests the Signing chapter draws, as data. Every wire message is + * the real shape of the call. Authorization headers are never written here: the + * lab signs each request in the browser and splices the result in, so what the + * page shows is what the algorithm actually produces. + * + * Every key is AWS's own published example value. Nothing here is a secret. + */ + +export const DEMO_SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; +export const LONG_TERM_KEY = "AKIAIOSFODNN7EXAMPLE"; +export const TEMP_KEY = "ASIAIOSFODNN7EXAMPLE"; +export const SESSION_TOKEN = + "IQoJb3JpZ2luX2VjEBYaCXVzLWVhc3QtMSJHMEUCIQDEXAMPLEEXAMPLEEXAMPLE"; +export const DATETIME = "20260919T120000Z"; + +/** How an annotated token on the wire is coloured. */ +export type Tone = "key" | "warn" | "good" | "info"; + +export const TONE_VAR: Record = { + key: "var(--role)", + warn: "var(--deny)", + good: "var(--resource)", + info: "var(--idp)", +}; + +/** Highlights a literal substring of the wire text and explains it. */ +export interface Annotation { + match: string; + note: L; + tone?: Tone; +} + +export interface WireMessage { + /** Request line or status line, verbatim. */ + start: string; + headers: Array<[string, string]>; + body?: string; + annotations?: Annotation[]; +} + +/** What AWS does with the message on its side of the wire. */ +export interface ServerNote { + title: L; + detail: L; + tone?: Tone; +} + +export interface SigningDemo { + id: string; + label: L; + /** One line under the selector: what is different about this request. */ + blurb: L; + request: SigV4Request; + credentials: SigV4Credentials; + options: SigV4Options; + /** The request as it goes on the wire, minus the computed Authorization. */ + wire: WireMessage; + serverSide: ServerNote[]; +} + +/** Renders a wire message as text, the way the lab shows it. */ +export function wireText( + message: WireMessage, + extraHeaders: Array<[string, string]> = [], +): string { + const headers = [...message.headers, ...extraHeaders] + .map(([name, value]) => `${name}: ${value}`) + .join("\n"); + const head = `${message.start}\n${headers}`; + return message.body ? `${head}\n\n${message.body}` : head; +} + +const S3_HOST = "my-bucket.s3.us-east-1.amazonaws.com"; +const S3_OPTIONS: SigV4Options = { + region: "us-east-1", + service: "s3", + datetime: DATETIME, + doubleEncodePath: false, +}; +const s3Get = (): SigV4Request => ({ + method: "GET", + path: "/key.txt", + headers: { + host: S3_HOST, + "x-amz-content-sha256": EMPTY_PAYLOAD_SHA256, + "x-amz-date": DATETIME, + }, +}); + +const ASSUME_ROLE_BODY = + "Action=AssumeRole&RoleArn=arn%3Aaws%3Aiam%3A%3A111111111111%3Arole%2FAppRole&RoleSessionName=demo-session&Version=2011-06-15"; + +export const SIGNING_DEMOS: SigningDemo[] = [ + { + id: "long-term", + label: { en: "Long-term key → S3", ja: "長期キー → S3" }, + blurb: { + en: "Two components only, so there is no `X-Amz-Security-Token` header. The signing maths is otherwise identical to the temporary-credential case.", + ja: "要素が 2 つだけなので `X-Amz-Security-Token` ヘッダーがありません。それ以外の署名計算は一時認証情報の場合とまったく同じです。", + }, + request: s3Get(), + credentials: { accessKeyId: LONG_TERM_KEY, secretAccessKey: DEMO_SECRET }, + options: S3_OPTIONS, + wire: { + start: "GET /key.txt HTTP/1.1", + headers: [ + ["Host", S3_HOST], + ["X-Amz-Content-Sha256", EMPTY_PAYLOAD_SHA256], + ["X-Amz-Date", DATETIME], + ], + annotations: [ + { + match: "X-Amz-Date", + tone: "info", + note: { + en: "S3 rejects a timestamp more than 15 minutes from its own clock with `RequestTimeTooSkewed`. AWS's general guidance is tighter: in most cases a signed request must arrive within five minutes. Either way the point is to bound how long an intercepted request stays replayable.", + ja: "S3 は自分の時計から 15 分以上ずれたタイムスタンプを `RequestTimeTooSkewed` で拒否します。AWS の一般的な案内はもっと厳しく、多くの場合は署名から 5 分以内に届く必要があります。どちらにせよ狙いは、傍受されたリクエストを使い回せる時間を区切ることです。", + }, + }, + { + match: "X-Amz-Content-Sha256", + tone: "info", + note: { + en: "SHA-256 of the body. An empty body still hashes, which is why the constant `e3b0c442…` shows up everywhere.", + ja: "ボディの SHA-256 です。空のボディでもハッシュは取るので、定数 `e3b0c442…` があちこちに出てきます。", + }, + }, + ], + }, + serverSide: [ + { + title: { + en: "Look the secret up by key ID", + ja: "キー ID からシークレットを引く", + }, + detail: { + en: "`Credential=` names `AKIA…`, so AWS fetches the stored secret for that IAM user and recomputes. There is no session to resolve.", + ja: "`Credential=` が `AKIA…` なので、AWS はその IAM ユーザーの保管済みシークレットを引いて再計算します。解決すべきセッションはありません。", + }, + }, + { + title: { + en: "The principal is the user itself", + ja: "プリンシパルはユーザーそのもの", + }, + detail: { + en: "No role, no session name. CloudTrail records `user/app-user`, so attribution stops at the key rather than at a person.", + ja: "ロールもセッション名もありません。CloudTrail には `user/app-user` としか残らないので、追跡はキーで止まり、人まで届きません。", + }, + tone: "warn", + }, + ], + }, + { + id: "assume-role", + label: { en: "AssumeRole → STS", ja: "AssumeRole → STS" }, + blurb: { + en: "STS is an ordinary AWS service, so the call that hands out temporary credentials is itself signed with credentials you already hold. The service in the credential scope is `sts`, and the form body is hashed into the signature.", + ja: "STS も普通の AWS サービスなので、一時認証情報をもらう呼び出し自体を、すでに持っている認証情報で署名します。credential scope のサービス名は `sts` で、フォームのボディもハッシュされて署名に入ります。", + }, + request: { + method: "POST", + path: "/", + headers: { + host: "sts.us-east-1.amazonaws.com", + "content-type": "application/x-www-form-urlencoded; charset=utf-8", + "x-amz-date": DATETIME, + }, + body: ASSUME_ROLE_BODY, + }, + credentials: { accessKeyId: LONG_TERM_KEY, secretAccessKey: DEMO_SECRET }, + options: { region: "us-east-1", service: "sts", datetime: DATETIME }, + wire: { + start: "POST / HTTP/1.1", + headers: [ + ["Host", "sts.us-east-1.amazonaws.com"], + ["Content-Type", "application/x-www-form-urlencoded; charset=utf-8"], + ["X-Amz-Date", DATETIME], + ], + body: ASSUME_ROLE_BODY, + annotations: [ + { + match: "RoleSessionName=demo-session", + tone: "key", + note: { + en: "This string becomes the session half of `assumed-role/AppRole/demo-session` in CloudTrail. The caller picks it, so it is only as trustworthy as the caller.", + ja: "この文字列が CloudTrail の `assumed-role/AppRole/demo-session` の後半になります。呼び出し側が自由に決めるので、信頼できる度合いは呼び出し側と同じです。", + }, + }, + ], + }, + serverSide: [ + { + title: { en: "Authenticate the caller first", ja: "まず呼び出し元を認証する" }, + detail: { + en: "STS verifies the SigV4 signature to establish who is asking. Only then is the question of whether they may assume the role reachable: the trust policy and the caller's own `sts:AssumeRole` permission come after.", + ja: "STS は SigV4 署名を検証して、誰が頼んでいるかを確定します。ロールを引き受けてよいかという問いに進めるのはその後で、信頼ポリシーと呼び出し元自身の `sts:AssumeRole` 権限はそこで評価されます。", + }, + }, + { + title: { en: "Mint the session", ja: "セッションを発行する" }, + detail: { + en: "A fresh secret is generated and bound, with the session's policies and expiry, to the session token the caller will send back on every request. The response carries an `ASIA…` key ID that is different from the one that signed this request.", + ja: "新しいシークレットが生成され、セッションのポリシーと有効期限とともにセッショントークンに結び付けられます。呼び出し側は以後、そのトークンを毎回送り返します。レスポンスの `ASIA…` キー ID は、このリクエストに署名したキーとは別物です。", + }, + tone: "good", + }, + ], + }, + { + id: "temporary", + label: { en: "Temporary credentials → S3", ja: "一時認証情報 → S3" }, + blurb: { + en: "The three parts travel together: `ASIA…` goes in `Credential=`, the secret never leaves the process, and the session token rides in its own header, which must be one of the signed headers.", + ja: "3 つの要素がそろって初めて成立します。`ASIA…` は `Credential=` に入り、シークレットはプロセスの外に出ず、セッショントークンは専用ヘッダーに乗ります。そのヘッダーは署名対象のヘッダーに含める必要があります。", + }, + request: s3Get(), + credentials: { + accessKeyId: TEMP_KEY, + secretAccessKey: DEMO_SECRET, + sessionToken: SESSION_TOKEN, + }, + options: S3_OPTIONS, + wire: { + start: "GET /key.txt HTTP/1.1", + headers: [ + ["Host", S3_HOST], + ["X-Amz-Content-Sha256", EMPTY_PAYLOAD_SHA256], + ["X-Amz-Date", DATETIME], + ["X-Amz-Security-Token", SESSION_TOKEN], + ], + annotations: [ + { + match: "X-Amz-Security-Token", + tone: "key", + note: { + en: "The third component, and the one people forget. AWS does not document its format; what matters is that AWS resolves it to this session's secret and attached policies, and that it is covered by the signature.", + ja: "3 つ目の要素で、忘れられがちなものです。AWS は中身の形式を公開していません。大事なのは、AWS がこれをこのセッションのシークレットと付随するポリシーに解決すること、そして署名の対象に含まれることです。", + }, + }, + ], + }, + serverSide: [ + { + title: { + en: "Rebuild the canonical request", + ja: "正規リクエストを組み立て直す", + }, + detail: { + en: "S3 reads the method, path, query, the headers named in `SignedHeaders` and the payload hash, and assembles the identical string.", + ja: "S3 はメソッド、パス、クエリ、`SignedHeaders` に挙がったヘッダー、ペイロードハッシュを読み、まったく同じ文字列を組み立てます。", + }, + }, + { + title: { + en: "Recover the session secret", + ja: "セッションのシークレットを復元する", + }, + detail: { + en: "`Credential=` names `ASIA…`, so the secret used to recompute comes from the session that `X-Amz-Security-Token` identifies, together with any session policy attached at issuance, not from a stored IAM user key.", + ja: "`Credential=` が `ASIA…` なので、再計算に使うシークレットは保管された IAM ユーザーのキーではなく、`X-Amz-Security-Token` が示すセッションから来ます。発行時に付いたセッションポリシーも一緒に効きます。", + }, + }, + { + title: { en: "Compare, then authorize", ja: "照合してから認可する" }, + detail: { + en: "The signatures match, so the principal is `assumed-role/AppRole/demo-session`. Only then are the identity policy, bucket policy, SCPs and session policy evaluated.", + ja: "署名が一致したので、プリンシパルは `assumed-role/AppRole/demo-session` に確定します。ID ポリシー、バケットポリシー、SCP、セッションポリシーが評価されるのはその後です。", + }, + tone: "good", + }, + ], + }, +]; + +/** Where an `AKIA…` key comes from: IAM, not STS. */ +export const CREATE_ACCESS_KEY: { request: WireMessage; response: WireMessage } = { + request: { + start: "POST / HTTP/1.1", + headers: [ + ["Host", "iam.amazonaws.com"], + ["Content-Type", "application/x-www-form-urlencoded; charset=utf-8"], + ], + body: "Action=CreateAccessKey&UserName=app-user&Version=2010-05-08", + }, + response: { + start: "HTTP/1.1 200 OK", + headers: [["Content-Type", "text/xml"]], + body: ` + + + app-user + ${LONG_TERM_KEY} + ${DEMO_SECRET} + Active + 2026-09-19T12:00:00Z + + +`, + annotations: [ + { + match: "2026-09-19T12:00:00Z", + tone: "warn", + note: { + en: "There is a creation date and no expiry to go with it. Age is the only signal you get, which is why key-age reports exist at all.", + ja: "作成日時はあるのに、対になる有効期限がありません。手がかりは経過時間だけです。キーの古さを報告する仕組みが必要になるのはそのためです。", + }, + }, + { + match: "", + tone: "key", + note: { + en: "Shown once, in this response, and never again. AWS keeps its own copy so it can recompute signatures later; nothing about the key encodes an expiry, a role or a session.", + ja: "表示されるのはこのレスポンスの一度きりです。AWS は後で署名を再計算するために自分の側でも保持します。キー自体には有効期限もロールもセッションも入っていません。", + }, + }, + ], + }, +}; + +/** The one AWS path that skips SigV4 entirely. */ +export const BEDROCK_BEARER: WireMessage = { + start: "POST /model/us.anthropic.claude-sonnet-4-6/converse HTTP/1.1", + headers: [ + ["Host", "bedrock-runtime.us-east-1.amazonaws.com"], + ["Content-Type", "application/json"], + ["Authorization", "Bearer ABSKQmVkcm9ja0FQSUtleS1leGFtcGxl..."], + ], + body: `{"messages":[{"role":"user","content":[{"text":"Hello"}]}]}`, + annotations: [ + { + match: "Bearer ABSKQmVkcm9ja0FQSUtleS1leGFtcGxl...", + tone: "warn", + note: { + en: "Whoever holds this string is the caller. No signature binds it to this request, so it works for any request until it expires.", + ja: "この文字列を持っている人が呼び出し元です。このリクエストに結び付ける署名がないので、有効期限までどのリクエストにも使えます。", + }, + }, + { + match: "Content-Type", + tone: "info", + note: { + en: "No `X-Amz-Date` either, so there is no clock-skew window and no replay bound from a timestamp.", + ja: "`X-Amz-Date` もありません。時刻ずれの許容幅も、タイムスタンプによるリプレイの制限も存在しません。", + }, + }, + ], +}; + +export const BEDROCK_NOTES: ServerNote[] = [ + { + title: { en: "Looked up, not recomputed", ja: "再計算ではなく照合" }, + detail: { + en: "Bedrock validates the token itself. Nothing about the request body or headers is covered by it.", + ja: "Bedrock はトークンそのものを検証します。リクエストのボディやヘッダーはこのトークンで一切保護されていません。", + }, + }, + { + title: { en: "A long-term key hides behind it", ja: "裏に長期キーが隠れている" }, + detail: { + en: "Creating a long-term Bedrock API key creates an IAM user and attaches `AmazonBedrockLimitedAccess` to it. Short-term keys inherit the permissions of the principal that generated them and expire within 12 hours.", + ja: "長期の Bedrock API キーを作ると、IAM ユーザーが作られて `AmazonBedrockLimitedAccess` が付きます。短期キーは生成したプリンシパルの権限を引き継ぎ、12 時間以内に失効します。", + }, + tone: "warn", + }, + { + title: { en: "Deny it if you do not need it", ja: "使わないなら禁止する" }, + detail: { + en: "AWS recommends blocking API key creation with an SCP when the use case does not require it, precisely because it walks back a no-long-term-keys posture.", + ja: "AWS 自身が、必要ないなら SCP で作成を禁止するよう勧めています。長期キーを置かない方針を巻き戻すものだからです。", + }, + }, +]; diff --git a/src/features/signing/sigv4.test.ts b/src/features/signing/sigv4.test.ts new file mode 100644 index 0000000..aa0ce97 --- /dev/null +++ b/src/features/signing/sigv4.test.ts @@ -0,0 +1,412 @@ +import { describe, expect, it } from "vitest"; +import { + EMPTY_PAYLOAD_SHA256, + presignUrl, + signRequest, + UNSIGNED_PAYLOAD, + uriEncode, + type SigV4Credentials, +} from "./sigv4"; + +/** + * Test vectors published by AWS in the Amazon Glacier "Example Signature + * Calculation" reference. They are the oracle: if our canonical request, our + * string to sign and our final signature all match theirs byte for byte, the + * signer is computing what AWS computes. + * + * https://docs.aws.amazon.com/amazonglacier/latest/dev/amazon-glacier-signing-requests.html + */ +const AWS_EXAMPLE: SigV4Credentials = { + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", +}; + +describe("signRequest against the AWS published vectors", () => { + it("reproduces the Create Vault signature", async () => { + const result = await signRequest( + { + method: "PUT", + path: "/-/vaults/examplevault", + headers: { + host: "glacier.us-east-1.amazonaws.com", + "x-amz-date": "20120525T002453Z", + "x-amz-glacier-version": "2012-06-01", + }, + }, + AWS_EXAMPLE, + { region: "us-east-1", service: "glacier", datetime: "20120525T002453Z" }, + ); + + expect(result.canonicalRequest).toBe( + [ + "PUT", + "/-/vaults/examplevault", + "", + "host:glacier.us-east-1.amazonaws.com", + "x-amz-date:20120525T002453Z", + "x-amz-glacier-version:2012-06-01", + "", + "host;x-amz-date;x-amz-glacier-version", + EMPTY_PAYLOAD_SHA256, + ].join("\n"), + ); + + expect(result.canonicalRequestHash).toBe( + "5f1da1a2d0feb614dd03d71e87928b8e449ac87614479332aced3a701f916743", + ); + + expect(result.stringToSign).toBe( + [ + "AWS4-HMAC-SHA256", + "20120525T002453Z", + "20120525/us-east-1/glacier/aws4_request", + "5f1da1a2d0feb614dd03d71e87928b8e449ac87614479332aced3a701f916743", + ].join("\n"), + ); + + expect(result.signature).toBe( + "3ce5b2f2fffac9262b4da9256f8d086b4aaf42eba5f111c21681a65a127b7c2a", + ); + + expect(result.authorizationHeader).toBe( + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20120525/us-east-1/glacier/aws4_request, " + + "SignedHeaders=host;x-amz-date;x-amz-glacier-version, " + + "Signature=3ce5b2f2fffac9262b4da9256f8d086b4aaf42eba5f111c21681a65a127b7c2a", + ); + }); + + /** + * The same page publishes a second example, for the streaming Upload Archive + * call. Its canonical request reproduces exactly, but its published signature + * (b092397439375d59119072764a1e9a144677c43d9906fd98a5742c57a2855de6) does + * not: from the canonical request the page itself prints, both this signer + * and an independent Python implementation produce e8ba379a74... instead. + * Adding the x-amz-archive-description and x-amz-sha256-tree-hash headers + * that the page's request syntax shows but its canonical request omits gives + * a third value, so that is not the explanation either. + * + * The AWS-published signature appears to be stale, so we assert the part that + * is verifiable: the canonical request, which is what AWS rebuilds on its + * side, matches the documentation byte for byte. + */ + it("reproduces the streaming Upload Archive canonical request", async () => { + const payloadHash = + "726e392cb4d09924dbad1cc0ba3b00c3643d03d14cb4b823e2f041cff612a628"; + + const result = await signRequest( + { + method: "POST", + path: "/-/vaults/examplevault", + headers: { + host: "glacier.us-east-1.amazonaws.com", + "x-amz-content-sha256": payloadHash, + "x-amz-date": "20120507T000000Z", + "x-amz-glacier-version": "2012-06-01", + }, + }, + AWS_EXAMPLE, + { + region: "us-east-1", + service: "glacier", + datetime: "20120507T000000Z", + payloadHash, + }, + ); + + expect(result.canonicalRequest).toBe( + [ + "POST", + "/-/vaults/examplevault", + "", + "host:glacier.us-east-1.amazonaws.com", + `x-amz-content-sha256:${payloadHash}`, + "x-amz-date:20120507T000000Z", + "x-amz-glacier-version:2012-06-01", + "", + "host;x-amz-content-sha256;x-amz-date;x-amz-glacier-version", + payloadHash, + ].join("\n"), + ); + + // Cross-checked against an independent implementation, not against the + // page's published value. See the comment above. + expect(result.signature).toBe( + "e8ba379a747bc294584102fd2430f7a563696740882e149c87b15754e7c10a89", + ); + }); + + it("derives the payload hash from the body itself", async () => { + // AWS states this body hashes to the x-amz-content-sha256 above, so hashing + // it ourselves is an independent check on the digest path. + const result = await signRequest( + { + method: "POST", + path: "/-/vaults/examplevault", + headers: { host: "glacier.us-east-1.amazonaws.com" }, + body: "Welcome to Amazon Glacier.", + }, + AWS_EXAMPLE, + { region: "us-east-1", service: "glacier", datetime: "20120507T000000Z" }, + ); + + expect(result.payloadHash).toBe( + "726e392cb4d09924dbad1cc0ba3b00c3643d03d14cb4b823e2f041cff612a628", + ); + }); +}); + +describe("what the signature actually covers", () => { + const base = { + method: "GET", + path: "/my-bucket/key.txt", + headers: { + host: "s3.us-east-1.amazonaws.com", + "x-amz-date": "20260919T120000Z", + }, + } as const; + + const opts = { + region: "us-east-1", + service: "s3", + datetime: "20260919T120000Z", + doubleEncodePath: false, + }; + + it("changes when a signed header value is tampered with", async () => { + const original = await signRequest(base, AWS_EXAMPLE, opts); + const tampered = await signRequest( + { ...base, headers: { ...base.headers, host: "s3.us-west-2.amazonaws.com" } }, + AWS_EXAMPLE, + opts, + ); + + expect(tampered.signature).not.toBe(original.signature); + }); + + it("changes when the body is tampered with", async () => { + const original = await signRequest( + { ...base, method: "PUT", body: "hello" }, + AWS_EXAMPLE, + opts, + ); + const tampered = await signRequest( + { ...base, method: "PUT", body: "hellp" }, + AWS_EXAMPLE, + opts, + ); + + expect(tampered.signature).not.toBe(original.signature); + }); + + it("scopes the signing key to date, region and service", async () => { + const s3 = await signRequest(base, AWS_EXAMPLE, opts); + const dynamodb = await signRequest(base, AWS_EXAMPLE, { + ...opts, + service: "dynamodb", + }); + const tomorrow = await signRequest(base, AWS_EXAMPLE, { + ...opts, + datetime: "20260920T120000Z", + }); + + const kSigning = (r: Awaited>) => + r.derivation.at(-1)!.keyHex; + + expect(kSigning(dynamodb)).not.toBe(kSigning(s3)); + expect(kSigning(tomorrow)).not.toBe(kSigning(s3)); + }); +}); + +describe("temporary credentials", () => { + it("folds the session token into SignedHeaders", async () => { + const result = await signRequest( + { + method: "GET", + path: "/", + headers: { + host: "s3.us-east-1.amazonaws.com", + "x-amz-date": "20260919T120000Z", + }, + }, + { + accessKeyId: "ASIAIOSFODNN7EXAMPLE", + secretAccessKey: AWS_EXAMPLE.secretAccessKey, + sessionToken: "IQoJb3JpZ2luX2VjEExampleSessionToken", + }, + { region: "us-east-1", service: "s3", datetime: "20260919T120000Z" }, + ); + + expect(result.signedHeaders).toBe("host;x-amz-date;x-amz-security-token"); + expect(result.wireHeaders["x-amz-security-token"]).toBe( + "IQoJb3JpZ2luX2VjEExampleSessionToken", + ); + }); + + it("produces a different signature than the same request without the token", async () => { + const request = { + method: "GET", + path: "/", + headers: { + host: "s3.us-east-1.amazonaws.com", + "x-amz-date": "20260919T120000Z", + }, + }; + const opts = { + region: "us-east-1", + service: "s3", + datetime: "20260919T120000Z", + }; + + const withToken = await signRequest( + request, + { ...AWS_EXAMPLE, sessionToken: "IQoJb3JpZ2luX2Vj" }, + opts, + ); + const withoutToken = await signRequest(request, AWS_EXAMPLE, opts); + + expect(withToken.signature).not.toBe(withoutToken.signature); + }); +}); + +describe("uriEncode", () => { + it("is stricter than encodeURIComponent", () => { + expect(uriEncode("a!b*c'd(e)")).toBe("a%21b%2Ac%27d%28e%29"); + }); + + it("leaves unreserved characters alone", () => { + expect(uriEncode("AZaz09-_.~")).toBe("AZaz09-_.~"); + }); + + it("encodes multi-byte characters per UTF-8 byte", () => { + expect(uriEncode("あ")).toBe("%E3%81%82"); + }); +}); + +describe("presignUrl against the AWS published example", () => { + /** + * From "Authenticating Requests: Using Query Parameters (AWS Signature + * Version 4)", which states outright that the example can be used as a test + * case. Same bucket, key, timestamp and example credentials. + * + * https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-query-string-auth.html + */ + it("reproduces the shareable test.txt URL", async () => { + const result = await presignUrl( + { + method: "GET", + path: "/test.txt", + headers: { host: "examplebucket.s3.amazonaws.com" }, + }, + AWS_EXAMPLE, + { + region: "us-east-1", + service: "s3", + datetime: "20130524T000000Z", + expiresIn: 86400, + doubleEncodePath: false, + }, + ); + + expect(result.canonicalRequest).toBe( + [ + "GET", + "/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", + "host:examplebucket.s3.amazonaws.com", + "", + "host", + "UNSIGNED-PAYLOAD", + ].join("\n"), + ); + + expect(result.canonicalRequestHash).toBe( + "3bfa292879f6447bbcda7001decf97f4a54dc650c8942174ae0a9121cf58ad04", + ); + + expect(result.signature).toBe( + "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404", + ); + + expect(result.url).toBe( + "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", + ); + }); + + it("carries the session token as a query parameter, not a header", async () => { + const result = await presignUrl( + { + method: "GET", + path: "/test.txt", + headers: { host: "examplebucket.s3.us-east-1.amazonaws.com" }, + }, + { + ...AWS_EXAMPLE, + accessKeyId: "ASIAIOSFODNN7EXAMPLE", + sessionToken: "IQoJb3JpZ2luX2Vj/EXAMPLE+TOKEN", + }, + { + region: "us-east-1", + service: "s3", + datetime: "20260919T120000Z", + expiresIn: 900, + doubleEncodePath: false, + }, + ); + + // Signed as a query parameter, so it appears in the canonical request. + expect(result.canonicalRequest).toContain("X-Amz-Security-Token=IQoJ"); + // And it is not a signed header, unlike the header-auth case. + expect(result.signedHeaders).toBe("host"); + // Reserved characters are percent-encoded in the URL. + expect(result.url).toContain( + "X-Amz-Security-Token=IQoJb3JpZ2luX2Vj%2FEXAMPLE%2BTOKEN", + ); + }); + + it("signs UNSIGNED-PAYLOAD rather than a body hash", async () => { + const result = await presignUrl( + { + method: "PUT", + path: "/upload.bin", + headers: { host: "examplebucket.s3.amazonaws.com" }, + body: "this body is not known at signing time", + }, + AWS_EXAMPLE, + { + region: "us-east-1", + service: "s3", + datetime: "20260919T120000Z", + expiresIn: 300, + doubleEncodePath: false, + }, + ); + + expect(result.payloadHash).toBe(UNSIGNED_PAYLOAD); + expect(result.canonicalRequest.endsWith("UNSIGNED-PAYLOAD")).toBe(true); + }); + + it("changes the signature when the expiry changes", async () => { + const base = { + method: "GET", + path: "/test.txt", + headers: { host: "examplebucket.s3.amazonaws.com" }, + }; + const opts = { + region: "us-east-1", + service: "s3", + datetime: "20130524T000000Z", + doubleEncodePath: false, + }; + + const day = await presignUrl(base, AWS_EXAMPLE, { ...opts, expiresIn: 86400 }); + const minute = await presignUrl(base, AWS_EXAMPLE, { ...opts, expiresIn: 60 }); + + expect(minute.signature).not.toBe(day.signature); + }); +}); diff --git a/src/features/signing/sigv4.ts b/src/features/signing/sigv4.ts new file mode 100644 index 0000000..e257f80 --- /dev/null +++ b/src/features/signing/sigv4.ts @@ -0,0 +1,334 @@ +/** + * A real AWS Signature Version 4 implementation running on WebCrypto. + * + * Every intermediate value is returned, not just the final signature, because + * the intermediates are the whole point: the canonical request is what AWS + * rebuilds on its side, and the signing-key chain is what scopes a leaked key + * to one day, one region and one service. + * + * Verified against the signature examples published by AWS. See sigv4.test.ts. + */ + +const encoder = new TextEncoder(); + +export const EMPTY_PAYLOAD_SHA256 = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +/** Payload hash sentinel accepted by S3 in place of a real body hash. */ +export const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; + +export interface SigV4Request { + method: string; + /** Path only, already in the form the server will see, e.g. `/-/vaults/examplevault`. */ + path: string; + /** Query parameters as ordered pairs. They are sorted during canonicalisation. */ + query?: Array<[string, string]>; + /** Headers to sign. Names are case-insensitive; `host` is required. */ + headers: Record; + body?: string; +} + +export interface SigV4Credentials { + accessKeyId: string; + secretAccessKey: string; + /** Present only for temporary credentials issued by STS. */ + sessionToken?: string; +} + +export interface SigV4Options { + region: string; + service: string; + /** ISO8601 basic format, e.g. `20120525T002453Z`. */ + datetime: string; + /** + * S3 signs the path as-is; every other service URI-encodes it a second time. + * Defaults to `true` (the non-S3 behaviour). + */ + doubleEncodePath?: boolean; + /** + * Overrides the computed body hash. Use {@link UNSIGNED_PAYLOAD} for large + * S3 uploads, or supply a precomputed digest. + */ + payloadHash?: string; +} + +export interface DerivationStep { + /** Human-readable label, e.g. `kDate = HMAC("AWS4" + secret, "20120525")`. */ + label: string; + input: string; + keyHex: string; +} + +export interface SigV4Result { + canonicalRequest: string; + canonicalRequestHash: string; + credentialScope: string; + stringToSign: string; + signedHeaders: string; + payloadHash: string; + derivation: DerivationStep[]; + signature: string; + authorizationHeader: string; + /** The headers as they go on the wire, including Authorization. */ + wireHeaders: Record; +} + +function toHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function sha256Hex(data: string): Promise { + return toHex(await crypto.subtle.digest("SHA-256", encoder.encode(data))); +} + +async function hmac(key: BufferSource, data: string): Promise { + const cryptoKey = await crypto.subtle.importKey( + "raw", + key, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(data)); +} + +/** + * RFC 3986 percent-encoding. AWS treats only `A-Za-z0-9-_.~` as unreserved, + * which is stricter than `encodeURIComponent` (it leaves `!*'()` alone). + */ +export function uriEncode(value: string, encodeSlash = true): string { + let out = ""; + for (const char of value) { + if (/[A-Za-z0-9\-_.~]/.test(char)) { + out += char; + } else if (char === "/" && !encodeSlash) { + out += char; + } else { + for (const byte of encoder.encode(char)) { + out += "%" + byte.toString(16).toUpperCase().padStart(2, "0"); + } + } + } + return out; +} + +function canonicalPath(path: string, doubleEncode: boolean): string { + if (path === "") return "/"; + if (!doubleEncode) return path; + return path + .split("/") + .map((segment) => uriEncode(segment)) + .join("/"); +} + +function canonicalQuery(query: Array<[string, string]>): string { + return query + .map(([k, v]) => [uriEncode(k), uriEncode(v)] as const) + .sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : 1) : a[0] < b[0] ? -1 : 1)) + .map(([k, v]) => `${k}=${v}`) + .join("&"); +} + +interface CanonicalHeaders { + canonical: string; + signedHeaders: string; +} + +function canonicalHeaders(headers: Record): CanonicalHeaders { + const normalised = Object.entries(headers) + .map( + ([name, value]) => + [name.toLowerCase().trim(), value.trim().replace(/\s+/g, " ")] as const, + ) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + + return { + canonical: normalised.map(([n, v]) => `${n}:${v}\n`).join(""), + signedHeaders: normalised.map(([n]) => n).join(";"), + }; +} + +/** + * Everything after the canonical request is identical for header auth and + * query auth, so both paths share this. + */ +async function finishSignature( + canonicalRequest: string, + credentials: SigV4Credentials, + options: SigV4Options, +): Promise<{ + canonicalRequestHash: string; + credentialScope: string; + stringToSign: string; + derivation: DerivationStep[]; + signature: string; +}> { + const { region, service, datetime } = options; + const date = datetime.slice(0, 8); + + const canonicalRequestHash = await sha256Hex(canonicalRequest); + const credentialScope = `${date}/${region}/${service}/aws4_request`; + const stringToSign = [ + "AWS4-HMAC-SHA256", + datetime, + credentialScope, + canonicalRequestHash, + ].join("\n"); + + const kDate = await hmac(encoder.encode(`AWS4${credentials.secretAccessKey}`), date); + const kRegion = await hmac(kDate, region); + const kService = await hmac(kRegion, service); + const kSigning = await hmac(kService, "aws4_request"); + const signature = toHex(await hmac(kSigning, stringToSign)); + + return { + canonicalRequestHash, + credentialScope, + stringToSign, + signature, + derivation: [ + { + label: 'kDate = HMAC("AWS4" + secret, date)', + input: date, + keyHex: toHex(kDate), + }, + { + label: "kRegion = HMAC(kDate, region)", + input: region, + keyHex: toHex(kRegion), + }, + { + label: "kService = HMAC(kRegion, service)", + input: service, + keyHex: toHex(kService), + }, + { + label: 'kSigning = HMAC(kService, "aws4_request")', + input: "aws4_request", + keyHex: toHex(kSigning), + }, + ], + }; +} + +export interface PresignOptions extends SigV4Options { + /** Seconds the URL stays valid. S3 caps this at 604800 (seven days). */ + expiresIn: number; +} + +export interface PresignResult extends SigV4Result { + /** The full URL, signature included. */ + url: string; + /** The auth parameters added to the query, in the order they are appended. */ + authParams: Array<[string, string]>; +} + +/** + * Signs a request into a URL instead of a header. + * + * Two differences from header auth matter. The payload hash is the literal + * UNSIGNED-PAYLOAD, because at signing time nobody knows what will be uploaded. + * And the session token travels as a query parameter that is part of the + * canonical request, rather than as a signed header. The result is a bearer + * credential: the URL alone is enough, for anyone holding it, until it expires. + */ +export async function presignUrl( + request: SigV4Request, + credentials: SigV4Credentials, + options: PresignOptions, +): Promise { + const { region, service, datetime, expiresIn } = options; + const credentialScope = `${datetime.slice(0, 8)}/${region}/${service}/aws4_request`; + const doubleEncode = options.doubleEncodePath ?? true; + + const { canonical, signedHeaders } = canonicalHeaders(request.headers); + + const authParams: Array<[string, string]> = [ + ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"], + ["X-Amz-Credential", `${credentials.accessKeyId}/${credentialScope}`], + ["X-Amz-Date", datetime], + ["X-Amz-Expires", String(expiresIn)], + ["X-Amz-SignedHeaders", signedHeaders], + ]; + if (credentials.sessionToken) { + authParams.push(["X-Amz-Security-Token", credentials.sessionToken]); + } + + const payloadHash = options.payloadHash ?? UNSIGNED_PAYLOAD; + const query = [...(request.query ?? []), ...authParams]; + + const canonicalRequest = [ + request.method.toUpperCase(), + canonicalPath(request.path, doubleEncode), + canonicalQuery(query), + canonical, + signedHeaders, + payloadHash, + ].join("\n"); + + const finished = await finishSignature(canonicalRequest, credentials, options); + + const host = request.headers.host ?? request.headers.Host ?? ""; + const encodedQuery = [...query, ["X-Amz-Signature", finished.signature]] + .map(([k, v]) => `${uriEncode(k)}=${uriEncode(v)}`) + .join("&"); + + return { + ...finished, + canonicalRequest, + signedHeaders, + payloadHash, + authorizationHeader: "", + wireHeaders: { ...request.headers }, + authParams, + url: `https://${host}${canonicalPath(request.path, false)}?${encodedQuery}`, + }; +} + +export async function signRequest( + request: SigV4Request, + credentials: SigV4Credentials, + options: SigV4Options, +): Promise { + // Scope, string to sign and the key chain all come from finishSignature. + const doubleEncode = options.doubleEncodePath ?? true; + + // The session token is part of the signed material. Omitting it from + // SignedHeaders is the classic cause of SignatureDoesNotMatch. + const headers: Record = { ...request.headers }; + if (credentials.sessionToken) { + headers["x-amz-security-token"] = credentials.sessionToken; + } + + const payloadHash = + options.payloadHash ?? + (request.body ? await sha256Hex(request.body) : EMPTY_PAYLOAD_SHA256); + + const { canonical, signedHeaders } = canonicalHeaders(headers); + + const canonicalRequest = [ + request.method.toUpperCase(), + canonicalPath(request.path, doubleEncode), + canonicalQuery(request.query ?? []), + canonical, + signedHeaders, + payloadHash, + ].join("\n"); + + const finished = await finishSignature(canonicalRequest, credentials, options); + + const authorizationHeader = + `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${finished.credentialScope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${finished.signature}`; + + return { + ...finished, + canonicalRequest, + signedHeaders, + payloadHash, + authorizationHeader, + wireHeaders: { ...headers, authorization: authorizationHeader }, + }; +} diff --git a/src/features/signing/sigv4a.test.ts b/src/features/signing/sigv4a.test.ts new file mode 100644 index 0000000..4a6baad --- /dev/null +++ b/src/features/signing/sigv4a.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { + bigIntTo32Bytes, + deriveSigV4aKeyPair, + signSigV4a, + SIGV4A_ALGORITHM, + toHex, + verifySigV4a, +} from "./sigv4a"; + +/** + * A note on what these tests do and do not prove. + * + * AWS publishes the SigV4a derivation as pseudocode but, unlike SigV4, does not + * publish a worked example with an expected derived key that we could assert + * against. So the derived values below are not AWS-blessed vectors. They were + * produced by this implementation and independently reproduced by a separate + * Python implementation of the same published pseudocode, written from the + * documentation rather than from this code. Two implementations agreeing is + * weaker evidence than a vendor vector, and this comment exists so nobody + * mistakes one for the other. + * + * 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 + * signature verifies against the derived public key, which is the check AWS + * actually performs. + */ + +const AWS_EXAMPLE = { + accessKeyId: "AKIDEXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", +}; + +describe("key derivation", () => { + it("is deterministic for a given access key id and secret", async () => { + const first = await deriveSigV4aKeyPair( + AWS_EXAMPLE.accessKeyId, + AWS_EXAMPLE.secretAccessKey, + ); + const second = await deriveSigV4aKeyPair( + AWS_EXAMPLE.accessKeyId, + AWS_EXAMPLE.secretAccessKey, + ); + + expect(first.privateKeyHex).toBe(second.privateKeyHex); + expect(first.publicKeyHex).toBe(second.publicKeyHex); + }); + + it("matches the independent Python implementation of the published pseudocode", async () => { + const keyPair = await deriveSigV4aKeyPair( + AWS_EXAMPLE.accessKeyId, + AWS_EXAMPLE.secretAccessKey, + ); + + expect(keyPair.privateKeyHex).toBe( + "7efc8c0e65a324242818c5a50c891c6060b6a00717b7ba3cbe3c5d765be9259c", + ); + expect(keyPair.counter).toBe(1); + }); + + it("depends on the access key id, not only the secret", async () => { + const a = await deriveSigV4aKeyPair("AKIAIOSFODNN7EXAMPLE", "same-secret"); + const b = await deriveSigV4aKeyPair("ASIAIOSFODNN7EXAMPLE", "same-secret"); + + expect(a.privateKeyHex).not.toBe(b.privateKeyHex); + }); + + it("produces a compressed SEC1 public key", async () => { + const keyPair = await deriveSigV4aKeyPair( + AWS_EXAMPLE.accessKeyId, + AWS_EXAMPLE.secretAccessKey, + ); + + // 33 bytes: a 0x02 or 0x03 prefix plus the 32-byte x coordinate. + expect(keyPair.publicKeyHex).toHaveLength(66); + expect(["02", "03"]).toContain(keyPair.publicKeyHex.slice(0, 2)); + }); +}); + +describe("bigIntTo32Bytes", () => { + it("pads on the left", () => { + expect(toHex(bigIntTo32Bytes(1n))).toBe( + "0000000000000000000000000000000000000000000000000000000000000001", + ); + }); + + it("is big-endian", () => { + expect(toHex(bigIntTo32Bytes(0x0102n)).slice(-4)).toBe("0102"); + }); +}); + +describe("signing", () => { + const canonicalRequest = [ + "GET", + "/key.txt", + "", + "host:my-bucket.accesspoint.s3-global.amazonaws.com", + "x-amz-date:20260919T120000Z", + "x-amz-region-set:us-east-1,us-west-2", + "", + "host;x-amz-date;x-amz-region-set", + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ].join("\n"); + + const options = { + service: "s3", + datetime: "20260919T120000Z", + regionSet: ["us-east-1", "us-west-2"], + }; + + it("leaves the region out of the credential scope", async () => { + const result = await signSigV4a( + canonicalRequest, + AWS_EXAMPLE, + "host;x-amz-date;x-amz-region-set", + options, + ); + + // SigV4 would be 20260919/us-east-1/s3/aws4_request. + expect(result.credentialScope).toBe("20260919/s3/aws4_request"); + expect(result.credentialScope).not.toContain("us-east-1"); + }); + + it("names the ECDSA algorithm in the string to sign", async () => { + const result = await signSigV4a( + canonicalRequest, + AWS_EXAMPLE, + "host;x-amz-date;x-amz-region-set", + options, + ); + + expect(result.stringToSign.split("\n")[0]).toBe(SIGV4A_ALGORITHM); + expect(result.authorizationHeader.startsWith(SIGV4A_ALGORITHM)).toBe(true); + }); + + it("carries the region set separately from the scope", async () => { + const result = await signSigV4a( + canonicalRequest, + AWS_EXAMPLE, + "host;x-amz-date;x-amz-region-set", + options, + ); + + expect(result.regionSet).toBe("us-east-1,us-west-2"); + }); + + it("verifies against the public half alone", async () => { + const result = await signSigV4a( + canonicalRequest, + AWS_EXAMPLE, + "host;x-amz-date;x-amz-region-set", + options, + ); + + expect( + verifySigV4a(result.signature, result.stringToSign, result.keyPair.publicKeyHex), + ).toBe(true); + }); + + it("fails verification when the signed material is altered", async () => { + const result = await signSigV4a( + canonicalRequest, + AWS_EXAMPLE, + "host;x-amz-date;x-amz-region-set", + options, + ); + + // The region set lives in the canonical request, so it reaches the string + // to sign only through the hash on the last line. Altering a line that is + // actually present is what tests the signature. + expect( + verifySigV4a( + result.signature, + result.stringToSign.replace("20260919T120000Z", "20260920T120000Z"), + result.keyPair.publicKeyHex, + ), + ).toBe(false); + }); + + it("covers the canonical request through its hash", async () => { + const original = await signSigV4a( + canonicalRequest, + AWS_EXAMPLE, + "host;x-amz-date;x-amz-region-set", + options, + ); + const widened = await signSigV4a( + canonicalRequest.replace("us-east-1,us-west-2", "us-east-1,eu-west-1"), + AWS_EXAMPLE, + "host;x-amz-date;x-amz-region-set", + options, + ); + + // Changing the region set changes the hash, so it changes the signature + // even though the credential scope is identical. + expect(widened.credentialScope).toBe(original.credentialScope); + expect(widened.signature).not.toBe(original.signature); + }); + + it("is deterministic, because this implementation signs per RFC 6979", async () => { + 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 + // AWS's own implementations sign through libcrypto, so the same request + // signed twice by an AWS SDK will not 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); + expect(verifySigV4a(a.signature, a.stringToSign, a.keyPair.publicKeyHex)).toBe(true); + }); +}); diff --git a/src/features/signing/sigv4a.ts b/src/features/signing/sigv4a.ts new file mode 100644 index 0000000..304d53f --- /dev/null +++ b/src/features/signing/sigv4a.ts @@ -0,0 +1,234 @@ +import { p256 } from "@noble/curves/nist.js"; + +/** + * SigV4a: the asymmetric variant, used when one signature has to be valid in + * more than one region (S3 Multi-Region Access Points). + * + * Two things make it different from SigV4, and both are visible here. + * + * The key is a P-256 keypair derived from the same secret access key, so AWS + * only ever stores the public half. SigV4's signing key is a shared secret; + * this one is not. + * + * And the credential scope drops the region, because the regions the request is + * valid in travel in the X-Amz-Region-Set header instead. The key is therefore + * not scoped per region at all: the same keypair signs every request. + * + * WebCrypto cannot do this alone. Deriving the public key requires multiplying + * the base point by a scalar we chose ourselves, and SubtleCrypto has no API + * for that, so the curve arithmetic comes from @noble/curves. + */ + +const encoder = new TextEncoder(); + +export const SIGV4A_ALGORITHM = "AWS4-ECDSA-P256-SHA256"; + +/** Order of the NIST P-256 group. */ +const N = p256.Point.CURVE().n; + +/** The maximum counter value AWS's derivation pseudocode allows. */ +const MAX_COUNTER = 254; + +function uint32be(value: number): Uint8Array { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value, false); + return out; +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + parts.reduce((offset, part) => { + out.set(part, offset); + return offset + part.length; + }, 0); + return out; +} + +function bytesToBigInt(bytes: Uint8Array): bigint { + return bytes.reduce((acc, byte) => (acc << 8n) | BigInt(byte), 0n); +} + +export function bigIntTo32Bytes(value: bigint): Uint8Array { + const out = new Uint8Array(32); + for (let i = 31; i >= 0; i -= 1) { + out[i] = Number((value >> BigInt((31 - i) * 8)) & 0xffn); + } + return out; +} + +export function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function hmacRaw(key: Uint8Array, data: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey( + "raw", + key as BufferSource, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array( + await crypto.subtle.sign("HMAC", cryptoKey, data as BufferSource), + ); +} + +export interface DerivedKeyPair { + /** The private scalar, as AWS's pseudocode produces it. */ + privateKey: bigint; + privateKeyHex: string; + /** Compressed SEC1 encoding of Q = k * G. */ + publicKeyHex: string; + /** How many candidates were rejected before one landed in range. */ + counter: number; +} + +/** + * The derivation AWS documents: + * + * input_key = "AWS4A" || secret + * context = access_key_id || counter (counter is one byte) + * key = KDF(input_key, label, context, 256) + * c = Oct2Int(key) + * k = c + 1, provided c <= n - 2, else retry with counter + 1 + * + * KDF is the NIST SP 800-108 counter-mode construction over HMAC-SHA256, whose + * single 256-bit block is + * + * HMAC(input_key, uint32be(1) || label || 0x00 || context || uint32be(256)) + * + * The retry loop exists because a uniformly random 256-bit integer can land + * above the group order, and a scalar out of range would not be a valid key. + * In practice the first candidate almost always works. + */ +export async function deriveSigV4aKeyPair( + accessKeyId: string, + secretAccessKey: string, +): Promise { + const inputKey = encoder.encode(`AWS4A${secretAccessKey}`); + const label = encoder.encode(SIGV4A_ALGORITHM); + const akid = encoder.encode(accessKeyId); + + for (let counter = 1; counter <= MAX_COUNTER; counter += 1) { + const fixedInput = concatBytes( + uint32be(1), + label, + Uint8Array.of(0), + akid, + Uint8Array.of(counter), + uint32be(256), + ); + + const candidate = bytesToBigInt(await hmacRaw(inputKey, fixedInput)); + if (candidate > N - 2n) continue; + + const privateKey = candidate + 1n; + const privateBytes = bigIntTo32Bytes(privateKey); + + return { + privateKey, + counter, + privateKeyHex: toHex(privateBytes), + publicKeyHex: toHex(p256.getPublicKey(privateBytes)), + }; + } + + throw new Error("SigV4a key derivation exhausted all 254 counter values"); +} + +export interface SigV4aOptions { + service: string; + /** ISO8601 basic format. */ + datetime: string; + /** The regions this signature is valid in, e.g. ["us-east-1", "us-west-2"]. */ + regionSet: string[]; +} + +export interface SigV4aResult { + algorithm: string; + /** Note the missing region: `YYYYMMDD/service/aws4_request`. */ + credentialScope: string; + stringToSign: string; + regionSet: string; + keyPair: DerivedKeyPair; + /** DER-encoded ECDSA signature, lowercase hex. */ + signature: string; + authorizationHeader: string; +} + +async function sha256Hex(data: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", encoder.encode(data)); + return toHex(new Uint8Array(digest)); +} + +/** + * Signs an already-built canonical request. The canonical request itself is + * produced exactly as for SigV4, except that `x-amz-region-set` is one of the + * signed headers. + */ +export async function signSigV4a( + canonicalRequest: string, + credentials: { accessKeyId: string; secretAccessKey: string }, + signedHeaders: string, + options: SigV4aOptions, +): Promise { + const { service, datetime, regionSet } = options; + const date = datetime.slice(0, 8); + + // No region here. That is the whole point of the variant. + const credentialScope = `${date}/${service}/aws4_request`; + + const stringToSign = [ + SIGV4A_ALGORITHM, + datetime, + credentialScope, + await sha256Hex(canonicalRequest), + ].join("\n"); + + const keyPair = await deriveSigV4aKeyPair( + credentials.accessKeyId, + credentials.secretAccessKey, + ); + + const signature = p256.sign( + encoder.encode(stringToSign), + bigIntTo32Bytes(keyPair.privateKey), + { format: "der" }, + ); + + return { + algorithm: SIGV4A_ALGORITHM, + credentialScope, + stringToSign, + keyPair, + regionSet: regionSet.join(","), + signature: toHex(signature), + authorizationHeader: + `${SIGV4A_ALGORITHM} Credential=${credentials.accessKeyId}/${credentialScope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${toHex(signature)}`, + }; +} + +/** + * Verifies a SigV4a signature with the public half only, which is what AWS + * does on its side. + */ +export function verifySigV4a( + signatureHex: string, + stringToSign: string, + publicKeyHex: string, +): boolean { + const signature = Uint8Array.from( + signatureHex.match(/../g)?.map((byte) => parseInt(byte, 16)) ?? [], + ); + const publicKey = Uint8Array.from( + publicKeyHex.match(/../g)?.map((byte) => parseInt(byte, 16)) ?? [], + ); + + return p256.verify(signature, encoder.encode(stringToSign), publicKey, { + format: "der", + }); +} diff --git a/src/features/trust/TrustSection.tsx b/src/features/trust/TrustSection.tsx index 1e8ed06..d9f52e0 100644 --- a/src/features/trust/TrustSection.tsx +++ b/src/features/trust/TrustSection.tsx @@ -72,6 +72,12 @@ export function TrustSection() { }}

+ + {{ + en: "The `Action` in an OIDC trust policy must be `sts:AssumeRoleWithWebIdentity`. Writing `sts:AssumeRole` there, out of habit from cross-account roles, is the classic mismatch: the principal and conditions all match and the call is still denied.", + ja: "OIDC 向け信頼ポリシーの `Action` は `sts:AssumeRoleWithWebIdentity` でなければなりません。クロスアカウントのロールの癖で `sts:AssumeRole` と書くのが典型的な取り違えで、プリンシパルも条件も一致しているのに拒否されます。", + }} + {{ en: "IAM now refuses to save a trust policy for GitHub's provider unless `token.actions.githubusercontent.com:sub` is present and is not only a wildcard. It does not stop `repo:*` or `repo:my-org*` (no slash), which still match repositories anyone can create.", diff --git a/src/features/why/WhySection.tsx b/src/features/why/WhySection.tsx index d672f02..40bd6cf 100644 --- a/src/features/why/WhySection.tsx +++ b/src/features/why/WhySection.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useLang } from "@/i18n/useLang"; -import { Callout, Code, Figure, P, Section, Src } from "@/components/ui"; +import { Callout, Code, Figure, P, Section } from "@/components/ui"; import { chapter } from "@/data/nav"; import { exposureHours, LIFETIMES } from "./exposure"; @@ -47,11 +47,13 @@ Action=GetCallerIdentity&Version=2011-06-15`}

{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: "署名そのものの計算 (と壊し方) は、第 ", })} - caller-identity - {t({ en: ".", ja: " で一歩ずつ図解しています。" })} + + {String(chapter("signing")).padStart(2, "0")} + + {t({ en: ".", ja: " 章でその場で計算しながら説明します。" })}

); From 051bf5e1fc68966840ad40bfb2f2df026b49d86a Mon Sep 17 00:00:00 2001 From: kanywst <45947799+kanywst@users.noreply.github.com> Date: Sun, 8 Nov 2026 11:26:14 +0900 Subject: [PATCH 2/5] docs(signing): source the presign expiry limit and describe the CI jobs accurately --- docs/signing/04-variants.ja.md | 6 ++++-- docs/signing/04-variants.md | 8 ++++++-- docs/signing/05-verification.ja.md | 4 ++-- docs/signing/05-verification.md | 8 ++++---- src/features/signing/PresignLab.tsx | 4 ++-- src/features/signing/sigv4.ts | 2 +- 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/signing/04-variants.ja.md b/docs/signing/04-variants.ja.md index 8de5eba..46ec932 100644 --- a/docs/signing/04-variants.ja.md +++ b/docs/signing/04-variants.ja.md @@ -52,8 +52,10 @@ flowchart LR - **URL はごく普通の経路で漏れる。** ブラウザ履歴、referrer ヘッダ、プロキシログ、チャット。チケットに 貼られた presigned URL は、チケットに貼られたクレデンシャル。 -被害を限定しているのは `X-Amz-Expires` だけ。S3 の上限は604800秒 (7日) で、signing key 自体がその -期間しか有効でないため。 +被害を限定している主な要素は `X-Amz-Expires`。CLI や SDK なら最大604800秒 (7日) まで設定できる +(S3 コンソールでは 1 分〜12 時間)。一時クレデンシャルで署名した URL は、`X-Amz-Expires` に関係なく +そのクレデンシャルの失効と同時に使えなくなる。どの presigned URL も、署名したクレデンシャルが失効・ +削除・無効化されれば止まる ([S3 ユーザーガイド](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html))。 ## SigV4a diff --git a/docs/signing/04-variants.md b/docs/signing/04-variants.md index 8cb2bbc..63b033b 100644 --- a/docs/signing/04-variants.md +++ b/docs/signing/04-variants.md @@ -54,8 +54,12 @@ Three consequences that follow directly and surprise people anyway. - **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. +`X-Amz-Expires` is the main thing bounding the damage. With the CLI or SDKs it can be +set as high as 604800 seconds, seven days (the S3 console allows 1 minute to 12 hours). +A URL presigned with temporary credentials also dies when those credentials expire, +whatever `X-Amz-Expires` says, and any presigned URL stops working once the credential +that signed it is revoked, deleted or deactivated +([S3 user guide](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html)). ## SigV4a diff --git a/docs/signing/05-verification.ja.md b/docs/signing/05-verification.ja.md index f55fe4b..cc4e1e8 100644 --- a/docs/signing/05-verification.ja.md +++ b/docs/signing/05-verification.ja.md @@ -32,7 +32,7 @@ AWS 公開の計算例2件を、CI で1バイト単位で照合している。 **S3 presigned の `test.txt`**。クエリパラメータ認証のリファレンスから。ページ自身が「テストケースと して使える」と明言している。canonical request、そのハッシュ、署名、完成した URL の全てが一致する。 -どちらかが壊れればサイトが表示する署名は全部嘘になるので、両方とも独立した CI ジョブで走らせている。 +どちらかが壊れればサイトが表示する署名は全部嘘になる。両方とも、プルリクエストごとに CI の `test` ジョブで走る。 ## AWS ドキュメントの誤り @@ -101,4 +101,4 @@ npm ci npm test ``` -各チェックは CI でも独立したジョブとして走る。typecheck、lint、フォーマット、markdown lint と並んで。 +CI では同じテストが `test` ジョブで走る。typecheck、lint、フォーマット、markdown lint、build はそれぞれ別のジョブ。 diff --git a/docs/signing/05-verification.md b/docs/signing/05-verification.md index 36516aa..5419312 100644 --- a/docs/signing/05-verification.md +++ b/docs/signing/05-verification.md @@ -34,8 +34,8 @@ and the signature. All three match. 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. +If either breaks, every signature the site renders is wrong. Both run in the `test` job in +CI on every pull request. ## An error in the AWS documentation @@ -111,5 +111,5 @@ npm ci npm test ``` -Each check also runs as its own CI job, alongside typecheck, lint, formatting and -markdown lint. +CI runs the same tests in its `test` job, alongside separate typecheck, lint, formatting, +markdown lint and build jobs. diff --git a/src/features/signing/PresignLab.tsx b/src/features/signing/PresignLab.tsx index 54feb14..451143c 100644 --- a/src/features/signing/PresignLab.tsx +++ b/src/features/signing/PresignLab.tsx @@ -75,8 +75,8 @@ export function PresignLab() { {expiry === "604800" && (

{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
CreateAccessKey"] - sts["STS
6つの発行 API"] + sts["STS
発行 API"] imds["IMDS / コンテナエンドポイント
プラットフォームが注入"] end diff --git a/docs/signing/README.md b/docs/signing/README.md index 7a8feb7..bc65c36 100644 --- a/docs/signing/README.md +++ b/docs/signing/README.md @@ -26,7 +26,7 @@ Three layers, and most confusion comes from collapsing them. flowchart TD subgraph issue["Where credentials come from"] iam["IAM
CreateAccessKey"] - sts["STS
six issuance APIs"] + sts["STS
issuance APIs"] imds["IMDS / container endpoint
injected by the platform"] end diff --git a/src/features/signing/SigV4aLab.tsx b/src/features/signing/SigV4aLab.tsx index 384a226..cb0c04d 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. 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 の署名に固定テストベクタを書こうとすると、ここが効いてくる。