ampless 1.0.0-alpha.29 → 1.0.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -712,18 +712,48 @@ Secret settings を使うと、trusted プラグインが認証情報 (Webhook
712
712
 
713
713
  `settings.secret` はストレージモデルが構造的に異なります:
714
714
 
715
- - KvStore とは **別テーブル** の `PluginSecret` DynamoDB model に保存。
716
- - admin/editor グループは **書き込み・削除のみ可**。AppSync `getPluginSecret` / `listPluginSecrets` を生成しない(read 権限がない)。
717
- - trusted-processor Lambda IAM role だけが DDB `GetItem` で読み取れる。
715
+ - KvStore とは **別テーブル** の `PluginSecret` DynamoDB テーブルに保存。admin/editor Cognito ユーザーは AppSync 経由でこのテーブルに**直接アクセスできない** — すべての書き込みは `setPluginSecret` mutation を通じて `plugin-secret-handler` Lambda 経由で行われる。
716
+ - 値は保存前に **AES-256-GCM 暗号化**される。admin ブラウザが plaintext Lambda に TLS 経由で送信 → Lambda がバリデーション + `process.env.PLUGIN_SECRET_ENCRYPTION_KEY`(CDK `amplify/secrets/encryption-key.ts` から注入)から鍵を読み取り + 暗号化 → ciphertext のみを DDB に書き込む。平文は DynamoDB に保存されず、ブラウザにも返らない。
717
+ - **脅威モデル(Phase 6a v2.2)**:
718
+
719
+ | 脅威 | 状態 |
720
+ |---|---|
721
+ | PluginSecret テーブルを閲覧する AWS Console オペレータ | ✓ 対策済み — ciphertext のみ、DDB に鍵なし |
722
+ | ソースリポジトリ / デプロイアーティファクトへのアクセス | ⚠ 対策なし — 鍵は `amplify/secrets/encryption-key.ts` に存在。リポジトリを private にし、アーティファクトアクセスを制限すること |
723
+ | 同一 Lambda 内の悪意ある trusted plugin | ✗ 対策なし — `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` はプラグインコードから読める。真の分離 = per-plugin Lambda(privileged tier, ロードマップ) |
724
+ | S3 mirror 漏洩 | ✓ 対策済み — PluginSecret テーブルは mirror されない |
725
+
726
+ - trusted-processor Lambda が `node:crypto` で復号する。`ctx.secret<T>(key)` は平文 string を返す(ciphertext ではない)。
718
727
  - S3 mirror 経路に絶対に流れない(mirror は KvStore のみを query する)。
719
728
  - 公開 render surface (`publicHead` など) からは読めない。
729
+ - **field manifest 検証の範囲**: admin クライアントは UX フィードバック目的で `pattern` / `maxLength` / `required` を検証するが、Lambda 側は **汎用の 10,000 文字ハードキャップと安全文字サニタイザのみ**を強制する。admin/editor が AppSync mutation を直接呼ぶと field 単位の制約は迂回できる。設計上意図したもので、admin/editor は secret 設定を許可された信頼されたオペレータと位置づけている。manifest チェックは UX ガイダンスであり、セキュリティ境界ではない。
720
730
 
721
731
  ### 要件
722
732
 
723
- `settings.secret` には 2 つの要件があります:
733
+ `settings.secret` には 3 つの要件があります:
724
734
 
725
735
  1. `trust_level: 'trusted'` — untrusted Lambda には PluginSecret table への DDB read 権限がない。他の trust level で宣言すると `definePlugin()` 時に throw する。
726
736
  2. `'secretSettings'` を `capabilities` に含める — admin UI や将来の allow-list から capability を参照できるようにするため必須。省略すると console.warn(`'schema'` vs `publicBodyForPost` の不整合パターンと同じ)。
737
+ 3. **鍵の初回セットアップ** — プロジェクトルートで実行:
738
+ ```sh
739
+ npx create-ampless setup-encryption-key
740
+ ```
741
+ 32 バイトのランダムな鍵を生成し、`amplify/secrets/encryption-key.ts` に書き込む。AWS 認証情報不要 — ローカルファイル操作のみ。
742
+
743
+ 次に `amplify/backend.ts` でその定数を import し、`defineAmplessBackend({ pluginSecretEncryptionKey })` に渡す。その後デプロイ(またはサンドボックス再起動)して Lambda env var に注入する。
744
+
745
+ public リポジトリの場合は `--gitignore` を渡してバージョン管理から除外し、鍵を別途配布する。
746
+
747
+ ### Dual-write 整合性
748
+
749
+ `setPluginSecret` と `clearPluginSecret` は各操作で **2 テーブル**に連続して書き込む: `PluginSecret`(ciphertext)と `PluginSecretIndicator`(存在タイムスタンプ)。2 回目の書き込みが失敗した場合、テーブルは以下の予測可能な状態になる:
750
+
751
+ | 障害ポイント | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
752
+ |---|---|---|---|---|
753
+ | **set**: indicator PutItem 失敗 | ciphertext 存在 | 不在 | plaintext を返す ✓ | `false`(UI: 「未保存」) |
754
+ | **clear**: indicator DeleteItem 失敗 | 不在 | stale(古いタイムスタンプ) | `undefined` ✓ | `true`(UI: 「保存済み」) |
755
+
756
+ clear パスの失敗は「安全側」: secret は発火しなくなるが、UI は一時的に「保存済み」と表示する。set パスの失敗は軽微な UI 不整合: secret は機能するが、存在インジケータはリトライが成功するまで不在となる。
727
757
 
728
758
  ### secret フィールドの宣言
729
759
 
@@ -803,7 +833,8 @@ ctx.secret<T = string>(key: string): Promise<T | undefined>
803
833
 
804
834
  - admin が未保存なら `undefined` を返す。
805
835
  - `T` は convenience cast (ctx.setting と同じ)。値は常に string として保存される。
806
- - 結果は per-invocation キャッシュされる。同 batch 内で同キーを 2 回呼んでも DDB 呼び出しは 1 回。
836
+ - 結果は per-invocation キャッシュされる。同 batch 内で同キーを 2 回呼んでも DDB 呼び出しと復号処理は 1 回ずつ。暗号化キーは Lambda env var から cold-start 時に decode される(DDB への余分な fetch は不要)。
837
+ - キャッシュされる値は **復号済みの平文** — ciphertext ではない。2 回目の呼び出しで再復号は発生しない。
807
838
  - cache key は namespace 化される: `${instanceId ?? name}:${fieldKey}`。異なる plugin instance が同名フィールドを持っても混線しない。
808
839
 
809
840
  ### admin UI
@@ -922,14 +922,38 @@ etc. — but completely wrong for a webhook signing secret.
922
922
 
923
923
  `settings.secret` has a structurally different storage model:
924
924
 
925
- - Stored in the `PluginSecret` DynamoDB model (separate from KvStore).
926
- - Admin/editor groups can **write and delete** but have **no read
927
- authorization**the AppSync schema does not generate
928
- `getPluginSecret` or `listPluginSecrets` queries for those groups.
929
- - Only the trusted-processor Lambda IAM role can read, via DDB
930
- `GetItem` directly.
925
+ - Stored in the `PluginSecret` DynamoDB table (separate from KvStore).
926
+ Admin/editor Cognito users have **no direct AppSync access** to this
927
+ tableall writes go through the `setPluginSecret` mutation, which
928
+ is backed by the `plugin-secret-handler` Lambda.
929
+ - Values are **AES-256-GCM encrypted** before reaching DynamoDB.
930
+ The admin browser sends the plaintext to the Lambda via AppSync
931
+ (TLS in transit); the Lambda validates it, reads the 32-byte
932
+ encryption key from `process.env.PLUGIN_SECRET_ENCRYPTION_KEY`
933
+ (injected by CDK from `amplify/secrets/encryption-key.ts`), encrypts
934
+ the value, and writes only the ciphertext to DDB. The plaintext
935
+ never rests in DynamoDB and never flows back to the browser.
936
+ - **Threat model (Phase 6a v2.2)**:
937
+
938
+ | Threat | Status |
939
+ |---|---|
940
+ | AWS Console operator browsing PluginSecret table | ✓ defeated — ciphertext only, no key in DDB |
941
+ | Source repo / deploy artifact access | ⚠ NOT defeated — key is in `amplify/secrets/encryption-key.ts`. Private repo + restricted artifact access expected. |
942
+ | Malicious trusted plugin in same Lambda | ✗ NOT defeated — `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` reachable from plugin code. True isolation = per-plugin Lambda (privileged tier, roadmap). |
943
+ | S3 mirror leak | ✓ defeated — PluginSecret table never mirrored. |
944
+
945
+ - The trusted-processor Lambda decrypts with `node:crypto` on read.
946
+ `ctx.secret<T>(key)` returns the plaintext string, never the
947
+ ciphertext.
931
948
  - Never queried by the site-settings mirror path.
932
949
  - Never passed to any public-render surface
950
+ - **Field-manifest validation scope**: the admin client validates
951
+ `pattern` / `maxLength` / `required` for UX feedback, but the
952
+ Lambda only enforces a generic 10,000-character hard cap + safe-
953
+ character sanitizer. An admin/editor calling the AppSync mutation
954
+ directly can bypass field-level constraints — by design, since
955
+ admin/editor are trusted operators authorised to set secrets.
956
+ The manifest checks are UX guidance, not a security boundary.
933
957
  (`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
934
958
  `publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
935
959
 
@@ -943,6 +967,37 @@ etc. — but completely wrong for a webhook signing secret.
943
967
  2. `'secretSettings'` in `capabilities` — required so admin UI and
944
968
  future allow-lists can gate the capability. Omitting it produces
945
969
  a console warning (same pattern as `'schema'` vs `publicBodyForPost`).
970
+ 3. **One-time key setup** — run from your project root:
971
+ ```sh
972
+ npx create-ampless setup-encryption-key
973
+ ```
974
+ This generates a cryptographically random 32-byte key and writes
975
+ it to `amplify/secrets/encryption-key.ts`. No AWS credentials
976
+ required — this is a local file operation only.
977
+
978
+ Then import the constant in `amplify/backend.ts` and pass it to
979
+ `defineAmplessBackend({ pluginSecretEncryptionKey })`. Redeploy (or
980
+ restart the sandbox) to inject the key into the Lambda env vars.
981
+
982
+ For public repos, pass `--gitignore` to exclude the key file from
983
+ version control and distribute the key separately.
984
+
985
+ ### Dual-write integrity
986
+
987
+ The `setPluginSecret` and `clearPluginSecret` operations each write
988
+ to **two DynamoDB tables** in sequence: `PluginSecret` (ciphertext)
989
+ and `PluginSecretIndicator` (existence timestamp). If the second
990
+ write fails, the tables are left in a predictable, documented state:
991
+
992
+ | Failure point | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
993
+ |---|---|---|---|---|
994
+ | **set**: indicator PutItem fails | ciphertext present | absent | returns plaintext ✓ | `false` (UI: "not saved") |
995
+ | **clear**: indicator DeleteItem fails | absent | stale (old timestamp) | `undefined` ✓ | `true` (UI: "saved") |
996
+
997
+ The clear-path failure is the "safe side": the secret stops firing even
998
+ though the UI briefly shows it as saved. The set-path failure is a minor
999
+ UI inaccuracy: the secret is functional but the existence indicator is
1000
+ absent until a retry succeeds.
946
1001
 
947
1002
  ### Declaring secret fields
948
1003
 
@@ -1035,10 +1090,14 @@ ctx.secret<T = string>(key: string): Promise<T | undefined>
1035
1090
  - The generic `T` is a convenience cast (same as `ctx.setting<T>()`).
1036
1091
  Values are always stored as strings; `T` defaults to `string`.
1037
1092
  - Results are per-invocation cached. Calling `ctx.secret('key')`
1038
- twice in the same hook batch costs one DDB round-trip.
1093
+ twice in the same hook batch costs one DDB round-trip (and one
1094
+ decrypt). The encryption key is decoded from the Lambda env var
1095
+ at cold-start (no extra DDB fetch).
1039
1096
  - Cache keys are namespaced: `${instanceId ?? name}:${fieldKey}`.
1040
1097
  Two plugin instances both declaring `'signingSecret'` never get
1041
1098
  each other's values.
1099
+ - The cached value is the **decrypted plaintext** — not the
1100
+ ciphertext. There is no redundant decrypt on repeated calls.
1042
1101
 
1043
1102
  ### Admin UI
1044
1103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampless",
3
- "version": "1.0.0-alpha.29",
3
+ "version": "1.0.0-alpha.30",
4
4
  "description": "Serverless CMS for AWS Amplify",
5
5
  "license": "MIT",
6
6
  "type": "module",