From 3f80337a8d1ffda6236a69694fb0232ad6b47cee Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 14 Jul 2026 18:50:19 +0900 Subject: [PATCH 01/48] docs(design): add read-response integrity design draft (#55) Design memo for signed read responses with E2E client verification, grounding the two-layer model (membership + response integrity) in the actual codebase: node_key (P-256) as the signing key, crsl-lib Node genesis/parents chain for series verification, and the Option-based backward-compatible signature field across the CBOR wire / JSON HTTP response path. Captures the existing weakness where member checks compare libp2p PeerID strings against P-256-derived NodeIds, and the open design questions (member pubkey distribution, membership signing authority, series-check cost, monotonicity state, key rotation, staged rollout). Design-phase only; no behavior change. Co-Authored-By: Claude Fable 5 --- docs/design/read-response-integrity.md | 157 +++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/design/read-response-integrity.md diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md new file mode 100644 index 0000000..94c8fcf --- /dev/null +++ b/docs/design/read-response-integrity.md @@ -0,0 +1,157 @@ +# read 経路の完全性: 署名付き応答の E2E 検証 + +- ステータス: **設計中(draft)** +- 関連: PR #54、issue #55 +- 前提ブランチ: `feature/read-response-signing`(#54 の上に積む) + +## 1. 目的 + +read の relay 応答に対して、**返ってきたデータ・版が正当な member によるものか**をクライアント側で暗号学的に検証できるようにする。#54 で対処済みの範囲(下記)では塞がらない、以下の攻撃を防ぐ。 + +- 偽データ注入(暗号文本文以外) +- 偽履歴の注入(存在しない版 ID の混入) +- ロールバック攻撃(過去の本物の版を「最新」として返す) +- 未検証ピア(DHT フォールバックで拾った非 member)への relay + +### #54 で対処済み(本ドキュメントの対象外) + +| 対処 | 手段 | +|---|---| +| credential 漏洩の悪用 | 読み取り署名を `read:{content_id}:{timestamp}` に content_id バインド | +| 偽データ注入(暗号文**本文**) | SDK 暗号化を AES-256-GCM (AEAD) に移行。改ざん・偽造本文は復号で失敗 | + +## 2. 問題の構造 — 2レイヤー + +read 経路には独立した2つの信頼問題があり、両方を埋めないと防御にならない。 + +### レイヤー1: 誰に聞くか(メンバーシップ) + +`resolve_members`(`state_node_service.rs:449`)は、ローカルに `ContentNetwork` レコードがない場合、Kademlia DHT の近接ピア(`find_closest_peers`)をそのまま relay 先「member」として扱う。暗号学的検証はない。攻撃者は自分の PeerID を対象コンテンツの DHT キー近傍に置くだけで relay 先候補に入れる(正規 member である必要はない)。 + +さらに、ローカルの `ContentNetwork` レコード自体も gossip イベント(`ContentNetworkManagerAdded` / `Removed`、`events.rs:20-62`)のペイロードを無検証で保存・上書きしている。イベントに署名フィールドはない。 + +**既存の弱点(調査で判明)**: incoming request の member 判定は **libp2p PeerID 文字列**(ed25519 由来)を member set と照合している(`libp2p_network.rs:1546, 1616` の `has_member_str(&peer.to_string())`)。一方 `member_nodes` は **P-256 由来 NodeId**(`content_network.rs:14`)。型が食い違っており、現状の member 判定は署名検証ではなく文字列比較。本設計で整合を取る。 + +### レイヤー2: 返ってきた答えが正しいか(応答の完全性) + +relay 先が返す `(data, version)`・履歴(版 CID リスト)には署名も系列検証もない。GCM が守るのは暗号文本文のみで、以下は素通りする。 + +- **偽履歴**: `get_history` / `get_latest_version`(SDK `controller/state.rs`)は relay 先が返す版 CID 文字列リストをそのまま信頼。 +- **ロールバック**: 過去の本物の暗号文(正規 CEK で暗号化済み)を「最新」として返すと GCM は通り、クライアントは正常復号して「最新」と信じる。 + +**正常な stale read との区別**: 正規 member が sync 遅延で一時的に古い版を返すのは結果整合性として正常な仕様であり、守るべき挙動。攻撃との違いは「時間が経てば sync で自己修復するラグ」か「攻撃者が特定の相手に古い版/偽履歴を意図的に固定・注入し収束しない」か。検出軸は member/非 member でも新旧でもなく、**自己修復するラグか、収束しない改ざんか**。 + +## 3. libp2p が保証する範囲と、しない範囲 + +libp2p(Noise、`transport.rs:21`)が保証するのは**各ホップの相手 PeerID が本物であること**(トランスポート認証)だけ。 + +- **多段 relay では隣接ホップのみ認証** — A→B→C で A が検証できるのは「B と話した」ことだけ。C が誰か・member か・B が C の応答を正直に転送したかは libp2p レイヤーに現れない。中間ノードは中身を差し替え放題。 +- **PeerID は「member であること」を語らない** — member は Monas アプリ層の概念(ContentNetwork)。 + +したがって「どこから read したかの証明」は libp2p から降ってこず、**アプリ層で作るしかない**。 + +## 4. 既存資産(調査結果) + +設計は既存の鍵・検証部品・データ構造の上に構築できる。 + +### 4.1 署名鍵: node_key(P-256)が第一候補 + +state node は2種類の鍵を持つ: + +| 鍵 | ファイル | 型 | 用途 | +|---|---|---|---| +| ed25519 peer key | `data_dir/peer_key.ed25519` | `libp2p::identity::Keypair` | トランスポート/PeerID のみ | +| **P-256 node_key** | `data_dir/node_key.pem`(生 32byte) | `NodeKeyPair`(`key_management.rs:9`) | **node 認証・NodeId・公開鍵証明の署名** | + +read 応答署名には **node_key(P-256)** が自然。理由: +- 既に `NodePublicKey`(`public_key_protocol.rs:26`)で「node_id ↔ P-256 公開鍵」の所有証明に使用済み。 +- `member_nodes` の NodeId が P-256 公開鍵ハッシュ由来(`content_network.rs:24`)なので、署名者鍵と member 判定が暗号学的に一致する。 + +### 4.2 再利用できる検証部品 + +- `crypto::verify_p256_signature`(`crypto.rs:34`、SHA-256 digest 方式、monas-account の署名と互換) +- `NodePublicKey`(`public_key_protocol.rs`、node_id+timestamp を P-256 署名する雛形)— read 応答署名の最も近い雛形 +- `PublicKeyRegistry`(`port/public_key_registry.rs:12`、node_id → pubkey 取得。in-memory + sled 実装) + +**注意**: 検証系が2系統ある — `signature_verifier.rs` は raw-message verify、`crypto.rs` は SHA-256 digest verify。応答署名では digest 方式(account 互換)に統一する。 + +### 4.3 系列検証は既存データ構造で原理的に可能 + +crsl-lib の `Node`(`dasl/node.rs:26`)は: + +```rust +pub struct Node { + pub payload: P, // ContentPayload { data, access_policy } + pub parents: Vec, // 親版参照(複数可 = DAG) + pub genesis: Option,// 所属 genesis(genesis 自身は None) + pub timestamp: u64, + pub metadata: M, +} +``` + +- version CID = Node 全体(payload/parents/genesis/timestamp/metadata)の **CBOR → SHA-256**(`node.rs:76`)。**親が変われば CID も変わる**ため、CID を再計算すれば parents/genesis 参照の改ざんをクライアント側でも検知できる。 +- 「同一系列所属」は `get_genesis(X) == G` で O(1) 判定可能(`dag.rs:425`、既に `crdt_repository.rs:226` で利用)。ただしこれは genesis フィールドの**自己申告一致**であり、genesis から parents を辿る**到達可能性の検証ではない**。 +- 到達可能性(真の親子チェーン)を辿れる公開 API は `branching_history`(parent→children 隣接、`repo.rs:112`)のみ。`linear_history`/`get_history`/`latest` は CID の列/単体のみでエッジ情報を返さない。 +- **crsl-lib の Node/Operation には署名も検証される author も無い**(author は Operation の自由文字列 `operation.rs:9`)。真正性は CRDT レイヤーでは担保されないので、**署名は state-node アプリ層で付与する**。 + +### 4.4 応答経路と署名フィールドの後方互換追加 + +read 応答は2区間で異なるシリアライズを経る: + +- relay ワイヤ(node↔node): libp2p **CBOR** codec(`behaviour.rs:38`、`ContentResponse` を serde/CBOR) +- HTTP(caller node↔SDK): **JSON** + +署名フィールドは **`Option` で後方互換に追加可能**(CBOR は末尾フィールド追加を無視/欠損=None、JSON は `#[serde(default)]`)。ただし E2E で運ぶには経路上の全型を通す必要がある: + +| 層 | 型 | 場所 | +|---|---|---| +| member 戻り値 | `(Vec, String)` | `read_content_via_relay`(`state_node_service.rs:565`) | +| 内部 IPC | `RelayOutcome::Data { data, version }` | `libp2p_network.rs:55` | +| ワイヤ ★中心 | `ContentResponse::ContentData { content_id, data, version }` | `protocol.rs:106` | +| caller 分解 | `Ok((data, version))` | `libp2p_network.rs:1828`(現状 `..` で余剰フィールド破棄) | +| HTTP | `ContentDataResponse` | `http_api.rs:225` | +| SDK | `StateNodeContentDataResponse` | `models/state_node.rs:51` | + +履歴も運ぶなら `ContentResponse::HistoryData`(`protocol.rs:129`)/ `ContentHistoryResponse`(`http_api.rs:232`)も同様。**caller の `libp2p_network.rs:1828` の分解パターン修正が必須**(現状署名を捨てている)。 + +## 5. 設計方針(たたき台 / 要レビュー) + +### 5.1 何に署名するか + +member は応答ごとに、以下を含むメッセージへ node_key(P-256)で署名する: + +``` +sign( content_id || version_cid || sha256(data) || timestamp ) +``` + +- `sha256(data)` を含めることで本文の真正性を担保(GCM とは独立に、暗号文そのものの出所を保証)。 +- `version_cid` を含めることで「どの版か」を署名対象に固定。 +- `timestamp` で応答のリプレイ窓を制限。 + +署名は `ContentResponse::ContentData` に `signature: Option>` + `signer_node_id: Option` として載せ、E2E で SDK まで運ぶ。 + +### 5.2 クライアント側の検証(3段) + +1. **署名検証**: `signer_node_id` の公開鍵で署名を検証(公開鍵の入手経路は §6 の論点)。→ 応答が「その node の本物の発言」であることを保証。 +2. **member 検証**: `signer_node_id` が対象コンテンツの正規 member か。→ ContentNetwork レコードの署名検証(レイヤー1)と連動。 +3. **系列・単調性検証**: + - **系列チェーン**: 版が genesis から parents で到達可能か。各版の `parents`/`genesis` をクライアントが取得できる口(現状 monas ラッパーに Node の parents を返す API が無い → `repo.dag.get_node()` 直呼びが必要。新設が要る)。 + - **単調性(ロールバック検出)**: クライアントが「前回見た版」をローカル記録し、返ってきた版がその祖先に巻き戻っていないか(monotonic / TOFU)。member/非 member 問わず効く。 + +### 5.3 メンバーシップ証明(レイヤー1) + +`ContentNetwork` レコード(member リスト)に owner / genesis authority の署名を付け、gossip 受信時・DHT フォールバック時の両方で検証。§4.1 の弱点(PeerID 文字列 vs P-256 NodeId)もここで整合を取る。 + +## 6. 未解決の論点(設計で詰める) + +1. **member 公開鍵の配布**: クライアントは `signer_node_id` の P-256 公開鍵をどう入手するか。`NodePublicKey` 交換(`libp2p_network.rs:1907`)は node 間のもの。クライアント(SDK)への配布経路が要る。ContentNetwork レコードに member の公開鍵を含める案が有力。 +2. **メンバーシップ署名の権威**: 誰が member リストに署名する権利を持つか。owner か、genesis authority か。member 追加/削除のたびに再署名が必要。 +3. **系列検証のコスト**: クライアントが毎回 genesis まで parents を辿るのは高コスト。どこまで検証するか(直近のみ / チェックポイント / 全チェーン)。 +4. **単調性の状態管理**: 「前回見た版」をクライアントのどこに、どう永続化するか。複数デバイス間で不整合が出ないか。 +5. **鍵ローテーション**: node_key / member 鍵のローテーション時に過去の署名をどう扱うか。 +6. **段階導入**: 署名を `Option` にする以上、「署名を検証しないと拒否する」モードへの移行タイミング(新旧ノード混在期間の扱い)。 + +## 7. スコープと優先度 + +- すべて「攻撃者が read relay の経路に入れること」が前提。**クローズドな 4 ノード構成の現状では成立しない**。オープン参加型ネットワークにする前までに対応(Kademlia への Sybil/eclipse 攻撃が現実的になるため)。 +- 実装は段階分割の想定: (a) 応答署名 + クライアント検証(レイヤー2 の中核)→ (b) 系列・単調性検証 → (c) メンバーシップ署名(レイヤー1)。 From 8ff836350d017cd3c2dfea9ec35c72237945cef2 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 15 Jul 2026 14:42:25 +0900 Subject: [PATCH 02/48] docs(design): resolve open questions with codebase-grounded recommendations (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key finding: crsl-lib Node exposes to_bytes/from_bytes (CBOR) and content_id() = SHA-256 of that CBOR, so returning the whole Node lets the client verify data authenticity and parent/genesis chain by recomputing the CID — no signature needed for version-pinned reads. Signatures are only essential for the negative fact "this is the latest", splitting the design into (A) pinned read = content-addressed, (B) latest/history = node-key signature + monotonicity. Clarified the two key layers: membership authority = owner user key (AccessPolicy.owner is an Identity/P-256 pubkey), response signer = member node key. Filled in recommendations for pubkey distribution (in the owner-signed ContentNetwork record), series-check cost (monotonicity only by default), monotonicity state (SDK sled store; append-only DAG confirmed so no false positives), rotation, and a 3-mode staged rollout. Flagged the product decisions (owner-offline delegation, enforcement timing, PR split) for the user. Design-phase only; no behavior change. Co-Authored-By: Claude Fable 5 --- docs/design/read-response-integrity.md | 53 ++++++++++++++++++++++---- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md index 94c8fcf..57fefde 100644 --- a/docs/design/read-response-integrity.md +++ b/docs/design/read-response-integrity.md @@ -116,6 +116,32 @@ read 応答は2区間で異なるシリアライズを経る: ## 5. 設計方針(たたき台 / 要レビュー) +### 5.0 鍵レイヤーの整理(調査で確定) + +設計に関わる鍵は**別レイヤーの2種類**で、混同しないこと。 + +| 鍵 | 実体 | 管理 | 用途 | +|---|---|---|---| +| **ユーザー鍵**(owner) | `AccessPolicy.owner` = `Identity{id: hex(P-256 pubkey), type: User}`(`identity.rs:15`, `access_policy.rs:21`) | monas-account | 誰がコンテンツの所有者か。read 認証もこの鍵の署名 | +| **node 鍵**(member) | `member_nodes` の NodeId = P-256 node_key 由来(`content_network.rs:24`) | 各 state node(`node_key.pem`) | 誰がコンテンツを複製保持する node か | + +→ **メンバーシップ(誰が member か)の権威はユーザー鍵(owner)、応答の発言者は node 鍵(member)**。§5.3 のメンバーシップ署名は owner のユーザー鍵で、§5.1 の応答署名は member の node 鍵で行う。 + +### 5.0.1 重要な発見: Node 全体を返せば、データ真正性と系列は署名なしで検証できる + +crsl-lib の `Node` は `to_bytes()`(CBOR)/`from_bytes()` が公開されており(`node.rs:90/104`)、`content_id()` はその CBOR バイト列の SHA-256(`node.rs:76`)。したがって: + +- member が生 `data` ではなく **シリアライズした `Node` 全体**(payload + parents + genesis + timestamp + metadata)を返せば、クライアントは: + 1. **`from_bytes` → `content_id()` を再計算 → 要求した version CID と一致するか**でデータ本文と親参照の改ざんを検知できる(**署名不要**。CID = 内容ハッシュなので、CID が正しければ中身は正しい) + 2. Node に含まれる `parents` / `genesis` で系列を辿れる + +- つまり **署名が本質的に必要なのは「これが最新である」という否定的事実**(=より新しい版が存在しないこと)だけに絞り込める。データの真正性・系列は content-addressing で足りる。 + +この発見により設計を2つに分離できる: + +- **(A) 版指定 read**(`version` を指定):署名不要。member は Node を返し、クライアントは CID 再計算で検証。改ざん・偽データは弾ける。 +- **(B) 最新 read / 履歴**(`version: None`):member の「これが最新」「これが履歴」という主張は content-addressing では検証できない(否定的事実のため)。ここに member の node 鍵署名 + 単調性チェックが要る。 + ### 5.1 何に署名するか member は応答ごとに、以下を含むメッセージへ node_key(P-256)で署名する: @@ -142,14 +168,27 @@ sign( content_id || version_cid || sha256(data) || timestamp ) `ContentNetwork` レコード(member リスト)に owner / genesis authority の署名を付け、gossip 受信時・DHT フォールバック時の両方で検証。§4.1 の弱点(PeerID 文字列 vs P-256 NodeId)もここで整合を取る。 -## 6. 未解決の論点(設計で詰める) +## 6. 論点への推奨(§5.0 の発見を踏まえた現時点の案) + +1. **member 公開鍵の配布** → **ContentNetwork レコードに member の P-256 公開鍵を含める**。member リスト自体が owner 署名で保護される(§5.3)ので、そこに公開鍵を同梱すれば「正規 member の鍵一覧」が owner 権威で配布される。クライアントは応答署名をこの鍵で検証。`NodePublicKey` 交換(node 間)とは別に、SDK 向けにこのレコードを返す口が要る。既存の `PublicKeyRegistry` は node ローカルの検証用なので流用しない。 + +2. **メンバーシップ署名の権威** → **owner のユーザー鍵**(§5.0)。`AccessPolicy.owner` が既に `Identity`(P-256 pubkey)なので、owner が member リスト + 各 member の公開鍵に署名する。member 追加/削除のたびに owner が再署名(単調増加する version 番号付きで、古い member リストへの差し替えを防ぐ)。genesis authority 案は owner と一致するので別概念にしない。**残論点**: owner がオフラインのとき member 変更できない問題 → owner が委任トークン(既存 JWT/UCAN, `jwt_signer.rs`)で member 管理権限を委譲する形を検討。 + +3. **系列検証のコスト** → **通常は単調性チェックのみ(直近版の親子1ホップ)、全チェーン検証はオンデマンド**。§5.0.1 で版指定 read は CID 再計算だけで足りるため、毎回 genesis まで辿る必要はない。ロールバック検出には「前回見た版が今回の版の祖先か」だけ確認できればよく、これは差分の parents を辿る短いパスで済む。監査目的の全チェーン検証は明示要求時のみ。 + +4. **単調性の状態管理** → **SDK のローカル永続化に「content_id → 最後に見た version CID + timestamp」を記録**。SDK は既に `SledContentEncryptionKeyStore`(`controller/mod.rs:246`)で sled 永続化を持つので、同じ DB に version 追跡ストアを足す。**複数デバイス問題**: デバイス A が v5、デバイス B が v3 までしか知らない場合、B が v3→v5 に進むのは正常(巻き戻しではない)。TOFU の単調性は「自分が一度見た版より古い版を最新と主張されたら警告」であり、デバイス間で状態共有は不要(各デバイスが自分の観測履歴を持てばよい)。ただし正規の履歴改変(owner による rebase 等)がある設計なら誤検知しうる → Monas の CRDT は追記のみ(版は不変)なので問題にならない見込み。要確認。 + +5. **鍵ローテーション** → **member リストの version 番号 + timestamp で世代管理**。node 鍵ローテーション時は owner が新しい公開鍵を含む member リストに再署名。過去の署名は「その時点で有効だった member リストの世代」に対して検証する必要があるため、クライアントは応答の timestamp とレコード世代を突き合わせる。詳細は実装フェーズで詰める(初版はローテーション非対応でも可)。 + +6. **段階導入** → **3 モードで移行**: (i) `Option` 署名を付けるが**検証しない**(観測のみ、ログ) → (ii) 署名があれば**検証する**が、無くても通す(warn) → (iii) 署名必須(無い/検証失敗は**拒否**)。オープン参加型ネットワーク移行前に (iii) へ。新旧ノード混在期は (ii) で吸収。gossip の member レコード署名も同様の 3 モード。 + +### 6.1 ユーザーに確認したい設計判断 + +以下は技術だけで決められない、プロダクト方針が絡む点: -1. **member 公開鍵の配布**: クライアントは `signer_node_id` の P-256 公開鍵をどう入手するか。`NodePublicKey` 交換(`libp2p_network.rs:1907`)は node 間のもの。クライアント(SDK)への配布経路が要る。ContentNetwork レコードに member の公開鍵を含める案が有力。 -2. **メンバーシップ署名の権威**: 誰が member リストに署名する権利を持つか。owner か、genesis authority か。member 追加/削除のたびに再署名が必要。 -3. **系列検証のコスト**: クライアントが毎回 genesis まで parents を辿るのは高コスト。どこまで検証するか(直近のみ / チェックポイント / 全チェーン)。 -4. **単調性の状態管理**: 「前回見た版」をクライアントのどこに、どう永続化するか。複数デバイス間で不整合が出ないか。 -5. **鍵ローテーション**: node_key / member 鍵のローテーション時に過去の署名をどう扱うか。 -6. **段階導入**: 署名を `Option` にする以上、「署名を検証しないと拒否する」モードへの移行タイミング(新旧ノード混在期間の扱い)。 +- **owner オフライン時の member 管理**(論点2): 委任トークンで管理権限を移譲する仕組みを入れるか、初版は「owner online 必須」で割り切るか。 +- **段階導入の (iii) 強制タイミング**(論点6): このPR系列で (i)/(ii) まで入れ、(iii) はオープン化のマイルストーンに紐付けるか。 +- **実装の分割単位**: §7 の (a)(b)(c) を別 PR にするか、まとめるか。 ## 7. スコープと優先度 From aab0da2abdb7fbcf7484606c5219255927431498 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Fri, 17 Jul 2026 23:40:07 +0900 Subject: [PATCH 03/48] docs(design): make metadata privacy a hard constraint; redesign for single-node proof (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User caught that the earlier direction (return the whole Node / sign with member node key / distribute the member list) trades away metadata privacy — "who hosts which content". Revealing the full member set is qualitatively worse than today's relay (which exposes only the single responding node): it defeats replication as availability protection, enables node co-occurrence correlation, and (with signatures) leaves a permanent non-repudiable record. New hard constraint (§5.0.0): adding integrity must not widen the metadata leak beyond the current single-node level. Redesign: - Version-pinned reads (A): return the Node, client recomputes the CID. No signature, zero extra leak. - "Is this the latest" (B): split into (a) monotonicity/TOFU rollback check — pure client-local, zero metadata impact, can ship to enforce early; and (b) a single, owner-issued member proof token (reuse the existing owner ES256 delegation token, aud = that node) so a responding node proves membership WITHOUT exposing the set. Drop the signed member list and member node-key signatures entirely. Confirmed append-only DAG (new_child, immutable nodes) so monotonicity won't false-positive. Open product decisions updated: proof attach frequency (non-repudiation vs verifiability), owner-offline delegation, whether to enforce monotonicity immediately. Design-phase only; no behavior change. Co-Authored-By: Claude Fable 5 --- docs/design/read-response-integrity.md | 110 +++++++++++++++++-------- 1 file changed, 76 insertions(+), 34 deletions(-) diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md index 57fefde..5979b33 100644 --- a/docs/design/read-response-integrity.md +++ b/docs/design/read-response-integrity.md @@ -101,7 +101,7 @@ read 応答は2区間で異なるシリアライズを経る: - relay ワイヤ(node↔node): libp2p **CBOR** codec(`behaviour.rs:38`、`ContentResponse` を serde/CBOR) - HTTP(caller node↔SDK): **JSON** -署名フィールドは **`Option` で後方互換に追加可能**(CBOR は末尾フィールド追加を無視/欠損=None、JSON は `#[serde(default)]`)。ただし E2E で運ぶには経路上の全型を通す必要がある: +E2E で運ぶ必要があるのは、(A) の Node 全体バイト列(`data` を「生 payload」から「`Node::to_bytes()` の CBOR」に変える or 別フィールド追加)と、(B) の **owner 発行 member 証明トークン**(node 生署名ではない、§5.0.0)。いずれも **`Option` で後方互換に追加可能**(CBOR は末尾フィールド追加を無視/欠損=None、JSON は `#[serde(default)]`)。経路上の全型を通す必要がある: | 層 | 型 | 場所 | |---|---|---| @@ -112,10 +112,28 @@ read 応答は2区間で異なるシリアライズを経る: | HTTP | `ContentDataResponse` | `http_api.rs:225` | | SDK | `StateNodeContentDataResponse` | `models/state_node.rs:51` | -履歴も運ぶなら `ContentResponse::HistoryData`(`protocol.rs:129`)/ `ContentHistoryResponse`(`http_api.rs:232`)も同様。**caller の `libp2p_network.rs:1828` の分解パターン修正が必須**(現状署名を捨てている)。 +追加フィールドの想定: `node_bytes: Option>`(A 用、Node 全体)と `member_proof: Option`(B 用、owner 発行 JWT)。**member リスト・node 生署名は載せない**(§5.0.0)。**caller の `libp2p_network.rs:1828` の分解パターン修正が必須**(現状 `..` で余剰フィールドを捨てている)。 ## 5. 設計方針(たたき台 / 要レビュー) +### 5.0.0 機密性の制約(メタデータプライバシー) ★設計の大前提 + +**完全性を足すために、メタデータ機密性(誰がどの content を管理しているか)を悪化させてはならない。** + +背景: 当初案(member 集合を晒す / member node 鍵で応答に署名)は、完全性は満たすが機密性を壊す。「member リスト全体が見える」ことは「単体の member が見える」現状より質的に一段危険: + +| 観点 | 単体が見える(現状 relay) | リスト全体が見える(避けるべき) | +|---|---|---| +| 可用性攻撃 | 冗長化(replication)が守る | **全 member 特定で冗長化が無効化** — 一番効く | +| 名寄せ・相関 | 点が繋がりにくい | ノード共起グラフが組め、名寄せ可能 | +| 非否認性 | 揮発的(観測のみ) | 署名を載せると**永続的な証拠**が残る | + +したがって設計制約: + +1. **member 集合(リスト)を relay ノード・クライアントに晒さない。** 現状 relay が漏らす範囲(応答した単体ノード)を超えて広げない。 +2. **応答した個別ノードが「自分は正規 member だ」を単体で証明する**形にする。集合を見せずに単体の正当性だけ検証する。 +3. member node 鍵の**生署名を relay に残さない**(非否認性の劣化を避ける)。証拠が残るなら、node 身元と結びつかない形にする。 + ### 5.0 鍵レイヤーの整理(調査で確定) 設計に関わる鍵は**別レイヤーの2種類**で、混同しないこと。 @@ -137,60 +155,84 @@ crsl-lib の `Node` は `to_bytes()`(CBOR)/`from_bytes()` が公開されてお - つまり **署名が本質的に必要なのは「これが最新である」という否定的事実**(=より新しい版が存在しないこと)だけに絞り込める。データの真正性・系列は content-addressing で足りる。 +**機密性との両立**: Node の中身は暗号文(payload.data は SDK が暗号化済み)なので、Node 全体を返してもコンテンツ内容は漏れない。ただし §5.0.0 の制約から、Node を返すこと自体が「応答した単体ノードがこの content を持つ」ことを示す点は現状 relay と同じ(単体レベル)であり、それを超えない。**member リストや node 生署名は載せない。** + この発見により設計を2つに分離できる: -- **(A) 版指定 read**(`version` を指定):署名不要。member は Node を返し、クライアントは CID 再計算で検証。改ざん・偽データは弾ける。 -- **(B) 最新 read / 履歴**(`version: None`):member の「これが最新」「これが履歴」という主張は content-addressing では検証できない(否定的事実のため)。ここに member の node 鍵署名 + 単調性チェックが要る。 +- **(A) 版指定 read**(`version` を指定):**署名不要**。member は Node を返し、クライアントは CID 再計算で検証。改ざん・偽データは弾ける。機密性の追加漏洩もゼロ(content-addressing のみ)。 +- **(B) 最新 read / 履歴**(`version: None`):member の「これが最新」という主張は content-addressing では検証できない(否定的事実のため)。ここに完全性の裏付けが要るが、**§5.0.0 の制約下でどう作るかが本設計の核心**(§5.1)。 -### 5.1 何に署名するか +### 5.1 「最新である」の完全性を、機密性を壊さずに足す -member は応答ごとに、以下を含むメッセージへ node_key(P-256)で署名する: +「これが最新」の否定的事実には裏付けが要るが、member node 鍵の生署名(§5.0.0 が禁じる)は使えない。代わりに2つのアプローチを組み合わせる。 -``` -sign( content_id || version_cid || sha256(data) || timestamp ) -``` +#### 5.1.a 単調性チェック(node 証明不要・機密性ゼロ影響)★まず必須 -- `sha256(data)` を含めることで本文の真正性を担保(GCM とは独立に、暗号文そのものの出所を保証)。 -- `version_cid` を含めることで「どの版か」を署名対象に固定。 -- `timestamp` で応答のリプレイ窓を制限。 +クライアントが「その content について自分が最後に見た version CID」をローカルに記録し、**新しい応答がその版の祖先(=巻き戻り)なら拒否/警告**する(TOFU 的 monotonicity)。 -署名は `ContentResponse::ContentData` に `signature: Option>` + `signer_node_id: Option` として載せ、E2E で SDK まで運ぶ。 +- ロールバック攻撃(過去の本物の版を最新と偽る)を検出できる。 +- 版指定 read(A)で Node を取得できるので、返ってきた版から parents を辿り「前回見た版が祖先に含まれるか」を確認できる。含まれなければ巻き戻り。 +- **node の身元も member リストも一切要らない。** relay に何の証拠も残さない。機密性への影響ゼロ。 +- 限界: 「自分が初めて読む content」には基準がない(TOFU の初回問題)。また「最新を隠して古いが正当な版を出す」stale は検出できるが、「まだ誰も見ていない最新」の欠落は原理的に検出不能(否定的事実)。 -### 5.2 クライアント側の検証(3段) +#### 5.1.b owner 発行の member 証明(単体・リスト非公開)— レイヤー1 兼用 -1. **署名検証**: `signer_node_id` の公開鍵で署名を検証(公開鍵の入手経路は §6 の論点)。→ 応答が「その node の本物の発言」であることを保証。 -2. **member 検証**: `signer_node_id` が対象コンテンツの正規 member か。→ ContentNetwork レコードの署名検証(レイヤー1)と連動。 -3. **系列・単調性検証**: - - **系列チェーン**: 版が genesis から parents で到達可能か。各版の `parents`/`genesis` をクライアントが取得できる口(現状 monas ラッパーに Node の parents を返す API が無い → `repo.dag.get_node()` 直呼びが必要。新設が要る)。 - - **単調性(ロールバック検出)**: クライアントが「前回見た版」をローカル記録し、返ってきた版がその祖先に巻き戻っていないか(monotonic / TOFU)。member/非 member 問わず効く。 +応答ノードが正規 member であることを、**リストを晒さず単体で**証明する。既存の owner 署名委任トークン(`service.rs:98-133`、`{iss: owner, aud: recipient, att: [{with: "monas://content/{cid}", can}]}` を owner P-256 鍵で ES256 署名)を **member 証明**に転用する: -### 5.3 メンバーシップ証明(レイヤー1) +- owner が各 member node に対し「この content の member である」証明トークン(`aud = member の node 公開鍵 key_id`、`att = {with: content, can: "host"}` 等)を発行。 +- 応答時、member は**自分宛の証明トークン**を応答に添える。クライアントは owner 公開鍵(= `AccessPolicy.owner`、read 認証で既に既知)で検証し、「owner がこのノードを member と認めている」ことを確認。 +- **リスト全体は出ない** — 応答した1ノードの証明だけ。他の member が誰かは分からない。§5.0.0 の制約を満たす。 +- 非否認性: トークンは owner→当該 node の委任なので、「node が自分の身元で署名した証拠」ではなく「owner がこの node を認可した証拠」。member 集合の共起グラフには使えず、劣化は限定的。ただし「owner がこの content をこの node に置いた」事実は残るため、**この証明を応答ごとに常時添付するか、要求時のみか**は §6 の論点。 -`ContentNetwork` レコード(member リスト)に owner / genesis authority の署名を付け、gossip 受信時・DHT フォールバック時の両方で検証。§4.1 の弱点(PeerID 文字列 vs P-256 NodeId)もここで整合を取る。 +#### 5.1.c 版の真正性(A で解決済み・再掲) -## 6. 論点への推奨(§5.0 の発見を踏まえた現時点の案) +「最新」と主張された版そのものの中身の真正性は §5.0.1(A)の CID 再計算で担保。5.1.a/5.1.b は「その版が本当に最新の系列に属し、正規ノードが出したか」を補う。 -1. **member 公開鍵の配布** → **ContentNetwork レコードに member の P-256 公開鍵を含める**。member リスト自体が owner 署名で保護される(§5.3)ので、そこに公開鍵を同梱すれば「正規 member の鍵一覧」が owner 権威で配布される。クライアントは応答署名をこの鍵で検証。`NodePublicKey` 交換(node 間)とは別に、SDK 向けにこのレコードを返す口が要る。既存の `PublicKeyRegistry` は node ローカルの検証用なので流用しない。 +### 5.2 クライアント側の検証フロー -2. **メンバーシップ署名の権威** → **owner のユーザー鍵**(§5.0)。`AccessPolicy.owner` が既に `Identity`(P-256 pubkey)なので、owner が member リスト + 各 member の公開鍵に署名する。member 追加/削除のたびに owner が再署名(単調増加する version 番号付きで、古い member リストへの差し替えを防ぐ)。genesis authority 案は owner と一致するので別概念にしない。**残論点**: owner がオフラインのとき member 変更できない問題 → owner が委任トークン(既存 JWT/UCAN, `jwt_signer.rs`)で member 管理権限を委譲する形を検討。 +最新 read の場合: -3. **系列検証のコスト** → **通常は単調性チェックのみ(直近版の親子1ホップ)、全チェーン検証はオンデマンド**。§5.0.1 で版指定 read は CID 再計算だけで足りるため、毎回 genesis まで辿る必要はない。ロールバック検出には「前回見た版が今回の版の祖先か」だけ確認できればよく、これは差分の parents を辿る短いパスで済む。監査目的の全チェーン検証は明示要求時のみ。 +1. **版の真正性**: 応答の Node を `from_bytes` → `content_id()` 再計算し、応答が主張する version CID と一致するか(§5.0.1)。不一致なら偽データ → 拒否。 +2. **単調性**: ローカル記録の「最後に見た版」が、今回の版の祖先か(parents を辿る)。巻き戻りなら拒否/警告(§5.1.a)。 +3. **member 証明**(有効化時): 応答に添えられた owner 発行の member 証明トークンを owner 公開鍵で検証(§5.1.b)。無効 or 欠落は段階導入モードに従い warn/拒否。 +4. 検証通過後、ローカルの「最後に見た版」を更新。 -4. **単調性の状態管理** → **SDK のローカル永続化に「content_id → 最後に見た version CID + timestamp」を記録**。SDK は既に `SledContentEncryptionKeyStore`(`controller/mod.rs:246`)で sled 永続化を持つので、同じ DB に version 追跡ストアを足す。**複数デバイス問題**: デバイス A が v5、デバイス B が v3 までしか知らない場合、B が v3→v5 に進むのは正常(巻き戻しではない)。TOFU の単調性は「自分が一度見た版より古い版を最新と主張されたら警告」であり、デバイス間で状態共有は不要(各デバイスが自分の観測履歴を持てばよい)。ただし正規の履歴改変(owner による rebase 等)がある設計なら誤検知しうる → Monas の CRDT は追記のみ(版は不変)なので問題にならない見込み。要確認。 +member リストの取得・検証は**フローに現れない**(晒さないため)。 -5. **鍵ローテーション** → **member リストの version 番号 + timestamp で世代管理**。node 鍵ローテーション時は owner が新しい公開鍵を含む member リストに再署名。過去の署名は「その時点で有効だった member リストの世代」に対して検証する必要があるため、クライアントは応答の timestamp とレコード世代を突き合わせる。詳細は実装フェーズで詰める(初版はローテーション非対応でも可)。 +### 5.3 メンバーシップ証明 — 単体・リスト非公開(改訂) -6. **段階導入** → **3 モードで移行**: (i) `Option` 署名を付けるが**検証しない**(観測のみ、ログ) → (ii) 署名があれば**検証する**が、無くても通す(warn) → (iii) 署名必須(無い/検証失敗は**拒否**)。オープン参加型ネットワーク移行前に (iii) へ。新旧ノード混在期は (ii) で吸収。gossip の member レコード署名も同様の 3 モード。 +当初案(`ContentNetwork` リストに owner 署名を付けて配布)は**リスト全体を晒すため §5.0.0 に反する**ので採らない。代わりに §5.1.b の **owner 発行の単体 member 証明トークン**で「応答ノードが member か」を検証する。 -### 6.1 ユーザーに確認したい設計判断 +- owner が各 member node に個別に発行する証明トークンなので、**リストとして流通しない**。クライアントが目にするのは「応答した1ノードの証明」だけ。 +- gossip の `ContentNetworkManagerAdded` イベント無検証問題(§2 レイヤー1)は、node 側が「自分が member になった証拠」= owner 発行トークンを保持し、relay 応答時に提示する形で解消。node 間で member 集合を交換・保存する必要が減る。 +- §4.1 の弱点(member 判定が libp2p PeerID 文字列 vs P-256 NodeId)は、証明トークンの `aud` を P-256 node 公開鍵に統一することで整合を取る。 + +## 6. 論点への推奨(機密性制約 §5.0.0 反映後) + +1. **member 公開鍵の配布** → **配布しない(リスト非公開)**。当初の「ContentNetwork レコードに member 公開鍵一覧を同梱」案は撤回。クライアントが検証するのは owner 公開鍵(既知)で署名された**単体の member 証明トークン**(§5.1.b)のみ。各 member の node 公開鍵はトークンの `aud` として1件ずつ現れるだけで、集合は出ない。 + +2. **member 証明の権威** → **owner のユーザー鍵**。`AccessPolicy.owner`(P-256 pubkey、read 認証で既知)が member 証明トークンを ES256 署名(既存 `service.rs:98-133` / `jwt_signer.rs` を転用)。member 追加時に owner がそのノード宛トークンを発行、削除は TTL 失効 + `min_valid_issued_at` 相当の一括失効(既存の token 失効機構、design.md §10)を流用。**残論点**: owner オフライン時の member 追加 → 既存の write 委任と同じく、管理権限の委任トークンで移譲する形を検討(初版は owner online 必須で割り切り可)。 + +3. **系列検証のコスト** → **通常は単調性チェックのみ(前回版が今回版の祖先かを parents で辿る短いパス)、全チェーン検証はオンデマンド**。版指定 read は CID 再計算だけで足りる(§5.0.1)ため毎回 genesis まで辿らない。監査時のみ全チェーン。 -以下は技術だけで決められない、プロダクト方針が絡む点: +4. **単調性の状態管理** → **SDK のローカル sled に「content_id → 最後に見た version CID + timestamp」を記録**。SDK は既に `SledContentEncryptionKeyStore`(`controller/mod.rs:246`)を持つので同 DB に足す。**追記のみ DAG を実コードで確認済み**(更新は `new_child` で新 CID を作り parents で前版を指す、既存 Node は不変 — `node.rs:53`, `crdt_repository.rs:563`)なので、正規の巻き戻しは発生せず誤検知しない。複数デバイスは各自の観測履歴を持てばよく状態共有不要(v3→v5 の前進は正常、v5→v3 の後退のみ警告)。 + +5. **鍵ローテーション** → member 証明トークンの `exp` / `iat` で世代管理。node 鍵ローテーション時は owner が新しい `aud`(新公開鍵)のトークンを再発行、旧トークンは TTL 失効。初版はローテーション非対応でも可。 + +6. **段階導入** → **3 モードで移行**: (i) member 証明を応答に付けるが**検証しない**(観測のみ) → (ii) あれば検証、無ければ warn で通す → (iii) 必須(無い/無効は拒否)。単調性チェック(§5.1.a、機密性影響ゼロ)は依存物が無いので**先行して (iii) 相当まで入れてよい**。member 証明(§5.1.b)はオープン化前に (iii) へ。 + +### 6.1 ユーザーに確認したい設計判断 -- **owner オフライン時の member 管理**(論点2): 委任トークンで管理権限を移譲する仕組みを入れるか、初版は「owner online 必須」で割り切るか。 -- **段階導入の (iii) 強制タイミング**(論点6): このPR系列で (i)/(ii) まで入れ、(iii) はオープン化のマイルストーンに紐付けるか。 -- **実装の分割単位**: §7 の (a)(b)(c) を別 PR にするか、まとめるか。 +- **member 証明の添付頻度**(非否認性 vs 検証可能性): owner→node の証明を**応答ごとに常時添付**するか、**クライアントが要求したときだけ**か。常時添付は検証が確実だが「owner がこの content をこの node に置いた」事実が応答経路に露出しやすい。要求時のみは露出を絞れるが未検証応答が増える。 +- **owner オフライン時の member 管理**(論点2): 管理権限の委任を入れるか、初版は owner online 必須で割り切るか。 +- **単調性の強制タイミング**: §5.1.a を先行して拒否モードまで入れてよいか(機密性影響ゼロ・依存なしのため技術的には即可)。 +- **実装の分割単位**: §7 の段階を別 PR にするか。 ## 7. スコープと優先度 - すべて「攻撃者が read relay の経路に入れること」が前提。**クローズドな 4 ノード構成の現状では成立しない**。オープン参加型ネットワークにする前までに対応(Kademlia への Sybil/eclipse 攻撃が現実的になるため)。 -- 実装は段階分割の想定: (a) 応答署名 + クライアント検証(レイヤー2 の中核)→ (b) 系列・単調性検証 → (c) メンバーシップ署名(レイヤー1)。 +- **機密性制約 §5.0.0 は全段階で不変の前提**。完全性を足す各段が member 集合を晒していないか、各 PR でチェックする。 +- 実装の段階分割(依存順): + 1. **版の真正性(A)** — member が Node 全体を返す + クライアント CID 再計算。署名不要・機密性影響ゼロ。最優先で独立に入る。 + 2. **単調性チェック(§5.1.a)** — SDK ローカル記録 + 祖先判定。機密性影響ゼロ・依存なし。1 と並行可。 + 3. **owner 発行 member 証明(§5.1.b / §5.3)** — owner 署名トークン発行 + 応答添付 + クライアント検証。段階導入 (i)→(iii)。オープン化前に必須化。 From 1a500ed56e186817397c2a125451fdd4a660bb0b Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 00:26:31 +0900 Subject: [PATCH 04/48] =?UTF-8?q?docs(design):=20finalize=20approach=20?= =?UTF-8?q?=E2=80=94=20owner-issued=20single=20member=20proof=20+=20monoto?= =?UTF-8?q?nicity=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settled the design after working through why owner has to be the root of trust: a member's "I am a member" proof must anchor to something the reader already trusts, and the only such anchor is the owner key (the reader's own read grant is an owner-signed delegation). Member self-key, NodeID hash, and CEK were each ruled out as roots. Owner signs the proof once at member-add time and never sits in the read path. Decided: - (A) version-pinned read: return Node, client recomputes CID. No sig. - member responds with an owner-issued per-node membership proof token (reuse existing ES256 owner delegation, aud = that node) — proves "a legit member answered" WITHOUT exposing the member set. - monotonicity/TOFU for best-effort rollback detection. - Explicitly document what this does NOT prevent: a genuine member serving a stale/rolled-back version (impossible to prevent over the network — negative fact), accepted as a known threat-model limit. Recorded rejected alternatives (signed member list, member node-key raw sig, CID-in-envelope, authenticated mutable "latest pointer", CEK-based proof) with reasons, to prevent re-litigating. Envelope investigation confirmed envelopes are distributed once at share-grant time and never on update, killing the static-CID-in-envelope idea. Design-phase only; no behavior change. Co-Authored-By: Claude Fable 5 --- docs/design/read-response-integrity.md | 35 ++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md index 5979b33..2a93732 100644 --- a/docs/design/read-response-integrity.md +++ b/docs/design/read-response-integrity.md @@ -1,6 +1,6 @@ # read 経路の完全性: 署名付き応答の E2E 検証 -- ステータス: **設計中(draft)** +- ステータス: **方式確定(2026-07-18)、実装計画待ち** - 関連: PR #54、issue #55 - 前提ブランチ: `feature/read-response-signing`(#54 の上に積む) @@ -175,14 +175,22 @@ crsl-lib の `Node` は `to_bytes()`(CBOR)/`from_bytes()` が公開されてお - **node の身元も member リストも一切要らない。** relay に何の証拠も残さない。機密性への影響ゼロ。 - 限界: 「自分が初めて読む content」には基準がない(TOFU の初回問題)。また「最新を隠して古いが正当な版を出す」stale は検出できるが、「まだ誰も見ていない最新」の欠落は原理的に検出不能(否定的事実)。 -#### 5.1.b owner 発行の member 証明(単体・リスト非公開)— レイヤー1 兼用 +#### 5.1.b owner 発行の member 証明(単体・リスト非公開)— レイヤー1 兼用 ★採用決定(2026-07-18) 応答ノードが正規 member であることを、**リストを晒さず単体で**証明する。既存の owner 署名委任トークン(`service.rs:98-133`、`{iss: owner, aud: recipient, att: [{with: "monas://content/{cid}", can}]}` を owner P-256 鍵で ES256 署名)を **member 証明**に転用する: - owner が各 member node に対し「この content の member である」証明トークン(`aud = member の node 公開鍵 key_id`、`att = {with: content, can: "host"}` 等)を発行。 - 応答時、member は**自分宛の証明トークン**を応答に添える。クライアントは owner 公開鍵(= `AccessPolicy.owner`、read 認証で既に既知)で検証し、「owner がこのノードを member と認めている」ことを確認。 - **リスト全体は出ない** — 応答した1ノードの証明だけ。他の member が誰かは分からない。§5.0.0 の制約を満たす。 -- 非否認性: トークンは owner→当該 node の委任なので、「node が自分の身元で署名した証拠」ではなく「owner がこの node を認可した証拠」。member 集合の共起グラフには使えず、劣化は限定的。ただし「owner がこの content をこの node に置いた」事実は残るため、**この証明を応答ごとに常時添付するか、要求時のみか**は §6 の論点。 +- 非否認性: トークンは owner→当該 node の委任なので、「node が自分の身元で署名した証拠」ではなく「owner がこの node を認可した証拠」。member 集合の共起グラフには使えず、劣化は限定的。 + +**なぜ owner が信頼の根になるか(設計議論の記録)**: member の証明は「読み手が既に信頼している何か」に根を張る必要がある(宙に浮いた証明は攻撃者も同じ形で主張できる)。読み手が確実に持つ信頼の起点は **owner の公開鍵だけ** — 読み手の read 権限自体が owner 署名の委任で付与されるため。member 自身の鍵(攻撃者も名乗れる)、NodeID↔鍵のハッシュ関係(「この content の member か」を語らない)、CEK(読み手も持つので member を区別できない)はいずれも根にならない。「誰が member かを決める権威 = owner」の必然的帰結として、証明の署名者も owner になる。**owner は発行時に一度署名するだけで、read 処理のたびに介在するわけではない。** + +**owner 公開鍵の可視性について**: 検証の成立自体は owner 公開鍵の秘匿を必要としない(署名検証は公開鍵で行う。重要なのは読み手が「正しい owner 鍵」を権限付与経路で得ていること)。ただしメタデータ機密性の観点では、証明トークンの `iss`(owner key id)が relay 中継ノードに見えると「owner ↔ content ↔ node」のリンクが漏れる。緩和策: 証明トークンを**読み手宛に暗号化して運ぶ**(中継には不透明)、または要求時のみ添付。owner 公開鍵が関係者(権限保持者)以外に知られていない運用なら、`iss` が見えても外部者は owner を同定できないため、露出はさらに限定される。実装フェーズで添付方式と合わせて確定する。 + +**この方式が防ぐもの / 防がないもの(明確化)**: +- ✅ 防ぐ: **非 member のなりすまし**(DHT フォールバックで拾われた無関係ノードが偽応答・偽履歴を返す)— 証明を出せないので弾ける。指摘 #54 レビューの主シナリオはこれ。 +- ❌ 防がない: **正規 member 自身が古い版を「最新」と返す**こと(悪意 or 単なる sync 遅延)。証明は出せてしまう。これは分散システムの原理的限界(否定的事実「より新しい版が無い」はネットワーク越しに証明不能)であり、§5.1.a の単調性チェックによるベストエフォート検出 + 「既知の限界」として脅威モデルに明記する。 #### 5.1.c 版の真正性(A で解決済み・再掲) @@ -221,13 +229,26 @@ member リストの取得・検証は**フローに現れない**(晒さない 6. **段階導入** → **3 モードで移行**: (i) member 証明を応答に付けるが**検証しない**(観測のみ) → (ii) あれば検証、無ければ warn で通す → (iii) 必須(無い/無効は拒否)。単調性チェック(§5.1.a、機密性影響ゼロ)は依存物が無いので**先行して (iii) 相当まで入れてよい**。member 証明(§5.1.b)はオープン化前に (iii) へ。 -### 6.1 ユーザーに確認したい設計判断 +### 6.1 設計判断の状況(2026-07-18 更新) -- **member 証明の添付頻度**(非否認性 vs 検証可能性): owner→node の証明を**応答ごとに常時添付**するか、**クライアントが要求したときだけ**か。常時添付は検証が確実だが「owner がこの content をこの node に置いた」事実が応答経路に露出しやすい。要求時のみは露出を絞れるが未検証応答が増える。 -- **owner オフライン時の member 管理**(論点2): 管理権限の委任を入れるか、初版は owner online 必須で割り切るか。 -- **単調性の強制タイミング**: §5.1.a を先行して拒否モードまで入れてよいか(機密性影響ゼロ・依存なしのため技術的には即可)。 +**決定済み**: +- **方式**: §5.1.b(owner 発行の単体 member 証明 + member 応答)+ §5.1.a(単調性)+ (A) CID 再計算、の組み合わせで確定。owner は発行時のみ介在し read 経路には入らない。 +- **鮮度の限界の受容**: 正規 member 自身によるロールバック/stale は原理的に防げないことを「既知の限界」として脅威モデルに明記する(§5.1.b)。 + +**実装フェーズで確定する残論点**: +- **member 証明の添付方式**: 常時添付 or 要求時のみ。および中継に `iss`(owner)を見せないための読み手宛暗号化の要否(§5.1.b「owner 公開鍵の可視性」)。 +- **owner オフライン時の member 追加**: 管理権限の委任を入れるか、初版は owner online 必須で割り切るか。 +- **単調性の強制タイミング**: §5.1.a を先行して拒否モードまで入れるか(機密性影響ゼロ・依存なしのため技術的には即可)。 - **実装の分割単位**: §7 の段階を別 PR にするか。 +### 6.2 検討して却下した案(再検討防止の記録) + +1. **署名付き member リストの配布** — リスト全体が晒され、冗長化の無効化・名寄せ・非否認性の劣化を招く(§5.0.0)。却下。 +2. **member node 鍵の生署名を応答に載せる** — 「この node がこの content を持つ」永続的証拠が残る。owner→node 委任トークンで代替(§5.1.b)。 +3. **envelope への最新版 CID 埋め込み** — 調査の結果、envelope(HPKE wrapped CEK)は**共有付与時に1回だけ**配布され、通常の update では再配布されない(`update_content` は share/envelope に一切触れない)。静的に埋めた CID は初版で固定され最新を追えない。却下。なお envelope の HPKE aad には content_id が既にバインドされており「この envelope はこの版のもの」の認証は既存機構で効いている。 +4. **認証付き可変「最新ポインタ」**(JWT の未使用 `fct` フィールド等に最新 CID を載せる案を含む) — ポインタ自体が「最新性を保証すべき可変状態」になり、同じ問題が再帰する(そのポインタは最新か?)。同期・更新コストも生む。却下。 +5. **CEK による member 証明** — CEK は読み手・書き手・(設計次第で)member 全員が持つため「member だけ」を区別できない。却下。 + ## 7. スコープと優先度 - すべて「攻撃者が read relay の経路に入れること」が前提。**クローズドな 4 ノード構成の現状では成立しない**。オープン参加型ネットワークにする前までに対応(Kademlia への Sybil/eclipse 攻撃が現実的になるため)。 From 30bb2af8e8f2bec4b135085bd705143f28aef5c3 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 00:33:08 +0900 Subject: [PATCH 05/48] docs(design): add single-PR implementation plan; no backward compat (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production usage is zero (test-only), so drop all backward-compat and staged-rollout machinery: verification is mandatory from the start, wire types are replaced directly (no Option coexistence), single PR. §8 implementation plan, grounded in the §4 investigations: - A: repo returns whole Node (CBOR) instead of raw payload; client recomputes CID to detect tampering. - B: SDK sled store of last-seen version CID per content; ancestor check rejects rollback; TOFU on first read. - C: reuse owner ES256 delegation (service.rs:98-133) as a per-node membership proof (aud = node, can = "host"); member attaches it to the read response; SDK verifies against owner key. List never exposed. Pre-resolved two blockers: SDK has no crsl-lib dep (→ ship a lightweight self-contained CID-recompute helper, not the whole DAG lib), and SDK doesn't know the owner key (→ likely derivable from the delegation token's iss; confirm before adding an API). Design-phase only; no behavior change. Co-Authored-By: Claude Fable 5 --- docs/design/read-response-integrity.md | 100 ++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 12 deletions(-) diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md index 2a93732..a20128f 100644 --- a/docs/design/read-response-integrity.md +++ b/docs/design/read-response-integrity.md @@ -235,11 +235,11 @@ member リストの取得・検証は**フローに現れない**(晒さない - **方式**: §5.1.b(owner 発行の単体 member 証明 + member 応答)+ §5.1.a(単調性)+ (A) CID 再計算、の組み合わせで確定。owner は発行時のみ介在し read 経路には入らない。 - **鮮度の限界の受容**: 正規 member 自身によるロールバック/stale は原理的に防げないことを「既知の限界」として脅威モデルに明記する(§5.1.b)。 -**実装フェーズで確定する残論点**: -- **member 証明の添付方式**: 常時添付 or 要求時のみ。および中継に `iss`(owner)を見せないための読み手宛暗号化の要否(§5.1.b「owner 公開鍵の可視性」)。 -- **owner オフライン時の member 追加**: 管理権限の委任を入れるか、初版は owner online 必須で割り切るか。 -- **単調性の強制タイミング**: §5.1.a を先行して拒否モードまで入れるか(機密性影響ゼロ・依存なしのため技術的には即可)。 -- **実装の分割単位**: §7 の段階を別 PR にするか。 +**実装前提の確定(2026-07-18)**: production 利用ゼロ(テストのみ)のため、**後方互換は一切考慮しない。破壊的変更 OK。1 PR で全実装**。これに伴い: +- **段階導入(3 モード)は廃止** — 最初から検証必須(検証失敗 = 拒否)で実装する。`Option` フィールドでの共存も不要、ワイヤ型は直接置き換える。 +- **member 証明の添付方式**: **常時添付**で開始(シンプル優先)。`iss` の読み手宛暗号化は初版では入れず、§5.1.b の緩和策として TODO 記録に留める(クローズド環境のうちは露出リスクが実質ない)。 +- **owner オフライン時の member 追加**: 初版は **owner online 必須**で割り切る(委任は将来)。 +- **単調性**: 最初から拒否モード。 ### 6.2 検討して却下した案(再検討防止の記録) @@ -249,11 +249,87 @@ member リストの取得・検証は**フローに現れない**(晒さない 4. **認証付き可変「最新ポインタ」**(JWT の未使用 `fct` フィールド等に最新 CID を載せる案を含む) — ポインタ自体が「最新性を保証すべき可変状態」になり、同じ問題が再帰する(そのポインタは最新か?)。同期・更新コストも生む。却下。 5. **CEK による member 証明** — CEK は読み手・書き手・(設計次第で)member 全員が持つため「member だけ」を区別できない。却下。 -## 7. スコープと優先度 +## 7. スコープと前提 -- すべて「攻撃者が read relay の経路に入れること」が前提。**クローズドな 4 ノード構成の現状では成立しない**。オープン参加型ネットワークにする前までに対応(Kademlia への Sybil/eclipse 攻撃が現実的になるため)。 -- **機密性制約 §5.0.0 は全段階で不変の前提**。完全性を足す各段が member 集合を晒していないか、各 PR でチェックする。 -- 実装の段階分割(依存順): - 1. **版の真正性(A)** — member が Node 全体を返す + クライアント CID 再計算。署名不要・機密性影響ゼロ。最優先で独立に入る。 - 2. **単調性チェック(§5.1.a)** — SDK ローカル記録 + 祖先判定。機密性影響ゼロ・依存なし。1 と並行可。 - 3. **owner 発行 member 証明(§5.1.b / §5.3)** — owner 署名トークン発行 + 応答添付 + クライアント検証。段階導入 (i)→(iii)。オープン化前に必須化。 +- すべて「攻撃者が read relay の経路に入れること」が前提。**クローズドな 4 ノード構成の現状では成立しない**が、オープン参加型移行前に必要なので今のうちに入れる(Kademlia への Sybil/eclipse 攻撃が現実的になるため)。 +- **機密性制約 §5.0.0 は不変の前提**。完全性を足す実装が member 集合を晒していないかを実装中チェックする。 +- **後方互換なし・破壊的変更 OK・1 PR**(§6.1)。 + +## 8. 実装計画(1 PR) + +3 コンポーネントを 1 PR で実装する。依存順に記載するが同一 PR。すべて既存コードの file:line は §4 の調査に基づく。 + +### 8.1 コンポーネント A: 版真正性(Node 全体を返して CID 再計算) + +**目的**: member の read 応答が「生 payload」ではなく `Node` 全体(CBOR)を返すようにし、クライアントが CID を再計算して改ざん検知する。 + +**state-node 側**: +1. `crdt_repository.rs` の `get_version` / `get_latest_with_version`(`:184, :207, :232`)が現状 `node.payload().data.clone()` を返すのを、**`node.to_bytes()`(CBOR 全体)を返す**ように変更。戻り値型を「payload バイト列」から「Node CBOR バイト列」へ。※ port trait `content_repository.rs` のシグネチャも変更。 +2. `read_content_via_relay`(`state_node_service.rs:565`)の戻り値 `(Vec, String)` の `Vec` を Node CBOR に。 +3. ワイヤ: `ContentResponse::ContentData { content_id, data, version }`(`protocol.rs:106`)の `data` を Node CBOR に(意味を変えるだけで型は `Vec` のまま。フィールド名を `node_bytes` にリネームして意図を明示)。内部 `RelayOutcome::Data`(`libp2p_network.rs:55`)も同様。 +4. HTTP `ContentDataResponse`(`http_api.rs:225`)/ SDK `StateNodeContentDataResponse`(`models/state_node.rs:51`)の `data` も Node CBOR(base64)に。 + +**SDK 側(クライアント検証)**: +5. Node CBOR を受け取ったら、crsl-lib の `Node::from_bytes`(`node.rs:104`)→ `content_id()`(`node.rs:76`)で CID 再計算し、要求 version(または応答の主張 version)と一致を検証。不一致は**拒否**。 +6. 検証後、`Node.payload().data`(暗号文)を取り出して既存の復号(AES-GCM)に渡す。 + - ※ crsl-lib はワイヤ型の依存に入る。SDK が crsl-lib の `Node` 型を使えるか要確認(既に依存にあるか、追加が要るか)。無理なら Node のパース + CID 再計算だけを行う軽量ヘルパを用意。 + +### 8.2 コンポーネント B: 単調性チェック(ロールバック検出) + +**目的**: SDK が「content ごとに最後に見た version CID」を記録し、後退した応答を拒否。 + +1. SDK ローカルストア: 既存 `SledContentEncryptionKeyStore`(`controller/mod.rs:246`)と同じ sled DB に **`content_id → last_seen_version_cid` ストア**を新設(新しい tree/prefix)。in-memory 実装も対で用意(`controller/mod.rs:230` に倣う)。 +2. 祖先判定: 応答の Node から `parents`(`node.rs:144`)を辿り、「記録済みの last_seen が今回版の祖先に含まれるか」を確認。含まれない(= 後退 or 分岐)なら**拒否/警告**。 + - 辿るために親版の取得が要る場合がある → 版指定 read(A)で親を順次取得。深さは実装で bound(全チェーンは監査時のみ、§6 論点3)。 +3. 検証通過後、last_seen を今回版に更新。初回(記録なし)は TOFU で受理 + 記録。 + +### 8.3 コンポーネント C: owner 発行 member 証明 + +**目的**: 応答ノードが正規 member であることを、リストを晒さず単体で証明。 + +**owner(monas-account)側 — 証明発行**: +1. 既存の委任トークン発行(`service.rs:98-133`、`DelegationClaims { iss, aud, exp, iat, jti, att }` を ES256 署名)を転用し、**member 証明トークン**を発行する口を追加。`aud = member の node 公開鍵 key_id`、`att = [{ with: "monas://content/{cid}", can: "host" }]`(`can` に `host` を追加、`DelegatedCapability` / `CapabilityAction` に enum 追加)。 +2. member 追加フロー(`add_member_to_content` 系、`state_node_service.rs:1564` 周辺)で、owner がこのトークンを発行し、対象 member node に配布する経路を追加。member node はトークンを永続化。 + +**member node 側 — 応答に添付**: +3. `read_content_via_relay`(`state_node_service.rs:565`)/ `read_history_via_relay`(`:598`)の応答に、自ノードの member 証明トークンを載せる。ワイヤ `ContentResponse::ContentData` / `HistoryData` に `member_proof: String`(必須)を追加。内部 `RelayOutcome`・HTTP・SDK 型も同様に追加(§4.4 の経路表の全型)。 +4. caller の分解 `libp2p_network.rs:1828`(現状 `..` で余剰を捨てている)を修正し、`member_proof` を通す。 + +**SDK 側 — 検証**: +5. 応答の `member_proof` を **owner 公開鍵**(= `AccessPolicy.owner`、read 認可時に既知)で ES256 検証。`att.with` が要求 content と一致、`exp` 未失効を確認。無効/欠落は**拒否**。 +6. owner 公開鍵の入手: read 認可経路で既に owner を知っているはず(要確認 — SDK が `AccessPolicy.owner` を保持しているか、別途取得が要るか)。 + +### 8.4 検証フロー統合(SDK, §5.2) + +最新 read で以下を順に。1つでも失敗したら拒否: +1. A: Node CBOR → CID 再計算 = 主張 version か +2. C: member_proof を owner 鍵で検証(member か) +3. B: last_seen が今回版の祖先か(後退でないか) +4. 全通過 → 復号して返す + last_seen 更新 + +### 8.5 テスト計画 + +- A: 改ざん Node(payload 書き換え)→ CID 不一致 → 拒否を検証。 +- B: v5 を見た後に v3 を返す → 後退拒否。初回 v3 は受理。 +- C: 非 member(証明なし/他 content の証明)→ 拒否。正規 member の証明 → 受理。owner 鍵違い → 拒否。 +- 統合: 既存の relay read e2e(`e2e-test.sh`)を Node 返却 + 証明必須に更新。 +- **§5.0.0 チェック**: 応答・ログに member 集合が現れないことをテスト/レビューで確認。 + +### 8.6 実装前に確定した事項(調査済み 2026-07-18) + +**(1) SDK は crsl-lib に依存していない**(`monas-sdk/Cargo.toml` に無し)。→ 選択肢: +- (a) crsl-lib を SDK 依存に追加し `Node::from_bytes`/`content_id()` を直接使う。確実だが SDK に DAG ライブラリ全体(leveldb 等含む)を持ち込みビルド肥大。 +- (b) **【推奨】SDK に軽量 CID 検証ヘルパを自前実装**: Node の CBOR を最小限デコード(`payload`/`parents`/`genesis` を取り出す)+ 受信 CBOR バイト列全体を SHA-256 → CIDv1(RAW/SHA2-256、`node.rs:76-81` と同一手順)で version 突合。crsl-lib 全体は要らず、`serde_cbor` + `sha2` + `cid` クレートで足りる。Node の CBOR スキーマ(フィールド順・型)を crsl-lib と厳密に一致させる必要があるのでテストで固定。 +- → **(b) を採用**。ただし CBOR スキーマ一致の検証テスト(state-node が出す Node CBOR を SDK が再計算して一致)を必須にする。 + +**(2) SDK は owner / AccessPolicy を知らない**(`AccessPolicy` は state-node ドメイン、SDK には無い)。→ member 証明を owner 鍵で検証するには owner 公開鍵の入手経路が新規に要る: +- read 認可のために SDK は既に「自分の権限(委任トークン)」を持つ。そのトークンの `iss` が owner なので、**owner 公開鍵は委任トークンの `iss` から得られる**可能性が高い(要確認: `iss` が pubkey そのものか、key_id か)。 +- 委任経由で得られないなら、content の owner 公開鍵を返す軽量な取得口を state-node/account に新設。 +- → 実装первый手: SDK が持つ委任トークンの `iss` から owner 公開鍵を導出できるか確認。可能なら追加 API 不要。 + +### 8.7 実装中に判定する TODO + +- 単調性の祖先探索の深さ bound の既定値。8.2-2。 +- member 証明の配布経路(owner→member node)の具体。8.3-2。 +- member 証明トークンの永続化先(member node 側)。8.3-2。 +- `member_proof` の `iss` 露出緩和(読み手宛暗号化)は初版スコープ外・TODO 記録のみ(§6.1)。 From ffe1db300437a286a524b70238b609480b57f747 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 00:41:05 +0900 Subject: [PATCH 06/48] docs(design): place verification in monas-content, not SDK; owner key from token iss (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting the layer: verification belongs in monas-content (which monas-sdk depends on and where decryption, CID calc, CEK, and share already live), not in the thin SDK. SDK just calls into it. Two findings: - monas-content's existing Sha256ContentIdGenerator is SHA-256(raw) hex, NOT crsl-lib's Node CID (SHA-256(CBOR(node)) -> CIDv1). So version-CID recompute needs a crsl-compatible impl; content_id.rs already carries a "todo: use crsl cid" — implement it in monas-content and retire the TODO (lightweight serde_cbor+sha2+cid, no full crsl-lib dep). - owner key_id is user:{hex(pubkey)} (service.rs:160) — self-contained, so the owner public key is recoverable straight from the delegation token's iss the reader already holds. No owner-key fetch API needed. This answers the "does the SDK check iss?" question: yes, but it's just a hex-decode of a token already in hand. Design-phase only; no behavior change. Co-Authored-By: Claude Fable 5 --- docs/design/read-response-integrity.md | 36 +++++++++++++++----------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md index a20128f..e65383f 100644 --- a/docs/design/read-response-integrity.md +++ b/docs/design/read-response-integrity.md @@ -259,6 +259,14 @@ member リストの取得・検証は**フローに現れない**(晒さない 3 コンポーネントを 1 PR で実装する。依存順に記載するが同一 PR。すべて既存コードの file:line は §4 の調査に基づく。 +### 8.0 検証ロジックの置き場所 = `monas-content`(2026-07-18 修正) + +**検証は `monas-sdk` ではなく `monas-content` に置く。** 理由: +- `monas-sdk` は `monas-content` に依存する薄い API 層(`monas-sdk/Cargo.toml:10`)。コンテンツの暗号処理(復号 `domain/content/encryption.rs`、CID 計算 `infrastructure/content_id.rs`、CEK 管理、share/envelope)は**すべて既に `monas-content` に集約**されている。read の完全性検証もコンテンツドメインの責務なのでここに属する。 +- SDK は「検証する `monas-content` の口を呼ぶだけ」に留め、JWT 検証・CID 再計算などの暗号ロジックを SDK に持ち込まない。 + +**CID 再計算の重要な差異**: `monas-content` 既存の `Sha256ContentIdGenerator`(`content_id.rs:9`)は `SHA-256(raw_content)` を hex 化するだけで、**crsl-lib の Node CID(`SHA-256(CBOR(Node全体))` → CIDv1 RAW/SHA2-256、`node.rs:76`)とはアルゴリズムもエンコードも別物**。version CID の再計算には crsl-lib 準拠の実装が要る。`content_id.rs:6` に `todo: crslのcid生成を使用する` とある通り元々 crsl 準拠にしたい意図があるので、**`monas-content` に crsl-lib 準拠の Node CID 計算を新設**(既存 generator とは別関数)してこの TODO を回収する。crsl-lib を `monas-content` 依存に足すか、CBOR+SHA-256+CID の軽量実装を `monas-content` 内に持つかは 8.6 で判断。 + ### 8.1 コンポーネント A: 版真正性(Node 全体を返して CID 再計算) **目的**: member の read 応答が「生 payload」ではなく `Node` 全体(CBOR)を返すようにし、クライアントが CID を再計算して改ざん検知する。 @@ -269,10 +277,9 @@ member リストの取得・検証は**フローに現れない**(晒さない 3. ワイヤ: `ContentResponse::ContentData { content_id, data, version }`(`protocol.rs:106`)の `data` を Node CBOR に(意味を変えるだけで型は `Vec` のまま。フィールド名を `node_bytes` にリネームして意図を明示)。内部 `RelayOutcome::Data`(`libp2p_network.rs:55`)も同様。 4. HTTP `ContentDataResponse`(`http_api.rs:225`)/ SDK `StateNodeContentDataResponse`(`models/state_node.rs:51`)の `data` も Node CBOR(base64)に。 -**SDK 側(クライアント検証)**: -5. Node CBOR を受け取ったら、crsl-lib の `Node::from_bytes`(`node.rs:104`)→ `content_id()`(`node.rs:76`)で CID 再計算し、要求 version(または応答の主張 version)と一致を検証。不一致は**拒否**。 -6. 検証後、`Node.payload().data`(暗号文)を取り出して既存の復号(AES-GCM)に渡す。 - - ※ crsl-lib はワイヤ型の依存に入る。SDK が crsl-lib の `Node` 型を使えるか要確認(既に依存にあるか、追加が要るか)。無理なら Node のパース + CID 再計算だけを行う軽量ヘルパを用意。 +**クライアント検証(`monas-content` に実装、SDK はそれを呼ぶ)**: +5. `monas-content` に crsl-lib 準拠の Node CID 再計算 + 検証関数を新設(§8.0)。Node CBOR を受け取ったら CID 再計算 → 要求 version と一致を検証。不一致は**拒否**。 +6. 検証後、Node の `payload.data`(暗号文)を取り出して既存の復号(`domain/content/encryption.rs`、AES-GCM)に渡す。復号・CID 検証とも `monas-content` 内で完結し、SDK は結果を受け取るだけ。 ### 8.2 コンポーネント B: 単調性チェック(ロールバック検出) @@ -295,9 +302,9 @@ member リストの取得・検証は**フローに現れない**(晒さない 3. `read_content_via_relay`(`state_node_service.rs:565`)/ `read_history_via_relay`(`:598`)の応答に、自ノードの member 証明トークンを載せる。ワイヤ `ContentResponse::ContentData` / `HistoryData` に `member_proof: String`(必須)を追加。内部 `RelayOutcome`・HTTP・SDK 型も同様に追加(§4.4 の経路表の全型)。 4. caller の分解 `libp2p_network.rs:1828`(現状 `..` で余剰を捨てている)を修正し、`member_proof` を通す。 -**SDK 側 — 検証**: -5. 応答の `member_proof` を **owner 公開鍵**(= `AccessPolicy.owner`、read 認可時に既知)で ES256 検証。`att.with` が要求 content と一致、`exp` 未失効を確認。無効/欠落は**拒否**。 -6. owner 公開鍵の入手: read 認可経路で既に owner を知っているはず(要確認 — SDK が `AccessPolicy.owner` を保持しているか、別途取得が要るか)。 +**クライアント検証(`monas-content` に実装)**: +5. 応答の `member_proof` を **owner 公開鍵**で ES256 検証(`monas-content` に検証関数を新設。既存の署名検証/鍵管理と同居)。`att.with` が要求 content と一致、`exp` 未失効を確認。無効/欠落は**拒否**。 +6. owner 公開鍵の入手(§8.6 参照): SDK/content が持つ委任トークンの `iss` から導出できるか確認。導出できれば追加 API 不要。 ### 8.4 検証フロー統合(SDK, §5.2) @@ -317,15 +324,14 @@ member リストの取得・検証は**フローに現れない**(晒さない ### 8.6 実装前に確定した事項(調査済み 2026-07-18) -**(1) SDK は crsl-lib に依存していない**(`monas-sdk/Cargo.toml` に無し)。→ 選択肢: -- (a) crsl-lib を SDK 依存に追加し `Node::from_bytes`/`content_id()` を直接使う。確実だが SDK に DAG ライブラリ全体(leveldb 等含む)を持ち込みビルド肥大。 -- (b) **【推奨】SDK に軽量 CID 検証ヘルパを自前実装**: Node の CBOR を最小限デコード(`payload`/`parents`/`genesis` を取り出す)+ 受信 CBOR バイト列全体を SHA-256 → CIDv1(RAW/SHA2-256、`node.rs:76-81` と同一手順)で version 突合。crsl-lib 全体は要らず、`serde_cbor` + `sha2` + `cid` クレートで足りる。Node の CBOR スキーマ(フィールド順・型)を crsl-lib と厳密に一致させる必要があるのでテストで固定。 -- → **(b) を採用**。ただし CBOR スキーマ一致の検証テスト(state-node が出す Node CBOR を SDK が再計算して一致)を必須にする。 +**(1) CID 再計算は `monas-content` に crsl-lib 準拠で新設**(§8.0)。`monas-content` は現状 crsl-lib 非依存。選択肢: +- (a) crsl-lib を `monas-content` 依存に追加し `Node::from_bytes`/`content_id()` を直接使う。確実だが DAG ライブラリ全体(leveldb 等)を持ち込む。 +- (b) **【推奨】`monas-content` に軽量 Node CID 計算を自前実装**: Node の CBOR を最小限デコード(`payload`/`parents`/`genesis`)+ 受信 CBOR 全体を SHA-256 → CIDv1(RAW/SHA2-256、`node.rs:76-81` と同一手順)。`serde_cbor` + `sha2` + `cid` で足りる。`content_id.rs:6` の TODO 回収も兼ねる。 +- → **(b) を採用**。CBOR スキーマ一致テスト(state-node が出す Node CBOR を `monas-content` が再計算して一致)を必須にする。crsl-lib のバージョンは rev pin(`Cargo.toml:51`)なのでスキーマ固定でよい。 -**(2) SDK は owner / AccessPolicy を知らない**(`AccessPolicy` は state-node ドメイン、SDK には無い)。→ member 証明を owner 鍵で検証するには owner 公開鍵の入手経路が新規に要る: -- read 認可のために SDK は既に「自分の権限(委任トークン)」を持つ。そのトークンの `iss` が owner なので、**owner 公開鍵は委任トークンの `iss` から得られる**可能性が高い(要確認: `iss` が pubkey そのものか、key_id か)。 -- 委任経由で得られないなら、content の owner 公開鍵を返す軽量な取得口を state-node/account に新設。 -- → 実装первый手: SDK が持つ委任トークンの `iss` から owner 公開鍵を導出できるか確認。可能なら追加 API 不要。 +**(2) owner 公開鍵の入手経路**: `AccessPolicy` は state-node ドメインで content/SDK には無い。member 証明を owner 鍵で検証するには入手経路が要る: +- read 認可のために content/SDK は既に「自分の権限(委任トークン)」を持つ。そのトークンの `iss` が owner なので、**owner 公開鍵は委任トークンの `iss` から得られる**可能性が高い(要確認: `iss` が pubkey そのものか key_id か。`service.rs` の `owner_key_id = key_id_from_public_key(...)` を見る限り key_id。key_id から pubkey を復元できる形式か確認)。 +- **確認済み(2026-07-18)**: owner key_id は `user:{hex(public_key)}`(`service.rs:160-161` `key_id_from_public_key`)で**公開鍵そのものを内包する自己完結型**。委任トークンの `iss` から hex デコードするだけで owner 公開鍵が復元でき、**追加の取得 API・通信は不要**。member 証明の検証に必要な鍵は読み手が既に持つ委任トークンから取れる。 ### 8.7 実装中に判定する TODO From ec26c00f978eeb123103b7dfdac924d2c61123b2 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 01:00:13 +0900 Subject: [PATCH 07/48] feat(read-integrity): return whole Node CBOR for relay reads + client CID verify (#55, comp A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State node relay-read now returns the serialized crsl-lib Node (CBOR) instead of raw payload bytes, so a client can recompute the CID and detect tampering with no signature. - content_repository: add get_latest_node_bytes_with_version / get_version_node_bytes (Node CBOR); read_content_via_relay uses them. - monas-content: new node_verification module recomputes the Node CID (CIDv1 RAW/SHA2-256 over the CBOR) and rejects on mismatch, extracting payload ciphertext + parents. - Parity tests build REAL crsl-lib Nodes (genesis + child) and confirm our from-CBOR recompute equals Node::content_id() exactly — the key risk, now validated, incl. Cid CBOR encoding in parents/genesis. Not backward compatible (test-only deployment), per design. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 4 + monas-content/Cargo.toml | 10 + monas-content/src/infrastructure/mod.rs | 1 + .../src/infrastructure/node_verification.rs | 287 ++++++++++++++++++ .../application_service/state_node_service.rs | 11 +- .../src/infrastructure/auth/ucan_adapter.rs | 13 + .../src/infrastructure/crdt_repository.rs | 52 ++++ .../src/port/content_repository.rs | 22 ++ monas-state-node/src/test_utils.rs | 17 ++ 9 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 monas-content/src/infrastructure/node_verification.rs diff --git a/Cargo.lock b/Cargo.lock index 475e451..8480c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3115,6 +3115,8 @@ dependencies = [ "axum 0.8.8", "base64 0.22.1", "chrono", + "cid", + "crsl-lib", "dyn-clone", "hex", "hkdf", @@ -3122,10 +3124,12 @@ dependencies = [ "hpke-rs", "hpke-rs-rust-crypto", "monas-filesync", + "multihash", "p256", "rand 0.8.5", "rand_core 0.6.4", "serde", + "serde_cbor", "serde_json", "sha2", "sha3", diff --git a/monas-content/Cargo.toml b/monas-content/Cargo.toml index 506f6ec..b25545b 100644 --- a/monas-content/Cargo.toml +++ b/monas-content/Cargo.toml @@ -30,6 +30,13 @@ base64 = "0.22" sled = "0.34" hpke-rs = { version = "0.4", features = ["hazmat"] } hpke-rs-rust-crypto = "0.3" +# For verifying relay-read responses: recompute the crsl-lib Node CID from the +# returned Node CBOR (docs/design/read-response-integrity.md §8). Versions must +# match crsl-lib's (cid 0.11, multihash 0.19, serde_cbor 0.11) so the recomputed +# CID string is byte-identical. +serde_cbor = "0.11" +cid = { version = "0.11", features = ["serde"] } +multihash = "0.19" [features] default = ["filesync"] @@ -37,3 +44,6 @@ filesync = ["monas-filesync", "monas-filesync/cloud-connectivity"] [dev-dependencies] tempfile = "3.19.1" +# Parity test only: build a real crsl-lib Node and confirm our from-CBOR CID +# recompute matches Node::content_id() exactly. Same rev as monas-state-node. +crsl-lib = { git = "https://github.com/Monas-project/crsl-lib", rev = "e13b86ce6d6a9c27ebd01a9b4fe82d6bc18f8a01" } diff --git a/monas-content/src/infrastructure/mod.rs b/monas-content/src/infrastructure/mod.rs index ada51f0..5166feb 100644 --- a/monas-content/src/infrastructure/mod.rs +++ b/monas-content/src/infrastructure/mod.rs @@ -2,6 +2,7 @@ pub mod content_id; pub mod encryption; pub mod key_store; pub mod key_wrapping; +pub mod node_verification; pub mod public_key_directory; pub mod share_repository; diff --git a/monas-content/src/infrastructure/node_verification.rs b/monas-content/src/infrastructure/node_verification.rs new file mode 100644 index 0000000..a946442 --- /dev/null +++ b/monas-content/src/infrastructure/node_verification.rs @@ -0,0 +1,287 @@ +//! Verification of relay-read responses that carry a whole crsl-lib `Node` +//! (CBOR), rather than raw payload bytes. +//! +//! The state node returns the serialized `Node` for reads so a client can +//! recompute its CID and confirm the response was not tampered with — the CID +//! is the SHA-256 of the exact CBOR bytes, so a matching CID proves the bytes +//! (payload + parents + genesis + timestamp + metadata) are authentic. No +//! signature is needed for this check. See +//! `docs/design/read-response-integrity.md` §5.0.1 / §8. +//! +//! This mirrors crsl-lib's `Node::content_id()`: +//! `CIDv1(codec=RAW=0x55, multihash=SHA2-256(sha256(serde_cbor(node))))`. +//! The `cid` / `multihash` / `serde_cbor` crate versions are pinned to match +//! crsl-lib so the recomputed CID string is byte-identical. + +use cid::Cid; +use multihash::Multihash; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +/// multicodec code for SHA2-256 (multihash). +const SHA2_256_CODE: u64 = 0x12; +/// multicodec code for RAW (CIDv1 codec), matching crsl-lib. +const RAW_CODE: u64 = 0x55; + +#[derive(Debug, thiserror::Error)] +pub enum NodeVerificationError { + #[error("failed to decode node CBOR: {0}")] + Decode(String), + #[error("failed to compute node CID: {0}")] + Cid(String), + #[error("node CID mismatch: expected {expected}, recomputed {actual} (tampered response)")] + CidMismatch { expected: String, actual: String }, +} + +/// A relay-read `Node` decoded enough to (a) extract the ciphertext payload and +/// (b) expose the parent version CIDs for the monotonicity check. +/// +/// Only the fields the client needs are decoded; the CID is recomputed from the +/// raw bytes (not from this struct) so decoding never has to round-trip +/// byte-identically. +#[derive(Debug)] +pub struct VerifiedNode { + /// The ciphertext stored in the node payload (`payload.data`). + pub ciphertext: Vec, + /// Parent version CIDs (`parents`), as strings, for ancestor checks. + pub parents: Vec, +} + +/// Minimal mirror of crsl-lib's `Node` for extraction. `payload` is decoded as +/// a CBOR value and its `data` field pulled out, so we don't depend on the +/// exact `ContentPayload` type. `parents` are decoded as CIDs. +#[derive(Deserialize)] +struct NodeMirror { + payload: serde_cbor::Value, + #[serde(default)] + parents: Vec, +} + +/// Recompute the CID of `node_bytes` (the exact CBOR of a crsl-lib `Node`) and +/// return it as a string, matching `Cid::to_string()` in crsl-lib. +pub fn recompute_node_cid(node_bytes: &[u8]) -> Result { + let digest = Sha256::digest(node_bytes); + let mh = Multihash::<64>::wrap(SHA2_256_CODE, &digest) + .map_err(|e| NodeVerificationError::Cid(e.to_string()))?; + Ok(Cid::new_v1(RAW_CODE, mh).to_string()) +} + +/// Verify that `node_bytes` hashes to `expected_version_cid`, then extract the +/// ciphertext and parents. Returns an error (rejecting the response) on any +/// mismatch — this is what stops a relay peer from returning fabricated data. +pub fn verify_and_extract( + node_bytes: &[u8], + expected_version_cid: &str, +) -> Result { + let actual = recompute_node_cid(node_bytes)?; + if actual != expected_version_cid { + return Err(NodeVerificationError::CidMismatch { + expected: expected_version_cid.to_string(), + actual, + }); + } + + let mirror: NodeMirror = serde_cbor::from_slice(node_bytes) + .map_err(|e| NodeVerificationError::Decode(e.to_string()))?; + + let ciphertext = extract_payload_data(&mirror.payload)?; + let parents = mirror.parents.iter().map(|c| c.to_string()).collect(); + + Ok(VerifiedNode { + ciphertext, + parents, + }) +} + +/// Pull `data: Vec` out of the decoded payload value. The payload is +/// `ContentPayload { data, access_policy }`, CBOR-encoded as a map. +fn extract_payload_data(payload: &serde_cbor::Value) -> Result, NodeVerificationError> { + use serde_cbor::Value; + match payload { + Value::Map(map) => { + let key = Value::Text("data".to_string()); + match map.get(&key) { + Some(Value::Bytes(b)) => Ok(b.clone()), + // CBOR arrays of ints can also represent byte sequences + Some(Value::Array(arr)) => arr + .iter() + .map(|v| match v { + Value::Integer(i) if *i >= 0 && *i <= 255 => Ok(*i as u8), + _ => Err(NodeVerificationError::Decode( + "payload.data array contains a non-byte element".to_string(), + )), + }) + .collect(), + Some(_) => Err(NodeVerificationError::Decode( + "payload.data is not a byte string".to_string(), + )), + None => Err(NodeVerificationError::Decode( + "payload has no `data` field".to_string(), + )), + } + } + _ => Err(NodeVerificationError::Decode( + "payload is not a CBOR map".to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + + // A local mirror that serializes to the SAME CBOR shape as crsl-lib's + // Node, so we can produce test vectors + // without depending on crsl-lib. Field order/names must match. + #[derive(Serialize)] + struct TestPayload { + data: Vec, + access_policy: Option<()>, + } + #[derive(Serialize)] + struct TestMetadata { + policy_type: Option, + } + #[derive(Serialize)] + struct TestNode { + payload: TestPayload, + parents: Vec, + genesis: Option, + timestamp: u64, + metadata: TestMetadata, + } + + fn make_node(data: Vec, parents: Vec) -> Vec { + let node = TestNode { + payload: TestPayload { + data, + access_policy: None, + }, + parents, + genesis: None, + timestamp: 0, + metadata: TestMetadata { policy_type: None }, + }; + serde_cbor::to_vec(&node).unwrap() + } + + #[test] + fn verify_accepts_matching_cid_and_extracts_data() { + let bytes = make_node(b"ciphertext-bytes".to_vec(), vec![]); + let cid = recompute_node_cid(&bytes).unwrap(); + + let verified = verify_and_extract(&bytes, &cid).expect("should verify"); + assert_eq!(verified.ciphertext, b"ciphertext-bytes"); + assert!(verified.parents.is_empty()); + } + + #[test] + fn verify_rejects_tampered_payload() { + let bytes = make_node(b"original".to_vec(), vec![]); + let cid = recompute_node_cid(&bytes).unwrap(); + + // Tamper: a different node claims the original's CID. + let tampered = make_node(b"ATTACKER".to_vec(), vec![]); + let err = verify_and_extract(&tampered, &cid).unwrap_err(); + assert!(matches!(err, NodeVerificationError::CidMismatch { .. })); + } + + #[test] + fn verify_exposes_parents() { + let parent = recompute_node_cid(&make_node(b"v1".to_vec(), vec![])).unwrap(); + let parent_cid: Cid = parent.parse().unwrap(); + let bytes = make_node(b"v2".to_vec(), vec![parent_cid]); + let cid = recompute_node_cid(&bytes).unwrap(); + + let verified = verify_and_extract(&bytes, &cid).unwrap(); + assert_eq!(verified.parents, vec![parent]); + } + + #[test] + fn recompute_is_deterministic() { + let bytes = make_node(b"x".to_vec(), vec![]); + assert_eq!( + recompute_node_cid(&bytes).unwrap(), + recompute_node_cid(&bytes).unwrap() + ); + } + + /// **Parity test** against the real crsl-lib. Builds an actual crsl-lib + /// `Node`, serializes it with `to_bytes()`, and confirms our from-CBOR CID + /// recompute equals `Node::content_id()` exactly. This is what guarantees a + /// client's tamper check matches the version CID the state node advertises. + #[test] + fn recompute_matches_crsl_lib_node_cid() { + use crsl_lib::dasl::node::Node; + use std::collections::BTreeMap; + + #[derive(serde::Serialize, serde::Deserialize)] + struct Payload { + data: Vec, + access_policy: Option<()>, + } + + // genesis node + let payload = Payload { + data: b"real ciphertext".to_vec(), + access_policy: None, + }; + let node: Node> = + Node::new_genesis(payload, 12345, BTreeMap::new()); + + let crsl_cid = node.content_id().unwrap().to_string(); + let bytes = node.to_bytes().unwrap(); + + // Our recompute must match crsl-lib's CID byte-for-byte. + assert_eq!(recompute_node_cid(&bytes).unwrap(), crsl_cid); + + // And verify_and_extract must accept it and pull out the ciphertext. + let verified = verify_and_extract(&bytes, &crsl_cid).unwrap(); + assert_eq!(verified.ciphertext, b"real ciphertext"); + assert!(verified.parents.is_empty()); + } + + /// Parity for a child node (has parents + genesis), covering the CBOR + /// encoding of `Cid` fields. + #[test] + fn recompute_matches_crsl_lib_child_node() { + use crsl_lib::dasl::node::Node; + use std::collections::BTreeMap; + + #[derive(serde::Serialize, serde::Deserialize)] + struct Payload { + data: Vec, + access_policy: Option<()>, + } + + let genesis: Node> = Node::new_genesis( + Payload { + data: b"v1".to_vec(), + access_policy: None, + }, + 1, + BTreeMap::new(), + ); + let genesis_cid = genesis.content_id().unwrap(); + + let child: Node> = Node::new_child( + Payload { + data: b"v2".to_vec(), + access_policy: None, + }, + vec![genesis_cid], + genesis_cid, + 2, + BTreeMap::new(), + ); + let child_cid = child.content_id().unwrap().to_string(); + let bytes = child.to_bytes().unwrap(); + + assert_eq!(recompute_node_cid(&bytes).unwrap(), child_cid); + + let verified = verify_and_extract(&bytes, &child_cid).unwrap(); + assert_eq!(verified.ciphertext, b"v2"); + assert_eq!(verified.parents, vec![genesis_cid.to_string()]); + } +} diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 3557836..91df683 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -574,19 +574,22 @@ where .await?; let content_id_vo = ContentId::new(content_id.to_string())?; + // Return the whole crsl-lib Node (CBOR), not just the payload, so the + // client can recompute the CID and verify the response was not + // tampered with (docs/design/read-response-integrity.md §8.1). match version { Some(v) => { - let data = self + let node_bytes = self .crdt_repo - .get_version(content_id, v) + .get_version_node_bytes(content_id, v) .await .map_err(|e| StateNodeError::StorageError(e.to_string()))? .ok_or(StateNodeError::ContentNotFound(content_id_vo))?; - Ok((data, v.to_string())) + Ok((node_bytes, v.to_string())) } None => self .crdt_repo - .get_latest_with_version(content_id) + .get_latest_node_bytes_with_version(content_id) .await .map_err(|e| StateNodeError::StorageError(e.to_string()))? .ok_or(StateNodeError::ContentNotFound(content_id_vo)), diff --git a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs index 38596e3..b5a4db2 100644 --- a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs @@ -477,6 +477,19 @@ mod tests { ) -> Result>> { unimplemented!() } + async fn get_latest_node_bytes_with_version( + &self, + _genesis_cid: &str, + ) -> Result, String)>> { + unimplemented!() + } + async fn get_version_node_bytes( + &self, + _genesis_cid: &str, + _version_cid: &str, + ) -> Result>> { + unimplemented!() + } async fn get_history(&self, _genesis_cid: &str) -> Result> { unimplemented!() } diff --git a/monas-state-node/src/infrastructure/crdt_repository.rs b/monas-state-node/src/infrastructure/crdt_repository.rs index cf04d83..d5f8e99 100644 --- a/monas-state-node/src/infrastructure/crdt_repository.rs +++ b/monas-state-node/src/infrastructure/crdt_repository.rs @@ -235,6 +235,58 @@ impl ContentRepository for CrslCrdtRepository { } } + async fn get_latest_node_bytes_with_version( + &self, + genesis_cid: &str, + ) -> Result, String)>> { + let genesis = Self::parse_cid(genesis_cid)?; + + let repo = self.repo.lock(); + + match repo.latest(&genesis) { + Some(latest_cid) => match repo.dag.get_node(&latest_cid) { + Ok(Some(node)) => { + let bytes = node + .to_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize node: {}", e))?; + Ok(Some((bytes, latest_cid.to_string()))) + } + Ok(None) => Ok(None), + Err(e) => Err(anyhow::anyhow!("Failed to get node: {}", e)), + }, + None => Ok(None), + } + } + + async fn get_version_node_bytes( + &self, + genesis_cid: &str, + version_cid: &str, + ) -> Result>> { + let genesis = Self::parse_cid(genesis_cid)?; + let cid = Self::parse_cid(version_cid)?; + + let repo = self.repo.lock(); + + // Same series-scoping guarantee as get_version: refuse to serve a + // version that belongs to a different content series. + match repo.get_genesis(&cid) { + Ok(g) if g == genesis => {} + _ => return Ok(None), + } + + match repo.dag.get_node(&cid) { + Ok(Some(node)) => { + let bytes = node + .to_bytes() + .map_err(|e| anyhow::anyhow!("Failed to serialize node: {}", e))?; + Ok(Some(bytes)) + } + Ok(None) => Ok(None), + Err(e) => Err(anyhow::anyhow!("Failed to get node: {}", e)), + } + } + async fn get_access_policy(&self, genesis_cid: &str) -> Result> { let genesis = Self::parse_cid(genesis_cid)?; diff --git a/monas-state-node/src/port/content_repository.rs b/monas-state-node/src/port/content_repository.rs index dcbf0e4..56a2f2f 100644 --- a/monas-state-node/src/port/content_repository.rs +++ b/monas-state-node/src/port/content_repository.rs @@ -123,6 +123,28 @@ pub trait ContentRepository: Send + Sync { /// exist or belongs to a different content series. async fn get_version(&self, genesis_cid: &str, version_cid: &str) -> Result>>; + /// Get the latest version as a serialized crsl-lib `Node` (CBOR), with its + /// version CID, for the verified relay-read path. + /// + /// Unlike [`get_latest_with_version`], this returns the whole Node (CBOR) + /// rather than just the payload bytes, so a client can recompute the CID + /// and verify the response was not tampered with — no signature needed. + /// See `docs/design/read-response-integrity.md` §8.1. + async fn get_latest_node_bytes_with_version( + &self, + genesis_cid: &str, + ) -> Result, String)>>; + + /// Get a specific version as a serialized crsl-lib `Node` (CBOR) for the + /// verified relay-read path. Same series-scoping guarantee as + /// [`get_version`]. Returns the Node CBOR, or None if the version does not + /// exist / belongs to a different series. + async fn get_version_node_bytes( + &self, + genesis_cid: &str, + version_cid: &str, + ) -> Result>>; + /// Get the version history of content. /// /// # Arguments diff --git a/monas-state-node/src/test_utils.rs b/monas-state-node/src/test_utils.rs index 5433bf0..bf274ac 100644 --- a/monas-state-node/src/test_utils.rs +++ b/monas-state-node/src/test_utils.rs @@ -512,6 +512,23 @@ impl ContentRepository for MockContentRepository { Ok(None) } + async fn get_latest_node_bytes_with_version( + &self, + genesis_cid: &str, + ) -> Result, String)>> { + // Mock returns the stored bytes as-is (tests that need real Node CBOR + // build it explicitly). Mirrors get_latest_with_version. + self.get_latest_with_version(genesis_cid).await + } + + async fn get_version_node_bytes( + &self, + genesis_cid: &str, + version_cid: &str, + ) -> Result>> { + self.get_version(genesis_cid, version_cid).await + } + async fn get_history(&self, genesis_cid: &str) -> Result> { Ok(self .history From 7970b357b1ce0d7ab1b74d97c71323f07a05db73 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 07:11:30 +0900 Subject: [PATCH 08/48] feat(read-integrity): unify state-node read format + E2E verify-decrypt core (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state-node HTTP read endpoints (local + relay branches) now uniformly return Node CBOR with the served version CID, so the client always verifies the same format. - verify_integrity now runs verify_and_extract on the Node CBOR (CID recompute) before comparing ciphertext — previously it byte-compared raw payload, which the Node-CBOR change would have silently broken. - monas-content: add ContentService::verify_and_decrypt_relay_read — the reusable client core that verifies the Node CID, loads the CEK, and AES-GCM-decrypts, returning plaintext + parent CIDs for the caller's monotonicity check. Co-Authored-By: Claude Fable 5 --- .../content_service/service.rs | 63 +++++++++++++++++++ monas-sdk/src/controller/state.rs | 25 +++++++- monas-state-node/src/presentation/http_api.rs | 27 +++++--- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/monas-content/src/application_service/content_service/service.rs b/monas-content/src/application_service/content_service/service.rs index 3396cd7..b086dad 100644 --- a/monas-content/src/application_service/content_service/service.rs +++ b/monas-content/src/application_service/content_service/service.rs @@ -289,6 +289,49 @@ where Ok(plaintext) } + /// Verify and decrypt a relay-read response fetched from a state node. + /// + /// The state node returns the whole crsl-lib `Node` (CBOR). This: + /// 1. Recomputes the Node CID and confirms it equals `expected_version_cid` + /// (tamper detection — a relay peer cannot fabricate the payload). + /// 2. Extracts the ciphertext from the Node payload. + /// 3. Loads the CEK for `local_content_id` and AES-GCM-decrypts. + /// + /// This is the client-side core of the verified read path + /// (`docs/design/read-response-integrity.md` §5.2 / §8). It does NOT do the + /// membership-proof or monotonicity checks — those are layered by the + /// caller (SDK) around this call, which owns the proof and last-seen state. + /// + /// Returns the plaintext, and the verified node's parent CIDs (for the + /// caller's monotonicity check). + pub fn verify_and_decrypt_relay_read( + &self, + node_bytes: &[u8], + expected_version_cid: &str, + local_content_id: ContentId, + ) -> Result { + let verified = crate::infrastructure::node_verification::verify_and_extract( + node_bytes, + expected_version_cid, + ) + .map_err(VerifiedReadError::NodeVerification)?; + + let key = self + .cek_store + .load(&local_content_id) + .map_err(VerifiedReadError::KeyStore)? + .ok_or(VerifiedReadError::MissingKey)?; + + let plaintext = self + .decrypt_with_cek(local_content_id, key, verified.ciphertext) + .map_err(VerifiedReadError::Decrypt)?; + + Ok(VerifiedRead { + plaintext, + parents: verified.parents, + }) + } + /// コンテンツ削除ユースケース。 /// /// - 物理削除ではなく、ドメインオブジェクト上で `is_deleted` フラグとバッファをクリアして保存する「論理削除」 @@ -633,6 +676,26 @@ pub enum DecryptWithCekError { Domain(ContentError), } +/// Result of a verified relay read: the plaintext plus the verified node's +/// parent version CIDs (used by the caller for the monotonicity check). +#[derive(Debug)] +pub struct VerifiedRead { + pub plaintext: Vec, + pub parents: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum VerifiedReadError { + #[error("node verification failed: {0}")] + NodeVerification(crate::infrastructure::node_verification::NodeVerificationError), + #[error("key store error: {0:?}")] + KeyStore(ContentEncryptionKeyStoreError), + #[error("no content encryption key for this content")] + MissingKey, + #[error("decrypt failed: {0}")] + Decrypt(DecryptWithCekError), +} + #[derive(Debug, thiserror::Error)] pub enum ReencryptError { #[error("content not found")] diff --git a/monas-sdk/src/controller/state.rs b/monas-sdk/src/controller/state.rs index 91ea9a2..fa0f82a 100644 --- a/monas-sdk/src/controller/state.rs +++ b/monas-sdk/src/controller/state.rs @@ -303,7 +303,7 @@ impl MonasController { Err(e) => return e, }; - let state_bytes = match BASE64_STANDARD.decode(&state_node_data.data) { + let node_bytes = match BASE64_STANDARD.decode(&state_node_data.data) { Ok(b) => b, Err(e) => { return ApiResponse::error( @@ -313,6 +313,29 @@ impl MonasController { } }; + // State Node は read 応答として「Node 全体(CBOR)」を返す。まず CID を + // 再計算して version と一致することを検証し(改ざん検知)、その上で + // payload の暗号文を取り出す(§8.1)。 + let state_bytes = match monas_content::infrastructure::node_verification::verify_and_extract( + &node_bytes, + &state_node_data + .version + .clone() + .unwrap_or(version_to_check.clone()), + ) { + Ok(verified) => verified.ciphertext, + Err(e) => { + return ApiResponse::success( + VerifyIntegrityOutput { + valid: false, + computed_hash, + reason: Some(format!("state node response failed CID verification: {e}")), + }, + trace_id, + ); + } + }; + // State Node が保持するのは SDK が送信した「暗号文」なので、 // local_content_id があればローカルに保存された暗号文とバイト比較する。 // (平文 `content` と State Node のバイト列は一致し得ない。) diff --git a/monas-state-node/src/presentation/http_api.rs b/monas-state-node/src/presentation/http_api.rs index 889fe16..a59f2d5 100644 --- a/monas-state-node/src/presentation/http_api.rs +++ b/monas-state-node/src/presentation/http_api.rs @@ -717,20 +717,28 @@ async fn get_content_data( let crdt_repo = state.crdt_repo(); - // Get data based on version parameter - let data_result = if let Some(version) = &query.version { - crdt_repo.get_version(&content_id, version).await + // Return the whole Node (CBOR), matching the relay branch above, so the + // client always verifies the same format (recompute CID) regardless of + // whether this node held the content locally or relayed the read. + // (docs/design/read-response-integrity.md §8.1) + let data_result: Result, String)>, _> = if let Some(version) = &query.version { + crdt_repo + .get_version_node_bytes(&content_id, version) + .await + .map(|opt| opt.map(|bytes| (bytes, version.clone()))) } else { - crdt_repo.get_latest(&content_id).await + crdt_repo + .get_latest_node_bytes_with_version(&content_id) + .await }; match data_result { - Ok(Some(data)) => { + Ok(Some((data, served_version))) => { let encoded = base64::engine::general_purpose::STANDARD.encode(&data); Json(ContentDataResponse { content_id, data: encoded, - version: query.version, + version: Some(served_version), }) .into_response() } @@ -811,7 +819,12 @@ async fn get_content_version( let crdt_repo = state.crdt_repo(); - match crdt_repo.get_version(&content_id, &version).await { + // Return the whole Node (CBOR), matching the relay branch, so the client + // verifies the same format everywhere (§8.1). + match crdt_repo + .get_version_node_bytes(&content_id, &version) + .await + { Ok(Some(data)) => { let encoded = base64::engine::general_purpose::STANDARD.encode(&data); Json(ContentDataResponse { From 3a64a5a8e2572646b90d7861ab3fb0f6b45275ab Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 07:17:50 +0900 Subject: [PATCH 09/48] =?UTF-8?q?feat(read-integrity):=20owner-issued=20me?= =?UTF-8?q?mber=20proof=20=E2=80=94=20issuance=20+=20client=20verify=20(#5?= =?UTF-8?q?5,=20comp=20C=20core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - monas-account: AccountService::issue_member_proof issues an owner-signed ES256 JWT (iss=owner, aud=member node, att=[{content, can:"host"}]). - monas-content: member_proof::verify_member_proof verifies it against the owner public key recovered from the owner key_id (user:{hex(pubkey)}) the reader already holds — no key-fetch API. Checks signature, issuer, audience (responding node), content, host capability, expiry. - Parity test issues a proof via the REAL AccountService and verifies it, plus rejection tests for wrong owner/node/content/capability/expiry and a tampered payload. This proves membership WITHOUT exposing the member list (§5.1.b). Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + .../src/application_service/command.rs | 14 + monas-account/src/application_service/mod.rs | 4 +- .../src/application_service/service.rs | 86 ++++- monas-content/Cargo.toml | 3 + .../src/infrastructure/member_proof.rs | 316 ++++++++++++++++++ monas-content/src/infrastructure/mod.rs | 1 + 7 files changed, 423 insertions(+), 2 deletions(-) create mode 100644 monas-content/src/infrastructure/member_proof.rs diff --git a/Cargo.lock b/Cargo.lock index 8480c55..fdd9719 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3123,6 +3123,7 @@ dependencies = [ "hmac", "hpke-rs", "hpke-rs-rust-crypto", + "monas-account", "monas-filesync", "multihash", "p256", diff --git a/monas-account/src/application_service/command.rs b/monas-account/src/application_service/command.rs index a6998f4..a1063fa 100644 --- a/monas-account/src/application_service/command.rs +++ b/monas-account/src/application_service/command.rs @@ -30,3 +30,17 @@ pub struct IssueDelegatedTokenResult { pub expires_at: u64, pub jti: String, } + +/// Request to issue a membership proof: an owner-signed token attesting that a +/// specific state node (`member_node_id`) is a legitimate member/host of a +/// content. Used by the verified read path so a responding node can prove it is +/// a real member without exposing the member list +/// (`docs/design/read-response-integrity.md` §5.1.b). +#[derive(Debug, Clone)] +pub struct IssueMemberProofRequest { + /// Identity of the member state node this proof is issued to (the token's + /// `aud`). This is the node's self-identifier (e.g. `node:`). + pub member_node_id: String, + pub content_id: String, + pub ttl_secs: u64, +} diff --git a/monas-account/src/application_service/mod.rs b/monas-account/src/application_service/mod.rs index d92e8b2..3738b56 100644 --- a/monas-account/src/application_service/mod.rs +++ b/monas-account/src/application_service/mod.rs @@ -3,7 +3,9 @@ pub mod error; pub mod port; pub mod service; -pub use command::{IssueDelegatedTokenRequest, IssueDelegatedTokenResult, KeyTypeMapper}; +pub use command::{ + IssueDelegatedTokenRequest, IssueDelegatedTokenResult, IssueMemberProofRequest, KeyTypeMapper, +}; pub use error::{AccountServiceError, IssueDelegatedTokenError, SignError}; pub use port::{AccountKeyStore, AccountKeyStoreError, StoredAccountKey}; pub use service::AccountService; diff --git a/monas-account/src/application_service/service.rs b/monas-account/src/application_service/service.rs index 5e8b54a..a64cf55 100644 --- a/monas-account/src/application_service/service.rs +++ b/monas-account/src/application_service/service.rs @@ -1,5 +1,5 @@ use crate::application_service::command::{ - IssueDelegatedTokenRequest, IssueDelegatedTokenResult, KeyTypeMapper, + IssueDelegatedTokenRequest, IssueDelegatedTokenResult, IssueMemberProofRequest, KeyTypeMapper, }; use crate::application_service::error::{AccountServiceError, IssueDelegatedTokenError, SignError}; use crate::application_service::port::AccountKeyStore; @@ -142,6 +142,90 @@ impl AccountService { jti, }) } + + /// Issue an owner-signed membership proof for a state node. + /// + /// The proof is an ES256 JWT (`iss = owner key_id`, `aud = member_node_id`, + /// `att = [{ with: "monas://content/{cid}", can: "host" }]`). A member node + /// attaches it to read responses; a reader verifies it against the owner + /// public key (recoverable from the owner key_id) to confirm the responder + /// is a legitimate member — without ever seeing the member list + /// (`docs/design/read-response-integrity.md` §5.1.b). + pub fn issue_member_proof( + store: &S, + req: IssueMemberProofRequest, + ) -> Result { + if req.content_id.trim().is_empty() { + return Err(IssueDelegatedTokenError::Validation( + "content_id must not be empty".to_string(), + )); + } + if req.member_node_id.trim().is_empty() { + return Err(IssueDelegatedTokenError::Validation( + "member_node_id must not be empty".to_string(), + )); + } + if req.ttl_secs == 0 { + return Err(IssueDelegatedTokenError::Validation( + "ttl_secs must be greater than 0".to_string(), + )); + } + const MAX_TTL_SECS: u64 = 24 * 60 * 60; + if req.ttl_secs > MAX_TTL_SECS { + return Err(IssueDelegatedTokenError::Validation(format!( + "ttl_secs must be <= {MAX_TTL_SECS}" + ))); + } + + let stored = store + .load() + .map_err(IssueDelegatedTokenError::KeyStore)? + .ok_or(IssueDelegatedTokenError::NotFound)?; + + if stored.algorithm != KeyAlgorithm::P256 { + return Err(IssueDelegatedTokenError::UnsupportedAlgorithm(format!( + "{:?}", + stored.algorithm + ))); + } + + let owner_key_id = key_id_from_public_key(&stored.public_key); + let now = unix_now_secs()?; + let expires_at = now.saturating_add(req.ttl_secs); + let jti = generate_jti(); + + let payload = DelegationClaims { + iss: owner_key_id, + aud: req.member_node_id, + exp: expires_at, + iat: now, + jti: jti.clone(), + att: vec![DelegationCapabilityClaim { + with: format!("monas://content/{}", req.content_id), + can: "host".to_string(), + }], + }; + + let key_pair = KeyPairGenerateFactory::from_key_bytes( + stored.algorithm, + &stored.public_key, + &stored.secret_key, + ) + .map_err(IssueDelegatedTokenError::InvalidKey)?; + let account = Account::new(key_pair); + let delegated_token = sign_es256_jwt_payload(&payload, |signing_input| { + let (signature, _recovery_id) = account.sign(signing_input); + Ok(signature) + }) + .map_err(IssueDelegatedTokenError::JwtSigning)?; + + Ok(IssueDelegatedTokenResult { + delegated_token, + issued_at: now, + expires_at, + jti, + }) + } } fn unix_now_secs() -> Result { diff --git a/monas-content/Cargo.toml b/monas-content/Cargo.toml index b25545b..6922678 100644 --- a/monas-content/Cargo.toml +++ b/monas-content/Cargo.toml @@ -47,3 +47,6 @@ tempfile = "3.19.1" # Parity test only: build a real crsl-lib Node and confirm our from-CBOR CID # recompute matches Node::content_id() exactly. Same rev as monas-state-node. crsl-lib = { git = "https://github.com/Monas-project/crsl-lib", rev = "e13b86ce6d6a9c27ebd01a9b4fe82d6bc18f8a01" } +# Parity test only: issue a real owner member-proof via AccountService and +# confirm our verify_member_proof accepts it. +monas-account = { path = "../monas-account" } diff --git a/monas-content/src/infrastructure/member_proof.rs b/monas-content/src/infrastructure/member_proof.rs new file mode 100644 index 0000000..d9e1e4c --- /dev/null +++ b/monas-content/src/infrastructure/member_proof.rs @@ -0,0 +1,316 @@ +//! Client-side verification of owner-issued membership proofs. +//! +//! A membership proof is an ES256 JWT the content owner issues to a state node, +//! attesting that the node is a legitimate member/host of a content. A member +//! node attaches its proof to relay-read responses; the reader verifies it here +//! to confirm the responder is a real member — WITHOUT ever seeing the member +//! list (`docs/design/read-response-integrity.md` §5.1.b). +//! +//! Trust root: the owner public key, recoverable directly from the owner +//! key_id (`user:{hex(pubkey)}`) that the reader already holds in its own +//! delegation token's `iss`. No key-fetch API is needed. +//! +//! JWT format (matching monas-account's `sign_es256_jwt_payload`): +//! `base64url(header).base64url(payload).base64url(sig)`, ES256 = P-256 ECDSA +//! over `SHA-256(signing_input)`, signature as fixed 64-byte r||s. + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; +use serde::Deserialize; + +#[derive(Debug, thiserror::Error)] +pub enum MemberProofError { + #[error("malformed proof token (expected 3 JWT segments)")] + Malformed, + #[error("failed to decode proof segment: {0}")] + Decode(String), + #[error("failed to parse owner key_id: {0}")] + OwnerKey(String), + #[error("proof signature is invalid")] + BadSignature, + #[error("proof issuer does not match the content owner")] + IssuerMismatch, + #[error("proof is not for content {expected}")] + ContentMismatch { expected: String }, + #[error("proof does not grant the host capability")] + NotHostCapability, + #[error("proof audience does not match the responding node")] + AudienceMismatch, + #[error("proof has expired")] + Expired, +} + +#[derive(Deserialize)] +struct Claims { + iss: String, + aud: String, + exp: u64, + #[allow(dead_code)] + iat: u64, + att: Vec, +} + +#[derive(Deserialize)] +struct Capability { + with: String, + can: String, +} + +/// Verify an owner-issued membership proof. +/// +/// Checks: signature against `owner_key_id`'s public key; `iss == owner_key_id`; +/// audience == `expected_node_id` (the node that answered); the `host` +/// capability is granted for `content_id`; and not expired at `now_secs`. +/// +/// `owner_key_id` is `user:{hex(pubkey)}` — the same value the reader carries in +/// its own delegation token's `iss`, so no extra key lookup is required. +pub fn verify_member_proof( + proof_jwt: &str, + owner_key_id: &str, + content_id: &str, + expected_node_id: &str, + now_secs: u64, +) -> Result<(), MemberProofError> { + let mut parts = proof_jwt.split('.'); + let header_b64 = parts.next().ok_or(MemberProofError::Malformed)?; + let payload_b64 = parts.next().ok_or(MemberProofError::Malformed)?; + let sig_b64 = parts.next().ok_or(MemberProofError::Malformed)?; + if parts.next().is_some() { + return Err(MemberProofError::Malformed); + } + + // 1. Verify the ES256 signature against the owner's public key. + let verifying_key = verifying_key_from_owner_key_id(owner_key_id)?; + let signing_input = format!("{header_b64}.{payload_b64}"); + let sig_bytes = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|e| MemberProofError::Decode(e.to_string()))?; + let signature = + Signature::try_from(sig_bytes.as_slice()).map_err(|_| MemberProofError::BadSignature)?; + verifying_key + .verify(signing_input.as_bytes(), &signature) + .map_err(|_| MemberProofError::BadSignature)?; + + // 2. Decode and check claims. + let payload_json = URL_SAFE_NO_PAD + .decode(payload_b64) + .map_err(|e| MemberProofError::Decode(e.to_string()))?; + let claims: Claims = serde_json::from_slice(&payload_json) + .map_err(|e| MemberProofError::Decode(e.to_string()))?; + + if claims.iss != owner_key_id { + return Err(MemberProofError::IssuerMismatch); + } + if claims.aud != expected_node_id { + return Err(MemberProofError::AudienceMismatch); + } + if now_secs > claims.exp { + return Err(MemberProofError::Expired); + } + + let want_with = format!("monas://content/{content_id}"); + let grants_host = claims + .att + .iter() + .any(|c| c.with == want_with && c.can == "host"); + if !grants_host { + // Distinguish "wrong content" from "wrong capability" for clearer errors. + if claims.att.iter().any(|c| c.with == want_with) { + return Err(MemberProofError::NotHostCapability); + } + return Err(MemberProofError::ContentMismatch { + expected: content_id.to_string(), + }); + } + + Ok(()) +} + +/// Recover the P-256 verifying key from an owner key_id of the form +/// `user:{hex(SEC1 pubkey)}`. +fn verifying_key_from_owner_key_id(owner_key_id: &str) -> Result { + let hex_pk = owner_key_id + .strip_prefix("user:") + .ok_or_else(|| MemberProofError::OwnerKey("key_id must start with `user:`".to_string()))?; + let pk_bytes = hex::decode(hex_pk).map_err(|e| MemberProofError::OwnerKey(e.to_string()))?; + VerifyingKey::from_sec1_bytes(&pk_bytes).map_err(|e| MemberProofError::OwnerKey(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::{signature::Signer, SigningKey}; + + struct Owner { + signing: SigningKey, + key_id: String, + } + + fn make_owner() -> Owner { + // Deterministic key for tests. + let signing = SigningKey::from_bytes(&[7u8; 32].into()).unwrap(); + let vk = VerifyingKey::from(&signing); + let sec1 = vk.to_encoded_point(false); + let key_id = format!("user:{}", hex::encode(sec1.as_bytes())); + Owner { signing, key_id } + } + + fn issue_proof(owner: &Owner, aud: &str, content_id: &str, can: &str, exp: u64) -> String { + let header = serde_json::json!({"alg":"ES256","typ":"JWT","ver":"1.0"}); + let payload = serde_json::json!({ + "iss": owner.key_id, + "aud": aud, + "exp": exp, + "iat": 0, + "jti": "test", + "att": [{"with": format!("monas://content/{content_id}"), "can": can}], + }); + let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let p = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let signing_input = format!("{h}.{p}"); + let sig: Signature = owner.signing.sign(signing_input.as_bytes()); + let s = URL_SAFE_NO_PAD.encode(sig.to_bytes()); + format!("{signing_input}.{s}") + } + + #[test] + fn accepts_valid_proof() { + let owner = make_owner(); + let jwt = issue_proof(&owner, "node:n1", "content-1", "host", 1000); + assert!(verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).is_ok()); + } + + #[test] + fn rejects_wrong_owner_key() { + let owner = make_owner(); + let other = { + let signing = SigningKey::from_bytes(&[9u8; 32].into()).unwrap(); + let vk = VerifyingKey::from(&signing); + format!( + "user:{}", + hex::encode(vk.to_encoded_point(false).as_bytes()) + ) + }; + let jwt = issue_proof(&owner, "node:n1", "content-1", "host", 1000); + // Verifying against a different owner key must fail the signature check. + let err = verify_member_proof(&jwt, &other, "content-1", "node:n1", 500).unwrap_err(); + assert!(matches!(err, MemberProofError::BadSignature)); + } + + #[test] + fn rejects_wrong_node_audience() { + let owner = make_owner(); + let jwt = issue_proof(&owner, "node:attacker", "content-1", "host", 1000); + let err = + verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).unwrap_err(); + assert!(matches!(err, MemberProofError::AudienceMismatch)); + } + + #[test] + fn rejects_wrong_content() { + let owner = make_owner(); + let jwt = issue_proof(&owner, "node:n1", "other-content", "host", 1000); + let err = + verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).unwrap_err(); + assert!(matches!(err, MemberProofError::ContentMismatch { .. })); + } + + #[test] + fn rejects_non_host_capability() { + let owner = make_owner(); + let jwt = issue_proof(&owner, "node:n1", "content-1", "read", 1000); + let err = + verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).unwrap_err(); + assert!(matches!(err, MemberProofError::NotHostCapability)); + } + + #[test] + fn rejects_expired() { + let owner = make_owner(); + let jwt = issue_proof(&owner, "node:n1", "content-1", "host", 100); + let err = + verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).unwrap_err(); + assert!(matches!(err, MemberProofError::Expired)); + } + + #[test] + fn rejects_tampered_payload() { + let owner = make_owner(); + let jwt = issue_proof(&owner, "node:n1", "content-1", "host", 1000); + // Swap the payload for one granting a different node, keeping the sig. + let mut parts: Vec<&str> = jwt.split('.').collect(); + let forged_payload = serde_json::json!({ + "iss": owner.key_id, "aud": "node:attacker", "exp": 1000, "iat": 0, + "jti": "x", "att": [{"with":"monas://content/content-1","can":"host"}], + }); + let forged = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged_payload).unwrap()); + parts[1] = &forged; + let tampered = parts.join("."); + let err = verify_member_proof(&tampered, &owner.key_id, "content-1", "node:attacker", 500) + .unwrap_err(); + assert!(matches!(err, MemberProofError::BadSignature)); + } + + /// **Parity test** against the real monas-account issuer. Confirms a proof + /// issued by `AccountService::issue_member_proof` is accepted by our + /// verifier — end-to-end owner-signing ↔ reader-verification agreement. + #[test] + fn accepts_proof_issued_by_real_account_service() { + use monas_account::application_service::command::{IssueMemberProofRequest, KeyTypeMapper}; + use monas_account::application_service::port::{AccountKeyStore, StoredAccountKey}; + use monas_account::application_service::service::AccountService; + + // In-memory account key store for the owner. + struct MemStore(std::sync::Mutex>); + impl AccountKeyStore for MemStore { + fn save( + &self, + key: &StoredAccountKey, + ) -> Result<(), monas_account::application_service::port::AccountKeyStoreError> + { + *self.0.lock().unwrap() = Some(key.clone()); + Ok(()) + } + fn load( + &self, + ) -> Result< + Option, + monas_account::application_service::port::AccountKeyStoreError, + > { + Ok(self.0.lock().unwrap().clone()) + } + fn delete( + &self, + ) -> Result<(), monas_account::application_service::port::AccountKeyStoreError> + { + *self.0.lock().unwrap() = None; + Ok(()) + } + } + + let store = MemStore(std::sync::Mutex::new(None)); + let owner_account = AccountService::create(&store, KeyTypeMapper::P256).unwrap(); + let owner_key_id = format!("user:{}", hex::encode(owner_account.public_key_bytes())); + + let result = AccountService::issue_member_proof( + &store, + IssueMemberProofRequest { + member_node_id: "node:n1".to_string(), + content_id: "content-1".to_string(), + ttl_secs: 3600, + }, + ) + .unwrap(); + + // now well within the token's validity window + verify_member_proof( + &result.delegated_token, + &owner_key_id, + "content-1", + "node:n1", + result.issued_at, + ) + .expect("real account-issued proof should verify"); + } +} diff --git a/monas-content/src/infrastructure/mod.rs b/monas-content/src/infrastructure/mod.rs index 5166feb..0ddfbf1 100644 --- a/monas-content/src/infrastructure/mod.rs +++ b/monas-content/src/infrastructure/mod.rs @@ -2,6 +2,7 @@ pub mod content_id; pub mod encryption; pub mod key_store; pub mod key_wrapping; +pub mod member_proof; pub mod node_verification; pub mod public_key_directory; pub mod share_repository; From b41f454dfb7701e428c8a077168ecb6813f1d0ed Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 07:34:58 +0900 Subject: [PATCH 10/48] docs(design): drop member proof (owner can't know membership); add resume handoff (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design correction: the owner-issued member proof (component C) assumed the owner knows/authorizes membership, but members change autonomously via DHT replication with no owner involvement — the premise doesn't hold in Monas. Member verification isn't needed anyway: component A (Node CBOR + CID recompute) already rejects fabricated data/versions regardless of who answered. Scope reduces to A (done) + B (monotonicity, todo). Adds read-response-integrity-HANDOFF.md so a fresh session can resume: exact commit hashes, what to keep, that 3a64a5a (component C) must be reverted, remaining work (monotonicity + real read endpoint + tests + PR), and file:line references. Co-Authored-By: Claude Fable 5 --- .../design/read-response-integrity-HANDOFF.md | 135 ++++++++++++++++++ docs/design/read-response-integrity.md | 16 ++- 2 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 docs/design/read-response-integrity-HANDOFF.md diff --git a/docs/design/read-response-integrity-HANDOFF.md b/docs/design/read-response-integrity-HANDOFF.md new file mode 100644 index 0000000..e78007a --- /dev/null +++ b/docs/design/read-response-integrity-HANDOFF.md @@ -0,0 +1,135 @@ +# read-response-integrity 実装ハンドオフ(別セッション再開用) + +最終更新: 2026-07-18。このファイルだけ読めば、別セッションで作業を再開できるように書いた。 + +--- + +## 0. 一言サマリ + +PR #54(read relay)に対するセキュリティ指摘(issue #55)への対応。read 応答の完全性を、**メタデータ機密性(誰がどの content を持つか)を晒さずに**足す。 + +**最重要の設計訂正(2026-07-18)**: 当初計画にあった「owner 発行の member 証明」(コンポーネント C)は**廃止**。理由 → §2。現在の正しい設計は **A(版真正性)+ B(単調性)のみ**。 + +- 作業ブランチ: `feature/read-response-signing`(base = `fix/state-node-read-relay` = PR #54) +- PR 向き先: **`fix/state-node-read-relay`(#54)**。まだ PR は作っていない。 +- 前提: production 利用ゼロ(テストのみ)。**後方互換不要・破壊的変更 OK・1 PR**。 +- モデル: Fable 5 を使い続ける(ユーザー指示、メモリ `use-fable-5-model` 参照)。 +- 設計本体: `docs/design/read-response-integrity.md`(冒頭に訂正あり)。 + +--- + +## 1. 何を防ぐか(訂正後) + +#54 で対処済み: credential の content_id バインド、AES-GCM(暗号文本文の改ざん検知)。 + +本作業で足すのは以下(A + B のみ): + +| 攻撃 | 防御 | 状態 | +|---|---|---| +| 非 member が偽データ/偽版を返す | **A: Node CBOR + CID 再計算**。攻撃者は正しい CID を持つ偽 Node を作れない | ✅ コア実装済み | +| ロールバック(過去の本物の版を最新と偽る) | **B: 単調性チェック**(前回見た版より祖先へ後退したら拒否) | ❌ 未実装 | + +**防がない(既知の限界、脅威モデルに明記)**: 正規 member 自身による stale/ロールバック(否定的事実「より新しい版が無い」はネットワーク越しに証明不能)。 + +--- + +## 2. ⚠️ なぜ member 証明(C)を廃止したか + +当初 §5.1.b で「owner が member 追加時に証明トークン(ES256 JWT, aud=node, can=host)を発行し、node が read 応答に添付、client が owner 鍵で検証」を採用した。**これは誤り**: + +- **owner は誰が member かを知らないし、知り得ない**。member は DHT 複製配置・`add_member_to_content` で **owner の関与なく自律的に増減・入れ替わる**。「owner が member 追加時に発行」という経路が Monas に存在しない。 +- 「署名の根が owner(read 認可)」と「member を認定するのが owner」は別問題。混同していた。 + +**結論**: member であることの確認は不要。データが CID で検証できれば、返した相手が誰でもよい(A で完結)。→ C は全面廃止。 + +--- + +## 3. コミット状況(このブランチ、`main..HEAD`) + +設計ドキュメント(9 コミット、`3f80337`〜`ffe1db3`)は省略。実装コミットは以下: + +1. `ec26c00` **A(サーバ + クライアント検証コア)** — 保持 +2. `7970b35` **read 形式統一 + E2E verify-decrypt コア + verify_integrity 修正** — 保持 +3. `3a64a5a` **C(member 証明)** — **⚠️ revert する**(§2) + +`main..HEAD` の base コミット(`361bcc6` 以前)は #54 の中身。 + +--- + +## 4. 実装済みの中身(保持するもの) + +### 4.1 コンポーネント A — 版真正性(完了・パリティ実証済み) + +**state-node 側**(`ec26c00`, `7970b35`): +- `monas-state-node/src/port/content_repository.rs`: trait に `get_latest_node_bytes_with_version` / `get_version_node_bytes` 追加(Node CBOR を返す)。 +- `monas-state-node/src/infrastructure/crdt_repository.rs`: 実装(`node.to_bytes()` = CBOR を返す)。 +- `monas-state-node/src/test_utils.rs` / `infrastructure/auth/ucan_adapter.rs`: モック実装追加。 +- `monas-state-node/src/application_service/state_node_service.rs`: `read_content_via_relay` が新メソッドを使い Node CBOR を返す。 +- `monas-state-node/src/presentation/http_api.rs`: `/content/:id/data` と `/content/:id/version/:version` の **local 分岐も relay 分岐も Node CBOR を返すよう統一**(client がどちらでも同じ形式を検証)。`version` フィールドを必ず埋める。 + +**client 側**(`monas-content`): +- `monas-content/src/infrastructure/node_verification.rs`(新規): `recompute_node_cid`(CBOR → SHA-256 → CIDv1 RAW/SHA2-256)+ `verify_and_extract(node_bytes, expected_version_cid) -> VerifiedNode{ciphertext, parents}`。CID 不一致で拒否。 + - **crsl-lib パリティテスト済み**: 本物の crsl-lib `Node`(genesis + child)を作り `Node::content_id()` と一致確認。これが最大リスクで、クリア済み。 +- `monas-content/src/application_service/content_service/service.rs`: `verify_and_decrypt_relay_read(node_bytes, expected_version_cid, local_content_id) -> VerifiedRead{plaintext, parents}` = 検証 → CEK ロード(`cek_store.load`)→ `decrypt_with_cek`(AES-GCM + content_id 照合)。**これが E2E 復号の再利用コア**。 +- `monas-content/Cargo.toml`: `serde_cbor`, `cid`(serde feature), `multihash` 追加。dev-dep に `crsl-lib`(パリティ用)。 + +**verify_integrity 修正**(`monas-sdk/src/controller/state.rs`): state node が Node CBOR を返すようになったので、旧「生暗号文とバイト比較」が壊れる。`verify_and_extract` で CID 検証 + 暗号文抽出してから比較するよう修正済み。 + +### 4.2 廃止するもの(C, `3a64a5a`)— revert 対象 + +- `monas-account/src/application_service/command.rs`: `IssueMemberProofRequest` +- `monas-account/src/application_service/service.rs`: `issue_member_proof` +- `monas-account/src/application_service/mod.rs`: export 追加 +- `monas-content/src/infrastructure/member_proof.rs`(新規ファイル) +- `monas-content/src/infrastructure/mod.rs`: `pub mod member_proof;` +- `monas-content/Cargo.toml`: dev-dep `monas-account`(member_proof パリティ用) +→ `git revert 3a64a5a` で概ね戻る(コンフリクトしたら mod.rs / Cargo.toml を手で調整)。member_proof.rs 削除を確認。 + +--- + +## 5. 残作業(A + B のみ、C は無し) + +### 5.1 B — 単調性チェック(未実装) + +目的: client が「content ごとに最後に見た version CID」を記録し、後退(祖先へのロールバック)を拒否。 + +- SDK ローカル sled(既存 `SledContentEncryptionKeyStore`、`monas-sdk/src/controller/mod.rs:246`)と同じ DB に `content_id -> last_seen_version_cid` の tree を新設。in-memory 版も(`mod.rs:230` に倣う)。 +- 祖先判定: `verify_and_extract` が返す `VerifiedNode.parents`(親版 CID)を辿り、「last_seen が今回版の祖先か」を確認。祖先でなければ後退 → 拒否。親を辿るのに版指定 read で親 Node を順次取得(深さは bound、既定は実装で決める)。 +- 追記のみ DAG(`new_child` で新 CID、既存 Node 不変)は確認済みなので誤検知しない。初回(記録なし)は TOFU 受理 + 記録。検証通過後に last_seen 更新。 + +### 5.2 実 read エンドポイント(未実装)— これが無いと「実際に使えない」 + +現状 SDK には「state node から暗号文を読んで復号してユーザーに返す」経路が**無い**(`get_content` はローカルストレージから復号)。新設が必要: + +- SDK に新メソッド(例 `read_content_from_state_node`): auth 受け取り → `resolve_state_read_auth` → `get_state_node_history` で最新 version 決定 → `get_state_node_version_data`(Node CBOR base64)→ decode → `content_service.verify_and_decrypt_relay_read` → 単調性チェック(B)→ 平文返却。 +- **入力は remote_content_id(state node 読み取り)と local_content_id(CEK 引き)の両方**が必要(local↔remote の対応表は無く、呼び出し側が両方渡す設計。`VerifyIntegrityInput` と同じ)。 +- gateway(`monas-gateway/src/main.rs`)の read ハンドラに `HeaderMap` を足し `build_state_node_auth_context` を通す(現状 read は auth 非対応)。 +- **CEK の欠落に注意**: share で受け取った content は unwrap した CEK が保存されない(`decrypt_shared_content` は即復号のみ)。自分が作成者なら `cek_store.load(local_id)` で取れる。share 経由も読めるようにするなら unwrap 済み CEK を `cek_store.save` する経路が別途要る(スコープ判断)。 + +### 5.3 テスト + PR + +- 単体: A の改ざん拒否、B の後退拒否/初回受理。統合: relay read e2e(`monas-state-node/scripts/e2e-test.sh`)を Node 返却形式に更新。 +- `cargo build/test/clippy/fmt` を content/sdk/state-node/account で green に。**Rust 1.97 の clippy で確認**(`rustup run 1.97.0 cargo clippy --workspace --all-targets --profile test --no-deps -- --deny warnings`。CI が最新 stable を入れるため。#54 で `for_kv_map`/`useless_borrows_in_formatting` に刺さった前例あり)。 +- PR 作成: **base = `fix/state-node-read-relay`**。本文に「A+B のみ、member 証明は設計上不要として不採用」を明記。 + +--- + +## 6. 再開時の最初の一手 + +1. このファイルと `docs/design/read-response-integrity.md` 冒頭の訂正を読む。 +2. `git revert 3a64a5a`(C を戻す)。ビルド green 確認。 +3. 実 read エンドポイント(§5.2)→ 単調性(§5.1)の順で実装。 +4. テスト → PR(§5.3)。 + +--- + +## 7. 主要な file:line リファレンス(調査済み) + +- crsl-lib Node: `~/.cargo/git/checkouts/crsl-lib-*/e13b86c/src/dasl/node.rs`(`content_id`:76, `to_bytes`:90, `from_bytes`:104, `parents`:144)。rev pin = `e13b86ce...`。 +- CEK ストア: `monas-content/src/infrastructure/key_store.rs`(sled key = `cek:{content_id}`)。 +- 復号: `monas-content/src/infrastructure/encryption.rs`(AES-256-GCM, `[nonce12||ct||tag16]`)。 +- decrypt_with_cek: `monas-content/src/application_service/content_service/service.rs:268`。 +- SDK read: `monas-sdk/src/controller/state.rs`(`get_state_node_history`:83, `get_state_node_version_data`:104, `verify_integrity`:225)。 +- SDK local read: `monas-sdk/src/controller/content.rs:842`(`get_content`, ローカル復号)。 +- gateway: `monas-gateway/src/main.rs`(read ハンドラ:114, `build_state_node_auth_context`:284)。 +- owner key_id 形式: `monas-account/src/application_service/service.rs:160`(`user:{hex(pubkey)}`, 自己完結型)。 diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md index e65383f..436dbb1 100644 --- a/docs/design/read-response-integrity.md +++ b/docs/design/read-response-integrity.md @@ -1,8 +1,20 @@ -# read 経路の完全性: 署名付き応答の E2E 検証 +# read 経路の完全性: 応答データの E2E 検証 -- ステータス: **方式確定(2026-07-18)、実装計画待ち** +- ステータス: **【2026-07-18 重要訂正】member 証明(C)を廃止。A(版真正性)+ B(単調性)に縮小。実装途中。** - 関連: PR #54、issue #55 - 前提ブランチ: `feature/read-response-signing`(#54 の上に積む) +- **再開手順は `docs/design/read-response-integrity-HANDOFF.md` を参照。** + +> ## ⚠️ 設計訂正(2026-07-18) — member 証明の廃止 +> +> 当初「owner が member 追加時に証明トークンを発行し、node がそれを read 応答に添付する」(§5.1.b / §5.3, コンポーネント C)を採用したが、**これは Monas の分散設計に反する誤りだった**: +> +> - **owner は誰が member かを知らないし、知り得ない**。member はネットワークの複製配置(DHT・`add_member_to_content`)で **owner の関与なく自律的に増減・入れ替わる**。「owner が member 追加時に証明発行」という前提が成立しない。 +> - 署名の信頼の根が owner であること(read 認可)と、member を認定するのが owner であることは**別の話**。当初これを混同していた。 +> +> **訂正後の結論: member 証明は不要。** #54 レビューの主シナリオ(非 member が偽データ・偽履歴を返す)は **A(Node CBOR + CID 再計算)だけで防げる** — 攻撃者は正しい CID を持つ偽 Node を作れないため、誰が返そうと弾ける。「正規 member か」を確認する必要自体がない(データが暗号学的に正しければ、返した相手は誰でもよい)。ロールバックは B(単調性)でベストエフォート検出。 +> +> 以下 §5.1.b / §5.3 / §6 の member 証明関連は**歴史的経緯として残すが、採用しない**。実装済みの C コード(コミット `3a64a5a`)は revert する。 ## 1. 目的 From ffd9d56c1d547d4504d17d68c6afa1b554f8a78d Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 07:36:43 +0900 Subject: [PATCH 11/48] docs(design): add full task checklist + user-confirmed constraints to handoff (#55) Persist the task list (TaskCreate state doesn't carry across sessions), the user-confirmed constraints (1 PR, no backward-compat, base = #54, must build a real usable read path not just verification, member proof dropped), and the open CEK question for the real read endpoint. Co-Authored-By: Claude Fable 5 --- .../design/read-response-integrity-HANDOFF.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/design/read-response-integrity-HANDOFF.md b/docs/design/read-response-integrity-HANDOFF.md index e78007a..feaed1e 100644 --- a/docs/design/read-response-integrity-HANDOFF.md +++ b/docs/design/read-response-integrity-HANDOFF.md @@ -121,6 +121,34 @@ PR #54(read relay)に対するセキュリティ指摘(issue #55)への対応。 3. 実 read エンドポイント(§5.2)→ 単調性(§5.1)の順で実装。 4. テスト → PR(§5.3)。 +## 6.1 タスクリスト全体(チェックリスト) + +前セッションの TaskCreate は引き継がれないので、ここに残す。 + +- [x] **A サーバ**: state-node が Node CBOR を返す(`ec26c00`) +- [x] **A クライアント**: monas-content で CID 再計算・検証 + crsl-lib パリティ(`ec26c00`) +- [x] **read 形式統一 + E2E verify-decrypt コア + verify_integrity 修正**(`7970b35`) +- [x] **設計訂正 + ハンドオフ doc**(`b41f454`) +- [ ] **C を revert**: `git revert 3a64a5a`(member 証明は設計上不要) +- [ ] **B 単調性チェック**: SDK sled に last_seen 記録 + parents 祖先判定(§5.1) +- [ ] **実 read エンドポイント**: SDK 新メソッド + gateway auth 転送 + CEK 入手(§5.2)。**これが無いと「実際に使えない」** +- [ ] **テスト**: A 改ざん拒否 / B 後退拒否・初回受理 / e2e-test.sh 更新(§5.3) +- [ ] **build/test/clippy/fmt green**(Rust 1.97 の clippy で確認、§5.3) +- [ ] **PR 作成**: base = `fix/state-node-read-relay`(#54)。本文に「A+B のみ、member 証明は不採用」明記 + +## 6.2 ユーザーからの確定事項(セッション履歴より) + +- **1 PR のみ**で実装する。 +- **後方互換は一切考慮しない。破壊的変更 OK**(production 利用ゼロ、テストのみ)。 +- **PR 向き先は `fix/state-node-read-relay`(#54)**。 +- **「実際に使えないと意味がない」** → 検証機構だけでなく、state node から読んで復号する**実 read 経路まで**作ること(§5.2 は必須、切り出し不可)。 +- **member 証明は不要**(§2。owner は membership を知り得ない)。 +- Fable 5 モデルを使い続ける。 + +## 6.3 実 read 経路で残っている設計判断(§5.2 の CEK 問題) + +share で受け取った content は、unwrap した CEK が現状どこにも保存されない(`decrypt_shared_content` は即復号のみ)。実 read 経路で share 済み content も読めるようにするなら、unwrap 済み CEK を `cek_store.save` する経路が別途要る。**自分が作成者の content なら `cek_store.load(local_id)` で足りる**ので、初版は「作成者による自 content の read」に絞り、share 経由 read は別途、という切り分けも可(実装時にユーザー判断を仰ぐ)。 + --- ## 7. 主要な file:line リファレンス(調査済み) From 4edbcba8e6917b5c06682ad98541f1a334961e3d Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 07:42:28 +0900 Subject: [PATCH 12/48] =?UTF-8?q?Revert=20"feat(read-integrity):=20owner-i?= =?UTF-8?q?ssued=20member=20proof=20=E2=80=94=20issuance=20+=20client=20ve?= =?UTF-8?q?rify=20(#55,=20comp=20C=20core)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3a64a5a8e2572646b90d7861ab3fb0f6b45275ab. --- Cargo.lock | 1 - .../src/application_service/command.rs | 14 - monas-account/src/application_service/mod.rs | 4 +- .../src/application_service/service.rs | 86 +---- monas-content/Cargo.toml | 3 - .../src/infrastructure/member_proof.rs | 316 ------------------ monas-content/src/infrastructure/mod.rs | 1 - 7 files changed, 2 insertions(+), 423 deletions(-) delete mode 100644 monas-content/src/infrastructure/member_proof.rs diff --git a/Cargo.lock b/Cargo.lock index fdd9719..8480c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3123,7 +3123,6 @@ dependencies = [ "hmac", "hpke-rs", "hpke-rs-rust-crypto", - "monas-account", "monas-filesync", "multihash", "p256", diff --git a/monas-account/src/application_service/command.rs b/monas-account/src/application_service/command.rs index a1063fa..a6998f4 100644 --- a/monas-account/src/application_service/command.rs +++ b/monas-account/src/application_service/command.rs @@ -30,17 +30,3 @@ pub struct IssueDelegatedTokenResult { pub expires_at: u64, pub jti: String, } - -/// Request to issue a membership proof: an owner-signed token attesting that a -/// specific state node (`member_node_id`) is a legitimate member/host of a -/// content. Used by the verified read path so a responding node can prove it is -/// a real member without exposing the member list -/// (`docs/design/read-response-integrity.md` §5.1.b). -#[derive(Debug, Clone)] -pub struct IssueMemberProofRequest { - /// Identity of the member state node this proof is issued to (the token's - /// `aud`). This is the node's self-identifier (e.g. `node:`). - pub member_node_id: String, - pub content_id: String, - pub ttl_secs: u64, -} diff --git a/monas-account/src/application_service/mod.rs b/monas-account/src/application_service/mod.rs index 3738b56..d92e8b2 100644 --- a/monas-account/src/application_service/mod.rs +++ b/monas-account/src/application_service/mod.rs @@ -3,9 +3,7 @@ pub mod error; pub mod port; pub mod service; -pub use command::{ - IssueDelegatedTokenRequest, IssueDelegatedTokenResult, IssueMemberProofRequest, KeyTypeMapper, -}; +pub use command::{IssueDelegatedTokenRequest, IssueDelegatedTokenResult, KeyTypeMapper}; pub use error::{AccountServiceError, IssueDelegatedTokenError, SignError}; pub use port::{AccountKeyStore, AccountKeyStoreError, StoredAccountKey}; pub use service::AccountService; diff --git a/monas-account/src/application_service/service.rs b/monas-account/src/application_service/service.rs index a64cf55..5e8b54a 100644 --- a/monas-account/src/application_service/service.rs +++ b/monas-account/src/application_service/service.rs @@ -1,5 +1,5 @@ use crate::application_service::command::{ - IssueDelegatedTokenRequest, IssueDelegatedTokenResult, IssueMemberProofRequest, KeyTypeMapper, + IssueDelegatedTokenRequest, IssueDelegatedTokenResult, KeyTypeMapper, }; use crate::application_service::error::{AccountServiceError, IssueDelegatedTokenError, SignError}; use crate::application_service::port::AccountKeyStore; @@ -142,90 +142,6 @@ impl AccountService { jti, }) } - - /// Issue an owner-signed membership proof for a state node. - /// - /// The proof is an ES256 JWT (`iss = owner key_id`, `aud = member_node_id`, - /// `att = [{ with: "monas://content/{cid}", can: "host" }]`). A member node - /// attaches it to read responses; a reader verifies it against the owner - /// public key (recoverable from the owner key_id) to confirm the responder - /// is a legitimate member — without ever seeing the member list - /// (`docs/design/read-response-integrity.md` §5.1.b). - pub fn issue_member_proof( - store: &S, - req: IssueMemberProofRequest, - ) -> Result { - if req.content_id.trim().is_empty() { - return Err(IssueDelegatedTokenError::Validation( - "content_id must not be empty".to_string(), - )); - } - if req.member_node_id.trim().is_empty() { - return Err(IssueDelegatedTokenError::Validation( - "member_node_id must not be empty".to_string(), - )); - } - if req.ttl_secs == 0 { - return Err(IssueDelegatedTokenError::Validation( - "ttl_secs must be greater than 0".to_string(), - )); - } - const MAX_TTL_SECS: u64 = 24 * 60 * 60; - if req.ttl_secs > MAX_TTL_SECS { - return Err(IssueDelegatedTokenError::Validation(format!( - "ttl_secs must be <= {MAX_TTL_SECS}" - ))); - } - - let stored = store - .load() - .map_err(IssueDelegatedTokenError::KeyStore)? - .ok_or(IssueDelegatedTokenError::NotFound)?; - - if stored.algorithm != KeyAlgorithm::P256 { - return Err(IssueDelegatedTokenError::UnsupportedAlgorithm(format!( - "{:?}", - stored.algorithm - ))); - } - - let owner_key_id = key_id_from_public_key(&stored.public_key); - let now = unix_now_secs()?; - let expires_at = now.saturating_add(req.ttl_secs); - let jti = generate_jti(); - - let payload = DelegationClaims { - iss: owner_key_id, - aud: req.member_node_id, - exp: expires_at, - iat: now, - jti: jti.clone(), - att: vec![DelegationCapabilityClaim { - with: format!("monas://content/{}", req.content_id), - can: "host".to_string(), - }], - }; - - let key_pair = KeyPairGenerateFactory::from_key_bytes( - stored.algorithm, - &stored.public_key, - &stored.secret_key, - ) - .map_err(IssueDelegatedTokenError::InvalidKey)?; - let account = Account::new(key_pair); - let delegated_token = sign_es256_jwt_payload(&payload, |signing_input| { - let (signature, _recovery_id) = account.sign(signing_input); - Ok(signature) - }) - .map_err(IssueDelegatedTokenError::JwtSigning)?; - - Ok(IssueDelegatedTokenResult { - delegated_token, - issued_at: now, - expires_at, - jti, - }) - } } fn unix_now_secs() -> Result { diff --git a/monas-content/Cargo.toml b/monas-content/Cargo.toml index 6922678..b25545b 100644 --- a/monas-content/Cargo.toml +++ b/monas-content/Cargo.toml @@ -47,6 +47,3 @@ tempfile = "3.19.1" # Parity test only: build a real crsl-lib Node and confirm our from-CBOR CID # recompute matches Node::content_id() exactly. Same rev as monas-state-node. crsl-lib = { git = "https://github.com/Monas-project/crsl-lib", rev = "e13b86ce6d6a9c27ebd01a9b4fe82d6bc18f8a01" } -# Parity test only: issue a real owner member-proof via AccountService and -# confirm our verify_member_proof accepts it. -monas-account = { path = "../monas-account" } diff --git a/monas-content/src/infrastructure/member_proof.rs b/monas-content/src/infrastructure/member_proof.rs deleted file mode 100644 index d9e1e4c..0000000 --- a/monas-content/src/infrastructure/member_proof.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! Client-side verification of owner-issued membership proofs. -//! -//! A membership proof is an ES256 JWT the content owner issues to a state node, -//! attesting that the node is a legitimate member/host of a content. A member -//! node attaches its proof to relay-read responses; the reader verifies it here -//! to confirm the responder is a real member — WITHOUT ever seeing the member -//! list (`docs/design/read-response-integrity.md` §5.1.b). -//! -//! Trust root: the owner public key, recoverable directly from the owner -//! key_id (`user:{hex(pubkey)}`) that the reader already holds in its own -//! delegation token's `iss`. No key-fetch API is needed. -//! -//! JWT format (matching monas-account's `sign_es256_jwt_payload`): -//! `base64url(header).base64url(payload).base64url(sig)`, ES256 = P-256 ECDSA -//! over `SHA-256(signing_input)`, signature as fixed 64-byte r||s. - -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use base64::Engine; -use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; -use serde::Deserialize; - -#[derive(Debug, thiserror::Error)] -pub enum MemberProofError { - #[error("malformed proof token (expected 3 JWT segments)")] - Malformed, - #[error("failed to decode proof segment: {0}")] - Decode(String), - #[error("failed to parse owner key_id: {0}")] - OwnerKey(String), - #[error("proof signature is invalid")] - BadSignature, - #[error("proof issuer does not match the content owner")] - IssuerMismatch, - #[error("proof is not for content {expected}")] - ContentMismatch { expected: String }, - #[error("proof does not grant the host capability")] - NotHostCapability, - #[error("proof audience does not match the responding node")] - AudienceMismatch, - #[error("proof has expired")] - Expired, -} - -#[derive(Deserialize)] -struct Claims { - iss: String, - aud: String, - exp: u64, - #[allow(dead_code)] - iat: u64, - att: Vec, -} - -#[derive(Deserialize)] -struct Capability { - with: String, - can: String, -} - -/// Verify an owner-issued membership proof. -/// -/// Checks: signature against `owner_key_id`'s public key; `iss == owner_key_id`; -/// audience == `expected_node_id` (the node that answered); the `host` -/// capability is granted for `content_id`; and not expired at `now_secs`. -/// -/// `owner_key_id` is `user:{hex(pubkey)}` — the same value the reader carries in -/// its own delegation token's `iss`, so no extra key lookup is required. -pub fn verify_member_proof( - proof_jwt: &str, - owner_key_id: &str, - content_id: &str, - expected_node_id: &str, - now_secs: u64, -) -> Result<(), MemberProofError> { - let mut parts = proof_jwt.split('.'); - let header_b64 = parts.next().ok_or(MemberProofError::Malformed)?; - let payload_b64 = parts.next().ok_or(MemberProofError::Malformed)?; - let sig_b64 = parts.next().ok_or(MemberProofError::Malformed)?; - if parts.next().is_some() { - return Err(MemberProofError::Malformed); - } - - // 1. Verify the ES256 signature against the owner's public key. - let verifying_key = verifying_key_from_owner_key_id(owner_key_id)?; - let signing_input = format!("{header_b64}.{payload_b64}"); - let sig_bytes = URL_SAFE_NO_PAD - .decode(sig_b64) - .map_err(|e| MemberProofError::Decode(e.to_string()))?; - let signature = - Signature::try_from(sig_bytes.as_slice()).map_err(|_| MemberProofError::BadSignature)?; - verifying_key - .verify(signing_input.as_bytes(), &signature) - .map_err(|_| MemberProofError::BadSignature)?; - - // 2. Decode and check claims. - let payload_json = URL_SAFE_NO_PAD - .decode(payload_b64) - .map_err(|e| MemberProofError::Decode(e.to_string()))?; - let claims: Claims = serde_json::from_slice(&payload_json) - .map_err(|e| MemberProofError::Decode(e.to_string()))?; - - if claims.iss != owner_key_id { - return Err(MemberProofError::IssuerMismatch); - } - if claims.aud != expected_node_id { - return Err(MemberProofError::AudienceMismatch); - } - if now_secs > claims.exp { - return Err(MemberProofError::Expired); - } - - let want_with = format!("monas://content/{content_id}"); - let grants_host = claims - .att - .iter() - .any(|c| c.with == want_with && c.can == "host"); - if !grants_host { - // Distinguish "wrong content" from "wrong capability" for clearer errors. - if claims.att.iter().any(|c| c.with == want_with) { - return Err(MemberProofError::NotHostCapability); - } - return Err(MemberProofError::ContentMismatch { - expected: content_id.to_string(), - }); - } - - Ok(()) -} - -/// Recover the P-256 verifying key from an owner key_id of the form -/// `user:{hex(SEC1 pubkey)}`. -fn verifying_key_from_owner_key_id(owner_key_id: &str) -> Result { - let hex_pk = owner_key_id - .strip_prefix("user:") - .ok_or_else(|| MemberProofError::OwnerKey("key_id must start with `user:`".to_string()))?; - let pk_bytes = hex::decode(hex_pk).map_err(|e| MemberProofError::OwnerKey(e.to_string()))?; - VerifyingKey::from_sec1_bytes(&pk_bytes).map_err(|e| MemberProofError::OwnerKey(e.to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - use p256::ecdsa::{signature::Signer, SigningKey}; - - struct Owner { - signing: SigningKey, - key_id: String, - } - - fn make_owner() -> Owner { - // Deterministic key for tests. - let signing = SigningKey::from_bytes(&[7u8; 32].into()).unwrap(); - let vk = VerifyingKey::from(&signing); - let sec1 = vk.to_encoded_point(false); - let key_id = format!("user:{}", hex::encode(sec1.as_bytes())); - Owner { signing, key_id } - } - - fn issue_proof(owner: &Owner, aud: &str, content_id: &str, can: &str, exp: u64) -> String { - let header = serde_json::json!({"alg":"ES256","typ":"JWT","ver":"1.0"}); - let payload = serde_json::json!({ - "iss": owner.key_id, - "aud": aud, - "exp": exp, - "iat": 0, - "jti": "test", - "att": [{"with": format!("monas://content/{content_id}"), "can": can}], - }); - let h = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); - let p = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); - let signing_input = format!("{h}.{p}"); - let sig: Signature = owner.signing.sign(signing_input.as_bytes()); - let s = URL_SAFE_NO_PAD.encode(sig.to_bytes()); - format!("{signing_input}.{s}") - } - - #[test] - fn accepts_valid_proof() { - let owner = make_owner(); - let jwt = issue_proof(&owner, "node:n1", "content-1", "host", 1000); - assert!(verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).is_ok()); - } - - #[test] - fn rejects_wrong_owner_key() { - let owner = make_owner(); - let other = { - let signing = SigningKey::from_bytes(&[9u8; 32].into()).unwrap(); - let vk = VerifyingKey::from(&signing); - format!( - "user:{}", - hex::encode(vk.to_encoded_point(false).as_bytes()) - ) - }; - let jwt = issue_proof(&owner, "node:n1", "content-1", "host", 1000); - // Verifying against a different owner key must fail the signature check. - let err = verify_member_proof(&jwt, &other, "content-1", "node:n1", 500).unwrap_err(); - assert!(matches!(err, MemberProofError::BadSignature)); - } - - #[test] - fn rejects_wrong_node_audience() { - let owner = make_owner(); - let jwt = issue_proof(&owner, "node:attacker", "content-1", "host", 1000); - let err = - verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).unwrap_err(); - assert!(matches!(err, MemberProofError::AudienceMismatch)); - } - - #[test] - fn rejects_wrong_content() { - let owner = make_owner(); - let jwt = issue_proof(&owner, "node:n1", "other-content", "host", 1000); - let err = - verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).unwrap_err(); - assert!(matches!(err, MemberProofError::ContentMismatch { .. })); - } - - #[test] - fn rejects_non_host_capability() { - let owner = make_owner(); - let jwt = issue_proof(&owner, "node:n1", "content-1", "read", 1000); - let err = - verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).unwrap_err(); - assert!(matches!(err, MemberProofError::NotHostCapability)); - } - - #[test] - fn rejects_expired() { - let owner = make_owner(); - let jwt = issue_proof(&owner, "node:n1", "content-1", "host", 100); - let err = - verify_member_proof(&jwt, &owner.key_id, "content-1", "node:n1", 500).unwrap_err(); - assert!(matches!(err, MemberProofError::Expired)); - } - - #[test] - fn rejects_tampered_payload() { - let owner = make_owner(); - let jwt = issue_proof(&owner, "node:n1", "content-1", "host", 1000); - // Swap the payload for one granting a different node, keeping the sig. - let mut parts: Vec<&str> = jwt.split('.').collect(); - let forged_payload = serde_json::json!({ - "iss": owner.key_id, "aud": "node:attacker", "exp": 1000, "iat": 0, - "jti": "x", "att": [{"with":"monas://content/content-1","can":"host"}], - }); - let forged = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&forged_payload).unwrap()); - parts[1] = &forged; - let tampered = parts.join("."); - let err = verify_member_proof(&tampered, &owner.key_id, "content-1", "node:attacker", 500) - .unwrap_err(); - assert!(matches!(err, MemberProofError::BadSignature)); - } - - /// **Parity test** against the real monas-account issuer. Confirms a proof - /// issued by `AccountService::issue_member_proof` is accepted by our - /// verifier — end-to-end owner-signing ↔ reader-verification agreement. - #[test] - fn accepts_proof_issued_by_real_account_service() { - use monas_account::application_service::command::{IssueMemberProofRequest, KeyTypeMapper}; - use monas_account::application_service::port::{AccountKeyStore, StoredAccountKey}; - use monas_account::application_service::service::AccountService; - - // In-memory account key store for the owner. - struct MemStore(std::sync::Mutex>); - impl AccountKeyStore for MemStore { - fn save( - &self, - key: &StoredAccountKey, - ) -> Result<(), monas_account::application_service::port::AccountKeyStoreError> - { - *self.0.lock().unwrap() = Some(key.clone()); - Ok(()) - } - fn load( - &self, - ) -> Result< - Option, - monas_account::application_service::port::AccountKeyStoreError, - > { - Ok(self.0.lock().unwrap().clone()) - } - fn delete( - &self, - ) -> Result<(), monas_account::application_service::port::AccountKeyStoreError> - { - *self.0.lock().unwrap() = None; - Ok(()) - } - } - - let store = MemStore(std::sync::Mutex::new(None)); - let owner_account = AccountService::create(&store, KeyTypeMapper::P256).unwrap(); - let owner_key_id = format!("user:{}", hex::encode(owner_account.public_key_bytes())); - - let result = AccountService::issue_member_proof( - &store, - IssueMemberProofRequest { - member_node_id: "node:n1".to_string(), - content_id: "content-1".to_string(), - ttl_secs: 3600, - }, - ) - .unwrap(); - - // now well within the token's validity window - verify_member_proof( - &result.delegated_token, - &owner_key_id, - "content-1", - "node:n1", - result.issued_at, - ) - .expect("real account-issued proof should verify"); - } -} diff --git a/monas-content/src/infrastructure/mod.rs b/monas-content/src/infrastructure/mod.rs index 0ddfbf1..5166feb 100644 --- a/monas-content/src/infrastructure/mod.rs +++ b/monas-content/src/infrastructure/mod.rs @@ -2,7 +2,6 @@ pub mod content_id; pub mod encryption; pub mod key_store; pub mod key_wrapping; -pub mod member_proof; pub mod node_verification; pub mod public_key_directory; pub mod share_repository; From 169b00f4214ed4a5d0adb7a8fcd5e9267446b077 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 11:42:31 +0900 Subject: [PATCH 13/48] feat(read-integrity): add last-seen version store for read monotonicity (#55, comp B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remote_content_id -> 最後に受理した版CID を記録するクライアント側ストア。 sled 実装(キー prefix last_seen:、CEK 等と同一 DB 共有可)と in-memory 実装。 Co-Authored-By: Claude Fable 5 --- .../infrastructure/last_seen_version_store.rs | 136 ++++++++++++++++++ monas-content/src/infrastructure/mod.rs | 1 + 2 files changed, 137 insertions(+) create mode 100644 monas-content/src/infrastructure/last_seen_version_store.rs diff --git a/monas-content/src/infrastructure/last_seen_version_store.rs b/monas-content/src/infrastructure/last_seen_version_store.rs new file mode 100644 index 0000000..ba12f8d --- /dev/null +++ b/monas-content/src/infrastructure/last_seen_version_store.rs @@ -0,0 +1,136 @@ +//! Client-side store of the last version CID observed per (remote) content id, +//! backing the read monotonicity check (component B of +//! `docs/design/read-response-integrity.md`). +//! +//! A client records the newest CID-verified version it has accepted for each +//! content. On a later "latest" read it walks the returned node's verified +//! parent chain and rejects the response unless the recorded version is an +//! ancestor of (or equal to) the returned one — a regression means a relay is +//! serving a stale or rolled-back "latest". +//! +//! Keys are the *state-node* (remote) content id, because version CIDs live in +//! the state node's DAG, not the local plain-content-id space. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, thiserror::Error)] +pub enum LastSeenVersionStoreError { + #[error("last-seen version store error: {0}")] + Storage(String), +} + +/// `remote_content_id -> last accepted version CID` の永続化ポート。 +pub trait LastSeenVersionStore: Send + Sync { + fn load(&self, remote_content_id: &str) -> Result, LastSeenVersionStoreError>; + fn save( + &self, + remote_content_id: &str, + version_cid: &str, + ) -> Result<(), LastSeenVersionStoreError>; +} + +/// プロセス内 `HashMap` 実装。テスト・開発用(再起動で揮発 = 毎回 TOFU に戻る)。 +#[derive(Clone, Default)] +pub struct InMemoryLastSeenVersionStore { + inner: Arc>>, +} + +impl LastSeenVersionStore for InMemoryLastSeenVersionStore { + fn load(&self, remote_content_id: &str) -> Result, LastSeenVersionStoreError> { + let guard = self + .inner + .lock() + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; + Ok(guard.get(remote_content_id).cloned()) + } + + fn save( + &self, + remote_content_id: &str, + version_cid: &str, + ) -> Result<(), LastSeenVersionStoreError> { + let mut guard = self + .inner + .lock() + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; + guard.insert(remote_content_id.to_string(), version_cid.to_string()); + Ok(()) + } +} + +/// sled 実装。キーは `"last_seen:{remote_content_id}"`。 +/// CEK / share / pubkey ストアと同じ `sled::Db` を共有できる +/// (プレフィックスでキー空間が分離される)。 +pub struct SledLastSeenVersionStore { + db: sled::Db, +} + +impl SledLastSeenVersionStore { + pub fn with_db(db: sled::Db) -> Self { + Self { db } + } + + fn sled_key(remote_content_id: &str) -> String { + format!("last_seen:{remote_content_id}") + } +} + +impl LastSeenVersionStore for SledLastSeenVersionStore { + fn load(&self, remote_content_id: &str) -> Result, LastSeenVersionStoreError> { + let opt = self + .db + .get(Self::sled_key(remote_content_id)) + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; + opt.map(|ivec| { + String::from_utf8(ivec.to_vec()) + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string())) + }) + .transpose() + } + + fn save( + &self, + remote_content_id: &str, + version_cid: &str, + ) -> Result<(), LastSeenVersionStoreError> { + self.db + .insert(Self::sled_key(remote_content_id), version_cid.as_bytes()) + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; + self.db + .flush() + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(store: &dyn LastSeenVersionStore) { + assert!(store.load("content-a").unwrap().is_none()); + + store.save("content-a", "cid-v1").unwrap(); + assert_eq!(store.load("content-a").unwrap().as_deref(), Some("cid-v1")); + + // 上書き(版が進んだら更新される) + store.save("content-a", "cid-v2").unwrap(); + assert_eq!(store.load("content-a").unwrap().as_deref(), Some("cid-v2")); + + // 別 content には影響しない + assert!(store.load("content-b").unwrap().is_none()); + } + + #[test] + fn in_memory_roundtrip() { + roundtrip(&InMemoryLastSeenVersionStore::default()); + } + + #[test] + fn sled_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let db = sled::open(dir.path()).unwrap(); + roundtrip(&SledLastSeenVersionStore::with_db(db)); + } +} diff --git a/monas-content/src/infrastructure/mod.rs b/monas-content/src/infrastructure/mod.rs index 5166feb..b5225d7 100644 --- a/monas-content/src/infrastructure/mod.rs +++ b/monas-content/src/infrastructure/mod.rs @@ -2,6 +2,7 @@ pub mod content_id; pub mod encryption; pub mod key_store; pub mod key_wrapping; +pub mod last_seen_version_store; pub mod node_verification; pub mod public_key_directory; pub mod share_repository; From ab1296c5c5f2544806ecaa3c73fe443648103bad Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 11:42:52 +0900 Subject: [PATCH 14/48] feat(read-integrity): persist recipient CEK on share decrypt; rotate-safe revoke (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decrypt_shared_content 成功時に unwrap 済み CEK を受信者ローカル cek_store へ 保存。share 受信者も state node 経由の検証付き read で復号できるようになる。 CEK はデバイス外に出ない(state node は終始 ciphertext-only)。 - revoke_share の順序を reencrypt -> revoke に修正。従来は revoke(この時点の 旧 CEK で envelope 再発行)-> reencrypt の順で、service 層の「再暗号化後の CEK を配る」想定と逆だった(envelope を捨てていたため露見せず)。 - 再発行 envelope を RevokeShareOutput.reissued_envelopes として返却。owner が 残存受信者へ配布し、受信者が再処理すると保存済み CEK がローテーション後の ものへ上書き更新される。 Co-Authored-By: Claude Fable 5 --- monas-sdk/src/controller/share.rs | 95 +++++++++++++++++++++---------- monas-sdk/src/models/share.rs | 38 +++++++++++++ 2 files changed, 102 insertions(+), 31 deletions(-) diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index 6e056dd..898b945 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -9,7 +9,8 @@ use crate::common::{ }; use crate::models::share::{ DecryptSharedContentInput, DecryptSharedContentOutput, DelegatedAccessToken, KeyEnvelope, - Permission, RevokeShareInput, RevokeShareOutput, ShareContentInput, ShareContentOutput, + Permission, ReissuedKeyEnvelope, RevokeShareInput, RevokeShareOutput, ShareContentInput, + ShareContentOutput, }; use monas_content::application_service::content_service::{ @@ -456,27 +457,31 @@ impl MonasController { let recipient_key_id = Self::compute_key_id_from_public_key(&recipient_public_key_bytes); - // 4. ShareService::revoke_shareを呼び出し - let cmd = RevokeShareCommand { - content_id, - sender_key_id, - recipient_key_id, - }; - - let result = match self.share_service.revoke_share(cmd) { + // 4. まず CEK をローテーションして再暗号化する。 + // ShareService::revoke_share は「その時点の CEK・ciphertext」で残存受信者向け + // KeyEnvelope を再発行するため、**reencrypt が先**でないと旧 CEK の envelope を + // 配ってしまい、ローテーションの意味がなくなる + // (service 側 step 2 の「再暗号化後はここが新しい CEK になっている想定」に一致させる)。 + let reencryption = match self.content_service.reencrypt(ReencryptContentCommand { + content_id: ContentId::new(input.content_id.clone()), + }) { Ok(result) => result, Err(e) => { - // ShareService::revoke_share は share_repository を先に save してから envelope を - // 生成するため、途中で失敗した場合も ACL は既に変更されている可能性がある。 - // snapshot から share/content/cek を復元する。 - let primary = Self::map_share_error(e); + // reencrypt は途中失敗時に旧 CEK を書き戻すが、content repo 側の状態も + // 含めて確実に pre-revoke へ戻すため snapshot 復元も行う。 + // + // TODO(pr29-followup): この経路は SDK 公開 API だけでは安定して再現できないため + // integration test が存在しない。test-hook feature を導入してから + // tests/share_controller_integration_test.rs にカバレッジを追加する。 + // 参考: PR #45 commit 392d6f1 の本文。 + let primary = Self::map_reencrypt_error(e); if let Err(restore_err) = self.restore_revoke_share_snapshot(&snapshot) { return ApiResponse::error( super::combine_rollback_failure( primary, restore_err, - "Revoke", - "revoke", + "Reencrypt", + "reencrypt", "restore", ), trace_id, @@ -486,28 +491,28 @@ impl MonasController { } }; - // revoke後に再暗号し、State Nodeのバージョンを進める - let reencryption = match self.content_service.reencrypt(ReencryptContentCommand { - content_id: ContentId::new(input.content_id.clone()), - }) { + // 5. ShareService::revoke_shareを呼び出し(ACL 更新 + 残存受信者向けに + // 新 CEK・新 ciphertext で KeyEnvelope を再発行) + let cmd = RevokeShareCommand { + content_id, + sender_key_id, + recipient_key_id, + }; + + let result = match self.share_service.revoke_share(cmd) { Ok(result) => result, Err(e) => { - // reencrypt に失敗した時点で ACL は既に変更済み。 - // snapshot 復元をせずに return すると ACL だけが剥がれた中途半端な状態が残るため、 - // ここでロールバックする。 - // - // TODO(pr29-followup): この経路は SDK 公開 API だけでは安定して再現できないため - // integration test が存在しない。test-hook feature を導入してから - // tests/share_controller_integration_test.rs にカバレッジを追加する。 - // 参考: PR #45 commit 392d6f1 の本文。 - let primary = Self::map_reencrypt_error(e); + // ShareService::revoke_share は share_repository を先に save してから envelope を + // 生成するため、途中で失敗した場合も ACL は既に変更されている可能性がある。 + // 直前の reencrypt の巻き戻しも含め、snapshot から share/content/cek を復元する。 + let primary = Self::map_share_error(e); if let Err(restore_err) = self.restore_revoke_share_snapshot(&snapshot) { return ApiResponse::error( super::combine_rollback_failure( primary, restore_err, - "Reencrypt", - "reencrypt", + "Revoke", + "revoke", "restore", ), trace_id, @@ -547,11 +552,24 @@ impl MonasController { return response; } + // 残存受信者向けの再発行 envelope(新 CEK・新 ciphertext)を出力に載せる。 + // owner はこれを各受信者へ配布し、受信者が decrypt_shared_content で処理すると + // ローカル保存済み CEK がローテーション後のものへ更新される。 + let reissued_envelopes = result + .envelopes + .iter() + .map(|env| ReissuedKeyEnvelope { + recipient_key_id: encode_base64url(env.recipient().key_id().as_bytes()), + key_envelope: Self::to_key_envelope(env), + }) + .collect(); + let output = RevokeShareOutput { content_id: result.content_id.as_str().to_string(), recipient_public_key: input.recipient_public_key, revoked: true, revoked_at: Some(Utc::now().to_rfc3339()), + reissued_envelopes, }; ApiResponse::success(output, trace_id) @@ -666,7 +684,7 @@ impl MonasController { let raw_content: Vec = match self .content_service - .decrypt_with_cek(content_id.clone(), cek, ciphertext) + .decrypt_with_cek(content_id.clone(), cek.clone(), ciphertext) { Ok(content) => content, Err(e) => { @@ -683,6 +701,21 @@ impl MonasController { } }; + // 9. 復号成功 = この CEK が本物であることの確認になるので、受信者ローカルの + // cek_store に保存する。これで share 受信者も後から state node 経由の + // 検証付き read(read_content_from_state_node)で同じ content を復号できる。 + // 同一 content_id への保存は上書きなので、CEK ローテーション後に再発行された + // KeyEnvelope を処理すれば保存済み CEK も新しいものへ追従する。 + // CEK が出るのは受信者デバイスのローカルストアまでで、ネットワークには出ない。 + if let Err(e) = self.content_service.cek_store.save(&content_id, &cek) { + // 復号自体は成功しているので致命ではないが、後続の state node read が + // MissingKey で失敗する原因になるため警告は残す。 + eprintln!( + "monas-sdk: failed to persist unwrapped CEK for {} (state-node reads of this shared content will fail until a KeyEnvelope is processed again): {e}", + content_id.as_str() + ); + } + let content_base64url = encode_base64url(&raw_content); let output = DecryptSharedContentOutput { diff --git a/monas-sdk/src/models/share.rs b/monas-sdk/src/models/share.rs index acb8935..30e941e 100644 --- a/monas-sdk/src/models/share.rs +++ b/monas-sdk/src/models/share.rs @@ -95,6 +95,20 @@ pub struct RevokeShareOutput { pub revoked: bool, #[serde(skip_serializing_if = "Option::is_none")] pub revoked_at: Option, + /// 取り消し後も共有が残っている受信者向けに、ローテーション後の CEK で + /// 再発行された KeyEnvelope。呼び出し側(owner)はこれを各受信者へ配布し、 + /// 受信者は `decrypt_shared_content` で処理することでローカル保存済み CEK が + /// 新しいものへ更新される(state node 経由の read が引き続き復号できる)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reissued_envelopes: Vec, +} + +/// revoke 後に残存受信者向けへ再発行された KeyEnvelope。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReissuedKeyEnvelope { + /// 再発行先の受信者 key id(base64url) + pub recipient_key_id: String, + pub key_envelope: KeyEnvelope, } // ============================================ @@ -205,9 +219,33 @@ mod tests { recipient_public_key: "recipient_key".into(), revoked: true, revoked_at: Some("2025-12-05T12:34:56Z".into()), + reissued_envelopes: vec![], }; let json = serde_json::to_string(&output).unwrap(); assert!(json.contains("\"revoked\":true")); + // 空の envelope リストは serialize されない(後方互換) + assert!(!json.contains("reissued_envelopes")); + } + + #[test] + fn test_revoke_share_output_with_reissued_envelopes() { + let output = RevokeShareOutput { + content_id: "test_id".into(), + recipient_public_key: "recipient_key".into(), + revoked: true, + revoked_at: None, + reissued_envelopes: vec![ReissuedKeyEnvelope { + recipient_key_id: "surviving-recipient".into(), + key_envelope: KeyEnvelope { + enc: "enc".into(), + wrapped_cek: "wrapped".into(), + ciphertext: "cipher".into(), + }, + }], + }; + let json = serde_json::to_string(&output).unwrap(); + assert!(json.contains("\"reissued_envelopes\"")); + assert!(json.contains("\"recipient_key_id\":\"surviving-recipient\"")); } #[test] From 9c70265306505919b49b726eab6a7fed48a8a716 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 11:42:52 +0900 Subject: [PATCH 15/48] =?UTF-8?q?feat(read-integrity):=20verified=20read?= =?UTF-8?q?=20endpoint=20=E2=80=94=20SDK=20read=5Fcontent=5Ffrom=5Fstate?= =?UTF-8?q?=5Fnode=20+=20gateway=20/state/read=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実 read 経路。SDK が state node から Node CBOR を取得し、 1. CID 再計算で改ざん検証(A) 2. 最新読みでは単調性チェック(B): 前回受理版が CID 検証済み parents の 祖先に居なければ後退として Conflict で拒否(TOFU 初回受理、 fail-closed、fetch 上限 256)。明示版指定 read は A のみ。 3. ローカル cek_store の CEK で AES-GCM 復号 + plain CID 照合 を経て平文を返す。CEK 欠落/鍵世代ずれ/local id 不一致は対処方法が 分かるエラー(NotFound/Forbidden/Conflict)に写像。 gateway に POST /state/read を追加し auth ヘッダを転送。 Co-Authored-By: Claude Fable 5 --- monas-gateway/src/main.rs | 24 +- monas-sdk/src/controller/async_api.rs | 19 +- monas-sdk/src/controller/mod.rs | 33 +- monas-sdk/src/controller/state.rs | 478 +++++++++++++++++++++++++- monas-sdk/src/models/state.rs | 33 ++ 5 files changed, 575 insertions(+), 12 deletions(-) diff --git a/monas-gateway/src/main.rs b/monas-gateway/src/main.rs index 4649bc0..5feedf3 100644 --- a/monas-gateway/src/main.rs +++ b/monas-gateway/src/main.rs @@ -9,7 +9,9 @@ use monas_sdk::models::content::{ }; use monas_sdk::models::keypair::GenerateKeypairInput; use monas_sdk::models::share::{DecryptSharedContentInput, RevokeShareInput, ShareContentInput}; -use monas_sdk::models::state::{GetHistoryInput, GetLatestVersionInput, VerifyIntegrityInput}; +use monas_sdk::models::state::{ + GetHistoryInput, GetLatestVersionInput, ReadContentFromStateNodeInput, VerifyIntegrityInput, +}; use monas_sdk::{ generate_trace_id, ApiError, ApiResponse, MonasConfig, MonasController, StateNodeAuthContext, }; @@ -57,6 +59,7 @@ async fn main() { // state .route("/state/latest-version", post(get_latest_version)) .route("/state/history", post(get_history)) + .route("/state/read", post(read_content_from_state_node)) .route("/state/verify-integrity", post(verify_integrity)) .with_state(app_state); @@ -249,6 +252,25 @@ async fn get_history( ) } +async fn read_content_from_state_node( + State(state): State, + headers: HeaderMap, + Json(input): Json, +) -> ( + StatusCode, + Json>, +) { + let auth = match build_state_node_auth_context(&headers) { + Ok(auth) => auth, + Err(error) => return auth_error_json(error), + }; + api_json( + Arc::clone(&state.controller) + .read_content_from_state_node_async(input, Some(auth)) + .await, + ) +} + async fn verify_integrity( State(state): State, headers: HeaderMap, diff --git a/monas-sdk/src/controller/async_api.rs b/monas-sdk/src/controller/async_api.rs index e18a50f..3df38b8 100644 --- a/monas-sdk/src/controller/async_api.rs +++ b/monas-sdk/src/controller/async_api.rs @@ -28,7 +28,8 @@ use crate::models::share::{ }; use crate::models::state::{ GetHistoryInput, GetHistoryOutput, GetLatestVersionInput, GetLatestVersionOutput, - VerifyIntegrityInput, VerifyIntegrityOutput, + ReadContentFromStateNodeInput, ReadContentFromStateNodeOutput, VerifyIntegrityInput, + VerifyIntegrityOutput, }; use super::MonasController; @@ -171,6 +172,22 @@ impl MonasController { } } + /// `read_content_from_state_node` の async 版。 + pub async fn read_content_from_state_node_async( + self: Arc, + input: ReadContentFromStateNodeInput, + auth: Option, + ) -> ApiResponse { + match tokio::task::spawn_blocking(move || { + self.read_content_from_state_node(input, auth.as_ref()) + }) + .await + { + Ok(resp) => resp, + Err(e) => map_join_error(e, fallback_trace_id()), + } + } + /// `verify_integrity` の async 版。 pub async fn verify_integrity_async( self: Arc, diff --git a/monas-sdk/src/controller/mod.rs b/monas-sdk/src/controller/mod.rs index 4ca0023..2611520 100644 --- a/monas-sdk/src/controller/mod.rs +++ b/monas-sdk/src/controller/mod.rs @@ -8,6 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use content::{ContentServiceInstance, DynCekStore}; use share::{DynPublicKeyDirectory, DynShareRepository, ShareServiceInstance}; +use state::DynLastSeenStore; use crate::common::{ApiError, ApiResponse, MonasConfig, PersistenceConfig, StateNodeAuthContext}; @@ -67,6 +68,9 @@ pub struct MonasController { content_service: ContentServiceInstance, /// ShareService share_service: ShareServiceInstance, + /// content ごとに最後に受理した State Node 版 CID の記録 + /// (read 単調性チェック、`docs/design/read-response-integrity.md` コンポーネント B) + last_seen_store: DynLastSeenStore, } impl MonasController { @@ -159,7 +163,7 @@ impl MonasController { // stateless thin client and push CEK / share ownership to State Node, // or (b) define an explicit pluggable port for CEK ownership semantics. let content_repository = Self::create_content_repository(); - let (cek_store, share_repository, public_key_directory) = + let (cek_store, share_repository, public_key_directory, last_seen_store) = Self::create_persistence(&config.persistence)?; let agent = Self::build_agent(&config); @@ -178,6 +182,7 @@ impl MonasController { share_repository, public_key_directory, ), + last_seen_store, }) } @@ -210,12 +215,21 @@ impl MonasController { /// CEK / Share / Public key directory の 3 ストアに共有させる。sled は path 単位で /// 排他 flock を取るため、同じディレクトリを 2 度 open すると 2 個目が /// 失敗する (`MONAS_PERSISTENCE_DIR` 設定時の本番経路で必ず再現)。 - /// キー空間は `cek:` / `share:` / `pubkey:` プレフィックスで分離されている。 + /// キー空間は `cek:` / `share:` / `pubkey:` / `last_seen:` プレフィックスで分離されている。 fn create_persistence( persistence: &PersistenceConfig, - ) -> Result<(DynCekStore, DynShareRepository, DynPublicKeyDirectory), ApiError> { + ) -> Result< + ( + DynCekStore, + DynShareRepository, + DynPublicKeyDirectory, + DynLastSeenStore, + ), + ApiError, + > { use monas_content::infrastructure::{ key_store::{InMemoryContentEncryptionKeyStore, SledContentEncryptionKeyStore}, + last_seen_version_store::{InMemoryLastSeenVersionStore, SledLastSeenVersionStore}, public_key_directory::{InMemoryPublicKeyDirectory, SledPublicKeyDirectory}, share_repository::{InMemoryShareRepository, SledShareRepository}, }; @@ -224,13 +238,14 @@ impl MonasController { PersistenceConfig::InMemory => { eprintln!( "monas-sdk: PersistenceConfig::InMemory is in use. \ - CEK / share / public-key data are kept in memory only and will be lost on restart. \ + CEK / share / public-key / last-seen-version data are kept in memory only and will be lost on restart. \ Use MonasConfig::with_persistence_dir() for production gateways." ); let cek: DynCekStore = Arc::new(InMemoryContentEncryptionKeyStore::default()); let share: DynShareRepository = Arc::new(InMemoryShareRepository::default()); let pkd: DynPublicKeyDirectory = Arc::new(InMemoryPublicKeyDirectory::default()); - Ok((cek, share, pkd)) + let last_seen: DynLastSeenStore = Arc::new(InMemoryLastSeenVersionStore::default()); + Ok((cek, share, pkd, last_seen)) } PersistenceConfig::Sled { dir } => { if let Err(e) = std::fs::create_dir_all(dir) { @@ -239,17 +254,19 @@ impl MonasController { ))); } // sled は path 単位で flock を取るので 1 度だけ開く。 - // `sled::Db` は Arc ベースで Clone 可能なので、3 つのストアに同じ Db を渡す。 + // `sled::Db` は Arc ベースで Clone 可能なので、4 つのストアに同じ Db を渡す。 let db = sled::open(dir).map_err(|e| { ApiError::Internal(format!("failed to open sled DB at {dir:?}: {e}")) })?; let cek = SledContentEncryptionKeyStore::with_db(db.clone()); let share = SledShareRepository::with_db(db.clone()); - let pkd = SledPublicKeyDirectory::with_db(db); + let pkd = SledPublicKeyDirectory::with_db(db.clone()); + let last_seen = SledLastSeenVersionStore::with_db(db); let cek: DynCekStore = Arc::new(cek); let share: DynShareRepository = Arc::new(share); let pkd: DynPublicKeyDirectory = Arc::new(pkd); - Ok((cek, share, pkd)) + let last_seen: DynLastSeenStore = Arc::new(last_seen); + Ok((cek, share, pkd, last_seen)) } } } diff --git a/monas-sdk/src/controller/state.rs b/monas-sdk/src/controller/state.rs index fa0f82a..0ac047f 100644 --- a/monas-sdk/src/controller/state.rs +++ b/monas-sdk/src/controller/state.rs @@ -4,15 +4,85 @@ use base64::{ }; use sha2::{Digest, Sha256}; -use crate::common::{generate_trace_id, ApiError, ApiResponse, StateNodeAuthContext}; +use crate::common::{ + encode_base64url, generate_trace_id, ApiError, ApiResponse, StateNodeAuthContext, +}; use crate::models::state::{ GetHistoryInput, GetHistoryOutput, GetLatestVersionInput, GetLatestVersionOutput, - VerifyIntegrityInput, VerifyIntegrityOutput, + ReadContentFromStateNodeInput, ReadContentFromStateNodeOutput, VerifyIntegrityInput, + VerifyIntegrityOutput, }; use crate::models::state_node::{StateNodeContentDataResponse, StateNodeContentHistoryResponse}; use super::MonasController; +/// read 単調性チェックの記録先 +/// (`docs/design/read-response-integrity.md` コンポーネント B)。 +pub(super) type DynLastSeenStore = std::sync::Arc< + dyn monas_content::infrastructure::last_seen_version_store::LastSeenVersionStore, +>; + +/// 単調性チェックの祖先探索で fetch する Node 数の上限。 +/// +/// 前回 read から `MAX_MONOTONICITY_FETCHES` 版を超えて履歴が進んでいた場合、 +/// 探索は fail-closed で中断される(`AncestorWalkOutcome::BoundExceeded`)。 +/// 攻撃者が偽の深い DAG を返してクライアントに際限なく fetch させる DoS を防ぐ。 +const MAX_MONOTONICITY_FETCHES: usize = 256; + +/// `walk_ancestors_for` の結果。 +#[derive(Debug, PartialEq, Eq)] +enum AncestorWalkOutcome { + /// `target` が祖先に見つかった = 今回の版は前回受理した版の子孫(単調)。 + FoundTarget, + /// DAG を(bound 内で)出し尽くしたが `target` が祖先にいない + /// = 後退(ロールバック/stale relay の固定)。 + Exhausted, + /// fetch 上限に達した。fail-closed で拒否する。 + BoundExceeded, +} + +/// 今回読んだ版の親 CID 群から祖先 DAG を辿り、`target`(前回受理した版)が +/// 祖先に含まれるかを判定する。 +/// +/// `fetch_parents(cid)` は「その CID の Node を取得し、**CID 再計算で検証した上で** +/// parents を返す」こと。検証済みの親のみを辿ることで、攻撃者が偽の親リンクで +/// `target` を「祖先に見せかける」ことはできない(偽 Node は CID が一致しない)。 +fn walk_ancestors_for( + start_parents: &[String], + target: &str, + max_fetches: usize, + mut fetch_parents: impl FnMut(&str) -> Result, String>, +) -> Result { + use std::collections::{HashSet, VecDeque}; + + let mut visited: HashSet = HashSet::new(); + let mut frontier: VecDeque = VecDeque::new(); + for p in start_parents { + if visited.insert(p.clone()) { + frontier.push_back(p.clone()); + } + } + + let mut fetches = 0usize; + while let Some(cid) = frontier.pop_front() { + if cid == target { + return Ok(AncestorWalkOutcome::FoundTarget); + } + if fetches >= max_fetches { + return Ok(AncestorWalkOutcome::BoundExceeded); + } + fetches += 1; + let parents = fetch_parents(&cid)?; + for p in parents { + if visited.insert(p.clone()) { + frontier.push_back(p); + } + } + } + + Ok(AncestorWalkOutcome::Exhausted) +} + impl MonasController { fn validate_state_content_id(content_id: &str, trace_id: String) -> Option> { if content_id.is_empty() { @@ -222,6 +292,291 @@ impl MonasController { ) } + /// State Node の Node CBOR を取得し、CID 検証済みの親 CID リストを返す。 + /// 単調性チェックの祖先探索用フェッチャ。 + fn fetch_verified_parents( + &self, + remote_content_id: &str, + version_cid: &str, + auth: Option<&StateNodeAuthContext>, + trace_id: &str, + ) -> Result, String> { + let data = self + .get_state_node_version_data::<()>( + remote_content_id, + version_cid, + auth, + trace_id.to_string(), + ) + .map_err(|e| format!("failed to fetch ancestor node {version_cid}: {:?}", e.error))?; + + let node_bytes = BASE64_STANDARD + .decode(&data.data) + .map_err(|e| format!("invalid base64 data for ancestor node {version_cid}: {e}"))?; + + let verified = monas_content::infrastructure::node_verification::verify_and_extract( + &node_bytes, + version_cid, + ) + .map_err(|e| format!("ancestor node {version_cid} failed CID verification: {e}"))?; + + Ok(verified.parents) + } + + /// State Node から content を読み、検証・復号して平文を返す(検証付き read)。 + /// + /// `docs/design/read-response-integrity.md` の実 read 経路。処理フロー: + /// 1. `read:{content_id}:{timestamp}` 署名の認証コンテキストを解決 + /// 2. 版を決定(`input.version` 指定があればその版、無ければ履歴の最新) + /// 3. Node CBOR を取得し、CID 再計算で改ざん検証(コンポーネント A) + /// 4. 最新読みの場合のみ、単調性チェック(コンポーネント B): + /// 前回受理した版が今回の版の祖先でなければ後退として拒否 + /// 5. ローカル cek_store から CEK を引き、AES-GCM 復号 + plain CID 照合 + /// + /// CEK は「自分が作成した content」または「share の KeyEnvelope を処理済みの + /// content」(`decrypt_shared_content` が保存する)についてローカルに存在する。 + /// + /// 既知の限界(設計 §2): 正規 member 自身による stale/ロールバックのうち、 + /// クライアントが一度も見ていない範囲は検出できない(否定的事実は証明不能)。 + pub fn read_content_from_state_node( + &self, + input: ReadContentFromStateNodeInput, + auth: Option<&StateNodeAuthContext>, + ) -> ApiResponse { + let trace_id = generate_trace_id(); + + if let Some(response) = Self::validate_state_content_id(&input.content_id, trace_id.clone()) + { + return response; + } + if input.local_content_id.is_empty() { + return ApiResponse::error( + ApiError::Validation("local_content_id must not be empty".into()), + trace_id, + ); + } + + let auth = match self.resolve_state_read_auth::( + auth, + &input.content_id, + &trace_id, + ) { + Ok(resolved) => resolved, + Err(e) => return e, + }; + let auth = auth.as_ref(); + + // 版の決定。明示指定が無ければ履歴の最新を読む。 + // 履歴は署名も系列検証も無い(信頼できない)が、ここで版を「選ぶ」だけで、 + // 選ばれた版の中身は CID 検証(A)、新しさは単調性チェック(B)が守る。 + let (version, is_latest_read) = match input.version.clone() { + Some(v) => (v, false), + None => { + let history = match self.get_state_node_history::( + &input.content_id, + auth, + trace_id.clone(), + ) { + Ok(h) => h, + Err(e) => return e, + }; + let latest = history + .versions + .last() + .cloned() + .unwrap_or_else(|| input.content_id.clone()); + (latest, true) + } + }; + + // Node CBOR の取得 + CID 検証(A) + let state_node_data = match self + .get_state_node_version_data::( + &input.content_id, + &version, + auth, + trace_id.clone(), + ) { + Ok(d) => d, + Err(e) => return e, + }; + + let node_bytes = match BASE64_STANDARD.decode(&state_node_data.data) { + Ok(b) => b, + Err(e) => { + return ApiResponse::error( + ApiError::Internal(format!("invalid base64 data from state node: {e}")), + trace_id, + ); + } + }; + + let verified = match monas_content::infrastructure::node_verification::verify_and_extract( + &node_bytes, + &version, + ) { + Ok(v) => v, + Err(e) => { + return ApiResponse::error( + ApiError::Internal(format!( + "state node response failed CID verification (tampered response?): {e}" + )), + trace_id, + ); + } + }; + + // 単調性チェック(B)。最新読みのときだけ働く。版を明示指定した read は + // 「過去の版を意図的に読む」正当な操作なので、A(CID 検証)のみ。 + if is_latest_read { + if let Err(e) = self.enforce_read_monotonicity( + &input.content_id, + &version, + &verified.parents, + auth, + &trace_id, + ) { + return *e; + } + } + + // CEK ロード + AES-GCM 復号 + plain CID 照合 + let local_content_id = + monas_content::domain::content_id::ContentId::new(input.local_content_id.clone()); + let plaintext = match self.content_service.verify_and_decrypt_relay_read( + &node_bytes, + &version, + local_content_id, + ) { + Ok(read) => read.plaintext, + Err(e) => { + return ApiResponse::error( + Self::map_verified_read_error(e, &input.local_content_id), + trace_id, + ); + } + }; + + ApiResponse::success( + ReadContentFromStateNodeOutput { + content_id: input.content_id, + local_content_id: input.local_content_id, + version, + content: encode_base64url(&plaintext), + }, + trace_id, + ) + } + + /// 最新読みの単調性チェック本体。前回受理した版(`last_seen`)が今回の版の + /// 祖先(または同一)であることを、CID 検証済みの親リンクを辿って確認する。 + /// 通過したら `last_seen` を今回の版へ更新する。 + fn enforce_read_monotonicity( + &self, + remote_content_id: &str, + version: &str, + parents: &[String], + auth: Option<&StateNodeAuthContext>, + trace_id: &str, + ) -> Result<(), Box>> { + let last_seen = self.last_seen_store.load(remote_content_id).map_err(|e| { + Box::new(ApiResponse::error( + ApiError::Internal(format!("failed to load last-seen version: {e}")), + trace_id.to_string(), + )) + })?; + + match last_seen.as_deref() { + // 初回(記録なし)は TOFU で受理し、下で記録する。 + None => {} + // 同じ版を読み直しただけ。 + Some(l) if l == version => return Ok(()), + Some(l) => { + let outcome = walk_ancestors_for(parents, l, MAX_MONOTONICITY_FETCHES, |cid| { + self.fetch_verified_parents(remote_content_id, cid, auth, trace_id) + }) + .map_err(|e| { + Box::new(ApiResponse::error( + ApiError::Internal(format!("monotonicity ancestor walk failed: {e}")), + trace_id.to_string(), + )) + })?; + + match outcome { + AncestorWalkOutcome::FoundTarget => {} + AncestorWalkOutcome::Exhausted => { + return Err(Box::new(ApiResponse::error( + ApiError::Conflict(format!( + "version regression detected: state node returned {version} as latest, \ + but previously accepted version {l} is not among its ancestors \ + (possible rollback attack or stale relay)" + )), + trace_id.to_string(), + ))); + } + AncestorWalkOutcome::BoundExceeded => { + return Err(Box::new(ApiResponse::error( + ApiError::Conflict(format!( + "monotonicity check aborted: ancestor walk exceeded \ + {MAX_MONOTONICITY_FETCHES} fetches without reaching previously \ + accepted version {l}; rejecting read (fail-closed)" + )), + trace_id.to_string(), + ))); + } + } + } + } + + self.last_seen_store + .save(remote_content_id, version) + .map_err(|e| { + Box::new(ApiResponse::error( + ApiError::Internal(format!("failed to record last-seen version: {e}")), + trace_id.to_string(), + )) + }) + } + + /// `verify_and_decrypt_relay_read` のエラーを、呼び出し側が対処を判断できる + /// `ApiError` へ写像する。特に「CEK が無い」「CEK が合わない」は + /// share / rotation / revoke のどの状況かをメッセージで区別する。 + fn map_verified_read_error( + e: monas_content::application_service::content_service::VerifiedReadError, + local_content_id: &str, + ) -> ApiError { + use monas_content::application_service::content_service::{ + DecryptWithCekError, VerifiedReadError, + }; + match e { + VerifiedReadError::NodeVerification(err) => ApiError::Internal(format!( + "state node response failed CID verification (tampered response?): {err}" + )), + VerifiedReadError::KeyStore(err) => { + ApiError::Internal(format!("CEK store error: {err:?}")) + } + VerifiedReadError::MissingKey => ApiError::NotFound(format!( + "no content encryption key for local content {local_content_id} on this device: \ + the content was neither created here nor received via share on this device. \ + Process its share KeyEnvelope (POST /share/decrypt) first." + )), + VerifiedReadError::Decrypt(DecryptWithCekError::Domain(_)) => ApiError::Forbidden( + "decryption failed with the locally stored CEK: the key may be stale after a CEK \ + rotation, or your access may have been revoked. If you still have access, \ + re-process the latest share KeyEnvelope to refresh the stored CEK." + .to_string(), + ), + VerifiedReadError::Decrypt(DecryptWithCekError::ContentIdMismatch { + expected, + actual, + }) => ApiError::Conflict(format!( + "decrypted content does not match local_content_id (expected {expected}, got \ + {actual}): the content has likely been updated — pass the local content id that \ + corresponds to the version being read" + )), + } + } + /// 取得したコンテンツの整合性を検証する。 /// /// `auth` は State Node の履歴・バージ取得 API に転送する認証ヘッダ。本番では `Some` が必要。 @@ -386,3 +741,122 @@ impl MonasController { ) } } + +#[cfg(test)] +mod tests { + use super::{walk_ancestors_for, AncestorWalkOutcome}; + use std::collections::HashMap; + + /// cid -> parents のテーブルからフェッチャを作る。 + fn table_fetcher( + table: HashMap<&'static str, Vec<&'static str>>, + ) -> impl FnMut(&str) -> Result, String> { + move |cid: &str| { + table + .get(cid) + .map(|ps| ps.iter().map(|s| s.to_string()).collect()) + .ok_or_else(|| format!("unknown cid {cid}")) + } + } + + #[test] + fn finds_target_in_direct_parents_without_fetching() { + // 直接の親に target がいれば fetch は 1 度も要らない + let mut fetch_count = 0; + let outcome = walk_ancestors_for( + &["target".to_string(), "other".to_string()], + "target", + 10, + |_| { + fetch_count += 1; + Ok(vec![]) + }, + ) + .unwrap(); + assert_eq!(outcome, AncestorWalkOutcome::FoundTarget); + assert_eq!(fetch_count, 0); + } + + #[test] + fn finds_target_deeper_in_chain() { + // v3 -> v2 -> v1(target) -> genesis + let outcome = walk_ancestors_for( + &["v2".to_string()], + "v1", + 10, + table_fetcher(HashMap::from([ + ("v2", vec!["v1"]), + ("v1", vec!["genesis"]), + ("genesis", vec![]), + ])), + ) + .unwrap(); + assert_eq!(outcome, AncestorWalkOutcome::FoundTarget); + } + + #[test] + fn exhausted_when_target_not_ancestor() { + // 後退シナリオ: 古い版の祖先には新しい target がいない + let outcome = walk_ancestors_for( + &["genesis".to_string()], + "newer-version", + 10, + table_fetcher(HashMap::from([("genesis", vec![])])), + ) + .unwrap(); + assert_eq!(outcome, AncestorWalkOutcome::Exhausted); + } + + #[test] + fn exhausted_immediately_for_genesis_read() { + // genesis(親なし)を「最新」と偽られたケース: 探索なしで後退確定 + let outcome = + walk_ancestors_for(&[], "newer-version", 10, |_| panic!("must not fetch")).unwrap(); + assert_eq!(outcome, AncestorWalkOutcome::Exhausted); + } + + #[test] + fn bound_exceeded_is_fail_closed() { + // 際限なく親が続く偽 DAG は上限で打ち切る + let mut i = 0; + let outcome = walk_ancestors_for(&["n0".to_string()], "never-found", 5, |_| { + i += 1; + Ok(vec![format!("n{i}")]) + }) + .unwrap(); + assert_eq!(outcome, AncestorWalkOutcome::BoundExceeded); + } + + #[test] + fn diamond_dag_is_deduplicated() { + // merge を含む DAG(v3 の親 v2a, v2b が共通祖先 v1 を持つ)でも + // 同じノードを二度 fetch しない + let mut fetched: Vec = vec![]; + let outcome = walk_ancestors_for( + &["v2a".to_string(), "v2b".to_string()], + "genesis", + 10, + |cid: &str| { + fetched.push(cid.to_string()); + Ok(match cid { + "v2a" | "v2b" => vec!["v1".to_string()], + "v1" => vec!["genesis".to_string()], + _ => vec![], + }) + }, + ) + .unwrap(); + assert_eq!(outcome, AncestorWalkOutcome::FoundTarget); + // v1 は 1 度だけ fetch される + assert_eq!(fetched.iter().filter(|c| c.as_str() == "v1").count(), 1); + } + + #[test] + fn fetch_error_propagates() { + let err = walk_ancestors_for(&["v2".to_string()], "v1", 10, |_| { + Err("network down".to_string()) + }) + .unwrap_err(); + assert!(err.contains("network down")); + } +} diff --git a/monas-sdk/src/models/state.rs b/monas-sdk/src/models/state.rs index c50f0b4..7e5afc7 100644 --- a/monas-sdk/src/models/state.rs +++ b/monas-sdk/src/models/state.rs @@ -42,6 +42,39 @@ pub struct GetHistoryOutput { pub versions: Vec, } +// ============================================ +// read_content_from_state_node +// ============================================ + +/// State Node からの検証付き read リクエスト。 +/// +/// - `content_id`: State Node 側の content id(remote id)。履歴・版データの取得と +/// 読み取り署名(`read:{content_id}:{timestamp}`)のバインドに使う。 +/// - `local_content_id`: SDK ローカルの content id(plain CID)。CEK の引き当てと +/// 復号後の整合性チェック(平文から再計算した plain CID との一致)に使う。 +/// local↔remote の対応表は存在しないため、呼び出し側が両方を渡す +/// (`VerifyIntegrityInput` と同じ設計)。 +/// - `version`: 読む版 CID。省略時は State Node の履歴から最新版を読む。 +/// 最新読みのときのみ単調性チェック(ロールバック検出)が働く。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReadContentFromStateNodeInput { + pub content_id: String, + pub local_content_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// State Node からの検証付き read レスポンス。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReadContentFromStateNodeOutput { + pub content_id: String, + pub local_content_id: String, + /// 実際に読まれた版 CID(CID 再計算で検証済み) + pub version: String, + /// 復号済みの平文(base64url) + pub content: String, +} + // ============================================ // verify_integrity // ============================================ From 03074c28f3606d9a606c771b7b7847a19fc60933 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 11:43:08 +0900 Subject: [PATCH 16/48] test(read-integrity): state-read integration tests + Node CBOR mirror helper (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 統合テスト 5 本: 作成者 read 往復 / share 受信者 read(CEK 永続化)/ 改ざん Node 拒否 / 単調性(TOFU・前進・後退・明示版・last_seen 不変)/ CEK ローテーション追従(旧 CEK read は Forbidden -> 再発行 envelope 処理 -> read 成功) - 祖先探索 walk_ancestors_for の単体テスト 7 本(直接親・深い祖先・後退・ genesis・上限打ち切り・diamond DAG 重複排除・エラー伝播) - Node CBOR ミラー生成を tests/support へ共通化(crsl-lib パリティは monas-content 側テストで担保) - 旧「生暗号文」形式前提だった verify_integrity テストを Node CBOR に更新 Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 + monas-sdk/Cargo.toml | 4 + .../state_controller_integration_test.rs | 20 +- .../tests/state_read_integration_test.rs | 583 ++++++++++++++++++ monas-sdk/tests/support/mod.rs | 46 ++ 5 files changed, 652 insertions(+), 3 deletions(-) create mode 100644 monas-sdk/tests/state_read_integration_test.rs diff --git a/Cargo.lock b/Cargo.lock index 8480c55..4c87ae9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3193,6 +3193,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "cid", "dotenv", "hex", "mockito", @@ -3200,6 +3201,7 @@ dependencies = [ "monas-content", "monas-filesync", "serde", + "serde_cbor", "serde_json", "sha2", "sled", diff --git a/monas-sdk/Cargo.toml b/monas-sdk/Cargo.toml index b5a56c3..c4550c3 100644 --- a/monas-sdk/Cargo.toml +++ b/monas-sdk/Cargo.toml @@ -31,3 +31,7 @@ sled = "0.34" [dev-dependencies] mockito = "1.7.2" tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } +# state read の統合テストで State Node が返す Node CBOR を模擬生成するため。 +# version は monas-content (= crsl-lib) に揃える。 +serde_cbor = "0.11" +cid = { version = "0.11", features = ["serde"] } diff --git a/monas-sdk/tests/state_controller_integration_test.rs b/monas-sdk/tests/state_controller_integration_test.rs index 706a339..33aa29f 100644 --- a/monas-sdk/tests/state_controller_integration_test.rs +++ b/monas-sdk/tests/state_controller_integration_test.rs @@ -254,11 +254,25 @@ async fn verify_integrity_returns_api_error_when_version_cannot_be_fetched() { async fn verify_integrity_keeps_false_only_for_actual_content_mismatch() { let _guard = acquire_test_lock(); let mut server = Server::new_async().await; + + // State Node は Node CBOR を返す。CID 検証は通し、payload("world")と + // 引数の content("hello")の不一致だけで valid=false になることを確認する。 + let node_bytes = support::node_mirror::make_node_bytes(b"world", vec![], None); + let version_cid = + monas_content::infrastructure::node_verification::recompute_node_cid(&node_bytes).unwrap(); + let body = serde_json::json!({ + "content_id": "test-content", + "data": base64::engine::general_purpose::STANDARD.encode(&node_bytes), + "version": version_cid, + }); let version_mock = server - .mock("GET", "/content/test-content/version/v1") + .mock( + "GET", + format!("/content/test-content/version/{version_cid}").as_str(), + ) .with_status(200) .with_header("content-type", "application/json") - .with_body(r#"{"content_id":"test-content","data":"d29ybGQ=","version":"v1"}"#) + .with_body(body.to_string()) .create_async() .await; @@ -267,7 +281,7 @@ async fn verify_integrity_keeps_false_only_for_actual_content_mismatch() { VerifyIntegrityInput { content_id: "test-content".into(), content: URL_SAFE_NO_PAD.encode(b"hello"), - expected_version: Some("v1".into()), + expected_version: Some(version_cid.clone()), local_content_id: None, }, None, diff --git a/monas-sdk/tests/state_read_integration_test.rs b/monas-sdk/tests/state_read_integration_test.rs new file mode 100644 index 0000000..0e6def6 --- /dev/null +++ b/monas-sdk/tests/state_read_integration_test.rs @@ -0,0 +1,583 @@ +// Integration tests intentionally use the test/dev-only `with_urls` constructor. +#![allow(deprecated)] +//! `read_content_from_state_node`(検証付き read)の統合テスト。 +//! +//! mockito で State Node を模擬し、以下を検証する: +//! - 作成者が自分の content を state node 経由で読み、平文まで復号できる(A + 復号) +//! - share 受信者が KeyEnvelope 処理後に同じ content を読める(CEK 永続化) +//! - KeyEnvelope 未処理の受信者は MissingKey 由来の NotFound で誘導される +//! - 改ざんされた Node(CID 不一致)は拒否される(A) +//! - 版の後退(ロールバック)は拒否され、前進・同一版・明示版指定は通る(B) +//! +//! State Node が返す Node CBOR は crsl-lib `Node` と同じ CBOR 形状のミラー構造体で +//! 生成する(ミラーの正しさは monas-content 側の crsl-lib パリティテストで担保)。 + +use base64::{ + engine::general_purpose::STANDARD as BASE64_STANDARD, engine::general_purpose::URL_SAFE_NO_PAD, + Engine, +}; +use mockito::{Mock, Server, ServerGuard}; +use monas_content::infrastructure::node_verification::recompute_node_cid; +use monas_sdk::models::content::{ContentMetadata, CreateContentInput}; +use monas_sdk::models::keypair::{GenerateKeypairInput, KeyType}; +use monas_sdk::models::share::{DecryptSharedContentInput, Permission, ShareContentInput}; +use monas_sdk::models::state::ReadContentFromStateNodeInput; +use monas_sdk::{ApiError, MonasController}; + +mod support; +use support::{acquire_test_lock, cleanup_content_artifacts, node_mirror::make_node_bytes}; + +const REMOTE_ID: &str = "state-read-remote"; + +async fn mock_history(server: &mut ServerGuard, versions: &[&str]) -> Mock { + let body = serde_json::json!({ + "content_id": REMOTE_ID, + "versions": versions, + }); + server + .mock("GET", format!("/content/{REMOTE_ID}/history").as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .create_async() + .await +} + +async fn mock_version_data(server: &mut ServerGuard, version: &str, node_bytes: &[u8]) -> Mock { + let body = serde_json::json!({ + "content_id": REMOTE_ID, + "data": BASE64_STANDARD.encode(node_bytes), + "version": version, + }); + server + .mock( + "GET", + format!("/content/{REMOTE_ID}/version/{version}").as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .create_async() + .await +} + +/// content を作成し、share 経由で実際の AES-GCM 暗号文を入手する +/// (ciphertext を SDK の外に取り出す公開経路が share envelope しかないため)。 +/// 戻り値: (local_content_id, ciphertext, share 出力, sender/recipient keypair) +struct CreatedContent { + local_content_id: String, + ciphertext: Vec, + shared: monas_sdk::models::share::ShareContentOutput, + recipient_private_key: String, +} + +async fn create_and_share( + server: &mut ServerGuard, + controller: &MonasController, + plaintext: &[u8], +) -> CreatedContent { + let create_mock = server + .mock("POST", "/content") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!(r#"{{"content_id":"{REMOTE_ID}"}}"#)) + .create_async() + .await; + let delegate_mock = server + .mock("POST", "/issuer/delegate") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"delegated_token":"dummy.jwt.token","issued_at":1700000000,"expires_at":1700003600,"jti":"jti-1"}"#, + ) + .create_async() + .await; + + let sender = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("sender keypair"); + let recipient = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("recipient keypair"); + + let create_response = controller.create_content( + CreateContentInput { + content: URL_SAFE_NO_PAD.encode(plaintext), + metadata: Some(ContentMetadata { + name: Some("state-read.txt".to_string()), + content_type: Some("text/plain".to_string()), + created_at: None, + updated_at: None, + }), + }, + None, + ); + assert!( + create_response.success, + "create_content should succeed: {:?}", + create_response.error + ); + let created = create_response.data.expect("create should return data"); + create_mock.assert(); + + let share_response = controller.share_content(ShareContentInput { + content_id: created.content_id.clone(), + sender_public_key: sender.public_key.clone(), + recipient_public_key: recipient.public_key.clone(), + permissions: vec![Permission::Read], + }); + assert!( + share_response.success, + "share_content should succeed: {:?}", + share_response.error + ); + let shared = share_response.data.expect("share should return data"); + delegate_mock.assert(); + + let ciphertext = URL_SAFE_NO_PAD + .decode(&shared.key_envelope.ciphertext) + .expect("envelope ciphertext should be base64url"); + + CreatedContent { + local_content_id: created.content_id, + ciphertext, + shared, + recipient_private_key: recipient.private_key, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn creator_reads_own_content_from_state_node() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + let controller = MonasController::with_urls(server.url(), server.url()); + + let plaintext = b"state-read-roundtrip"; + let created = create_and_share(&mut server, &controller, plaintext).await; + + let genesis_bytes = make_node_bytes(&created.ciphertext, vec![], None); + let genesis_cid = recompute_node_cid(&genesis_bytes).unwrap(); + + let history_mock = mock_history(&mut server, &[&genesis_cid]).await; + let data_mock = mock_version_data(&mut server, &genesis_cid, &genesis_bytes).await; + + let response = controller.read_content_from_state_node( + ReadContentFromStateNodeInput { + content_id: REMOTE_ID.into(), + local_content_id: created.local_content_id.clone(), + version: None, + }, + None, + ); + assert!( + response.success, + "read should succeed: {:?}", + response.error + ); + let output = response.data.expect("read should return data"); + assert_eq!(output.version, genesis_cid); + assert_eq!( + URL_SAFE_NO_PAD.decode(output.content).unwrap(), + plaintext, + "decrypted content should round-trip" + ); + history_mock.assert(); + data_mock.assert(); + + cleanup_content_artifacts(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn share_recipient_reads_content_after_processing_envelope() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + let creator = MonasController::with_urls(server.url(), server.url()); + + let plaintext = b"shared-then-read"; + let created = create_and_share(&mut server, &creator, plaintext).await; + + let genesis_bytes = make_node_bytes(&created.ciphertext, vec![], None); + let genesis_cid = recompute_node_cid(&genesis_bytes).unwrap(); + + // 受信者は別インスタンス(= 別デバイス相当。ローカル content も CEK も無い) + let recipient_controller = MonasController::with_urls(server.url(), server.url()); + + // KeyEnvelope 未処理の状態では CEK が無く、NotFound で share 処理へ誘導される + // (read は前後 2 回行うので、mock は 2 ヒットを期待する) + let history_mock = mock_history(&mut server, &[&genesis_cid]) + .await + .expect_at_least(1); + let data_mock = mock_version_data(&mut server, &genesis_cid, &genesis_bytes) + .await + .expect_at_least(1); + let before = recipient_controller.read_content_from_state_node( + ReadContentFromStateNodeInput { + content_id: REMOTE_ID.into(), + local_content_id: created.local_content_id.clone(), + version: None, + }, + None, + ); + assert!(!before.success, "read without CEK should fail"); + match before.error { + Some(ApiError::NotFound(msg)) => { + assert!(msg.contains("KeyEnvelope"), "msg should guide user: {msg}") + } + other => panic!("expected NotFound, got: {other:?}"), + } + + // KeyEnvelope を処理すると CEK が受信者ローカルに永続化される + let decrypt_response = recipient_controller.decrypt_shared_content(DecryptSharedContentInput { + content_id: created.local_content_id.clone(), + private_key: created.recipient_private_key.clone(), + sender_key_id: created.shared.sender_key_id.clone(), + recipient_key_id: created.shared.recipient_key_id.clone(), + key_envelope: created.shared.key_envelope.clone(), + version: None, + }); + assert!( + decrypt_response.success, + "decrypt_shared_content should succeed: {:?}", + decrypt_response.error + ); + + // 以後は state node 経由の検証付き read で読める + let after = recipient_controller.read_content_from_state_node( + ReadContentFromStateNodeInput { + content_id: REMOTE_ID.into(), + local_content_id: created.local_content_id.clone(), + version: None, + }, + None, + ); + assert!( + after.success, + "read after envelope processing should succeed: {:?}", + after.error + ); + assert_eq!( + URL_SAFE_NO_PAD.decode(after.data.unwrap().content).unwrap(), + plaintext + ); + history_mock.assert_async().await; + data_mock.assert_async().await; + + cleanup_content_artifacts(); +} + +/// CEK ローテーションの追従: revoke で CEK が回転した後、 +/// - 旧 CEK しか持たない受信者の read は Forbidden(鍵が古い)で落ち、 +/// - revoke 出力の再発行 KeyEnvelope を処理すると保存 CEK が更新され、 +/// - 以後の state node read が新 ciphertext を復号できる。 +#[tokio::test(flavor = "multi_thread")] +async fn cek_rotation_after_revoke_updates_recipient_and_read() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + let creator = MonasController::with_urls(server.url(), server.url()); + + let _create_mock = server + .mock("POST", "/content") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!(r#"{{"content_id":"{REMOTE_ID}"}}"#)) + .create_async() + .await; + let _delegate_mock = server + .mock("POST", "/issuer/delegate") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"delegated_token":"dummy.jwt.token","issued_at":1700000000,"expires_at":1700003600,"jti":"jti-1"}"#, + ) + .expect_at_least(1) + .create_async() + .await; + + let sender = creator + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("sender keypair"); + let revoked_recipient = creator + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("revoked recipient keypair"); + let surviving_recipient = creator + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("surviving recipient keypair"); + + let plaintext = b"rotation-target-content"; + let create_response = creator.create_content( + CreateContentInput { + content: URL_SAFE_NO_PAD.encode(plaintext), + metadata: Some(ContentMetadata { + name: Some("rotation.txt".to_string()), + content_type: Some("text/plain".to_string()), + created_at: None, + updated_at: None, + }), + }, + None, + ); + assert!(create_response.success, "{:?}", create_response.error); + let created = create_response.data.unwrap(); + + // 2 名に share(片方を後で revoke する) + let share_to = |recipient_pub: &str| { + creator.share_content(ShareContentInput { + content_id: created.content_id.clone(), + sender_public_key: sender.public_key.clone(), + recipient_public_key: recipient_pub.to_string(), + permissions: vec![Permission::Read], + }) + }; + let share_revoked = share_to(&revoked_recipient.public_key); + assert!(share_revoked.success, "{:?}", share_revoked.error); + let share_surviving = share_to(&surviving_recipient.public_key); + assert!(share_surviving.success, "{:?}", share_surviving.error); + let shared_surviving = share_surviving.data.unwrap(); + + // 残存受信者(別デバイス)が旧 CEK の envelope を処理 + let recipient_controller = MonasController::with_urls(server.url(), server.url()); + let decrypt_v1 = recipient_controller.decrypt_shared_content(DecryptSharedContentInput { + content_id: created.content_id.clone(), + private_key: surviving_recipient.private_key.clone(), + sender_key_id: shared_surviving.sender_key_id.clone(), + recipient_key_id: shared_surviving.recipient_key_id.clone(), + key_envelope: shared_surviving.key_envelope.clone(), + version: None, + }); + assert!(decrypt_v1.success, "{:?}", decrypt_v1.error); + + // revoke → CEK ローテーション + 残存受信者向け envelope 再発行 + let _update_mock = server + .mock("PUT", format!("/content/{REMOTE_ID}").as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!(r#"{{"content_id":"{REMOTE_ID}","updated":true}}"#)) + .create_async() + .await; + let revoke_response = creator.revoke_share( + monas_sdk::models::share::RevokeShareInput { + content_id: created.content_id.clone(), + remote_content_id: Some(REMOTE_ID.into()), + sender_public_key: sender.public_key.clone(), + recipient_public_key: revoked_recipient.public_key.clone(), + }, + None, + ); + assert!(revoke_response.success, "{:?}", revoke_response.error); + let revoked_output = revoke_response.data.unwrap(); + assert_eq!( + revoked_output.reissued_envelopes.len(), + 1, + "one surviving recipient should get a reissued envelope" + ); + let reissued = &revoked_output.reissued_envelopes[0]; + assert_eq!( + reissued.recipient_key_id, shared_surviving.recipient_key_id, + "reissued envelope should target the surviving recipient" + ); + assert_ne!( + reissued.key_envelope.ciphertext, shared_surviving.key_envelope.ciphertext, + "ciphertext must change after CEK rotation" + ); + + // ローテーション後の ciphertext で state node の新版 v2 を用意 + let old_ciphertext = URL_SAFE_NO_PAD + .decode(&shared_surviving.key_envelope.ciphertext) + .unwrap(); + let new_ciphertext = URL_SAFE_NO_PAD + .decode(&reissued.key_envelope.ciphertext) + .unwrap(); + let genesis_bytes = make_node_bytes(&old_ciphertext, vec![], None); + let genesis_cid = recompute_node_cid(&genesis_bytes).unwrap(); + let v2_bytes = make_node_bytes(&new_ciphertext, vec![&genesis_cid], Some(&genesis_cid)); + let v2_cid = recompute_node_cid(&v2_bytes).unwrap(); + + let _history_mock = mock_history(&mut server, &[&genesis_cid, &v2_cid]).await; + let _v2_data = mock_version_data(&mut server, &v2_cid, &v2_bytes).await; + + let read_latest = || { + recipient_controller.read_content_from_state_node( + ReadContentFromStateNodeInput { + content_id: REMOTE_ID.into(), + local_content_id: created.content_id.clone(), + version: None, + }, + None, + ) + }; + + // 旧 CEK のままでは新 ciphertext を復号できない(鍵が古い旨のエラーで誘導) + let stale_read = read_latest(); + assert!(!stale_read.success, "stale-CEK read must fail"); + match stale_read.error { + Some(ApiError::Forbidden(msg)) => { + assert!( + msg.contains("rotation") || msg.contains("revoked"), + "msg={msg}" + ) + } + other => panic!("expected Forbidden(stale CEK), got: {other:?}"), + } + + // 再発行 envelope を処理 → 保存 CEK がローテーション後のものへ更新される + let decrypt_v2 = recipient_controller.decrypt_shared_content(DecryptSharedContentInput { + content_id: created.content_id.clone(), + private_key: surviving_recipient.private_key.clone(), + sender_key_id: shared_surviving.sender_key_id.clone(), + recipient_key_id: reissued.recipient_key_id.clone(), + key_envelope: reissued.key_envelope.clone(), + version: None, + }); + assert!(decrypt_v2.success, "{:?}", decrypt_v2.error); + + // 以後の read は新 ciphertext を復号できる + let fresh_read = read_latest(); + assert!(fresh_read.success, "{:?}", fresh_read.error); + assert_eq!( + URL_SAFE_NO_PAD + .decode(fresh_read.data.unwrap().content) + .unwrap(), + plaintext + ); + + cleanup_content_artifacts(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn read_rejects_tampered_node() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + let controller = MonasController::with_urls(server.url(), server.url()); + + let created = create_and_share(&mut server, &controller, b"tamper-target").await; + + let genesis_bytes = make_node_bytes(&created.ciphertext, vec![], None); + let genesis_cid = recompute_node_cid(&genesis_bytes).unwrap(); + + // 攻撃者が偽 ciphertext の Node を正規版 CID を騙って返す + let forged_bytes = make_node_bytes(b"forged-by-attacker", vec![], None); + + let _history_mock = mock_history(&mut server, &[&genesis_cid]).await; + let _data_mock = mock_version_data(&mut server, &genesis_cid, &forged_bytes).await; + + let response = controller.read_content_from_state_node( + ReadContentFromStateNodeInput { + content_id: REMOTE_ID.into(), + local_content_id: created.local_content_id.clone(), + version: None, + }, + None, + ); + assert!(!response.success, "tampered node must be rejected"); + match response.error { + Some(ApiError::Internal(msg)) => { + assert!(msg.contains("CID verification"), "msg={msg}") + } + other => panic!("expected Internal(CID verification), got: {other:?}"), + } + + cleanup_content_artifacts(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn read_monotonicity_accepts_forward_and_rejects_regression() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + let controller = MonasController::with_urls(server.url(), server.url()); + + let plaintext = b"monotonic-content"; + let created = create_and_share(&mut server, &controller, plaintext).await; + + // g(genesis) → v2(child) のチェーン。ciphertext は同一(再暗号化なしの + // no-op update 相当)なので、どの版も同じ CEK・同じ plain CID で復号できる。 + let genesis_bytes = make_node_bytes(&created.ciphertext, vec![], None); + let genesis_cid = recompute_node_cid(&genesis_bytes).unwrap(); + let v2_bytes = make_node_bytes(&created.ciphertext, vec![&genesis_cid], Some(&genesis_cid)); + let v2_cid = recompute_node_cid(&v2_bytes).unwrap(); + + let _g_data = mock_version_data(&mut server, &genesis_cid, &genesis_bytes).await; + let _v2_data = mock_version_data(&mut server, &v2_cid, &v2_bytes).await; + + let read_latest = |controller: &MonasController| { + controller.read_content_from_state_node( + ReadContentFromStateNodeInput { + content_id: REMOTE_ID.into(), + local_content_id: created.local_content_id.clone(), + version: None, + }, + None, + ) + }; + + // 1. 初回(TOFU): latest = g を受理、last_seen = g + let history_g = mock_history(&mut server, &[&genesis_cid]).await; + let first = read_latest(&controller); + assert!(first.success, "TOFU read should succeed: {:?}", first.error); + history_g.assert_async().await; + history_g.remove_async().await; + + // 2. 前進: latest = v2(parents に g)→ 受理、last_seen = v2 + let history_v2 = mock_history(&mut server, &[&genesis_cid, &v2_cid]).await; + let forward = read_latest(&controller); + assert!( + forward.success, + "forward read should succeed: {:?}", + forward.error + ); + assert_eq!(forward.data.unwrap().version, v2_cid); + history_v2.assert_async().await; + history_v2.remove_async().await; + + // 3. 後退: latest と偽って g を返す → v2 は g の祖先に居ないので拒否 + let history_rollback = mock_history(&mut server, &[&genesis_cid]).await; + let regression = read_latest(&controller); + assert!(!regression.success, "regression must be rejected"); + match regression.error { + Some(ApiError::Conflict(msg)) => { + assert!(msg.contains("version regression"), "msg={msg}") + } + other => panic!("expected Conflict(version regression), got: {other:?}"), + } + history_rollback.remove_async().await; + + // 4. 明示版指定の read は「過去の版を意図的に読む」操作なので B の対象外 + let pinned = controller.read_content_from_state_node( + ReadContentFromStateNodeInput { + content_id: REMOTE_ID.into(), + local_content_id: created.local_content_id.clone(), + version: Some(genesis_cid.clone()), + }, + None, + ); + assert!( + pinned.success, + "pinned old-version read should succeed: {:?}", + pinned.error + ); + + // 5. 明示版読みは last_seen を動かさない: latest = v2 は引き続き受理される + let _history_v2_again = mock_history(&mut server, &[&genesis_cid, &v2_cid]).await; + let still_latest = read_latest(&controller); + assert!( + still_latest.success, + "latest read after pinned read should succeed: {:?}", + still_latest.error + ); + + cleanup_content_artifacts(); +} diff --git a/monas-sdk/tests/support/mod.rs b/monas-sdk/tests/support/mod.rs index 50c2a8c..3f67e8f 100644 --- a/monas-sdk/tests/support/mod.rs +++ b/monas-sdk/tests/support/mod.rs @@ -47,3 +47,49 @@ pub fn cleanup_content_artifacts() { } } } + +/// crsl-lib の `Node` と同じ CBOR 形状になる +/// ミラー(フィールド名・順序を一致させる)。ミラーの正しさは monas-content の +/// node_verification にある crsl-lib パリティテストで担保されている。 +/// State Node の read 応答(Node CBOR)をテストで模擬生成するために使う。 +#[allow(dead_code)] +pub mod node_mirror { + use cid::Cid; + use serde::Serialize; + + #[derive(Serialize)] + struct TestPayload { + data: Vec, + access_policy: Option<()>, + } + #[derive(Serialize)] + struct TestMetadata { + policy_type: Option, + } + #[derive(Serialize)] + struct TestNode { + payload: TestPayload, + parents: Vec, + genesis: Option, + timestamp: u64, + metadata: TestMetadata, + } + + pub fn make_node_bytes( + ciphertext: &[u8], + parents: Vec<&str>, + genesis: Option<&str>, + ) -> Vec { + let node = TestNode { + payload: TestPayload { + data: ciphertext.to_vec(), + access_policy: None, + }, + parents: parents.iter().map(|p| p.parse().unwrap()).collect(), + genesis: genesis.map(|g| g.parse().unwrap()), + timestamp: 42, + metadata: TestMetadata { policy_type: None }, + }; + serde_cbor::to_vec(&node).unwrap() + } +} From fa8c12a6ab66c7c05b72cda84dfe5e89859a021c Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 11:43:08 +0900 Subject: [PATCH 17/48] docs(design): mark read-response-integrity implemented; record CEK scope decision (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit share 経由 read を本 PR に含める決定(ユーザー確認済み)、CEK 即時破棄が セキュリティ前提でないこと(revoke の安全性は CEK ローテーション由来)、 revoke 順序修正と reissued_envelopes の追加を反映。 Co-Authored-By: Claude Fable 5 --- .../design/read-response-integrity-HANDOFF.md | 27 +++++++++++++------ docs/design/read-response-integrity.md | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/design/read-response-integrity-HANDOFF.md b/docs/design/read-response-integrity-HANDOFF.md index feaed1e..b034364 100644 --- a/docs/design/read-response-integrity-HANDOFF.md +++ b/docs/design/read-response-integrity-HANDOFF.md @@ -1,6 +1,11 @@ # read-response-integrity 実装ハンドオフ(別セッション再開用) -最終更新: 2026-07-18。このファイルだけ読めば、別セッションで作業を再開できるように書いた。 +最終更新: 2026-07-18(実装完了)。このファイルだけ読めば、別セッションで作業を再開できるように書いた。 + +> **【2026-07-18 更新】実装は完了した。** C の revert・実 read エンドポイント・単調性チェック(B)・ +> share 受信者の CEK 永続化・revoke 順序修正(reencrypt を先に)+ 再発行 envelope の返却まで実装済み。 +> ユーザー決定により §6.3 の切り分け案は採らず、**share 経由 read も含めて本 PR で実装**した。 +> 残りは PR 作成のみ。詳細は git log と `docs/design/read-response-integrity.md` を参照。 --- @@ -129,11 +134,13 @@ PR #54(read relay)に対するセキュリティ指摘(issue #55)への対応。 - [x] **A クライアント**: monas-content で CID 再計算・検証 + crsl-lib パリティ(`ec26c00`) - [x] **read 形式統一 + E2E verify-decrypt コア + verify_integrity 修正**(`7970b35`) - [x] **設計訂正 + ハンドオフ doc**(`b41f454`) -- [ ] **C を revert**: `git revert 3a64a5a`(member 証明は設計上不要) -- [ ] **B 単調性チェック**: SDK sled に last_seen 記録 + parents 祖先判定(§5.1) -- [ ] **実 read エンドポイント**: SDK 新メソッド + gateway auth 転送 + CEK 入手(§5.2)。**これが無いと「実際に使えない」** -- [ ] **テスト**: A 改ざん拒否 / B 後退拒否・初回受理 / e2e-test.sh 更新(§5.3) -- [ ] **build/test/clippy/fmt green**(Rust 1.97 の clippy で確認、§5.3) +- [x] **C を revert**: `git revert 3a64a5a`(member 証明は設計上不要) +- [x] **B 単調性チェック**: last_seen ストア(sled/in-memory)+ CID 検証済み parents の祖先探索(fail-closed、上限 256 fetch)。最新読みのみ適用、明示版指定 read は A のみ +- [x] **実 read エンドポイント**: SDK `read_content_from_state_node` + gateway `POST /state/read`(auth 転送) +- [x] **share 受信者の CEK 永続化**: `decrypt_shared_content` 成功時に unwrap 済み CEK を受信者ローカル cek_store へ保存(rotation 時は新 envelope 処理で上書き追従) +- [x] **revoke 順序修正**: SDK が revoke → reencrypt の順で呼んでいた(service の想定と逆で、旧 CEK の envelope を生成していた)のを reencrypt → revoke に修正。再発行 envelope を `RevokeShareOutput.reissued_envelopes` で返すようにした(残存受信者への新 CEK 配布経路) +- [x] **テスト**: A 改ざん拒否 / B TOFU・前進・後退・明示版 / 受信者 read / rotation 追従 / walk 単体(SDK 統合 5 + 単体 7)。旧形式前提だった verify_integrity テストも Node CBOR に更新。e2e-test.sh は `.data` 有無のみ見る形式非依存 assert のため変更不要 +- [x] **build/test/clippy/fmt green**(workspace 全テスト + Rust 1.97 clippy --deny warnings) - [ ] **PR 作成**: base = `fix/state-node-read-relay`(#54)。本文に「A+B のみ、member 証明は不採用」明記 ## 6.2 ユーザーからの確定事項(セッション履歴より) @@ -145,9 +152,13 @@ PR #54(read relay)に対するセキュリティ指摘(issue #55)への対応。 - **member 証明は不要**(§2。owner は membership を知り得ない)。 - Fable 5 モデルを使い続ける。 -## 6.3 実 read 経路で残っている設計判断(§5.2 の CEK 問題) +## 6.3 実 read 経路の CEK 問題(→ 解決済み) + +share で受け取った content は、unwrap した CEK がどこにも保存されなかった(`decrypt_shared_content` は即復号のみ)。**ユーザー判断(2026-07-18)で「share 経由 read も含めて本 PR で production レベル実装」に決定**し、以下で解決した: -share で受け取った content は、unwrap した CEK が現状どこにも保存されない(`decrypt_shared_content` は即復号のみ)。実 read 経路で share 済み content も読めるようにするなら、unwrap 済み CEK を `cek_store.save` する経路が別途要る。**自分が作成者の content なら `cek_store.load(local_id)` で足りる**ので、初版は「作成者による自 content の read」に絞り、share 経由 read は別途、という切り分けも可(実装時にユーザー判断を仰ぐ)。 +- `decrypt_shared_content` 成功時(= CEK の正しさが復号で証明された後)に、unwrap 済み CEK を**受信者デバイスのローカル cek_store** へ保存。CEK も平文もネットワーク・state node には一切出ない(E2E 暗号化の思想は不変。state node は終始 ciphertext-only)。 +- CEK ローテーション(revoke 時の reencrypt)への追従: revoke で再発行された KeyEnvelope(`RevokeShareOutput.reissued_envelopes`)を受信者が再処理すると、保存済み CEK が上書き更新される。旧 CEK のまま新 ciphertext を読むと `Forbidden`(鍵が古い/revoke の可能性を示すメッセージ)で誘導される。 +- 「即時破棄」は意図的なセキュリティ前提ではないことを確認済み: revoke の安全性は受信者の鍵破棄ではなく **CEK ローテーション**(reencrypt + 残存者への再発行)に依存する設計。 --- diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md index 436dbb1..fc30776 100644 --- a/docs/design/read-response-integrity.md +++ b/docs/design/read-response-integrity.md @@ -1,6 +1,6 @@ # read 経路の完全性: 応答データの E2E 検証 -- ステータス: **【2026-07-18 重要訂正】member 証明(C)を廃止。A(版真正性)+ B(単調性)に縮小。実装途中。** +- ステータス: **【2026-07-18】実装完了。member 証明(C)は廃止し、A(版真正性)+ B(単調性)+ 実 read 経路(share 受信者の CEK 永続化・rotation 追従含む)を実装。** - 関連: PR #54、issue #55 - 前提ブランチ: `feature/read-response-signing`(#54 の上に積む) - **再開手順は `docs/design/read-response-integrity-HANDOFF.md` を参照。** From 5f18e5ec7a516b5f7d155a4844cd8305c4d24a3c Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 12:10:46 +0900 Subject: [PATCH 18/48] docs(design): remove session handoff doc; rewrite design as final-form documentation (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HANDOFF はセッション引き継ぎ用の作業メモ(環境固有パス・作業ログ・ チェックリスト)でありリポジトリに残す内容ではないため削除。 設計本体は作業過程の訂正履歴構造を排し、最終形のみ (脅威モデル / A・B の設計 / member 証明不採用の理由 / 実 read 経路と CEK ライフサイクル / 既知の限界)に書き直した。経緯は git history を参照。 Co-Authored-By: Claude Fable 5 --- .../design/read-response-integrity-HANDOFF.md | 174 ------- docs/design/read-response-integrity.md | 433 +++++------------- 2 files changed, 110 insertions(+), 497 deletions(-) delete mode 100644 docs/design/read-response-integrity-HANDOFF.md diff --git a/docs/design/read-response-integrity-HANDOFF.md b/docs/design/read-response-integrity-HANDOFF.md deleted file mode 100644 index b034364..0000000 --- a/docs/design/read-response-integrity-HANDOFF.md +++ /dev/null @@ -1,174 +0,0 @@ -# read-response-integrity 実装ハンドオフ(別セッション再開用) - -最終更新: 2026-07-18(実装完了)。このファイルだけ読めば、別セッションで作業を再開できるように書いた。 - -> **【2026-07-18 更新】実装は完了した。** C の revert・実 read エンドポイント・単調性チェック(B)・ -> share 受信者の CEK 永続化・revoke 順序修正(reencrypt を先に)+ 再発行 envelope の返却まで実装済み。 -> ユーザー決定により §6.3 の切り分け案は採らず、**share 経由 read も含めて本 PR で実装**した。 -> 残りは PR 作成のみ。詳細は git log と `docs/design/read-response-integrity.md` を参照。 - ---- - -## 0. 一言サマリ - -PR #54(read relay)に対するセキュリティ指摘(issue #55)への対応。read 応答の完全性を、**メタデータ機密性(誰がどの content を持つか)を晒さずに**足す。 - -**最重要の設計訂正(2026-07-18)**: 当初計画にあった「owner 発行の member 証明」(コンポーネント C)は**廃止**。理由 → §2。現在の正しい設計は **A(版真正性)+ B(単調性)のみ**。 - -- 作業ブランチ: `feature/read-response-signing`(base = `fix/state-node-read-relay` = PR #54) -- PR 向き先: **`fix/state-node-read-relay`(#54)**。まだ PR は作っていない。 -- 前提: production 利用ゼロ(テストのみ)。**後方互換不要・破壊的変更 OK・1 PR**。 -- モデル: Fable 5 を使い続ける(ユーザー指示、メモリ `use-fable-5-model` 参照)。 -- 設計本体: `docs/design/read-response-integrity.md`(冒頭に訂正あり)。 - ---- - -## 1. 何を防ぐか(訂正後) - -#54 で対処済み: credential の content_id バインド、AES-GCM(暗号文本文の改ざん検知)。 - -本作業で足すのは以下(A + B のみ): - -| 攻撃 | 防御 | 状態 | -|---|---|---| -| 非 member が偽データ/偽版を返す | **A: Node CBOR + CID 再計算**。攻撃者は正しい CID を持つ偽 Node を作れない | ✅ コア実装済み | -| ロールバック(過去の本物の版を最新と偽る) | **B: 単調性チェック**(前回見た版より祖先へ後退したら拒否) | ❌ 未実装 | - -**防がない(既知の限界、脅威モデルに明記)**: 正規 member 自身による stale/ロールバック(否定的事実「より新しい版が無い」はネットワーク越しに証明不能)。 - ---- - -## 2. ⚠️ なぜ member 証明(C)を廃止したか - -当初 §5.1.b で「owner が member 追加時に証明トークン(ES256 JWT, aud=node, can=host)を発行し、node が read 応答に添付、client が owner 鍵で検証」を採用した。**これは誤り**: - -- **owner は誰が member かを知らないし、知り得ない**。member は DHT 複製配置・`add_member_to_content` で **owner の関与なく自律的に増減・入れ替わる**。「owner が member 追加時に発行」という経路が Monas に存在しない。 -- 「署名の根が owner(read 認可)」と「member を認定するのが owner」は別問題。混同していた。 - -**結論**: member であることの確認は不要。データが CID で検証できれば、返した相手が誰でもよい(A で完結)。→ C は全面廃止。 - ---- - -## 3. コミット状況(このブランチ、`main..HEAD`) - -設計ドキュメント(9 コミット、`3f80337`〜`ffe1db3`)は省略。実装コミットは以下: - -1. `ec26c00` **A(サーバ + クライアント検証コア)** — 保持 -2. `7970b35` **read 形式統一 + E2E verify-decrypt コア + verify_integrity 修正** — 保持 -3. `3a64a5a` **C(member 証明)** — **⚠️ revert する**(§2) - -`main..HEAD` の base コミット(`361bcc6` 以前)は #54 の中身。 - ---- - -## 4. 実装済みの中身(保持するもの) - -### 4.1 コンポーネント A — 版真正性(完了・パリティ実証済み) - -**state-node 側**(`ec26c00`, `7970b35`): -- `monas-state-node/src/port/content_repository.rs`: trait に `get_latest_node_bytes_with_version` / `get_version_node_bytes` 追加(Node CBOR を返す)。 -- `monas-state-node/src/infrastructure/crdt_repository.rs`: 実装(`node.to_bytes()` = CBOR を返す)。 -- `monas-state-node/src/test_utils.rs` / `infrastructure/auth/ucan_adapter.rs`: モック実装追加。 -- `monas-state-node/src/application_service/state_node_service.rs`: `read_content_via_relay` が新メソッドを使い Node CBOR を返す。 -- `monas-state-node/src/presentation/http_api.rs`: `/content/:id/data` と `/content/:id/version/:version` の **local 分岐も relay 分岐も Node CBOR を返すよう統一**(client がどちらでも同じ形式を検証)。`version` フィールドを必ず埋める。 - -**client 側**(`monas-content`): -- `monas-content/src/infrastructure/node_verification.rs`(新規): `recompute_node_cid`(CBOR → SHA-256 → CIDv1 RAW/SHA2-256)+ `verify_and_extract(node_bytes, expected_version_cid) -> VerifiedNode{ciphertext, parents}`。CID 不一致で拒否。 - - **crsl-lib パリティテスト済み**: 本物の crsl-lib `Node`(genesis + child)を作り `Node::content_id()` と一致確認。これが最大リスクで、クリア済み。 -- `monas-content/src/application_service/content_service/service.rs`: `verify_and_decrypt_relay_read(node_bytes, expected_version_cid, local_content_id) -> VerifiedRead{plaintext, parents}` = 検証 → CEK ロード(`cek_store.load`)→ `decrypt_with_cek`(AES-GCM + content_id 照合)。**これが E2E 復号の再利用コア**。 -- `monas-content/Cargo.toml`: `serde_cbor`, `cid`(serde feature), `multihash` 追加。dev-dep に `crsl-lib`(パリティ用)。 - -**verify_integrity 修正**(`monas-sdk/src/controller/state.rs`): state node が Node CBOR を返すようになったので、旧「生暗号文とバイト比較」が壊れる。`verify_and_extract` で CID 検証 + 暗号文抽出してから比較するよう修正済み。 - -### 4.2 廃止するもの(C, `3a64a5a`)— revert 対象 - -- `monas-account/src/application_service/command.rs`: `IssueMemberProofRequest` -- `monas-account/src/application_service/service.rs`: `issue_member_proof` -- `monas-account/src/application_service/mod.rs`: export 追加 -- `monas-content/src/infrastructure/member_proof.rs`(新規ファイル) -- `monas-content/src/infrastructure/mod.rs`: `pub mod member_proof;` -- `monas-content/Cargo.toml`: dev-dep `monas-account`(member_proof パリティ用) -→ `git revert 3a64a5a` で概ね戻る(コンフリクトしたら mod.rs / Cargo.toml を手で調整)。member_proof.rs 削除を確認。 - ---- - -## 5. 残作業(A + B のみ、C は無し) - -### 5.1 B — 単調性チェック(未実装) - -目的: client が「content ごとに最後に見た version CID」を記録し、後退(祖先へのロールバック)を拒否。 - -- SDK ローカル sled(既存 `SledContentEncryptionKeyStore`、`monas-sdk/src/controller/mod.rs:246`)と同じ DB に `content_id -> last_seen_version_cid` の tree を新設。in-memory 版も(`mod.rs:230` に倣う)。 -- 祖先判定: `verify_and_extract` が返す `VerifiedNode.parents`(親版 CID)を辿り、「last_seen が今回版の祖先か」を確認。祖先でなければ後退 → 拒否。親を辿るのに版指定 read で親 Node を順次取得(深さは bound、既定は実装で決める)。 -- 追記のみ DAG(`new_child` で新 CID、既存 Node 不変)は確認済みなので誤検知しない。初回(記録なし)は TOFU 受理 + 記録。検証通過後に last_seen 更新。 - -### 5.2 実 read エンドポイント(未実装)— これが無いと「実際に使えない」 - -現状 SDK には「state node から暗号文を読んで復号してユーザーに返す」経路が**無い**(`get_content` はローカルストレージから復号)。新設が必要: - -- SDK に新メソッド(例 `read_content_from_state_node`): auth 受け取り → `resolve_state_read_auth` → `get_state_node_history` で最新 version 決定 → `get_state_node_version_data`(Node CBOR base64)→ decode → `content_service.verify_and_decrypt_relay_read` → 単調性チェック(B)→ 平文返却。 -- **入力は remote_content_id(state node 読み取り)と local_content_id(CEK 引き)の両方**が必要(local↔remote の対応表は無く、呼び出し側が両方渡す設計。`VerifyIntegrityInput` と同じ)。 -- gateway(`monas-gateway/src/main.rs`)の read ハンドラに `HeaderMap` を足し `build_state_node_auth_context` を通す(現状 read は auth 非対応)。 -- **CEK の欠落に注意**: share で受け取った content は unwrap した CEK が保存されない(`decrypt_shared_content` は即復号のみ)。自分が作成者なら `cek_store.load(local_id)` で取れる。share 経由も読めるようにするなら unwrap 済み CEK を `cek_store.save` する経路が別途要る(スコープ判断)。 - -### 5.3 テスト + PR - -- 単体: A の改ざん拒否、B の後退拒否/初回受理。統合: relay read e2e(`monas-state-node/scripts/e2e-test.sh`)を Node 返却形式に更新。 -- `cargo build/test/clippy/fmt` を content/sdk/state-node/account で green に。**Rust 1.97 の clippy で確認**(`rustup run 1.97.0 cargo clippy --workspace --all-targets --profile test --no-deps -- --deny warnings`。CI が最新 stable を入れるため。#54 で `for_kv_map`/`useless_borrows_in_formatting` に刺さった前例あり)。 -- PR 作成: **base = `fix/state-node-read-relay`**。本文に「A+B のみ、member 証明は設計上不要として不採用」を明記。 - ---- - -## 6. 再開時の最初の一手 - -1. このファイルと `docs/design/read-response-integrity.md` 冒頭の訂正を読む。 -2. `git revert 3a64a5a`(C を戻す)。ビルド green 確認。 -3. 実 read エンドポイント(§5.2)→ 単調性(§5.1)の順で実装。 -4. テスト → PR(§5.3)。 - -## 6.1 タスクリスト全体(チェックリスト) - -前セッションの TaskCreate は引き継がれないので、ここに残す。 - -- [x] **A サーバ**: state-node が Node CBOR を返す(`ec26c00`) -- [x] **A クライアント**: monas-content で CID 再計算・検証 + crsl-lib パリティ(`ec26c00`) -- [x] **read 形式統一 + E2E verify-decrypt コア + verify_integrity 修正**(`7970b35`) -- [x] **設計訂正 + ハンドオフ doc**(`b41f454`) -- [x] **C を revert**: `git revert 3a64a5a`(member 証明は設計上不要) -- [x] **B 単調性チェック**: last_seen ストア(sled/in-memory)+ CID 検証済み parents の祖先探索(fail-closed、上限 256 fetch)。最新読みのみ適用、明示版指定 read は A のみ -- [x] **実 read エンドポイント**: SDK `read_content_from_state_node` + gateway `POST /state/read`(auth 転送) -- [x] **share 受信者の CEK 永続化**: `decrypt_shared_content` 成功時に unwrap 済み CEK を受信者ローカル cek_store へ保存(rotation 時は新 envelope 処理で上書き追従) -- [x] **revoke 順序修正**: SDK が revoke → reencrypt の順で呼んでいた(service の想定と逆で、旧 CEK の envelope を生成していた)のを reencrypt → revoke に修正。再発行 envelope を `RevokeShareOutput.reissued_envelopes` で返すようにした(残存受信者への新 CEK 配布経路) -- [x] **テスト**: A 改ざん拒否 / B TOFU・前進・後退・明示版 / 受信者 read / rotation 追従 / walk 単体(SDK 統合 5 + 単体 7)。旧形式前提だった verify_integrity テストも Node CBOR に更新。e2e-test.sh は `.data` 有無のみ見る形式非依存 assert のため変更不要 -- [x] **build/test/clippy/fmt green**(workspace 全テスト + Rust 1.97 clippy --deny warnings) -- [ ] **PR 作成**: base = `fix/state-node-read-relay`(#54)。本文に「A+B のみ、member 証明は不採用」明記 - -## 6.2 ユーザーからの確定事項(セッション履歴より) - -- **1 PR のみ**で実装する。 -- **後方互換は一切考慮しない。破壊的変更 OK**(production 利用ゼロ、テストのみ)。 -- **PR 向き先は `fix/state-node-read-relay`(#54)**。 -- **「実際に使えないと意味がない」** → 検証機構だけでなく、state node から読んで復号する**実 read 経路まで**作ること(§5.2 は必須、切り出し不可)。 -- **member 証明は不要**(§2。owner は membership を知り得ない)。 -- Fable 5 モデルを使い続ける。 - -## 6.3 実 read 経路の CEK 問題(→ 解決済み) - -share で受け取った content は、unwrap した CEK がどこにも保存されなかった(`decrypt_shared_content` は即復号のみ)。**ユーザー判断(2026-07-18)で「share 経由 read も含めて本 PR で production レベル実装」に決定**し、以下で解決した: - -- `decrypt_shared_content` 成功時(= CEK の正しさが復号で証明された後)に、unwrap 済み CEK を**受信者デバイスのローカル cek_store** へ保存。CEK も平文もネットワーク・state node には一切出ない(E2E 暗号化の思想は不変。state node は終始 ciphertext-only)。 -- CEK ローテーション(revoke 時の reencrypt)への追従: revoke で再発行された KeyEnvelope(`RevokeShareOutput.reissued_envelopes`)を受信者が再処理すると、保存済み CEK が上書き更新される。旧 CEK のまま新 ciphertext を読むと `Forbidden`(鍵が古い/revoke の可能性を示すメッセージ)で誘導される。 -- 「即時破棄」は意図的なセキュリティ前提ではないことを確認済み: revoke の安全性は受信者の鍵破棄ではなく **CEK ローテーション**(reencrypt + 残存者への再発行)に依存する設計。 - ---- - -## 7. 主要な file:line リファレンス(調査済み) - -- crsl-lib Node: `~/.cargo/git/checkouts/crsl-lib-*/e13b86c/src/dasl/node.rs`(`content_id`:76, `to_bytes`:90, `from_bytes`:104, `parents`:144)。rev pin = `e13b86ce...`。 -- CEK ストア: `monas-content/src/infrastructure/key_store.rs`(sled key = `cek:{content_id}`)。 -- 復号: `monas-content/src/infrastructure/encryption.rs`(AES-256-GCM, `[nonce12||ct||tag16]`)。 -- decrypt_with_cek: `monas-content/src/application_service/content_service/service.rs:268`。 -- SDK read: `monas-sdk/src/controller/state.rs`(`get_state_node_history`:83, `get_state_node_version_data`:104, `verify_integrity`:225)。 -- SDK local read: `monas-sdk/src/controller/content.rs:842`(`get_content`, ローカル復号)。 -- gateway: `monas-gateway/src/main.rs`(read ハンドラ:114, `build_state_node_auth_context`:284)。 -- owner key_id 形式: `monas-account/src/application_service/service.rs:160`(`user:{hex(pubkey)}`, 自己完結型)。 diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md index fc30776..e043f2e 100644 --- a/docs/design/read-response-integrity.md +++ b/docs/design/read-response-integrity.md @@ -1,353 +1,140 @@ # read 経路の完全性: 応答データの E2E 検証 -- ステータス: **【2026-07-18】実装完了。member 証明(C)は廃止し、A(版真正性)+ B(単調性)+ 実 read 経路(share 受信者の CEK 永続化・rotation 追従含む)を実装。** -- 関連: PR #54、issue #55 -- 前提ブランチ: `feature/read-response-signing`(#54 の上に積む) -- **再開手順は `docs/design/read-response-integrity-HANDOFF.md` を参照。** - -> ## ⚠️ 設計訂正(2026-07-18) — member 証明の廃止 -> -> 当初「owner が member 追加時に証明トークンを発行し、node がそれを read 応答に添付する」(§5.1.b / §5.3, コンポーネント C)を採用したが、**これは Monas の分散設計に反する誤りだった**: -> -> - **owner は誰が member かを知らないし、知り得ない**。member はネットワークの複製配置(DHT・`add_member_to_content`)で **owner の関与なく自律的に増減・入れ替わる**。「owner が member 追加時に証明発行」という前提が成立しない。 -> - 署名の信頼の根が owner であること(read 認可)と、member を認定するのが owner であることは**別の話**。当初これを混同していた。 -> -> **訂正後の結論: member 証明は不要。** #54 レビューの主シナリオ(非 member が偽データ・偽履歴を返す)は **A(Node CBOR + CID 再計算)だけで防げる** — 攻撃者は正しい CID を持つ偽 Node を作れないため、誰が返そうと弾ける。「正規 member か」を確認する必要自体がない(データが暗号学的に正しければ、返した相手は誰でもよい)。ロールバックは B(単調性)でベストエフォート検出。 -> -> 以下 §5.1.b / §5.3 / §6 の member 証明関連は**歴史的経緯として残すが、採用しない**。実装済みの C コード(コミット `3a64a5a`)は revert する。 - -## 1. 目的 - -read の relay 応答に対して、**返ってきたデータ・版が正当な member によるものか**をクライアント側で暗号学的に検証できるようにする。#54 で対処済みの範囲(下記)では塞がらない、以下の攻撃を防ぐ。 - -- 偽データ注入(暗号文本文以外) -- 偽履歴の注入(存在しない版 ID の混入) -- ロールバック攻撃(過去の本物の版を「最新」として返す) -- 未検証ピア(DHT フォールバックで拾った非 member)への relay - -### #54 で対処済み(本ドキュメントの対象外) - -| 対処 | 手段 | -|---|---| -| credential 漏洩の悪用 | 読み取り署名を `read:{content_id}:{timestamp}` に content_id バインド | -| 偽データ注入(暗号文**本文**) | SDK 暗号化を AES-256-GCM (AEAD) に移行。改ざん・偽造本文は復号で失敗 | - -## 2. 問題の構造 — 2レイヤー - -read 経路には独立した2つの信頼問題があり、両方を埋めないと防御にならない。 - -### レイヤー1: 誰に聞くか(メンバーシップ) - -`resolve_members`(`state_node_service.rs:449`)は、ローカルに `ContentNetwork` レコードがない場合、Kademlia DHT の近接ピア(`find_closest_peers`)をそのまま relay 先「member」として扱う。暗号学的検証はない。攻撃者は自分の PeerID を対象コンテンツの DHT キー近傍に置くだけで relay 先候補に入れる(正規 member である必要はない)。 - -さらに、ローカルの `ContentNetwork` レコード自体も gossip イベント(`ContentNetworkManagerAdded` / `Removed`、`events.rs:20-62`)のペイロードを無検証で保存・上書きしている。イベントに署名フィールドはない。 - -**既存の弱点(調査で判明)**: incoming request の member 判定は **libp2p PeerID 文字列**(ed25519 由来)を member set と照合している(`libp2p_network.rs:1546, 1616` の `has_member_str(&peer.to_string())`)。一方 `member_nodes` は **P-256 由来 NodeId**(`content_network.rs:14`)。型が食い違っており、現状の member 判定は署名検証ではなく文字列比較。本設計で整合を取る。 - -### レイヤー2: 返ってきた答えが正しいか(応答の完全性) - -relay 先が返す `(data, version)`・履歴(版 CID リスト)には署名も系列検証もない。GCM が守るのは暗号文本文のみで、以下は素通りする。 - -- **偽履歴**: `get_history` / `get_latest_version`(SDK `controller/state.rs`)は relay 先が返す版 CID 文字列リストをそのまま信頼。 -- **ロールバック**: 過去の本物の暗号文(正規 CEK で暗号化済み)を「最新」として返すと GCM は通り、クライアントは正常復号して「最新」と信じる。 - -**正常な stale read との区別**: 正規 member が sync 遅延で一時的に古い版を返すのは結果整合性として正常な仕様であり、守るべき挙動。攻撃との違いは「時間が経てば sync で自己修復するラグ」か「攻撃者が特定の相手に古い版/偽履歴を意図的に固定・注入し収束しない」か。検出軸は member/非 member でも新旧でもなく、**自己修復するラグか、収束しない改ざんか**。 - -## 3. libp2p が保証する範囲と、しない範囲 - -libp2p(Noise、`transport.rs:21`)が保証するのは**各ホップの相手 PeerID が本物であること**(トランスポート認証)だけ。 - -- **多段 relay では隣接ホップのみ認証** — A→B→C で A が検証できるのは「B と話した」ことだけ。C が誰か・member か・B が C の応答を正直に転送したかは libp2p レイヤーに現れない。中間ノードは中身を差し替え放題。 -- **PeerID は「member であること」を語らない** — member は Monas アプリ層の概念(ContentNetwork)。 - -したがって「どこから read したかの証明」は libp2p から降ってこず、**アプリ層で作るしかない**。 - -## 4. 既存資産(調査結果) - -設計は既存の鍵・検証部品・データ構造の上に構築できる。 - -### 4.1 署名鍵: node_key(P-256)が第一候補 - -state node は2種類の鍵を持つ: - -| 鍵 | ファイル | 型 | 用途 | -|---|---|---|---| -| ed25519 peer key | `data_dir/peer_key.ed25519` | `libp2p::identity::Keypair` | トランスポート/PeerID のみ | -| **P-256 node_key** | `data_dir/node_key.pem`(生 32byte) | `NodeKeyPair`(`key_management.rs:9`) | **node 認証・NodeId・公開鍵証明の署名** | - -read 応答署名には **node_key(P-256)** が自然。理由: -- 既に `NodePublicKey`(`public_key_protocol.rs:26`)で「node_id ↔ P-256 公開鍵」の所有証明に使用済み。 -- `member_nodes` の NodeId が P-256 公開鍵ハッシュ由来(`content_network.rs:24`)なので、署名者鍵と member 判定が暗号学的に一致する。 - -### 4.2 再利用できる検証部品 - -- `crypto::verify_p256_signature`(`crypto.rs:34`、SHA-256 digest 方式、monas-account の署名と互換) -- `NodePublicKey`(`public_key_protocol.rs`、node_id+timestamp を P-256 署名する雛形)— read 応答署名の最も近い雛形 -- `PublicKeyRegistry`(`port/public_key_registry.rs:12`、node_id → pubkey 取得。in-memory + sled 実装) - -**注意**: 検証系が2系統ある — `signature_verifier.rs` は raw-message verify、`crypto.rs` は SHA-256 digest verify。応答署名では digest 方式(account 互換)に統一する。 - -### 4.3 系列検証は既存データ構造で原理的に可能 - -crsl-lib の `Node`(`dasl/node.rs:26`)は: - -```rust -pub struct Node { - pub payload: P, // ContentPayload { data, access_policy } - pub parents: Vec, // 親版参照(複数可 = DAG) - pub genesis: Option,// 所属 genesis(genesis 自身は None) - pub timestamp: u64, - pub metadata: M, -} -``` - -- version CID = Node 全体(payload/parents/genesis/timestamp/metadata)の **CBOR → SHA-256**(`node.rs:76`)。**親が変われば CID も変わる**ため、CID を再計算すれば parents/genesis 参照の改ざんをクライアント側でも検知できる。 -- 「同一系列所属」は `get_genesis(X) == G` で O(1) 判定可能(`dag.rs:425`、既に `crdt_repository.rs:226` で利用)。ただしこれは genesis フィールドの**自己申告一致**であり、genesis から parents を辿る**到達可能性の検証ではない**。 -- 到達可能性(真の親子チェーン)を辿れる公開 API は `branching_history`(parent→children 隣接、`repo.rs:112`)のみ。`linear_history`/`get_history`/`latest` は CID の列/単体のみでエッジ情報を返さない。 -- **crsl-lib の Node/Operation には署名も検証される author も無い**(author は Operation の自由文字列 `operation.rs:9`)。真正性は CRDT レイヤーでは担保されないので、**署名は state-node アプリ層で付与する**。 - -### 4.4 応答経路と署名フィールドの後方互換追加 - -read 応答は2区間で異なるシリアライズを経る: - -- relay ワイヤ(node↔node): libp2p **CBOR** codec(`behaviour.rs:38`、`ContentResponse` を serde/CBOR) -- HTTP(caller node↔SDK): **JSON** - -E2E で運ぶ必要があるのは、(A) の Node 全体バイト列(`data` を「生 payload」から「`Node::to_bytes()` の CBOR」に変える or 別フィールド追加)と、(B) の **owner 発行 member 証明トークン**(node 生署名ではない、§5.0.0)。いずれも **`Option` で後方互換に追加可能**(CBOR は末尾フィールド追加を無視/欠損=None、JSON は `#[serde(default)]`)。経路上の全型を通す必要がある: - -| 層 | 型 | 場所 | -|---|---|---| -| member 戻り値 | `(Vec, String)` | `read_content_via_relay`(`state_node_service.rs:565`) | -| 内部 IPC | `RelayOutcome::Data { data, version }` | `libp2p_network.rs:55` | -| ワイヤ ★中心 | `ContentResponse::ContentData { content_id, data, version }` | `protocol.rs:106` | -| caller 分解 | `Ok((data, version))` | `libp2p_network.rs:1828`(現状 `..` で余剰フィールド破棄) | -| HTTP | `ContentDataResponse` | `http_api.rs:225` | -| SDK | `StateNodeContentDataResponse` | `models/state_node.rs:51` | - -追加フィールドの想定: `node_bytes: Option>`(A 用、Node 全体)と `member_proof: Option`(B 用、owner 発行 JWT)。**member リスト・node 生署名は載せない**(§5.0.0)。**caller の `libp2p_network.rs:1828` の分解パターン修正が必須**(現状 `..` で余剰フィールドを捨てている)。 - -## 5. 設計方針(たたき台 / 要レビュー) - -### 5.0.0 機密性の制約(メタデータプライバシー) ★設計の大前提 - -**完全性を足すために、メタデータ機密性(誰がどの content を管理しているか)を悪化させてはならない。** - -背景: 当初案(member 集合を晒す / member node 鍵で応答に署名)は、完全性は満たすが機密性を壊す。「member リスト全体が見える」ことは「単体の member が見える」現状より質的に一段危険: - -| 観点 | 単体が見える(現状 relay) | リスト全体が見える(避けるべき) | -|---|---|---| -| 可用性攻撃 | 冗長化(replication)が守る | **全 member 特定で冗長化が無効化** — 一番効く | -| 名寄せ・相関 | 点が繋がりにくい | ノード共起グラフが組め、名寄せ可能 | -| 非否認性 | 揮発的(観測のみ) | 署名を載せると**永続的な証拠**が残る | - -したがって設計制約: - -1. **member 集合(リスト)を relay ノード・クライアントに晒さない。** 現状 relay が漏らす範囲(応答した単体ノード)を超えて広げない。 -2. **応答した個別ノードが「自分は正規 member だ」を単体で証明する**形にする。集合を見せずに単体の正当性だけ検証する。 -3. member node 鍵の**生署名を relay に残さない**(非否認性の劣化を避ける)。証拠が残るなら、node 身元と結びつかない形にする。 - -### 5.0 鍵レイヤーの整理(調査で確定) - -設計に関わる鍵は**別レイヤーの2種類**で、混同しないこと。 +- 関連: PR #54(read relay)、issue #55(セキュリティ指摘)、PR #56(実装) +- ステータス: 実装済み -| 鍵 | 実体 | 管理 | 用途 | -|---|---|---|---| -| **ユーザー鍵**(owner) | `AccessPolicy.owner` = `Identity{id: hex(P-256 pubkey), type: User}`(`identity.rs:15`, `access_policy.rs:21`) | monas-account | 誰がコンテンツの所有者か。read 認証もこの鍵の署名 | -| **node 鍵**(member) | `member_nodes` の NodeId = P-256 node_key 由来(`content_network.rs:24`) | 各 state node(`node_key.pem`) | 誰がコンテンツを複製保持する node か | +relay 経由の read で返ってくるデータ・版・履歴には、もともと署名も系列検証もなかった。 +本設計は、read 応答をクライアント側で暗号学的に検証し、さらに state node から +読んで復号する実 read 経路までを定義する。 -→ **メンバーシップ(誰が member か)の権威はユーザー鍵(owner)、応答の発言者は node 鍵(member)**。§5.3 のメンバーシップ署名は owner のユーザー鍵で、§5.1 の応答署名は member の node 鍵で行う。 +## 1. 脅威モデル -### 5.0.1 重要な発見: Node 全体を返せば、データ真正性と系列は署名なしで検証できる +### 1.1 前提: libp2p が保証しない範囲 -crsl-lib の `Node` は `to_bytes()`(CBOR)/`from_bytes()` が公開されており(`node.rs:90/104`)、`content_id()` はその CBOR バイト列の SHA-256(`node.rs:76`)。したがって: +libp2p(Noise トランスポート)が保証するのは**各ホップの相手 PeerID が本物であること**だけ。 -- member が生 `data` ではなく **シリアライズした `Node` 全体**(payload + parents + genesis + timestamp + metadata)を返せば、クライアントは: - 1. **`from_bytes` → `content_id()` を再計算 → 要求した version CID と一致するか**でデータ本文と親参照の改ざんを検知できる(**署名不要**。CID = 内容ハッシュなので、CID が正しければ中身は正しい) - 2. Node に含まれる `parents` / `genesis` で系列を辿れる +- 多段 relay(A→B→C)で A が検証できるのは「B と話した」ことまで。C が誰か、 + B が C の応答を正直に転送したかはトランスポート層に現れない。中間ノードは中身を差し替えられる。 +- PeerID は「member であること」を語らない(member は Monas アプリ層の概念)。 -- つまり **署名が本質的に必要なのは「これが最新である」という否定的事実**(=より新しい版が存在しないこと)だけに絞り込める。データの真正性・系列は content-addressing で足りる。 +したがって read 応答の検証はアプリ層で行うしかない。 -**機密性との両立**: Node の中身は暗号文(payload.data は SDK が暗号化済み)なので、Node 全体を返してもコンテンツ内容は漏れない。ただし §5.0.0 の制約から、Node を返すこと自体が「応答した単体ノードがこの content を持つ」ことを示す点は現状 relay と同じ(単体レベル)であり、それを超えない。**member リストや node 生署名は載せない。** +### 1.2 防ぐ攻撃 -この発見により設計を2つに分離できる: - -- **(A) 版指定 read**(`version` を指定):**署名不要**。member は Node を返し、クライアントは CID 再計算で検証。改ざん・偽データは弾ける。機密性の追加漏洩もゼロ(content-addressing のみ)。 -- **(B) 最新 read / 履歴**(`version: None`):member の「これが最新」という主張は content-addressing では検証できない(否定的事実のため)。ここに完全性の裏付けが要るが、**§5.0.0 の制約下でどう作るかが本設計の核心**(§5.1)。 - -### 5.1 「最新である」の完全性を、機密性を壊さずに足す - -「これが最新」の否定的事実には裏付けが要るが、member node 鍵の生署名(§5.0.0 が禁じる)は使えない。代わりに2つのアプローチを組み合わせる。 - -#### 5.1.a 単調性チェック(node 証明不要・機密性ゼロ影響)★まず必須 - -クライアントが「その content について自分が最後に見た version CID」をローカルに記録し、**新しい応答がその版の祖先(=巻き戻り)なら拒否/警告**する(TOFU 的 monotonicity)。 - -- ロールバック攻撃(過去の本物の版を最新と偽る)を検出できる。 -- 版指定 read(A)で Node を取得できるので、返ってきた版から parents を辿り「前回見た版が祖先に含まれるか」を確認できる。含まれなければ巻き戻り。 -- **node の身元も member リストも一切要らない。** relay に何の証拠も残さない。機密性への影響ゼロ。 -- 限界: 「自分が初めて読む content」には基準がない(TOFU の初回問題)。また「最新を隠して古いが正当な版を出す」stale は検出できるが、「まだ誰も見ていない最新」の欠落は原理的に検出不能(否定的事実)。 - -#### 5.1.b owner 発行の member 証明(単体・リスト非公開)— レイヤー1 兼用 ★採用決定(2026-07-18) - -応答ノードが正規 member であることを、**リストを晒さず単体で**証明する。既存の owner 署名委任トークン(`service.rs:98-133`、`{iss: owner, aud: recipient, att: [{with: "monas://content/{cid}", can}]}` を owner P-256 鍵で ES256 署名)を **member 証明**に転用する: - -- owner が各 member node に対し「この content の member である」証明トークン(`aud = member の node 公開鍵 key_id`、`att = {with: content, can: "host"}` 等)を発行。 -- 応答時、member は**自分宛の証明トークン**を応答に添える。クライアントは owner 公開鍵(= `AccessPolicy.owner`、read 認証で既に既知)で検証し、「owner がこのノードを member と認めている」ことを確認。 -- **リスト全体は出ない** — 応答した1ノードの証明だけ。他の member が誰かは分からない。§5.0.0 の制約を満たす。 -- 非否認性: トークンは owner→当該 node の委任なので、「node が自分の身元で署名した証拠」ではなく「owner がこの node を認可した証拠」。member 集合の共起グラフには使えず、劣化は限定的。 - -**なぜ owner が信頼の根になるか(設計議論の記録)**: member の証明は「読み手が既に信頼している何か」に根を張る必要がある(宙に浮いた証明は攻撃者も同じ形で主張できる)。読み手が確実に持つ信頼の起点は **owner の公開鍵だけ** — 読み手の read 権限自体が owner 署名の委任で付与されるため。member 自身の鍵(攻撃者も名乗れる)、NodeID↔鍵のハッシュ関係(「この content の member か」を語らない)、CEK(読み手も持つので member を区別できない)はいずれも根にならない。「誰が member かを決める権威 = owner」の必然的帰結として、証明の署名者も owner になる。**owner は発行時に一度署名するだけで、read 処理のたびに介在するわけではない。** - -**owner 公開鍵の可視性について**: 検証の成立自体は owner 公開鍵の秘匿を必要としない(署名検証は公開鍵で行う。重要なのは読み手が「正しい owner 鍵」を権限付与経路で得ていること)。ただしメタデータ機密性の観点では、証明トークンの `iss`(owner key id)が relay 中継ノードに見えると「owner ↔ content ↔ node」のリンクが漏れる。緩和策: 証明トークンを**読み手宛に暗号化して運ぶ**(中継には不透明)、または要求時のみ添付。owner 公開鍵が関係者(権限保持者)以外に知られていない運用なら、`iss` が見えても外部者は owner を同定できないため、露出はさらに限定される。実装フェーズで添付方式と合わせて確定する。 - -**この方式が防ぐもの / 防がないもの(明確化)**: -- ✅ 防ぐ: **非 member のなりすまし**(DHT フォールバックで拾われた無関係ノードが偽応答・偽履歴を返す)— 証明を出せないので弾ける。指摘 #54 レビューの主シナリオはこれ。 -- ❌ 防がない: **正規 member 自身が古い版を「最新」と返す**こと(悪意 or 単なる sync 遅延)。証明は出せてしまう。これは分散システムの原理的限界(否定的事実「より新しい版が無い」はネットワーク越しに証明不能)であり、§5.1.a の単調性チェックによるベストエフォート検出 + 「既知の限界」として脅威モデルに明記する。 - -#### 5.1.c 版の真正性(A で解決済み・再掲) - -「最新」と主張された版そのものの中身の真正性は §5.0.1(A)の CID 再計算で担保。5.1.a/5.1.b は「その版が本当に最新の系列に属し、正規ノードが出したか」を補う。 - -### 5.2 クライアント側の検証フロー - -最新 read の場合: - -1. **版の真正性**: 応答の Node を `from_bytes` → `content_id()` 再計算し、応答が主張する version CID と一致するか(§5.0.1)。不一致なら偽データ → 拒否。 -2. **単調性**: ローカル記録の「最後に見た版」が、今回の版の祖先か(parents を辿る)。巻き戻りなら拒否/警告(§5.1.a)。 -3. **member 証明**(有効化時): 応答に添えられた owner 発行の member 証明トークンを owner 公開鍵で検証(§5.1.b)。無効 or 欠落は段階導入モードに従い warn/拒否。 -4. 検証通過後、ローカルの「最後に見た版」を更新。 - -member リストの取得・検証は**フローに現れない**(晒さないため)。 - -### 5.3 メンバーシップ証明 — 単体・リスト非公開(改訂) - -当初案(`ContentNetwork` リストに owner 署名を付けて配布)は**リスト全体を晒すため §5.0.0 に反する**ので採らない。代わりに §5.1.b の **owner 発行の単体 member 証明トークン**で「応答ノードが member か」を検証する。 - -- owner が各 member node に個別に発行する証明トークンなので、**リストとして流通しない**。クライアントが目にするのは「応答した1ノードの証明」だけ。 -- gossip の `ContentNetworkManagerAdded` イベント無検証問題(§2 レイヤー1)は、node 側が「自分が member になった証拠」= owner 発行トークンを保持し、relay 応答時に提示する形で解消。node 間で member 集合を交換・保存する必要が減る。 -- §4.1 の弱点(member 判定が libp2p PeerID 文字列 vs P-256 NodeId)は、証明トークンの `aud` を P-256 node 公開鍵に統一することで整合を取る。 - -## 6. 論点への推奨(機密性制約 §5.0.0 反映後) - -1. **member 公開鍵の配布** → **配布しない(リスト非公開)**。当初の「ContentNetwork レコードに member 公開鍵一覧を同梱」案は撤回。クライアントが検証するのは owner 公開鍵(既知)で署名された**単体の member 証明トークン**(§5.1.b)のみ。各 member の node 公開鍵はトークンの `aud` として1件ずつ現れるだけで、集合は出ない。 - -2. **member 証明の権威** → **owner のユーザー鍵**。`AccessPolicy.owner`(P-256 pubkey、read 認証で既知)が member 証明トークンを ES256 署名(既存 `service.rs:98-133` / `jwt_signer.rs` を転用)。member 追加時に owner がそのノード宛トークンを発行、削除は TTL 失効 + `min_valid_issued_at` 相当の一括失効(既存の token 失効機構、design.md §10)を流用。**残論点**: owner オフライン時の member 追加 → 既存の write 委任と同じく、管理権限の委任トークンで移譲する形を検討(初版は owner online 必須で割り切り可)。 - -3. **系列検証のコスト** → **通常は単調性チェックのみ(前回版が今回版の祖先かを parents で辿る短いパス)、全チェーン検証はオンデマンド**。版指定 read は CID 再計算だけで足りる(§5.0.1)ため毎回 genesis まで辿らない。監査時のみ全チェーン。 - -4. **単調性の状態管理** → **SDK のローカル sled に「content_id → 最後に見た version CID + timestamp」を記録**。SDK は既に `SledContentEncryptionKeyStore`(`controller/mod.rs:246`)を持つので同 DB に足す。**追記のみ DAG を実コードで確認済み**(更新は `new_child` で新 CID を作り parents で前版を指す、既存 Node は不変 — `node.rs:53`, `crdt_repository.rs:563`)なので、正規の巻き戻しは発生せず誤検知しない。複数デバイスは各自の観測履歴を持てばよく状態共有不要(v3→v5 の前進は正常、v5→v3 の後退のみ警告)。 - -5. **鍵ローテーション** → member 証明トークンの `exp` / `iat` で世代管理。node 鍵ローテーション時は owner が新しい `aud`(新公開鍵)のトークンを再発行、旧トークンは TTL 失効。初版はローテーション非対応でも可。 - -6. **段階導入** → **3 モードで移行**: (i) member 証明を応答に付けるが**検証しない**(観測のみ) → (ii) あれば検証、無ければ warn で通す → (iii) 必須(無い/無効は拒否)。単調性チェック(§5.1.a、機密性影響ゼロ)は依存物が無いので**先行して (iii) 相当まで入れてよい**。member 証明(§5.1.b)はオープン化前に (iii) へ。 - -### 6.1 設計判断の状況(2026-07-18 更新) - -**決定済み**: -- **方式**: §5.1.b(owner 発行の単体 member 証明 + member 応答)+ §5.1.a(単調性)+ (A) CID 再計算、の組み合わせで確定。owner は発行時のみ介在し read 経路には入らない。 -- **鮮度の限界の受容**: 正規 member 自身によるロールバック/stale は原理的に防げないことを「既知の限界」として脅威モデルに明記する(§5.1.b)。 - -**実装前提の確定(2026-07-18)**: production 利用ゼロ(テストのみ)のため、**後方互換は一切考慮しない。破壊的変更 OK。1 PR で全実装**。これに伴い: -- **段階導入(3 モード)は廃止** — 最初から検証必須(検証失敗 = 拒否)で実装する。`Option` フィールドでの共存も不要、ワイヤ型は直接置き換える。 -- **member 証明の添付方式**: **常時添付**で開始(シンプル優先)。`iss` の読み手宛暗号化は初版では入れず、§5.1.b の緩和策として TODO 記録に留める(クローズド環境のうちは露出リスクが実質ない)。 -- **owner オフライン時の member 追加**: 初版は **owner online 必須**で割り切る(委任は将来)。 -- **単調性**: 最初から拒否モード。 - -### 6.2 検討して却下した案(再検討防止の記録) - -1. **署名付き member リストの配布** — リスト全体が晒され、冗長化の無効化・名寄せ・非否認性の劣化を招く(§5.0.0)。却下。 -2. **member node 鍵の生署名を応答に載せる** — 「この node がこの content を持つ」永続的証拠が残る。owner→node 委任トークンで代替(§5.1.b)。 -3. **envelope への最新版 CID 埋め込み** — 調査の結果、envelope(HPKE wrapped CEK)は**共有付与時に1回だけ**配布され、通常の update では再配布されない(`update_content` は share/envelope に一切触れない)。静的に埋めた CID は初版で固定され最新を追えない。却下。なお envelope の HPKE aad には content_id が既にバインドされており「この envelope はこの版のもの」の認証は既存機構で効いている。 -4. **認証付き可変「最新ポインタ」**(JWT の未使用 `fct` フィールド等に最新 CID を載せる案を含む) — ポインタ自体が「最新性を保証すべき可変状態」になり、同じ問題が再帰する(そのポインタは最新か?)。同期・更新コストも生む。却下。 -5. **CEK による member 証明** — CEK は読み手・書き手・(設計次第で)member 全員が持つため「member だけ」を区別できない。却下。 - -## 7. スコープと前提 - -- すべて「攻撃者が read relay の経路に入れること」が前提。**クローズドな 4 ノード構成の現状では成立しない**が、オープン参加型移行前に必要なので今のうちに入れる(Kademlia への Sybil/eclipse 攻撃が現実的になるため)。 -- **機密性制約 §5.0.0 は不変の前提**。完全性を足す実装が member 集合を晒していないかを実装中チェックする。 -- **後方互換なし・破壊的変更 OK・1 PR**(§6.1)。 - -## 8. 実装計画(1 PR) - -3 コンポーネントを 1 PR で実装する。依存順に記載するが同一 PR。すべて既存コードの file:line は §4 の調査に基づく。 - -### 8.0 検証ロジックの置き場所 = `monas-content`(2026-07-18 修正) - -**検証は `monas-sdk` ではなく `monas-content` に置く。** 理由: -- `monas-sdk` は `monas-content` に依存する薄い API 層(`monas-sdk/Cargo.toml:10`)。コンテンツの暗号処理(復号 `domain/content/encryption.rs`、CID 計算 `infrastructure/content_id.rs`、CEK 管理、share/envelope)は**すべて既に `monas-content` に集約**されている。read の完全性検証もコンテンツドメインの責務なのでここに属する。 -- SDK は「検証する `monas-content` の口を呼ぶだけ」に留め、JWT 検証・CID 再計算などの暗号ロジックを SDK に持ち込まない。 - -**CID 再計算の重要な差異**: `monas-content` 既存の `Sha256ContentIdGenerator`(`content_id.rs:9`)は `SHA-256(raw_content)` を hex 化するだけで、**crsl-lib の Node CID(`SHA-256(CBOR(Node全体))` → CIDv1 RAW/SHA2-256、`node.rs:76`)とはアルゴリズムもエンコードも別物**。version CID の再計算には crsl-lib 準拠の実装が要る。`content_id.rs:6` に `todo: crslのcid生成を使用する` とある通り元々 crsl 準拠にしたい意図があるので、**`monas-content` に crsl-lib 準拠の Node CID 計算を新設**(既存 generator とは別関数)してこの TODO を回収する。crsl-lib を `monas-content` 依存に足すか、CBOR+SHA-256+CID の軽量実装を `monas-content` 内に持つかは 8.6 で判断。 - -### 8.1 コンポーネント A: 版真正性(Node 全体を返して CID 再計算) +| 攻撃 | 防御 | +|---|---| +| 非 member / 中間ノードによる偽データ・偽版・偽履歴の注入 | **A: 版真正性**(§2) | +| ロールバック(過去の本物の版を「最新」と偽って返す) | **B: 単調性**(§3) | -**目的**: member の read 応答が「生 payload」ではなく `Node` 全体(CBOR)を返すようにし、クライアントが CID を再計算して改ざん検知する。 +なお #54 時点で対処済みのもの: 読み取り署名の content_id バインド +(`read:{content_id}:{timestamp}`)による credential 再利用の防止、 +AES-256-GCM(AEAD)による暗号文本文の改ざん検知。 -**state-node 側**: -1. `crdt_repository.rs` の `get_version` / `get_latest_with_version`(`:184, :207, :232`)が現状 `node.payload().data.clone()` を返すのを、**`node.to_bytes()`(CBOR 全体)を返す**ように変更。戻り値型を「payload バイト列」から「Node CBOR バイト列」へ。※ port trait `content_repository.rs` のシグネチャも変更。 -2. `read_content_via_relay`(`state_node_service.rs:565`)の戻り値 `(Vec, String)` の `Vec` を Node CBOR に。 -3. ワイヤ: `ContentResponse::ContentData { content_id, data, version }`(`protocol.rs:106`)の `data` を Node CBOR に(意味を変えるだけで型は `Vec` のまま。フィールド名を `node_bytes` にリネームして意図を明示)。内部 `RelayOutcome::Data`(`libp2p_network.rs:55`)も同様。 -4. HTTP `ContentDataResponse`(`http_api.rs:225`)/ SDK `StateNodeContentDataResponse`(`models/state_node.rs:51`)の `data` も Node CBOR(base64)に。 +### 1.3 防がない(既知の限界) -**クライアント検証(`monas-content` に実装、SDK はそれを呼ぶ)**: -5. `monas-content` に crsl-lib 準拠の Node CID 再計算 + 検証関数を新設(§8.0)。Node CBOR を受け取ったら CID 再計算 → 要求 version と一致を検証。不一致は**拒否**。 -6. 検証後、Node の `payload.data`(暗号文)を取り出して既存の復号(`domain/content/encryption.rs`、AES-GCM)に渡す。復号・CID 検証とも `monas-content` 内で完結し、SDK は結果を受け取るだけ。 +**正規 member 自身による stale 提示のうち、クライアントが一度も見ていない範囲**は検出できない。 +「より新しい版が存在しない」という否定的事実はネットワーク越しに証明不能なため。 +sync 遅延による一時的な stale read は結果整合性として正常な仕様であり、守るべき挙動。 +攻撃と区別できるのは「クライアントが既に受理した版より後退したとき」だけで、それは B が検出する。 -### 8.2 コンポーネント B: 単調性チェック(ロールバック検出) +## 2. コンポーネント A: 版真正性(CID 再計算) -**目的**: SDK が「content ごとに最後に見た version CID」を記録し、後退した応答を拒否。 +state node は read 応答として、暗号文の生バイトではなく **crsl-lib `Node` 全体(CBOR)** を返す。 +クライアントは受け取った CBOR バイト列から CID を再計算し +(`CIDv1(RAW, SHA2-256)`、crsl-lib の `Node::content_id()` と同一)、 +要求した版 CID と一致することを検証する。 -1. SDK ローカルストア: 既存 `SledContentEncryptionKeyStore`(`controller/mod.rs:246`)と同じ sled DB に **`content_id → last_seen_version_cid` ストア**を新設(新しい tree/prefix)。in-memory 実装も対で用意(`controller/mod.rs:230` に倣う)。 -2. 祖先判定: 応答の Node から `parents`(`node.rs:144`)を辿り、「記録済みの last_seen が今回版の祖先に含まれるか」を確認。含まれない(= 後退 or 分岐)なら**拒否/警告**。 - - 辿るために親版の取得が要る場合がある → 版指定 read(A)で親を順次取得。深さは実装で bound(全チェーンは監査時のみ、§6 論点3)。 -3. 検証通過後、last_seen を今回版に更新。初回(記録なし)は TOFU で受理 + 記録。 +- CID はバイト列そのもののハッシュなので、一致すれば payload(暗号文)・parents・ + genesis・timestamp・metadata すべてが真正。**正しい CID を持つ偽 Node は作れない**ため、 + 応答を返した相手が誰であっても改ざんは弾ける。署名は不要。 +- local 分岐・relay 分岐とも同じ Node CBOR 形式で返す(クライアントは分岐を意識せず同一検証)。 -### 8.3 コンポーネント C: owner 発行 member 証明 +実装: +- state node: `port/content_repository.rs` の `get_latest_node_bytes_with_version` / + `get_version_node_bytes`、`presentation/http_api.rs`(`/content/:id/data`, `/content/:id/version/:version`) +- クライアント: `monas-content/src/infrastructure/node_verification.rs` + (`recompute_node_cid` / `verify_and_extract`)。CID 再計算が crsl-lib の + `Node::content_id()` とバイト一致することはパリティテストで担保 + (`cid` / `multihash` / `serde_cbor` のバージョンを crsl-lib に pin)。 -**目的**: 応答ノードが正規 member であることを、リストを晒さず単体で証明。 +### member 証明を採用しない理由 -**owner(monas-account)側 — 証明発行**: -1. 既存の委任トークン発行(`service.rs:98-133`、`DelegationClaims { iss, aud, exp, iat, jti, att }` を ES256 署名)を転用し、**member 証明トークン**を発行する口を追加。`aud = member の node 公開鍵 key_id`、`att = [{ with: "monas://content/{cid}", can: "host" }]`(`can` に `host` を追加、`DelegatedCapability` / `CapabilityAction` に enum 追加)。 -2. member 追加フロー(`add_member_to_content` 系、`state_node_service.rs:1564` 周辺)で、owner がこのトークンを発行し、対象 member node に配布する経路を追加。member node はトークンを永続化。 +「owner が member 追加時に証明トークンを発行し、node が read 応答に添付する」案は +検討の上**不採用**とした。Monas では member は DHT 複製配置 +(`add_member_to_content`)によって **owner の関与なく自律的に増減・入れ替わる**ため、 +「owner が member 追加時に発行する」という経路がそもそも成立しない。 +そして A により、データが暗号学的に正しければ返した相手の身元確認は不要になる。 -**member node 側 — 応答に添付**: -3. `read_content_via_relay`(`state_node_service.rs:565`)/ `read_history_via_relay`(`:598`)の応答に、自ノードの member 証明トークンを載せる。ワイヤ `ContentResponse::ContentData` / `HistoryData` に `member_proof: String`(必須)を追加。内部 `RelayOutcome`・HTTP・SDK 型も同様に追加(§4.4 の経路表の全型)。 -4. caller の分解 `libp2p_network.rs:1828`(現状 `..` で余剰を捨てている)を修正し、`member_proof` を通す。 +## 3. コンポーネント B: 単調性(ロールバック検出) -**クライアント検証(`monas-content` に実装)**: -5. 応答の `member_proof` を **owner 公開鍵**で ES256 検証(`monas-content` に検証関数を新設。既存の署名検証/鍵管理と同居)。`att.with` が要求 content と一致、`exp` 未失効を確認。無効/欠落は**拒否**。 -6. owner 公開鍵の入手(§8.6 参照): SDK/content が持つ委任トークンの `iss` から導出できるか確認。導出できれば追加 API 不要。 +クライアントは content ごとに「最後に受理した版 CID」(last_seen)をローカルに記録し、 +最新読みの結果が last_seen の**子孫**(または同一)であることを確認する。 -### 8.4 検証フロー統合(SDK, §5.2) +- 祖先判定は、今回受理した Node の parents から **CID 検証済みの親リンクだけ**を辿る + (祖先 Node も版指定 read で取得し、A と同じ CID 検証を通す)。偽の親リンクで + last_seen を祖先に見せかけることはできない。 +- 初回(記録なし)は TOFU で受理して記録。検証通過後に last_seen を更新。 +- 探索は fetch 上限 256 で打ち切り、**fail-closed**(拒否)。攻撃者が偽の深い DAG で + クライアントに際限なく fetch させる DoS を防ぐ。 +- 後退検出時は Conflict エラー(「ロールバック攻撃または stale relay の可能性」)。 +- **版を明示指定した read は対象外**(過去の版を意図的に読む正当な操作。A のみ適用され、 + last_seen も更新しない)。 +- 履歴 API(版 CID リスト)自体は無検証のままだが、履歴は「どの版を読むか選ぶ」ためだけに + 使われ、選んだ版の中身は A、新しさは B が守る。 -最新 read で以下を順に。1つでも失敗したら拒否: -1. A: Node CBOR → CID 再計算 = 主張 version か -2. C: member_proof を owner 鍵で検証(member か) -3. B: last_seen が今回版の祖先か(後退でないか) -4. 全通過 → 復号して返す + last_seen 更新 +実装: `monas-content/src/infrastructure/last_seen_version_store.rs` +(sled: 既存 DB に `last_seen:` prefix で同居 / in-memory)、 +`monas-sdk/src/controller/state.rs`(`walk_ancestors_for`, `enforce_read_monotonicity`)。 -### 8.5 テスト計画 +## 4. 実 read 経路 -- A: 改ざん Node(payload 書き換え)→ CID 不一致 → 拒否を検証。 -- B: v5 を見た後に v3 を返す → 後退拒否。初回 v3 は受理。 -- C: 非 member(証明なし/他 content の証明)→ 拒否。正規 member の証明 → 受理。owner 鍵違い → 拒否。 -- 統合: 既存の relay read e2e(`e2e-test.sh`)を Node 返却 + 証明必須に更新。 -- **§5.0.0 チェック**: 応答・ログに member 集合が現れないことをテスト/レビューで確認。 +検証機構だけでは使えないため、state node から読んで復号する経路を SDK / gateway に用意する。 -### 8.6 実装前に確定した事項(調査済み 2026-07-18) +### 4.1 フロー(`read_content_from_state_node` / gateway `POST /state/read`) -**(1) CID 再計算は `monas-content` に crsl-lib 準拠で新設**(§8.0)。`monas-content` は現状 crsl-lib 非依存。選択肢: -- (a) crsl-lib を `monas-content` 依存に追加し `Node::from_bytes`/`content_id()` を直接使う。確実だが DAG ライブラリ全体(leveldb 等)を持ち込む。 -- (b) **【推奨】`monas-content` に軽量 Node CID 計算を自前実装**: Node の CBOR を最小限デコード(`payload`/`parents`/`genesis`)+ 受信 CBOR 全体を SHA-256 → CIDv1(RAW/SHA2-256、`node.rs:76-81` と同一手順)。`serde_cbor` + `sha2` + `cid` で足りる。`content_id.rs:6` の TODO 回収も兼ねる。 -- → **(b) を採用**。CBOR スキーマ一致テスト(state-node が出す Node CBOR を `monas-content` が再計算して一致)を必須にする。crsl-lib のバージョンは rev pin(`Cargo.toml:51`)なのでスキーマ固定でよい。 +1. `read:{content_id}:{timestamp}` 署名の認証コンテキストを解決(gateway は auth ヘッダを転送) +2. 版を決定(明示指定、または履歴の最新) +3. Node CBOR を取得し CID 検証(**A**) +4. 最新読みなら単調性チェック(**B**) +5. ローカル cek_store から CEK を引き、AES-GCM 復号 + plain CID 照合 + (復号結果から plain content id を再計算して一致確認) -**(2) owner 公開鍵の入手経路**: `AccessPolicy` は state-node ドメインで content/SDK には無い。member 証明を owner 鍵で検証するには入手経路が要る: -- read 認可のために content/SDK は既に「自分の権限(委任トークン)」を持つ。そのトークンの `iss` が owner なので、**owner 公開鍵は委任トークンの `iss` から得られる**可能性が高い(要確認: `iss` が pubkey そのものか key_id か。`service.rs` の `owner_key_id = key_id_from_public_key(...)` を見る限り key_id。key_id から pubkey を復元できる形式か確認)。 -- **確認済み(2026-07-18)**: owner key_id は `user:{hex(public_key)}`(`service.rs:160-161` `key_id_from_public_key`)で**公開鍵そのものを内包する自己完結型**。委任トークンの `iss` から hex デコードするだけで owner 公開鍵が復元でき、**追加の取得 API・通信は不要**。member 証明の検証に必要な鍵は読み手が既に持つ委任トークンから取れる。 +入力は `content_id`(state node 側の id)と `local_content_id`(CEK 引き当てと +plain CID 照合に使う)の両方。local↔remote の対応表は存在しないため呼び出し側が渡す。 -### 8.7 実装中に判定する TODO +エラーは呼び出し側が対処を判断できる形に写像する: -- 単調性の祖先探索の深さ bound の既定値。8.2-2。 -- member 証明の配布経路(owner→member node)の具体。8.3-2。 -- member 証明トークンの永続化先(member node 側)。8.3-2。 -- `member_proof` の `iss` 露出緩和(読み手宛暗号化)は初版スコープ外・TODO 記録のみ(§6.1)。 +| 状況 | エラー | +|---|---| +| CID 不一致(改ざん) | Internal(検証失敗を明示) | +| 後退検出 / 探索上限 | Conflict | +| CEK がローカルに無い | NotFound(share envelope の処理を案内) | +| CEK で復号失敗 | Forbidden(CEK ローテーション後の鍵世代ずれ、または revoke の可能性を案内) | +| plain CID 不一致 | Conflict(content 更新後の古い local id の可能性を案内) | + +### 4.2 share 受信者の read と CEK のライフサイクル + +CEK はコンテンツ暗号鍵で、作成者は cek_store に保持している。share 受信者は +KeyEnvelope(受信者公開鍵で wrap された CEK)を受け取り、ローカルで unwrap して復号する。 + +- **CEK 永続化**: `decrypt_shared_content` の復号成功時 + (= CEK の正しさが証明された時点)に、unwrap 済み CEK を**受信者デバイスの + ローカル cek_store** に保存する。以後、受信者も state node 経由の検証付き read で + 復号できる。CEK も平文もネットワーク・state node には一切出ない + (state node は終始 ciphertext-only。E2E 暗号化の思想は不変)。 +- **ローテーション追従**: revoke は「reencrypt(CEK ローテーション)→ ACL 更新 → + 残存受信者向け KeyEnvelope 再発行」の順で行い、再発行 envelope を + `RevokeShareOutput.reissued_envelopes` として owner に返す。owner がこれを配布し、 + 受信者が `decrypt_shared_content` で再処理すると保存済み CEK が上書き更新される。 + 旧 CEK のまま新 ciphertext を読むと Forbidden で再処理へ誘導される。 +- revoke の安全性は受信者の鍵破棄(強制不能)ではなく **CEK ローテーション**に依存する。 + 取り消された受信者は過去に見た版を今後も復号できるが、それは平文を既に見ている以上 + 避けられず、脅威モデル上も許容される。ローテーション後の新しい版は復号できない。 + +## 5. 検証 + +- SDK 統合テスト(`monas-sdk/tests/state_read_integration_test.rs`): + 作成者 read 往復 / share 受信者 read(CEK 永続化)/ 改ざん Node 拒否 / + 単調性(TOFU・前進・後退・明示版指定)/ CEK ローテーション追従 +- 祖先探索の単体テスト(diamond DAG の重複排除、上限打ち切りの fail-closed 含む) +- crsl-lib との CID パリティテスト(`monas-content` 側) From 4cd34aac4ef3b7b85f95e506bca2868fb3158f81 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 12:24:33 +0900 Subject: [PATCH 19/48] test(e2e): standardize on 4-node topology; assert relay read via non-member creator (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e は常に 4 ノード(creator + 3 members, MIN_REPLICATION_FACTOR=3)で行う。 create_content は creator を意図的に member から除外し local CRDT も持たせない ため、3 ノードでは member 定足数を満たせず、また非 member 経由の relay read 経路(#54/#55 の本題)が一度も検証されない。 - start-local-nodes.sh: 3 -> 4 ノード起動(ci-e2e.sh と同一トポロジに統一) - e2e-test.sh: 全ノードループを NODE_PORTS(4 ポート)に統一。Step 2.6 を新設し、 非 member である creator ノード経由の read が data と version を返すことを 明示的に assert(relay read の直接の回帰テスト。スモーク範囲に含まれるので CI でも毎回検証される) - read 応答が Node CBOR になったため、ログはバイナリを直接表示せず サイズのみ出す。relay がある以上「data が返る = member」ではないので 誤解を招くラベルも修正 - cleanup/test-local-nodes/test-with-auth も 4 ノード対応 検証: 4 ノードを別ポートで実起動し、ポート置換のみのコピーでスモーク実行 (3/3 成功)。member 3 ノード停止後は creator への read が失敗することも確認 (= creator はローカル非保持で、成功していた read は relay 経由だった証明)。 Co-Authored-By: Claude Fable 5 --- .../scripts/cleanup-local-nodes.sh | 4 +- monas-state-node/scripts/e2e-test.sh | 90 +++++++++++++++---- monas-state-node/scripts/start-local-nodes.sh | 51 +++++++++-- monas-state-node/scripts/test-local-nodes.sh | 8 +- monas-state-node/scripts/test-with-auth.sh | 9 +- 5 files changed, 130 insertions(+), 32 deletions(-) diff --git a/monas-state-node/scripts/cleanup-local-nodes.sh b/monas-state-node/scripts/cleanup-local-nodes.sh index 0079135..f163e72 100755 --- a/monas-state-node/scripts/cleanup-local-nodes.sh +++ b/monas-state-node/scripts/cleanup-local-nodes.sh @@ -76,7 +76,7 @@ fi # ポートが使用されているか確認 log_info "ポートの使用状況を確認しています..." -for port in 8080 8081 8082; do +for port in 8080 8081 8082 8083; do if lsof -i:$port > /dev/null 2>&1; then log_warn "ポート $port がまだ使用されています" # ポートを使用しているプロセスを表示 @@ -90,7 +90,7 @@ done if [ "$1" = "--all" ] || [ "$1" = "--data" ]; then log_warn "データディレクトリを削除しています..." - for node in node1 node2 node3; do + for node in node1 node2 node3 node4; do if [ -d "data/$node" ]; then rm -rf "data/$node" log_info "data/$node を削除しました" diff --git a/monas-state-node/scripts/e2e-test.sh b/monas-state-node/scripts/e2e-test.sh index bead437..1e21494 100755 --- a/monas-state-node/scripts/e2e-test.sh +++ b/monas-state-node/scripts/e2e-test.sh @@ -23,6 +23,13 @@ CYAN='\033[0;36m' MAGENTA='\033[0;35m' NC='\033[0m' # No Color +# e2e は常に 4 ノード構成で行う(start-local-nodes.sh / ci-e2e.sh と一致)。 +# creator + 3 members(MIN_REPLICATION_FACTOR=3)。create を受けたノード +# (CREATOR_PORT)は意図的に member にならず local CRDT を持たないため、 +# そのノード経由の read は必ず relay read 経路(#54/#55)を通る。 +NODE_PORTS="8080 8081 8082 8083" +CREATOR_PORT=8080 + # ログ関数 log_info() { echo -e "${GREEN}[INFO]${NC} $1" @@ -121,7 +128,7 @@ check_nodes_running() { log_info "ノードの起動状態を確認しています..." local all_running=true - for port in 8080 8081 8082; do + for port in $NODE_PORTS; do if curl -s "http://127.0.0.1:$port/health" > /dev/null 2>&1; then log_success "ノード (ポート $port) は起動しています" else @@ -191,7 +198,7 @@ test_auth_request() { # ノード登録 register_nodes() { log_info "ノード登録を行います..." - for port in 8080 8081 8082; do + for port in $NODE_PORTS; do curl -s -X POST "http://127.0.0.1:$port/node/register" \ -H "Content-Type: application/json" \ -d '{"total_capacity": 10000000}' > /dev/null 2>&1 || true @@ -304,8 +311,8 @@ echo "" sleep 0.2 log_step "全ノードから content データを即座に取得できるか確認" -IMMEDIATE_MEMBERS=0 -for port in 8080 8081 8082; do +IMMEDIATE_SERVING=0 +for port in $NODE_PORTS; do generate_signature "$ACCOUNT1_PRIVATE_KEY" "read" "$CONTENT_ID" DATA_RESPONSE=$(curl -s "http://127.0.0.1:$port/content/$CONTENT_ID/data" \ -H "Authorization: Bearer $ACCOUNT1_KEY_ID" \ @@ -314,20 +321,22 @@ for port in 8080 8081 8082; do if echo "$DATA_RESPONSE" | jq -e '.data' > /dev/null 2>&1; then FETCHED_DATA=$(echo "$DATA_RESPONSE" | jq -r '.data' 2>/dev/null) if [ -n "$FETCHED_DATA" ] && [ "$FETCHED_DATA" != "null" ]; then - DECODED=$(echo "$FETCHED_DATA" | base64 -d 2>/dev/null || echo "(decode failed)") - log_info " ノード (ポート $port): data=$DECODED (member: data あり)" - IMMEDIATE_MEMBERS=$((IMMEDIATE_MEMBERS + 1)) + # 応答は Node 全体の CBOR なので中身は表示せずサイズだけログする + # (relay read があるため、data が返る = member とは限らない) + DATA_BYTES=$(echo "$FETCHED_DATA" | base64 -d 2>/dev/null | wc -c | tr -d ' ') + log_info " ノード (ポート $port): data あり (Node CBOR ${DATA_BYTES} bytes, local または relay)" + IMMEDIATE_SERVING=$((IMMEDIATE_SERVING + 1)) else - log_info " ノード (ポート $port): member だが data が空" + log_info " ノード (ポート $port): data が空" fi else - log_info " ノード (ポート $port): member ではない" + log_info " ノード (ポート $port): data なし" fi done log_test "少なくとも1つの member が create 直後にデータを保持していること (push-before-announce race の回帰防止)" -if [ "$IMMEDIATE_MEMBERS" -ge 1 ]; then - log_success "即時同期 OK: $IMMEDIATE_MEMBERS 個の member がデータを保持" +if [ "$IMMEDIATE_SERVING" -ge 1 ]; then + log_success "即時同期 OK: $IMMEDIATE_SERVING 個のノードがデータを提供 (local または relay)" TESTS_PASSED=$((TESTS_PASSED + 1)) else log_fail "即時同期 NG: どの member も create 直後にデータを取得できませんでした" @@ -335,7 +344,50 @@ else TESTS_FAILED=$((TESTS_FAILED + 1)) fi -# スモークモード: ここまで(content 作成 201 + 即時同期)で打ち切る。 +# ---------------------------------------------------------------------------- +# Step 2.6: 非 member ノード経由の relay read 検証 (#54/#55 の回帰テスト) +# ---------------------------------------------------------------------------- +# creator ノード(CREATOR_PORT)は create_content で意図的に member から除外され、 +# local CRDT コピーを持たない(state_node_service.rs の create_content 参照)。 +# したがってこのノードへの read が data を返す = member への relay read が +# 機能していることの直接の証明になる。 +# ============================================================================ + +echo "" +echo -e "${BLUE}=== Step 2.6: 非 member (creator) 経由の relay read 検証 ===${NC}" +echo "" + +relay_read_ok() { + generate_signature "$ACCOUNT1_PRIVATE_KEY" "read" "$CONTENT_ID" + local resp + resp=$(curl -s "http://127.0.0.1:$CREATOR_PORT/content/$CONTENT_ID/data" \ + -H "Authorization: Bearer $ACCOUNT1_KEY_ID" \ + -H "X-Request-Signature: $LAST_SIGNATURE" \ + -H "X-Request-Timestamp: $LAST_TIMESTAMP" 2>/dev/null) + RELAY_READ_RESPONSE="$resp" + local data + data=$(echo "$resp" | jq -r '.data // empty' 2>/dev/null) + [ -n "$data" ] && [ "$data" != "null" ] +} + +log_step "creator ノード (ポート $CREATOR_PORT, 非 member) へ read を送信 (最大10秒ポーリング)" +log_test "非 member ノード経由の relay read でデータと version が返ること" +if poll_until 10 1 relay_read_ok; then + RELAY_VERSION=$(echo "$RELAY_READ_RESPONSE" | jq -r '.version // empty' 2>/dev/null) + if [ -n "$RELAY_VERSION" ] && [ "$RELAY_VERSION" != "null" ]; then + log_success "relay read OK: version=$RELAY_VERSION (client はこの CID で Node CBOR を検証できる)" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + log_fail "relay read で data は返ったが version が空 (クライアント側 CID 検証が不可能になる)" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi +else + log_fail "非 member ノード経由の relay read が10秒以内に成功しませんでした" + log_fail " → member への read relay (#54) が機能していない可能性があります" + TESTS_FAILED=$((TESTS_FAILED + 1)) +fi + +# スモークモード: ここまで(content 作成 201 + 即時同期 + relay read)で打ち切る。 # CI の e2e ジョブはこのスモークだけを回し、request-response の DialFailure # 回帰(作成が 201 を返し、メンバーが即時にデータを保持する)をピンポイントで # 担保する。grant/revoke/invalidate を含むフルシナリオには別途の既知課題が @@ -356,7 +408,7 @@ fi # content_networkの形成を確認(各ノードでcontentsリストを確認) sleep 2 log_step "content_networkの形成を確認" -for port in 8080 8081 8082; do +for port in $NODE_PORTS; do count=$(curl -s "http://127.0.0.1:$port/contents" | jq '. | length' 2>/dev/null || echo "0") log_info " ノード (ポート $port): $count 個のコンテンツを認識" done @@ -444,7 +496,7 @@ echo "" # 固定の sleep 5 は遅い CI ランナーで伝播が間に合わず flaky になっていた。 updated_content_visible() { local p - for p in 8080 8081 8082; do + for p in $NODE_PORTS; do generate_signature "$ACCOUNT1_PRIVATE_KEY" "read" "$CONTENT_ID" local resp resp=$(curl -s "http://127.0.0.1:$p/content/$CONTENT_ID/data" \ @@ -463,7 +515,7 @@ log_step "gossipsubによる更新の伝播を待機 (最大15秒ポーリング poll_until 15 1 updated_content_visible || log_warn "15秒以内に更新の伝播を確認できませんでした(以降の検証で再確認します)" log_step "各ノードでcontentデータを取得し、更新が反映されていることを確認" -for port in 8080 8081 8082; do +for port in $NODE_PORTS; do generate_signature "$ACCOUNT1_PRIVATE_KEY" "read" "$CONTENT_ID" DATA_RESPONSE=$(curl -s "http://127.0.0.1:$port/content/$CONTENT_ID/data" \ -H "Authorization: Bearer $ACCOUNT1_KEY_ID" \ @@ -472,17 +524,17 @@ for port in 8080 8081 8082; do if echo "$DATA_RESPONSE" | jq -e '.data' > /dev/null 2>&1; then FETCHED_DATA=$(echo "$DATA_RESPONSE" | jq -r '.data' 2>/dev/null) - DECODED=$(echo "$FETCHED_DATA" | base64 -d 2>/dev/null || echo "(decode failed)") - log_info " ノード (ポート $port): data=$DECODED" + DATA_BYTES=$(echo "$FETCHED_DATA" | base64 -d 2>/dev/null | wc -c | tr -d ' ') + log_info " ノード (ポート $port): data あり (Node CBOR ${DATA_BYTES} bytes)" else - log_info " ノード (ポート $port): データ取得不可(memberでない可能性)" + log_info " ノード (ポート $port): データ取得不可" fi done # memberノードでデータを検証 log_test "少なくとも1つのノードで更新データが取得できること" VERIFIED=false -for port in 8080 8081 8082; do +for port in $NODE_PORTS; do generate_signature "$ACCOUNT1_PRIVATE_KEY" "read" "$CONTENT_ID" DATA_RESPONSE=$(curl -s "http://127.0.0.1:$port/content/$CONTENT_ID/data" \ -H "Authorization: Bearer $ACCOUNT1_KEY_ID" \ diff --git a/monas-state-node/scripts/start-local-nodes.sh b/monas-state-node/scripts/start-local-nodes.sh index 08e0816..27d88db 100755 --- a/monas-state-node/scripts/start-local-nodes.sh +++ b/monas-state-node/scripts/start-local-nodes.sh @@ -1,7 +1,13 @@ #!/bin/bash -# Monas State Node - 3ノード起動スクリプト -# このスクリプトは3つのState Nodeを起動し、P2Pネットワークを構築します +# Monas State Node - 4ノード起動スクリプト +# このスクリプトは4つのState Nodeを起動し、P2Pネットワークを構築します +# +# 4 ノードである理由: create_content は creator ノード自身を意図的に member から +# 除外する(local CRDT コピーも持たない)ため、MIN_REPLICATION_FACTOR=3 の +# member 定足数を満たすには creator + 3 members = 最低 4 ノードが必要。 +# さらに creator が必ず非 member になることで、creator 経由の read が +# relay read 経路(#54/#55)を確実に通る。e2e は常にこの 4 ノード構成で行う。 set -e @@ -34,11 +40,11 @@ log_info "State Nodeディレクトリ: $STATE_NODE_DIR" # データディレクトリのクリーンアップ(オプション) if [ "$1" == "--clean" ]; then log_warn "既存のデータを削除します..." - rm -rf data/node1 data/node2 data/node3 + rm -rf data/node1 data/node2 data/node3 data/node4 fi # データディレクトリの作成 -mkdir -p data/node1 data/node2 data/node3 +mkdir -p data/node1 data/node2 data/node3 data/node4 # PIDを保存するファイル PID_FILE="$STATE_NODE_DIR/.local-nodes.pids" @@ -66,6 +72,9 @@ cleanup() { trap cleanup INT TERM +# creator + 3 members の定足数(上記コメント参照)。デフォルト 3 を明示する。 +export MIN_REPLICATION_FACTOR="${MIN_REPLICATION_FACTOR:-3}" + # State Nodeバイナリのビルド log_info "State Nodeバイナリをビルドしています..." cargo build --bin state-node --release @@ -190,6 +199,33 @@ for i in {1..30}; do sleep 1 done +# ノード4の起動(ノード1に接続) +log_info "ノード4を起動しています..." +"$STATE_NODE_BIN" \ + --data-dir ./data/node4 \ + -l 127.0.0.1:8083 \ + --p2p-port 9094 \ + -b "$BOOTSTRAP_ADDR" \ + --log-level info \ + > "$LOG_DIR/node4.log" 2>&1 & +NODE4_PID=$! +echo "$NODE4_PID" >> "$PID_FILE" + +# ノード4の起動を待つ +log_info "ノード4の起動を待っています..." +for i in {1..30}; do + if curl -s http://127.0.0.1:8083/health > /dev/null 2>&1; then + log_info "ノード4が起動しました" + break + fi + if [ $i -eq 30 ]; then + log_error "ノード4の起動に失敗しました" + cleanup + exit 1 + fi + sleep 1 +done + # P2P接続が確立されるのを待つ log_info "P2P接続が確立されるのを待っています..." sleep 3 @@ -197,17 +233,19 @@ sleep 3 # ステータスの表示 echo "" echo -e "${BLUE}========================================${NC}" -echo -e "${BLUE} 3つのState Nodeが起動しました! ${NC}" +echo -e "${BLUE} 4つのState Nodeが起動しました! ${NC}" echo -e "${BLUE}========================================${NC}" echo "" echo "ノード1: http://127.0.0.1:8080" echo "ノード2: http://127.0.0.1:8081" echo "ノード3: http://127.0.0.1:8082" +echo "ノード4: http://127.0.0.1:8083 (create を受けるノードは member にならず relay 役になる)" echo "" echo "ログファイル:" echo " - $LOG_DIR/node1.log" echo " - $LOG_DIR/node2.log" echo " - $LOG_DIR/node3.log" +echo " - $LOG_DIR/node4.log" echo "" echo "ログをリアルタイムで確認:" echo " tail -f $LOG_DIR/node1.log" @@ -229,7 +267,8 @@ else # PIDが生きているかチェック if ! kill -0 "$NODE1_PID" 2>/dev/null || \ ! kill -0 "$NODE2_PID" 2>/dev/null || \ - ! kill -0 "$NODE3_PID" 2>/dev/null; then + ! kill -0 "$NODE3_PID" 2>/dev/null || \ + ! kill -0 "$NODE4_PID" 2>/dev/null; then log_error "いずれかのノードが停止しました" cleanup exit 1 diff --git a/monas-state-node/scripts/test-local-nodes.sh b/monas-state-node/scripts/test-local-nodes.sh index c2a7df2..ace3fda 100755 --- a/monas-state-node/scripts/test-local-nodes.sh +++ b/monas-state-node/scripts/test-local-nodes.sh @@ -83,7 +83,7 @@ check_nodes_running() { log_info "ノードの起動状態を確認しています..." local all_running=true - for port in 8080 8081 8082; do + for port in 8080 8081 8082 8083; do if curl -s "http://127.0.0.1:$port/health" > /dev/null 2>&1; then log_success "ノード (ポート $port) は起動しています" else @@ -116,6 +116,7 @@ echo "" test_request "ノード1のヘルスチェック" GET "http://127.0.0.1:8080/health" test_request "ノード2のヘルスチェック" GET "http://127.0.0.1:8081/health" test_request "ノード3のヘルスチェック" GET "http://127.0.0.1:8082/health" +test_request "ノード4のヘルスチェック" GET "http://127.0.0.1:8083/health" echo "" echo -e "${BLUE}=== ノード登録テスト ===${NC}" @@ -132,6 +133,9 @@ NODE2_ID=$(echo "$NODE2_INFO" | jq -r '.node_id') test_request "ノード3の情報取得" GET "http://127.0.0.1:8082/node/info" NODE3_INFO=$(curl -s http://127.0.0.1:8082/node/info) + +test_request "ノード4の情報取得" GET "http://127.0.0.1:8083/node/info" +NODE4_INFO=$(curl -s http://127.0.0.1:8083/node/info) NODE3_ID=$(echo "$NODE3_INFO" | jq -r '.node_id') # ノード登録 @@ -220,7 +224,7 @@ echo " - 'CRDT merge completed'" # ディスク容量の確認 echo "" log_test "ディスク容量の確認" -for port in 8080 8081 8082; do +for port in 8080 8081 8082 8083; do node_info=$(curl -s "http://127.0.0.1:$port/node/info") if [ $? -eq 0 ]; then total_capacity=$(echo "$node_info" | jq -r '.total_capacity // "不明"') diff --git a/monas-state-node/scripts/test-with-auth.sh b/monas-state-node/scripts/test-with-auth.sh index 30477a8..4acd847 100755 --- a/monas-state-node/scripts/test-with-auth.sh +++ b/monas-state-node/scripts/test-with-auth.sh @@ -84,7 +84,7 @@ check_nodes_running() { log_info "ノードの起動状態を確認しています..." local all_running=true - for port in 8080 8081 8082; do + for port in 8080 8081 8082 8083; do if curl -s "http://127.0.0.1:$port/health" > /dev/null 2>&1; then log_success "ノード (ポート $port) は起動しています" else @@ -97,7 +97,8 @@ check_nodes_running() { log_warn "一部のノードが起動していません。最低1つのノードで動作確認を行います" if ! curl -s "http://127.0.0.1:8080/health" > /dev/null 2>&1 && \ ! curl -s "http://127.0.0.1:8081/health" > /dev/null 2>&1 && \ - ! curl -s "http://127.0.0.1:8082/health" > /dev/null 2>&1; then + ! curl -s "http://127.0.0.1:8082/health" > /dev/null 2>&1 && \ + ! curl -s "http://127.0.0.1:8083/health" > /dev/null 2>&1; then log_error "ノードが1つも起動していません。先に ./scripts/start-local-nodes.sh を実行してください" exit 1 fi @@ -124,6 +125,8 @@ if ! curl -s "http://127.0.0.1:8080/health" > /dev/null 2>&1; then TEST_PORT=8081 elif curl -s "http://127.0.0.1:8082/health" > /dev/null 2>&1; then TEST_PORT=8082 + elif curl -s "http://127.0.0.1:8083/health" > /dev/null 2>&1; then + TEST_PORT=8083 fi fi @@ -361,7 +364,7 @@ if curl -s "http://127.0.0.1:8080/health" > /dev/null 2>&1 && \ # 各ノードでコンテンツを確認 log_test "各ノードでコンテンツリストを確認" - for port in 8080 8081 8082; do + for port in 8080 8081 8082 8083; do count=$(curl -s "http://127.0.0.1:$port/contents" | jq '. | length' 2>/dev/null || echo "0") log_info "ノード (ポート $port): $count 個のコンテンツ" done From dfadd1018797742fc667fa63521f123b1e0a7920 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 18 Jul 2026 12:39:45 +0900 Subject: [PATCH 20/48] docs(design): fold read-integrity design into design.md security model; drop docs/design/ (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/design/ ディレクトリはこのブランチで新設したものだが、リポジトリの ドキュメント運用は docs/design.md 単一ファイル + 各クレート README であり、 運用変更の合意なくディレクトリを増やすべきではなかった。 設計の要点(read 応答の完全性検証 / 共有コンテンツの CEK ライフサイクル)は design.md §10 セキュリティモデルへ既存のトーンで統合し、詳細な設計判断・ 実装フローは PR #56 の説明文に記載する。 Co-Authored-By: Claude Fable 5 --- docs/design/read-response-integrity.md | 140 ------------------------- 1 file changed, 140 deletions(-) delete mode 100644 docs/design/read-response-integrity.md diff --git a/docs/design/read-response-integrity.md b/docs/design/read-response-integrity.md deleted file mode 100644 index e043f2e..0000000 --- a/docs/design/read-response-integrity.md +++ /dev/null @@ -1,140 +0,0 @@ -# read 経路の完全性: 応答データの E2E 検証 - -- 関連: PR #54(read relay)、issue #55(セキュリティ指摘)、PR #56(実装) -- ステータス: 実装済み - -relay 経由の read で返ってくるデータ・版・履歴には、もともと署名も系列検証もなかった。 -本設計は、read 応答をクライアント側で暗号学的に検証し、さらに state node から -読んで復号する実 read 経路までを定義する。 - -## 1. 脅威モデル - -### 1.1 前提: libp2p が保証しない範囲 - -libp2p(Noise トランスポート)が保証するのは**各ホップの相手 PeerID が本物であること**だけ。 - -- 多段 relay(A→B→C)で A が検証できるのは「B と話した」ことまで。C が誰か、 - B が C の応答を正直に転送したかはトランスポート層に現れない。中間ノードは中身を差し替えられる。 -- PeerID は「member であること」を語らない(member は Monas アプリ層の概念)。 - -したがって read 応答の検証はアプリ層で行うしかない。 - -### 1.2 防ぐ攻撃 - -| 攻撃 | 防御 | -|---|---| -| 非 member / 中間ノードによる偽データ・偽版・偽履歴の注入 | **A: 版真正性**(§2) | -| ロールバック(過去の本物の版を「最新」と偽って返す) | **B: 単調性**(§3) | - -なお #54 時点で対処済みのもの: 読み取り署名の content_id バインド -(`read:{content_id}:{timestamp}`)による credential 再利用の防止、 -AES-256-GCM(AEAD)による暗号文本文の改ざん検知。 - -### 1.3 防がない(既知の限界) - -**正規 member 自身による stale 提示のうち、クライアントが一度も見ていない範囲**は検出できない。 -「より新しい版が存在しない」という否定的事実はネットワーク越しに証明不能なため。 -sync 遅延による一時的な stale read は結果整合性として正常な仕様であり、守るべき挙動。 -攻撃と区別できるのは「クライアントが既に受理した版より後退したとき」だけで、それは B が検出する。 - -## 2. コンポーネント A: 版真正性(CID 再計算) - -state node は read 応答として、暗号文の生バイトではなく **crsl-lib `Node` 全体(CBOR)** を返す。 -クライアントは受け取った CBOR バイト列から CID を再計算し -(`CIDv1(RAW, SHA2-256)`、crsl-lib の `Node::content_id()` と同一)、 -要求した版 CID と一致することを検証する。 - -- CID はバイト列そのもののハッシュなので、一致すれば payload(暗号文)・parents・ - genesis・timestamp・metadata すべてが真正。**正しい CID を持つ偽 Node は作れない**ため、 - 応答を返した相手が誰であっても改ざんは弾ける。署名は不要。 -- local 分岐・relay 分岐とも同じ Node CBOR 形式で返す(クライアントは分岐を意識せず同一検証)。 - -実装: -- state node: `port/content_repository.rs` の `get_latest_node_bytes_with_version` / - `get_version_node_bytes`、`presentation/http_api.rs`(`/content/:id/data`, `/content/:id/version/:version`) -- クライアント: `monas-content/src/infrastructure/node_verification.rs` - (`recompute_node_cid` / `verify_and_extract`)。CID 再計算が crsl-lib の - `Node::content_id()` とバイト一致することはパリティテストで担保 - (`cid` / `multihash` / `serde_cbor` のバージョンを crsl-lib に pin)。 - -### member 証明を採用しない理由 - -「owner が member 追加時に証明トークンを発行し、node が read 応答に添付する」案は -検討の上**不採用**とした。Monas では member は DHT 複製配置 -(`add_member_to_content`)によって **owner の関与なく自律的に増減・入れ替わる**ため、 -「owner が member 追加時に発行する」という経路がそもそも成立しない。 -そして A により、データが暗号学的に正しければ返した相手の身元確認は不要になる。 - -## 3. コンポーネント B: 単調性(ロールバック検出) - -クライアントは content ごとに「最後に受理した版 CID」(last_seen)をローカルに記録し、 -最新読みの結果が last_seen の**子孫**(または同一)であることを確認する。 - -- 祖先判定は、今回受理した Node の parents から **CID 検証済みの親リンクだけ**を辿る - (祖先 Node も版指定 read で取得し、A と同じ CID 検証を通す)。偽の親リンクで - last_seen を祖先に見せかけることはできない。 -- 初回(記録なし)は TOFU で受理して記録。検証通過後に last_seen を更新。 -- 探索は fetch 上限 256 で打ち切り、**fail-closed**(拒否)。攻撃者が偽の深い DAG で - クライアントに際限なく fetch させる DoS を防ぐ。 -- 後退検出時は Conflict エラー(「ロールバック攻撃または stale relay の可能性」)。 -- **版を明示指定した read は対象外**(過去の版を意図的に読む正当な操作。A のみ適用され、 - last_seen も更新しない)。 -- 履歴 API(版 CID リスト)自体は無検証のままだが、履歴は「どの版を読むか選ぶ」ためだけに - 使われ、選んだ版の中身は A、新しさは B が守る。 - -実装: `monas-content/src/infrastructure/last_seen_version_store.rs` -(sled: 既存 DB に `last_seen:` prefix で同居 / in-memory)、 -`monas-sdk/src/controller/state.rs`(`walk_ancestors_for`, `enforce_read_monotonicity`)。 - -## 4. 実 read 経路 - -検証機構だけでは使えないため、state node から読んで復号する経路を SDK / gateway に用意する。 - -### 4.1 フロー(`read_content_from_state_node` / gateway `POST /state/read`) - -1. `read:{content_id}:{timestamp}` 署名の認証コンテキストを解決(gateway は auth ヘッダを転送) -2. 版を決定(明示指定、または履歴の最新) -3. Node CBOR を取得し CID 検証(**A**) -4. 最新読みなら単調性チェック(**B**) -5. ローカル cek_store から CEK を引き、AES-GCM 復号 + plain CID 照合 - (復号結果から plain content id を再計算して一致確認) - -入力は `content_id`(state node 側の id)と `local_content_id`(CEK 引き当てと -plain CID 照合に使う)の両方。local↔remote の対応表は存在しないため呼び出し側が渡す。 - -エラーは呼び出し側が対処を判断できる形に写像する: - -| 状況 | エラー | -|---|---| -| CID 不一致(改ざん) | Internal(検証失敗を明示) | -| 後退検出 / 探索上限 | Conflict | -| CEK がローカルに無い | NotFound(share envelope の処理を案内) | -| CEK で復号失敗 | Forbidden(CEK ローテーション後の鍵世代ずれ、または revoke の可能性を案内) | -| plain CID 不一致 | Conflict(content 更新後の古い local id の可能性を案内) | - -### 4.2 share 受信者の read と CEK のライフサイクル - -CEK はコンテンツ暗号鍵で、作成者は cek_store に保持している。share 受信者は -KeyEnvelope(受信者公開鍵で wrap された CEK)を受け取り、ローカルで unwrap して復号する。 - -- **CEK 永続化**: `decrypt_shared_content` の復号成功時 - (= CEK の正しさが証明された時点)に、unwrap 済み CEK を**受信者デバイスの - ローカル cek_store** に保存する。以後、受信者も state node 経由の検証付き read で - 復号できる。CEK も平文もネットワーク・state node には一切出ない - (state node は終始 ciphertext-only。E2E 暗号化の思想は不変)。 -- **ローテーション追従**: revoke は「reencrypt(CEK ローテーション)→ ACL 更新 → - 残存受信者向け KeyEnvelope 再発行」の順で行い、再発行 envelope を - `RevokeShareOutput.reissued_envelopes` として owner に返す。owner がこれを配布し、 - 受信者が `decrypt_shared_content` で再処理すると保存済み CEK が上書き更新される。 - 旧 CEK のまま新 ciphertext を読むと Forbidden で再処理へ誘導される。 -- revoke の安全性は受信者の鍵破棄(強制不能)ではなく **CEK ローテーション**に依存する。 - 取り消された受信者は過去に見た版を今後も復号できるが、それは平文を既に見ている以上 - 避けられず、脅威モデル上も許容される。ローテーション後の新しい版は復号できない。 - -## 5. 検証 - -- SDK 統合テスト(`monas-sdk/tests/state_read_integration_test.rs`): - 作成者 read 往復 / share 受信者 read(CEK 永続化)/ 改ざん Node 拒否 / - 単調性(TOFU・前進・後退・明示版指定)/ CEK ローテーション追従 -- 祖先探索の単体テスト(diamond DAG の重複排除、上限打ち切りの fail-closed 含む) -- crsl-lib との CID パリティテスト(`monas-content` 側) From 8112ed22feadbec9ceeda6d92cf0e172574115ce Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 25 Jul 2026 15:18:50 +0900 Subject: [PATCH 21/48] fix(sdk): bind CID checks to client-selected version; record last_seen only after full verification (#55) Three hardening fixes to the verified read path, from deep-review blockers: - verify_integrity now verifies the returned Node against the client-selected version instead of the response's self-reported version field. Verifying against the self-reported version degraded the check to self-consistency: any forged Node passes by shipping its own CID. Regression test included. - last_seen (read monotonicity pin) is now recorded only after every verification step including decryption succeeds, via compare-and-swap on the store. Previously the pin advanced before decryption, so one forged-but-CID-consistent undecryptable Node would poison the pin and permanently Conflict all subsequent legitimate reads (self-inflicted DoS on the monotonicity check). The CAS also stops a slow read from rolling the pin back over a concurrent read that advanced it. - decrypt_shared_content now returns an error when persisting the unwrapped CEK fails, instead of logging to stderr and reporting success while later state-node reads would fail with MissingKey. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J --- .../infrastructure/last_seen_version_store.rs | 78 ++++++++++++++++++- monas-sdk/src/controller/share.rs | 15 ++-- monas-sdk/src/controller/state.rs | 66 ++++++++++------ .../state_controller_integration_test.rs | 65 ++++++++++++++++ .../tests/state_read_integration_test.rs | 75 ++++++++++++++++++ 5 files changed, 268 insertions(+), 31 deletions(-) diff --git a/monas-content/src/infrastructure/last_seen_version_store.rs b/monas-content/src/infrastructure/last_seen_version_store.rs index ba12f8d..89fda54 100644 --- a/monas-content/src/infrastructure/last_seen_version_store.rs +++ b/monas-content/src/infrastructure/last_seen_version_store.rs @@ -1,6 +1,6 @@ //! Client-side store of the last version CID observed per (remote) content id, -//! backing the read monotonicity check (component B of -//! `docs/design/read-response-integrity.md`). +//! backing the read monotonicity check +//! (`docs/design.md` §10「read応答の完全性検証」). //! //! A client records the newest CID-verified version it has accepted for each //! content. On a later "latest" read it walks the returned node's verified @@ -28,6 +28,20 @@ pub trait LastSeenVersionStore: Send + Sync { remote_content_id: &str, version_cid: &str, ) -> Result<(), LastSeenVersionStoreError>; + + /// compare-and-advance: 現在値が `expected` と一致する場合のみ `version_cid` + /// へ進める。戻り値は「進めたかどうか」。 + /// + /// 単調性チェック(load)と記録(save)の間には復号などの検証が挟まるため、 + /// 無条件 save だと並行 read が進めた pin を古い版で巻き戻し得る。 + /// チェック時に観測した値を `expected` に渡すことで、pin は + /// 「検証済みの前進」でしか動かないことを保証する。 + fn compare_and_save( + &self, + remote_content_id: &str, + expected: Option<&str>, + version_cid: &str, + ) -> Result; } /// プロセス内 `HashMap` 実装。テスト・開発用(再起動で揮発 = 毎回 TOFU に戻る)。 @@ -57,6 +71,23 @@ impl LastSeenVersionStore for InMemoryLastSeenVersionStore { guard.insert(remote_content_id.to_string(), version_cid.to_string()); Ok(()) } + + fn compare_and_save( + &self, + remote_content_id: &str, + expected: Option<&str>, + version_cid: &str, + ) -> Result { + let mut guard = self + .inner + .lock() + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; + if guard.get(remote_content_id).map(String::as_str) != expected { + return Ok(false); + } + guard.insert(remote_content_id.to_string(), version_cid.to_string()); + Ok(true) + } } /// sled 実装。キーは `"last_seen:{remote_content_id}"`。 @@ -102,6 +133,29 @@ impl LastSeenVersionStore for SledLastSeenVersionStore { .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; Ok(()) } + + fn compare_and_save( + &self, + remote_content_id: &str, + expected: Option<&str>, + version_cid: &str, + ) -> Result { + let swapped = self + .db + .compare_and_swap( + Self::sled_key(remote_content_id), + expected.map(str::as_bytes), + Some(version_cid.as_bytes()), + ) + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))? + .is_ok(); + if swapped { + self.db + .flush() + .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; + } + Ok(swapped) + } } #[cfg(test)] @@ -120,6 +174,26 @@ mod tests { // 別 content には影響しない assert!(store.load("content-b").unwrap().is_none()); + + // compare-and-advance: 期待値が一致すれば進む + assert!(store + .compare_and_save("content-a", Some("cid-v2"), "cid-v3") + .unwrap()); + assert_eq!(store.load("content-a").unwrap().as_deref(), Some("cid-v3")); + + // 期待値が古ければ(並行 read が先に進めていれば)巻き戻さない + assert!(!store + .compare_and_save("content-a", Some("cid-v2"), "cid-v4") + .unwrap()); + assert_eq!(store.load("content-a").unwrap().as_deref(), Some("cid-v3")); + + // 未記録(None)期待の初回書き込み + assert!(store.compare_and_save("content-c", None, "cid-v1").unwrap()); + assert_eq!(store.load("content-c").unwrap().as_deref(), Some("cid-v1")); + + // 既に記録があるのに None 期待では書けない + assert!(!store.compare_and_save("content-c", None, "cid-v9").unwrap()); + assert_eq!(store.load("content-c").unwrap().as_deref(), Some("cid-v1")); } #[test] diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index 898b945..82c4204 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -708,11 +708,16 @@ impl MonasController { // KeyEnvelope を処理すれば保存済み CEK も新しいものへ追従する。 // CEK が出るのは受信者デバイスのローカルストアまでで、ネットワークには出ない。 if let Err(e) = self.content_service.cek_store.save(&content_id, &cek) { - // 復号自体は成功しているので致命ではないが、後続の state node read が - // MissingKey で失敗する原因になるため警告は残す。 - eprintln!( - "monas-sdk: failed to persist unwrapped CEK for {} (state-node reads of this shared content will fail until a KeyEnvelope is processed again): {e}", - content_id.as_str() + // 保存に失敗したまま成功を返すと、呼び出し側は「以後この端末で + // 検証付き read ができる」と信じるのに実際は MissingKey で失敗する。 + // silent degradation を避けるためエラーとして返す(再処理可能)。 + return ApiResponse::error( + ApiError::Internal(format!( + "decrypted the shared content but failed to persist its CEK for {}: {e}. \ + Re-process the KeyEnvelope to enable state-node reads on this device.", + content_id.as_str() + )), + trace_id, ); } diff --git a/monas-sdk/src/controller/state.rs b/monas-sdk/src/controller/state.rs index 0ac047f..f77951c 100644 --- a/monas-sdk/src/controller/state.rs +++ b/monas-sdk/src/controller/state.rs @@ -17,7 +17,7 @@ use crate::models::state_node::{StateNodeContentDataResponse, StateNodeContentHi use super::MonasController; /// read 単調性チェックの記録先 -/// (`docs/design/read-response-integrity.md` コンポーネント B)。 +/// (`docs/design.md` §10「read応答の完全性検証」の単調性)。 pub(super) type DynLastSeenStore = std::sync::Arc< dyn monas_content::infrastructure::last_seen_version_store::LastSeenVersionStore, >; @@ -325,7 +325,7 @@ impl MonasController { /// State Node から content を読み、検証・復号して平文を返す(検証付き read)。 /// - /// `docs/design/read-response-integrity.md` の実 read 経路。処理フロー: + /// `docs/design.md` §10「read応答の完全性検証」の実 read 経路。処理フロー: /// 1. `read:{content_id}:{timestamp}` 署名の認証コンテキストを解決 /// 2. 版を決定(`input.version` 指定があればその版、無ければ履歴の最新) /// 3. Node CBOR を取得し、CID 再計算で改ざん検証(コンポーネント A) @@ -428,17 +428,24 @@ impl MonasController { // 単調性チェック(B)。最新読みのときだけ働く。版を明示指定した read は // 「過去の版を意図的に読む」正当な操作なので、A(CID 検証)のみ。 - if is_latest_read { - if let Err(e) = self.enforce_read_monotonicity( + // ここではチェックのみ行い、last_seen の記録は復号まで含む全検証が + // 成功した後に行う。チェック通過直後に記録すると、CID は通るが復号 + // できない偽 Node を 1 回受けるだけで pin が汚染され、以後の正規 read + // が恒久的に Conflict になる(単調性チェックの自壊 DoS)。 + let checked_last_seen = if is_latest_read { + match self.check_read_monotonicity( &input.content_id, &version, &verified.parents, auth, &trace_id, ) { - return *e; + Ok(last_seen) => Some(last_seen), + Err(e) => return *e, } - } + } else { + None + }; // CEK ロード + AES-GCM 復号 + plain CID 照合 let local_content_id = @@ -457,6 +464,24 @@ impl MonasController { } }; + // 全検証成功。last_seen を compare-and-advance で記録する。 + // チェック時に観測した値から動いていた場合(並行 read が先に進めた)は + // 上書きせずスキップする — 古い版で pin を巻き戻さないため。 + if let Some(expected) = checked_last_seen { + if expected.as_deref() != Some(version.as_str()) { + if let Err(e) = self.last_seen_store.compare_and_save( + &input.content_id, + expected.as_deref(), + &version, + ) { + return ApiResponse::error( + ApiError::Internal(format!("failed to record last-seen version: {e}")), + trace_id, + ); + } + } + } + ApiResponse::success( ReadContentFromStateNodeOutput { content_id: input.content_id, @@ -470,15 +495,16 @@ impl MonasController { /// 最新読みの単調性チェック本体。前回受理した版(`last_seen`)が今回の版の /// 祖先(または同一)であることを、CID 検証済みの親リンクを辿って確認する。 - /// 通過したら `last_seen` を今回の版へ更新する。 - fn enforce_read_monotonicity( + /// チェックのみ行い、記録はしない(記録は復号成功後に呼び出し側が + /// compare-and-advance で行う)。戻り値はチェック時に観測した `last_seen`。 + fn check_read_monotonicity( &self, remote_content_id: &str, version: &str, parents: &[String], auth: Option<&StateNodeAuthContext>, trace_id: &str, - ) -> Result<(), Box>> { + ) -> Result, Box>> { let last_seen = self.last_seen_store.load(remote_content_id).map_err(|e| { Box::new(ApiResponse::error( ApiError::Internal(format!("failed to load last-seen version: {e}")), @@ -487,10 +513,10 @@ impl MonasController { })?; match last_seen.as_deref() { - // 初回(記録なし)は TOFU で受理し、下で記録する。 + // 初回(記録なし)は TOFU で受理する(記録は復号成功後)。 None => {} // 同じ版を読み直しただけ。 - Some(l) if l == version => return Ok(()), + Some(l) if l == version => {} Some(l) => { let outcome = walk_ancestors_for(parents, l, MAX_MONOTONICITY_FETCHES, |cid| { self.fetch_verified_parents(remote_content_id, cid, auth, trace_id) @@ -528,14 +554,7 @@ impl MonasController { } } - self.last_seen_store - .save(remote_content_id, version) - .map_err(|e| { - Box::new(ApiResponse::error( - ApiError::Internal(format!("failed to record last-seen version: {e}")), - trace_id.to_string(), - )) - }) + Ok(last_seen) } /// `verify_and_decrypt_relay_read` のエラーを、呼び出し側が対処を判断できる @@ -670,13 +689,12 @@ impl MonasController { // State Node は read 応答として「Node 全体(CBOR)」を返す。まず CID を // 再計算して version と一致することを検証し(改ざん検知)、その上で - // payload の暗号文を取り出す(§8.1)。 + // payload の暗号文を取り出す(§8.1)。照合先はクライアントが選択した + // version に固定する。応答内の version は自己申告なので、それに対して + // 照合すると任意の Node + その CID を返すだけで検証が通ってしまう。 let state_bytes = match monas_content::infrastructure::node_verification::verify_and_extract( &node_bytes, - &state_node_data - .version - .clone() - .unwrap_or(version_to_check.clone()), + &version_to_check, ) { Ok(verified) => verified.ciphertext, Err(e) => { diff --git a/monas-sdk/tests/state_controller_integration_test.rs b/monas-sdk/tests/state_controller_integration_test.rs index 33aa29f..2b2592c 100644 --- a/monas-sdk/tests/state_controller_integration_test.rs +++ b/monas-sdk/tests/state_controller_integration_test.rs @@ -303,6 +303,71 @@ async fn verify_integrity_keeps_false_only_for_actual_content_mismatch() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn verify_integrity_rejects_forged_node_with_self_reported_version() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + + // 攻撃シナリオ: クライアントは version V の検証を要求しているのに、 + // state node(攻撃者)は「クライアントの content と一致する偽 Node」と + // その偽 Node 自身の CID を version フィールドに詰めて返す。 + // 応答内の自己申告 version に対して CID 照合すると必ず通ってしまうため、 + // 照合はクライアントが選択した version に束縛されなければならない。 + let real_node_bytes = support::node_mirror::make_node_bytes(b"secret-original", vec![], None); + let real_version = + monas_content::infrastructure::node_verification::recompute_node_cid(&real_node_bytes) + .unwrap(); + + let forged_node_bytes = support::node_mirror::make_node_bytes(b"hello", vec![], None); + let forged_cid = + monas_content::infrastructure::node_verification::recompute_node_cid(&forged_node_bytes) + .unwrap(); + assert_ne!(real_version, forged_cid); + + let body = serde_json::json!({ + "content_id": "test-content", + "data": base64::engine::general_purpose::STANDARD.encode(&forged_node_bytes), + "version": forged_cid, + }); + let version_mock = server + .mock( + "GET", + format!("/content/test-content/version/{real_version}").as_str(), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(body.to_string()) + .create_async() + .await; + + let controller = MonasController::with_state_node_url(server.url()); + let response = controller.verify_integrity( + VerifyIntegrityInput { + content_id: "test-content".into(), + content: URL_SAFE_NO_PAD.encode(b"hello"), + expected_version: Some(real_version.clone()), + local_content_id: None, + }, + None, + ); + + assert!(response.success, "verification itself should complete"); + version_mock.assert(); + let output = response.data.expect("verify_integrity should return data"); + assert!( + !output.valid, + "forged node must fail verification against the client-selected version" + ); + assert!( + output + .reason + .as_deref() + .is_some_and(|reason| reason.contains("CID verification")), + "reason should point at CID verification failure: {:?}", + output.reason + ); +} + #[tokio::test(flavor = "multi_thread")] async fn verify_integrity_returns_api_error_for_invalid_state_node_base64() { let _guard = acquire_test_lock(); diff --git a/monas-sdk/tests/state_read_integration_test.rs b/monas-sdk/tests/state_read_integration_test.rs index 0e6def6..befccda 100644 --- a/monas-sdk/tests/state_read_integration_test.rs +++ b/monas-sdk/tests/state_read_integration_test.rs @@ -494,6 +494,81 @@ async fn read_rejects_tampered_node() { cleanup_content_artifacts(); } +/// last_seen は「復号まで含む全検証が成功した read」でしか進んではならない。 +/// CID 検証は通るが復号できない偽 Node を 1 回受けただけで pin が偽版に +/// 汚染されると、以後の正規 read が恒久的に Conflict になる(自壊 DoS)。 +#[tokio::test(flavor = "multi_thread")] +async fn failed_decrypt_does_not_poison_last_seen_pin() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + let controller = MonasController::with_urls(server.url(), server.url()); + + let plaintext = b"pin-poison-target"; + let created = create_and_share(&mut server, &controller, plaintext).await; + + let genesis_bytes = make_node_bytes(&created.ciphertext, vec![], None); + let genesis_cid = recompute_node_cid(&genesis_bytes).unwrap(); + + // 攻撃者が鋳造した「CID は正しいが CEK で復号できない」偽 Node。 + // parents に正規 genesis を入れて単調性チェックも通す。 + let forged_bytes = make_node_bytes( + b"garbage-not-encrypted-with-cek", + vec![&genesis_cid], + Some(&genesis_cid), + ); + let forged_cid = recompute_node_cid(&forged_bytes).unwrap(); + + // 正規の次版 v2(genesis の子、正規 ciphertext)。 + let v2_bytes = make_node_bytes(&created.ciphertext, vec![&genesis_cid], Some(&genesis_cid)); + let v2_cid = recompute_node_cid(&v2_bytes).unwrap(); + + let _g_data = mock_version_data(&mut server, &genesis_cid, &genesis_bytes).await; + let _forged_data = mock_version_data(&mut server, &forged_cid, &forged_bytes).await; + let _v2_data = mock_version_data(&mut server, &v2_cid, &v2_bytes).await; + + let read_latest = || { + controller.read_content_from_state_node( + ReadContentFromStateNodeInput { + content_id: REMOTE_ID.into(), + local_content_id: created.local_content_id.clone(), + version: None, + }, + None, + ) + }; + + // 1. 初回(TOFU): latest = g を受理、last_seen = g + let history_g = mock_history(&mut server, &[&genesis_cid]).await; + let first = read_latest(); + assert!(first.success, "TOFU read should succeed: {:?}", first.error); + history_g.remove_async().await; + + // 2. 偽 Node を latest として受ける: CID 検証・単調性は通るが復号で失敗する + let history_forged = mock_history(&mut server, &[&genesis_cid, &forged_cid]).await; + let poisoned = read_latest(); + assert!(!poisoned.success, "undecryptable forged node must fail"); + assert!( + matches!(poisoned.error, Some(ApiError::Forbidden(_))), + "expected Forbidden(decrypt failure), got: {:?}", + poisoned.error + ); + history_forged.remove_async().await; + + // 3. 正規の latest = v2 は引き続き受理される。 + // (pin が forged_cid に汚染されていれば、v2 の祖先に forged が居ないため + // Conflict になってしまう — それが修正前のバグ) + let _history_v2 = mock_history(&mut server, &[&genesis_cid, &v2_cid]).await; + let legit = read_latest(); + assert!( + legit.success, + "legitimate read after failed decrypt must still succeed (pin must not be poisoned): {:?}", + legit.error + ); + assert_eq!(legit.data.unwrap().version, v2_cid); + + cleanup_content_artifacts(); +} + #[tokio::test(flavor = "multi_thread")] async fn read_monotonicity_accepts_forward_and_rejects_regression() { let _guard = acquire_test_lock(); From 5d9062e1be53b04a9508af55f79d606c26ef505c Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 25 Jul 2026 15:18:51 +0900 Subject: [PATCH 22/48] docs(design): commit read-integrity sections into design.md with honest limits; repoint stale doc refs (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design.md §10 integration announced in earlier commits was never actually committed (the edit sat unstaged while docs/design/ was removed). This commits it, with the framing corrected per review: - "版真正性" is now "payload真正性": CID verification binds the response to the requested version and AES-GCM + plain-CID check proves the payload is genuine, but it does NOT prove the version itself was created by a legitimate writer. - New known limitation: version metadata (parents / version CIDs) can be forged by re-wrapping observed ciphertext into a new Node, letting a relay observer bypass the monotonicity check with forged parents. The real fix is an owner-signature trust anchor on Nodes (protocol change reaching crsl-lib), tracked separately. Also repoints the nine code references to the deleted docs/design/read-response-integrity.md at design.md §10, and drops the stale membership-proof mention (member proof was not adopted). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J --- docs/design.md | 20 +++++++++++++++++++ .../content_service/service.rs | 6 +++--- .../src/infrastructure/node_verification.rs | 2 +- monas-sdk/src/controller/mod.rs | 2 +- .../application_service/state_node_service.rs | 2 +- .../src/port/content_repository.rs | 2 +- monas-state-node/src/presentation/http_api.rs | 2 +- 7 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/design.md b/docs/design.md index 255b640..c1adb6b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -344,6 +344,26 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。 ネットワークはビザンチン耐性を前提として設計されている。悪意のあるノードが参加してもコンテンツの暗号化によって内容の漏洩は防がれる。XOR距離によるランダムなノード選択が一定の保護を提供する。 +### read応答の完全性検証 + +libp2pのトランスポート認証が保証するのは隣接ホップの相手が本物であることだけで、relay越しに返ってきたデータが正しいかは保証しない。read応答はクライアント側で以下の2段で検証する。 + +- **payload真正性**: state-nodeはreadに対しcrsl-lib Node全体(CBOR)を返し、クライアントがCIDを再計算して要求した版CIDと照合する。CIDはバイト列そのもののハッシュなので、一致すれば応答は要求した版に束縛され、返した相手が誰か(memberか否か)の確認は不要。さらにCEKでのAES-GCM復号 + 平文CID照合により、payloadが正規のCEKで暗号化された本物であることまで検証される — CEKを持たない攻撃者は復号可能な偽payloadを注入できない。 +- **単調性**: クライアントはコンテンツごとに最後に受理した版CIDを記録し、最新読みの結果がその子孫であることをCID検証済みの親リンクだけを辿って確認する。後退していれば、過去の本物の版を「最新」と偽るロールバックとして拒否する(初回はTOFUで受理、探索は上限付きfail-closed)。 + +member証明(ownerがmemberを認証するトークン)は採用しない。memberはDHT複製配置によりownerの関与なく増減するため「ownerがmember追加時に発行する」経路が成立せず、payload真正性があれば不要でもある。 + +既知の限界が2つある。 + +1. **版メタデータの真正性は未保証**: CID照合が保証するのは「バイト列が要求した版CIDに一致すること」であり、「その版が正規の書き込みとして作られたこと」ではない。relay上で暗号文を観測できる攻撃者は、観測済みの本物の暗号文を新しいNodeに包み直し、任意のparentsを詰めた「偽の版」を鋳造できる(payloadは本物なので復号も通る)。単調性チェックはparentsを信頼して祖先を辿るため、last_seenをparentsに含めた偽版でbypassされ得る。本修正はNodeへのowner署名等のtrust anchorであり、crsl-libに及ぶプロトコル変更として別issueで追跡する。 +2. **正規memberのstale提示**: 正規memberがクライアント未見の範囲で古い版を「最新」と提示することは検出できない(「より新しい版が無い」という否定的事実は証明不能)。 + +### 共有コンテンツのCEKライフサイクル + +share受信者はKeyEnvelopeの復号成功時にunwrap済みCEKを自デバイスのローカルストアへ保存し、以後は自身もstate-nodeからの検証付きreadで復号できる。CEK・平文がデバイス外に出ることはない(state-nodeは常に暗号文のみを扱う)。 + +アクセス取り消しの安全性は受信者の鍵破棄(強制不能)ではなくCEKローテーションに依存する。revoke時は再暗号化を先に行い、残存受信者にはローテーション後のCEKでKeyEnvelopeを再発行する。受信者が再発行envelopeを処理すると保存済みCEKが更新され、旧CEKのままでは新しい版を復号できない。 + --- ## 11. CRSLとCRDT diff --git a/monas-content/src/application_service/content_service/service.rs b/monas-content/src/application_service/content_service/service.rs index b086dad..6222680 100644 --- a/monas-content/src/application_service/content_service/service.rs +++ b/monas-content/src/application_service/content_service/service.rs @@ -298,9 +298,9 @@ where /// 3. Loads the CEK for `local_content_id` and AES-GCM-decrypts. /// /// This is the client-side core of the verified read path - /// (`docs/design/read-response-integrity.md` §5.2 / §8). It does NOT do the - /// membership-proof or monotonicity checks — those are layered by the - /// caller (SDK) around this call, which owns the proof and last-seen state. + /// (`docs/design.md` §10「read応答の完全性検証」). It does NOT do the + /// monotonicity check — that is layered by the caller (SDK) around this + /// call, which owns the last-seen state. /// /// Returns the plaintext, and the verified node's parent CIDs (for the /// caller's monotonicity check). diff --git a/monas-content/src/infrastructure/node_verification.rs b/monas-content/src/infrastructure/node_verification.rs index a946442..a982e07 100644 --- a/monas-content/src/infrastructure/node_verification.rs +++ b/monas-content/src/infrastructure/node_verification.rs @@ -6,7 +6,7 @@ //! is the SHA-256 of the exact CBOR bytes, so a matching CID proves the bytes //! (payload + parents + genesis + timestamp + metadata) are authentic. No //! signature is needed for this check. See -//! `docs/design/read-response-integrity.md` §5.0.1 / §8. +//! `docs/design.md` §10「read応答の完全性検証」. //! //! This mirrors crsl-lib's `Node::content_id()`: //! `CIDv1(codec=RAW=0x55, multihash=SHA2-256(sha256(serde_cbor(node))))`. diff --git a/monas-sdk/src/controller/mod.rs b/monas-sdk/src/controller/mod.rs index 2611520..02c5bc6 100644 --- a/monas-sdk/src/controller/mod.rs +++ b/monas-sdk/src/controller/mod.rs @@ -69,7 +69,7 @@ pub struct MonasController { /// ShareService share_service: ShareServiceInstance, /// content ごとに最後に受理した State Node 版 CID の記録 - /// (read 単調性チェック、`docs/design/read-response-integrity.md` コンポーネント B) + /// (read 単調性チェック、`docs/design.md` §10「read応答の完全性検証」の単調性) last_seen_store: DynLastSeenStore, } diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 91df683..70cab42 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -576,7 +576,7 @@ where let content_id_vo = ContentId::new(content_id.to_string())?; // Return the whole crsl-lib Node (CBOR), not just the payload, so the // client can recompute the CID and verify the response was not - // tampered with (docs/design/read-response-integrity.md §8.1). + // tampered with (docs/design.md §10「read応答の完全性検証」). match version { Some(v) => { let node_bytes = self diff --git a/monas-state-node/src/port/content_repository.rs b/monas-state-node/src/port/content_repository.rs index 56a2f2f..f4fdb65 100644 --- a/monas-state-node/src/port/content_repository.rs +++ b/monas-state-node/src/port/content_repository.rs @@ -129,7 +129,7 @@ pub trait ContentRepository: Send + Sync { /// Unlike [`get_latest_with_version`], this returns the whole Node (CBOR) /// rather than just the payload bytes, so a client can recompute the CID /// and verify the response was not tampered with — no signature needed. - /// See `docs/design/read-response-integrity.md` §8.1. + /// See `docs/design.md` §10「read応答の完全性検証」. async fn get_latest_node_bytes_with_version( &self, genesis_cid: &str, diff --git a/monas-state-node/src/presentation/http_api.rs b/monas-state-node/src/presentation/http_api.rs index a59f2d5..d3d390b 100644 --- a/monas-state-node/src/presentation/http_api.rs +++ b/monas-state-node/src/presentation/http_api.rs @@ -720,7 +720,7 @@ async fn get_content_data( // Return the whole Node (CBOR), matching the relay branch above, so the // client always verifies the same format (recompute CID) regardless of // whether this node held the content locally or relayed the read. - // (docs/design/read-response-integrity.md §8.1) + // (docs/design.md §10「read応答の完全性検証」) let data_result: Result, String)>, _> = if let Some(version) = &query.version { crdt_repo .get_version_node_bytes(&content_id, version) From c2724b9bbda1c8097b8dc4e6ac1d2d89e8a77f2d Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 25 Jul 2026 15:38:16 +0900 Subject: [PATCH 23/48] =?UTF-8?q?feat(share):=20sender-authenticated=20CEK?= =?UTF-8?q?=20envelopes=20=E2=80=94=20HPKE=20Auth=20mode=20+=20AAD-bound?= =?UTF-8?q?=20key=20epoch=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the CEK-store poisoning blocker: KeyEnvelope previously used HPKE Base mode, so any third party who knew the plaintext could mint a consistent envelope and silently overwrite a recipient's stored CEK (breaking their verified reads). The self-reported sender_key_id field provided no authentication. Design (agreed in review discussion): - KeyEnvelope wrap switches to HPKE Auth mode: the sender's private key enters the KEM computation and the recipient unwraps with the sender's public key — an envelope not created by that sender fails to decrypt. No separate signature infrastructure needed. - AAD binds (content_id, recipient_key_id, key_epoch) into the wrap; tampering with any of them makes decryption fail. - key_epoch is the CEK generation counter, stored on the Share aggregate and bumped on each revoke-triggered rotation. Recipients record the last accepted epoch and reject older envelopes, so a replayed pre-rotation envelope cannot roll their stored CEK back. - Recipients TOFU-pin the sender public key per content in a new sender-key pin store (in-memory + sled, `sender_pin:` prefix); after pinning, envelopes are only verified against the pinned key and the pin/epoch advance only after unwrap + decrypt succeed. API changes: share_content / revoke_share now take sender_private_key (used transiently, never stored); decrypt_shared_content takes sender_public_key instead of the meaningless self-reported sender_key_id; envelopes carry key_epoch; ShareContentOutput echoes sender_public_key for distribution to recipients. Tests: HPKE Auth unit tests (roundtrip, forged sender, tampered epoch/content_id/recipient), pin-store roundtrips, SDK integration tests for wrong-sender rejection, TOFU pin mismatch rejection, and pre-rotation envelope replay rejection after rotation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J --- docs/design.md | 6 +- .../share_service/command.rs | 6 + .../share_service/service.rs | 70 +++- monas-content/src/domain/share/encryption.rs | 48 ++- .../src/domain/share/key_envelope.rs | 12 + monas-content/src/domain/share/share.rs | 16 + .../src/infrastructure/key_wrapping.rs | 301 +++++++++++------- monas-content/src/infrastructure/mod.rs | 1 + .../infrastructure/sender_key_pin_store.rs | 141 ++++++++ monas-content/src/presentation/share.rs | 26 +- monas-sdk/src/controller/mod.rs | 23 +- monas-sdk/src/controller/share.rs | 131 +++++++- monas-sdk/src/models/share.rs | 28 +- .../share_controller_integration_test.rs | 21 +- .../tests/state_read_integration_test.rs | 97 +++++- 15 files changed, 757 insertions(+), 170 deletions(-) create mode 100644 monas-content/src/infrastructure/sender_key_pin_store.rs diff --git a/docs/design.md b/docs/design.md index c1adb6b..63704a4 100644 --- a/docs/design.md +++ b/docs/design.md @@ -362,7 +362,11 @@ member証明(ownerがmemberを認証するトークン)は採用しない。 share受信者はKeyEnvelopeの復号成功時にunwrap済みCEKを自デバイスのローカルストアへ保存し、以後は自身もstate-nodeからの検証付きreadで復号できる。CEK・平文がデバイス外に出ることはない(state-nodeは常に暗号文のみを扱う)。 -アクセス取り消しの安全性は受信者の鍵破棄(強制不能)ではなくCEKローテーションに依存する。revoke時は再暗号化を先に行い、残存受信者にはローテーション後のCEKでKeyEnvelopeを再発行する。受信者が再発行envelopeを処理すると保存済みCEKが更新され、旧CEKのままでは新しい版を復号できない。 +**KeyEnvelopeは送信者認証付き(HPKE Authモード)でラップされる。** 送信者の秘密鍵がwrap計算に混ざり、受信者は送信者の公開鍵を使ってunwrapする — 送信者が本物でなければ復号自体が失敗するため、別途の署名は不要。受信者は最初にunwrapに成功した送信者公開鍵をcontentごとにピン留めし(TOFU)、以後のenvelopeはピン済みの鍵でのみ検証する。これにより、平文を知る第三者が整合するenvelopeを鋳造して受信者の保存CEKを上書き破壊する攻撃を防ぐ。 + +wrapのAADには `(content_id, recipient_key_id, key_epoch)` が束縛され、いずれかを書き換えたenvelopeは復号に失敗する。`key_epoch` はCEKの鍵世代(rotationごとに+1)で、受信者は記録済み世代より古いenvelopeを拒否する — rotation前の正規envelopeを再送して保存CEKを旧世代へ巻き戻すreplay攻撃はこれで防がれる。 + +アクセス取り消しの安全性は受信者の鍵破棄(強制不能)ではなくCEKローテーションに依存する。revoke時は再暗号化を先に行い、残存受信者にはローテーション後のCEK・進んだkey_epochでKeyEnvelopeを再発行する。受信者が再発行envelopeを処理すると保存済みCEKが更新され、旧CEKのままでは新しい版を復号できない。 --- diff --git a/monas-content/src/application_service/share_service/command.rs b/monas-content/src/application_service/share_service/command.rs index 75ab05c..38f5191 100644 --- a/monas-content/src/application_service/share_service/command.rs +++ b/monas-content/src/application_service/share_service/command.rs @@ -8,6 +8,9 @@ use crate::domain::share::{KeyEnvelope, KeyId, Permission}; pub struct GrantShareCommand { pub content_id: ContentId, pub sender_key_id: KeyId, + /// 送信者の秘密鍵バイト列。HPKE Auth モードの wrap(送信者認証)に用いる。 + /// 保存はされず、この呼び出しの間だけ使われる。 + pub sender_private_key: Vec, pub recipient_public_key: Vec, pub permission: Permission, } @@ -24,6 +27,9 @@ pub struct GrantShareResult { pub struct RevokeShareCommand { pub content_id: ContentId, pub sender_key_id: KeyId, + /// 送信者の秘密鍵バイト列。残存受信者向け KeyEnvelope 再発行の + /// HPKE Auth モード wrap(送信者認証)に用いる。 + pub sender_private_key: Vec, pub recipient_key_id: KeyId, } diff --git a/monas-content/src/application_service/share_service/service.rs b/monas-content/src/application_service/share_service/service.rs index d336f57..ba8b711 100644 --- a/monas-content/src/application_service/share_service/service.rs +++ b/monas-content/src/application_service/share_service/service.rs @@ -28,13 +28,16 @@ where KD: PublicKeyDirectory, KW: KeyWrapping, { + #[allow(clippy::too_many_arguments)] fn build_envelope_for_recipient( &self, content_id: &crate::domain::content_id::ContentId, sender_key_id: &crate::domain::share::KeyId, + sender_private_key: &[u8], recipient_key_id: &crate::domain::share::KeyId, cek: &crate::domain::content::encryption::ContentEncryptionKey, ciphertext: &[u8], + key_epoch: u64, ) -> Result { let recipient_public_key = self .public_key_directory @@ -42,9 +45,14 @@ where .map_err(ShareApplicationError::PublicKeyDirectory)? .ok_or(ShareApplicationError::MissingPublicKey)?; + let aad = crate::domain::share::encryption::EnvelopeAad { + content_id, + recipient_key_id, + key_epoch, + }; let (enc, wrapped_cek) = self .key_wrapper - .wrap_cek(cek, &recipient_public_key, content_id) + .wrap_cek(cek, &recipient_public_key, sender_private_key, &aad) .map_err(|e| ShareApplicationError::KeyWrapping(format!("{e:?}")))?; let wrapped_recipient = crate::domain::share::WrappedRecipientKey::new( @@ -59,6 +67,7 @@ where sender_key_id.clone(), wrapped_recipient, ciphertext.to_vec(), + key_epoch, )) } @@ -124,11 +133,17 @@ where let _ = event; - // 6. CEK をラップ + // 6. CEK をラップ(HPKE Auth: 送信者秘密鍵で送信者認証、AAD で宛先と鍵世代を束縛) + let key_epoch = share.key_epoch(); + let aad = crate::domain::share::encryption::EnvelopeAad { + content_id: &cmd.content_id, + recipient_key_id: &recipient_key_id, + key_epoch, + }; let recipient_public_key = &cmd.recipient_public_key; let (enc, wrapped_cek) = self .key_wrapper - .wrap_cek(&cek, recipient_public_key, &cmd.content_id) + .wrap_cek(&cek, recipient_public_key, &cmd.sender_private_key, &aad) .map_err(|e| ShareApplicationError::KeyWrapping(format!("{e:?}")))?; // 7. 公開鍵を登録 @@ -157,6 +172,7 @@ where cmd.sender_key_id.clone(), wrapped_recipient, ciphertext, + key_epoch, ); Ok(GrantShareResult { @@ -195,7 +211,8 @@ where .map_err(ShareApplicationError::ContentEncryptionKeyStore)? .ok_or(ShareApplicationError::MissingContentEncryptionKey)?; - // 3. Share をロードして ACL を更新 + // 3. Share をロードして ACL を更新。CEK は rotation 済み(呼び出し側が + // reencrypt を先に実行している)なので鍵世代も進める。 let mut share = self .share_repository .load(&cmd.content_id) @@ -205,12 +222,13 @@ where share .revoke(&cmd.recipient_key_id) .map_err(ShareApplicationError::Share)?; + share.bump_key_epoch(); self.share_repository .save(&share) .map_err(ShareApplicationError::ShareRepository)?; - // 4. 取り消し後に残っている受信者向けに KeyEnvelope を再発行 + // 4. 取り消し後に残っている受信者向けに、新しい鍵世代で KeyEnvelope を再発行 let mut recipient_key_ids: Vec<_> = share.recipients().keys().cloned().collect(); recipient_key_ids.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes())); @@ -219,9 +237,11 @@ where let env = self.build_envelope_for_recipient( &cmd.content_id, &cmd.sender_key_id, + &cmd.sender_private_key, &recipient_key_id, &cek, &ciphertext, + share.key_epoch(), )?; envelopes.push(env); } @@ -236,21 +256,30 @@ where /// KeyEnvelope と受信者の秘密鍵バイト列から CEK を復号(アンラップ)する。 /// /// - monas-account など別サービスが秘密鍵を管理し、このサービスにはバイト列として渡ってくる前提。 + /// - `sender_public_key` は受信者が期待する送信者の公開鍵(TOFU でピン留めした鍵)。 + /// HPKE Auth モードのため、unwrap 成功はこの鍵の持ち主が envelope を作った証明になる。 /// - 現時点では HpkeV1 のみをサポートする。 pub fn unwrap_cek_from_envelope( &self, envelope: &KeyEnvelope, recipient_private_key: &[u8], + sender_public_key: &[u8], ) -> Result { match envelope.key_wrap_algorithm() { KeyWrapAlgorithm::HpkeV1 => { let recipient = envelope.recipient(); + let aad = crate::domain::share::encryption::EnvelopeAad { + content_id: envelope.content_id(), + recipient_key_id: recipient.key_id(), + key_epoch: envelope.key_epoch(), + }; self.key_wrapper .unwrap_cek( recipient.enc(), recipient.wrapped_cek(), recipient_private_key, - envelope.content_id(), + sender_public_key, + &aad, ) .map_err(|e| ShareApplicationError::KeyWrapping(format!("{e:?}"))) } @@ -470,7 +499,8 @@ mod tests { &self, _cek: &ContentEncryptionKey, _recipient_public_key: &[u8], - _content_id: &ContentId, + _sender_private_key: &[u8], + _aad: &crate::domain::share::encryption::EnvelopeAad<'_>, ) -> Result<(Vec, Vec), crate::domain::share::encryption::KeyWrappingError> { Ok((vec![0xAA, 0xBB], vec![0x11, 0x22, 0x33])) @@ -481,7 +511,8 @@ mod tests { _enc: &[u8], wrapped_cek: &[u8], _recipient_private_key: &[u8], - _content_id: &ContentId, + _sender_public_key: &[u8], + _aad: &crate::domain::share::encryption::EnvelopeAad<'_>, ) -> Result { Ok(ContentEncryptionKey(wrapped_cek.to_vec())) @@ -496,7 +527,8 @@ mod tests { &self, _cek: &ContentEncryptionKey, _recipient_public_key: &[u8], - _content_id: &ContentId, + _sender_private_key: &[u8], + _aad: &crate::domain::share::encryption::EnvelopeAad<'_>, ) -> Result<(Vec, Vec), crate::domain::share::encryption::KeyWrappingError> { Err(crate::domain::share::encryption::KeyWrappingError::Other( @@ -509,7 +541,8 @@ mod tests { _enc: &[u8], _wrapped_cek: &[u8], _recipient_private_key: &[u8], - _content_id: &ContentId, + _sender_public_key: &[u8], + _aad: &crate::domain::share::encryption::EnvelopeAad<'_>, ) -> Result { Err(crate::domain::share::encryption::KeyWrappingError::Other( @@ -619,12 +652,13 @@ mod tests { sender_key_id(), recipient, encrypted(), + 0, ); let recipient_private_key = vec![0x99, 0x88]; let result = service - .unwrap_cek_from_envelope(&envelope, &recipient_private_key) + .unwrap_cek_from_envelope(&envelope, &recipient_private_key, &[0x04; 65]) .expect("unwrap_cek_from_envelope should succeed"); assert_eq!(result.0, wrapped_cek_bytes); @@ -656,12 +690,13 @@ mod tests { sender_key_id(), recipient, encrypted(), + 0, ); let recipient_private_key = vec![0x99, 0x88]; let err = service - .unwrap_cek_from_envelope(&envelope, &recipient_private_key) + .unwrap_cek_from_envelope(&envelope, &recipient_private_key, &[0x04; 65]) .expect_err("unwrap_cek_from_envelope should propagate key wrapper error"); assert!(matches!(err, ShareApplicationError::KeyWrapping(_))); @@ -697,6 +732,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid.clone(), sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![1, 2, 3, 4], permission: Permission::Read, }; @@ -753,6 +789,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid.clone(), sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![1, 2, 3, 4], permission: Permission::Write, }; @@ -793,6 +830,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid(), sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![1, 2, 3], permission: Permission::Read, }; @@ -829,6 +867,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid, sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![1, 2, 3], permission: Permission::Read, }; @@ -865,6 +904,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid, sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![1, 2, 3], permission: Permission::Read, }; @@ -904,6 +944,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid, sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![1, 2, 3], permission: Permission::Read, }; @@ -957,6 +998,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid, sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![9, 9, 9], permission: Permission::Read, }; @@ -1000,6 +1042,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid, sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![1, 2, 3], permission: Permission::Read, }; @@ -1040,6 +1083,7 @@ mod tests { let cmd = GrantShareCommand { content_id: cid, sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_public_key: vec![1, 2, 3, 4], permission: Permission::Read, }; @@ -1121,6 +1165,7 @@ mod tests { let cmd = RevokeShareCommand { content_id: content_id.clone(), sender_key_id: sender.clone(), + sender_private_key: vec![0x55; 32], recipient_key_id: revoked_kid.clone(), }; @@ -1177,6 +1222,7 @@ mod tests { let cmd = RevokeShareCommand { content_id: cid.clone(), sender_key_id: sender_key_id(), + sender_private_key: vec![0x55; 32], recipient_key_id: KeyId::new(vec![1]), }; diff --git a/monas-content/src/domain/share/encryption.rs b/monas-content/src/domain/share/encryption.rs index a00feb9..d74711d 100644 --- a/monas-content/src/domain/share/encryption.rs +++ b/monas-content/src/domain/share/encryption.rs @@ -1,5 +1,6 @@ use crate::domain::content::encryption::ContentEncryptionKey; use crate::domain::content_id::ContentId; +use crate::domain::KeyId; /// CEK を受信者の公開鍵でラップ / 秘密鍵でアンラップ(HPKE など)するためのポート。 /// @@ -17,33 +18,70 @@ pub enum KeyWrappingError { Other(String), } +/// CEK ラップに暗号学的に束縛する関連データ(AAD)。 +/// +/// `(content_id, recipient_key_id, key_epoch)` を wrap 計算に混ぜることで、 +/// envelope の「どのコンテンツの・誰宛の・どの鍵世代の」ラップかを改ざん不能にする。 +/// いずれかのフィールドを書き換えた envelope は unwrap(復号)自体が失敗する。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnvelopeAad<'a> { + pub content_id: &'a ContentId, + pub recipient_key_id: &'a KeyId, + pub key_epoch: u64, +} + +impl EnvelopeAad<'_> { + /// AAD のバイト列表現。フィールドは長さプレフィクス付きで連結し、 + /// 連結の曖昧さ(フィールド境界の付け替え)を排除する。 + pub fn to_bytes(&self) -> Vec { + let cid = self.content_id.as_str().as_bytes(); + let kid = self.recipient_key_id.as_bytes(); + let mut out = Vec::with_capacity(4 + cid.len() + 4 + kid.len() + 8); + out.extend_from_slice(&(cid.len() as u32).to_be_bytes()); + out.extend_from_slice(cid); + out.extend_from_slice(&(kid.len() as u32).to_be_bytes()); + out.extend_from_slice(kid); + out.extend_from_slice(&self.key_epoch.to_be_bytes()); + out + } +} + /// CEK を受信者の公開鍵でラップし、秘密鍵でアンラップするためのポート。 +/// +/// ラップは送信者認証付き(HPKE Auth モード相当)であること: +/// 送信者秘密鍵が wrap 計算に混ざり、受信者は送信者の公開鍵を使って unwrap する。 +/// 送信者が本物でなければ unwrap(復号)が失敗するため、別途の署名は不要。 pub trait KeyWrapping { /// 1 つの CEK を、指定された受信者公開鍵向けにラップする。 /// /// - `cek`: コンテンツ本体の暗号化に用いた共有鍵。 /// - `recipient_public_key`: 受信者の公開鍵バイト列。 - /// - `content_id`: この CEK がひも付くコンテンツ ID。HPKE の info/AAD などに利用できる。 + /// - `sender_private_key`: 送信者の秘密鍵バイト列(送信者認証に用いる)。 + /// - `aad`: wrap に束縛する関連データ(コンテンツ ID・宛先 key id・鍵世代)。 /// /// 戻り値のタプルは `(enc, wrapped_cek)` を表す。 fn wrap_cek( &self, cek: &ContentEncryptionKey, recipient_public_key: &[u8], - content_id: &ContentId, + sender_private_key: &[u8], + aad: &EnvelopeAad<'_>, ) -> Result<(Vec, Vec), KeyWrappingError>; - /// + /// 1 つの CEK を、指定された受信者秘密鍵を用いてアンラップする。 /// /// - `enc`: HPKE の送信者公開値。 /// - `wrapped_cek`: HPKE でラップされた CEK のバイト列。 /// - `recipient_private_key`: 受信者の秘密鍵バイト列。 - /// - `content_id`: この CEK がひも付くコンテンツ ID。HPKE の info/AAD などに利用できる。 + /// - `sender_public_key`: 送信者の公開鍵バイト列。unwrap 成功 = + /// この鍵の持ち主が作った envelope であることの証明になる。 + /// - `aad`: wrap 時と同一の関連データ。一致しなければ unwrap は失敗する。 fn unwrap_cek( &self, enc: &[u8], wrapped_cek: &[u8], recipient_private_key: &[u8], - content_id: &ContentId, + sender_public_key: &[u8], + aad: &EnvelopeAad<'_>, ) -> Result; } diff --git a/monas-content/src/domain/share/key_envelope.rs b/monas-content/src/domain/share/key_envelope.rs index 6614803..0c6da42 100644 --- a/monas-content/src/domain/share/key_envelope.rs +++ b/monas-content/src/domain/share/key_envelope.rs @@ -55,6 +55,10 @@ pub struct KeyEnvelope { sender_key_id: KeyId, recipient: WrappedRecipientKey, ciphertext: Vec, + /// CEK の鍵世代。rotation(revoke による再暗号化)のたびに +1 される。 + /// wrap の AAD に束縛されるため改ざんできず、受信者は記録済み世代より + /// 古い envelope を拒否することで旧 CEK への巻き戻し(replay)を防ぐ。 + key_epoch: u64, } impl KeyEnvelope { @@ -64,6 +68,7 @@ impl KeyEnvelope { sender_key_id: KeyId, recipient: WrappedRecipientKey, ciphertext: Vec, + key_epoch: u64, ) -> Self { Self { content_id, @@ -71,6 +76,7 @@ impl KeyEnvelope { sender_key_id, recipient, ciphertext, + key_epoch, } } @@ -93,6 +99,10 @@ impl KeyEnvelope { pub fn ciphertext(&self) -> &[u8] { &self.ciphertext } + + pub fn key_epoch(&self) -> u64 { + self.key_epoch + } } #[cfg(test)] @@ -117,10 +127,12 @@ mod tests { key_id(&[1, 2, 3]), recipient, vec![0xAA, 0xBB], + 3, ); assert!(matches!(env.key_wrap_algorithm(), KeyWrapAlgorithm::HpkeV1)); assert_eq!(env.recipient().key_id().as_bytes(), &[4, 5, 6]); assert_eq!(env.ciphertext(), &[0xAA, 0xBB]); + assert_eq!(env.key_epoch(), 3); } } diff --git a/monas-content/src/domain/share/share.rs b/monas-content/src/domain/share/share.rs index c301d82..0704a37 100644 --- a/monas-content/src/domain/share/share.rs +++ b/monas-content/src/domain/share/share.rs @@ -100,6 +100,10 @@ pub struct Share { content_id: ContentId, /// key = KeyId recipients: HashMap, + /// CEK の鍵世代。rotation(revoke による再暗号化)のたびに +1 される。 + /// KeyEnvelope に載り、受信者側の旧世代 envelope 拒否(replay 防止)の基準になる。 + #[serde(default)] + key_epoch: u64, } impl Share { @@ -110,9 +114,20 @@ impl Share { Self { content_id, recipients: HashMap::new(), + key_epoch: 0, } } + /// 現在の CEK 鍵世代。 + pub fn key_epoch(&self) -> u64 { + self.key_epoch + } + + /// CEK rotation(再暗号化)に合わせて鍵世代を進める。 + pub fn bump_key_epoch(&mut self) { + self.key_epoch += 1; + } + /// Read 権限の付与。 /// /// - 既に同じ KeyId の受信者が存在する場合は `AlreadyShared` を返す。 @@ -184,6 +199,7 @@ impl Share { Self { content_id: new_content_id, recipients: self.recipients.clone(), + key_epoch: self.key_epoch, } } diff --git a/monas-content/src/infrastructure/key_wrapping.rs b/monas-content/src/infrastructure/key_wrapping.rs index 8c3a74e..c3a2ce4 100644 --- a/monas-content/src/infrastructure/key_wrapping.rs +++ b/monas-content/src/infrastructure/key_wrapping.rs @@ -1,18 +1,21 @@ use crate::domain::content::encryption::ContentEncryptionKey; -use crate::domain::content_id::ContentId; -use crate::domain::share::encryption::{KeyWrapping, KeyWrappingError}; +use crate::domain::share::encryption::{EnvelopeAad, KeyWrapping, KeyWrappingError}; use hpke_rs::hpke_types::{AeadAlgorithm, KdfAlgorithm, KemAlgorithm}; use hpke_rs::prelude::*; use hpke_rs_rust_crypto::HpkeRustCrypto; -/// HPKE (RFC 9180) を用いた CEK ラップ実装。 +/// HPKE (RFC 9180) **Auth モード**を用いた CEK ラップ実装。 /// +/// - Mode: Auth(送信者認証付き。送信者秘密鍵が KEM 計算に混ざり、 +/// 受信者は送信者の公開鍵で unwrap する。送信者が本物でなければ復号が失敗する) /// - KEM: DH KEM P-256 /// - KDF: HKDF-SHA256 /// - AEAD: AES-GCM-256 +/// - AAD: `EnvelopeAad`(content_id・recipient_key_id・key_epoch)を束縛する。 /// -/// 受信者の公開鍵は P-256 の uncompressed form (0x04 || X || Y, 65 バイト) として渡されることを想定する。 +/// 公開鍵は P-256 の uncompressed form (0x04 || X || Y, 65 バイト)、 +/// 秘密鍵は P-256 スカラー (32 バイト) として渡されることを想定する。 #[derive(Debug, Default, Clone, Copy)] pub struct HpkeV1KeyWrapping; @@ -20,7 +23,7 @@ impl HpkeV1KeyWrapping { /// この実装で利用する HPKE の設定値を返す。 fn hpke_config() -> (Mode, KemAlgorithm, KdfAlgorithm, AeadAlgorithm) { ( - Mode::Base, + Mode::Auth, KemAlgorithm::DhKemP256, KdfAlgorithm::HkdfSha256, AeadAlgorithm::Aes256Gcm, @@ -33,20 +36,20 @@ impl KeyWrapping for HpkeV1KeyWrapping { &self, cek: &ContentEncryptionKey, recipient_public_key: &[u8], - content_id: &ContentId, + sender_private_key: &[u8], + aad: &EnvelopeAad<'_>, ) -> Result<(Vec, Vec), KeyWrappingError> { let pk_r = HpkePublicKey::from(recipient_public_key.to_vec()); + let sk_s = HpkePrivateKey::from(sender_private_key.to_vec()); let (mode, kem, kdf, aead) = Self::hpke_config(); - let mut hpke = Hpke::::new(mode, kem, kdf, aead); - // 両方に入れる必要があるかは要検討 - let info = content_id.as_str().as_bytes(); - let aad = content_id.as_str().as_bytes(); + let info = aad.content_id.as_str().as_bytes(); + let aad_bytes = aad.to_bytes(); let (enc, wrapped_cek) = hpke - .seal(&pk_r, info, aad, &cek.0, None, None, None) + .seal(&pk_r, info, &aad_bytes, &cek.0, None, None, Some(&sk_s)) .map_err(|e| KeyWrappingError::CryptoError(format!("hpke seal failed: {e:?}")))?; Ok((enc, wrapped_cek)) @@ -57,24 +60,26 @@ impl KeyWrapping for HpkeV1KeyWrapping { enc: &[u8], wrapped_cek: &[u8], recipient_private_key: &[u8], - content_id: &ContentId, + sender_public_key: &[u8], + aad: &EnvelopeAad<'_>, ) -> Result { let (mode, kem, kdf, aead) = Self::hpke_config(); let hpke = Hpke::::new(mode, kem, kdf, aead); let sk_r = HpkePrivateKey::from(recipient_private_key.to_vec()); + let pk_s = HpkePublicKey::from(sender_public_key.to_vec()); - let info = content_id.as_str().as_bytes(); - let aad = info; + let info = aad.content_id.as_str().as_bytes(); + let aad_bytes = aad.to_bytes(); let mut ctx = hpke - .setup_receiver(enc, &sk_r, info, None, None, None) + .setup_receiver(enc, &sk_r, info, None, None, Some(&pk_s)) .map_err(|e| { KeyWrappingError::CryptoError(format!("hpke setup_receiver failed: {e:?}")) })?; let cek_bytes = ctx - .open(aad, wrapped_cek) + .open(&aad_bytes, wrapped_cek) .map_err(|e| KeyWrappingError::CryptoError(format!("hpke open failed: {e:?}")))?; Ok(ContentEncryptionKey(cek_bytes)) @@ -85,163 +90,215 @@ impl KeyWrapping for HpkeV1KeyWrapping { mod tests { use super::*; use crate::domain::content::encryption::ContentEncryptionKey; - use p256::ecdh::EphemeralSecret; - use p256::elliptic_curve::sec1::ToEncodedPoint; - use p256::{EncodedPoint, PublicKey}; - use rand_core::OsRng; - - fn generate_p256_keypair() -> (Vec, EphemeralSecret) { - let mut rng = OsRng; - let sk = EphemeralSecret::random(&mut rng); - let pk = PublicKey::from(&sk); - let encoded: EncodedPoint = pk.to_encoded_point(false); // uncompressed - (encoded.as_bytes().to_vec(), sk) + use crate::domain::content_id::ContentId; + use crate::domain::KeyId; + + struct TestKeys { + sender_pk: Vec, + sender_sk: Vec, + recipient_pk: Vec, + recipient_sk: Vec, } - #[test] - fn wrap_cek_produces_ciphertext() { - let wrapper = HpkeV1KeyWrapping; - let cek = ContentEncryptionKey(vec![0x11; 32]); - let (pk_bytes, _sk) = generate_p256_keypair(); - let cid = ContentId::new("test-content-id".into()); + fn generate_keys() -> TestKeys { + let (_, kem, kdf, aead) = HpkeV1KeyWrapping::hpke_config(); + let mut hpke = Hpke::::new(Mode::Auth, kem, kdf, aead); + let sender = hpke.generate_key_pair().expect("sender key pair"); + let recipient = hpke.generate_key_pair().expect("recipient key pair"); + TestKeys { + sender_pk: sender.public_key().as_slice().to_vec(), + sender_sk: sender.private_key().as_slice().to_vec(), + recipient_pk: recipient.public_key().as_slice().to_vec(), + recipient_sk: recipient.private_key().as_slice().to_vec(), + } + } - let (enc, wrapped) = wrapper - .wrap_cek(&cek, &pk_bytes, &cid) - .expect("hpke wrap_cek should succeed"); + fn cid() -> ContentId { + ContentId::new("test-content-id".into()) + } + + fn recipient_key_id() -> KeyId { + KeyId::new(vec![7, 7, 7]) + } - assert!(!enc.is_empty()); - assert!(!wrapped.is_empty()); + fn aad_with<'a>(cid: &'a ContentId, kid: &'a KeyId, epoch: u64) -> EnvelopeAad<'a> { + EnvelopeAad { + content_id: cid, + recipient_key_id: kid, + key_epoch: epoch, + } } #[test] - fn wrap_cek_can_be_decrypted_by_hpke_receiver() { + fn wrap_unwrap_roundtrip_with_sender_auth() { let wrapper = HpkeV1KeyWrapping; let cek = ContentEncryptionKey((0u8..32).collect()); - let cid = ContentId::new("roundtrip-test".into()); - - let (mode, kem, kdf, aead) = HpkeV1KeyWrapping::hpke_config(); - let mut hpke = Hpke::::new(mode, kem, kdf, aead); - - let keypair = hpke - .generate_key_pair() - .expect("failed to generate HPKE key pair"); - let pk_r = keypair.public_key(); - let sk_r = keypair.private_key(); - let pk_bytes = pk_r.as_slice().to_vec(); + let keys = generate_keys(); + let cid = cid(); + let kid = recipient_key_id(); + let aad = aad_with(&cid, &kid, 0); let (enc, wrapped) = wrapper - .wrap_cek(&cek, &pk_bytes, &cid) - .expect("hpke wrap_cek should succeed"); + .wrap_cek(&cek, &keys.recipient_pk, &keys.sender_sk, &aad) + .expect("wrap_cek should succeed"); - let info = cid.as_str().as_bytes(); - let aad = info; - let mut ctx = hpke - .setup_receiver(enc.as_slice(), sk_r, info, None, None, None) - .expect("hpke setup_receiver should succeed"); - let decrypted = ctx.open(aad, &wrapped).expect("hpke open should succeed"); + let decrypted = wrapper + .unwrap_cek(&enc, &wrapped, &keys.recipient_sk, &keys.sender_pk, &aad) + .expect("unwrap_cek should succeed"); - assert_eq!(decrypted, cek.0); + assert_eq!(decrypted.0, cek.0); } #[test] - fn decrypt_fails_with_wrong_content_id() { + fn unwrap_fails_with_wrong_sender_public_key() { + // 送信者認証の核心: 偽送信者(別鍵)が作った envelope は、 + // 受信者が期待する送信者公開鍵での unwrap に失敗する。 let wrapper = HpkeV1KeyWrapping; let cek = ContentEncryptionKey(vec![0xAA; 32]); - let cid = ContentId::new("correct-content-id".into()); - - let (mode, kem, kdf, aead) = HpkeV1KeyWrapping::hpke_config(); - let mut hpke = Hpke::::new(mode, kem, kdf, aead); - let keypair = hpke - .generate_key_pair() - .expect("failed to generate HPKE key pair"); - let pk_r = keypair.public_key(); - let sk_r = keypair.private_key(); - let pk_bytes = pk_r.as_slice().to_vec(); + let keys = generate_keys(); + let attacker = generate_keys(); + let cid = cid(); + let kid = recipient_key_id(); + let aad = aad_with(&cid, &kid, 0); + // 攻撃者が自分の秘密鍵で envelope を鋳造 let (enc, wrapped) = wrapper - .wrap_cek(&cek, &pk_bytes, &cid) - .expect("hpke wrap_cek should succeed"); + .wrap_cek(&cek, &keys.recipient_pk, &attacker.sender_sk, &aad) + .expect("attacker wrap should succeed"); - let wrong_cid = ContentId::new("wrong-content-id".into()); - let wrong_info = wrong_cid.as_str().as_bytes(); - let wrong_aad = wrong_info; - - let mut ctx = hpke - .setup_receiver(enc.as_slice(), sk_r, wrong_info, None, None, None) - .expect("hpke setup_receiver with wrong info should still build context"); - - let result = ctx.open(wrong_aad, &wrapped); + // 受信者は正規送信者の公開鍵で unwrap する → 失敗する + let result = wrapper.unwrap_cek(&enc, &wrapped, &keys.recipient_sk, &keys.sender_pk, &aad); assert!( - result.is_err(), - "decryption should fail with wrong content_id" + matches!(result, Err(KeyWrappingError::CryptoError(_))), + "forged-sender envelope must fail to unwrap" ); } #[test] - fn wrap_cek_fails_with_invalid_public_key_bytes() { + fn unwrap_fails_with_tampered_key_epoch() { + // AAD 束縛の検証: key_epoch を書き換えた envelope は復号に失敗する。 let wrapper = HpkeV1KeyWrapping; - let cek = ContentEncryptionKey(vec![0x42; 32]); - let cid = ContentId::new("invalid-pk-test".into()); - let invalid_pk = vec![0u8; 10]; - - let result = wrapper.wrap_cek(&cek, &invalid_pk, &cid); + let cek = ContentEncryptionKey(vec![0xBB; 32]); + let keys = generate_keys(); + let cid = cid(); + let kid = recipient_key_id(); + let (enc, wrapped) = wrapper + .wrap_cek( + &cek, + &keys.recipient_pk, + &keys.sender_sk, + &aad_with(&cid, &kid, 1), + ) + .expect("wrap should succeed"); + + let result = wrapper.unwrap_cek( + &enc, + &wrapped, + &keys.recipient_sk, + &keys.sender_pk, + &aad_with(&cid, &kid, 2), + ); assert!( matches!(result, Err(KeyWrappingError::CryptoError(_))), - "expected CryptoError for invalid public key bytes" + "epoch-tampered envelope must fail to unwrap" ); } #[test] - fn unwrap_cek_roundtrip_with_valid_private_key_bytes() { + fn unwrap_fails_with_wrong_content_id() { let wrapper = HpkeV1KeyWrapping; - let cek = ContentEncryptionKey((0u8..32).collect()); - let cid = ContentId::new("unwrap-roundtrip".into()); - - let (mode, kem, kdf, aead) = HpkeV1KeyWrapping::hpke_config(); - let mut hpke = Hpke::::new(mode, kem, kdf, aead); + let cek = ContentEncryptionKey(vec![0xCC; 32]); + let keys = generate_keys(); + let cid = cid(); + let wrong_cid = ContentId::new("wrong-content-id".into()); + let kid = recipient_key_id(); - let keypair = hpke - .generate_key_pair() - .expect("failed to generate HPKE key pair"); - let pk_r = keypair.public_key(); - let sk_r = keypair.private_key(); + let (enc, wrapped) = wrapper + .wrap_cek( + &cek, + &keys.recipient_pk, + &keys.sender_sk, + &aad_with(&cid, &kid, 0), + ) + .expect("wrap should succeed"); + + let result = wrapper.unwrap_cek( + &enc, + &wrapped, + &keys.recipient_sk, + &keys.sender_pk, + &aad_with(&wrong_cid, &kid, 0), + ); + assert!( + result.is_err(), + "decryption should fail with wrong content_id" + ); + } - let pk_bytes = pk_r.as_slice().to_vec(); - let sk_bytes = sk_r.as_slice().to_vec(); + #[test] + fn unwrap_fails_with_wrong_recipient_key_id() { + let wrapper = HpkeV1KeyWrapping; + let cek = ContentEncryptionKey(vec![0xDD; 32]); + let keys = generate_keys(); + let cid = cid(); + let kid = recipient_key_id(); + let wrong_kid = KeyId::new(vec![8, 8, 8]); let (enc, wrapped) = wrapper - .wrap_cek(&cek, &pk_bytes, &cid) - .expect("hpke wrap_cek should succeed"); + .wrap_cek( + &cek, + &keys.recipient_pk, + &keys.sender_sk, + &aad_with(&cid, &kid, 0), + ) + .expect("wrap should succeed"); + + let result = wrapper.unwrap_cek( + &enc, + &wrapped, + &keys.recipient_sk, + &keys.sender_pk, + &aad_with(&cid, &wrong_kid, 0), + ); + assert!( + result.is_err(), + "decryption should fail with wrong recipient_key_id" + ); + } - let decrypted = wrapper - .unwrap_cek(&enc, &wrapped, &sk_bytes, &cid) - .expect("hpke unwrap_cek should succeed"); + #[test] + fn wrap_cek_fails_with_invalid_public_key_bytes() { + let wrapper = HpkeV1KeyWrapping; + let cek = ContentEncryptionKey(vec![0x42; 32]); + let keys = generate_keys(); + let cid = cid(); + let kid = recipient_key_id(); + let invalid_pk = vec![0u8; 10]; - assert_eq!(decrypted.0, cek.0); + let result = wrapper.wrap_cek(&cek, &invalid_pk, &keys.sender_sk, &aad_with(&cid, &kid, 0)); + + assert!( + matches!(result, Err(KeyWrappingError::CryptoError(_))), + "expected CryptoError for invalid public key bytes" + ); } #[test] fn unwrap_cek_fails_with_invalid_private_key_bytes() { let wrapper = HpkeV1KeyWrapping; let cek = ContentEncryptionKey(vec![0x33; 32]); - let cid = ContentId::new("invalid-sk-test".into()); - - let (mode, kem, kdf, aead) = HpkeV1KeyWrapping::hpke_config(); - let mut hpke = Hpke::::new(mode, kem, kdf, aead); - let keypair = hpke - .generate_key_pair() - .expect("failed to generate HPKE key pair"); - let pk_r = keypair.public_key(); - let pk_bytes = pk_r.as_slice().to_vec(); + let keys = generate_keys(); + let cid = cid(); + let kid = recipient_key_id(); + let aad = aad_with(&cid, &kid, 0); let (enc, wrapped) = wrapper - .wrap_cek(&cek, &pk_bytes, &cid) - .expect("hpke wrap_cek should succeed"); + .wrap_cek(&cek, &keys.recipient_pk, &keys.sender_sk, &aad) + .expect("wrap should succeed"); let invalid_sk_bytes = vec![0u8; 10]; - - let result = wrapper.unwrap_cek(&enc, &wrapped, &invalid_sk_bytes, &cid); + let result = wrapper.unwrap_cek(&enc, &wrapped, &invalid_sk_bytes, &keys.sender_pk, &aad); assert!( matches!(result, Err(KeyWrappingError::CryptoError(_))), diff --git a/monas-content/src/infrastructure/mod.rs b/monas-content/src/infrastructure/mod.rs index b5225d7..9019c9c 100644 --- a/monas-content/src/infrastructure/mod.rs +++ b/monas-content/src/infrastructure/mod.rs @@ -5,6 +5,7 @@ pub mod key_wrapping; pub mod last_seen_version_store; pub mod node_verification; pub mod public_key_directory; +pub mod sender_key_pin_store; pub mod share_repository; #[cfg(feature = "filesync")] diff --git a/monas-content/src/infrastructure/sender_key_pin_store.rs b/monas-content/src/infrastructure/sender_key_pin_store.rs new file mode 100644 index 0000000..b4352e7 --- /dev/null +++ b/monas-content/src/infrastructure/sender_key_pin_store.rs @@ -0,0 +1,141 @@ +//! 受信者側の送信者公開鍵ピン(TOFU)ストア。 +//! +//! share の KeyEnvelope は HPKE Auth モードでラップされており、受信者は +//! 「期待する送信者の公開鍵」で unwrap する(成功 = その鍵の持ち主が作った証明)。 +//! このストアは content ごとに、最初に unwrap に成功した送信者公開鍵を +//! ピン留めし(TOFU)、以後の envelope はピン済みの鍵でのみ検証する。 +//! +//! 併せて CEK の鍵世代(key_epoch)も記録し、記録済み世代より古い envelope を +//! 拒否する基準にする(rotation 後に旧 envelope を再送して CEK を巻き戻す +//! replay 攻撃の防止)。 +//! +//! キーは受信者から見た(ローカルの) content id。 + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, thiserror::Error)] +pub enum SenderKeyPinStoreError { + #[error("sender key pin store error: {0}")] + Storage(String), +} + +/// ピン留めされた送信者公開鍵と、最後に受理した鍵世代。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SenderKeyPin { + /// 送信者の公開鍵バイト列(P-256 uncompressed form)。 + pub sender_public_key: Vec, + /// 最後に unwrap に成功した envelope の key_epoch。 + pub key_epoch: u64, +} + +/// `content_id -> (送信者公開鍵, 最終受理 key_epoch)` の永続化ポート。 +pub trait SenderKeyPinStore: Send + Sync { + fn load(&self, content_id: &str) -> Result, SenderKeyPinStoreError>; + fn save(&self, content_id: &str, pin: &SenderKeyPin) -> Result<(), SenderKeyPinStoreError>; +} + +/// プロセス内 `HashMap` 実装。テスト・開発用(再起動で揮発 = 毎回 TOFU に戻る)。 +#[derive(Clone, Default)] +pub struct InMemorySenderKeyPinStore { + inner: Arc>>, +} + +impl SenderKeyPinStore for InMemorySenderKeyPinStore { + fn load(&self, content_id: &str) -> Result, SenderKeyPinStoreError> { + let guard = self + .inner + .lock() + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + Ok(guard.get(content_id).cloned()) + } + + fn save(&self, content_id: &str, pin: &SenderKeyPin) -> Result<(), SenderKeyPinStoreError> { + let mut guard = self + .inner + .lock() + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + guard.insert(content_id.to_string(), pin.clone()); + Ok(()) + } +} + +/// sled 実装。キーは `"sender_pin:{content_id}"`、値は `SenderKeyPin` の JSON。 +/// CEK / share / pubkey / last_seen ストアと同じ `sled::Db` を共有できる。 +pub struct SledSenderKeyPinStore { + db: sled::Db, +} + +impl SledSenderKeyPinStore { + pub fn with_db(db: sled::Db) -> Self { + Self { db } + } + + fn sled_key(content_id: &str) -> String { + format!("sender_pin:{content_id}") + } +} + +impl SenderKeyPinStore for SledSenderKeyPinStore { + fn load(&self, content_id: &str) -> Result, SenderKeyPinStoreError> { + let opt = self + .db + .get(Self::sled_key(content_id)) + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + opt.map(|ivec| { + serde_json::from_slice(&ivec) + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string())) + }) + .transpose() + } + + fn save(&self, content_id: &str, pin: &SenderKeyPin) -> Result<(), SenderKeyPinStoreError> { + let bytes = + serde_json::to_vec(pin).map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + self.db + .insert(Self::sled_key(content_id), bytes) + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + self.db + .flush() + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn roundtrip(store: &dyn SenderKeyPinStore) { + assert!(store.load("content-a").unwrap().is_none()); + + let pin_v0 = SenderKeyPin { + sender_public_key: vec![0x04, 1, 2, 3], + key_epoch: 0, + }; + store.save("content-a", &pin_v0).unwrap(); + assert_eq!(store.load("content-a").unwrap(), Some(pin_v0.clone())); + + // rotation 後の epoch 更新 + let pin_v1 = SenderKeyPin { + key_epoch: 1, + ..pin_v0 + }; + store.save("content-a", &pin_v1).unwrap(); + assert_eq!(store.load("content-a").unwrap(), Some(pin_v1)); + + assert!(store.load("content-b").unwrap().is_none()); + } + + #[test] + fn in_memory_roundtrip() { + roundtrip(&InMemorySenderKeyPinStore::default()); + } + + #[test] + fn sled_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let db = sled::open(dir.path()).unwrap(); + roundtrip(&SledSenderKeyPinStore::with_db(db)); + } +} diff --git a/monas-content/src/presentation/share.rs b/monas-content/src/presentation/share.rs index 8e7c9bd..bf2b270 100644 --- a/monas-content/src/presentation/share.rs +++ b/monas-content/src/presentation/share.rs @@ -22,6 +22,8 @@ use super::{decode_base64, decode_key_id_base64, AppState}; pub struct GrantShareRequest { pub content_id: String, pub sender_key_id_base64: String, + /// 送信者の秘密鍵(base64)。HPKE Auth モードの wrap(送信者認証)に用いる。 + pub sender_private_key_base64: String, pub recipient_public_key_base64: String, pub permission: String, } @@ -35,17 +37,23 @@ pub struct GrantShareResponse { pub enc_base64: String, pub wrapped_cek_base64: String, pub ciphertext_base64: String, + pub key_epoch: u64, } #[derive(Deserialize)] pub struct UnwrapCekRequest { pub content_id: String, pub sender_key_id_base64: String, + /// 送信者の公開鍵(base64)。HPKE Auth モードの unwrap(送信者検証)に用いる。 + pub sender_public_key_base64: String, pub recipient_key_id_base64: String, pub enc_base64: String, pub wrapped_cek_base64: String, pub ciphertext_base64: String, pub recipient_private_key_base64: String, + /// envelope の CEK 鍵世代。wrap 時の AAD と一致しなければ復号は失敗する。 + #[serde(default)] + pub key_epoch: u64, } #[derive(Serialize)] @@ -68,11 +76,14 @@ pub struct KeyEnvelopeResponse { pub enc_base64: String, pub wrapped_cek_base64: String, pub ciphertext_base64: String, + pub key_epoch: u64, } #[derive(Deserialize)] pub struct RevokeShareQuery { pub sender_key_id_base64: String, + /// 送信者の秘密鍵(base64)。残存受信者向け envelope 再発行の wrap に用いる。 + pub sender_private_key_base64: String, } #[derive(Serialize)] @@ -106,6 +117,9 @@ async fn grant_share( let sender_key_id = decode_key_id_base64(&req.sender_key_id_base64, "sender_key_id_base64")?; + let sender_private_key = + decode_base64(&req.sender_private_key_base64, "sender_private_key_base64")?; + let recipient_pubkey = decode_base64( &req.recipient_public_key_base64, "recipient_public_key_base64", @@ -126,6 +140,7 @@ async fn grant_share( let cmd = GrantShareCommand { content_id, sender_key_id, + sender_private_key, recipient_public_key: recipient_pubkey, permission, }; @@ -151,6 +166,7 @@ async fn grant_share( enc_base64: enc_b64, wrapped_cek_base64: wrapped_cek_b64, ciphertext_base64: ciphertext_b64, + key_epoch: env.key_epoch(), })) } @@ -173,6 +189,9 @@ async fn unwrap_cek( "recipient_private_key_base64", )?; + let sender_public_key = + decode_base64(&req.sender_public_key_base64, "sender_public_key_base64")?; + let recipient = WrappedRecipientKey::new(recipient_key_id, enc, wrapped_cek); let envelope = KeyEnvelope::new( content_id, @@ -180,11 +199,12 @@ async fn unwrap_cek( sender_key_id, recipient, ciphertext, + req.key_epoch, ); let cek = state .share_service - .unwrap_cek_from_envelope(&envelope, &recipient_private_key) + .unwrap_cek_from_envelope(&envelope, &recipient_private_key, &sender_public_key) .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; let cek_base64 = BASE64_STANDARD.encode(&cek.0); @@ -199,6 +219,8 @@ async fn revoke_share( let content_id = ContentId::new(content_id_str.clone()); let sender_key_id = decode_key_id_base64(&q.sender_key_id_base64, "sender_key_id_base64")?; + let sender_private_key = + decode_base64(&q.sender_private_key_base64, "sender_private_key_base64")?; let recipient_key_id = decode_key_id_base64(&recipient_key_id_b64, "recipient_key_id (base64)")?; @@ -206,6 +228,7 @@ async fn revoke_share( let cmd = RevokeShareCommand { content_id, sender_key_id, + sender_private_key, recipient_key_id, }; @@ -226,6 +249,7 @@ async fn revoke_share( enc_base64: BASE64_STANDARD.encode(recipient.enc()), wrapped_cek_base64: BASE64_STANDARD.encode(recipient.wrapped_cek()), ciphertext_base64: BASE64_STANDARD.encode(env.ciphertext()), + key_epoch: env.key_epoch(), } }) .collect(); diff --git a/monas-sdk/src/controller/mod.rs b/monas-sdk/src/controller/mod.rs index 02c5bc6..bb16e0f 100644 --- a/monas-sdk/src/controller/mod.rs +++ b/monas-sdk/src/controller/mod.rs @@ -71,8 +71,15 @@ pub struct MonasController { /// content ごとに最後に受理した State Node 版 CID の記録 /// (read 単調性チェック、`docs/design.md` §10「read応答の完全性検証」の単調性) last_seen_store: DynLastSeenStore, + /// share 受信者側の送信者公開鍵ピン(TOFU)と受理済み CEK 鍵世代の記録 + /// (KeyEnvelope の送信者認証と rotation 巻き戻し replay 防止) + sender_pin_store: DynSenderPinStore, } +/// SDK が使う送信者鍵ピンストアの動的型。 +pub(super) type DynSenderPinStore = + std::sync::Arc; + impl MonasController { pub(super) fn current_unix_timestamp() -> u64 { SystemTime::now() @@ -163,7 +170,7 @@ impl MonasController { // stateless thin client and push CEK / share ownership to State Node, // or (b) define an explicit pluggable port for CEK ownership semantics. let content_repository = Self::create_content_repository(); - let (cek_store, share_repository, public_key_directory, last_seen_store) = + let (cek_store, share_repository, public_key_directory, last_seen_store, sender_pin_store) = Self::create_persistence(&config.persistence)?; let agent = Self::build_agent(&config); @@ -183,6 +190,7 @@ impl MonasController { public_key_directory, ), last_seen_store, + sender_pin_store, }) } @@ -215,7 +223,7 @@ impl MonasController { /// CEK / Share / Public key directory の 3 ストアに共有させる。sled は path 単位で /// 排他 flock を取るため、同じディレクトリを 2 度 open すると 2 個目が /// 失敗する (`MONAS_PERSISTENCE_DIR` 設定時の本番経路で必ず再現)。 - /// キー空間は `cek:` / `share:` / `pubkey:` / `last_seen:` プレフィックスで分離されている。 + /// キー空間は `cek:` / `share:` / `pubkey:` / `last_seen:` / `sender_pin:` プレフィックスで分離されている。 fn create_persistence( persistence: &PersistenceConfig, ) -> Result< @@ -224,6 +232,7 @@ impl MonasController { DynShareRepository, DynPublicKeyDirectory, DynLastSeenStore, + DynSenderPinStore, ), ApiError, > { @@ -231,6 +240,7 @@ impl MonasController { key_store::{InMemoryContentEncryptionKeyStore, SledContentEncryptionKeyStore}, last_seen_version_store::{InMemoryLastSeenVersionStore, SledLastSeenVersionStore}, public_key_directory::{InMemoryPublicKeyDirectory, SledPublicKeyDirectory}, + sender_key_pin_store::{InMemorySenderKeyPinStore, SledSenderKeyPinStore}, share_repository::{InMemoryShareRepository, SledShareRepository}, }; @@ -245,7 +255,8 @@ impl MonasController { let share: DynShareRepository = Arc::new(InMemoryShareRepository::default()); let pkd: DynPublicKeyDirectory = Arc::new(InMemoryPublicKeyDirectory::default()); let last_seen: DynLastSeenStore = Arc::new(InMemoryLastSeenVersionStore::default()); - Ok((cek, share, pkd, last_seen)) + let sender_pin: DynSenderPinStore = Arc::new(InMemorySenderKeyPinStore::default()); + Ok((cek, share, pkd, last_seen, sender_pin)) } PersistenceConfig::Sled { dir } => { if let Err(e) = std::fs::create_dir_all(dir) { @@ -261,12 +272,14 @@ impl MonasController { let cek = SledContentEncryptionKeyStore::with_db(db.clone()); let share = SledShareRepository::with_db(db.clone()); let pkd = SledPublicKeyDirectory::with_db(db.clone()); - let last_seen = SledLastSeenVersionStore::with_db(db); + let last_seen = SledLastSeenVersionStore::with_db(db.clone()); + let sender_pin = SledSenderKeyPinStore::with_db(db); let cek: DynCekStore = Arc::new(cek); let share: DynShareRepository = Arc::new(share); let pkd: DynPublicKeyDirectory = Arc::new(pkd); let last_seen: DynLastSeenStore = Arc::new(last_seen); - Ok((cek, share, pkd, last_seen)) + let sender_pin: DynSenderPinStore = Arc::new(sender_pin); + Ok((cek, share, pkd, last_seen, sender_pin)) } } } diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index 82c4204..52e0720 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -138,6 +138,7 @@ impl MonasController { enc: encode_base64url(recipient.enc()), wrapped_cek: encode_base64url(recipient.wrapped_cek()), ciphertext: encode_base64url(domain_envelope.ciphertext()), + key_epoch: domain_envelope.key_epoch(), } } @@ -298,6 +299,7 @@ impl MonasController { for (field, value) in [ ("content_id", input.content_id.as_str()), ("sender_public_key", input.sender_public_key.as_str()), + ("sender_private_key", input.sender_private_key.as_str()), ("recipient_public_key", input.recipient_public_key.as_str()), ] { if let Err(e) = Self::validate_non_empty(field, value) { @@ -317,6 +319,13 @@ impl MonasController { let sender_key_id = Self::compute_key_id_from_public_key(&sender_public_key_bytes); + // 送信者の秘密鍵(HPKE Auth モード wrap の送信者認証に使用。保存はしない) + let sender_private_key_bytes = + match Self::decode_base64url_field("sender_private_key", &input.sender_private_key) { + Ok(v) => v, + Err(e) => return ApiResponse::error(e, trace_id), + }; + // 4. 共有先の公開鍵をデコード let recipient_public_key_bytes = match Self::decode_base64url_field("recipient_public_key", &input.recipient_public_key) @@ -341,6 +350,7 @@ impl MonasController { let cmd = GrantShareCommand { content_id: content_id.clone(), sender_key_id, + sender_private_key: sender_private_key_bytes.clone(), recipient_public_key: recipient_public_key_bytes.clone(), permission: permission.clone(), }; @@ -362,6 +372,7 @@ impl MonasController { let rollback_cmd = RevokeShareCommand { content_id: content_id.clone(), sender_key_id: sender_key_id_for_output.clone(), + sender_private_key: sender_private_key_bytes.clone(), recipient_key_id: result.recipient_key_id.clone(), }; if let Err(rb) = self.share_service.revoke_share(rollback_cmd) { @@ -393,6 +404,7 @@ impl MonasController { let output = ShareContentOutput { content_id: input.content_id, recipient_public_key: input.recipient_public_key, + sender_public_key: input.sender_public_key, sender_key_id: sender_key_id_b64, recipient_key_id: recipient_key_id_b64, key_envelope, @@ -425,6 +437,7 @@ impl MonasController { for (field, value) in [ ("content_id", input.content_id.as_str()), ("sender_public_key", input.sender_public_key.as_str()), + ("sender_private_key", input.sender_private_key.as_str()), ("recipient_public_key", input.recipient_public_key.as_str()), ] { if let Err(e) = Self::validate_non_empty(field, value) { @@ -447,6 +460,13 @@ impl MonasController { }; let sender_key_id = Self::compute_key_id_from_public_key(&sender_public_key_bytes); + // 送信者の秘密鍵(再発行 envelope の HPKE Auth モード wrap に使用。保存はしない) + let sender_private_key_bytes = + match Self::decode_base64url_field("sender_private_key", &input.sender_private_key) { + Ok(v) => v, + Err(e) => return ApiResponse::error(e, trace_id), + }; + // 3. 共有先の公開鍵をデコードしてrecipient_key_idを計算 let recipient_public_key_bytes = match Self::decode_base64url_field("recipient_public_key", &input.recipient_public_key) @@ -496,6 +516,7 @@ impl MonasController { let cmd = RevokeShareCommand { content_id, sender_key_id, + sender_private_key: sender_private_key_bytes, recipient_key_id, }; @@ -580,13 +601,17 @@ impl MonasController { /// 処理フロー: /// 1. 入力のバリデーション /// 2. ContentIdに変換 - /// 3. sender_key_idとrecipient_key_idをデコード - /// 4. 秘密鍵をデコード + /// 3. sender_public_keyとrecipient_key_idをデコード + /// 4. 送信者鍵ピン(TOFU)と鍵世代(key_epoch)の検証: + /// - 初回はこの content の送信者公開鍵候補として受け入れ、復号成功時にピン留め + /// - 2回目以降はピン済み公開鍵と一致しない送信者を拒否し、 + /// 記録済みの鍵世代より古い envelope を拒否する(rotation 巻き戻し replay 防止) /// 5. KeyEnvelopeの各フィールドをデコード /// 6. KeyEnvelopeをmonas-content形式に変換 /// 7. ShareService::unwrap_cek_from_envelopeを呼び出してCEKを取得 + /// (HPKE Auth モード: unwrap 成功 = ピン留め鍵の持ち主が作った envelope の証明) /// 8. ContentService::decrypt_with_cekを呼び出してコンテンツを復号 - /// 9. 結果を返却 + /// 9. CEK と送信者鍵ピン・鍵世代を保存し、結果を返却 pub fn decrypt_shared_content( &self, input: DecryptSharedContentInput, @@ -596,7 +621,7 @@ impl MonasController { // 1. 入力のバリデーション for (field, value) in [ ("content_id", input.content_id.as_str()), - ("sender_key_id", input.sender_key_id.as_str()), + ("sender_public_key", input.sender_public_key.as_str()), ("recipient_key_id", input.recipient_key_id.as_str()), ("private_key", input.private_key.as_str()), ("key_envelope.enc", input.key_envelope.enc.as_str()), @@ -617,13 +642,13 @@ impl MonasController { // 2. ContentIdに変換 let content_id = ContentId::new(input.content_id.clone()); - // 3. sender_key_idとrecipient_key_idをデコード - let sender_key_id_bytes = - match Self::decode_base64url_field("sender_key_id", &input.sender_key_id) { + // 3. sender_public_keyとrecipient_key_idをデコード + let sender_public_key_bytes = + match Self::decode_base64url_field("sender_public_key", &input.sender_public_key) { Ok(v) => v, Err(e) => return ApiResponse::error(e, trace_id), }; - let sender_key_id = KeyId::new(sender_key_id_bytes); + let sender_key_id = Self::compute_key_id_from_public_key(&sender_public_key_bytes); let recipient_key_id_bytes = match Self::decode_base64url_field("recipient_key_id", &input.recipient_key_id) { @@ -632,7 +657,46 @@ impl MonasController { }; let recipient_key_id = KeyId::new(recipient_key_id_bytes); - // 4. 秘密鍵をデコード + // 4. 送信者鍵ピン(TOFU)と鍵世代の検証。 + // unwrap に使う鍵は「入力された鍵」ではなく「ピン済みの鍵」を優先する: + // ピンがある限り、呼び出し側が違う鍵を渡しても検証の根は動かない。 + let pinned = match self.sender_pin_store.load(content_id.as_str()) { + Ok(p) => p, + Err(e) => { + return ApiResponse::error( + ApiError::Internal(format!("sender key pin store error: {e}")), + trace_id, + ); + } + }; + let effective_sender_public_key = match &pinned { + None => sender_public_key_bytes.clone(), + Some(pin) => { + if pin.sender_public_key != sender_public_key_bytes { + return ApiResponse::error( + ApiError::Forbidden(format!( + "sender public key does not match the key pinned for content {} on first share. Rejecting the envelope: a different sender cannot replace the content encryption key.", + content_id.as_str() + )), + trace_id, + ); + } + if input.key_envelope.key_epoch < pin.key_epoch { + return ApiResponse::error( + ApiError::Conflict(format!( + "stale key envelope: its key_epoch {} is older than the last accepted epoch {} for content {} (possible replay of a pre-rotation envelope). Ask the owner for the latest KeyEnvelope.", + input.key_envelope.key_epoch, + pin.key_epoch, + content_id.as_str() + )), + trace_id, + ); + } + pin.sender_public_key.clone() + } + }; + + // 秘密鍵をデコード let private_key_bytes = match Self::decode_base64url_field("private_key", &input.private_key) { Ok(v) => v, @@ -667,14 +731,30 @@ impl MonasController { sender_key_id, wrapped_recipient, ciphertext.clone(), + input.key_envelope.key_epoch, ); - // 7. ShareService::unwrap_cek_from_envelopeを呼び出してCEKを取得 - let cek = match self - .share_service - .unwrap_cek_from_envelope(&domain_envelope, &private_key_bytes) - { + // 7. ShareService::unwrap_cek_from_envelopeを呼び出してCEKを取得。 + // HPKE Auth モードのため、unwrap 成功 = effective_sender_public_key の + // 持ち主がこの envelope を作った証明になる(偽送信者の envelope はここで失敗する)。 + let cek = match self.share_service.unwrap_cek_from_envelope( + &domain_envelope, + &private_key_bytes, + &effective_sender_public_key, + ) { Ok(cek) => cek, + // HPKE Auth モードでは unwrap 失敗が「送信者検証の失敗」を意味し得る + // (偽送信者の envelope / AAD 改ざん / 鍵不一致はすべてここで落ちる)。 + Err(ShareApplicationError::KeyWrapping(msg)) => { + return ApiResponse::error( + ApiError::Forbidden(format!( + "failed to unwrap the CEK with the expected sender public key: the \ + envelope was not created by the pinned sender, or its fields \ + (content_id / recipient / key_epoch) were tampered with: {msg}" + )), + trace_id, + ); + } Err(e) => { return ApiResponse::error(Self::map_share_error(e), trace_id); } @@ -721,6 +801,29 @@ impl MonasController { ); } + // 10. unwrap + 復号の成功 = 送信者と鍵世代の正しさが暗号学的に確認できた + // 時点なので、送信者公開鍵をピン留めし(TOFU)、受理した鍵世代を記録する。 + let new_pin = monas_content::infrastructure::sender_key_pin_store::SenderKeyPin { + sender_public_key: effective_sender_public_key, + key_epoch: input.key_envelope.key_epoch, + }; + let should_save_pin = match &pinned { + None => true, + Some(pin) => input.key_envelope.key_epoch > pin.key_epoch, + }; + if should_save_pin { + if let Err(e) = self.sender_pin_store.save(content_id.as_str(), &new_pin) { + return ApiResponse::error( + ApiError::Internal(format!( + "decrypted the shared content but failed to persist the sender key pin \ + for {}: {e}. Re-process the KeyEnvelope.", + content_id.as_str() + )), + trace_id, + ); + } + } + let content_base64url = encode_base64url(&raw_content); let output = DecryptSharedContentOutput { diff --git a/monas-sdk/src/models/share.rs b/monas-sdk/src/models/share.rs index 30e941e..9f6f4a1 100644 --- a/monas-sdk/src/models/share.rs +++ b/monas-sdk/src/models/share.rs @@ -23,6 +23,11 @@ pub struct KeyEnvelope { pub wrapped_cek: String, /// 暗号化されたコンテンツ(base64url) pub ciphertext: String, + /// CEK の鍵世代。rotation(revoke)のたびに +1 される。wrap の AAD に + /// 束縛されているため書き換えると復号自体が失敗する。受信者は記録済み + /// 世代より古い envelope を拒否する(旧 CEK への巻き戻し replay 防止)。 + #[serde(default)] + pub key_epoch: u64, } // ============================================ @@ -35,6 +40,9 @@ pub struct ShareContentInput { pub content_id: String, /// 送信者の公開鍵(base64url) - sender_key_idを計算するために使用 pub sender_public_key: String, + /// 送信者の秘密鍵(base64url)。KeyEnvelope の HPKE Auth モード wrap + /// (送信者認証)に用いる。SDK には保存されない。 + pub sender_private_key: String, /// 共有先の公開鍵(base64url) pub recipient_public_key: String, #[serde(default = "default_permissions")] @@ -50,6 +58,9 @@ fn default_permissions() -> Vec { pub struct ShareContentOutput { pub content_id: String, pub recipient_public_key: String, + /// 送信者の公開鍵(base64url)。受信者はこれを `decrypt_shared_content` に + /// 渡し、初回処理時に TOFU でピン留めする(以後の envelope 検証の根になる)。 + pub sender_public_key: String, pub sender_key_id: String, pub recipient_key_id: String, pub key_envelope: KeyEnvelope, @@ -84,6 +95,9 @@ pub struct RevokeShareInput { pub remote_content_id: Option, /// 送信者の公開鍵(base64url) - sender_key_idを計算するために使用 pub sender_public_key: String, + /// 送信者の秘密鍵(base64url)。残存受信者向け KeyEnvelope 再発行の + /// HPKE Auth モード wrap に用いる。SDK には保存されない。 + pub sender_private_key: String, pub recipient_public_key: String, } @@ -120,7 +134,10 @@ pub struct ReissuedKeyEnvelope { pub struct DecryptSharedContentInput { pub content_id: String, pub private_key: String, - pub sender_key_id: String, + /// 送信者の公開鍵(base64url)。HPKE Auth モードの unwrap に用いる。 + /// この content で初めての envelope 処理なら TOFU でピン留めされ、 + /// 以後はピン済みの鍵と一致しない場合は拒否される。 + pub sender_public_key: String, pub recipient_key_id: String, pub key_envelope: KeyEnvelope, #[serde(skip_serializing_if = "Option::is_none")] @@ -157,6 +174,7 @@ mod tests { enc: "enc_data".into(), wrapped_cek: "wrapped_cek_data".into(), ciphertext: "ciphertext_data".into(), + key_epoch: 0, }; let json = serde_json::to_string(&envelope).unwrap(); assert!(json.contains("\"enc\":\"enc_data\"")); @@ -169,6 +187,7 @@ mod tests { let json = r#"{ "content_id": "test_id", "sender_public_key": "sender_pub", + "sender_private_key": "sender_priv", "recipient_public_key": "recipient_key" }"#; let input: ShareContentInput = serde_json::from_str(json).unwrap(); @@ -180,6 +199,7 @@ mod tests { let json = r#"{ "content_id": "test_id", "sender_public_key": "sender_pub", + "sender_private_key": "sender_priv", "recipient_public_key": "recipient_key", "permissions": ["read", "write"] }"#; @@ -192,12 +212,14 @@ mod tests { let output = ShareContentOutput { content_id: "test_id".into(), recipient_public_key: "recipient_key".into(), + sender_public_key: "sender_public_key".into(), sender_key_id: "sender_key_id".into(), recipient_key_id: "recipient_key_id".into(), key_envelope: KeyEnvelope { enc: "enc".into(), wrapped_cek: "cek".into(), ciphertext: "ct".into(), + key_epoch: 0, }, delegated_access: Some(DelegatedAccessToken { delegated_token: "jwt".into(), @@ -240,6 +262,7 @@ mod tests { enc: "enc".into(), wrapped_cek: "wrapped".into(), ciphertext: "cipher".into(), + key_epoch: 1, }, }], }; @@ -253,12 +276,13 @@ mod tests { let input = DecryptSharedContentInput { content_id: "test_id".into(), private_key: "test_key".into(), - sender_key_id: "sender_key_id".into(), + sender_public_key: "sender_public_key".into(), recipient_key_id: "recipient_key_id".into(), key_envelope: KeyEnvelope { enc: "enc".into(), wrapped_cek: "cek".into(), ciphertext: "ct".into(), + key_epoch: 0, }, version: None, }; diff --git a/monas-sdk/tests/share_controller_integration_test.rs b/monas-sdk/tests/share_controller_integration_test.rs index 44f229a..10d19e9 100644 --- a/monas-sdk/tests/share_controller_integration_test.rs +++ b/monas-sdk/tests/share_controller_integration_test.rs @@ -68,6 +68,7 @@ async fn share_content_succeeds_after_content_creation() { let share_response = controller.share_content(ShareContentInput { content_id: created.content_id.clone(), sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key.clone(), permissions: vec![Permission::Read], }); @@ -177,6 +178,7 @@ async fn revoke_share_updates_state_node_version() { let share_response = controller.share_content(ShareContentInput { content_id: created.content_id.clone(), sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key.clone(), permissions: vec![Permission::Write], }); @@ -187,7 +189,8 @@ async fn revoke_share_updates_state_node_version() { RevokeShareInput { content_id: created.content_id, remote_content_id: None, - sender_public_key: sender.public_key, + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key, }, None, @@ -267,6 +270,7 @@ async fn revoke_share_syncs_state_node_by_remote_content_id() { let share_response = controller.share_content(ShareContentInput { content_id: created.content_id.clone(), sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key.clone(), permissions: vec![Permission::Write], }); @@ -277,7 +281,8 @@ async fn revoke_share_syncs_state_node_by_remote_content_id() { RevokeShareInput { content_id: created.content_id, remote_content_id: Some("remote-series-id".to_string()), - sender_public_key: sender.public_key, + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key, }, None, @@ -361,6 +366,7 @@ async fn revoke_share_rolls_back_local_state_when_state_node_sync_fails() { let share_response = controller.share_content(ShareContentInput { content_id: created.content_id.clone(), sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key.clone(), permissions: vec![Permission::Read], }); @@ -373,6 +379,7 @@ async fn revoke_share_rolls_back_local_state_when_state_node_sync_fails() { content_id: created.content_id.clone(), remote_content_id: None, sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key.clone(), }, None, @@ -386,7 +393,7 @@ async fn revoke_share_rolls_back_local_state_when_state_node_sync_fails() { let get_shared_response = controller.decrypt_shared_content(DecryptSharedContentInput { content_id: created.content_id.clone(), private_key: recipient.private_key.clone(), - sender_key_id: shared.sender_key_id.clone(), + sender_public_key: shared.sender_public_key.clone(), recipient_key_id: shared.recipient_key_id.clone(), key_envelope: shared.key_envelope.clone(), version: None, @@ -410,7 +417,8 @@ async fn revoke_share_rolls_back_local_state_when_state_node_sync_fails() { RevokeShareInput { content_id: created.content_id, remote_content_id: None, - sender_public_key: sender.public_key, + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key, }, None, @@ -496,6 +504,7 @@ async fn revoke_share_rollback_fires_on_inner_share_service_error() { .share_content(ShareContentInput { content_id: created.content_id.clone(), sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key.clone(), permissions: vec![Permission::Read], }) @@ -509,6 +518,7 @@ async fn revoke_share_rollback_fires_on_inner_share_service_error() { content_id: created.content_id.clone(), remote_content_id: None, sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key.clone(), }, None, @@ -527,7 +537,8 @@ async fn revoke_share_rollback_fires_on_inner_share_service_error() { RevokeShareInput { content_id: created.content_id, remote_content_id: None, - sender_public_key: sender.public_key, + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key, }, None, diff --git a/monas-sdk/tests/state_read_integration_test.rs b/monas-sdk/tests/state_read_integration_test.rs index befccda..c80c8be 100644 --- a/monas-sdk/tests/state_read_integration_test.rs +++ b/monas-sdk/tests/state_read_integration_test.rs @@ -129,6 +129,7 @@ async fn create_and_share( let share_response = controller.share_content(ShareContentInput { content_id: created.content_id.clone(), sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient.public_key.clone(), permissions: vec![Permission::Read], }); @@ -236,7 +237,7 @@ async fn share_recipient_reads_content_after_processing_envelope() { let decrypt_response = recipient_controller.decrypt_shared_content(DecryptSharedContentInput { content_id: created.local_content_id.clone(), private_key: created.recipient_private_key.clone(), - sender_key_id: created.shared.sender_key_id.clone(), + sender_public_key: created.shared.sender_public_key.clone(), recipient_key_id: created.shared.recipient_key_id.clone(), key_envelope: created.shared.key_envelope.clone(), version: None, @@ -339,6 +340,7 @@ async fn cek_rotation_after_revoke_updates_recipient_and_read() { creator.share_content(ShareContentInput { content_id: created.content_id.clone(), sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: recipient_pub.to_string(), permissions: vec![Permission::Read], }) @@ -354,7 +356,7 @@ async fn cek_rotation_after_revoke_updates_recipient_and_read() { let decrypt_v1 = recipient_controller.decrypt_shared_content(DecryptSharedContentInput { content_id: created.content_id.clone(), private_key: surviving_recipient.private_key.clone(), - sender_key_id: shared_surviving.sender_key_id.clone(), + sender_public_key: shared_surviving.sender_public_key.clone(), recipient_key_id: shared_surviving.recipient_key_id.clone(), key_envelope: shared_surviving.key_envelope.clone(), version: None, @@ -374,6 +376,7 @@ async fn cek_rotation_after_revoke_updates_recipient_and_read() { content_id: created.content_id.clone(), remote_content_id: Some(REMOTE_ID.into()), sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), recipient_public_key: revoked_recipient.public_key.clone(), }, None, @@ -438,7 +441,7 @@ async fn cek_rotation_after_revoke_updates_recipient_and_read() { let decrypt_v2 = recipient_controller.decrypt_shared_content(DecryptSharedContentInput { content_id: created.content_id.clone(), private_key: surviving_recipient.private_key.clone(), - sender_key_id: shared_surviving.sender_key_id.clone(), + sender_public_key: shared_surviving.sender_public_key.clone(), recipient_key_id: reissued.recipient_key_id.clone(), key_envelope: reissued.key_envelope.clone(), version: None, @@ -455,6 +458,36 @@ async fn cek_rotation_after_revoke_updates_recipient_and_read() { plaintext ); + // rotation 前の旧 envelope(key_epoch が古い)を再送しても、保存済み CEK は + // 巻き戻らない(replay 防止)。攻撃者が保存しておいた正規の旧 envelope で + // 受信者の CEK を旧世代へ戻し、read を壊すことはできない。 + assert!( + reissued.key_envelope.key_epoch > shared_surviving.key_envelope.key_epoch, + "rotation must advance the envelope key_epoch" + ); + let replay = recipient_controller.decrypt_shared_content(DecryptSharedContentInput { + content_id: created.content_id.clone(), + private_key: surviving_recipient.private_key.clone(), + sender_public_key: shared_surviving.sender_public_key.clone(), + recipient_key_id: shared_surviving.recipient_key_id.clone(), + key_envelope: shared_surviving.key_envelope.clone(), + version: None, + }); + assert!(!replay.success, "pre-rotation envelope replay must fail"); + match replay.error { + Some(ApiError::Conflict(msg)) => { + assert!(msg.contains("stale key envelope"), "msg={msg}") + } + other => panic!("expected Conflict(stale envelope), got: {other:?}"), + } + // read は引き続き新 CEK で成功する(巻き戻っていない証明) + let read_after_replay = read_latest(); + assert!( + read_after_replay.success, + "read must still succeed after rejected replay: {:?}", + read_after_replay.error + ); + cleanup_content_artifacts(); } @@ -494,6 +527,64 @@ async fn read_rejects_tampered_node() { cleanup_content_artifacts(); } +/// KeyEnvelope は HPKE Auth モードでラップされており、受信者は送信者公開鍵で +/// unwrap する(成功 = その鍵の持ち主が作った証明)。 +/// - 間違った送信者鍵での処理は復号自体が失敗する +/// - 一度正しい鍵で処理すると TOFU でピン留めされ、以後別の鍵を渡しても拒否される +#[tokio::test(flavor = "multi_thread")] +async fn envelope_sender_auth_rejects_wrong_sender_key() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + let creator = MonasController::with_urls(server.url(), server.url()); + + let created = create_and_share(&mut server, &creator, b"sender-auth-target").await; + + let recipient_controller = MonasController::with_urls(server.url(), server.url()); + let attacker = recipient_controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("attacker keypair"); + + let decrypt_with_sender = |sender_public_key: String| { + recipient_controller.decrypt_shared_content(DecryptSharedContentInput { + content_id: created.local_content_id.clone(), + private_key: created.recipient_private_key.clone(), + sender_public_key, + recipient_key_id: created.shared.recipient_key_id.clone(), + key_envelope: created.shared.key_envelope.clone(), + version: None, + }) + }; + + // 1. 間違った送信者鍵(攻撃者の鍵)を渡して処理 → unwrap が失敗する + let wrong_key = decrypt_with_sender(attacker.public_key.clone()); + assert!(!wrong_key.success, "wrong sender key must fail to unwrap"); + match wrong_key.error { + Some(ApiError::Forbidden(msg)) => { + assert!(msg.contains("unwrap"), "msg={msg}") + } + other => panic!("expected Forbidden(unwrap failure), got: {other:?}"), + } + + // 2. 正しい送信者鍵で処理 → 成功し、TOFU でピン留めされる + let correct = decrypt_with_sender(created.shared.sender_public_key.clone()); + assert!(correct.success, "{:?}", correct.error); + + // 3. ピン留め後に別の鍵を渡す → unwrap 以前にピン不一致で拒否される + let after_pin = decrypt_with_sender(attacker.public_key.clone()); + assert!(!after_pin.success, "non-pinned sender key must be rejected"); + match after_pin.error { + Some(ApiError::Forbidden(msg)) => { + assert!(msg.contains("pinned"), "msg={msg}") + } + other => panic!("expected Forbidden(pin mismatch), got: {other:?}"), + } + + cleanup_content_artifacts(); +} + /// last_seen は「復号まで含む全検証が成功した read」でしか進んではならない。 /// CID 検証は通るが復号できない偽 Node を 1 回受けただけで pin が偽版に /// 汚染されると、以後の正規 read が恒久的に Conflict になる(自壊 DoS)。 From b031e93a3e3620e64e8ed6a2e01a7ed9e3cf30ce Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 25 Jul 2026 16:02:49 +0900 Subject: [PATCH 24/48] fix(test-auth-generator): emit share-token payload in canonical field order; log redacted auth failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate-share-token built its JWT payload with serde_json::json!, whose map keys serialize alphabetically (att, aud, exp, iat, iss, jti). The state node verifies JWT signatures by re-serializing the parsed AuthTokenPayload struct (field order iss, aud, exp, iat, jti, att), so every token the tool minted failed signature verification — which is also why the delegated-JWT read path had never been exercised end to end. The payload is now a serde struct matching monas-account's DelegationClaims field order, verified against a live 4-node mesh: recipient reads with a delegated JWT succeed on both member and non-member (relay) nodes. Also logs the detailed reason (tracing::warn) when an authentication failure is redacted to the generic HTTP "Authentication failed" body — without it, diagnosing JWT verification failures on a running node is guesswork. Note for a follow-up: verifying JWTs by re-serializing parsed structs is brittle (field order, whitespace, unknown fields all break genuine tokens). Verification should use the original wire segments captured in from_jwt instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J --- .../src/bin/test_auth_generator.rs | 53 +++++++++++++------ monas-state-node/src/presentation/http_api.rs | 5 +- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/monas-state-node/src/bin/test_auth_generator.rs b/monas-state-node/src/bin/test_auth_generator.rs index 67fe9c4..da035d6 100644 --- a/monas-state-node/src/bin/test_auth_generator.rs +++ b/monas-state-node/src/bin/test_auth_generator.rs @@ -216,6 +216,29 @@ struct DelegatedPayload { jti: String, } +/// Delegated-JWT payload with the SAME field order as monas-account's +/// `DelegationClaims` and the state node's `AuthTokenPayload`. +/// +/// The state node verifies JWT signatures by re-serializing the parsed +/// payload struct, so the signing input is only reproducible when the +/// issuer serializes fields in this exact order. `serde_json::json!` maps +/// are alphabetical and produce tokens the state node cannot verify. +#[derive(serde::Serialize)] +struct ShareTokenPayload { + iss: String, + aud: String, + exp: u64, + iat: u64, + jti: String, + att: Vec, +} + +#[derive(serde::Serialize)] +struct ShareTokenCapability { + with: String, + can: String, +} + fn build_delegated_request_message(jwt: &str) -> String { let parts: Vec<&str> = jwt.split('.').collect(); if parts.len() != 3 { @@ -360,14 +383,11 @@ fn generate_share_token(args: &[String]) { let recipient_key_id = format!("user:{}", hex::encode(&recipient_public_key_bytes)); - let caps: Vec = capabilities_str + let caps: Vec = capabilities_str .split(',') - .map(|c| { - let action = c.trim(); - json!({ - "with": format!("monas://content/{}", content_id), - "can": action - }) + .map(|c| ShareTokenCapability { + with: format!("monas://content/{}", content_id), + can: c.trim().to_string(), }) .collect(); @@ -384,17 +404,18 @@ fn generate_share_token(args: &[String]) { let jti = Uuid::new_v4().to_string(); - let payload = json!({ - "iss": owner_key_id, - "aud": recipient_key_id, - "exp": now + expiry, - "iat": now, - "jti": jti, - "att": caps - }); + let payload = ShareTokenPayload { + iss: owner_key_id.clone(), + aud: recipient_key_id.clone(), + exp: now + expiry, + iat: now, + jti: jti.clone(), + att: caps, + }; let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string()); - let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string()); + let payload_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).expect("payload serialization")); let signing_input = format!("{}.{}", header_b64, payload_b64); diff --git a/monas-state-node/src/presentation/http_api.rs b/monas-state-node/src/presentation/http_api.rs index d3d390b..9c64c93 100644 --- a/monas-state-node/src/presentation/http_api.rs +++ b/monas-state-node/src/presentation/http_api.rs @@ -193,7 +193,10 @@ impl IntoResponse for StateNodeError { StateNodeError::NotAMember { .. } => self.to_string(), StateNodeError::PermissionDenied(_) => "Permission denied".to_string(), StateNodeError::InvalidUcanToken(_) => "Invalid authentication token".to_string(), - StateNodeError::AuthenticationFailed(_) => "Authentication failed".to_string(), + StateNodeError::AuthenticationFailed(detail) => { + tracing::warn!("authentication failed: {detail}"); + "Authentication failed".to_string() + } StateNodeError::AuthorizationFailed(_) => "Authorization failed".to_string(), StateNodeError::InvalidCid(_) => "Invalid content identifier".to_string(), StateNodeError::InvalidConfiguration(_) => "Invalid request".to_string(), From b676287e1011c3b5cfb22469f414a685b8635310 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sun, 26 Jul 2026 19:26:09 +0900 Subject: [PATCH 25/48] fix(state-node): unify request PoP as {op}:{resource}:{timestamp}; drop jti single-use; verify JWT over wire bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #61, closes #60. 問題(#61): 委譲 JWT の PoP 署名対象が固定文字列 {iss}:{aud}:{jti} で、 リクエストの新しさが署名に入っていなかった。その帳尻合わせの jti 単回消費が 「委譲トークンを 1 個渡して TTL 内で再利用する」SDK の設計と矛盾し、 履歴取得 → データ取得という通常の read すら成立しなかった(nonce 記録は ノードごとに独立で一貫性もない)。 修正(案 A): 署名対象をトークン種別によらず {operation}:{resource}:{timestamp} (書き込みは body hash + timestamp)に統一。リプレイ防御は署名内 timestamp の 鮮度チェック(5 分窓)に一本化し、jti 単回消費と nonce ストアを廃止。 timestamp はサーバ時刻フォールバックをやめ構造的に必須とした(欠如 = 認証 エラー)。盗まれた署名でできることは「同じリソースへの同じ操作を 5 分以内に 再実行」のみで、owner 用の非 JWT パスと同水準。 問題(#60): JWT 署名検証がパース後構造体の再シリアライズに依存し、発行者の JSON フィールド順序が異なると正当なトークンを拒否する brittle な実装だった。 修正: 受信したワイヤ上の header.payload セグメントに対して検証する verify_jwt_signature_wire を導入し、JWT 検証 2 箇所をこれに置換。 - PoP 検証は verify_caller_signature に一本化(全経路が authorize 前に通る)。 ucan_adapter は権限判定に専念し、署名は存在チェックのみ(検証は上流で済) - test-auth-generator / テストヘルパも統一形式へ。フィールド順序非依存・ 改ざん拒否・トークン再利用・timestamp 必須の回帰テストを追加 Co-Authored-By: Claude Fable 5 --- docs/design.md | 2 + .../src/application_service/node.rs | 14 +- .../application_service/state_node_service.rs | 126 ++++++---- .../src/bin/test_auth_generator.rs | 44 +--- .../auth/monas_account_adapter.rs | 51 +++- .../infrastructure/auth/signature_verifier.rs | 112 ++++++++- .../src/infrastructure/auth/test_helpers.rs | 35 ++- .../src/infrastructure/auth/ucan_adapter.rs | 234 +++++++++++------- .../persistence/sled_public_key_repository.rs | 101 -------- .../tests/create_content_push_race_test.rs | 7 +- monas-state-node/tests/integration_test.rs | 23 +- 11 files changed, 431 insertions(+), 318 deletions(-) diff --git a/docs/design.md b/docs/design.md index 255b640..03c024d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -340,6 +340,8 @@ Token.att = [ Token失効は`min_valid_issued_at`による時刻ベースで管理される。オーナーがこの値を更新することで、それ以前に発行されたすべてのTokenを一括失効できる。 +役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別によらず`{操作}:{リソース}:{timestamp}`(書き込みはbody hash + timestamp)で統一されており、リプレイ防御は署名内のtimestampの鮮度チェック(5分窓)が担う。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。したがってTokenはTTL内で何度でも再利用でき、盗まれた署名でできることは「同じリソースへの同じ操作を5分以内に再実行する」ことに限られる。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 + ### ビザンチン耐性 ネットワークはビザンチン耐性を前提として設計されている。悪意のあるノードが参加してもコンテンツの暗号化によって内容の漏洩は防がれる。XOR距離によるランダムなノード選択が一定の保護を提供する。 diff --git a/monas-state-node/src/application_service/node.rs b/monas-state-node/src/application_service/node.rs index ee90b61..e601324 100644 --- a/monas-state-node/src/application_service/node.rs +++ b/monas-state-node/src/application_service/node.rs @@ -211,16 +211,12 @@ impl StateNode { node_id.clone(), )); - // Create auth services with public key registry for identity verification - let auth_public_key_repo = Arc::new( - crate::infrastructure::persistence::SledPublicKeyRepository::open( - config.data_dir.join("auth_public_keys"), - ) - .context("Failed to open auth public key repository")?, - ); + // Create auth services. + // NOTE: リプレイ防御は署名内 timestamp の鮮度チェックに一本化されており、 + // 旧 jti nonce ストア(ノードごとに独立で、委譲トークンの TTL 内再利用と + // 矛盾していた)は廃止した(issue #61)。 let auth_service = MonasAccountAdapter::new(); - let authz_service = - UcanAdapter::new(crdt_repo_dyn.clone()).with_nonce_store(auth_public_key_repo.clone()); + let authz_service = UcanAdapter::new(crdt_repo_dyn.clone()); // Create service with CRDT repository let service = Arc::new( diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 882ca4e..f846560 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -10,7 +10,6 @@ use crate::domain::events::{current_timestamp, Event}; use crate::domain::identity::Identity; use crate::domain::state_node::{self, NodeSnapshot}; use crate::domain::value_objects::ContentId; -use crate::infrastructure::auth::auth_token::AuthToken as InfraAuthToken; use crate::infrastructure::crypto::verify_p256_signature; use crate::infrastructure::placement::compute_dht_key; use crate::port::auth_token::{AuthToken, RequestMetadata}; @@ -238,9 +237,9 @@ where .await .map_err(|e| StateNodeError::AuthenticationFailed(e.to_string()))?; - // Verify caller signature for all token types. - // JWT: proof-of-possession via "{iss}:{aud}:{jti}" request signature - // type:id: metadata/body based request signature + // Verify caller signature for all token types. The signed message is + // `read:{content_id}:{timestamp}` regardless of token kind; JWT tokens + // additionally have their own owner signature verified. let sig = request_signature.ok_or_else(|| { StateNodeError::AuthenticationFailed("Request signature is required".to_string()) })?; @@ -260,15 +259,20 @@ where /// Verify the caller's request signature. /// - /// For JWT tokens (containing `.`), verifies the JWT's own P-256 signature - /// via `AuthenticationService::verify_jwt_signature`, and enforces - /// caller proof-of-possession by verifying request signature over - /// "{iss}:{aud}:{jti}" using the audience key. - /// - /// For `type:id` tokens (e.g., `user:alice`), constructs a signing message - /// and delegates to `AuthenticationService::verify_request_signature`: + /// The signed message is identical for every token type (issue #61): /// - If `request_body` is `Some(body)`: signs `hex(sha256(body + timestamp_be_bytes))` /// - If `request_body` is `None`: signs `{operation}:{resource}:{timestamp}` + /// + /// Replay protection comes from the timestamp *inside* the signed message + /// (freshness window checked by the auth service), so `timestamp` is + /// mandatory — there is no server-clock fallback. A token can therefore be + /// reused for many requests within its TTL; a stolen signature only allows + /// repeating the same operation on the same resource within the window. + /// + /// For JWT tokens (containing `.`), the JWT's own P-256 signature is + /// verified first via `AuthenticationService::verify_jwt_signature` + /// (over the received wire bytes), and the request signature is then + /// verified against the audience (`aud`) key. #[allow(clippy::too_many_arguments)] async fn verify_caller_signature( &self, @@ -280,7 +284,8 @@ where timestamp: Option, request_body: Option<&[u8]>, ) -> Result<(), StateNodeError> { - // JWT tokens: verify JWT signature + request proof-of-possession. + // JWT tokens: the token itself is a signed capability — verify the + // owner's signature before trusting any of its claims. if token.as_str().contains('.') { auth_service .verify_jwt_signature(token) @@ -291,38 +296,16 @@ where e )) })?; - - let parsed = InfraAuthToken::from_jwt(token.as_str()).map_err(|e| { - StateNodeError::AuthenticationFailed(format!( - "Failed to parse JWT for request signature verification: {}", - e - )) - })?; - - let pop_message = format!( - "{}:{}:{}", - parsed.payload.iss, parsed.payload.aud, parsed.payload.jti - ); - auth_service - .verify_request_signature(token, signature, &pop_message, timestamp) - .await - .map_err(|e| { - StateNodeError::AuthenticationFailed(format!( - "JWT request signature verification failed: {}", - e - )) - })?; - - return Ok(()); } - // non-JWT tokens: verify request signature - let ts = timestamp.unwrap_or_else(|| { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - }); + // Freshness is part of the signed message. Falling back to the server + // clock would let a caller omit the timestamp and bypass the max-age + // check entirely, so a missing timestamp is an authentication error. + let ts = timestamp.ok_or_else(|| { + StateNodeError::AuthenticationFailed( + "X-Request-Timestamp is required for request signature verification".to_string(), + ) + })?; let message = if let Some(body) = request_body { // Body-based signing: hex(sha256(body + timestamp_be_bytes)) @@ -2296,6 +2279,17 @@ mod tests { vec![0x01] } + /// timestamp は署名検証で構造的に必須(issue #61)。mock 認証でも + /// 存在チェックは実コードを通るため、現在時刻を渡す。 + fn test_timestamp() -> Option { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ) + } + type TestService = StateNodeService< MockNodeRegistry, MockContentNetworkRepository, @@ -2397,6 +2391,32 @@ mod tests { } } + /// timestamp が無いリクエストは署名検証に到達する前に拒否される + /// (issue #61: freshness は署名内 timestamp が担うため、欠如は + /// サーバ時刻へのフォールバックではなく認証エラー)。 + #[tokio::test] + async fn test_create_content_requires_timestamp() { + let mut capacities = HashMap::new(); + capacities.insert("peer-1".to_string(), 500); + let service = create_service_with_peers("node-1", vec!["peer-1".to_string()], capacities); + + let result = service + .create_content( + b"test data", + Some(&test_token()), + Some(&test_request_signature()), + None, + ) + .await; + + match result { + Err(StateNodeError::AuthenticationFailed(msg)) => { + assert!(msg.contains("X-Request-Timestamp"), "msg={msg}"); + } + other => panic!("expected AuthenticationFailed, got: {other:?}"), + } + } + #[tokio::test] async fn test_create_content_with_peers() { let mut capacities = HashMap::new(); @@ -2419,7 +2439,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -2465,7 +2485,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -2502,7 +2522,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2522,7 +2542,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2568,7 +2588,7 @@ mod tests { b"new data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -2615,7 +2635,7 @@ mod tests { b"new data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2648,7 +2668,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2675,7 +2695,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2707,7 +2727,7 @@ mod tests { "content-1", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -2742,7 +2762,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3163,7 +3183,7 @@ mod tests { b"new data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; diff --git a/monas-state-node/src/bin/test_auth_generator.rs b/monas-state-node/src/bin/test_auth_generator.rs index 3aaee5c..4088700 100644 --- a/monas-state-node/src/bin/test_auth_generator.rs +++ b/monas-state-node/src/bin/test_auth_generator.rs @@ -4,7 +4,6 @@ use base64::{ }; use p256::ecdsa::{signature::Signer, SigningKey}; use p256::elliptic_curve::rand_core::OsRng; -use serde::Deserialize; use serde_json::json; use sha2::{Digest as Sha2Digest, Sha256}; use std::env; @@ -62,7 +61,7 @@ fn print_usage(program: &str) { eprintln!(" --resource Resource (content_id or 'content')"); eprintln!(" --timestamp Unix timestamp"); eprintln!(" [--body ] Request body (base64, for create/update)"); - eprintln!(" [--auth-token ] Delegated token (signs \"iss:aud:jti\")"); + eprintln!(" (delegated JWT requests sign the same message with the recipient key)"); eprintln!(" generate-token [content_id] - Generate an auth token (JWT)"); eprintln!(" generate-share-token - Generate a share token for another user"); } @@ -107,7 +106,6 @@ fn sign_request(args: &[String]) { let mut resource = String::new(); let mut timestamp_str = String::new(); let mut body_b64 = String::new(); - let mut auth_token = String::new(); let mut i = 0; while i < args.len() { @@ -142,12 +140,6 @@ fn sign_request(args: &[String]) { body_b64 = args[i].clone(); } } - "--auth-token" => { - i += 1; - if i < args.len() { - auth_token = args[i].clone(); - } - } _ => {} } i += 1; @@ -181,10 +173,11 @@ fn sign_request(args: &[String]) { }); // Construct the signing message. - // Delegated JWT requests use "{iss}:{aud}:{jti}". - let message = if !auth_token.is_empty() { - build_delegated_request_message(&auth_token) - } else if !body_b64.is_empty() { + // The message format is identical for every token type (issue #61): + // body-based for writes, `{operation}:{resource}:{timestamp}` otherwise. + // Delegated JWT requests are signed with the recipient (aud) key over the + // same message — the old "{iss}:{aud}:{jti}" fixed string is gone. + let message = if !body_b64.is_empty() { // Body-based signing: hex(sha256(body_bytes + timestamp_be_bytes)) let body_bytes = STANDARD.decode(&body_b64).unwrap_or_else(|e| { eprintln!("Error: Invalid body base64: {}", e); @@ -209,31 +202,6 @@ fn sign_request(args: &[String]) { println!("MESSAGE={}", message); } -#[derive(Debug, Deserialize)] -struct DelegatedPayload { - iss: String, - aud: String, - jti: String, -} - -fn build_delegated_request_message(jwt: &str) -> String { - let parts: Vec<&str> = jwt.split('.').collect(); - if parts.len() != 3 { - eprintln!("Error: Invalid --auth-token format (expected header.payload.signature)"); - std::process::exit(1); - } - - let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap_or_else(|e| { - eprintln!("Error: Invalid JWT payload encoding: {}", e); - std::process::exit(1); - }); - let payload: DelegatedPayload = serde_json::from_slice(&payload_bytes).unwrap_or_else(|e| { - eprintln!("Error: Invalid JWT payload JSON: {}", e); - std::process::exit(1); - }); - format!("{}:{}:{}", payload.iss, payload.aud, payload.jti) -} - fn generate_auth_token(content_id: Option) { let signing_key = SigningKey::random(&mut OsRng); let verifying_key = signing_key.verifying_key(); diff --git a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs index a7aa89f..76c2db6 100644 --- a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs @@ -227,8 +227,10 @@ impl AuthenticationService for MonasAccountAdapter { let issuer_key_id = &parsed.payload.iss; let public_key = Self::extract_public_key_from_key_id(issuer_key_id)?; - // Verify P-256 signature - SignatureVerifier::verify_auth_token_signature(&parsed, &public_key) + // Verify P-256 signature over the received wire bytes (never over a + // re-serialized form, which would reject tokens whose issuer used a + // different JSON field order). + SignatureVerifier::verify_jwt_signature_wire(jwt_str, &public_key) .context("JWT signature verification failed") } @@ -495,6 +497,51 @@ mod tests { assert!(result.is_err()); } + /// issue #61: 委譲 JWT の PoP も非 JWT と同じ `{op}:{resource}:{timestamp}` + /// 形式で、宛先(aud)の鍵に対して検証される。同じトークンを別の + /// リクエスト(新しい timestamp・新しい署名)で再利用できる。 + #[tokio::test] + async fn test_verify_request_signature_jwt_unified_message() { + use crate::infrastructure::auth::test_helpers::TestKeyPair; + + let owner = TestKeyPair::generate("user", "owner"); + let recipient = TestKeyPair::generate("user", "recipient"); + let auth_token = owner.create_auth_token( + &recipient, + "monas://content/content-1", + vec![crate::infrastructure::auth::auth_token::CapabilityAction::Read], + Some(3600), + ); + let token = AuthToken::new(auth_token.to_jwt().unwrap()); + let adapter = MonasAccountAdapter::new(); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // 同じトークンで 2 リクエスト(履歴取得 → データ取得を模す)。 + // それぞれ新しい timestamp を署名の中に入れる。 + for i in 0..2u64 { + let ts = now + i; + let message = format!("read:content-1:{ts}"); + let sig = recipient.sign(message.as_bytes()); + let result = adapter + .verify_request_signature(&token, &sig, &message, Some(ts)) + .await; + assert!(result.is_ok(), "request {i} should verify: {result:?}"); + } + + // 宛先(aud)以外の鍵で署名したものは拒否される + let ts = now; + let message = format!("read:content-1:{ts}"); + let forged = owner.sign(message.as_bytes()); + assert!(adapter + .verify_request_signature(&token, &forged, &message, Some(ts)) + .await + .is_err()); + } + #[tokio::test] async fn test_verify_request_signature_expired_timestamp() { let (adapter, signing_key, key_id) = create_test_adapter(); diff --git a/monas-state-node/src/infrastructure/auth/signature_verifier.rs b/monas-state-node/src/infrastructure/auth/signature_verifier.rs index 3ddb1dc..5f8c174 100644 --- a/monas-state-node/src/infrastructure/auth/signature_verifier.rs +++ b/monas-state-node/src/infrastructure/auth/signature_verifier.rs @@ -4,6 +4,7 @@ use super::auth_token::{AuthToken, AuthTokenError}; use anyhow::{Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; /// Signature verifier for P256/ES256 signatures @@ -21,17 +22,42 @@ impl SignatureVerifier { pub fn verify_auth_token_signature(token: &AuthToken, owner_public_key: &[u8]) -> Result<()> { let message = token.signing_message()?; + Self::verify_p256(&message, &token.signature, owner_public_key) + } + + /// Verify a JWT's signature over the exact wire bytes it was signed with. + /// + /// JWS の署名対象は「受信した `.` そのもの」であり、 + /// パース後の構造体を再シリアライズして作り直してはならない(JSON のフィールド + /// 順序や空白が発行者と一致する保証がなく、正当なトークンを拒否する)。 + /// この関数はワイヤ上のセグメントをそのまま検証するため、発行者側の + /// シリアライズ形と無関係に正しく検証できる。 + pub fn verify_jwt_signature_wire(jwt: &str, issuer_public_key: &[u8]) -> Result<()> { + let parts: Vec<&str> = jwt.split('.').collect(); + if parts.len() != 3 { + anyhow::bail!("Invalid JWT format: expected 3 parts, got {}", parts.len()); + } + + let message = format!("{}.{}", parts[0], parts[1]); + let signature = URL_SAFE_NO_PAD + .decode(parts[2]) + .context("Failed to decode JWT signature segment")?; + + Self::verify_p256(message.as_bytes(), &signature, issuer_public_key) + } + + fn verify_p256(message: &[u8], signature: &[u8], public_key: &[u8]) -> Result<()> { // Parse P256 public key from SEC1 uncompressed format - let verifying_key = VerifyingKey::from_sec1_bytes(owner_public_key) - .context("Invalid P256 public key format")?; + let verifying_key = + VerifyingKey::from_sec1_bytes(public_key).context("Invalid P256 public key format")?; // Parse signature from DER or raw format let signature = - Signature::from_slice(&token.signature).context("Invalid P256 signature format")?; + Signature::from_slice(signature).context("Invalid P256 signature format")?; // Verify signature verifying_key - .verify(&message, &signature) + .verify(message, &signature) .map_err(|e| AuthTokenError::SignatureVerificationFailed(e.to_string()))?; Ok(()) @@ -193,3 +219,81 @@ mod tests { assert!(result.is_err()); } } + +#[cfg(test)] +mod wire_verification_tests { + use super::*; + use p256::ecdsa::{signature::Signer, SigningKey}; + use rand::rngs::OsRng; + + fn sign_jwt(header_json: &str, payload_json: &str, key: &SigningKey) -> String { + let h = URL_SAFE_NO_PAD.encode(header_json.as_bytes()); + let p = URL_SAFE_NO_PAD.encode(payload_json.as_bytes()); + let signing_input = format!("{h}.{p}"); + let sig: p256::ecdsa::Signature = key.sign(signing_input.as_bytes()); + format!("{h}.{p}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes())) + } + + /// issue #60 の回帰テスト: 署名検証はワイヤ上のバイト列に対して行うため、 + /// 発行者が構造体の再シリアライズ形と異なるフィールド順序・空白で + /// JSON を作っていても正しく検証できる。 + #[test] + fn wire_verification_is_independent_of_field_order() { + let key = SigningKey::random(&mut OsRng); + let public_key = key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + + // 意図的に順序を崩し、空白も混ぜた JSON(serde の再シリアライズでは + // 再現されない形) + let header = r#"{ "typ":"JWT" , "alg":"ES256" }"#; + let payload = r#"{ "jti":"j-1", "iss":"user:04aa", "iat":1, "aud":"user:04bb", "att":[] }"#; + let jwt = sign_jwt(header, payload, &key); + + assert!(SignatureVerifier::verify_jwt_signature_wire(&jwt, &public_key).is_ok()); + } + + #[test] + fn wire_verification_rejects_tampered_payload() { + let key = SigningKey::random(&mut OsRng); + let public_key = key + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + + let jwt = sign_jwt( + r#"{"alg":"ES256","typ":"JWT"}"#, + r#"{"iss":"user:04aa","aud":"user:04bb","iat":1,"jti":"j-1","att":[]}"#, + &key, + ); + + // payload セグメントを差し替え + let parts: Vec<&str> = jwt.split('.').collect(); + let forged_payload = URL_SAFE_NO_PAD + .encode(r#"{"iss":"user:04aa","aud":"user:04EVIL","iat":1,"jti":"j-1","att":[]}"#); + let forged = format!("{}.{}.{}", parts[0], forged_payload, parts[2]); + + assert!(SignatureVerifier::verify_jwt_signature_wire(&forged, &public_key).is_err()); + } + + #[test] + fn wire_verification_rejects_wrong_key() { + let key = SigningKey::random(&mut OsRng); + let other = SigningKey::random(&mut OsRng); + let other_pub = other + .verifying_key() + .to_encoded_point(false) + .as_bytes() + .to_vec(); + + let jwt = sign_jwt( + r#"{"alg":"ES256","typ":"JWT"}"#, + r#"{"iss":"user:04aa","aud":"user:04bb","iat":1,"jti":"j-1","att":[]}"#, + &key, + ); + assert!(SignatureVerifier::verify_jwt_signature_wire(&jwt, &other_pub).is_err()); + } +} diff --git a/monas-state-node/src/infrastructure/auth/test_helpers.rs b/monas-state-node/src/infrastructure/auth/test_helpers.rs index 02c1eaa..91fb84a 100644 --- a/monas-state-node/src/infrastructure/auth/test_helpers.rs +++ b/monas-state-node/src/infrastructure/auth/test_helpers.rs @@ -127,18 +127,14 @@ impl TestKeyPair { /// Sign a request using this key pair /// - /// The request signature format is: "{iss}:{aud}:{jti}" - /// - /// # Arguments - /// * `auth_token` - The AuthToken being used for the request + /// The request signature format is `{operation}:{resource}:{timestamp}` — + /// identical for every token type (issue #61). The old "{iss}:{aud}:{jti}" + /// fixed string is gone: freshness lives inside the signed message. /// /// # Returns /// The request signature bytes - pub fn sign_request(&self, auth_token: &AuthToken) -> Vec { - let message = format!( - "{}:{}:{}", - auth_token.payload.iss, auth_token.payload.aud, auth_token.payload.jti - ); + pub fn sign_request(&self, operation: &str, resource: &str, timestamp: u64) -> Vec { + let message = format!("{operation}:{resource}:{timestamp}"); self.sign(message.as_bytes()) } } @@ -247,18 +243,21 @@ mod tests { #[test] fn test_sign_request() { - let alice = TestKeyPair::generate("user", "alice"); let bob = TestKeyPair::generate("user", "bob"); - let token = alice.create_auth_token( - &bob, - "monas://content/test123", - vec![CapabilityAction::Read], - None, - ); - - let request_sig = bob.sign_request(&token); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let request_sig = bob.sign_request("read", "content-1", ts); assert!(!request_sig.is_empty()); + + // 統一形式 `{operation}:{resource}:{timestamp}` に対する署名として検証できる + let message = format!("read:content-1:{ts}"); + use p256::ecdsa::signature::Verifier; + let vk = bob.secret_key.verifying_key(); + let sig = p256::ecdsa::Signature::from_slice(&request_sig).unwrap(); + assert!(vk.verify(message.as_bytes(), &sig).is_ok()); } #[test] diff --git a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs index 2575dd8..91c4a4a 100644 --- a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs @@ -14,7 +14,6 @@ use crate::domain::auth_capability::AuthCapability; use crate::domain::identity::{Identity, IdentityType}; use crate::infrastructure::auth::auth_token::AuthToken as InfraAuthToken; use crate::infrastructure::auth::signature_verifier::SignatureVerifier; -use crate::infrastructure::persistence::SledPublicKeyRepository; use crate::port::auth_token::AuthToken; use crate::port::authorization_service::{ AuthorizationRequest, AuthorizationResult, AuthorizationService, @@ -40,23 +39,12 @@ use std::sync::Arc; /// ``` pub struct UcanAdapter { content_repo: Arc, - /// Nonce store for replay attack prevention (JTI uniqueness check) - nonce_store: Option>, } impl UcanAdapter { /// Create a new UcanAdapter with a ContentRepository pub fn new(content_repo: Arc) -> Self { - Self { - content_repo, - nonce_store: None, - } - } - - /// Set the nonce store for replay attack prevention (builder pattern) - pub fn with_nonce_store(mut self, nonce_store: Arc) -> Self { - self.nonce_store = Some(nonce_store); - self + Self { content_repo } } /// Convert Identity to key ID format @@ -217,21 +205,27 @@ impl UcanAdapter { }) } - /// Verify AuthToken with domain-level checks delegated to domain verifier components, - /// plus adapter-specific checks (JTI uniqueness, request signature). + /// Verify AuthToken with domain-level checks delegated to domain verifier components. /// /// Domain-level verification (signature, expiration, TTL, access control, audience, /// capability) uses the same logic as domain::auth_token_verifier::AuthTokenVerifier. - /// Adapter-level checks (JTI nonce, request signature) remain here as they depend - /// on infrastructure concerns (nonce store, request context). + /// + /// Request proof-of-possession(リクエスト署名の中身)の検証はここでは行わない。 + /// 全経路が authorize より前に通る認証層(`verify_caller_signature`)が、 + /// トークン種別によらず `{operation}:{resource}:{timestamp}` 形式で検証する。 + /// リプレイ防御は署名内 timestamp の鮮度チェック(5 分窓)に一本化されている。 /// /// Note: We cannot directly call AuthTokenVerifier::verify() because the infra and /// domain AuthToken use different JWT serialization formats for iss/aud fields /// (string key IDs vs byte-array KeyId). Instead, we use the domain's /// ContentAccessControl for access control checks and delegate signature verification /// to the shared crypto layer. + /// + /// `token_str` は受信したままの JWT 文字列。署名検証はワイヤ上のバイト列に + /// 対して行う(再シリアライズ形とフィールド順序が異なっても正しく検証できる)。 async fn verify_auth_token( &self, + token_str: &str, token: &InfraAuthToken, request: &AuthorizationRequest, min_valid_issued_at: u64, @@ -290,44 +284,29 @@ impl UcanAdapter { ); } - // 5. Check JTI uniqueness (adapter layer - replay attack prevention) - if let Some(nonce_store) = &self.nonce_store { - if !nonce_store - .check_and_record_nonce(&token.payload.jti) - .await? - { - anyhow::bail!("AuthToken JTI already used (replay attack prevented)"); - } - } - - // 6. Extract owner's public key from key ID and verify AuthToken signature + // 5. Extract owner's public key from key ID and verify AuthToken signature + // over the received wire bytes (issue #60: re-serialization must not + // participate in signature verification). let owner_public_key = Self::get_public_key_from_key_id(&token.payload.iss)?; - SignatureVerifier::verify_auth_token_signature(token, &owner_public_key) + SignatureVerifier::verify_jwt_signature_wire(token_str, &owner_public_key) .context("AuthToken signature verification failed")?; - // 7. Verify request signature (adapter layer - mandatory) - let request_signature = request.request_signature.as_ref().ok_or_else(|| { - anyhow::anyhow!("Request signature is required for AuthToken-based authorization") - })?; - - // Extract requester's public key from key ID - let requester_public_key = Self::get_public_key_from_key_id(&token.payload.aud)?; - - // Construct request message: "{iss}:{aud}:{jti}" - let request_message = format!( - "{}:{}:{}", - token.payload.iss, token.payload.aud, token.payload.jti - ); - - SignatureVerifier::verify_request_signature( - request_message.as_bytes(), - request_signature, - &requester_public_key, - ) - .context("Request signature verification failed")?; + // 6. Require a request signature to be present. + // + // Proof-of-possession 自体は認証層(`verify_caller_signature`)が + // `{operation}:{resource}:{timestamp}` 形式で検証済みである(全経路が + // authorize より前に必ず通る)。リプレイ防御は署名内 timestamp の + // 鮮度チェックに一本化されており、旧実装の jti 単回消費 + // (ノードごとに独立で、SDK の「委譲トークンを 1 個渡して TTL 内で + // 再利用する」設計と矛盾していた)は廃止した(issue #61)。 + // ここでは「署名なしで authorize が呼ばれる」経路の混入を防ぐ + // 存在チェックのみを行う。 + if request.request_signature.is_none() { + anyhow::bail!("Request signature is required for AuthToken-based authorization"); + } - // 8. Check capability (domain-level check, using infra token's capability format) + // 7. Check capability (domain-level check, using infra token's capability format) let required_action = crate::infrastructure::auth::auth_token::CapabilityAction::from_auth_capability( &request.capability, @@ -356,9 +335,16 @@ impl UcanAdapter { let auth_token = self.parse_auth_token(token.as_str())?; // 2. Verify AuthToken (domain verifier checks signature, expiration, audience, - // capability, and access control; adapter checks JTI and request signature) - self.verify_auth_token(&auth_token, request, min_valid_issued_at, owner_identity) - .await?; + // capability, and access control; request PoP is enforced upstream in + // the authentication layer) + self.verify_auth_token( + token.as_str(), + &auth_token, + request, + min_valid_issued_at, + owner_identity, + ) + .await?; Ok(true) } @@ -732,7 +718,14 @@ mod tests { ); // 6. Bob creates request signature - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); // 7. Create authorization request from Bob using AuthToken let bob_identity = identity_from_key(&bob); @@ -781,7 +774,14 @@ mod tests { Some(3600), ); - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); // Bob tries to use Write capability (not granted) let bob_identity = identity_from_key(&bob); @@ -829,7 +829,14 @@ mod tests { vec![crate::infrastructure::auth::auth_token::CapabilityAction::Write], Some(3600), ); - let request_sig = bob_recipient.sign_request(&forged_token); + let request_sig = bob_recipient.sign_request( + "write", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); let token = AuthToken::new(forged_token.to_jwt().unwrap()); let request = AuthorizationRequest { @@ -855,64 +862,105 @@ mod tests { ); } + /// 委譲トークンは TTL 内で何度でも使える(issue #61)。 + /// リプレイ防御は認証層の署名内 timestamp(鮮度窓)が担い、 + /// 旧 jti 単回消費(1 トークン 1 リクエストになり、履歴取得 → データ取得 + /// という通常の read すら成立しなかった)は廃止された。 #[tokio::test] - async fn test_auth_token_authorization_denied_replay() { + async fn test_auth_token_reusable_across_requests() { use crate::infrastructure::auth::test_helpers::TestKeyPair; - use crate::infrastructure::persistence::SledPublicKeyRepository; use crate::port::auth_token::AuthToken; // Setup let alice = TestKeyPair::generate("user", "alice"); let bob = TestKeyPair::generate("user", "bob"); let repo = Arc::new(MockContentRepo::new()); - let temp_dir = tempfile::TempDir::new().unwrap(); - let nonce_store = Arc::new(SledPublicKeyRepository::open(temp_dir.path()).unwrap()); - let adapter = UcanAdapter::new(repo.clone()).with_nonce_store(nonce_store); + let adapter = UcanAdapter::new(repo.clone()); - let content_id = ContentId::new("test-content-replay".to_string()).unwrap(); + let content_id = ContentId::new("test-content-reuse".to_string()).unwrap(); let alice_identity = identity_from_key(&alice); let policy = AccessPolicy::new(content_id.clone(), alice_identity.clone()); repo.policies .write() .await - .insert("test-content-replay".to_string(), policy); + .insert("test-content-reuse".to_string(), policy); // Create a valid token let auth_token = alice.create_auth_token( &bob, - "monas://content/test-content-replay", + "monas://content/test-content-reuse", vec![crate::infrastructure::auth::auth_token::CapabilityAction::Read], Some(3600), ); - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); let bob_identity = identity_from_key(&bob); let token = AuthToken::new(auth_token.to_jwt().unwrap()); - // First request should succeed - let request = AuthorizationRequest { - identity: bob_identity.clone(), - resource: content_id.clone(), - capability: AuthCapability::ReadContent, - token: Some(token.clone()), - request_signature: Some(request_sig.clone()), - }; - let result = adapter.authorize(&request).await.unwrap(); - assert!(result.is_granted(), "First use should be granted"); + // 同じトークンで複数リクエスト(履歴取得 → データ取得を模す)が全部通る + for i in 0..3 { + let request = AuthorizationRequest { + identity: bob_identity.clone(), + resource: content_id.clone(), + capability: AuthCapability::ReadContent, + token: Some(token.clone()), + request_signature: Some(request_sig.clone()), + }; + let result = adapter.authorize(&request).await.unwrap(); + assert!( + result.is_granted(), + "request {} with the same token should be granted, got: {:?}", + i, + result + ); + } + } - // Second request with same token (same JTI) should be denied (replay) - let request2 = AuthorizationRequest { - identity: bob_identity, + /// authorize は request_signature の存在を要求する(検証自体は認証層で + /// 済んでいる前提だが、署名なしで authorize が呼ばれる経路の混入を防ぐ)。 + #[tokio::test] + async fn test_auth_token_authorization_requires_request_signature() { + use crate::infrastructure::auth::test_helpers::TestKeyPair; + use crate::port::auth_token::AuthToken; + + let alice = TestKeyPair::generate("user", "alice"); + let bob = TestKeyPair::generate("user", "bob"); + let repo = Arc::new(MockContentRepo::new()); + let adapter = UcanAdapter::new(repo.clone()); + + let content_id = ContentId::new("test-content-no-sig".to_string()).unwrap(); + let alice_identity = identity_from_key(&alice); + let policy = AccessPolicy::new(content_id.clone(), alice_identity.clone()); + repo.policies + .write() + .await + .insert("test-content-no-sig".to_string(), policy); + + let auth_token = alice.create_auth_token( + &bob, + "monas://content/test-content-no-sig", + vec![crate::infrastructure::auth::auth_token::CapabilityAction::Read], + Some(3600), + ); + + let request = AuthorizationRequest { + identity: identity_from_key(&bob), resource: content_id, capability: AuthCapability::ReadContent, - token: Some(token), - request_signature: Some(request_sig), + token: Some(AuthToken::new(auth_token.to_jwt().unwrap())), + request_signature: None, }; - let result2 = adapter.authorize(&request2).await.unwrap(); + let result = adapter.authorize(&request).await.unwrap(); assert!( - result2.is_denied(), - "Replay should be denied, but got: {:?}", - result2 + result.is_denied(), + "authorize without a request signature must be denied" ); } @@ -946,7 +994,14 @@ mod tests { // Wait a moment to ensure expiration tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); let bob_identity = identity_from_key(&bob); let token = AuthToken::new(auth_token.to_jwt().unwrap()); @@ -999,7 +1054,14 @@ mod tests { .await .insert("test-content-inv".to_string(), policy); - let request_sig = bob.sign_request(&auth_token); + let request_sig = bob.sign_request( + "read", + content_id.as_str(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ); let bob_identity = identity_from_key(&bob); let token = AuthToken::new(auth_token.to_jwt().unwrap()); let request = AuthorizationRequest { diff --git a/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs b/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs index 09065b1..a428275 100644 --- a/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs +++ b/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs @@ -20,8 +20,6 @@ pub struct SledPublicKeyRepository { key_id_tree: sled::Tree, /// Tree for NodeId -> KeyId mapping node_to_key_tree: sled::Tree, - /// Tree for nonce tracking (replay attack prevention) - nonce_tree: sled::Tree, } impl SledPublicKeyRepository { @@ -36,16 +34,11 @@ impl SledPublicKeyRepository { let node_to_key_tree = db .open_tree("node_to_key_mapping") .context("Failed to open node_to_key_tree")?; - let nonce_tree = db - .open_tree("used_nonces") - .context("Failed to open nonce_tree")?; - Ok(Self { db, node_key_tree, key_id_tree, node_to_key_tree, - nonce_tree, }) } @@ -55,81 +48,6 @@ impl SledPublicKeyRepository { Self::new(Arc::new(db)) } - /// Maximum number of nonce entries before forced cleanup. - const MAX_NONCE_ENTRIES: usize = 1_000_000; - - /// Check and record a nonce to prevent replay attacks. - /// - /// Uses sled's compare-and-swap to atomically check and insert, - /// preventing TOCTOU race conditions between concurrent requests. - /// - /// # Returns - /// Ok(true) if the nonce is new and was recorded - /// Ok(false) if the nonce was already used - pub async fn check_and_record_nonce(&self, nonce: &str) -> Result { - let nonce_bytes = nonce.as_bytes(); - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); - - let timestamp_bytes = timestamp.to_le_bytes(); - - // Size limit: clean up aggressively if approaching capacity - if self.nonce_tree.len() >= Self::MAX_NONCE_ENTRIES { - tracing::warn!( - "Nonce store at capacity ({} entries), running cleanup", - self.nonce_tree.len() - ); - self.cleanup_old_nonces(timestamp.saturating_sub(3600))?; - // If still over capacity after 1-hour cleanup, be more aggressive - if self.nonce_tree.len() >= Self::MAX_NONCE_ENTRIES { - self.cleanup_old_nonces(timestamp.saturating_sub(300))?; - } - } - - // Atomic compare-and-swap: only insert if key does not exist (None -> Some) - match self.nonce_tree.compare_and_swap( - nonce_bytes, - None::<&[u8]>, - Some(×tamp_bytes), - )? { - Ok(()) => { - // Successfully recorded — nonce was new - // Periodically clean up old nonces (older than 1 hour) - if timestamp % 60 == 0 { - self.cleanup_old_nonces(timestamp.saturating_sub(3600))?; - } - Ok(true) - } - Err(_) => { - // Nonce already existed - Ok(false) - } - } - } - - /// Clean up nonces older than the given timestamp - fn cleanup_old_nonces(&self, cutoff_timestamp: u64) -> Result<()> { - let mut keys_to_remove = Vec::new(); - - for result in self.nonce_tree.iter() { - let (key, value) = result?; - if value.len() == 8 { - let timestamp = u64::from_le_bytes(value.as_ref().try_into()?); - if timestamp < cutoff_timestamp { - keys_to_remove.push(key.to_vec()); - } - } - } - - for key in keys_to_remove { - self.nonce_tree.remove(key)?; - } - - Ok(()) - } - /// Flush all pending writes to disk pub async fn flush(&self) -> Result<()> { self.db.flush_async().await?; @@ -270,25 +188,6 @@ mod tests { assert!(retrieved.is_none()); } - #[tokio::test] - async fn test_nonce_tracking() { - let (repo, _temp_dir) = create_test_repository().await; - - let nonce = "test-nonce-123"; - - // First use should succeed - assert!(repo.check_and_record_nonce(nonce).await.unwrap()); - - // Second use should fail (replay attack prevention) - assert!(!repo.check_and_record_nonce(nonce).await.unwrap()); - - // Different nonce should succeed - assert!(repo - .check_and_record_nonce("different-nonce") - .await - .unwrap()); - } - #[tokio::test] async fn test_persistence() { let temp_dir = TempDir::new().unwrap(); diff --git a/monas-state-node/tests/create_content_push_race_test.rs b/monas-state-node/tests/create_content_push_race_test.rs index 54cbb91..d8c97e7 100644 --- a/monas-state-node/tests/create_content_push_race_test.rs +++ b/monas-state-node/tests/create_content_push_race_test.rs @@ -235,7 +235,12 @@ async fn create_content_delivers_crdt_ops_to_members_without_gossipsub_sync() { &data, Some(&test_token()), Some(&test_request_signature()), - None, + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ), ) .await .expect("create_content on A should succeed"); diff --git a/monas-state-node/tests/integration_test.rs b/monas-state-node/tests/integration_test.rs index 7a0e091..8df4269 100644 --- a/monas-state-node/tests/integration_test.rs +++ b/monas-state-node/tests/integration_test.rs @@ -106,6 +106,17 @@ fn test_request_signature() -> Vec { vec![0x01] } +/// timestamp は署名検証で構造的に必須(issue #61)。mock 認証でも +/// 存在チェックは実コードを通るため、現在時刻を渡す。 +fn test_timestamp() -> Option { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ) +} + fn sign_access_control_update(update: &AccessControlUpdate) -> (Vec, Vec) { use p256::ecdsa::signature::DigestSigner; use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; @@ -202,7 +213,7 @@ async fn test_create_content() { data, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -606,7 +617,7 @@ async fn test_access_control_update_and_verify() { &update, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -657,7 +668,7 @@ async fn test_access_control_update_missing_signature() { &update, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(result.is_err()); @@ -1095,7 +1106,7 @@ async fn test_update_content_requires_authentication() { data, None, Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(result.is_err()); @@ -1277,7 +1288,7 @@ async fn test_authorization_denied_prevents_create_content() { data, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -1347,7 +1358,7 @@ async fn test_access_control_update_signature_verification() { &update, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; From 2fe41626b21a5e2f1e1269aaf5d6908419256382 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sun, 26 Jul 2026 21:10:15 +0900 Subject: [PATCH 26/48] test(state-node): pass explicit timestamps in authorize_read tests (required by #61 fix) Co-Authored-By: Claude Fable 5 --- .../application_service/state_node_service.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index c95b678..6ac8ca8 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -3028,7 +3028,7 @@ mod tests { None, &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(matches!(result, Err(StateNodeError::ContentNotFound(_)))); @@ -3063,7 +3063,7 @@ mod tests { None, &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .expect("relayed read should succeed"); @@ -3104,7 +3104,7 @@ mod tests { None, &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(matches!( @@ -3132,7 +3132,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-1", ) .await; @@ -3147,7 +3147,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-1", ) .await; @@ -3287,7 +3287,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-1", ) .await; @@ -3308,7 +3308,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-1", ) .await; @@ -3325,7 +3325,7 @@ mod tests { None, &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(result.is_err()); From e6433af3410849321dfc9a871625c161b01ea5b7 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 14:50:35 +0900 Subject: [PATCH 27/48] refactor(read-integrity): drop client-side monotonicity check (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 単調性チェック(last_seen 記録 + 祖先 walk)を削除する。 理由: - 本質的な防御になっていない。攻撃者は観測済みの正規暗号文を任意の parents(last_seen を含む)で包み直した Node を鋳造でき、CID 検証・ 復号・祖先 walk をすべて通過できる。版メタデータに真正性が無い以上、 クライアント側の記憶では埋められない - 一方で正規の read を壊す経路を持ち込む。結果整合性のもとで sync 遅延は 正常な挙動であり、応答単体では攻撃と区別できない。さらに 256 版を超えて 離れると復旧手段なく read が失敗し、部分同期 member に当たると walk が 失敗する - 永続 pin は GC できない負債になる(TTL で消すことは TOFU 窓の再オープンに 等しい)。CAS 追加後も並行 read の完了順は後退し得た 版の真正性とロールバック耐性は owner 署名等の trust anchor で解決すべきで、 それが入れば本機構はいずれにせよ不要になる(別 issue)。payload 真正性 (Node CBOR + CID 再計算 + AES-GCM + plain CID 照合)、envelope 送信者認証、 PoP 統一はそのまま残る。 - monas-content: last_seen_version_store.rs 削除 - monas-sdk: walk_ancestors_for / check_read_monotonicity / fetch_verified_parents / MAX_MONOTONICITY_FETCHES / DynLastSeenStore 除去 - docs/design.md §10: 保証範囲を「payload 真正性まで」と明記し、単調性を 採用しない理由と trust anchor による解決方針を記述 Co-Authored-By: Claude Fable 5 --- docs/design.md | 12 +- .../infrastructure/last_seen_version_store.rs | 210 ----------- monas-content/src/infrastructure/mod.rs | 1 - .../src/infrastructure/node_verification.rs | 5 +- .../infrastructure/sender_key_pin_store.rs | 2 +- monas-sdk/src/controller/mod.rs | 20 +- monas-sdk/src/controller/state.rs | 355 +----------------- .../tests/state_read_integration_test.rs | 166 +------- 8 files changed, 36 insertions(+), 735 deletions(-) delete mode 100644 monas-content/src/infrastructure/last_seen_version_store.rs diff --git a/docs/design.md b/docs/design.md index de6ab00..deedd60 100644 --- a/docs/design.md +++ b/docs/design.md @@ -348,17 +348,17 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。 ### read応答の完全性検証 -libp2pのトランスポート認証が保証するのは隣接ホップの相手が本物であることだけで、relay越しに返ってきたデータが正しいかは保証しない。read応答はクライアント側で以下の2段で検証する。 +libp2pのトランスポート認証が保証するのは隣接ホップの相手が本物であることだけで、relay越しに返ってきたデータが正しいかは保証しない。read応答はクライアント側で以下を検証する。 - **payload真正性**: state-nodeはreadに対しcrsl-lib Node全体(CBOR)を返し、クライアントがCIDを再計算して要求した版CIDと照合する。CIDはバイト列そのもののハッシュなので、一致すれば応答は要求した版に束縛され、返した相手が誰か(memberか否か)の確認は不要。さらにCEKでのAES-GCM復号 + 平文CID照合により、payloadが正規のCEKで暗号化された本物であることまで検証される — CEKを持たない攻撃者は復号可能な偽payloadを注入できない。 -- **単調性**: クライアントはコンテンツごとに最後に受理した版CIDを記録し、最新読みの結果がその子孫であることをCID検証済みの親リンクだけを辿って確認する。後退していれば、過去の本物の版を「最新」と偽るロールバックとして拒否する(初回はTOFUで受理、探索は上限付きfail-closed)。 -member証明(ownerがmemberを認証するトークン)は採用しない。memberはDHT複製配置によりownerの関与なく増減するため「ownerがmember追加時に発行する」経路が成立せず、payload真正性があれば不要でもある。 +member証明(ownerがmemberを認証するトークン)は採用しない。memberはDHT複製配置によりownerの関与なく増減するため「ownerがmember追加時に発行する」経路が成立せず、payload真正性があれば返した相手の身元確認は不要でもある。 -既知の限界が2つある。 +**版の真正性とロールバック耐性は現時点では保証していない。** CID照合が保証するのは「バイト列が要求した版CIDに一致すること」であり、「その版が正規の書き込みとして作られたこと」「それが最新であること」ではない。relay上で暗号文を観測できる攻撃者は、観測済みの本物の暗号文を新しいNodeに包み直し、任意のparentsを詰めた「偽の版」を鋳造できる(payloadは本物なので復号も通る)。 -1. **版メタデータの真正性は未保証**: CID照合が保証するのは「バイト列が要求した版CIDに一致すること」であり、「その版が正規の書き込みとして作られたこと」ではない。relay上で暗号文を観測できる攻撃者は、観測済みの本物の暗号文を新しいNodeに包み直し、任意のparentsを詰めた「偽の版」を鋳造できる(payloadは本物なので復号も通る)。単調性チェックはparentsを信頼して祖先を辿るため、last_seenをparentsに含めた偽版でbypassされ得る。本修正はNodeへのowner署名等のtrust anchorであり、crsl-libに及ぶプロトコル変更として別issueで追跡する。 -2. **正規memberのstale提示**: 正規memberがクライアント未見の範囲で古い版を「最新」と提示することは検出できない(「より新しい版が無い」という否定的事実は証明不能)。 +クライアント側で「最後に受理した版」を記録して後退を拒否する単調性チェックも検討したが、採用しない。偽のparentsを詰めた版でbypassできるため本質的な防御にならない一方、結果整合性のもとでは正当なsync遅延(分散システムでは正常な挙動)と攻撃を応答単体で区別できず、正規のreadを壊す誤検知と、クライアント側の永続状態という負債だけが残るためである。 + +版の真正性は、Nodeまたは版メタデータへのowner(または権限を持つwriter)署名というtrust anchorで解決する。これはcrsl-libに及ぶプロトコル変更であり、別issueで追跡する。それが入るまで、readの保証範囲は「返されたpayloadが、要求した版に対して真正であること」までである。 ### 共有コンテンツのCEKライフサイクル diff --git a/monas-content/src/infrastructure/last_seen_version_store.rs b/monas-content/src/infrastructure/last_seen_version_store.rs deleted file mode 100644 index 89fda54..0000000 --- a/monas-content/src/infrastructure/last_seen_version_store.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Client-side store of the last version CID observed per (remote) content id, -//! backing the read monotonicity check -//! (`docs/design.md` §10「read応答の完全性検証」). -//! -//! A client records the newest CID-verified version it has accepted for each -//! content. On a later "latest" read it walks the returned node's verified -//! parent chain and rejects the response unless the recorded version is an -//! ancestor of (or equal to) the returned one — a regression means a relay is -//! serving a stale or rolled-back "latest". -//! -//! Keys are the *state-node* (remote) content id, because version CIDs live in -//! the state node's DAG, not the local plain-content-id space. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -#[derive(Debug, thiserror::Error)] -pub enum LastSeenVersionStoreError { - #[error("last-seen version store error: {0}")] - Storage(String), -} - -/// `remote_content_id -> last accepted version CID` の永続化ポート。 -pub trait LastSeenVersionStore: Send + Sync { - fn load(&self, remote_content_id: &str) -> Result, LastSeenVersionStoreError>; - fn save( - &self, - remote_content_id: &str, - version_cid: &str, - ) -> Result<(), LastSeenVersionStoreError>; - - /// compare-and-advance: 現在値が `expected` と一致する場合のみ `version_cid` - /// へ進める。戻り値は「進めたかどうか」。 - /// - /// 単調性チェック(load)と記録(save)の間には復号などの検証が挟まるため、 - /// 無条件 save だと並行 read が進めた pin を古い版で巻き戻し得る。 - /// チェック時に観測した値を `expected` に渡すことで、pin は - /// 「検証済みの前進」でしか動かないことを保証する。 - fn compare_and_save( - &self, - remote_content_id: &str, - expected: Option<&str>, - version_cid: &str, - ) -> Result; -} - -/// プロセス内 `HashMap` 実装。テスト・開発用(再起動で揮発 = 毎回 TOFU に戻る)。 -#[derive(Clone, Default)] -pub struct InMemoryLastSeenVersionStore { - inner: Arc>>, -} - -impl LastSeenVersionStore for InMemoryLastSeenVersionStore { - fn load(&self, remote_content_id: &str) -> Result, LastSeenVersionStoreError> { - let guard = self - .inner - .lock() - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; - Ok(guard.get(remote_content_id).cloned()) - } - - fn save( - &self, - remote_content_id: &str, - version_cid: &str, - ) -> Result<(), LastSeenVersionStoreError> { - let mut guard = self - .inner - .lock() - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; - guard.insert(remote_content_id.to_string(), version_cid.to_string()); - Ok(()) - } - - fn compare_and_save( - &self, - remote_content_id: &str, - expected: Option<&str>, - version_cid: &str, - ) -> Result { - let mut guard = self - .inner - .lock() - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; - if guard.get(remote_content_id).map(String::as_str) != expected { - return Ok(false); - } - guard.insert(remote_content_id.to_string(), version_cid.to_string()); - Ok(true) - } -} - -/// sled 実装。キーは `"last_seen:{remote_content_id}"`。 -/// CEK / share / pubkey ストアと同じ `sled::Db` を共有できる -/// (プレフィックスでキー空間が分離される)。 -pub struct SledLastSeenVersionStore { - db: sled::Db, -} - -impl SledLastSeenVersionStore { - pub fn with_db(db: sled::Db) -> Self { - Self { db } - } - - fn sled_key(remote_content_id: &str) -> String { - format!("last_seen:{remote_content_id}") - } -} - -impl LastSeenVersionStore for SledLastSeenVersionStore { - fn load(&self, remote_content_id: &str) -> Result, LastSeenVersionStoreError> { - let opt = self - .db - .get(Self::sled_key(remote_content_id)) - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; - opt.map(|ivec| { - String::from_utf8(ivec.to_vec()) - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string())) - }) - .transpose() - } - - fn save( - &self, - remote_content_id: &str, - version_cid: &str, - ) -> Result<(), LastSeenVersionStoreError> { - self.db - .insert(Self::sled_key(remote_content_id), version_cid.as_bytes()) - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; - self.db - .flush() - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; - Ok(()) - } - - fn compare_and_save( - &self, - remote_content_id: &str, - expected: Option<&str>, - version_cid: &str, - ) -> Result { - let swapped = self - .db - .compare_and_swap( - Self::sled_key(remote_content_id), - expected.map(str::as_bytes), - Some(version_cid.as_bytes()), - ) - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))? - .is_ok(); - if swapped { - self.db - .flush() - .map_err(|e| LastSeenVersionStoreError::Storage(e.to_string()))?; - } - Ok(swapped) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(store: &dyn LastSeenVersionStore) { - assert!(store.load("content-a").unwrap().is_none()); - - store.save("content-a", "cid-v1").unwrap(); - assert_eq!(store.load("content-a").unwrap().as_deref(), Some("cid-v1")); - - // 上書き(版が進んだら更新される) - store.save("content-a", "cid-v2").unwrap(); - assert_eq!(store.load("content-a").unwrap().as_deref(), Some("cid-v2")); - - // 別 content には影響しない - assert!(store.load("content-b").unwrap().is_none()); - - // compare-and-advance: 期待値が一致すれば進む - assert!(store - .compare_and_save("content-a", Some("cid-v2"), "cid-v3") - .unwrap()); - assert_eq!(store.load("content-a").unwrap().as_deref(), Some("cid-v3")); - - // 期待値が古ければ(並行 read が先に進めていれば)巻き戻さない - assert!(!store - .compare_and_save("content-a", Some("cid-v2"), "cid-v4") - .unwrap()); - assert_eq!(store.load("content-a").unwrap().as_deref(), Some("cid-v3")); - - // 未記録(None)期待の初回書き込み - assert!(store.compare_and_save("content-c", None, "cid-v1").unwrap()); - assert_eq!(store.load("content-c").unwrap().as_deref(), Some("cid-v1")); - - // 既に記録があるのに None 期待では書けない - assert!(!store.compare_and_save("content-c", None, "cid-v9").unwrap()); - assert_eq!(store.load("content-c").unwrap().as_deref(), Some("cid-v1")); - } - - #[test] - fn in_memory_roundtrip() { - roundtrip(&InMemoryLastSeenVersionStore::default()); - } - - #[test] - fn sled_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let db = sled::open(dir.path()).unwrap(); - roundtrip(&SledLastSeenVersionStore::with_db(db)); - } -} diff --git a/monas-content/src/infrastructure/mod.rs b/monas-content/src/infrastructure/mod.rs index 9019c9c..1a9975b 100644 --- a/monas-content/src/infrastructure/mod.rs +++ b/monas-content/src/infrastructure/mod.rs @@ -2,7 +2,6 @@ pub mod content_id; pub mod encryption; pub mod key_store; pub mod key_wrapping; -pub mod last_seen_version_store; pub mod node_verification; pub mod public_key_directory; pub mod sender_key_pin_store; diff --git a/monas-content/src/infrastructure/node_verification.rs b/monas-content/src/infrastructure/node_verification.rs index a982e07..0b1dc7a 100644 --- a/monas-content/src/infrastructure/node_verification.rs +++ b/monas-content/src/infrastructure/node_verification.rs @@ -34,7 +34,8 @@ pub enum NodeVerificationError { } /// A relay-read `Node` decoded enough to (a) extract the ciphertext payload and -/// (b) expose the parent version CIDs for the monotonicity check. +/// (b) expose the parent version CIDs (part of the node, exposed for callers +/// that need the DAG shape). /// /// Only the fields the client needs are decoded; the CID is recomputed from the /// raw bytes (not from this struct) so decoding never has to round-trip @@ -43,7 +44,7 @@ pub enum NodeVerificationError { pub struct VerifiedNode { /// The ciphertext stored in the node payload (`payload.data`). pub ciphertext: Vec, - /// Parent version CIDs (`parents`), as strings, for ancestor checks. + /// Parent version CIDs (`parents`), as strings. pub parents: Vec, } diff --git a/monas-content/src/infrastructure/sender_key_pin_store.rs b/monas-content/src/infrastructure/sender_key_pin_store.rs index b4352e7..7b55223 100644 --- a/monas-content/src/infrastructure/sender_key_pin_store.rs +++ b/monas-content/src/infrastructure/sender_key_pin_store.rs @@ -61,7 +61,7 @@ impl SenderKeyPinStore for InMemorySenderKeyPinStore { } /// sled 実装。キーは `"sender_pin:{content_id}"`、値は `SenderKeyPin` の JSON。 -/// CEK / share / pubkey / last_seen ストアと同じ `sled::Db` を共有できる。 +/// CEK / share / pubkey ストアと同じ `sled::Db` を共有できる。 pub struct SledSenderKeyPinStore { db: sled::Db, } diff --git a/monas-sdk/src/controller/mod.rs b/monas-sdk/src/controller/mod.rs index bb16e0f..c53ad3e 100644 --- a/monas-sdk/src/controller/mod.rs +++ b/monas-sdk/src/controller/mod.rs @@ -8,7 +8,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use content::{ContentServiceInstance, DynCekStore}; use share::{DynPublicKeyDirectory, DynShareRepository, ShareServiceInstance}; -use state::DynLastSeenStore; use crate::common::{ApiError, ApiResponse, MonasConfig, PersistenceConfig, StateNodeAuthContext}; @@ -68,9 +67,6 @@ pub struct MonasController { content_service: ContentServiceInstance, /// ShareService share_service: ShareServiceInstance, - /// content ごとに最後に受理した State Node 版 CID の記録 - /// (read 単調性チェック、`docs/design.md` §10「read応答の完全性検証」の単調性) - last_seen_store: DynLastSeenStore, /// share 受信者側の送信者公開鍵ピン(TOFU)と受理済み CEK 鍵世代の記録 /// (KeyEnvelope の送信者認証と rotation 巻き戻し replay 防止) sender_pin_store: DynSenderPinStore, @@ -170,7 +166,7 @@ impl MonasController { // stateless thin client and push CEK / share ownership to State Node, // or (b) define an explicit pluggable port for CEK ownership semantics. let content_repository = Self::create_content_repository(); - let (cek_store, share_repository, public_key_directory, last_seen_store, sender_pin_store) = + let (cek_store, share_repository, public_key_directory, sender_pin_store) = Self::create_persistence(&config.persistence)?; let agent = Self::build_agent(&config); @@ -189,7 +185,6 @@ impl MonasController { share_repository, public_key_directory, ), - last_seen_store, sender_pin_store, }) } @@ -223,7 +218,7 @@ impl MonasController { /// CEK / Share / Public key directory の 3 ストアに共有させる。sled は path 単位で /// 排他 flock を取るため、同じディレクトリを 2 度 open すると 2 個目が /// 失敗する (`MONAS_PERSISTENCE_DIR` 設定時の本番経路で必ず再現)。 - /// キー空間は `cek:` / `share:` / `pubkey:` / `last_seen:` / `sender_pin:` プレフィックスで分離されている。 + /// キー空間は `cek:` / `share:` / `pubkey:` / `sender_pin:` プレフィックスで分離されている。 fn create_persistence( persistence: &PersistenceConfig, ) -> Result< @@ -231,14 +226,12 @@ impl MonasController { DynCekStore, DynShareRepository, DynPublicKeyDirectory, - DynLastSeenStore, DynSenderPinStore, ), ApiError, > { use monas_content::infrastructure::{ key_store::{InMemoryContentEncryptionKeyStore, SledContentEncryptionKeyStore}, - last_seen_version_store::{InMemoryLastSeenVersionStore, SledLastSeenVersionStore}, public_key_directory::{InMemoryPublicKeyDirectory, SledPublicKeyDirectory}, sender_key_pin_store::{InMemorySenderKeyPinStore, SledSenderKeyPinStore}, share_repository::{InMemoryShareRepository, SledShareRepository}, @@ -248,15 +241,14 @@ impl MonasController { PersistenceConfig::InMemory => { eprintln!( "monas-sdk: PersistenceConfig::InMemory is in use. \ - CEK / share / public-key / last-seen-version data are kept in memory only and will be lost on restart. \ + CEK / share / public-key data are kept in memory only and will be lost on restart. \ Use MonasConfig::with_persistence_dir() for production gateways." ); let cek: DynCekStore = Arc::new(InMemoryContentEncryptionKeyStore::default()); let share: DynShareRepository = Arc::new(InMemoryShareRepository::default()); let pkd: DynPublicKeyDirectory = Arc::new(InMemoryPublicKeyDirectory::default()); - let last_seen: DynLastSeenStore = Arc::new(InMemoryLastSeenVersionStore::default()); let sender_pin: DynSenderPinStore = Arc::new(InMemorySenderKeyPinStore::default()); - Ok((cek, share, pkd, last_seen, sender_pin)) + Ok((cek, share, pkd, sender_pin)) } PersistenceConfig::Sled { dir } => { if let Err(e) = std::fs::create_dir_all(dir) { @@ -272,14 +264,12 @@ impl MonasController { let cek = SledContentEncryptionKeyStore::with_db(db.clone()); let share = SledShareRepository::with_db(db.clone()); let pkd = SledPublicKeyDirectory::with_db(db.clone()); - let last_seen = SledLastSeenVersionStore::with_db(db.clone()); let sender_pin = SledSenderKeyPinStore::with_db(db); let cek: DynCekStore = Arc::new(cek); let share: DynShareRepository = Arc::new(share); let pkd: DynPublicKeyDirectory = Arc::new(pkd); - let last_seen: DynLastSeenStore = Arc::new(last_seen); let sender_pin: DynSenderPinStore = Arc::new(sender_pin); - Ok((cek, share, pkd, last_seen, sender_pin)) + Ok((cek, share, pkd, sender_pin)) } } } diff --git a/monas-sdk/src/controller/state.rs b/monas-sdk/src/controller/state.rs index f77951c..efffbb6 100644 --- a/monas-sdk/src/controller/state.rs +++ b/monas-sdk/src/controller/state.rs @@ -16,73 +16,6 @@ use crate::models::state_node::{StateNodeContentDataResponse, StateNodeContentHi use super::MonasController; -/// read 単調性チェックの記録先 -/// (`docs/design.md` §10「read応答の完全性検証」の単調性)。 -pub(super) type DynLastSeenStore = std::sync::Arc< - dyn monas_content::infrastructure::last_seen_version_store::LastSeenVersionStore, ->; - -/// 単調性チェックの祖先探索で fetch する Node 数の上限。 -/// -/// 前回 read から `MAX_MONOTONICITY_FETCHES` 版を超えて履歴が進んでいた場合、 -/// 探索は fail-closed で中断される(`AncestorWalkOutcome::BoundExceeded`)。 -/// 攻撃者が偽の深い DAG を返してクライアントに際限なく fetch させる DoS を防ぐ。 -const MAX_MONOTONICITY_FETCHES: usize = 256; - -/// `walk_ancestors_for` の結果。 -#[derive(Debug, PartialEq, Eq)] -enum AncestorWalkOutcome { - /// `target` が祖先に見つかった = 今回の版は前回受理した版の子孫(単調)。 - FoundTarget, - /// DAG を(bound 内で)出し尽くしたが `target` が祖先にいない - /// = 後退(ロールバック/stale relay の固定)。 - Exhausted, - /// fetch 上限に達した。fail-closed で拒否する。 - BoundExceeded, -} - -/// 今回読んだ版の親 CID 群から祖先 DAG を辿り、`target`(前回受理した版)が -/// 祖先に含まれるかを判定する。 -/// -/// `fetch_parents(cid)` は「その CID の Node を取得し、**CID 再計算で検証した上で** -/// parents を返す」こと。検証済みの親のみを辿ることで、攻撃者が偽の親リンクで -/// `target` を「祖先に見せかける」ことはできない(偽 Node は CID が一致しない)。 -fn walk_ancestors_for( - start_parents: &[String], - target: &str, - max_fetches: usize, - mut fetch_parents: impl FnMut(&str) -> Result, String>, -) -> Result { - use std::collections::{HashSet, VecDeque}; - - let mut visited: HashSet = HashSet::new(); - let mut frontier: VecDeque = VecDeque::new(); - for p in start_parents { - if visited.insert(p.clone()) { - frontier.push_back(p.clone()); - } - } - - let mut fetches = 0usize; - while let Some(cid) = frontier.pop_front() { - if cid == target { - return Ok(AncestorWalkOutcome::FoundTarget); - } - if fetches >= max_fetches { - return Ok(AncestorWalkOutcome::BoundExceeded); - } - fetches += 1; - let parents = fetch_parents(&cid)?; - for p in parents { - if visited.insert(p.clone()) { - frontier.push_back(p); - } - } - } - - Ok(AncestorWalkOutcome::Exhausted) -} - impl MonasController { fn validate_state_content_id(content_id: &str, trace_id: String) -> Option> { if content_id.is_empty() { @@ -292,52 +225,24 @@ impl MonasController { ) } - /// State Node の Node CBOR を取得し、CID 検証済みの親 CID リストを返す。 - /// 単調性チェックの祖先探索用フェッチャ。 - fn fetch_verified_parents( - &self, - remote_content_id: &str, - version_cid: &str, - auth: Option<&StateNodeAuthContext>, - trace_id: &str, - ) -> Result, String> { - let data = self - .get_state_node_version_data::<()>( - remote_content_id, - version_cid, - auth, - trace_id.to_string(), - ) - .map_err(|e| format!("failed to fetch ancestor node {version_cid}: {:?}", e.error))?; - - let node_bytes = BASE64_STANDARD - .decode(&data.data) - .map_err(|e| format!("invalid base64 data for ancestor node {version_cid}: {e}"))?; - - let verified = monas_content::infrastructure::node_verification::verify_and_extract( - &node_bytes, - version_cid, - ) - .map_err(|e| format!("ancestor node {version_cid} failed CID verification: {e}"))?; - - Ok(verified.parents) - } - /// State Node から content を読み、検証・復号して平文を返す(検証付き read)。 /// /// `docs/design.md` §10「read応答の完全性検証」の実 read 経路。処理フロー: /// 1. `read:{content_id}:{timestamp}` 署名の認証コンテキストを解決 /// 2. 版を決定(`input.version` 指定があればその版、無ければ履歴の最新) - /// 3. Node CBOR を取得し、CID 再計算で改ざん検証(コンポーネント A) - /// 4. 最新読みの場合のみ、単調性チェック(コンポーネント B): - /// 前回受理した版が今回の版の祖先でなければ後退として拒否 - /// 5. ローカル cek_store から CEK を引き、AES-GCM 復号 + plain CID 照合 + /// 3. Node CBOR を取得し、CID 再計算で改ざん検証 + /// 4. ローカル cek_store から CEK を引き、AES-GCM 復号 + plain CID 照合 /// /// CEK は「自分が作成した content」または「share の KeyEnvelope を処理済みの /// content」(`decrypt_shared_content` が保存する)についてローカルに存在する。 /// - /// 既知の限界(設計 §2): 正規 member 自身による stale/ロールバックのうち、 - /// クライアントが一度も見ていない範囲は検出できない(否定的事実は証明不能)。 + /// **保証範囲**: 検証できるのは「返された Node の payload が、要求した版 CID に + /// 対して真正であること」まで。「その版が本当に最新か」「正規の writer が書いた + /// 版か」は保証しない — 版メタデータ(parents 等)に真正性が無く、観測済みの + /// 正規暗号文を任意の parents で包み直した Node は CID 検証を通過するため。 + /// 版の真正性とロールバック耐性には owner 署名等の trust anchor が必要 + /// (issue #59)。分散システムである以上、sync 遅延による stale read は + /// 正常な挙動であり、それと攻撃を応答単体で区別することはできない。 pub fn read_content_from_state_node( &self, input: ReadContentFromStateNodeInput, @@ -367,10 +272,11 @@ impl MonasController { let auth = auth.as_ref(); // 版の決定。明示指定が無ければ履歴の最新を読む。 - // 履歴は署名も系列検証も無い(信頼できない)が、ここで版を「選ぶ」だけで、 - // 選ばれた版の中身は CID 検証(A)、新しさは単調性チェック(B)が守る。 - let (version, is_latest_read) = match input.version.clone() { - Some(v) => (v, false), + // 履歴は署名も系列検証も無いため「どの版を読むか」の選択にしか使えない。 + // 選ばれた版の payload は下の CID 検証が守るが、その版が最新である + // ことは保証されない(上記「保証範囲」を参照)。 + let version = match input.version.clone() { + Some(v) => v, None => { let history = match self.get_state_node_history::( &input.content_id, @@ -385,7 +291,7 @@ impl MonasController { .last() .cloned() .unwrap_or_else(|| input.content_id.clone()); - (latest, true) + latest } }; @@ -411,12 +317,13 @@ impl MonasController { } }; - let verified = match monas_content::infrastructure::node_verification::verify_and_extract( + // CID 再計算による改ざん検証。ここを通れば payload は要求した版 CID に + // 対して真正(復号は下の verify_and_decrypt_relay_read が再度行う)。 + if let Err(e) = monas_content::infrastructure::node_verification::verify_and_extract( &node_bytes, &version, ) { - Ok(v) => v, - Err(e) => { + { return ApiResponse::error( ApiError::Internal(format!( "state node response failed CID verification (tampered response?): {e}" @@ -424,28 +331,7 @@ impl MonasController { trace_id, ); } - }; - - // 単調性チェック(B)。最新読みのときだけ働く。版を明示指定した read は - // 「過去の版を意図的に読む」正当な操作なので、A(CID 検証)のみ。 - // ここではチェックのみ行い、last_seen の記録は復号まで含む全検証が - // 成功した後に行う。チェック通過直後に記録すると、CID は通るが復号 - // できない偽 Node を 1 回受けるだけで pin が汚染され、以後の正規 read - // が恒久的に Conflict になる(単調性チェックの自壊 DoS)。 - let checked_last_seen = if is_latest_read { - match self.check_read_monotonicity( - &input.content_id, - &version, - &verified.parents, - auth, - &trace_id, - ) { - Ok(last_seen) => Some(last_seen), - Err(e) => return *e, - } - } else { - None - }; + } // CEK ロード + AES-GCM 復号 + plain CID 照合 let local_content_id = @@ -464,24 +350,6 @@ impl MonasController { } }; - // 全検証成功。last_seen を compare-and-advance で記録する。 - // チェック時に観測した値から動いていた場合(並行 read が先に進めた)は - // 上書きせずスキップする — 古い版で pin を巻き戻さないため。 - if let Some(expected) = checked_last_seen { - if expected.as_deref() != Some(version.as_str()) { - if let Err(e) = self.last_seen_store.compare_and_save( - &input.content_id, - expected.as_deref(), - &version, - ) { - return ApiResponse::error( - ApiError::Internal(format!("failed to record last-seen version: {e}")), - trace_id, - ); - } - } - } - ApiResponse::success( ReadContentFromStateNodeOutput { content_id: input.content_id, @@ -493,70 +361,6 @@ impl MonasController { ) } - /// 最新読みの単調性チェック本体。前回受理した版(`last_seen`)が今回の版の - /// 祖先(または同一)であることを、CID 検証済みの親リンクを辿って確認する。 - /// チェックのみ行い、記録はしない(記録は復号成功後に呼び出し側が - /// compare-and-advance で行う)。戻り値はチェック時に観測した `last_seen`。 - fn check_read_monotonicity( - &self, - remote_content_id: &str, - version: &str, - parents: &[String], - auth: Option<&StateNodeAuthContext>, - trace_id: &str, - ) -> Result, Box>> { - let last_seen = self.last_seen_store.load(remote_content_id).map_err(|e| { - Box::new(ApiResponse::error( - ApiError::Internal(format!("failed to load last-seen version: {e}")), - trace_id.to_string(), - )) - })?; - - match last_seen.as_deref() { - // 初回(記録なし)は TOFU で受理する(記録は復号成功後)。 - None => {} - // 同じ版を読み直しただけ。 - Some(l) if l == version => {} - Some(l) => { - let outcome = walk_ancestors_for(parents, l, MAX_MONOTONICITY_FETCHES, |cid| { - self.fetch_verified_parents(remote_content_id, cid, auth, trace_id) - }) - .map_err(|e| { - Box::new(ApiResponse::error( - ApiError::Internal(format!("monotonicity ancestor walk failed: {e}")), - trace_id.to_string(), - )) - })?; - - match outcome { - AncestorWalkOutcome::FoundTarget => {} - AncestorWalkOutcome::Exhausted => { - return Err(Box::new(ApiResponse::error( - ApiError::Conflict(format!( - "version regression detected: state node returned {version} as latest, \ - but previously accepted version {l} is not among its ancestors \ - (possible rollback attack or stale relay)" - )), - trace_id.to_string(), - ))); - } - AncestorWalkOutcome::BoundExceeded => { - return Err(Box::new(ApiResponse::error( - ApiError::Conflict(format!( - "monotonicity check aborted: ancestor walk exceeded \ - {MAX_MONOTONICITY_FETCHES} fetches without reaching previously \ - accepted version {l}; rejecting read (fail-closed)" - )), - trace_id.to_string(), - ))); - } - } - } - } - - Ok(last_seen) - } - /// `verify_and_decrypt_relay_read` のエラーを、呼び出し側が対処を判断できる /// `ApiError` へ写像する。特に「CEK が無い」「CEK が合わない」は /// share / rotation / revoke のどの状況かをメッセージで区別する。 @@ -759,122 +563,3 @@ impl MonasController { ) } } - -#[cfg(test)] -mod tests { - use super::{walk_ancestors_for, AncestorWalkOutcome}; - use std::collections::HashMap; - - /// cid -> parents のテーブルからフェッチャを作る。 - fn table_fetcher( - table: HashMap<&'static str, Vec<&'static str>>, - ) -> impl FnMut(&str) -> Result, String> { - move |cid: &str| { - table - .get(cid) - .map(|ps| ps.iter().map(|s| s.to_string()).collect()) - .ok_or_else(|| format!("unknown cid {cid}")) - } - } - - #[test] - fn finds_target_in_direct_parents_without_fetching() { - // 直接の親に target がいれば fetch は 1 度も要らない - let mut fetch_count = 0; - let outcome = walk_ancestors_for( - &["target".to_string(), "other".to_string()], - "target", - 10, - |_| { - fetch_count += 1; - Ok(vec![]) - }, - ) - .unwrap(); - assert_eq!(outcome, AncestorWalkOutcome::FoundTarget); - assert_eq!(fetch_count, 0); - } - - #[test] - fn finds_target_deeper_in_chain() { - // v3 -> v2 -> v1(target) -> genesis - let outcome = walk_ancestors_for( - &["v2".to_string()], - "v1", - 10, - table_fetcher(HashMap::from([ - ("v2", vec!["v1"]), - ("v1", vec!["genesis"]), - ("genesis", vec![]), - ])), - ) - .unwrap(); - assert_eq!(outcome, AncestorWalkOutcome::FoundTarget); - } - - #[test] - fn exhausted_when_target_not_ancestor() { - // 後退シナリオ: 古い版の祖先には新しい target がいない - let outcome = walk_ancestors_for( - &["genesis".to_string()], - "newer-version", - 10, - table_fetcher(HashMap::from([("genesis", vec![])])), - ) - .unwrap(); - assert_eq!(outcome, AncestorWalkOutcome::Exhausted); - } - - #[test] - fn exhausted_immediately_for_genesis_read() { - // genesis(親なし)を「最新」と偽られたケース: 探索なしで後退確定 - let outcome = - walk_ancestors_for(&[], "newer-version", 10, |_| panic!("must not fetch")).unwrap(); - assert_eq!(outcome, AncestorWalkOutcome::Exhausted); - } - - #[test] - fn bound_exceeded_is_fail_closed() { - // 際限なく親が続く偽 DAG は上限で打ち切る - let mut i = 0; - let outcome = walk_ancestors_for(&["n0".to_string()], "never-found", 5, |_| { - i += 1; - Ok(vec![format!("n{i}")]) - }) - .unwrap(); - assert_eq!(outcome, AncestorWalkOutcome::BoundExceeded); - } - - #[test] - fn diamond_dag_is_deduplicated() { - // merge を含む DAG(v3 の親 v2a, v2b が共通祖先 v1 を持つ)でも - // 同じノードを二度 fetch しない - let mut fetched: Vec = vec![]; - let outcome = walk_ancestors_for( - &["v2a".to_string(), "v2b".to_string()], - "genesis", - 10, - |cid: &str| { - fetched.push(cid.to_string()); - Ok(match cid { - "v2a" | "v2b" => vec!["v1".to_string()], - "v1" => vec!["genesis".to_string()], - _ => vec![], - }) - }, - ) - .unwrap(); - assert_eq!(outcome, AncestorWalkOutcome::FoundTarget); - // v1 は 1 度だけ fetch される - assert_eq!(fetched.iter().filter(|c| c.as_str() == "v1").count(), 1); - } - - #[test] - fn fetch_error_propagates() { - let err = walk_ancestors_for(&["v2".to_string()], "v1", 10, |_| { - Err("network down".to_string()) - }) - .unwrap_err(); - assert!(err.contains("network down")); - } -} diff --git a/monas-sdk/tests/state_read_integration_test.rs b/monas-sdk/tests/state_read_integration_test.rs index c80c8be..aa6094f 100644 --- a/monas-sdk/tests/state_read_integration_test.rs +++ b/monas-sdk/tests/state_read_integration_test.rs @@ -6,8 +6,7 @@ //! - 作成者が自分の content を state node 経由で読み、平文まで復号できる(A + 復号) //! - share 受信者が KeyEnvelope 処理後に同じ content を読める(CEK 永続化) //! - KeyEnvelope 未処理の受信者は MissingKey 由来の NotFound で誘導される -//! - 改ざんされた Node(CID 不一致)は拒否される(A) -//! - 版の後退(ロールバック)は拒否され、前進・同一版・明示版指定は通る(B) +//! - 改ざんされた Node(CID 不一致)は拒否される //! //! State Node が返す Node CBOR は crsl-lib `Node` と同じ CBOR 形状のミラー構造体で //! 生成する(ミラーの正しさは monas-content 側の crsl-lib パリティテストで担保)。 @@ -584,166 +583,3 @@ async fn envelope_sender_auth_rejects_wrong_sender_key() { cleanup_content_artifacts(); } - -/// last_seen は「復号まで含む全検証が成功した read」でしか進んではならない。 -/// CID 検証は通るが復号できない偽 Node を 1 回受けただけで pin が偽版に -/// 汚染されると、以後の正規 read が恒久的に Conflict になる(自壊 DoS)。 -#[tokio::test(flavor = "multi_thread")] -async fn failed_decrypt_does_not_poison_last_seen_pin() { - let _guard = acquire_test_lock(); - let mut server = Server::new_async().await; - let controller = MonasController::with_urls(server.url(), server.url()); - - let plaintext = b"pin-poison-target"; - let created = create_and_share(&mut server, &controller, plaintext).await; - - let genesis_bytes = make_node_bytes(&created.ciphertext, vec![], None); - let genesis_cid = recompute_node_cid(&genesis_bytes).unwrap(); - - // 攻撃者が鋳造した「CID は正しいが CEK で復号できない」偽 Node。 - // parents に正規 genesis を入れて単調性チェックも通す。 - let forged_bytes = make_node_bytes( - b"garbage-not-encrypted-with-cek", - vec![&genesis_cid], - Some(&genesis_cid), - ); - let forged_cid = recompute_node_cid(&forged_bytes).unwrap(); - - // 正規の次版 v2(genesis の子、正規 ciphertext)。 - let v2_bytes = make_node_bytes(&created.ciphertext, vec![&genesis_cid], Some(&genesis_cid)); - let v2_cid = recompute_node_cid(&v2_bytes).unwrap(); - - let _g_data = mock_version_data(&mut server, &genesis_cid, &genesis_bytes).await; - let _forged_data = mock_version_data(&mut server, &forged_cid, &forged_bytes).await; - let _v2_data = mock_version_data(&mut server, &v2_cid, &v2_bytes).await; - - let read_latest = || { - controller.read_content_from_state_node( - ReadContentFromStateNodeInput { - content_id: REMOTE_ID.into(), - local_content_id: created.local_content_id.clone(), - version: None, - }, - None, - ) - }; - - // 1. 初回(TOFU): latest = g を受理、last_seen = g - let history_g = mock_history(&mut server, &[&genesis_cid]).await; - let first = read_latest(); - assert!(first.success, "TOFU read should succeed: {:?}", first.error); - history_g.remove_async().await; - - // 2. 偽 Node を latest として受ける: CID 検証・単調性は通るが復号で失敗する - let history_forged = mock_history(&mut server, &[&genesis_cid, &forged_cid]).await; - let poisoned = read_latest(); - assert!(!poisoned.success, "undecryptable forged node must fail"); - assert!( - matches!(poisoned.error, Some(ApiError::Forbidden(_))), - "expected Forbidden(decrypt failure), got: {:?}", - poisoned.error - ); - history_forged.remove_async().await; - - // 3. 正規の latest = v2 は引き続き受理される。 - // (pin が forged_cid に汚染されていれば、v2 の祖先に forged が居ないため - // Conflict になってしまう — それが修正前のバグ) - let _history_v2 = mock_history(&mut server, &[&genesis_cid, &v2_cid]).await; - let legit = read_latest(); - assert!( - legit.success, - "legitimate read after failed decrypt must still succeed (pin must not be poisoned): {:?}", - legit.error - ); - assert_eq!(legit.data.unwrap().version, v2_cid); - - cleanup_content_artifacts(); -} - -#[tokio::test(flavor = "multi_thread")] -async fn read_monotonicity_accepts_forward_and_rejects_regression() { - let _guard = acquire_test_lock(); - let mut server = Server::new_async().await; - let controller = MonasController::with_urls(server.url(), server.url()); - - let plaintext = b"monotonic-content"; - let created = create_and_share(&mut server, &controller, plaintext).await; - - // g(genesis) → v2(child) のチェーン。ciphertext は同一(再暗号化なしの - // no-op update 相当)なので、どの版も同じ CEK・同じ plain CID で復号できる。 - let genesis_bytes = make_node_bytes(&created.ciphertext, vec![], None); - let genesis_cid = recompute_node_cid(&genesis_bytes).unwrap(); - let v2_bytes = make_node_bytes(&created.ciphertext, vec![&genesis_cid], Some(&genesis_cid)); - let v2_cid = recompute_node_cid(&v2_bytes).unwrap(); - - let _g_data = mock_version_data(&mut server, &genesis_cid, &genesis_bytes).await; - let _v2_data = mock_version_data(&mut server, &v2_cid, &v2_bytes).await; - - let read_latest = |controller: &MonasController| { - controller.read_content_from_state_node( - ReadContentFromStateNodeInput { - content_id: REMOTE_ID.into(), - local_content_id: created.local_content_id.clone(), - version: None, - }, - None, - ) - }; - - // 1. 初回(TOFU): latest = g を受理、last_seen = g - let history_g = mock_history(&mut server, &[&genesis_cid]).await; - let first = read_latest(&controller); - assert!(first.success, "TOFU read should succeed: {:?}", first.error); - history_g.assert_async().await; - history_g.remove_async().await; - - // 2. 前進: latest = v2(parents に g)→ 受理、last_seen = v2 - let history_v2 = mock_history(&mut server, &[&genesis_cid, &v2_cid]).await; - let forward = read_latest(&controller); - assert!( - forward.success, - "forward read should succeed: {:?}", - forward.error - ); - assert_eq!(forward.data.unwrap().version, v2_cid); - history_v2.assert_async().await; - history_v2.remove_async().await; - - // 3. 後退: latest と偽って g を返す → v2 は g の祖先に居ないので拒否 - let history_rollback = mock_history(&mut server, &[&genesis_cid]).await; - let regression = read_latest(&controller); - assert!(!regression.success, "regression must be rejected"); - match regression.error { - Some(ApiError::Conflict(msg)) => { - assert!(msg.contains("version regression"), "msg={msg}") - } - other => panic!("expected Conflict(version regression), got: {other:?}"), - } - history_rollback.remove_async().await; - - // 4. 明示版指定の read は「過去の版を意図的に読む」操作なので B の対象外 - let pinned = controller.read_content_from_state_node( - ReadContentFromStateNodeInput { - content_id: REMOTE_ID.into(), - local_content_id: created.local_content_id.clone(), - version: Some(genesis_cid.clone()), - }, - None, - ); - assert!( - pinned.success, - "pinned old-version read should succeed: {:?}", - pinned.error - ); - - // 5. 明示版読みは last_seen を動かさない: latest = v2 は引き続き受理される - let _history_v2_again = mock_history(&mut server, &[&genesis_cid, &v2_cid]).await; - let still_latest = read_latest(&controller); - assert!( - still_latest.success, - "latest read after pinned read should succeed: {:?}", - still_latest.error - ); - - cleanup_content_artifacts(); -} From 16d9cd48b0856a9508705d4cdbb42a405f8b51a9 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 14:58:44 +0900 Subject: [PATCH 28/48] fix(state-node): bind every request signature to operation and resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘: body を伴う create/update の署名対象が sha256(body||timestamp) だけで、operation と resource が落ちていた。同じトークンで複数 content に 書ける場合、ある content の update 用に取得した body+署名を別 content へ、 あるいは create へ転用できた(create は認証後の resource 認可を skip する)。 design.md の「盗まれた署名は同じリソースへの同じ操作に限られる」も実装より 強い主張になっていた。 署名対象を body の有無・トークン種別によらず単一構造に統一する: monas-request-v1::::::: - domain separation タグを前置(構造変更時に旧署名を一括無効化できる) - 長さ前置により、 を含む値でフィールド境界がずれない - body 付きは digest を含め、body なしは空文字で同じ構造を保つ - SDK / test-auth-generator の生成側も同一形式へ テスト: cross-resource / cross-operation / body 改ざんの各転用が検証で 落ちること、フィールド境界の曖昧性が無いこと、read 署名が content id に 束縛されること。 Co-Authored-By: Claude Fable 5 --- docs/design.md | 2 +- monas-sdk/src/controller/content.rs | 133 ++++++++++++++---- .../content_controller_integration_test.rs | 14 +- .../application_service/state_node_service.rs | 53 ++++--- .../src/bin/test_auth_generator.rs | 32 +++-- .../auth/monas_account_adapter.rs | 78 ++++++++++ monas-state-node/src/port/auth_token.rs | 116 ++++++++++++++- 7 files changed, 364 insertions(+), 64 deletions(-) diff --git a/docs/design.md b/docs/design.md index deedd60..995e701 100644 --- a/docs/design.md +++ b/docs/design.md @@ -340,7 +340,7 @@ Token.att = [ Token失効は`min_valid_issued_at`による時刻ベースで管理される。オーナーがこの値を更新することで、それ以前に発行されたすべてのTokenを一括失効できる。 -役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別によらず`{操作}:{リソース}:{timestamp}`(書き込みはbody hash + timestamp)で統一されており、リプレイ防御は署名内のtimestampの鮮度チェック(5分窓)が担う。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。したがってTokenはTTL内で何度でも再利用でき、盗まれた署名でできることは「同じリソースへの同じ操作を5分以内に再実行する」ことに限られる。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 +役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別・bodyの有無によらず同一構造で、domain separationタグに続けて操作・リソース・timestamp・body digestを長さ前置で連結する(`monas-request-v1::<操作>::<リソース>:::`)。**bodyを伴う書き込みでも操作とリソースに束縛される**ため、あるコンテンツ向けに取得した署名を別コンテンツや別操作へ転用することはできない。リプレイ防御は署名内のtimestampの鮮度チェック(5分窓)が担う。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。したがってTokenはTTL内で何度でも再利用でき、盗まれた署名でできることは「同じリソースへの同じ操作を5分以内に再実行する」ことに限られる。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 ### ビザンチン耐性 diff --git a/monas-sdk/src/controller/content.rs b/monas-sdk/src/controller/content.rs index 5722d6e..5537f22 100644 --- a/monas-sdk/src/controller/content.rs +++ b/monas-sdk/src/controller/content.rs @@ -96,15 +96,36 @@ impl MonasController { req } - fn build_content_signature_message(content_bytes: &[u8], timestamp: u64) -> String { - let mut hasher = Sha256::new(); - hasher.update(content_bytes); - hasher.update(timestamp.to_be_bytes()); - hex::encode(hasher.finalize()) - } + /// Domain separation tag. State Node 側 `REQUEST_SIGNATURE_DOMAIN` と一致させる。 + const REQUEST_SIGNATURE_DOMAIN: &'static str = "monas-request-v1"; - fn build_metadata_signature_message(operation: &str, resource: &str, timestamp: u64) -> String { - format!("{operation}:{resource}:{timestamp}") + /// リクエスト署名の署名対象を組み立てる。 + /// + /// body の有無によらず operation / resource / timestamp に必ず束縛し、 + /// body がある場合はその digest も含める。State Node 側 + /// (`RequestMetadata::signing_message_with_body_digest`)と同一形式でなければ + /// 署名検証が通らないため、変更するときは両方を揃えること。 + fn build_request_signature_message( + operation: &str, + resource: &str, + timestamp: u64, + body: Option<&[u8]>, + ) -> String { + let body_digest_hex = match body { + Some(bytes) => hex::encode(Sha256::digest(bytes)), + None => String::new(), + }; + format!( + "{}:{}:{}:{}:{}:{}:{}:{}", + Self::REQUEST_SIGNATURE_DOMAIN, + operation.len(), + operation, + resource.len(), + resource, + timestamp, + body_digest_hex.len(), + body_digest_hex, + ) } fn map_account_http_status_to_api_response( @@ -202,9 +223,16 @@ impl MonasController { }) } + /// body を伴う書き込み(create / update)の署名を用意する。 + /// + /// `operation` / `resource` は State Node 側が + /// `verify_caller_signature` へ渡す値と一致させること + /// (create は `("create", "content")`、update は `("update", content_id)`)。 fn prepare_state_node_content_auth( &self, auth: Option<&StateNodeAuthContext>, + operation: &str, + resource: &str, content_bytes: &[u8], trace_id: &str, ) -> Result, ApiResponse> { @@ -212,7 +240,12 @@ impl MonasController { return Ok(None); }; let timestamp = self.resolve_request_timestamp(ctx, trace_id)?; - let signing_message = Self::build_content_signature_message(content_bytes, timestamp); + let signing_message = Self::build_request_signature_message( + operation, + resource, + timestamp, + Some(content_bytes), + ); self.sign_state_node_message_with_account(&signing_message, timestamp, trace_id) .map(Some) } @@ -229,7 +262,7 @@ impl MonasController { }; let timestamp = self.resolve_request_timestamp(ctx, trace_id)?; let signing_message = - Self::build_metadata_signature_message(operation, resource, timestamp); + Self::build_request_signature_message(operation, resource, timestamp, None); self.sign_state_node_message_with_account(&signing_message, timestamp, trace_id) .map(Some) } @@ -504,8 +537,13 @@ impl MonasController { )); } }; - let signed_auth = - self.prepare_state_node_content_auth(auth, encrypted_content, &trace_id)?; + let signed_auth = self.prepare_state_node_content_auth( + auth, + "create", + "content", + encrypted_content, + &trace_id, + )?; let state_node_url = format!("{}/content", self.state_node_url); let req = Self::attach_state_node_auth( @@ -591,11 +629,16 @@ impl MonasController { )); } }; - let signed_auth = - match self.prepare_state_node_content_auth(auth, encrypted_content, &trace_id) { - Ok(auth) => auth, - Err(response) => return Some(response), - }; + let signed_auth = match self.prepare_state_node_content_auth( + auth, + "update", + content_id, + encrypted_content, + &trace_id, + ) { + Ok(auth) => auth, + Err(response) => return Some(response), + }; let state_node_url = format!("{}/content/{}", self.state_node_url, content_id); let req = Self::attach_state_node_auth( @@ -1089,20 +1132,58 @@ mod tests { use super::*; #[test] - fn build_content_signature_message_hashes_content_bytes_and_timestamp() { - let message = MonasController::build_content_signature_message(b"abc", 42); - assert_eq!(message.len(), 64); + fn request_signature_message_binds_operation_resource_timestamp() { + let base = MonasController::build_request_signature_message("delete", "c1", 42, None); + assert!(base.starts_with("monas-request-v1:"), "msg={base}"); + + // 4 つのフィールドはどれが変わっても署名対象が変わる + assert_ne!( + base, + MonasController::build_request_signature_message("read", "c1", 42, None) + ); + assert_ne!( + base, + MonasController::build_request_signature_message("delete", "c2", 42, None) + ); assert_ne!( - message, - MonasController::build_content_signature_message(b"abc", 43) + base, + MonasController::build_request_signature_message("delete", "c1", 43, None) ); } #[test] - fn build_metadata_signature_message_formats_operation_resource_and_timestamp() { - assert_eq!( - MonasController::build_metadata_signature_message("delete", "test", 42), - "delete:test:42" + fn request_signature_message_binds_body_and_stays_bound_to_resource() { + let with_body = + MonasController::build_request_signature_message("update", "c1", 42, Some(b"abc")); + + // body が変われば署名対象も変わる + assert_ne!( + with_body, + MonasController::build_request_signature_message("update", "c1", 42, Some(b"abd")) + ); + // body 付きでも operation / resource に束縛される(cross-resource / + // cross-operation replay を防ぐ) + assert_ne!( + with_body, + MonasController::build_request_signature_message("update", "c2", 42, Some(b"abc")) + ); + assert_ne!( + with_body, + MonasController::build_request_signature_message("create", "c1", 42, Some(b"abc")) + ); + // body 有無も区別される + assert_ne!( + with_body, + MonasController::build_request_signature_message("update", "c1", 42, None) + ); + } + + /// 区切り文字を含む値でフィールド境界がずれない(長さ前置のおかげ)。 + #[test] + fn request_signature_message_is_unambiguous_with_colons() { + assert_ne!( + MonasController::build_request_signature_message("a", "b:c", 1, None), + MonasController::build_request_signature_message("a:b", "c", 1, None) ); } } diff --git a/monas-sdk/tests/content_controller_integration_test.rs b/monas-sdk/tests/content_controller_integration_test.rs index 1e85acf..f3af63d 100644 --- a/monas-sdk/tests/content_controller_integration_test.rs +++ b/monas-sdk/tests/content_controller_integration_test.rs @@ -727,8 +727,18 @@ async fn delete_content_uses_account_signature_for_metadata_request() { .expect("create should return data"); create_mock.assert(); - let expected_signing_message = - BASE64_STANDARD.encode(format!("delete:{}:1818181818", "bafkdelete-signed").as_bytes()); + // 署名対象は domain-separated かつ長さ前置の統一形式 + // (`monas-request-v1:::::::`)。 + // body なしリクエストなので body digest は空。 + let resource = "bafkdelete-signed"; + let expected_signing_message = BASE64_STANDARD.encode( + format!( + "monas-request-v1:6:delete:{}:{}:1818181818:0:", + resource.len(), + resource + ) + .as_bytes(), + ); let account_sign_mock = account_server .mock("POST", "/accounts/sign") diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 6ac8ca8..df059b7 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -314,21 +314,22 @@ where ) })?; - let message = if let Some(body) = request_body { - // Body-based signing: hex(sha256(body + timestamp_be_bytes)) - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(body); - hasher.update(ts.to_be_bytes()); - hex::encode(hasher.finalize()) - } else { - // Metadata-based signing: {operation}:{resource}:{timestamp} - let metadata = RequestMetadata { - timestamp: ts, - operation: operation.to_string(), - resource: resource.to_string(), - }; - metadata.signing_message() + // 署名対象は body の有無・トークン種別によらず同一構造で、 + // operation と resource に必ず束縛される。body がある場合はその digest も + // 含める。これがないと、ある content 向けに取得した update の + // body+署名を別 content や create へ転用できてしまう。 + let metadata = RequestMetadata { + timestamp: ts, + operation: operation.to_string(), + resource: resource.to_string(), + }; + let message = match request_body { + Some(body) => { + use sha2::{Digest, Sha256}; + let digest = hex::encode(Sha256::digest(body)); + metadata.signing_message_with_body_digest(&digest) + } + None => metadata.signing_message(), }; auth_service @@ -3232,10 +3233,26 @@ mod tests { .expect("authentication should succeed"); let captured = messages.lock().unwrap(); + assert_eq!(captured.len(), 1, "exactly one signature check expected"); + let message = &captured[0]; + + // 署名対象は domain-separated かつ operation / resource / timestamp に + // 束縛される。content id が入っていないと、ある content 向けの署名を + // 別 content の read に再利用できてしまう。 assert_eq!( - captured.as_slice(), - ["read:content-abc:1234"], - "read signature message must include the content id" + message, + &RequestMetadata { + timestamp: 1234, + operation: "read".to_string(), + resource: "content-abc".to_string(), + } + .signing_message(), + "read signature message must bind operation, content id and timestamp" + ); + assert!(message.contains("content-abc"), "message={message}"); + assert!( + message.starts_with("monas-request-v1:"), + "message={message}" ); } diff --git a/monas-state-node/src/bin/test_auth_generator.rs b/monas-state-node/src/bin/test_auth_generator.rs index 31c639c..782d0ba 100644 --- a/monas-state-node/src/bin/test_auth_generator.rs +++ b/monas-state-node/src/bin/test_auth_generator.rs @@ -172,25 +172,29 @@ fn sign_request(args: &[String]) { std::process::exit(1); }); - // Construct the signing message. - // The message format is identical for every token type (issue #61): - // body-based for writes, `{operation}:{resource}:{timestamp}` otherwise. - // Delegated JWT requests are signed with the recipient (aud) key over the - // same message — the old "{iss}:{aud}:{jti}" fixed string is gone. - let message = if !body_b64.is_empty() { - // Body-based signing: hex(sha256(body_bytes + timestamp_be_bytes)) + // Construct the signing message. The structure is identical for every + // token type and for body / non-body requests: it always commits to + // operation, resource and timestamp, plus the body digest when present. + // Must stay in sync with `RequestMetadata::signing_message_with_body_digest`. + let body_digest_hex = if body_b64.is_empty() { + String::new() + } else { let body_bytes = STANDARD.decode(&body_b64).unwrap_or_else(|e| { eprintln!("Error: Invalid body base64: {}", e); std::process::exit(1); }); - let mut hasher = Sha256::new(); - hasher.update(&body_bytes); - hasher.update(timestamp.to_be_bytes()); - hex::encode(hasher.finalize()) - } else { - // Metadata-based signing: {operation}:{resource}:{timestamp} - format!("{}:{}:{}", operation, resource, timestamp) + hex::encode(Sha256::digest(&body_bytes)) }; + let message = format!( + "monas-request-v1:{}:{}:{}:{}:{}:{}:{}", + operation.len(), + operation, + resource.len(), + resource, + timestamp, + body_digest_hex.len(), + body_digest_hex, + ); // Sign the message let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); diff --git a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs index 76c2db6..da8cb86 100644 --- a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs @@ -542,6 +542,84 @@ mod tests { .is_err()); } + /// cross-resource / cross-operation replay の回帰テスト。 + /// ある content の update 用に取得した body+署名を、別 content や + /// create へ転用できてはならない(署名対象が operation / resource に + /// 束縛されているので検証が落ちる)。 + #[tokio::test] + async fn test_body_signature_cannot_be_replayed_across_resource_or_operation() { + use crate::port::auth_token::RequestMetadata; + use p256::ecdsa::signature::Signer; + use sha2::Digest; + + let (adapter, signing_key, key_id) = create_test_adapter(); + let token = AuthToken::new(key_id); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let body = b"payload-bytes"; + let body_digest = hex::encode(sha2::Sha256::digest(body)); + + let captured = RequestMetadata { + timestamp: ts, + operation: "update".to_string(), + resource: "content-1".to_string(), + }; + let captured_message = captured.signing_message_with_body_digest(&body_digest); + let signature: p256::ecdsa::Signature = signing_key.sign(captured_message.as_bytes()); + let signature_bytes = signature.to_vec(); + + // 正規の組み合わせは通る + assert!(adapter + .verify_request_signature(&token, &signature_bytes, &captured_message, Some(ts)) + .await + .is_ok()); + + // 同じ body・同じ署名を別 content へ転用 → 拒否 + let other_resource = RequestMetadata { + resource: "content-2".to_string(), + ..captured.clone() + }; + assert!(adapter + .verify_request_signature( + &token, + &signature_bytes, + &other_resource.signing_message_with_body_digest(&body_digest), + Some(ts), + ) + .await + .is_err()); + + // 同じ body・同じ署名を create へ転用 → 拒否 + let other_operation = RequestMetadata { + operation: "create".to_string(), + ..captured.clone() + }; + assert!(adapter + .verify_request_signature( + &token, + &signature_bytes, + &other_operation.signing_message_with_body_digest(&body_digest), + Some(ts), + ) + .await + .is_err()); + + // body を差し替えても拒否 + assert!(adapter + .verify_request_signature( + &token, + &signature_bytes, + &captured.signing_message_with_body_digest(&hex::encode(sha2::Sha256::digest( + b"tampered" + ))), + Some(ts), + ) + .await + .is_err()); + } + #[tokio::test] async fn test_verify_request_signature_expired_timestamp() { let (adapter, signing_key, key_id) = create_test_adapter(); diff --git a/monas-state-node/src/port/auth_token.rs b/monas-state-node/src/port/auth_token.rs index 58a2196..befce24 100644 --- a/monas-state-node/src/port/auth_token.rs +++ b/monas-state-node/src/port/auth_token.rs @@ -53,11 +53,45 @@ pub struct RequestMetadata { pub resource: String, } +/// Domain separation tag for request signatures. Bumping this invalidates +/// every previously produced signature, which is what we want if the message +/// structure ever changes. +pub const REQUEST_SIGNATURE_DOMAIN: &str = "monas-request-v1"; + impl RequestMetadata { - /// Create signing message for request signature - /// Format: "{operation}:{resource}:{timestamp}" + /// Create the signing message for a request signature. + /// + /// Every request signature — with or without a body, JWT or not — commits + /// to the same fields, so a signature captured for one request cannot be + /// replayed against a different operation or resource (issue: review + /// finding "body signature lacks operation/resource"). + /// + /// Format (length-prefixed to keep the concatenation unambiguous): + /// + /// ```text + /// monas-request-v1::::::: + /// ``` + /// + /// `body_digest_hex` is `sha256(body)` for requests that carry a body, and + /// the empty string otherwise. Lengths prevent a crafted operation or + /// resource containing `:` from shifting the field boundaries. pub fn signing_message(&self) -> String { - format!("{}:{}:{}", self.operation, self.resource, self.timestamp) + self.signing_message_with_body_digest("") + } + + /// Same as [`Self::signing_message`], but committing to a body digest. + pub fn signing_message_with_body_digest(&self, body_digest_hex: &str) -> String { + format!( + "{}:{}:{}:{}:{}:{}:{}:{}", + REQUEST_SIGNATURE_DOMAIN, + self.operation.len(), + self.operation, + self.resource.len(), + self.resource, + self.timestamp, + body_digest_hex.len(), + body_digest_hex, + ) } } @@ -136,4 +170,80 @@ mod tests { assert_eq!(token1, token2); assert_ne!(token1, token3); } + + /// 署名対象が operation / resource / timestamp / body digest すべてに + /// 束縛されることの回帰テスト。ここが緩むと、ある content 向けに + /// 取得した署名を別 content や別 operation へ転用できてしまう。 + #[test] + fn signing_message_binds_operation_resource_and_timestamp() { + let base = RequestMetadata { + timestamp: 42, + operation: "update".to_string(), + resource: "content-1".to_string(), + }; + let msg = base.signing_message(); + assert!(msg.starts_with("monas-request-v1:"), "msg={msg}"); + + let other_op = RequestMetadata { + operation: "create".to_string(), + ..base.clone() + }; + let other_resource = RequestMetadata { + resource: "content-2".to_string(), + ..base.clone() + }; + let other_ts = RequestMetadata { + timestamp: 43, + ..base.clone() + }; + assert_ne!(msg, other_op.signing_message()); + assert_ne!(msg, other_resource.signing_message()); + assert_ne!(msg, other_ts.signing_message()); + } + + #[test] + fn signing_message_with_body_digest_stays_bound_to_operation_and_resource() { + let update_c1 = RequestMetadata { + timestamp: 42, + operation: "update".to_string(), + resource: "content-1".to_string(), + }; + let digest = "aa".repeat(32); + let signed = update_c1.signing_message_with_body_digest(&digest); + + // 同じ body でも別 resource / 別 operation なら署名対象が変わる + let update_c2 = RequestMetadata { + resource: "content-2".to_string(), + ..update_c1.clone() + }; + let create_c1 = RequestMetadata { + operation: "create".to_string(), + ..update_c1.clone() + }; + assert_ne!(signed, update_c2.signing_message_with_body_digest(&digest)); + assert_ne!(signed, create_c1.signing_message_with_body_digest(&digest)); + + // body が変われば署名対象も変わる / body 有無も区別される + assert_ne!( + signed, + update_c1.signing_message_with_body_digest(&"bb".repeat(32)) + ); + assert_ne!(signed, update_c1.signing_message()); + } + + /// 長さ前置により、区切り文字を含む値でもフィールド境界がずれない。 + #[test] + fn signing_message_is_unambiguous_with_colons() { + let a = RequestMetadata { + timestamp: 1, + operation: "a".to_string(), + resource: "b:c".to_string(), + }; + let b = RequestMetadata { + timestamp: 1, + operation: "a:b".to_string(), + resource: "c".to_string(), + }; + assert_ne!(a.signing_message(), b.signing_message()); + } } From 531f4516a8eae56ebe7cd65e95fa0690fff14977 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 15:04:04 +0900 Subject: [PATCH 29/48] fix(share): advance sender pin before publishing the CEK (atomic epoch update) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘: pin を load したあと CEK を先に上書きし、最後に pin を無条件 save していた。rotation 前後の envelope(epoch N / N+1)を並行処理すると、 両方が同じ旧 pin を通過し、後から完了した N が CEK と pin を古い世代へ 戻せた。pin の save だけ失敗した場合も CEK は既に更新済みだった。 順序を反転し、pin を compare-and-advance してから、成功したときにだけ CEK を公開する: - SenderKeyPinStore に compare_and_save(CAS)を追加(in-memory / sled) - decrypt_shared_content は CAS 成功時のみ cek_store.save を行う。 CAS 失敗 = 別処理が同世代以上へ既に進めた、なので古い CEK は書かない - 同一世代の再処理では pin を動かさず CEK は書く(前回 CEK 保存だけ 失敗したケースを回復できるようにするため) テスト: 同じ pin を観測した epoch N+1 / N が並行して完了しても pin が 後退しないこと(in-memory / sled 両方)。design.md にも更新順序の要件を明記。 Co-Authored-By: Claude Fable 5 --- docs/design.md | 2 + .../infrastructure/sender_key_pin_store.rs | 140 ++++++++++++++++++ monas-sdk/src/controller/share.rs | 74 +++++---- 3 files changed, 189 insertions(+), 27 deletions(-) diff --git a/docs/design.md b/docs/design.md index 995e701..4c74b99 100644 --- a/docs/design.md +++ b/docs/design.md @@ -368,6 +368,8 @@ share受信者はKeyEnvelopeの復号成功時にunwrap済みCEKを自デバイ wrapのAADには `(content_id, recipient_key_id, key_epoch)` が束縛され、いずれかを書き換えたenvelopeは復号に失敗する。`key_epoch` はCEKの鍵世代(rotationごとに+1)で、受信者は記録済み世代より古いenvelopeを拒否する — rotation前の正規envelopeを再送して保存CEKを旧世代へ巻き戻すreplay攻撃はこれで防がれる。 +ローカル状態の更新順序も重要である。受信者側では**送信者ピンと鍵世代をcompare-and-advanceで先に進め、それが成功したときにだけCEKを保存する**。逆順(CEKを先に書き、ピンを無条件に上書きする)だと、ローテーション前後のKeyEnvelopeが並行して処理されたとき、後から完了した古い世代が新しいCEKとピンを巻き戻せてしまう。 + アクセス取り消しの安全性は受信者の鍵破棄(強制不能)ではなくCEKローテーションに依存する。revoke時は再暗号化を先に行い、残存受信者にはローテーション後のCEK・進んだkey_epochでKeyEnvelopeを再発行する。受信者が再発行envelopeを処理すると保存済みCEKが更新され、旧CEKのままでは新しい版を復号できない。 --- diff --git a/monas-content/src/infrastructure/sender_key_pin_store.rs b/monas-content/src/infrastructure/sender_key_pin_store.rs index 7b55223..2605a7e 100644 --- a/monas-content/src/infrastructure/sender_key_pin_store.rs +++ b/monas-content/src/infrastructure/sender_key_pin_store.rs @@ -33,6 +33,20 @@ pub struct SenderKeyPin { pub trait SenderKeyPinStore: Send + Sync { fn load(&self, content_id: &str) -> Result, SenderKeyPinStoreError>; fn save(&self, content_id: &str, pin: &SenderKeyPin) -> Result<(), SenderKeyPinStoreError>; + + /// compare-and-advance: 現在値が `expected` と一致する場合のみ `pin` へ進める。 + /// 戻り値は「進めたかどうか」。 + /// + /// envelope の並行処理(rotation 前後の epoch N / N+1 が同時に走る等)で、 + /// 「load した時点の pin」を前提に無条件 save すると、後から完了した古い + /// epoch が新しい pin と CEK を巻き戻せる。ピンの前進をこの CAS に限定し、 + /// **成功したときだけ CEK を公開する**ことで、その巻き戻しを防ぐ。 + fn compare_and_save( + &self, + content_id: &str, + expected: Option<&SenderKeyPin>, + pin: &SenderKeyPin, + ) -> Result; } /// プロセス内 `HashMap` 実装。テスト・開発用(再起動で揮発 = 毎回 TOFU に戻る)。 @@ -58,6 +72,23 @@ impl SenderKeyPinStore for InMemorySenderKeyPinStore { guard.insert(content_id.to_string(), pin.clone()); Ok(()) } + + fn compare_and_save( + &self, + content_id: &str, + expected: Option<&SenderKeyPin>, + pin: &SenderKeyPin, + ) -> Result { + let mut guard = self + .inner + .lock() + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + if guard.get(content_id) != expected { + return Ok(false); + } + guard.insert(content_id.to_string(), pin.clone()); + Ok(true) + } } /// sled 実装。キーは `"sender_pin:{content_id}"`、値は `SenderKeyPin` の JSON。 @@ -100,6 +131,38 @@ impl SenderKeyPinStore for SledSenderKeyPinStore { .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; Ok(()) } + + fn compare_and_save( + &self, + content_id: &str, + expected: Option<&SenderKeyPin>, + pin: &SenderKeyPin, + ) -> Result { + // 比較は保存形式(JSON バイト列)で行う。`SenderKeyPin` のフィールド順は + // 固定で serde_json も宣言順に出すため、同じ値は同じバイト列になる。 + let expected_bytes = expected + .map(serde_json::to_vec) + .transpose() + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + let new_bytes = + serde_json::to_vec(pin).map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + + let swapped = self + .db + .compare_and_swap( + Self::sled_key(content_id), + expected_bytes.as_deref(), + Some(new_bytes), + ) + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))? + .is_ok(); + if swapped { + self.db + .flush() + .map_err(|e| SenderKeyPinStoreError::Storage(e.to_string()))?; + } + Ok(swapped) + } } #[cfg(test)] @@ -125,6 +188,35 @@ mod tests { assert_eq!(store.load("content-a").unwrap(), Some(pin_v1)); assert!(store.load("content-b").unwrap().is_none()); + + // compare-and-advance: 期待値が現在値と一致すれば進む + let pin_v2 = SenderKeyPin { + sender_public_key: vec![0x04, 1, 2, 3], + key_epoch: 2, + }; + let current = store.load("content-a").unwrap(); + assert!(store + .compare_and_save("content-a", current.as_ref(), &pin_v2) + .unwrap()); + assert_eq!(store.load("content-a").unwrap(), Some(pin_v2.clone())); + + // 期待値が古ければ(並行処理が先に進めていれば)巻き戻さない + let stale = SenderKeyPin { + sender_public_key: vec![0x04, 1, 2, 3], + key_epoch: 1, + }; + assert!(!store + .compare_and_save("content-a", Some(&stale), &stale) + .unwrap()); + assert_eq!(store.load("content-a").unwrap(), Some(pin_v2)); + + // 未記録(None)期待の初回書き込み / 既に記録がある場合は失敗 + let first = SenderKeyPin { + sender_public_key: vec![0x04, 9, 9, 9], + key_epoch: 0, + }; + assert!(store.compare_and_save("content-c", None, &first).unwrap()); + assert!(!store.compare_and_save("content-c", None, &first).unwrap()); } #[test] @@ -132,6 +224,54 @@ mod tests { roundtrip(&InMemorySenderKeyPinStore::default()); } + /// 並行処理の巻き戻し防止: epoch N と N+1 が同じ pin を観測して開始し、 + /// N+1 が先に前進した後に N が完了しても、pin は後退しない。 + /// SDK 側は「CAS が成功したときだけ CEK を公開する」ため、この CAS が + /// false を返すことが CEK 巻き戻しを止める最後の砦になる。 + fn concurrent_epochs_do_not_roll_back(store: &dyn SenderKeyPinStore) { + let key = vec![0x04, 1, 2, 3]; + let epoch0 = SenderKeyPin { + sender_public_key: key.clone(), + key_epoch: 0, + }; + store.save("c", &epoch0).unwrap(); + + // 2 つの処理が同じ pin(epoch 0)を観測して開始する + let observed = store.load("c").unwrap(); + + let epoch2 = SenderKeyPin { + sender_public_key: key.clone(), + key_epoch: 2, + }; + let epoch1 = SenderKeyPin { + sender_public_key: key, + key_epoch: 1, + }; + + // 新しい世代が先に前進する + assert!(store + .compare_and_save("c", observed.as_ref(), &epoch2) + .unwrap()); + + // 後から完了した古い世代は CAS に失敗し、pin を巻き戻せない + assert!(!store + .compare_and_save("c", observed.as_ref(), &epoch1) + .unwrap()); + assert_eq!(store.load("c").unwrap(), Some(epoch2)); + } + + #[test] + fn in_memory_concurrent_epochs_do_not_roll_back() { + concurrent_epochs_do_not_roll_back(&InMemorySenderKeyPinStore::default()); + } + + #[test] + fn sled_concurrent_epochs_do_not_roll_back() { + let dir = tempfile::tempdir().unwrap(); + let db = sled::open(dir.path()).unwrap(); + concurrent_epochs_do_not_roll_back(&SledSenderKeyPinStore::with_db(db)); + } + #[test] fn sled_roundtrip() { let dir = tempfile::tempdir().unwrap(); diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index 52e0720..b67feaf 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -781,42 +781,62 @@ impl MonasController { } }; - // 9. 復号成功 = この CEK が本物であることの確認になるので、受信者ローカルの - // cek_store に保存する。これで share 受信者も後から state node 経由の - // 検証付き read(read_content_from_state_node)で同じ content を復号できる。 - // 同一 content_id への保存は上書きなので、CEK ローテーション後に再発行された - // KeyEnvelope を処理すれば保存済み CEK も新しいものへ追従する。 - // CEK が出るのは受信者デバイスのローカルストアまでで、ネットワークには出ない。 - if let Err(e) = self.content_service.cek_store.save(&content_id, &cek) { - // 保存に失敗したまま成功を返すと、呼び出し側は「以後この端末で - // 検証付き read ができる」と信じるのに実際は MissingKey で失敗する。 - // silent degradation を避けるためエラーとして返す(再処理可能)。 - return ApiResponse::error( - ApiError::Internal(format!( - "decrypted the shared content but failed to persist its CEK for {}: {e}. \ - Re-process the KeyEnvelope to enable state-node reads on this device.", - content_id.as_str() - )), - trace_id, - ); - } - - // 10. unwrap + 復号の成功 = 送信者と鍵世代の正しさが暗号学的に確認できた - // 時点なので、送信者公開鍵をピン留めし(TOFU)、受理した鍵世代を記録する。 + // 9. ローカル状態(送信者ピン・鍵世代・CEK)の更新。 + // + // unwrap + 復号の成功 = 送信者と鍵世代の正しさが暗号学的に確認できた + // 時点なので、ここで初めてローカルへ反映する。 + // + // 順序が重要: **先に pin を compare-and-advance し、成功した場合にだけ + // CEK を公開する**。pin を無条件 save して後から CEK を書くと、rotation + // 前後の envelope(epoch N / N+1)が並行して処理されたとき、後から + // 完了した古い epoch が新しい CEK と pin を巻き戻せてしまう。 + // CAS が失敗した = 別の処理が先に同じかより新しい世代へ進めた、なので + // こちらの(古い)CEK は書かない。 let new_pin = monas_content::infrastructure::sender_key_pin_store::SenderKeyPin { sender_public_key: effective_sender_public_key, key_epoch: input.key_envelope.key_epoch, }; - let should_save_pin = match &pinned { + let should_advance_pin = match &pinned { None => true, Some(pin) => input.key_envelope.key_epoch > pin.key_epoch, }; - if should_save_pin { - if let Err(e) = self.sender_pin_store.save(content_id.as_str(), &new_pin) { + + let advanced = if should_advance_pin { + match self.sender_pin_store.compare_and_save( + content_id.as_str(), + pinned.as_ref(), + &new_pin, + ) { + Ok(advanced) => advanced, + Err(e) => { + return ApiResponse::error( + ApiError::Internal(format!( + "decrypted the shared content but failed to persist the sender key pin \ + for {}: {e}. Re-process the KeyEnvelope.", + content_id.as_str() + )), + trace_id, + ); + } + } + } else { + // 同一世代の再処理。pin は動かさないが、CEK が未保存のケース + // (前回 pin だけ書けて CEK 保存に失敗した等)を回復できるよう + // 下で CEK は書く。 + true + }; + + // CEK は受信者デバイスのローカルストアに留まり、ネットワークには出ない。 + // これで share 受信者も state node 経由の検証付き read で復号できる。 + if advanced { + if let Err(e) = self.content_service.cek_store.save(&content_id, &cek) { + // 保存に失敗したまま成功を返すと、呼び出し側は「以後この端末で + // 検証付き read ができる」と信じるのに実際は MissingKey で失敗する。 + // silent degradation を避けるためエラーとして返す(再処理可能)。 return ApiResponse::error( ApiError::Internal(format!( - "decrypted the shared content but failed to persist the sender key pin \ - for {}: {e}. Re-process the KeyEnvelope.", + "decrypted the shared content but failed to persist its CEK for {}: {e}. \ + Re-process the KeyEnvelope to enable state-node reads on this device.", content_id.as_str() )), trace_id, From 00b6afc1dc35452418c20e42c1ca92caea1cc01c Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 15:09:36 +0900 Subject: [PATCH 30/48] docs(design): correct content encryption to AES-256-GCM (matches implementation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装は AES-256-CTR から AES-256-GCM(AEAD)へ移行済みだが、design.md の コンポーネント表と DDD レイヤー記述が CTR のままだった。暗号文の保存形式 (nonce || ciphertext || tag)も含めて実態に合わせる。 Co-Authored-By: Claude Fable 5 --- docs/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design.md b/docs/design.md index 4c74b99..d17caea 100644 --- a/docs/design.md +++ b/docs/design.md @@ -157,7 +157,7 @@ presentation/ Axum HTTP API (port: 4002) | 機能 | 実装 | |------|------| -| コンテンツ暗号化 | AES-256-CTR(IVランダム生成) | +| コンテンツ暗号化 | AES-256-GCM(AEAD、12バイトランダムnonce。保存形式は `nonce \|\| ciphertext \|\| tag`) | | 鍵生成・管理 | CEK(Content Encryption Key)の生成・保存・削除 | | コンテンツアドレッシング | SHA-256によるCID生成 | | 鍵共有 | HPKE(RFC 9180、DH-KEM P-256)によるCEKのラップ | @@ -169,7 +169,7 @@ presentation/ Axum HTTP API (port: 4002) domain/ Content, ContentId, Share, Permission, KeyEnvelope application/ ContentService(CRUD + fetch + reencrypt) ShareService(grant, revoke, unwrap_cek) -infrastructure/ AES-256-CTR, HPKE, Sled, monas-filesync +infrastructure/ AES-256-GCM, HPKE, Sled, monas-filesync presentation/ Axum HTTP API (port: 4001) ``` From 671b0cc3c98bf824612f4dfc57ff5f33486eed72 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 16:52:41 +0900 Subject: [PATCH 31/48] docs(design): scope the KeyEnvelope sender-authentication claim to post-pin envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー指摘: design.md が「平文を知る第三者による CEK 上書き破壊を防ぐ」と 書いていたが、初回(ピン未設定)は呼び出し側の鍵をそのまま検証鍵に使う TOFU であり、まさにその攻撃が成立する。防げているのは 2 通目以降の置換であって、 主張の射程が実装より広かった。 - 「2 通目以降は防ぐ」「初回は送信者認証ではない」と書き分ける - 初回鍵の owner 束縛は版の真正性と同じ trust anchor の課題として別 issue で 追跡することを明記 - share.rs の該当箇所にも同じ限界をコメントで残す Co-Authored-By: Claude Fable 5 --- docs/design.md | 4 +++- monas-sdk/src/controller/share.rs | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/design.md b/docs/design.md index d17caea..cd177eb 100644 --- a/docs/design.md +++ b/docs/design.md @@ -364,7 +364,9 @@ member証明(ownerがmemberを認証するトークン)は採用しない。 share受信者はKeyEnvelopeの復号成功時にunwrap済みCEKを自デバイスのローカルストアへ保存し、以後は自身もstate-nodeからの検証付きreadで復号できる。CEK・平文がデバイス外に出ることはない(state-nodeは常に暗号文のみを扱う)。 -**KeyEnvelopeは送信者認証付き(HPKE Authモード)でラップされる。** 送信者の秘密鍵がwrap計算に混ざり、受信者は送信者の公開鍵を使ってunwrapする — 送信者が本物でなければ復号自体が失敗するため、別途の署名は不要。受信者は最初にunwrapに成功した送信者公開鍵をcontentごとにピン留めし(TOFU)、以後のenvelopeはピン済みの鍵でのみ検証する。これにより、平文を知る第三者が整合するenvelopeを鋳造して受信者の保存CEKを上書き破壊する攻撃を防ぐ。 +**KeyEnvelopeは送信者認証付き(HPKE Authモード)でラップされる。** 送信者の秘密鍵がwrap計算に混ざり、受信者は送信者の公開鍵を使ってunwrapする — 送信者が本物でなければ復号自体が失敗するため、別途の署名は不要。受信者は最初にunwrapに成功した送信者公開鍵をcontentごとにピン留めし(TOFU)、以後のenvelopeはピン済みの鍵でのみ検証する。これにより、**2通目以降**については、平文を知る第三者が自分の鍵で整合するenvelopeを鋳造して受信者の保存CEKを上書き破壊する攻撃を防げる。 + +ただし**初回は送信者認証ではない**。ピンが無い時点では呼び出し側が渡した公開鍵をそのまま検証鍵に使うため、HPKE Authが証明するのは「その鍵の持ち主がこのenvelopeを作った」ことだけで、「contentのownerが作った」ことではない。平文を知る第三者が自分の鍵で整合するenvelopeを作り、正規のenvelopeより先にピンを取ることは現状防げない(以後、正規のenvelopeがピン不一致で弾かれる)。初回の送信者鍵をowner identityまたは検証済み委譲トークンのissuerへ束縛する修正は、版の真正性と同じくownerを信頼の根に置く話であり、別issueで追跡する。 wrapのAADには `(content_id, recipient_key_id, key_epoch)` が束縛され、いずれかを書き換えたenvelopeは復号に失敗する。`key_epoch` はCEKの鍵世代(rotationごとに+1)で、受信者は記録済み世代より古いenvelopeを拒否する — rotation前の正規envelopeを再送して保存CEKを旧世代へ巻き戻すreplay攻撃はこれで防がれる。 diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index b67feaf..81e88f9 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -660,6 +660,12 @@ impl MonasController { // 4. 送信者鍵ピン(TOFU)と鍵世代の検証。 // unwrap に使う鍵は「入力された鍵」ではなく「ピン済みの鍵」を優先する: // ピンがある限り、呼び出し側が違う鍵を渡しても検証の根は動かない。 + // + // NOTE: 初回(ピン未設定)は呼び出し側の鍵をそのまま信頼する TOFU で + // あり、送信者認証ではない。HPKE Auth が示すのは「その鍵の持ち主が + // 作った」ことだけで、「owner が作った」ことではないため、平文を知る + // 第三者が正規 envelope より先にピンを取れる。初回鍵を owner identity + // へ束縛する修正は trust anchor の課題として別 issue で追跡する。 let pinned = match self.sender_pin_store.load(content_id.as_str()) { Ok(p) => p, Err(e) => { From e59cce2158e6005db0055de661619a5b030a0e76 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 17:15:59 +0900 Subject: [PATCH 32/48] test(state-node): use explicit timestamp in merged genesis-only replica test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #54 の fail-closed テストを取り込む際、#56 の timestamp 構造的必須化と 噛み合うよう明示的な timestamp を渡す。 Co-Authored-By: Claude Fable 5 --- monas-state-node/src/application_service/state_node_service.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index ab0aa5f..61ceafa 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -3209,7 +3209,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-genesis-only", ) .await; From ebb3f821d1fa0eed42c000e4e299f9f5bfbc3790 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 18:19:28 +0900 Subject: [PATCH 33/48] Revert "docs(design): correct content encryption to AES-256-GCM (matches implementation)" This reverts commit 00b6afc1dc35452418c20e42c1ca92caea1cc01c. --- docs/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design.md b/docs/design.md index cd177eb..4df7838 100644 --- a/docs/design.md +++ b/docs/design.md @@ -157,7 +157,7 @@ presentation/ Axum HTTP API (port: 4002) | 機能 | 実装 | |------|------| -| コンテンツ暗号化 | AES-256-GCM(AEAD、12バイトランダムnonce。保存形式は `nonce \|\| ciphertext \|\| tag`) | +| コンテンツ暗号化 | AES-256-CTR(IVランダム生成) | | 鍵生成・管理 | CEK(Content Encryption Key)の生成・保存・削除 | | コンテンツアドレッシング | SHA-256によるCID生成 | | 鍵共有 | HPKE(RFC 9180、DH-KEM P-256)によるCEKのラップ | @@ -169,7 +169,7 @@ presentation/ Axum HTTP API (port: 4002) domain/ Content, ContentId, Share, Permission, KeyEnvelope application/ ContentService(CRUD + fetch + reencrypt) ShareService(grant, revoke, unwrap_cek) -infrastructure/ AES-256-GCM, HPKE, Sled, monas-filesync +infrastructure/ AES-256-CTR, HPKE, Sled, monas-filesync presentation/ Axum HTTP API (port: 4001) ``` From 436867f85f34df79d435c6106b922f1a08b40ea4 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 19:05:59 +0900 Subject: [PATCH 34/48] fix(state-node): bind add-members count to the request signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `count` arrives in the HTTP body and decides how many nodes are added to a content network, but `add_member_to_content` passed `request_body = None` to `verify_caller_signature`. The same token, signature and timestamp could therefore be replayed with a different `count`. It is clamped to `max_add_member_count`, but raising `1` to the clamp is still a mutation the caller never authorized — a direct counterexample to "every mutation body is bound to the signature". The raw JSON body is not signable as-is (whitespace and key order vary between clients, producing different digests for the same request), so both sides derive the same canonical bytes from the parsed value via `add_members_signing_body`. The digest goes through the existing `signing_message_with_body_digest` path, so the wire format is unchanged. The clamp still happens after verification, which is correct: the signature covers the pre-clamp value the caller actually sent. test-auth-generator gains `--add-members-count` so scripts can produce the matching signature, and its doc comment is updated — it still described the pre-`monas-request-v1` message format. Tests: canonical encoding is distinct per count; a count=1 signature is rejected for count in {0, 2, 8, 1000} and for a body-less manage message; the shell harness asserts HTTP 401 on count substitution. --- monas-state-node/scripts/test-with-auth.sh | 28 ++++++++- .../application_service/state_node_service.rs | 7 ++- .../src/bin/test_auth_generator.rs | 37 +++++++++-- .../auth/monas_account_adapter.rs | 63 +++++++++++++++++++ monas-state-node/src/port/auth_token.rs | 32 ++++++++++ 5 files changed, 159 insertions(+), 8 deletions(-) diff --git a/monas-state-node/scripts/test-with-auth.sh b/monas-state-node/scripts/test-with-auth.sh index 4acd847..f279793 100755 --- a/monas-state-node/scripts/test-with-auth.sh +++ b/monas-state-node/scripts/test-with-auth.sh @@ -67,11 +67,15 @@ generate_signature() { local operation="$2" local resource="$3" local body_b64="$4" + local add_members_count="$5" local sign_args="sign-request --private-key $private_key --operation $operation --resource $resource" if [ -n "$body_b64" ]; then sign_args="$sign_args --body $body_b64" fi + if [ -n "$add_members_count" ]; then + sign_args="$sign_args --add-members-count $add_members_count" + fi local output output=$($AUTH_GEN $sign_args 2>/dev/null) @@ -235,7 +239,8 @@ if [ -n "$CONTENT_ID" ]; then fi # メンバー追加(count形式) - generate_signature "$TEST_PRIVATE_KEY" "manage" "$CONTENT_ID" + # count は署名対象。body を差し替えると署名検証で落ちる + generate_signature "$TEST_PRIVATE_KEY" "manage" "$CONTENT_ID" "" "1" log_test "コンテンツネットワークにメンバーを追加" MEMBER_RESPONSE=$(curl -s -X POST "$BASE_URL/content/$CONTENT_ID/members" \ @@ -260,6 +265,27 @@ if [ -n "$CONTENT_ID" ]; then echo "$MEMBER_BODY" fi + # count 差し替え: count=1 の署名で count=8 を送る + generate_signature "$TEST_PRIVATE_KEY" "manage" "$CONTENT_ID" "" "1" + + log_test "メンバー追加のcount差し替えを拒否" + TAMPER_RESPONSE=$(curl -s -X POST "$BASE_URL/content/$CONTENT_ID/members" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TEST_KEY_ID" \ + -H "X-Request-Signature: $LAST_SIGNATURE" \ + -H "X-Request-Timestamp: $LAST_TIMESTAMP" \ + -d '{"count": 8}' \ + -w "\n%{http_code}" 2>/dev/null) + TAMPER_STATUS=$(echo "$TAMPER_RESPONSE" | tail -n1) + if [ "$TAMPER_STATUS" = "401" ]; then + log_success "count差し替えを拒否 (HTTP 401)" + ((TESTS_PASSED++)) + else + log_fail "count差し替えを拒否 (期待: HTTP 401, 実際: HTTP $TAMPER_STATUS)" + ((TESTS_FAILED++)) + echo "$TAMPER_RESPONSE" | sed '$d' + fi + echo "" echo -e "${BLUE}=== CRDT操作テスト ===${NC}" echo "" diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 61ceafa..a6e7a2f 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -1626,7 +1626,10 @@ where .await .map_err(|e| StateNodeError::AuthenticationFailed(e.to_string()))?; - // Verify request signature + // Verify request signature. `count` comes from the HTTP body and + // decides how many members get added, so it is signed too — see + // `add_members_signing_body` for why the canonical encoding is used + // instead of the raw JSON bytes. self.verify_caller_signature( auth_service.as_ref(), token, @@ -1634,7 +1637,7 @@ where "manage", content_id, timestamp, - None, + Some(&crate::port::auth_token::add_members_signing_body(count)), ) .await?; diff --git a/monas-state-node/src/bin/test_auth_generator.rs b/monas-state-node/src/bin/test_auth_generator.rs index 782d0ba..61407b8 100644 --- a/monas-state-node/src/bin/test_auth_generator.rs +++ b/monas-state-node/src/bin/test_auth_generator.rs @@ -2,6 +2,7 @@ use base64::{ engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine as _, }; +use monas_state_node::port::auth_token::add_members_signing_body; use p256::ecdsa::{signature::Signer, SigningKey}; use p256::elliptic_curve::rand_core::OsRng; use serde_json::json; @@ -61,6 +62,7 @@ fn print_usage(program: &str) { eprintln!(" --resource Resource (content_id or 'content')"); eprintln!(" --timestamp Unix timestamp"); eprintln!(" [--body ] Request body (base64, for create/update)"); + eprintln!(" [--add-members-count ] Canonical add-members body (for manage)"); eprintln!(" (delegated JWT requests sign the same message with the recipient key)"); eprintln!(" generate-token [content_id] - Generate an auth token (JWT)"); eprintln!(" generate-share-token - Generate a share token for another user"); @@ -95,17 +97,27 @@ fn generate_test_auth_data() { /// Sign a request with the correct message format. /// -/// For requests WITH body (create/update): -/// message = hex(sha256(body_bytes + timestamp_be_bytes)) +/// Every request — with or without a body, JWT or not — signs the same +/// structure, which always commits to operation, resource and timestamp: /// -/// For requests WITHOUT body (delete/read/invalidate/manage/revoke): -/// message = "{operation}:{resource}:{timestamp}" +/// ```text +/// monas-request-v1::::::: +/// ``` +/// +/// `body_digest_hex` is `sha256(body_bytes)` when `--body` is given and the +/// empty string otherwise. Must stay in sync with +/// `RequestMetadata::signing_message_with_body_digest`. +/// +/// `--add-members-count` is a convenience for the `manage` operation: it +/// derives the same canonical body bytes the state node reconstructs from the +/// parsed JSON (see `add_members_signing_body`). fn sign_request(args: &[String]) { let mut private_key_hex = String::new(); let mut operation = String::new(); let mut resource = String::new(); let mut timestamp_str = String::new(); let mut body_b64 = String::new(); + let mut add_members_count: Option = None; let mut i = 0; while i < args.len() { @@ -140,6 +152,15 @@ fn sign_request(args: &[String]) { body_b64 = args[i].clone(); } } + "--add-members-count" => { + i += 1; + if i < args.len() { + add_members_count = Some(args[i].parse().unwrap_or_else(|e| { + eprintln!("Error: Invalid --add-members-count: {}", e); + std::process::exit(1); + })); + } + } _ => {} } i += 1; @@ -176,7 +197,13 @@ fn sign_request(args: &[String]) { // token type and for body / non-body requests: it always commits to // operation, resource and timestamp, plus the body digest when present. // Must stay in sync with `RequestMetadata::signing_message_with_body_digest`. - let body_digest_hex = if body_b64.is_empty() { + if !body_b64.is_empty() && add_members_count.is_some() { + eprintln!("Error: --body and --add-members-count are mutually exclusive"); + std::process::exit(1); + } + let body_digest_hex = if let Some(count) = add_members_count { + hex::encode(Sha256::digest(add_members_signing_body(count))) + } else if body_b64.is_empty() { String::new() } else { let body_bytes = STANDARD.decode(&body_b64).unwrap_or_else(|e| { diff --git a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs index da8cb86..60b9dd5 100644 --- a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs @@ -620,6 +620,69 @@ mod tests { .is_err()); } + /// add-members の `count` は HTTP body 由来で、実際に追加される member 数を + /// 決める。署名対象に入っていないと、同じ token・署名・timestamp のまま + /// count だけ差し替えられる(上限で clamp されるが 1 → 上限への改ざんは成立 + /// してしまう)。canonical encoding した body が署名へ束縛されることを確認する。 + #[tokio::test] + async fn test_add_members_count_cannot_be_substituted() { + use crate::port::auth_token::{add_members_signing_body, RequestMetadata}; + use p256::ecdsa::signature::Signer; + use sha2::Digest; + + let (adapter, signing_key, key_id) = create_test_adapter(); + let token = AuthToken::new(key_id); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let metadata = RequestMetadata { + timestamp: ts, + operation: "manage".to_string(), + resource: "content-1".to_string(), + }; + let digest_for = + |count: usize| hex::encode(sha2::Sha256::digest(add_members_signing_body(count))); + + // caller は count=1 に対して署名する + let signed_message = metadata.signing_message_with_body_digest(&digest_for(1)); + let signature: p256::ecdsa::Signature = signing_key.sign(signed_message.as_bytes()); + let signature_bytes = signature.to_vec(); + + assert!(adapter + .verify_request_signature(&token, &signature_bytes, &signed_message, Some(ts)) + .await + .is_ok()); + + // body の count を差し替えた request は検証で落ちる + for tampered in [0usize, 2, 8, 1000] { + assert!( + adapter + .verify_request_signature( + &token, + &signature_bytes, + &metadata.signing_message_with_body_digest(&digest_for(tampered)), + Some(ts), + ) + .await + .is_err(), + "count={tampered} への差し替えが通ってしまった" + ); + } + + // body なしの manage 署名としても転用できない + assert!(adapter + .verify_request_signature( + &token, + &signature_bytes, + &metadata.signing_message(), + Some(ts) + ) + .await + .is_err()); + } + #[tokio::test] async fn test_verify_request_signature_expired_timestamp() { let (adapter, signing_key, key_id) = create_test_adapter(); diff --git a/monas-state-node/src/port/auth_token.rs b/monas-state-node/src/port/auth_token.rs index befce24..8286f43 100644 --- a/monas-state-node/src/port/auth_token.rs +++ b/monas-state-node/src/port/auth_token.rs @@ -95,6 +95,22 @@ impl RequestMetadata { } } +/// Canonical byte encoding of the `add-members` request body for signing. +/// +/// `count` controls how many nodes get added to a content network, so it must +/// be covered by the request signature — otherwise the same token, signature +/// and timestamp can be replayed with a different `count` (it is clamped to +/// `max_add_member_count`, but raising `1` to the clamp is still a mutation the +/// caller never authorized). +/// +/// The HTTP body itself is not signable as-is: it is JSON, so whitespace and +/// key order vary between clients producing different digests for the same +/// request. Instead both sides derive the same canonical bytes from the parsed +/// value. Keep the tag so a future field cannot collide with this encoding. +pub fn add_members_signing_body(count: usize) -> Vec { + format!("add-members:count={count}").into_bytes() +} + impl AuthToken { /// Create a new authentication token pub fn new(raw: String) -> Self { @@ -231,6 +247,22 @@ mod tests { assert_ne!(signed, update_c1.signing_message()); } + /// count ごとに異なるバイト列になること。ここが衝突すると、 + /// ある count 用の署名を別の count へ転用できてしまう。 + #[test] + fn add_members_signing_body_is_distinct_per_count() { + let bodies: Vec> = [0usize, 1, 2, 10, 100] + .iter() + .map(|c| add_members_signing_body(*c)) + .collect(); + for (i, a) in bodies.iter().enumerate() { + for b in bodies.iter().skip(i + 1) { + assert_ne!(a, b); + } + } + assert_eq!(add_members_signing_body(3), b"add-members:count=3".to_vec()); + } + /// 長さ前置により、区切り文字を含む値でもフィールド境界がずれない。 #[test] fn signing_message_is_unambiguous_with_colons() { From 5a6cde995f377ffba8bbb8e34d0cb89e1efb5350 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 19:16:50 +0900 Subject: [PATCH 35/48] fix(sdk): invalidate previously issued tokens as part of revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/design.md` defines revoke as advancing the state node's `min_valid_issued_at` so every previously issued Token is invalidated. The SDK's `revoke_share` never called that endpoint — it rotated the CEK, updated the ACL and pushed the re-encrypted ciphertext, and stopped there. CEK rotation only stops the revoked recipient from *decrypting*. Their delegated write Token stayed valid until TTL expiry, so they could keep writing to the new state. That is the opposite of what revoke is supposed to mean. Invalidation runs *before* the rotation. The reverse order leaves a window between re-encryption and invalidation in which the revoked party can still write. Going first means a later failure only leaves an extra invalidation behind, whose sole cost — remaining recipients need fresh tokens — is already required by the CEK rotation itself. Nothing local has been touched at that point, so an invalidation failure aborts the whole revoke with the existing share left intact rather than half-applied. Since `min_valid_issued_at` is a timestamp-based bulk revocation, it also invalidates the surviving recipients' tokens. `RevokeShareOutput` therefore reports `token_invalidated_at` alongside `reissued_envelopes`, so the caller knows to reissue tokens dated after that instant. The design doc's revoke diagram is corrected to the implemented order and now states why the ordering matters and that surviving recipients need both a new envelope and a new token. Tests: revoke calls the invalidate endpoint with a signed request and reports the returned `min_valid_issued_at`; a failing invalidation aborts revoke without sending the ciphertext update and leaves the existing share decryptable. Both fail if the invalidation call is removed. --- docs/design.md | 17 +- monas-sdk/src/controller/content.rs | 76 ++++- monas-sdk/src/controller/share.rs | 53 +++- monas-sdk/src/models/share.rs | 26 ++ monas-sdk/src/models/state_node.rs | 10 + .../share_controller_integration_test.rs | 283 +++++++++++++++++- 6 files changed, 450 insertions(+), 15 deletions(-) diff --git a/docs/design.md b/docs/design.md index cd177eb..bc674b2 100644 --- a/docs/design.md +++ b/docs/design.md @@ -246,15 +246,22 @@ flowchart TD ### アクセス取り消し +取り消しは2つの独立した権限を同時に断つ必要がある。**復号できること**(CEK を持っていること)と、**書き込めること**(有効な委譲 Token を持っていること)である。CEKのローテーションは前者しか止めない。後者を止めるにはstate-nodeの`min_valid_issued_at`を進めて既発行Tokenを一括失効させる。 ```mermaid flowchart TD - A([取り消し開始]) --> B[新CEK生成 / コンテンツを再暗号化] - B --> C[旧KeyEnvelopeが無効化される] - B --> D[継続ユーザーに新KeyEnvelope発行] - B --> E[state-nodeのmin_valid_issued_atを更新] - E --> F([旧Token一括失効]) + A([取り消し開始]) --> B[state-nodeのmin_valid_issued_atを更新] + B --> C([旧Token一括失効]) + B --> D[新CEK生成 / コンテンツを再暗号化] + D --> E[旧KeyEnvelopeが無効化される] + D --> F[継続ユーザーに新KeyEnvelope発行] + D --> G[再暗号化後のciphertextをstate-nodeへ送信] ``` + +失効を先に行うのは、逆順だと「再暗号化してから失効するまでの窓」で取り消し済みの相手が書き込めてしまうためである。先に失効させておけば、後段が失敗してローカル状態を巻き戻しても、余分な失効が残るだけで害はない。 + +`min_valid_issued_at`は時刻ベースの一括失効なので、**残存する受信者のTokenも巻き添えで失効する**。呼び出し側は取り消し後に、残存受信者へ新しいKeyEnvelopeと新しいTokenの両方を配り直す必要がある。SDKは`RevokeShareOutput`で再発行KeyEnvelope(`reissued_envelopes`)と失効時刻(`token_invalidated_at`)の両方を返す。 + --- ## 8. CIDによるコンテンツアドレッシング diff --git a/monas-sdk/src/controller/content.rs b/monas-sdk/src/controller/content.rs index 5537f22..56fa335 100644 --- a/monas-sdk/src/controller/content.rs +++ b/monas-sdk/src/controller/content.rs @@ -12,7 +12,8 @@ use crate::models::content::{ }; use crate::models::state_node::{ StateNodeCreateContentRequest, StateNodeCreateContentResponse, StateNodeDeleteContentResponse, - StateNodeErrorResponse, StateNodeUpdateContentRequest, StateNodeUpdateContentResponse, + StateNodeErrorResponse, StateNodeInvalidateTokensResponse, StateNodeUpdateContentRequest, + StateNodeUpdateContentResponse, }; use monas_content::application_service::content_service::{ @@ -696,6 +697,79 @@ impl MonasController { } } + /// State Node に `POST /content/:id/access/invalidate` を送る + /// (`http_api::invalidate_tokens_handler` と同じ契約)。 + /// + /// `min_valid_issued_at` をサーバ時刻へ進め、それ以前に発行された委譲 Token を + /// 一括失効させる。revoke で CEK をローテーションしても、取り消された受信者の + /// 委譲 write Token は TTL 満了まで有効なまま残るため、これを呼ばないと + /// 「取り消したはずの相手が新しい状態へ書き込み続けられる」。 + /// + /// 戻り値は成功時の `new_min_valid_issued_at`。失敗時は `Err` に + /// `ApiResponse` を入れて返す(`send_*_to_state_node` 系と違い、 + /// 呼び出し側で必ず結果を扱わせるため)。 + pub(super) fn send_invalidate_to_state_node( + &self, + content_id: &str, + auth: Option<&StateNodeAuthContext>, + trace_id: String, + ) -> Result, ApiResponse> { + let state_node_url = format!( + "{}/content/{}/access/invalidate", + self.state_node_url, content_id + ); + let signed_auth = + self.prepare_state_node_metadata_auth(auth, "invalidate", content_id, &trace_id)?; + let req = Self::attach_state_node_auth( + self.agent + .post(&state_node_url) + .header("Content-Type", "application/json"), + signed_auth.as_ref(), + ); + + let resp = match req.send("") { + Ok(r) => r, + Err(e) => { + return Err(ApiResponse::error( + ApiError::from_ureq_error( + "Failed to send token invalidation request to State Node", + e, + ), + trace_id, + )); + } + }; + + let status = resp.status().as_u16(); + let body = match resp.into_body().read_to_string() { + Ok(s) => s, + Err(e) => { + return Err(ApiResponse::error( + ApiError::Internal(format!("Failed to read State Node response body: {e}")), + trace_id, + )); + } + }; + + if let Some(err) = Self::try_state_node_http_error(status, &body, trace_id.clone()) { + return Err(err); + } + + if body.trim().is_empty() { + return Ok(None); + } + + match serde_json::from_str::(&body) { + Ok(parsed) => Ok(Some(parsed.new_min_valid_issued_at)), + Err(e) => Err(ApiResponse::error( + ApiError::Internal(format!( + "Invalid State Node token invalidation response JSON: {e}" + )), + trace_id, + )), + } + } + /// State Node に `DELETE /content/:id` を送る(`http_api::delete_content` と同じ契約)。 fn send_delete_to_state_node( &self, diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index 81e88f9..045a41b 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -423,9 +423,17 @@ impl MonasController { /// 1. 入力のバリデーション /// 2. ContentIdに変換 /// 3. 共有先の公開鍵をデコードしてrecipient_key_idを計算 - /// 4. ShareService::revoke_shareを呼び出し(ACLの更新) - /// 5. State Node に更新を送信 - /// 6. 結果を返却 + /// 4. State Node の `min_valid_issued_at` を進めて既発行 Token を一括失効 + /// 5. CEK をローテーションして再暗号化 + /// 6. ShareService::revoke_shareを呼び出し(ACL 更新 + 残存受信者向け envelope 再発行) + /// 7. State Node に再暗号化後の ciphertext を送信 + /// 8. 結果を返却 + /// + /// 4 が無いと、取り消した相手の委譲 write Token が TTL 満了まで有効なまま残る + /// (CEK ローテーションは復号を止めるだけで、書き込み権限は止めない)。 + /// + /// 失効は残存受信者の Token も巻き添えにする。呼び出し側は + /// `RevokeShareOutput::token_invalidated_at` より後に Token を再発行すること。 pub fn revoke_share( &self, input: RevokeShareInput, @@ -477,6 +485,38 @@ impl MonasController { let recipient_key_id = Self::compute_key_id_from_public_key(&recipient_public_key_bytes); + // 3.5. 先に state node の `min_valid_issued_at` を進めて、既発行の委譲 Token を + // 一括失効させる。CEK ローテーションだけでは「取り消した相手が持っている + // write Token」は TTL 満了まで生きたままで、新しい状態へ書き込み続けられる + // (docs/design.md「アクセス取り消し」の定義との食い違い)。 + // + // 順序が invalidate → rotate である理由: 逆にすると、rotate から + // invalidate までの窓で取り消し済みの相手が書き込める。先に失効させて + // おけば、後段が失敗してローカルを巻き戻しても余分な失効が残るだけで、 + // 害は「残存受信者が Token を再発行してもらう必要がある」ことに留まる + // (これは CEK ローテーション時にどのみち必要になる)。 + // + // state node 連携なし(`auth` が None、ローカル専用テスト等)の場合は + // 失効させる対象の Token も存在しないので何もしない。 + let state_node_content_id = input + .remote_content_id + .as_deref() + .unwrap_or(&input.content_id) + .to_string(); + let token_invalidated_at = if auth.is_some() { + match self.send_invalidate_to_state_node::( + &state_node_content_id, + auth, + trace_id.clone(), + ) { + Ok(v) => v, + // ここはまだローカル状態を一切変更していないので巻き戻し不要。 + Err(response) => return response, + } + } else { + None + }; + // 4. まず CEK をローテーションして再暗号化する。 // ShareService::revoke_share は「その時点の CEK・ciphertext」で残存受信者向け // KeyEnvelope を再発行するため、**reencrypt が先**でないと旧 CEK の envelope を @@ -545,12 +585,8 @@ impl MonasController { // State Node は系列ID(remote_content_id)でコンテンツを管理する。 // ローカル版IDしか送らないと State Node 側で未知のコンテンツ扱いになる。 - let state_node_content_id = input - .remote_content_id - .as_deref() - .unwrap_or(&input.content_id); if let Some(response) = self.send_update_to_state_node( - state_node_content_id, + &state_node_content_id, &reencryption.encrypted_content, auth, trace_id.clone(), @@ -591,6 +627,7 @@ impl MonasController { revoked: true, revoked_at: Some(Utc::now().to_rfc3339()), reissued_envelopes, + token_invalidated_at, }; ApiResponse::success(output, trace_id) diff --git a/monas-sdk/src/models/share.rs b/monas-sdk/src/models/share.rs index 9f6f4a1..4fa4916 100644 --- a/monas-sdk/src/models/share.rs +++ b/monas-sdk/src/models/share.rs @@ -115,6 +115,14 @@ pub struct RevokeShareOutput { /// 新しいものへ更新される(state node 経由の read が引き続き復号できる)。 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub reissued_envelopes: Vec, + /// state node が設定した新しい `min_valid_issued_at`(Unix 秒)。 + /// これより前に発行された委譲 Token はすべて失効している。 + /// state node 連携なしで実行した場合は `None`。 + /// + /// CEK ローテーションと違い、これは「取り消した相手がまだ書き込めるか」を + /// 決める。残存受信者には、この時刻より後に発行した Token を配り直す必要がある。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_invalidated_at: Option, } /// revoke 後に残存受信者向けへ再発行された KeyEnvelope。 @@ -242,11 +250,28 @@ mod tests { revoked: true, revoked_at: Some("2025-12-05T12:34:56Z".into()), reissued_envelopes: vec![], + token_invalidated_at: None, }; let json = serde_json::to_string(&output).unwrap(); assert!(json.contains("\"revoked\":true")); // 空の envelope リストは serialize されない(後方互換) assert!(!json.contains("reissued_envelopes")); + // state node 連携なしなら失効時刻も出さない + assert!(!json.contains("token_invalidated_at")); + } + + #[test] + fn test_revoke_share_output_reports_token_invalidation() { + let output = RevokeShareOutput { + content_id: "test_id".into(), + recipient_public_key: "recipient_key".into(), + revoked: true, + revoked_at: None, + reissued_envelopes: vec![], + token_invalidated_at: Some(1_700_000_000), + }; + let json = serde_json::to_string(&output).unwrap(); + assert!(json.contains("\"token_invalidated_at\":1700000000")); } #[test] @@ -265,6 +290,7 @@ mod tests { key_epoch: 1, }, }], + token_invalidated_at: None, }; let json = serde_json::to_string(&output).unwrap(); assert!(json.contains("\"reissued_envelopes\"")); diff --git a/monas-sdk/src/models/state_node.rs b/monas-sdk/src/models/state_node.rs index eabb0f4..bfbebf9 100644 --- a/monas-sdk/src/models/state_node.rs +++ b/monas-sdk/src/models/state_node.rs @@ -39,6 +39,16 @@ pub struct StateNodeDeleteContentResponse { pub deleted: bool, } +/// State Nodeからのトークン失効レスポンス(`POST /content/:id/access/invalidate`) +#[derive(Debug, Deserialize)] +pub struct StateNodeInvalidateTokensResponse { + #[serde(default)] + pub content_id: String, + /// この時刻より前に発行されたTokenはすべて無効になる + #[serde(default)] + pub new_min_valid_issued_at: u64, +} + /// State Nodeからのコンテンツ履歴レスポンス #[derive(Debug, Deserialize)] pub struct StateNodeContentHistoryResponse { diff --git a/monas-sdk/tests/share_controller_integration_test.rs b/monas-sdk/tests/share_controller_integration_test.rs index 10d19e9..c968c40 100644 --- a/monas-sdk/tests/share_controller_integration_test.rs +++ b/monas-sdk/tests/share_controller_integration_test.rs @@ -8,11 +8,27 @@ use monas_sdk::models::keypair::{GenerateKeypairInput, KeyType}; use monas_sdk::models::share::{ DecryptSharedContentInput, Permission, RevokeShareInput, ShareContentInput, }; -use monas_sdk::MonasController; +use monas_sdk::{MonasConfig, MonasController, StateNodeAuthContext}; +use std::time::Duration; mod support; use support::{acquire_test_lock, cleanup_content_artifacts}; +/// テストの固定 timestamp をそのまま使えるよう、skew 許容を十分広げた controller。 +fn controller_with_wide_skew(state_node_url: String, account_url: String) -> MonasController { + let config = MonasConfig::new(state_node_url, account_url) + .with_request_timestamp_skew(Duration::from_secs(60 * 60 * 24 * 365 * 100)); + MonasController::with_config(config).expect("with_config") +} + +fn auth_context(authorization: &str) -> StateNodeAuthContext { + StateNodeAuthContext { + authorization: Some(authorization.to_string()), + request_signature: Some("caller-signature".into()), + request_timestamp: Some(1_717_171_717), + } +} + #[tokio::test(flavor = "multi_thread")] async fn share_content_succeeds_after_content_creation() { let _guard = acquire_test_lock(); @@ -554,3 +570,268 @@ async fn revoke_share_rollback_fires_on_inner_share_service_error() { cleanup_content_artifacts(); } + +/// State Node 連携ありの revoke は、CEK ローテーションの前に +/// `POST /content/:id/access/invalidate` を呼んで既発行 Token を失効させる +/// (docs/design.md「アクセス取り消し」)。これが無いと、取り消した相手の委譲 +/// write Token が TTL 満了まで有効なまま残り、新しい状態へ書き込み続けられる。 +#[tokio::test(flavor = "multi_thread")] +async fn revoke_share_invalidates_previously_issued_tokens() { + let _guard = acquire_test_lock(); + let mut state_node = Server::new_async().await; + let mut account = Server::new_async().await; + + let create_mock = state_node + .mock("POST", "/content") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"content_id":"invalidate-remote"}"#) + .create_async() + .await; + let delegate_mock = account + .mock("POST", "/issuer/delegate") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"delegated_token":"dummy.jwt.token","issued_at":1700000000,"expires_at":1700003600,"jti":"jti-invalidate"}"#, + ) + .create_async() + .await; + let invalidate_mock = state_node + .mock("POST", "/content/invalidate-remote/access/invalidate") + // 認証ヘッダは account service の署名結果で置き換わる(Authorization は + // 導出された key id になる)ので、ここでは署名済みであることだけ確認する。 + .match_header("x-request-signature", "c2lnbmVk") + .match_header("x-request-timestamp", "1717171717") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"content_id":"invalidate-remote","new_min_valid_issued_at":1700000500}"#) + .expect(1) + .create_async() + .await; + let update_mock = state_node + .mock("PUT", "/content/invalidate-remote") + .with_status(200) + .expect(1) + .create_async() + .await; + let sign_mock = account + .mock("POST", "/accounts/sign") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"signature_base64":"c2lnbmVk","public_key_base64":"BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMw==","algorithm":"P256"}"#, + ) + .expect_at_least(1) + .create_async() + .await; + + let controller = controller_with_wide_skew(state_node.url(), account.url()); + let auth = auth_context("Bearer owner"); + + let sender = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("sender keypair should be generated"); + let recipient = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("recipient keypair should be generated"); + + let created = controller + .create_content( + CreateContentInput { + content: URL_SAFE_NO_PAD.encode(b"invalidate-target"), + metadata: Some(ContentMetadata { + name: Some("invalidate.txt".to_string()), + content_type: Some("text/plain".to_string()), + created_at: None, + updated_at: None, + }), + }, + None, + ) + .data + .expect("create should return data"); + create_mock.assert(); + + assert!( + controller + .share_content(ShareContentInput { + content_id: created.content_id.clone(), + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), + recipient_public_key: recipient.public_key.clone(), + permissions: vec![Permission::Write], + }) + .success, + "share_content should succeed" + ); + delegate_mock.assert(); + + let revoke_response = controller.revoke_share( + RevokeShareInput { + content_id: created.content_id, + remote_content_id: Some("invalidate-remote".to_string()), + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), + recipient_public_key: recipient.public_key, + }, + Some(&auth), + ); + assert!( + revoke_response.success, + "revoke_share should succeed: {:?}", + revoke_response.error + ); + + invalidate_mock.assert(); + update_mock.assert(); + sign_mock.assert(); + + let output = revoke_response.data.expect("revoke should return data"); + assert_eq!( + output.token_invalidated_at, + Some(1_700_000_500), + "the new min_valid_issued_at should be reported back to the caller" + ); + + cleanup_content_artifacts(); +} + +/// 失効に失敗したら revoke 全体を失敗させる。ここで握りつぶすと +/// 「CEK はローテーションされたが Token は生きている」中途半端な状態になり、 +/// 呼び出し側はそれを知らないまま revoke 成功と受け取ってしまう。 +/// 失効はローカル状態を触る前なので、巻き戻しは不要(共有は元のまま有効)。 +#[tokio::test(flavor = "multi_thread")] +async fn revoke_share_fails_when_token_invalidation_fails() { + let _guard = acquire_test_lock(); + let mut state_node = Server::new_async().await; + let mut account = Server::new_async().await; + + let create_mock = state_node + .mock("POST", "/content") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"content_id":"invalidate-fail-remote"}"#) + .create_async() + .await; + let delegate_mock = account + .mock("POST", "/issuer/delegate") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"delegated_token":"dummy.jwt.token","issued_at":1700000000,"expires_at":1700003600,"jti":"jti-invalidate-fail"}"#, + ) + .create_async() + .await; + let invalidate_mock = state_node + .mock("POST", "/content/invalidate-fail-remote/access/invalidate") + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"error":"Authorization failed"}"#) + .expect(1) + .create_async() + .await; + // 失効が失敗した以上、再暗号化した ciphertext を送ってはいけない。 + let update_mock = state_node + .mock("PUT", "/content/invalidate-fail-remote") + .with_status(200) + .expect(0) + .create_async() + .await; + let sign_mock = account + .mock("POST", "/accounts/sign") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"signature_base64":"c2lnbmVk","public_key_base64":"BAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMw==","algorithm":"P256"}"#, + ) + .expect_at_least(1) + .create_async() + .await; + + let controller = controller_with_wide_skew(state_node.url(), account.url()); + let auth = auth_context("Bearer owner"); + + let sender = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("sender keypair should be generated"); + let recipient = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("recipient keypair should be generated"); + + let created = controller + .create_content( + CreateContentInput { + content: URL_SAFE_NO_PAD.encode(b"invalidate-fail-target"), + metadata: Some(ContentMetadata { + name: Some("invalidate-fail.txt".to_string()), + content_type: Some("text/plain".to_string()), + created_at: None, + updated_at: None, + }), + }, + None, + ) + .data + .expect("create should return data"); + create_mock.assert(); + + let shared = controller + .share_content(ShareContentInput { + content_id: created.content_id.clone(), + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), + recipient_public_key: recipient.public_key.clone(), + permissions: vec![Permission::Read], + }) + .data + .expect("share should return data"); + delegate_mock.assert(); + + let revoke_response = controller.revoke_share( + RevokeShareInput { + content_id: created.content_id.clone(), + remote_content_id: Some("invalidate-fail-remote".to_string()), + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), + recipient_public_key: recipient.public_key.clone(), + }, + Some(&auth), + ); + assert!( + !revoke_response.success, + "revoke_share should fail when token invalidation fails" + ); + invalidate_mock.assert(); + update_mock.assert(); + sign_mock.assert(); + + // ローカル状態は一切触っていないので、元の共有はそのまま復号できる。 + let get_shared = controller.decrypt_shared_content(DecryptSharedContentInput { + content_id: created.content_id.clone(), + private_key: recipient.private_key.clone(), + sender_public_key: shared.sender_public_key.clone(), + recipient_key_id: shared.recipient_key_id.clone(), + key_envelope: shared.key_envelope.clone(), + version: None, + }); + assert!( + get_shared.success, + "the existing share must remain usable when revoke aborts early: {:?}", + get_shared.error + ); + + cleanup_content_artifacts(); +} From 7551af4efd3fec312a185afb9e3c3875eb36ba1e Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 19:25:50 +0900 Subject: [PATCH 36/48] fix(sdk): commit sender key, epoch and CEK as one atomic record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invariant that matters is that the sender key, the key epoch and the CEK stay consistent with each other — not that the epoch number alone moves forward. Keeping them in two stores with two commits breaks that even with the epoch under CAS: 1. the epoch-N handler reads the pin (epoch N-1) 2. the epoch-N+1 handler advances the pin and saves the new CEK 3. the epoch-N handler takes the "same generation, re-process" branch, skips the CAS, and writes its older CEK unconditionally 4. the result is pin = N+1 with CEK = N, and every later read fails to decrypt `SenderKeyPin` now carries the CEK, so one compare-and-swap swaps all three together and that interleaving cannot be constructed. The CEK store becomes a cache derived from this authoritative record: it is only refreshed when the CAS wins, and a failed write is recoverable by re-processing the envelope (which is what the error already tells the caller to do). Same-generation re-processing still goes through the CAS so a record written before this change — or one whose CEK write failed — can be filled in without moving the epoch. That is not a rollback: the expected value is the record just read. Older generations never reach this code; they are already rejected upstream as stale envelopes. `SenderKeyPin` gets a hand-written `Debug` that redacts the CEK so key material cannot leak through logs or panic messages, and the `cek` field is optional so existing pinned records keep deserializing (otherwise every recipient would fall back to TOFU). Also corrects a stale doc comment that still named AES-CTR as the expected content encryption, and the design doc paragraph that described the superseded two-store ordering. Tests: epoch and CEK advance together under a losing CAS; a legacy record without the CEK field deserializes; Debug redacts the CEK; re-processing the current envelope stays idempotent and leaves reads working. --- docs/design.md | 2 +- .../src/domain/content/encryption.rs | 2 +- .../infrastructure/sender_key_pin_store.rs | 142 +++++++++++++++++- monas-sdk/src/controller/share.rs | 58 ++++--- .../tests/state_read_integration_test.rs | 26 ++++ 5 files changed, 197 insertions(+), 33 deletions(-) diff --git a/docs/design.md b/docs/design.md index bc674b2..b6c8c4b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -377,7 +377,7 @@ share受信者はKeyEnvelopeの復号成功時にunwrap済みCEKを自デバイ wrapのAADには `(content_id, recipient_key_id, key_epoch)` が束縛され、いずれかを書き換えたenvelopeは復号に失敗する。`key_epoch` はCEKの鍵世代(rotationごとに+1)で、受信者は記録済み世代より古いenvelopeを拒否する — rotation前の正規envelopeを再送して保存CEKを旧世代へ巻き戻すreplay攻撃はこれで防がれる。 -ローカル状態の更新順序も重要である。受信者側では**送信者ピンと鍵世代をcompare-and-advanceで先に進め、それが成功したときにだけCEKを保存する**。逆順(CEKを先に書き、ピンを無条件に上書きする)だと、ローテーション前後のKeyEnvelopeが並行して処理されたとき、後から完了した古い世代が新しいCEKとピンを巻き戻せてしまう。 +受信者側のローカル状態では、**送信者鍵・鍵世代・CEKの3つ組を1レコードにまとめ、単一のcompare-and-swapで入れ替える**。守るべき不変条件は「3つ組が常に整合していること」であって世代番号だけではないため、これらを別ストアに分けて別々にcommitすると、世代をCASで守っても壊れる — 世代Nの処理がピンを読んだ後に世代N+1の処理がピンとCEKを進め、その後で世代Nの処理がCEKだけを書き戻せば、`ピン=N+1 / CEK=N` という復号不能な状態が残る。3つ組が1レコードなら、この割り込みは構造的に起こり得ない。CEKストアはこの権威レコードから導出されるキャッシュとして扱い、書き損じてもKeyEnvelopeの再処理で埋め直せる。 アクセス取り消しの安全性は受信者の鍵破棄(強制不能)ではなくCEKローテーションに依存する。revoke時は再暗号化を先に行い、残存受信者にはローテーション後のCEK・進んだkey_epochでKeyEnvelopeを再発行する。受信者が再発行envelopeを処理すると保存済みCEKが更新され、旧CEKのままでは新しい版を復号できない。 diff --git a/monas-content/src/domain/content/encryption.rs b/monas-content/src/domain/content/encryption.rs index adebc4d..f93309d 100644 --- a/monas-content/src/domain/content/encryption.rs +++ b/monas-content/src/domain/content/encryption.rs @@ -13,7 +13,7 @@ pub trait ContentEncryptionKeyGenerator { /// CEK を用いてコンテンツを暗号化/復号するためのポート。 /// -/// 実装は AES-CTR などの暗号アルゴリズムを用いる infra 層に置く想定。 +/// 実装は AES-GCM などの AEAD を用いる infra 層に置く想定。 pub trait ContentEncryption { fn encrypt( &self, diff --git a/monas-content/src/infrastructure/sender_key_pin_store.rs b/monas-content/src/infrastructure/sender_key_pin_store.rs index 2605a7e..8d4b08f 100644 --- a/monas-content/src/infrastructure/sender_key_pin_store.rs +++ b/monas-content/src/infrastructure/sender_key_pin_store.rs @@ -5,9 +5,24 @@ //! このストアは content ごとに、最初に unwrap に成功した送信者公開鍵を //! ピン留めし(TOFU)、以後の envelope はピン済みの鍵でのみ検証する。 //! -//! 併せて CEK の鍵世代(key_epoch)も記録し、記録済み世代より古い envelope を -//! 拒否する基準にする(rotation 後に旧 envelope を再送して CEK を巻き戻す -//! replay 攻撃の防止)。 +//! 併せて CEK の鍵世代(key_epoch)と、**その世代の CEK 自体**を記録する。 +//! 記録済み世代より古い envelope は拒否する(rotation 後に旧 envelope を +//! 再送して CEK を巻き戻す replay 攻撃の防止)。 +//! +//! ## なぜ CEK をここに置くのか +//! +//! 守るべき不変条件は「送信者鍵・世代・CEK の3つ組が常に整合していること」で +//! あって、世代番号だけではない。3つ組を別ストアに分けて別々に commit すると、 +//! 世代を CAS で守っても次の interleaving で壊れる: +//! +//! 1. epoch N の処理が pin(epoch N-1)を読む +//! 2. epoch N+1 の処理が pin を N+1 へ進め、新しい CEK を保存する +//! 3. epoch N の処理が「同一世代の再処理」等の経路で CEK だけを書き戻す +//! 4. 結果は `pin = N+1, CEK = N` となり、以後の復号が失敗する +//! +//! 3つ組を1レコードに入れて単一の compare-and-swap で入れ替えれば、この +//! interleaving は構造的に起こり得ない。CEK ストア側は、この権威レコードから +//! 導出されるキャッシュとして扱う(書き損じても再処理で回復できる)。 //! //! キーは受信者から見た(ローカルの) content id。 @@ -20,16 +35,40 @@ pub enum SenderKeyPinStoreError { Storage(String), } -/// ピン留めされた送信者公開鍵と、最後に受理した鍵世代。 -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +/// ピン留めされた送信者公開鍵と、その送信者から受理した最新の鍵世代・CEK。 +/// +/// この3つは常に同じ commit で入れ替わる。個別に更新してはならない +/// (モジュール doc の interleaving を参照)。 +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SenderKeyPin { /// 送信者の公開鍵バイト列(P-256 uncompressed form)。 pub sender_public_key: Vec, /// 最後に unwrap に成功した envelope の key_epoch。 pub key_epoch: u64, + /// `key_epoch` 世代の CEK。この端末のローカルにのみ存在し、ネットワークには出ない。 + /// + /// 旧レコード(CEK を持たない形式)から読んだ場合は `None` になる。 + /// その場合は次に受理した envelope で埋まる。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cek: Option>, +} + +/// CEK を含むため、`Debug` は鍵素材を出さない。ログや panic メッセージに +/// レコードが載っても CEK が漏れないようにする。 +impl std::fmt::Debug for SenderKeyPin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SenderKeyPin") + .field("sender_public_key", &self.sender_public_key) + .field("key_epoch", &self.key_epoch) + .field( + "cek", + &self.cek.as_ref().map(|_| "").unwrap_or("None"), + ) + .finish() + } } -/// `content_id -> (送信者公開鍵, 最終受理 key_epoch)` の永続化ポート。 +/// `content_id -> (送信者公開鍵, 最終受理 key_epoch, その世代の CEK)` の永続化ポート。 pub trait SenderKeyPinStore: Send + Sync { fn load(&self, content_id: &str) -> Result, SenderKeyPinStoreError>; fn save(&self, content_id: &str, pin: &SenderKeyPin) -> Result<(), SenderKeyPinStoreError>; @@ -39,8 +78,8 @@ pub trait SenderKeyPinStore: Send + Sync { /// /// envelope の並行処理(rotation 前後の epoch N / N+1 が同時に走る等)で、 /// 「load した時点の pin」を前提に無条件 save すると、後から完了した古い - /// epoch が新しい pin と CEK を巻き戻せる。ピンの前進をこの CAS に限定し、 - /// **成功したときだけ CEK を公開する**ことで、その巻き戻しを防ぐ。 + /// epoch が新しいレコードを巻き戻せる。3つ組は1レコードなので、この CAS が + /// 成功した時点で送信者鍵・世代・CEK は一括で入れ替わっている。 fn compare_and_save( &self, content_id: &str, @@ -140,6 +179,8 @@ impl SenderKeyPinStore for SledSenderKeyPinStore { ) -> Result { // 比較は保存形式(JSON バイト列)で行う。`SenderKeyPin` のフィールド順は // 固定で serde_json も宣言順に出すため、同じ値は同じバイト列になる。 + // `cek: None` は `skip_serializing_if` で欄ごと省かれるが、これも + // 値ごとに一意なので比較は成立する(旧形式レコードとも一致する)。 let expected_bytes = expected .map(serde_json::to_vec) .transpose() @@ -175,6 +216,7 @@ mod tests { let pin_v0 = SenderKeyPin { sender_public_key: vec![0x04, 1, 2, 3], key_epoch: 0, + cek: None, }; store.save("content-a", &pin_v0).unwrap(); assert_eq!(store.load("content-a").unwrap(), Some(pin_v0.clone())); @@ -193,6 +235,7 @@ mod tests { let pin_v2 = SenderKeyPin { sender_public_key: vec![0x04, 1, 2, 3], key_epoch: 2, + cek: None, }; let current = store.load("content-a").unwrap(); assert!(store @@ -204,6 +247,7 @@ mod tests { let stale = SenderKeyPin { sender_public_key: vec![0x04, 1, 2, 3], key_epoch: 1, + cek: None, }; assert!(!store .compare_and_save("content-a", Some(&stale), &stale) @@ -214,6 +258,7 @@ mod tests { let first = SenderKeyPin { sender_public_key: vec![0x04, 9, 9, 9], key_epoch: 0, + cek: None, }; assert!(store.compare_and_save("content-c", None, &first).unwrap()); assert!(!store.compare_and_save("content-c", None, &first).unwrap()); @@ -233,6 +278,7 @@ mod tests { let epoch0 = SenderKeyPin { sender_public_key: key.clone(), key_epoch: 0, + cek: None, }; store.save("c", &epoch0).unwrap(); @@ -242,10 +288,12 @@ mod tests { let epoch2 = SenderKeyPin { sender_public_key: key.clone(), key_epoch: 2, + cek: None, }; let epoch1 = SenderKeyPin { sender_public_key: key, key_epoch: 1, + cek: None, }; // 新しい世代が先に前進する @@ -260,6 +308,84 @@ mod tests { assert_eq!(store.load("c").unwrap(), Some(epoch2)); } + /// 3つ組が1レコードなので、世代と CEK が食い違った状態を CAS 経由では + /// 作れない。旧設計(pin と CEK が別ストア・別 commit)では、epoch N の + /// 処理が CEK だけを書き戻して `pin = N+1, CEK = N` を作れた。 + fn epoch_and_cek_advance_together(store: &dyn SenderKeyPinStore) { + let key = vec![0x04, 1, 2, 3]; + let cek_of = |epoch: u64| Some(vec![epoch as u8; 32]); + + let epoch0 = SenderKeyPin { + sender_public_key: key.clone(), + key_epoch: 0, + cek: cek_of(0), + }; + store.save("c", &epoch0).unwrap(); + + // epoch 1 と epoch 2 の処理が、同じ pin(epoch 0)を観測して開始する + let observed = store.load("c").unwrap(); + + let epoch2 = SenderKeyPin { + sender_public_key: key.clone(), + key_epoch: 2, + cek: cek_of(2), + }; + let epoch1 = SenderKeyPin { + sender_public_key: key, + key_epoch: 1, + cek: cek_of(1), + }; + + assert!(store + .compare_and_save("c", observed.as_ref(), &epoch2) + .unwrap()); + // 後から完了した古い世代は CAS に負ける。CEK も一緒に載っているので、 + // 「世代だけ新しく CEK は古い」状態は生じ得ない。 + assert!(!store + .compare_and_save("c", observed.as_ref(), &epoch1) + .unwrap()); + + let current = store.load("c").unwrap().expect("record should exist"); + assert_eq!(current.key_epoch, 2); + assert_eq!(current.cek, cek_of(2), "CEK は世代と一緒に進む"); + } + + #[test] + fn in_memory_epoch_and_cek_advance_together() { + epoch_and_cek_advance_together(&InMemorySenderKeyPinStore::default()); + } + + #[test] + fn sled_epoch_and_cek_advance_together() { + let dir = tempfile::tempdir().unwrap(); + let db = sled::open(dir.path()).unwrap(); + epoch_and_cek_advance_together(&SledSenderKeyPinStore::with_db(db)); + } + + /// この修正より前に書かれたレコード(CEK 欄なし)を読めること。 + /// 読めないと、既存の受信者が全員 TOFU からやり直しになる。 + #[test] + fn legacy_record_without_cek_deserializes() { + let legacy = br#"{"sender_public_key":[4,1,2,3],"key_epoch":7}"#; + let pin: SenderKeyPin = serde_json::from_slice(legacy).unwrap(); + assert_eq!(pin.key_epoch, 7); + assert_eq!(pin.cek, None); + } + + /// CEK が `Debug` 出力に出ないこと。ログや panic メッセージ経由で + /// 鍵素材が漏れるのを防ぐ。 + #[test] + fn debug_output_redacts_the_cek() { + let pin = SenderKeyPin { + sender_public_key: vec![0x04, 1, 2, 3], + key_epoch: 1, + cek: Some(vec![0xAB; 32]), + }; + let rendered = format!("{pin:?}"); + assert!(rendered.contains(""), "rendered={rendered}"); + assert!(!rendered.contains("171"), "CEK bytes leaked: {rendered}"); + } + #[test] fn in_memory_concurrent_epochs_do_not_roll_back() { concurrent_epochs_do_not_roll_back(&InMemorySenderKeyPinStore::default()); diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index 045a41b..20b0986 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -829,23 +829,36 @@ impl MonasController { // unwrap + 復号の成功 = 送信者と鍵世代の正しさが暗号学的に確認できた // 時点なので、ここで初めてローカルへ反映する。 // - // 順序が重要: **先に pin を compare-and-advance し、成功した場合にだけ - // CEK を公開する**。pin を無条件 save して後から CEK を書くと、rotation - // 前後の envelope(epoch N / N+1)が並行して処理されたとき、後から - // 完了した古い epoch が新しい CEK と pin を巻き戻せてしまう。 + // 3つ組は 1 レコードにまとめて単一の compare-and-swap で入れ替える。 + // 以前は「pin を CAS してから CEK を別ストアへ save」していたが、 + // 2 つの commit に分かれている限り、間に別の世代の処理が割り込めば + // `pin = N+1, CEK = N` のような不整合が作れてしまう + // (`SenderKeyPin` のモジュール doc に interleaving を記載)。 + // // CAS が失敗した = 別の処理が先に同じかより新しい世代へ進めた、なので - // こちらの(古い)CEK は書かない。 + // こちらの(古い)3つ組は捨てる。 let new_pin = monas_content::infrastructure::sender_key_pin_store::SenderKeyPin { sender_public_key: effective_sender_public_key, key_epoch: input.key_envelope.key_epoch, + cek: Some(cek.0.clone()), }; - let should_advance_pin = match &pinned { - None => true, - Some(pin) => input.key_envelope.key_epoch > pin.key_epoch, - }; + // ここへ来る時点で、記録済み世代より古い envelope は step 4 で既に + // 拒否されている(`stale key envelope`)。よって残るのは「同じ世代」か + // 「より新しい世代」のどちらかで、どちらも CAS の期待値が + // 「今読んだレコードそのもの」なので巻き戻しにはならない。 + // + // 同一世代でも CAS を通すのは、旧レコードが CEK を持たない + // (この修正より前に作られた、あるいは CEK 保存に失敗した)場合に、 + // 同じ世代のまま CEK を埋め直して回復できるようにするため。 + // + // 権威レコードが既にこの3つ組そのものなら CAS 自体は不要。ただし + // CEK キャッシュだけが欠けている可能性はあるので、その更新は通す。 + let already_current = pinned.as_ref() == Some(&new_pin); - let advanced = if should_advance_pin { - match self.sender_pin_store.compare_and_save( + let should_refresh_cek_cache = if already_current { + true + } else { + let advanced = match self.sender_pin_store.compare_and_save( content_id.as_str(), pinned.as_ref(), &new_pin, @@ -861,21 +874,20 @@ impl MonasController { trace_id, ); } - } - } else { - // 同一世代の再処理。pin は動かさないが、CEK が未保存のケース - // (前回 pin だけ書けて CEK 保存に失敗した等)を回復できるよう - // 下で CEK は書く。 - true + }; + // CAS に負けた場合は、勝った側がより新しい(または同じ)世代を + // 書いているので、こちらの CEK でキャッシュを上書きしてはいけない。 + advanced }; - // CEK は受信者デバイスのローカルストアに留まり、ネットワークには出ない。 - // これで share 受信者も state node 経由の検証付き read で復号できる。 - if advanced { + // CEK ストアは上の権威レコードから導出されるキャッシュ。 + // CEK は受信者デバイスのローカルに留まり、ネットワークには出ない。 + if should_refresh_cek_cache { if let Err(e) = self.content_service.cek_store.save(&content_id, &cek) { - // 保存に失敗したまま成功を返すと、呼び出し側は「以後この端末で - // 検証付き read ができる」と信じるのに実際は MissingKey で失敗する。 - // silent degradation を避けるためエラーとして返す(再処理可能)。 + // 権威レコードには CEK が入っているので、ここで失敗しても + // 再処理すればキャッシュを埋め直せる。ただし黙って成功を + // 返すと、呼び出し側は「以後この端末で検証付き read ができる」 + // と信じるのに実際は MissingKey で失敗するため、エラーにする。 return ApiResponse::error( ApiError::Internal(format!( "decrypted the shared content but failed to persist its CEK for {}: {e}. \ diff --git a/monas-sdk/tests/state_read_integration_test.rs b/monas-sdk/tests/state_read_integration_test.rs index aa6094f..6190c3e 100644 --- a/monas-sdk/tests/state_read_integration_test.rs +++ b/monas-sdk/tests/state_read_integration_test.rs @@ -487,6 +487,32 @@ async fn cek_rotation_after_revoke_updates_recipient_and_read() { read_after_replay.error ); + // 同一世代の envelope を再処理しても、送信者鍵・世代・CEK の3つ組は + // そのままで read も壊れない。以前はこの経路が「pin は据え置き、CEK だけ + // 無条件 save」だったため、並行処理と組み合わせると世代と CEK が食い違う + // 状態を作れた(pin=N+1 / CEK=N)。3つ組を1レコードの CAS で入れ替える + // ようにしたので、この経路からは不整合が作れない。 + let reprocess_same_epoch = + recipient_controller.decrypt_shared_content(DecryptSharedContentInput { + content_id: created.content_id.clone(), + private_key: surviving_recipient.private_key.clone(), + sender_public_key: shared_surviving.sender_public_key.clone(), + recipient_key_id: reissued.recipient_key_id.clone(), + key_envelope: reissued.key_envelope.clone(), + version: None, + }); + assert!( + reprocess_same_epoch.success, + "re-processing the current envelope must stay idempotent: {:?}", + reprocess_same_epoch.error + ); + let read_after_reprocess = read_latest(); + assert!( + read_after_reprocess.success, + "read must still succeed after re-processing the current envelope: {:?}", + read_after_reprocess.error + ); + cleanup_content_artifacts(); } From 0f8c35bb5c7bb7bbea5514bb4b76df8d15ebc085 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 19:30:20 +0900 Subject: [PATCH 37/48] fix(sdk): serialize revokes per content Revoke is a load-modify-save spanning the ACL, the CEK, the local ciphertext and the state node, and none of those carry a version CAS. `MonasController` is shared behind an `Arc` by the gateway and called from concurrent request handlers, so two revokes on the same content really do interleave: - both read the same Share and save it last-write-wins, dropping one recipient's removal entirely (lost update) - different CEKs get handed out under the same key_epoch - the local ACL/CEK and the remote ciphertext end up coming from different requests The whole sequence now runs under a per-content mutex, taken before the snapshot is captured. Taking it later would let the snapshot go stale between the read and the lock, so a failed revoke's rollback would overwrite the other revoke's result. This is deliberately not the full fix. The real invariant wants Share, CEK and ciphertext under one transactional CAS; the lock is process-local and does not cover concurrent revokes from separate gateway processes. Both limits are stated in the design doc rather than left implied. Tests: two recipients revoked concurrently, both removals survive. Without the lock the test fails in 4 of 5 runs; with it, 5 of 5 pass. --- docs/design.md | 2 + monas-sdk/src/controller/mod.rs | 38 +++++ monas-sdk/src/controller/share.rs | 18 +++ .../share_controller_integration_test.rs | 142 ++++++++++++++++++ 4 files changed, 200 insertions(+) diff --git a/docs/design.md b/docs/design.md index b6c8c4b..18790d3 100644 --- a/docs/design.md +++ b/docs/design.md @@ -262,6 +262,8 @@ flowchart TD `min_valid_issued_at`は時刻ベースの一括失効なので、**残存する受信者のTokenも巻き添えで失効する**。呼び出し側は取り消し後に、残存受信者へ新しいKeyEnvelopeと新しいTokenの両方を配り直す必要がある。SDKは`RevokeShareOutput`で再発行KeyEnvelope(`reissued_envelopes`)と失効時刻(`token_invalidated_at`)の両方を返す。 +取り消しはACL・CEK・ローカルciphertext・state node状態にまたがるload-modify-saveであり、そのどれにもversion CASが無い。したがって**同じcontentへの取り消しはcontent単位で直列化する**。並行させると、双方が同じShareを読んで後勝ちでsaveし片方の受信者削除が消える(lost update)、異なるCEKが同じ`key_epoch`として配られる、といった分岐が起こる。SDKのコントローラはgatewayから共有され複数リクエストから同時に呼ばれるため、これは理論上の話ではない。現状の直列化はプロセス内に閉じており、複数gatewayプロセスからの並行取り消しには対応しない — そこまで守るにはShare・CEK・ciphertextを1つのtransactional CASにまとめるか、state node側にCASを置く必要がある。 + --- ## 8. CIDによるコンテンツアドレッシング diff --git a/monas-sdk/src/controller/mod.rs b/monas-sdk/src/controller/mod.rs index c53ad3e..d1a5a39 100644 --- a/monas-sdk/src/controller/mod.rs +++ b/monas-sdk/src/controller/mod.rs @@ -70,12 +70,49 @@ pub struct MonasController { /// share 受信者側の送信者公開鍵ピン(TOFU)と受理済み CEK 鍵世代の記録 /// (KeyEnvelope の送信者認証と rotation 巻き戻し replay 防止) sender_pin_store: DynSenderPinStore, + /// content 単位の revoke 直列化ロック。 + content_revoke_locks: ContentLocks, } /// SDK が使う送信者鍵ピンストアの動的型。 pub(super) type DynSenderPinStore = std::sync::Arc; +/// content id ごとの相互排他ロック。 +/// +/// revoke は「ACL・CEK・ローカル ciphertext・state node の状態」を +/// load-modify-save で更新する複合操作で、そのどれにも version CAS が無い。 +/// `MonasController` は gateway 等で `Arc` 共有され複数リクエストから同時に +/// 呼ばれるため、同じ content への revoke が並行すると次が起こる: +/// +/// - 双方が同じ Share を読み、後勝ちで save → 片方の受信者削除が消える +/// (lost update) +/// - 異なる CEK が同じ key_epoch として配られる +/// - ローカル ACL/CEK と state node の ciphertext が別リクエスト由来になる +/// +/// 根本解決は Share・CEK・ciphertext を1つの transactional CAS にまとめる +/// ことだが、3ストア + リモート更新にまたがるため、まず content 単位の +/// 直列化で「並行 revoke が状態を分岐させない」ことを保証する。 +/// ロックはプロセス内のみで、複数 gateway プロセスからの並行 revoke は +/// カバーしない(その場合は state node 側の CAS が必要)。 +#[derive(Clone, Default)] +pub(super) struct ContentLocks { + inner: Arc>>>>, +} + +impl ContentLocks { + /// `content_id` 専用の mutex を取得する。同じ id には常に同じ mutex を返す。 + pub(super) fn mutex_for(&self, content_id: &str) -> Arc> { + let mut map = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + map.entry(content_id.to_string()) + .or_insert_with(|| Arc::new(std::sync::Mutex::new(()))) + .clone() + } +} + impl MonasController { pub(super) fn current_unix_timestamp() -> u64 { SystemTime::now() @@ -186,6 +223,7 @@ impl MonasController { public_key_directory, ), sender_pin_store, + content_revoke_locks: ContentLocks::default(), }) } diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index 20b0986..dc01e47 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -456,6 +456,24 @@ impl MonasController { // 2. ContentIdに変換 let content_id = ContentId::new(input.content_id.clone()); + // この content への revoke を直列化する。revoke は ACL・CEK・ローカル + // ciphertext・state node 状態にまたがる load-modify-save で、どこにも + // version CAS が無い。`MonasController` は gateway から `Arc` 共有され + // 同時に呼ばれるため、ロックが無いと 2 つの revoke が同じ Share を読んで + // 後勝ちで save し、片方の受信者削除が消える(lost update)。 + // 異なる CEK が同じ key_epoch として配られる問題も同じ原因。 + // + // snapshot 取得より前にロックを取る: 後にすると、読んだ snapshot が + // ロック取得までの間に古くなり、失敗時の巻き戻しが他方の結果を + // 上書きしてしまう。 + // + // ロックはプロセス内のみ。複数 gateway プロセスからの並行 revoke は + // これでは防げず、state node 側の CAS が必要になる(現状の制約)。 + let revoke_lock = self.content_revoke_locks.mutex_for(content_id.as_str()); + let _revoke_guard = revoke_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let snapshot = match self.capture_revoke_share_snapshot(&content_id) { Ok(snapshot) => snapshot, Err(e) => return ApiResponse::error(e, trace_id), diff --git a/monas-sdk/tests/share_controller_integration_test.rs b/monas-sdk/tests/share_controller_integration_test.rs index c968c40..d1763a5 100644 --- a/monas-sdk/tests/share_controller_integration_test.rs +++ b/monas-sdk/tests/share_controller_integration_test.rs @@ -835,3 +835,145 @@ async fn revoke_share_fails_when_token_invalidation_fails() { cleanup_content_artifacts(); } + +/// 同じ content への revoke が並行しても、両方の受信者削除が残る。 +/// +/// revoke は ACL・CEK・ローカル ciphertext・state node 状態にまたがる +/// load-modify-save で、どこにも version CAS が無い。直列化しないと 2 つの +/// revoke が同じ Share を読んで後勝ちで save し、片方の削除が消える +/// (lost update)。`MonasController` は gateway から `Arc` 共有されるので、 +/// これは理論上の話ではない。 +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_revokes_do_not_lose_either_removal() { + let _guard = acquire_test_lock(); + let mut server = Server::new_async().await; + let _create_mock = server + .mock("POST", "/content") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"content_id":"concurrent-revoke-remote"}"#) + .create_async() + .await; + let _delegate_mock = server + .mock("POST", "/issuer/delegate") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"delegated_token":"dummy.jwt.token","issued_at":1700000000,"expires_at":1700003600,"jti":"jti-concurrent"}"#, + ) + .expect_at_least(1) + .create_async() + .await; + let _update_mock = server + .mock("PUT", mockito::Matcher::Regex(r"^/content/.+$".to_string())) + .with_status(200) + .expect_at_least(1) + .create_async() + .await; + + let controller = std::sync::Arc::new(MonasController::with_urls(server.url(), server.url())); + + let sender = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("sender keypair should be generated"); + let recipient_a = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("recipient a keypair should be generated"); + let recipient_b = controller + .generate_keypair(GenerateKeypairInput { + key_type: KeyType::Secp256r1, + }) + .data + .expect("recipient b keypair should be generated"); + + let created = controller + .create_content( + CreateContentInput { + content: URL_SAFE_NO_PAD.encode(b"concurrent-revoke-target"), + metadata: Some(ContentMetadata { + name: Some("concurrent-revoke.txt".to_string()), + content_type: Some("text/plain".to_string()), + created_at: None, + updated_at: None, + }), + }, + None, + ) + .data + .expect("create should return data"); + + for recipient in [&recipient_a, &recipient_b] { + assert!( + controller + .share_content(ShareContentInput { + content_id: created.content_id.clone(), + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), + recipient_public_key: recipient.public_key.clone(), + permissions: vec![Permission::Read], + }) + .success, + "share_content should succeed" + ); + } + + // 2 つの受信者を同時に revoke する + let handles: Vec<_> = [&recipient_a, &recipient_b] + .into_iter() + .map(|recipient| { + let controller = controller.clone(); + let content_id = created.content_id.clone(); + let sender_public_key = sender.public_key.clone(); + let sender_private_key = sender.private_key.clone(); + let recipient_public_key = recipient.public_key.clone(); + // filesync repository が Tokio reactor を要求するので、素の + // std::thread ではなく blocking task として走らせる。 + tokio::task::spawn_blocking(move || { + controller.revoke_share( + RevokeShareInput { + content_id, + remote_content_id: None, + sender_public_key, + sender_private_key, + recipient_public_key, + }, + None, + ) + }) + }) + .collect(); + + for handle in handles { + let response = handle.await.expect("revoke task should not panic"); + assert!( + response.success, + "concurrent revoke should succeed: {:?}", + response.error + ); + } + + // どちらの受信者も復号できない = 両方の削除が残っている。 + // 片方の削除が lost update で消えていれば、その受信者は復号できてしまう。 + for (label, recipient) in [("a", &recipient_a), ("b", &recipient_b)] { + let shared_again = controller.share_content(ShareContentInput { + content_id: created.content_id.clone(), + sender_public_key: sender.public_key.clone(), + sender_private_key: sender.private_key.clone(), + recipient_public_key: recipient.public_key.clone(), + permissions: vec![Permission::Read], + }); + assert!( + shared_again.success, + "re-sharing to recipient {label} should succeed after revoke: {:?}", + shared_again.error + ); + } + + cleanup_content_artifacts(); +} From eb739e72c1d9c36793355fbac12febab4412357f Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 28 Jul 2026 20:18:35 +0900 Subject: [PATCH 38/48] fix(state-node): make signed mutations single-use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timestamp freshness check only bounds how long a captured signature stays usable. Inside that five-minute window the same signature can be presented any number of times, and `update`, `delete`, `invalidate` and `manage` are not idempotent — so a replay is not a duplicate, it is a state rollback. Replaying a signed update of old ciphertext after a legitimate update re-commits the old bytes as a *new* version parented on the current head, making the stale content the latest version. Accepted mutation requests are now recorded and a second presentation is rejected. The request identity is the digest of the request signature itself: the signature already commits to operation, resource, timestamp and body digest, so an identical digest means an identical request, and no nonce field has to be added to the wire format. Records only need to outlive the freshness window (outside it the signature fails the freshness check anyway), so the store is pruned on insert and cannot grow without bound. Reads are idempotent and are deliberately not covered. Relays consume too. A relay holds no access policy, so it forwards credentials and lets a member decide — which means the member is the only one consuming, and re-sending to the relay gets the request applied again whenever the relay picks a member that has not seen it. Recording on the relay closes that path. It is not a substitute for the member-side check (a caller can always use a different relay), and because the relay cannot verify what it records, an unauthenticated caller can burn a digest on that node — bounded to one node, one window, and a signature the attacker already holds and could replay directly anyway. Replay rejection gets its own error rather than collapsing into "Authentication failed", and returns 409. The two mean opposite things to an operator — a forged request versus a genuine one arriving twice — and a client told "authentication failed" will retry the same signature forever instead of re-signing. Corrects three places that asserted freshness alone was replay protection: the design doc, `verify_caller_signature`'s doc comment, and the note left when the jti nonce store was removed in #61. The scope this actually buys is now stated, including what it does not cover (per-node records; a volatile default store that forgets across a restart). Also fixes a pre-existing bug in the auth script: `set -e` plus `((TESTS_PASSED++))` aborted the run at the first passing test, because the post-increment returns the old value 0. The member-add expectations were wrong too — the creating node is never a member, so 403 is the correct outcome there. Tests: a replayed signature is rejected and reported as a replay, and the state does not roll back; the same signature never applies twice. Both fail without the consumption gate. Verified on four real nodes — e2e 8/8, auth 9/9 including a replayed update rejected with 409 through a relay. --- docs/design.md | 6 +- monas-state-node/scripts/test-with-auth.sh | 93 +++++---- .../src/application_service/node.rs | 9 +- .../application_service/state_node_service.rs | 184 ++++++++++++++++-- monas-state-node/src/domain/access_control.rs | 9 + monas-state-node/src/domain/errors.rs | 10 + .../src/port/consumed_request_store.rs | 133 +++++++++++++ monas-state-node/src/port/mod.rs | 2 + monas-state-node/src/presentation/http_api.rs | 9 + monas-state-node/tests/integration_test.rs | 128 +++++++++++- 10 files changed, 525 insertions(+), 58 deletions(-) create mode 100644 monas-state-node/src/port/consumed_request_store.rs diff --git a/docs/design.md b/docs/design.md index 18790d3..930ca2f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -349,7 +349,11 @@ Token.att = [ Token失効は`min_valid_issued_at`による時刻ベースで管理される。オーナーがこの値を更新することで、それ以前に発行されたすべてのTokenを一括失効できる。 -役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別・bodyの有無によらず同一構造で、domain separationタグに続けて操作・リソース・timestamp・body digestを長さ前置で連結する(`monas-request-v1::<操作>::<リソース>:::`)。**bodyを伴う書き込みでも操作とリソースに束縛される**ため、あるコンテンツ向けに取得した署名を別コンテンツや別操作へ転用することはできない。リプレイ防御は署名内のtimestampの鮮度チェック(5分窓)が担う。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。したがってTokenはTTL内で何度でも再利用でき、盗まれた署名でできることは「同じリソースへの同じ操作を5分以内に再実行する」ことに限られる。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 +役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別・bodyの有無によらず同一構造で、domain separationタグに続けて操作・リソース・timestamp・body digestを長さ前置で連結する(`monas-request-v1::<操作>::<リソース>:::`)。**bodyを伴う書き込みでも操作とリソースに束縛される**ため、あるコンテンツ向けに取得した署名を別コンテンツや別操作へ転用することはできない。リプレイ防御は2層で担う。第1に署名内のtimestampの鮮度チェック(5分窓)で、これは「古い署名を無限に使い回せない」ことを保証する。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。第2に、**mutationについては受理した署名を記録して2度目の提示を拒否する**。鮮度チェックだけでは窓の中で同じ署名を何度でも通せてしまい、update・delete・invalidate・manageは冪等でないため、それは単なる重複ではなく状態の巻き戻しになる — 署名済みの旧ciphertext更新を正規の更新の後に再送すると、サーバはそれを「現在のheadを親とする新しい操作」としてcommitし、古い内容が最新版になる。 + +リクエストの同一性には**リクエスト署名そのもののdigest**を使う。署名は既に操作・リソース・timestamp・body digestすべてに束縛されているので、digestが一致する=完全に同じリクエストの再送であり、nonceのような新しいフィールドをワイヤ形式へ足す必要がない。記録の保持期間は鮮度窓と同じでよい(窓の外へ出た署名は記録が無くても鮮度チェックで落ちる)ため、記録は無制限には育たない。読み取りは冪等なのでこの記録の対象外である。 + +したがってTokenはTTL内で何度でも再利用できる一方、**個々のリクエスト署名は使い切り**である。記録はノードごとに独立で、同じ署名を複数のレプリカへ送ればそれぞれで1回ずつ受理される(CRDTは同一操作の重複適用に耐えるが、ネットワーク全体で厳密に1回を保証するものではない)。またデフォルト実装はプロセス内に閉じており、再起動をまたいで5分以内に届いた同一署名までは防げない。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 ### ビザンチン耐性 diff --git a/monas-state-node/scripts/test-with-auth.sh b/monas-state-node/scripts/test-with-auth.sh index f279793..f22777f 100755 --- a/monas-state-node/scripts/test-with-auth.sh +++ b/monas-state-node/scripts/test-with-auth.sh @@ -197,12 +197,12 @@ response_body=$(echo "$CONTENT_RESPONSE" | sed '$d') if [ "$status_code" = "201" ]; then log_success "新しいコンテンツを作成 (HTTP 201)" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) echo "$response_body" | jq -C '.' 2>/dev/null || echo "$response_body" CONTENT_ID=$(echo "$response_body" | jq -r '.content_id // empty' 2>/dev/null) else log_fail "新しいコンテンツを作成 (期待: HTTP 201, 実際: HTTP $status_code)" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) echo "$response_body" CONTENT_ID="" fi @@ -231,13 +231,35 @@ if [ -n "$CONTENT_ID" ]; then UPDATE_BODY=$(echo "$UPDATE_RESPONSE" | sed '$d') if [ "$UPDATE_STATUS" = "200" ]; then log_success "コンテンツを更新 (HTTP 200)" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) else log_fail "コンテンツを更新 (期待: HTTP 200, 実際: HTTP $UPDATE_STATUS)" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) echo "$UPDATE_BODY" fi + # 直前の更新とまったく同じ署名・timestamp・body を再送する。 + # 鮮度チェックだけなら 5 分窓の中なので通ってしまうが、mutation の署名は + # 使い切りなので 409 で拒否される。通してしまうと、攻撃者が署名済みの + # 旧 ciphertext 更新を後から再送して最新版を巻き戻せる。 + log_test "同じ署名での更新の再送を拒否" + REPLAY_RESPONSE=$(curl -s -X PUT "$BASE_URL/content/$CONTENT_ID" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TEST_KEY_ID" \ + -H "X-Request-Signature: $LAST_SIGNATURE" \ + -H "X-Request-Timestamp: $LAST_TIMESTAMP" \ + -d "{\"data\": \"$UPDATED_B64\"}" \ + -w "\n%{http_code}" 2>/dev/null) + REPLAY_STATUS=$(echo "$REPLAY_RESPONSE" | tail -n1) + if [ "$REPLAY_STATUS" = "409" ]; then + log_success "更新の再送を拒否 (HTTP 409)" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + log_fail "更新の再送を拒否 (期待: HTTP 409, 実際: HTTP $REPLAY_STATUS)" + TESTS_FAILED=$((TESTS_FAILED + 1)) + echo "$REPLAY_RESPONSE" | sed '$d' + fi + # メンバー追加(count形式) # count は署名対象。body を差し替えると署名検証で落ちる generate_signature "$TEST_PRIVATE_KEY" "manage" "$CONTENT_ID" "" "1" @@ -252,40 +274,29 @@ if [ -n "$CONTENT_ID" ]; then -w "\n%{http_code}" 2>/dev/null) MEMBER_STATUS=$(echo "$MEMBER_RESPONSE" | tail -n1) MEMBER_BODY=$(echo "$MEMBER_RESPONSE" | sed '$d') - if [ "$MEMBER_STATUS" = "200" ]; then + # このスクリプトはコンテンツを作成したノードへ直接送る。作成ノードは + # 自分自身を member にしないので(relay 役)、add_members は member 判定で + # 403 になるのが正常系。200/503 はこのノードが member だった場合。 + # + # count が署名に束縛されていることの検証は、この 403 が member 判定で + # 起きる以上ここでは行えない。ユニットテスト + # (`test_add_members_count_cannot_be_substituted`)が担当する。 + if [ "$MEMBER_STATUS" = "403" ]; then + log_success "メンバー追加: 作成ノードは非memberなので拒否 (HTTP 403 - 想定内)" + TESTS_PASSED=$((TESTS_PASSED + 1)) + elif [ "$MEMBER_STATUS" = "200" ]; then log_success "メンバー追加成功 (HTTP 200)" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) echo "$MEMBER_BODY" | jq -C '.' 2>/dev/null || echo "$MEMBER_BODY" elif [ "$MEMBER_STATUS" = "503" ]; then log_warn "メンバー追加: DHT peer discovery で利用可能ノードが見つかりません (HTTP 503 - 小規模クラスタでは想定内)" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) else - log_fail "メンバー追加 (期待: HTTP 200 or 503, 実際: HTTP $MEMBER_STATUS)" - ((TESTS_FAILED++)) + log_fail "メンバー追加 (期待: HTTP 403, 200 or 503, 実際: HTTP $MEMBER_STATUS)" + TESTS_FAILED=$((TESTS_FAILED + 1)) echo "$MEMBER_BODY" fi - # count 差し替え: count=1 の署名で count=8 を送る - generate_signature "$TEST_PRIVATE_KEY" "manage" "$CONTENT_ID" "" "1" - - log_test "メンバー追加のcount差し替えを拒否" - TAMPER_RESPONSE=$(curl -s -X POST "$BASE_URL/content/$CONTENT_ID/members" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $TEST_KEY_ID" \ - -H "X-Request-Signature: $LAST_SIGNATURE" \ - -H "X-Request-Timestamp: $LAST_TIMESTAMP" \ - -d '{"count": 8}' \ - -w "\n%{http_code}" 2>/dev/null) - TAMPER_STATUS=$(echo "$TAMPER_RESPONSE" | tail -n1) - if [ "$TAMPER_STATUS" = "401" ]; then - log_success "count差し替えを拒否 (HTTP 401)" - ((TESTS_PASSED++)) - else - log_fail "count差し替えを拒否 (期待: HTTP 401, 実際: HTTP $TAMPER_STATUS)" - ((TESTS_FAILED++)) - echo "$TAMPER_RESPONSE" | sed '$d' - fi - echo "" echo -e "${BLUE}=== CRDT操作テスト ===${NC}" echo "" @@ -303,10 +314,10 @@ if [ -n "$CONTENT_ID" ]; then DATA_BODY=$(echo "$DATA_RESPONSE" | sed '$d') if [ "$DATA_STATUS" = "200" ]; then log_success "CRDTデータの取得 (HTTP 200)" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) else log_fail "CRDTデータの取得 (期待: HTTP 200, 実際: HTTP $DATA_STATUS)" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) echo "$DATA_BODY" fi @@ -323,10 +334,10 @@ if [ -n "$CONTENT_ID" ]; then HIST_BODY=$(echo "$HIST_RESPONSE" | sed '$d') if [ "$HIST_STATUS" = "200" ]; then log_success "CRDT履歴の取得 (HTTP 200)" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) else log_fail "CRDT履歴の取得 (期待: HTTP 200, 実際: HTTP $HIST_STATUS)" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) echo "$HIST_BODY" fi @@ -347,10 +358,10 @@ if [ -n "$CONTENT_ID" ]; then DEL_BODY=$(echo "$DEL_RESPONSE" | sed '$d') if [ "$DEL_STATUS" = "200" ]; then log_success "コンテンツを削除 (HTTP 200)" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) else log_fail "コンテンツを削除 (期待: HTTP 200, 実際: HTTP $DEL_STATUS)" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) echo "$DEL_BODY" fi fi @@ -397,7 +408,7 @@ if curl -s "http://127.0.0.1:8080/health" > /dev/null 2>&1 && \ fi else log_error "すべてのノードが起動していないため、同期テストを実行できません" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) fi # ============================================================================ @@ -422,10 +433,10 @@ response=$(curl -s -X POST "$BASE_URL/content" \ status_code=$(echo "$response" | tail -n1) if [ "$status_code" = "401" ]; then log_success "無効なトークンが正しく拒否されました" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) else log_fail "無効なトークンが拒否されませんでした (HTTP $status_code)" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) fi # 署名なしのリクエスト @@ -439,10 +450,10 @@ response=$(curl -s -X POST "$BASE_URL/content" \ status_code=$(echo "$response" | tail -n1) if [ "$status_code" = "401" ]; then log_success "署名なしリクエストが正しく拒否されました" - ((TESTS_PASSED++)) + TESTS_PASSED=$((TESTS_PASSED + 1)) else log_fail "署名なしリクエストが拒否されませんでした (HTTP $status_code)" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) fi # ============================================================================ diff --git a/monas-state-node/src/application_service/node.rs b/monas-state-node/src/application_service/node.rs index 7ef8a8b..f05b4e3 100644 --- a/monas-state-node/src/application_service/node.rs +++ b/monas-state-node/src/application_service/node.rs @@ -212,9 +212,12 @@ impl StateNode { )); // Create auth services. - // NOTE: リプレイ防御は署名内 timestamp の鮮度チェックに一本化されており、 - // 旧 jti nonce ストア(ノードごとに独立で、委譲トークンの TTL 内再利用と - // 矛盾していた)は廃止した(issue #61)。 + // NOTE: 旧 jti nonce ストア(委譲トークンの TTL 内再利用と矛盾していた)は + // 廃止した(issue #61)。リプレイ防御は「署名内 timestamp の鮮度チェック」 + // と「mutation の署名を使い切りにする消費記録」の2層が担う。後者は + // `StateNodeService` が保持するので、ここで組み立てる必要はない。 + // 失効させる単位が *トークン* から *リクエスト署名* へ変わったのが要点で、 + // これならトークンの再利用を妨げずに mutation の再送だけを止められる。 let auth_service = MonasAccountAdapter::new(); let authz_service = UcanAdapter::new(crdt_repo_dyn.clone()); diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index a6e7a2f..5c6ef9e 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -15,6 +15,7 @@ use crate::infrastructure::placement::compute_dht_key; use crate::port::auth_token::{AuthToken, RequestMetadata}; use crate::port::authentication_service::AuthenticationService; use crate::port::authorization_service::{AuthorizationRequest, AuthorizationService}; +use crate::port::consumed_request_store::{ConsumedRequestStore, InMemoryConsumedRequestStore}; use crate::port::content_repository::ContentRepository; use crate::port::event_publisher::EventPublisher; use crate::port::peer_network::{PeerNetwork, RelayReadError, RelayReadErrorKind}; @@ -92,6 +93,9 @@ where capacity_threshold_bytes: u64, /// Maximum number of members to add in a single add_member_to_content call. max_add_member_count: usize, + /// Records mutation requests that have already been accepted, so a captured + /// signature cannot be replayed inside its freshness window. + consumed_requests: Arc, } /// No-op access control repository for backward compatibility. @@ -172,9 +176,21 @@ where min_replication_factor: config.min_replication_factor, capacity_threshold_bytes: config.capacity_threshold_bytes, max_add_member_count: config.max_add_member_count, + consumed_requests: Arc::new(InMemoryConsumedRequestStore::default()), } } + /// Replace the consumed-request store (builder pattern). + /// + /// The default is process-local and volatile, which is sound because records + /// only need to outlive the signature freshness window. Deployments that + /// want replay protection to survive a restart can inject a persistent + /// implementation here. + pub fn with_consumed_request_store(mut self, store: Arc) -> Self { + self.consumed_requests = store; + self + } + /// Set the access control repository (builder pattern). /// /// This method allows adding access control support after construction. @@ -270,11 +286,16 @@ where /// - If `request_body` is `Some(body)`: signs `hex(sha256(body + timestamp_be_bytes))` /// - If `request_body` is `None`: signs `{operation}:{resource}:{timestamp}` /// - /// Replay protection comes from the timestamp *inside* the signed message - /// (freshness window checked by the auth service), so `timestamp` is - /// mandatory — there is no server-clock fallback. A token can therefore be - /// reused for many requests within its TTL; a stolen signature only allows - /// repeating the same operation on the same resource within the window. + /// The timestamp *inside* the signed message is checked for freshness by the + /// auth service, so `timestamp` is mandatory — there is no server-clock + /// fallback. A token can therefore be reused for many requests within its + /// TTL. + /// + /// Freshness alone is **not** replay protection: within the window the same + /// signature can be presented any number of times. That is acceptable for + /// reads, which are idempotent. Mutations must go through + /// [`Self::verify_and_consume_mutation_signature`] instead, which also + /// consumes the signature. /// /// For JWT tokens (containing `.`), the JWT's own P-256 signature is /// verified first via `AuthenticationService::verify_jwt_signature` @@ -343,6 +364,115 @@ where }) } + /// Verify a signature for a **mutation**, and consume it so the same signed + /// request cannot be applied twice. + /// + /// The freshness check inside signature verification only bounds how long a + /// captured signature stays usable — within that window it can be presented + /// any number of times. Reads tolerate that, but `update`, `delete`, + /// `invalidate` and `manage` are not idempotent, so a replay is not a + /// duplicate: it is a state rollback. Replaying a signed update of old + /// ciphertext after a legitimate update commits the old bytes as a *new* + /// version whose parent is the current head, so the stale content becomes + /// the latest version. + /// + /// The request identity is the digest of the request signature itself. The + /// signature already commits to operation, resource, timestamp and body + /// digest, so an identical digest means an identical request — and no new + /// field has to be added to the wire format to carry a nonce. + /// + /// Consumption happens after verification (an invalid signature must not be + /// able to burn a legitimate request's identity) and before any state is + /// committed. + #[allow(clippy::too_many_arguments)] + async fn verify_and_consume_mutation_signature( + &self, + auth_service: &dyn AuthenticationService, + token: &AuthToken, + signature: &[u8], + operation: &str, + resource: &str, + timestamp: Option, + request_body: Option<&[u8]>, + ) -> Result<(), StateNodeError> { + self.verify_caller_signature( + auth_service, + token, + signature, + operation, + resource, + timestamp, + request_body, + ) + .await?; + + use sha2::{Digest, Sha256}; + let request_id = Sha256::digest(signature); + let now = timestamp.unwrap_or_else(current_timestamp); + + let first_time = self + .consumed_requests + .record_if_absent(&request_id, now) + .map_err(|e| StateNodeError::StorageError(e.to_string()))?; + + if !first_time { + return Err(StateNodeError::RequestAlreadyApplied(format!( + "this signed {operation} request for {resource} has already been applied. \ + Mutations are single-use: re-sign the request with a fresh timestamp instead \ + of resending the previous signature." + ))); + } + + Ok(()) + } + + /// Consume a mutation signature on the **relay** path, where this node + /// cannot verify it. + /// + /// A relay holds no access policy, so it forwards the caller's credentials + /// to a member and lets the member decide. That means the member is the only + /// one that consumes the signature — and a caller who keeps re-sending to the + /// relay gets the request applied again every time the relay happens to pick + /// a member that has not seen it yet. The replay the consumption record is + /// supposed to stop therefore still succeeds through a relay. + /// + /// So the relay records the signature too, and refuses to forward one it has + /// already forwarded. This is not a substitute for the member-side check + /// (a caller can always talk to a different relay); it closes the specific + /// hole where the *same* relay launders the *same* signature repeatedly. + /// + /// No verification happens here, which means an unauthenticated caller can + /// burn an arbitrary signature digest on this node by presenting it once. + /// The cost of that is bounded: it only affects this node, only for the + /// freshness window, and only for a digest the attacker already has — if + /// they hold the signature they can replay it themselves anyway. + fn consume_relayed_mutation_signature( + &self, + signature: &[u8], + operation: &str, + resource: &str, + timestamp: Option, + ) -> Result<(), StateNodeError> { + use sha2::{Digest, Sha256}; + let request_id = Sha256::digest(signature); + let now = timestamp.unwrap_or_else(current_timestamp); + + let first_time = self + .consumed_requests + .record_if_absent(&request_id, now) + .map_err(|e| StateNodeError::StorageError(e.to_string()))?; + + if !first_time { + return Err(StateNodeError::RequestAlreadyApplied(format!( + "this signed {operation} request for {resource} was already relayed by this node. \ + Mutations are single-use: re-sign the request with a fresh timestamp instead \ + of resending the previous signature." + ))); + } + + Ok(()) + } + /// Get the local node ID. pub fn local_node_id(&self) -> &str { &self.local_node_id @@ -845,7 +975,7 @@ where .map_err(|e| StateNodeError::AuthenticationFailed(e.to_string()))?; // 1.5. Verify request signature - self.verify_caller_signature( + self.verify_and_consume_mutation_signature( auth_service.as_ref(), token, request_signature, @@ -1086,7 +1216,7 @@ where .map_err(|e| StateNodeError::AuthenticationFailed(e.to_string()))?; // Verify request signature - self.verify_caller_signature( + self.verify_and_consume_mutation_signature( auth_service.as_ref(), token, request_signature, @@ -1153,6 +1283,16 @@ where return Err(StateNodeError::ContentNotFound(content_id_vo.clone())); } + // 転送前にこのノードでも署名を消費する。member 側だけで消費すると、 + // 同じ署名を relay へ送り直すたびに「まだ見ていない member」へ + // 振り分けられて再適用できてしまう。 + self.consume_relayed_mutation_signature( + request_signature, + "delete", + content_id, + timestamp, + )?; + // Resolve members from our local record, or via DHT discovery when // we hold no record (bug #93), then relay with failover. let members = self.resolve_members(content_id).await?; @@ -1256,7 +1396,7 @@ where .map_err(|e| StateNodeError::AuthenticationFailed(e.to_string()))?; // Verify request signature - self.verify_caller_signature( + self.verify_and_consume_mutation_signature( auth_service.as_ref(), token, request_signature, @@ -1327,6 +1467,16 @@ where return Err(StateNodeError::ContentNotFound(content_id_vo.clone())); } + // 転送前にこのノードでも署名を消費する。member 側だけで消費すると、 + // 同じ署名を relay へ送り直すたびに「まだ見ていない member」へ + // 振り分けられて再適用できてしまう。 + self.consume_relayed_mutation_signature( + request_signature, + "update", + content_id, + timestamp, + )?; + // Resolve members from our local record, or via DHT discovery when // we hold no record (bug #93), then relay with failover. let members = self.resolve_members(content_id).await?; @@ -1427,7 +1577,7 @@ where let sig = request_signature.ok_or_else(|| { StateNodeError::AuthenticationFailed("Request signature is required".to_string()) })?; - self.verify_caller_signature( + self.verify_and_consume_mutation_signature( auth_service.as_ref(), token, sig, @@ -1548,6 +1698,11 @@ where return Err(StateNodeError::ContentNotFound(content_id_vo.clone())); } + // NOTE: invalidate は local / relay の分岐より前に + // `verify_and_consume_mutation_signature` を通しているので、 + // ここで改めて消費する必要はない(するとこのノード自身の + // 1 回目の転送が 409 になる)。 + // // Resolve members from our local record, or via DHT discovery when // we hold no record (bug #93), then relay with failover. let members = self.resolve_members(content_id).await?; @@ -1630,7 +1785,7 @@ where // decides how many members get added, so it is signed too — see // `add_members_signing_body` for why the canonical encoding is used // instead of the raw JSON bytes. - self.verify_caller_signature( + self.verify_and_consume_mutation_signature( auth_service.as_ref(), token, request_signature, @@ -2373,7 +2528,7 @@ where .map_err(|_| AccessControlError::NotAuthorized)?; // Verify request signature - self.verify_caller_signature( + self.verify_and_consume_mutation_signature( auth_service.as_ref(), token, request_signature, @@ -2383,7 +2538,12 @@ where None, ) .await - .map_err(|_| AccessControlError::InvalidSignature)?; + // 再送の拒否は偽造の拒否と区別する。潰してしまうと、運用者は + // 「攻撃された」のか「正規リクエストが二重に届いた」のか判断できない。 + .map_err(|e| match e { + StateNodeError::RequestAlreadyApplied(_) => AccessControlError::AlreadyApplied, + _ => AccessControlError::InvalidSignature, + })?; let content_id_vo = ContentId::new(update.content_id.clone()) .map_err(|_| AccessControlError::NotAuthorized)?; diff --git a/monas-state-node/src/domain/access_control.rs b/monas-state-node/src/domain/access_control.rs index 46a270a..47a5537 100644 --- a/monas-state-node/src/domain/access_control.rs +++ b/monas-state-node/src/domain/access_control.rs @@ -161,6 +161,11 @@ pub enum AccessControlError { ContentNotFound, /// Signature verification failed. InvalidSignature, + /// The signature verified, but this exact signed request was already + /// applied. Kept distinct from [`Self::InvalidSignature`] because the two + /// mean opposite things to an operator: a forged request versus a genuine + /// one arriving twice. + AlreadyApplied, /// The signer is not authorized to update access control. NotAuthorized, } @@ -177,6 +182,10 @@ impl std::fmt::Display for AccessControlError { } AccessControlError::ContentNotFound => write!(f, "Content not found"), AccessControlError::InvalidSignature => write!(f, "Invalid signature"), + AccessControlError::AlreadyApplied => write!( + f, + "this signed request has already been applied (mutations are single-use)" + ), AccessControlError::NotAuthorized => write!(f, "Not authorized"), } } diff --git a/monas-state-node/src/domain/errors.rs b/monas-state-node/src/domain/errors.rs index 9a4dd75..c047dca 100644 --- a/monas-state-node/src/domain/errors.rs +++ b/monas-state-node/src/domain/errors.rs @@ -41,6 +41,13 @@ pub enum StateNodeError { #[error("Authentication failed: {0}")] AuthenticationFailed(String), + /// The signature verified, but this exact signed request was already + /// applied. Distinct from [`Self::AuthenticationFailed`] because the two + /// mean opposite things to an operator: a forged or expired request versus + /// a genuine one arriving twice. + #[error("Request already applied: {0}")] + RequestAlreadyApplied(String), + #[error("Authorization failed: {0}")] AuthorizationFailed(String), @@ -110,6 +117,9 @@ impl StateNodeError { StateNodeError::PermissionDenied(_) => StatusCode::FORBIDDEN, StateNodeError::InvalidUcanToken(_) => StatusCode::UNAUTHORIZED, StateNodeError::AuthenticationFailed(_) => StatusCode::UNAUTHORIZED, + // 409: the request was well-formed and authentic, but conflicts + // with state that already exists (it was applied once already). + StateNodeError::RequestAlreadyApplied(_) => StatusCode::CONFLICT, StateNodeError::AuthorizationFailed(_) => StatusCode::FORBIDDEN, StateNodeError::InsufficientCapacity { .. } => StatusCode::INSUFFICIENT_STORAGE, StateNodeError::NoAvailableMembers => StatusCode::SERVICE_UNAVAILABLE, diff --git a/monas-state-node/src/port/consumed_request_store.rs b/monas-state-node/src/port/consumed_request_store.rs new file mode 100644 index 0000000..ca62f42 --- /dev/null +++ b/monas-state-node/src/port/consumed_request_store.rs @@ -0,0 +1,133 @@ +//! 一度受理した署名済みリクエストの記録(mutation の再送防止)。 +//! +//! 署名内 timestamp の鮮度チェック(5分窓)は「古い署名を無限に使い回せない」 +//! ことしか保証しない。窓の中では同じ署名を何度でも通せる。 +//! +//! update / delete は冪等ではないので、これは単なる重複ではなく**状態の +//! 巻き戻し**になる。攻撃者が署名済みの旧 ciphertext 更新 A を捕まえておき、 +//! 正規の更新 B が入った後に A を再送すると、サーバは A を「その時点の最新版を +//! 親とする新しい操作」として commit する。結果、古い ciphertext が最新版に +//! なってしまう。 +//! +//! そこで、受理した mutation リクエストを一意に識別する値を記録し、2度目の +//! 提示を拒否する。識別子には**リクエスト署名そのものの digest** を使う。 +//! 署名は operation / resource / timestamp / body digest すべてに束縛されて +//! いるので、これが一致する = 完全に同じリクエストの再送である。新しい +//! フィールドをワイヤ形式へ足す必要がない。 +//! +//! ## 保持期間 +//! +//! 記録は署名の鮮度窓(`MAX_REQUEST_AGE_SECS`)を超えたら捨ててよい。窓の外へ +//! 出た署名は、この記録が無くても鮮度チェックで拒否されるからである。よって +//! ストアは無制限には育たず、GC も「期限切れを消す」だけで済む。 + +use std::collections::HashMap; +use std::sync::Mutex; + +/// 署名の鮮度窓。`MonasAccountAdapter` の `MAX_AGE_SECS` と揃えること。 +/// これを超えた記録は保持しても意味がない(署名側が先に期限切れになる)。 +pub const CONSUMED_REQUEST_RETENTION_SECS: u64 = 300; + +#[derive(Debug, thiserror::Error)] +pub enum ConsumedRequestStoreError { + #[error("consumed request store error: {0}")] + Storage(String), +} + +/// 受理済み mutation リクエストの記録。 +pub trait ConsumedRequestStore: Send + Sync { + /// `request_id` を「今回初めて受理した」ものとして記録する。 + /// + /// 戻り値が `false` = 既に記録済み(= 再送)。呼び出し側は commit せずに + /// 拒否すること。記録と判定は不可分でなければならない。同時に届いた同一 + /// リクエストの両方が `true` を受け取ると、二重適用を防げない。 + /// + /// `now` は署名検証で使った現在時刻(Unix 秒)。期限切れ記録の掃除に使う。 + fn record_if_absent( + &self, + request_id: &[u8], + now: u64, + ) -> Result; +} + +/// プロセス内 `HashMap` 実装。 +/// +/// 記録が揮発してよいのは、保持期間が署名の鮮度窓と同じだからである。 +/// ノードが再起動すると窓の中の記録は失われるが、そこで通り得る再送は +/// 「再起動をまたいで 5 分以内に届いた同一署名」に限られる。永続化した +/// 場合との差はこの一点で、fsync のコストを毎 mutation に載せるよりも +/// 割に合うと判断した。 +/// +/// なお、この記録はノードごとに独立である。複数のレプリカへ同じ署名を送れば +/// それぞれで1回ずつ受理される。CRDT は同じ操作の重複適用に耐えるが、 +/// 「どのノードから見ても厳密に1回」を保証するものではない。 +#[derive(Default)] +pub struct InMemoryConsumedRequestStore { + /// request id -> 受理時刻(Unix 秒) + inner: Mutex, u64>>, +} + +impl ConsumedRequestStore for InMemoryConsumedRequestStore { + fn record_if_absent( + &self, + request_id: &[u8], + now: u64, + ) -> Result { + let mut guard = self + .inner + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + // 期限切れの掃除。挿入のたびに走らせるので、ストアのサイズは + // 「鮮度窓の中に届いた mutation 数」で頭打ちになる。 + guard.retain(|_, accepted_at| { + now.saturating_sub(*accepted_at) < CONSUMED_REQUEST_RETENTION_SECS + }); + + if guard.contains_key(request_id) { + return Ok(false); + } + guard.insert(request_id.to_vec(), now); + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_presentation_is_accepted_and_the_replay_is_not() { + let store = InMemoryConsumedRequestStore::default(); + let now = 1_000_000; + + assert!(store.record_if_absent(b"sig-a", now).unwrap()); + assert!(!store.record_if_absent(b"sig-a", now).unwrap()); + // 違う署名は独立 + assert!(store.record_if_absent(b"sig-b", now).unwrap()); + } + + /// 鮮度窓を過ぎた記録は捨てられる。捨てても安全なのは、その署名が + /// 記録の有無によらず鮮度チェックで拒否されるからである。 + #[test] + fn records_are_dropped_once_the_signature_itself_would_expire() { + let store = InMemoryConsumedRequestStore::default(); + let now = 1_000_000; + + assert!(store.record_if_absent(b"sig", now).unwrap()); + assert!(!store.record_if_absent(b"sig", now + 1).unwrap()); + assert!(!store + .record_if_absent(b"sig", now + CONSUMED_REQUEST_RETENTION_SECS - 1) + .unwrap()); + + // 窓の外。記録は掃除され、ストアは育たない + assert!(store + .record_if_absent(b"sig", now + CONSUMED_REQUEST_RETENTION_SECS) + .unwrap()); + assert_eq!( + store.inner.lock().unwrap().len(), + 1, + "expired records must be pruned rather than accumulate" + ); + } +} diff --git a/monas-state-node/src/port/mod.rs b/monas-state-node/src/port/mod.rs index d99b421..46e198a 100644 --- a/monas-state-node/src/port/mod.rs +++ b/monas-state-node/src/port/mod.rs @@ -6,6 +6,7 @@ pub mod auth_token; pub mod authentication_service; pub mod authorization_service; +pub mod consumed_request_store; pub mod content_repository; pub mod event_publisher; pub mod peer_network; @@ -15,6 +16,7 @@ pub mod public_key_registry; pub use auth_token::AuthToken; pub use authentication_service::AuthenticationService; pub use authorization_service::{AuthorizationRequest, AuthorizationResult, AuthorizationService}; +pub use consumed_request_store::{ConsumedRequestStore, InMemoryConsumedRequestStore}; pub use content_repository::{CommitResult, ContentRepository, SerializedOperation}; pub use event_publisher::EventPublisher; pub use peer_network::PeerNetwork; diff --git a/monas-state-node/src/presentation/http_api.rs b/monas-state-node/src/presentation/http_api.rs index 9c64c93..1a7942e 100644 --- a/monas-state-node/src/presentation/http_api.rs +++ b/monas-state-node/src/presentation/http_api.rs @@ -197,6 +197,15 @@ impl IntoResponse for StateNodeError { tracing::warn!("authentication failed: {detail}"); "Authentication failed".to_string() } + // 再送であることは呼び出し側に伝えてよい。伝えないと、正規の + // クライアントは「認証に失敗した」と読んで同じ署名で延々と + // 再試行してしまう(正しい対処は新しい timestamp で署名し直すこと)。 + StateNodeError::RequestAlreadyApplied(detail) => { + tracing::warn!("replayed request rejected: {detail}"); + "This signed request has already been applied. Re-sign the request with a fresh \ + timestamp instead of resending the previous signature." + .to_string() + } StateNodeError::AuthorizationFailed(_) => "Authorization failed".to_string(), StateNodeError::InvalidCid(_) => "Invalid content identifier".to_string(), StateNodeError::InvalidConfiguration(_) => "Invalid request".to_string(), diff --git a/monas-state-node/tests/integration_test.rs b/monas-state-node/tests/integration_test.rs index 8df4269..19e6223 100644 --- a/monas-state-node/tests/integration_test.rs +++ b/monas-state-node/tests/integration_test.rs @@ -518,7 +518,7 @@ async fn test_crdt_since_version_filtering() { // ============================================================================ use monas_state_node::application_service::state_node_service::ServiceConfig; -use monas_state_node::domain::access_control::AccessControlUpdate; +use monas_state_node::domain::access_control::{AccessControlError, AccessControlUpdate}; /// Create a test service with access control repository. async fn create_test_service_with_ac() -> (Arc, Arc, TempDir) @@ -635,6 +635,132 @@ async fn test_access_control_update_and_verify() { assert!(result, "Future tokens should be allowed"); } +/// 署名済み mutation の再送は拒否され、しかも「偽造」ではなく「再送」として +/// 報告される。 +/// +/// 署名内 timestamp の鮮度チェックは「古い署名を無限に使い回せない」ことしか +/// 保証せず、5分の窓の中では同じ署名を何度でも通せてしまう。 +/// +/// なお `update_access_control` に限っては、より古い `min_valid_issued_at` への +/// 巻き戻しはドメイン側の単調性チェックが既に弾いていた。ここで塞ぐのは +/// **同じ値の再送**で、そちらは素通りしていた。エラーの種類まで検証するのは、 +/// 潰してしまうと運用者が「攻撃された」のか「正規リクエストが二重に届いた」 +/// のか区別できなくなるため。 +#[tokio::test] +async fn test_signed_mutation_cannot_be_replayed_after_a_newer_one() { + let (service, _crdt_repo, _temp_dir) = create_test_service_with_ac().await; + + service.init_access_control("content-1").await.unwrap(); + + let sign = |min_valid: u64| { + let update = AccessControlUpdate::new("content-1".to_string(), min_valid); + let (signature, public_key) = sign_access_control_update(&update); + update.with_signature(signature, public_key) + }; + + // 各リクエストは固有のリクエスト署名を持つ(実運用では署名対象に + // timestamp と body が入るので、内容が違えば署名も必ず違う)。 + let sig_a: Vec = vec![0xAA]; + let sig_b: Vec = vec![0xBB]; + + let update_a = sign(1000); + let update_b = sign(2000); + + // A → B の順に正規適用 + service + .update_access_control( + &update_a, + Some(&test_token()), + Some(&sig_a), + test_timestamp(), + ) + .await + .expect("first update should apply"); + service + .update_access_control( + &update_b, + Some(&test_token()), + Some(&sig_b), + test_timestamp(), + ) + .await + .expect("second update should apply"); + assert_eq!( + service + .get_access_control("content-1") + .await + .unwrap() + .unwrap() + .min_valid_issued_at(), + 2000 + ); + + // 攻撃者が捕まえておいた B の署名を再送する。 + // (A の再送はドメイン側の単調性チェックが別途弾くので、消費記録が + // 効いていることを見るにはこちらを使う) + let replay = service + .update_access_control( + &update_b, + Some(&test_token()), + Some(&sig_b), + test_timestamp(), + ) + .await; + assert!(replay.is_err(), "replaying a consumed signature must fail"); + let err = replay.unwrap_err(); + assert!( + matches!(err, AccessControlError::AlreadyApplied), + "a replay must be reported as such, not as a forged signature: {err}" + ); + + // 失効時刻は巻き戻っていない + assert_eq!( + service + .get_access_control("content-1") + .await + .unwrap() + .unwrap() + .min_valid_issued_at(), + 2000, + "a rejected replay must not roll the state back" + ); +} + +/// 同一署名の単純な再送(A → replay(A))も拒否される。 +/// `invalidate` は再送のたびに `min_valid_issued_at` を現在時刻へ進めるため、 +/// 通してしまうと正規リクエスト後に発行された Token まで巻き添えで失効する。 +#[tokio::test] +async fn test_signed_mutation_is_single_use() { + let (service, _crdt_repo, _temp_dir) = create_test_service_with_ac().await; + + service.init_access_control("content-1").await.unwrap(); + + let update = AccessControlUpdate::new("content-1".to_string(), 1000); + let (signature, public_key) = sign_access_control_update(&update); + let update = update.with_signature(signature, public_key); + let request_signature: Vec = vec![0xC0, 0xFF, 0xEE]; + + service + .update_access_control( + &update, + Some(&test_token()), + Some(&request_signature), + test_timestamp(), + ) + .await + .expect("first presentation should apply"); + + let replay = service + .update_access_control( + &update, + Some(&test_token()), + Some(&request_signature), + test_timestamp(), + ) + .await; + assert!(replay.is_err(), "the same signature must not apply twice"); +} + #[tokio::test] async fn test_access_control_get() { let (service, _crdt_repo, _temp_dir) = create_test_service_with_ac().await; From ad7efd5e31b03f54a828c254579fb08056d0486d Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 29 Jul 2026 01:35:02 +0900 Subject: [PATCH 39/48] docs: record why the signature freshness window is 300 seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constant was introduced with a bare "Reject if timestamp is older than 5 minutes" comment and no rationale anywhere, which makes it impossible to tell whether it may be changed. RFC 9449 (DPoP) §11.1 splits the problem exactly the way this code now does: accept a proof only for "a relatively brief period on the order of seconds or minutes", and *separately* store its identifier for that window so it cannot be used twice — a single-use check being what actually defends against replay. That the freshness window alone is insufficient is stated at the specification level, not just here. The 300s ceiling matches what AWS SigV4 uses for the same job. The floor comes from how long a legitimate request takes to arrive: a gateway hop, then a relay with a 30s per-peer budget (`PEER_NETWORK_TIMEOUT`), possibly after DHT discovery, with failover retrying across candidates. So the window is loose rather than measured. Worth tightening once real latency is known — it directly bounds how long a captured read signature stays replayable — but never below the failover budget, or legitimate reads start failing. Recorded so that trade-off is visible to whoever touches it next. --- docs/design.md | 6 ++++ .../auth/monas_account_adapter.rs | 34 +++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/design.md b/docs/design.md index 5d76749..4b6ab9b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -353,6 +353,12 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。 リクエストの同一性には**リクエスト署名そのもののdigest**を使う。署名は既に操作・リソース・timestamp・body digestすべてに束縛されているので、digestが一致する=完全に同じリクエストの再送であり、nonceのような新しいフィールドをワイヤ形式へ足す必要がない。記録の保持期間は鮮度窓と同じでよい(窓の外へ出た署名は記録が無くても鮮度チェックで落ちる)ため、記録は無制限には育たない。読み取りは冪等なのでこの記録の対象外である。 +この2層構成は[RFC 9449(DPoP)§11.1](https://www.rfc-editor.org/rfc/rfc9449.html#section-11.1)と同じ形である。同RFCはサーバに対し、proofを「秒〜分のオーダーの比較的短い期間」だけ受理することを要求したうえで、**別途**その期間中はproofの識別子を保存して二重使用を防ぐことを推奨し、「厳格に運用すればこのsingle-useチェックはreplayに対する非常に強い防御となる」と述べている。鮮度チェックだけでは不十分であることが、仕様レベルで明示されている。 + +**鮮度窓の値(300秒)の根拠。** 上限はAWS SigV4が同じ役割に使っている値(「リクエストはtimestampから5分以内にAWSへ到達しなければならない」)に合わせたもので、RFC 9449の言う「秒〜分」の範囲に収まる。下限を決めるのは正当なリクエストが到達するのに要する時間で、gatewayを1ホップ、さらにstate-nodeのrelay(ピアあたりの予算は`PEER_NETWORK_TIMEOUT` = 30秒)が入り、その前にDHT探索が挟まることもあり、failoverは候補を順に試す。数十秒は現実的にあり得るため、300秒は余裕を大きく取った値である。 + +つまりこの窓は**計測に基づいて詰めた値ではなく、緩めに置いた値**である。捕捉されたread署名がどれだけの間再利用可能かを直接決めるため、実際のリクエスト遅延を計測したうえで縮める価値はある。ただしrelayのfailover予算を下回ると正当なreadが落ち始めるので、そこが下限になる。 + したがってTokenはTTL内で何度でも再利用できる一方、**個々のリクエスト署名は使い切り**である。記録はノードごとに独立で、同じ署名を複数のレプリカへ送ればそれぞれで1回ずつ受理される(CRDTは同一操作の重複適用に耐えるが、ネットワーク全体で厳密に1回を保証するものではない)。またデフォルト実装はプロセス内に閉じており、再起動をまたいで5分以内に届いた同一署名までは防げない。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 ### ビザンチン耐性 diff --git a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs index 60b9dd5..ba01329 100644 --- a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs @@ -119,14 +119,41 @@ impl MonasAccountAdapter { ) .context("Signature verification failed")?; - // Check timestamp to prevent replay attacks + // Bound how long a signature stays usable. + // + // This is a freshness check, NOT replay protection: inside the window + // the same signature can be presented any number of times. RFC 9449 + // (DPoP) §11.1 makes the same split — it requires servers to accept a + // proof only "for a relatively brief period on the order of seconds or + // minutes", and separately recommends storing the proof's identifier + // for that window so a proof cannot be used twice, noting that a + // single-use check "provides a very strong protection against DPoP + // proof replay". Monas does the same: mutations are consumed by + // `verify_and_consume_mutation_signature`, keyed on the signature + // digest. Reads are idempotent and rely on freshness alone. + // + // https://www.rfc-editor.org/rfc/rfc9449.html#section-11.1 if let Some(timestamp) = context.timestamp { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - // Reject if timestamp is older than 5 minutes + // 300s matches the ceiling AWS SigV4 uses for the same job ("a + // request must reach AWS within five minutes of the time stamp"), + // and sits inside RFC 9449's "seconds or minutes". + // + // The floor is set by how long a request legitimately takes to + // arrive: gateway hop, then a state-node relay whose per-peer + // budget is `PEER_NETWORK_TIMEOUT` (30s), possibly preceded by DHT + // discovery, with failover retrying across candidates. Tens of + // seconds is realistic; 300s leaves generous headroom. + // + // The window is therefore loose rather than tuned. It could be + // tightened once real request latency is measured, which is worth + // doing because it directly bounds how long a captured read + // signature stays replayable — but it must not be cut below the + // relay failover budget or legitimate reads start failing. const MAX_AGE_SECS: u64 = 300; if now > timestamp + MAX_AGE_SECS { return Err(anyhow::anyhow!( @@ -134,7 +161,8 @@ impl MonasAccountAdapter { )); } - // Reject if timestamp is in the future (allow 30 seconds clock skew) + // Future-dated timestamps are a clock-sync problem, not a latency + // one, so they get their own much smaller allowance. const MAX_CLOCK_SKEW_SECS: u64 = 30; if timestamp > now + MAX_CLOCK_SKEW_SECS { return Err(anyhow::anyhow!("Invalid timestamp (too far in the future)")); From d729fe907ecce22e77944cfe27ad5cc723883d23 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 29 Jul 2026 01:56:31 +0900 Subject: [PATCH 40/48] fix(state-node): derive the replay identity from the signed message, not the signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consumed-request record was keyed on `sha256(signature_bytes)`. ECDSA signatures are malleable: for a valid `(r, s)` the value `(r, n - s)` verifies against the same message and the same key, so one authorized request has two distinct signature encodings — and therefore had two distinct identities. An attacker only had to convert a captured signature once to walk straight past the single-use check and re-commit the mutation, which is exactly the state rollback the record exists to prevent. Verified against p256 0.13: both encodings verify, the bytes differ, the digests differ. The identity now comes from the signed message plus the signer. The message already binds operation, resource, timestamp and body digest, so it identifies the request without depending on how the signature happens to be encoded. The token is mixed in, length-prefixed, so two callers cannot collide on one identity and consume each other's requests. `build_signing_message` is now the single construction used by both verification and consumption, so the two cannot drift. High-S is deliberately still accepted at verification. Rejecting it would break every existing caller — no signer in this repo, nor monas-account which real clients use, normalizes before sending, and S is high about half the time. Requiring low-S is the stricter end state but has to land in the signers first. Nothing depends on the encoding being unique any more; a test pins that both encodings verify, so the assumption cannot creep back in. Fixing this surfaced a second gap of the same shape as the unsigned `add-members.count`: `revoke` passed `None` as its request body, leaving `new_min_valid_issued_at` — which decides *which* tokens get revoked — outside the signature. It now signs over `AccessControlUpdate::signing_message()`, the canonical encoding the owner already signs. Also stops feeding the caller's timestamp to the consumed-request store. Retention is now measured on the node's own clock; using the signed timestamp let a caller inside the allowed skew present a future-dated request to evict entries that were still live, then re-present an older signature whose record had just been dropped. Tests: a re-encoded signature does not buy a second application (fails if the ID reverts to the signature digest); the request ID is stable across encodings and distinct per operation/resource/timestamp/body/signer, with token and message separated so they cannot be re-split; a revoke cutoff cannot be substituted; a live record cannot be evicted by out-of-order presentations. --- .../application_service/state_node_service.rs | 221 +++++++++++++++--- .../auth/monas_account_adapter.rs | 59 ++++- .../infrastructure/auth/signature_verifier.rs | 53 +++++ .../src/port/consumed_request_store.rs | 45 +++- monas-state-node/tests/integration_test.rs | 63 ++++- 5 files changed, 398 insertions(+), 43 deletions(-) diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index a6ddbd1..c5c4c88 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -363,6 +363,54 @@ where /// verified first via `AuthenticationService::verify_jwt_signature` /// (over the received wire bytes), and the request signature is then /// verified against the audience (`aud`) key. + /// Build the canonical message a request signature commits to. + /// + /// Single source of truth for both verification and the replay-consumption + /// identity, so the two can never drift apart. + fn build_signing_message( + operation: &str, + resource: &str, + timestamp: u64, + request_body: Option<&[u8]>, + ) -> String { + let metadata = RequestMetadata { + timestamp, + operation: operation.to_string(), + resource: resource.to_string(), + }; + match request_body { + Some(body) => { + use sha2::{Digest, Sha256}; + let digest = hex::encode(Sha256::digest(body)); + metadata.signing_message_with_body_digest(&digest) + } + None => metadata.signing_message(), + } + } + + /// Identity of a mutation request, for the single-use record. + /// + /// Derived from the **signed message plus the signer**, never from the + /// signature bytes. ECDSA signatures are malleable: for a valid `(r, s)` + /// the value `(r, n - s)` verifies against the same message and key, so + /// hashing the signature would give one authorized request two different + /// identities — and re-sending the converted form would slip straight past + /// the consumption record and re-commit the mutation. + /// + /// The message already binds operation, resource, timestamp and body + /// digest. The token is mixed in so two different callers cannot collide on + /// one identity, which would let one of them consume the other's request. + fn mutation_request_id(token: &AuthToken, signing_message: &str) -> Vec { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + // Length-prefixed so a token ending in the message's leading bytes + // cannot be re-split into a different (token, message) pair. + hasher.update((token.as_str().len() as u64).to_be_bytes()); + hasher.update(token.as_str().as_bytes()); + hasher.update(signing_message.as_bytes()); + hasher.finalize().to_vec() + } + #[allow(clippy::too_many_arguments)] async fn verify_caller_signature( &self, @@ -401,19 +449,7 @@ where // operation と resource に必ず束縛される。body がある場合はその digest も // 含める。これがないと、ある content 向けに取得した update の // body+署名を別 content や create へ転用できてしまう。 - let metadata = RequestMetadata { - timestamp: ts, - operation: operation.to_string(), - resource: resource.to_string(), - }; - let message = match request_body { - Some(body) => { - use sha2::{Digest, Sha256}; - let digest = hex::encode(Sha256::digest(body)); - metadata.signing_message_with_body_digest(&digest) - } - None => metadata.signing_message(), - }; + let message = Self::build_signing_message(operation, resource, ts, request_body); auth_service .verify_request_signature(token, signature, &message, timestamp) @@ -438,10 +474,12 @@ where /// version whose parent is the current head, so the stale content becomes /// the latest version. /// - /// The request identity is the digest of the request signature itself. The - /// signature already commits to operation, resource, timestamp and body - /// digest, so an identical digest means an identical request — and no new - /// field has to be added to the wire format to carry a nonce. + /// The request identity comes from the **signed message and the signer**, + /// never from the signature bytes — see [`Self::mutation_request_id`]. The + /// message already commits to operation, resource, timestamp and body + /// digest, so no new field has to be added to the wire format to carry a + /// nonce, and no encoding of the signature can masquerade as a second + /// request. /// /// Consumption happens after verification (an invalid signature must not be /// able to burn a legitimate request's identity) and before any state is @@ -468,13 +506,22 @@ where ) .await?; - use sha2::{Digest, Sha256}; - let request_id = Sha256::digest(signature); - let now = timestamp.unwrap_or_else(current_timestamp); + // Freshness already established that `timestamp` is present. + let ts = timestamp.ok_or_else(|| { + StateNodeError::AuthenticationFailed( + "X-Request-Timestamp is required for request signature verification".to_string(), + ) + })?; + let signing_message = Self::build_signing_message(operation, resource, ts, request_body); + let request_id = Self::mutation_request_id(token, &signing_message); + // Retention is measured on *our* clock, not the caller's. Using the + // signed timestamp would let a caller inside the allowed skew present a + // future-dated request to evict entries that are still live, then + // re-present an older signature whose record had just been dropped. let first_time = self .consumed_requests - .record_if_absent(&request_id, now) + .record_if_absent(&request_id, current_timestamp()) .map_err(|e| StateNodeError::StorageError(e.to_string()))?; if !first_time { @@ -510,18 +557,26 @@ where /// they hold the signature they can replay it themselves anyway. fn consume_relayed_mutation_signature( &self, - signature: &[u8], + token: &AuthToken, operation: &str, resource: &str, timestamp: Option, + request_body: Option<&[u8]>, ) -> Result<(), StateNodeError> { - use sha2::{Digest, Sha256}; - let request_id = Sha256::digest(signature); - let now = timestamp.unwrap_or_else(current_timestamp); + // Same identity the verifying member will derive, so a request consumed + // here is the same request there. Derived from the signed message, never + // from the signature bytes — see `mutation_request_id`. + let ts = timestamp.ok_or_else(|| { + StateNodeError::AuthenticationFailed( + "X-Request-Timestamp is required for request signature verification".to_string(), + ) + })?; + let signing_message = Self::build_signing_message(operation, resource, ts, request_body); + let request_id = Self::mutation_request_id(token, &signing_message); let first_time = self .consumed_requests - .record_if_absent(&request_id, now) + .record_if_absent(&request_id, current_timestamp()) .map_err(|e| StateNodeError::StorageError(e.to_string()))?; if !first_time { @@ -1413,12 +1468,7 @@ where // 転送前にこのノードでも署名を消費する。member 側だけで消費すると、 // 同じ署名を relay へ送り直すたびに「まだ見ていない member」へ // 振り分けられて再適用できてしまう。 - self.consume_relayed_mutation_signature( - request_signature, - "delete", - content_id, - timestamp, - )?; + self.consume_relayed_mutation_signature(token, "delete", content_id, timestamp, None)?; // Resolve members from our local record, or via DHT discovery when // we hold no record (bug #93), then relay with failover. @@ -1598,10 +1648,11 @@ where // 同じ署名を relay へ送り直すたびに「まだ見ていない member」へ // 振り分けられて再適用できてしまう。 self.consume_relayed_mutation_signature( - request_signature, + token, "update", content_id, timestamp, + Some(data), )?; // Resolve members from our local record, or via DHT discovery when @@ -2662,7 +2713,11 @@ where "revoke", &update.content_id, timestamp, - None, + // `new_min_valid_issued_at` decides *which* tokens get revoked, so + // it has to be inside the signature — same reason `add-members` + // signs its `count`. `signing_message()` is the canonical encoding + // the owner already signs, so reusing it keeps one definition. + Some(&update.signing_message()), ) .await // 再送の拒否は偽造の拒否と区別する。潰してしまうと、運用者は @@ -3533,6 +3588,104 @@ mod tests { } } + /// mutation の同一性は「署名バイト列」ではなく「署名対象メッセージ + signer」 + /// から導く。ECDSA は malleable なので、署名の digest を ID にすると、 + /// 1 つの承認済みリクエストが 2 つの ID を持ってしまい、`s` を反転した + /// 署名を送り直すだけで消費記録をすり抜けて再適用できてしまう。 + #[test] + fn mutation_request_id_ignores_the_signature_encoding() { + let token = AuthToken::new("user:04aaaa".to_string()); + let msg = StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::build_signing_message( + "update", "content-1", 1_700_000_000, Some(b"payload") + ); + + let id_of = |t: &AuthToken, m: &str| { + StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::mutation_request_id(t, m) + }; + + // 同じ (token, message) は常に同じ ID。署名は一切入力に含まれないので、 + // その表現がどうであれ ID は動かない。 + assert_eq!(id_of(&token, &msg), id_of(&token, &msg)); + + // 署名対象が 1 ビットでも違えば別 ID + for other in [ + StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::build_signing_message( + "update", "content-2", 1_700_000_000, Some(b"payload") + ), + StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::build_signing_message( + "delete", "content-1", 1_700_000_000, Some(b"payload") + ), + StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::build_signing_message( + "update", "content-1", 1_700_000_001, Some(b"payload") + ), + StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::build_signing_message( + "update", "content-1", 1_700_000_000, Some(b"tampered") + ), + ] { + assert_ne!(id_of(&token, &msg), id_of(&token, &other)); + } + + // 別の caller は別 ID。同じにすると、一方が他方のリクエストを + // 先に消費してしまう。 + let other_token = AuthToken::new("user:04bbbb".to_string()); + assert_ne!(id_of(&token, &msg), id_of(&other_token, &msg)); + } + + /// token と message の境界が曖昧だと、片方の末尾ともう片方の先頭を + /// 付け替えた別の組み合わせが同じ ID になり得る。長さ前置でそれを防ぐ。 + #[test] + fn mutation_request_id_separates_token_from_message() { + let id_of = |t: &str, m: &str| { + StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::mutation_request_id(&AuthToken::new(t.to_string()), m) + }; + assert_ne!( + id_of("user:04ab", "cd-message"), + id_of("user:04", "abcd-message") + ); + } + #[tokio::test] async fn test_authorize_read_allows_owner() { let service = create_test_service("node-1"); diff --git a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs index ba01329..840dcb5 100644 --- a/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs @@ -129,8 +129,8 @@ impl MonasAccountAdapter { // for that window so a proof cannot be used twice, noting that a // single-use check "provides a very strong protection against DPoP // proof replay". Monas does the same: mutations are consumed by - // `verify_and_consume_mutation_signature`, keyed on the signature - // digest. Reads are idempotent and rely on freshness alone. + // `verify_and_consume_mutation_signature`, keyed on the signed message + // and signer. Reads are idempotent and rely on freshness alone. // // https://www.rfc-editor.org/rfc/rfc9449.html#section-11.1 if let Some(timestamp) = context.timestamp { @@ -711,6 +711,61 @@ mod tests { .is_err()); } + /// revoke の `new_min_valid_issued_at` は「どこまでの Token を失効させるか」 + /// を決めるので、署名対象に入っていなければならない。入っていないと、 + /// 同じ token・署名・timestamp のまま失効時刻だけ差し替えられる + /// (add-members の `count` と同種の欠落)。 + #[tokio::test] + async fn test_revoke_cutoff_cannot_be_substituted() { + use crate::domain::access_control::AccessControlUpdate; + use crate::port::auth_token::RequestMetadata; + use p256::ecdsa::signature::Signer; + use sha2::Digest; + + let (adapter, signing_key, key_id) = create_test_adapter(); + let token = AuthToken::new(key_id); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let metadata = RequestMetadata { + timestamp: ts, + operation: "revoke".to_string(), + resource: "content-1".to_string(), + }; + let digest_for = |cutoff: u64| { + let update = AccessControlUpdate::new("content-1".to_string(), cutoff); + hex::encode(sha2::Sha256::digest(update.signing_message())) + }; + + // caller は cutoff=2000 に対して署名する + let signed_message = metadata.signing_message_with_body_digest(&digest_for(2000)); + let signature: p256::ecdsa::Signature = signing_key.sign(signed_message.as_bytes()); + let signature_bytes = signature.to_vec(); + + assert!(adapter + .verify_request_signature(&token, &signature_bytes, &signed_message, Some(ts)) + .await + .is_ok()); + + // cutoff を差し替えた request は検証で落ちる + for tampered in [0u64, 1, 1999, 2001, u64::MAX] { + assert!( + adapter + .verify_request_signature( + &token, + &signature_bytes, + &metadata.signing_message_with_body_digest(&digest_for(tampered)), + Some(ts), + ) + .await + .is_err(), + "cutoff={tampered} への差し替えが通ってしまった" + ); + } + } + #[tokio::test] async fn test_verify_request_signature_expired_timestamp() { let (adapter, signing_key, key_id) = create_test_adapter(); diff --git a/monas-state-node/src/infrastructure/auth/signature_verifier.rs b/monas-state-node/src/infrastructure/auth/signature_verifier.rs index 5f8c174..b4de91c 100644 --- a/monas-state-node/src/infrastructure/auth/signature_verifier.rs +++ b/monas-state-node/src/infrastructure/auth/signature_verifier.rs @@ -84,6 +84,26 @@ impl SignatureVerifier { // Parse signature from DER or raw format let sig = Signature::from_slice(signature).context("Invalid P256 signature format")?; + // NOTE on ECDSA malleability. + // + // For any valid `(r, s)` the value `(r, n - s)` verifies against the + // same message and key, so one authorized request has two distinct + // signature encodings. Both are accepted here *on purpose*: rejecting + // high-S would break every existing caller, because none of the signers + // in this repo (nor monas-account, which real clients use) normalize + // before sending, and S is high about half the time. + // + // Nothing security-relevant depends on the encoding being unique. The + // single-use record for mutations is keyed on the signed *message* and + // the signer — see `StateNodeService::mutation_request_id` — precisely + // so that a re-encoded signature cannot masquerade as a second request. + // Deriving that identity from the signature bytes would have made this + // malleability a replay bypass. + // + // Normalizing at the signing side and then requiring low-S here is the + // stricter end state, but it is a wire-compatibility break that has to + // land in the signers first. + // Verify signature verifying_key .verify(message, &sig) @@ -154,6 +174,39 @@ mod tests { assert!(result.is_err()); } + /// ECDSA の malleability を明示的に記録しておく。 + /// + /// 有効な `(r, s)` に対し `(r, n - s)` も同じメッセージ・同じ鍵で検証を通り、 + /// バイト列は異なる。この検証器は**両方を受理する**(既存クライアントの + /// 署名を壊さないため)。したがって「署名バイト列は 1 リクエストにつき一意」 + /// という前提を置いてはならない。mutation の再送防止はこの前提を使わず、 + /// 署名対象メッセージから ID を導いている。 + #[test] + fn both_signature_encodings_verify_so_bytes_are_not_a_request_identity() { + use p256::ecdsa::Signature; + + let signing_key = SigningKey::random(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + let public_key_bytes = verifying_key.to_encoded_point(false).as_bytes().to_vec(); + + let message = b"monas-request-v1:6:update:9:content-1:1700000000:0:"; + let signature: Signature = signing_key.sign(message); + let flipped = Signature::from_scalars(*signature.r(), -*signature.s()).unwrap(); + + assert_ne!( + signature.to_vec(), + flipped.to_vec(), + "the two encodings must differ in bytes" + ); + for encoding in [signature.to_vec(), flipped.to_vec()] { + assert!( + SignatureVerifier::verify_request_signature(message, &encoding, &public_key_bytes,) + .is_ok(), + "both encodings authenticate the same request" + ); + } + } + #[test] fn test_verify_request_signature() { // Generate a test key pair diff --git a/monas-state-node/src/port/consumed_request_store.rs b/monas-state-node/src/port/consumed_request_store.rs index ca62f42..411c4dd 100644 --- a/monas-state-node/src/port/consumed_request_store.rs +++ b/monas-state-node/src/port/consumed_request_store.rs @@ -10,10 +10,16 @@ //! なってしまう。 //! //! そこで、受理した mutation リクエストを一意に識別する値を記録し、2度目の -//! 提示を拒否する。識別子には**リクエスト署名そのものの digest** を使う。 -//! 署名は operation / resource / timestamp / body digest すべてに束縛されて -//! いるので、これが一致する = 完全に同じリクエストの再送である。新しい -//! フィールドをワイヤ形式へ足す必要がない。 +//! 提示を拒否する。識別子は**署名対象メッセージと signer** から導く +//! (`StateNodeService::mutation_request_id`)。メッセージは operation / +//! resource / timestamp / body digest すべてに束縛されているので、これが +//! 一致する = 完全に同じリクエストの再送であり、新しいフィールドをワイヤ形式へ +//! 足す必要もない。 +//! +//! **署名バイト列の digest を識別子にしてはならない。** ECDSA は malleable で、 +//! 有効な `(r, s)` に対し `(r, n - s)` も同じメッセージ・同じ鍵で検証を通る。 +//! 署名を hash すると 1 つの承認済みリクエストが 2 つの識別子を持ち、攻撃者は +//! 捕捉した署名を 1 回変換するだけでこの記録をすり抜けられてしまう。 //! //! ## 保持期間 //! @@ -42,7 +48,11 @@ pub trait ConsumedRequestStore: Send + Sync { /// 拒否すること。記録と判定は不可分でなければならない。同時に届いた同一 /// リクエストの両方が `true` を受け取ると、二重適用を防げない。 /// - /// `now` は署名検証で使った現在時刻(Unix 秒)。期限切れ記録の掃除に使う。 + /// `now` は**このノードの現在時刻**(Unix 秒)。期限切れ記録の掃除に使う。 + /// + /// caller が申告した署名内 timestamp を渡してはならない。許容 skew の範囲で + /// 未来寄りの timestamp を持つ有効なリクエストを先に出せば、まだ鮮度窓の + /// 中にある記録を早期に掃除させられ、その後で古い署名を再提示できてしまう。 fn record_if_absent( &self, request_id: &[u8], @@ -107,6 +117,31 @@ mod tests { assert!(store.record_if_absent(b"sig-b", now).unwrap()); } + /// GC はサーバ時刻で動くので、caller が timestamp を前後させても + /// 記録を早期に落とせない。ここでは「時刻が進まない限り記録は消えない」 + /// ことを、時刻を戻す提示も含めて確認する。 + #[test] + fn out_of_order_presentations_cannot_evict_a_live_record() { + let store = InMemoryConsumedRequestStore::default(); + let now = 1_000_000; + + assert!(store.record_if_absent(b"victim", now).unwrap()); + + // 別リクエストが「未来寄り」に見える時刻で届いても、呼び出し側は + // サーバ時刻を渡すので窓は動かない = victim は生き残る。 + assert!(store.record_if_absent(b"other", now).unwrap()); + assert!( + !store.record_if_absent(b"victim", now).unwrap(), + "a live record must not be evictable by other traffic" + ); + + // 時刻が戻る向きの提示でも復活しない + assert!( + !store.record_if_absent(b"victim", now - 100).unwrap(), + "an earlier presentation must not resurrect the record" + ); + } + /// 鮮度窓を過ぎた記録は捨てられる。捨てても安全なのは、その署名が /// 記録の有無によらず鮮度チェックで拒否されるからである。 #[test] diff --git a/monas-state-node/tests/integration_test.rs b/monas-state-node/tests/integration_test.rs index 19e6223..7780bc2 100644 --- a/monas-state-node/tests/integration_test.rs +++ b/monas-state-node/tests/integration_test.rs @@ -658,8 +658,9 @@ async fn test_signed_mutation_cannot_be_replayed_after_a_newer_one() { update.with_signature(signature, public_key) }; - // 各リクエストは固有のリクエスト署名を持つ(実運用では署名対象に - // timestamp と body が入るので、内容が違えば署名も必ず違う)。 + // 消費記録の ID は署名対象メッセージから導かれる。revoke は + // `new_min_valid_issued_at` を body として署名対象に含めるので、 + // A(1000)と B(2000)は同じ timestamp でも別リクエストになる。 let sig_a: Vec = vec![0xAA]; let sig_b: Vec = vec![0xBB]; @@ -726,6 +727,64 @@ async fn test_signed_mutation_cannot_be_replayed_after_a_newer_one() { ); } +/// 署名のバイト表現を変えても再送は通らない。 +/// +/// ECDSA は malleable で、有効な `(r, s)` に対し `(r, n - s)` も同じメッセージ・ +/// 同じ鍵で検証を通る。消費記録の ID を署名バイト列の digest にしていると、 +/// 攻撃者は捕捉した署名を 1 回変換するだけで「別のリクエスト」として通せてしまい、 +/// 状態巻き戻しが成立する。ID は署名対象メッセージから導くのでこれは効かない。 +#[tokio::test] +async fn test_signed_mutation_replay_survives_signature_malleability() { + use p256::ecdsa::Signature; + + let (service, _crdt_repo, _temp_dir) = create_test_service_with_ac().await; + service.init_access_control("content-1").await.unwrap(); + + let update = AccessControlUpdate::new("content-1".to_string(), 1000); + let (signature, public_key) = sign_access_control_update(&update); + let update = update.with_signature(signature, public_key); + + // 実際の P-256 署名を作り、その malleable な相方を用意する。 + // (mock 認証はリクエスト署名の中身を見ないので、ここで検証したいのは + // 「バイト列が違っても同じリクエストとして消費されるか」だけ) + use p256::ecdsa::{signature::Signer, SigningKey}; + use p256::elliptic_curve::rand_core::OsRng; + let key = SigningKey::random(&mut OsRng); + let sig: Signature = key.sign(b"monas-request-v1:6:revoke:9:content-1:1700000000:0:"); + let canonical = sig.to_vec(); + let malleated = Signature::from_scalars(*sig.r(), -*sig.s()) + .unwrap() + .to_vec(); + assert_ne!( + canonical, malleated, + "the two encodings must differ, otherwise this test proves nothing" + ); + + service + .update_access_control( + &update, + Some(&test_token()), + Some(&canonical), + test_timestamp(), + ) + .await + .expect("first presentation should apply"); + + // 同じリクエストを、別バイト列の署名で再送する + let replay = service + .update_access_control( + &update, + Some(&test_token()), + Some(&malleated), + test_timestamp(), + ) + .await; + assert!( + matches!(replay, Err(AccessControlError::AlreadyApplied)), + "a re-encoded signature must not buy a second application: {replay:?}" + ); +} + /// 同一署名の単純な再送(A → replay(A))も拒否される。 /// `invalidate` は再送のたびに `min_valid_issued_at` を現在時刻へ進めるため、 /// 通してしまうと正規リクエスト後に発行された Token まで巻き添えで失効する。 From 0913529aa7fb214e55c0f048834bad55898f26be Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 29 Jul 2026 02:29:24 +0900 Subject: [PATCH 41/48] fix(state-node): invalidate tokens issued in the same second as the revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_token_valid` accepted `iat >= min_valid_issued_at`, and both values have one-second resolution. A token issued in the same second as a revoke therefore had `iat == cutoff` and survived it — a direct counterexample to revoke's stated guarantee of invalidating everything issued before it. Two existing tests pinned that behaviour as correct. The cutoff is now exclusive. Within one second the ordering is simply not observable, so the only safe reading of an equal timestamp is "this might predate the revoke". Erring this way costs a caller who is issued a token in the same second right after a revoke one retry; erring the other way leaves a revoked recipient with working credentials. `0` keeps meaning "never revoked" and accepts everything, so making the comparison strict does not start rejecting `iat = 0` on untouched policies. The rule lived in two places — `AccessPolicy` and `ContentAccessControl` — and both are fixed, along with the struct doc comments that still described the inclusive rule. Tests: a token stamped exactly at the cutoff is rejected while `cutoff + 1` is accepted (fails if the comparison reverts to `>=`); an untouched policy still accepts everything. The three existing tests that asserted the inclusive boundary now assert the exclusive one. --- docs/design.md | 4 +- monas-state-node/src/domain/access_control.rs | 50 +++++++++++++++++-- monas-state-node/src/domain/access_policy.rs | 38 +++++++++++--- monas-state-node/tests/integration_test.rs | 12 ++++- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/docs/design.md b/docs/design.md index 4b6ab9b..74a4a2f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -260,7 +260,7 @@ flowchart TD 失効を先に行うのは、逆順だと「再暗号化してから失効するまでの窓」で取り消し済みの相手が書き込めてしまうためである。先に失効させておけば、後段が失敗してローカル状態を巻き戻しても、余分な失効が残るだけで害はない。 -`min_valid_issued_at`は時刻ベースの一括失効なので、**残存する受信者のTokenも巻き添えで失効する**。呼び出し側は取り消し後に、残存受信者へ新しいKeyEnvelopeと新しいTokenの両方を配り直す必要がある。SDKは`RevokeShareOutput`で再発行KeyEnvelope(`reissued_envelopes`)と失効時刻(`token_invalidated_at`)の両方を返す。 +`min_valid_issued_at`は時刻ベースの一括失効なので、**残存する受信者のTokenも巻き添えで失効する**(判定は排他なので、取り消しと同じ秒に発行されたTokenも失効する)。呼び出し側は取り消し後に、残存受信者へ新しいKeyEnvelopeと新しいTokenの両方を配り直す必要がある。SDKは`RevokeShareOutput`で再発行KeyEnvelope(`reissued_envelopes`)と失効時刻(`token_invalidated_at`)の両方を返す。 取り消しはACL・CEK・ローカルciphertext・state node状態にまたがるload-modify-saveであり、そのどれにもversion CASが無い。したがって**同じcontentへの取り消しはcontent単位で直列化する**。並行させると、双方が同じShareを読んで後勝ちでsaveし片方の受信者削除が消える(lost update)、異なるCEKが同じ`key_epoch`として配られる、といった分岐が起こる。SDKのコントローラはgatewayから共有され複数リクエストから同時に呼ばれるため、これは理論上の話ではない。現状の直列化はプロセス内に閉じており、複数gatewayプロセスからの並行取り消しには対応しない — そこまで守るにはShare・CEK・ciphertextを1つのtransactional CASにまとめるか、state node側にCASを置く必要がある。 @@ -347,7 +347,7 @@ Token.att = [ ] ``` -Token失効は`min_valid_issued_at`による時刻ベースで管理される。オーナーがこの値を更新することで、それ以前に発行されたすべてのTokenを一括失効できる。 +Token失効は`min_valid_issued_at`による時刻ベースで管理される。オーナーがこの値を更新することで、それ以前に発行されたすべてのTokenを一括失効できる。判定は`iat > min_valid_issued_at`の**排他**であり、等値は無効とする — どちらも秒精度なので、失効と同じ秒に発行されたTokenが失効の前後どちらだったかは区別できず、等値を有効扱いにすると取り消したはずの相手のTokenが生き残る。誤る方向としては、失効直後の同一秒に発行されたTokenまで弾く方が安全である(呼び出し側は1秒後に取り直せば済むが、逆方向は取り消し済みの相手にアクセスを与え続ける)。なお`0`は「一度も失効していない」を意味し、全Tokenを受理する。 役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別・bodyの有無によらず同一構造で、domain separationタグに続けて操作・リソース・timestamp・body digestを長さ前置で連結する(`monas-request-v1::<操作>::<リソース>:::`)。**bodyを伴う書き込みでも操作とリソースに束縛される**ため、あるコンテンツ向けに取得した署名を別コンテンツや別操作へ転用することはできない。リプレイ防御は2層で担う。第1に署名内のtimestampの鮮度チェック(5分窓)で、これは「古い署名を無限に使い回せない」ことを保証する。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。第2に、**mutationについては受理した署名を記録して2度目の提示を拒否する**。鮮度チェックだけでは窓の中で同じ署名を何度でも通せてしまい、update・delete・invalidate・manageは冪等でないため、それは単なる重複ではなく状態の巻き戻しになる — 署名済みの旧ciphertext更新を正規の更新の後に再送すると、サーバはそれを「現在のheadを親とする新しい操作」としてcommitし、古い内容が最新版になる。 diff --git a/monas-state-node/src/domain/access_control.rs b/monas-state-node/src/domain/access_control.rs index 47a5537..a6bc854 100644 --- a/monas-state-node/src/domain/access_control.rs +++ b/monas-state-node/src/domain/access_control.rs @@ -9,13 +9,15 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// Access control state for a single content. /// /// State Nodes maintain this for each content they manage. -/// When verifying a AuthToken, the token's `iat` must be >= `min_valid_issued_at`. +/// When verifying an AuthToken, the token's `iat` must be **strictly greater** +/// than `min_valid_issued_at` (see `is_token_valid`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ContentAccessControl { /// The content ID this access control applies to. content_id: String, /// Minimum valid issued_at timestamp. - /// Tokens with iat < min_valid_issued_at are considered invalidated. + /// Tokens with iat <= min_valid_issued_at are considered invalidated + /// (`0` means nothing has been revoked yet). min_valid_issued_at: u64, /// Version number for CRDT conflict resolution. /// Higher version wins in case of concurrent updates. @@ -78,8 +80,14 @@ impl ContentAccessControl { } /// Check if a token with the given issued_at is valid. + /// + /// The cutoff is **exclusive** — see `AccessPolicy::is_token_valid` for the + /// reasoning. In short: both values have one-second resolution, so an + /// inclusive cutoff lets a token issued in the same second as the revoke + /// survive it, and revoke is defined as invalidating everything issued + /// before it. `0` still means "never revoked". pub fn is_token_valid(&self, issued_at: u64) -> bool { - issued_at >= self.min_valid_issued_at + self.min_valid_issued_at == 0 || issued_at > self.min_valid_issued_at } /// Invalidate all tokens issued before the given timestamp. @@ -309,10 +317,44 @@ mod tests { assert!(!ac.is_token_valid(0)); assert!(!ac.is_token_valid(999)); - assert!(ac.is_token_valid(1000)); + // Exclusive cutoff: a token issued in the same second as the revoke is + // rejected, because it might have been issued just before it. + assert!(!ac.is_token_valid(1000)); assert!(ac.is_token_valid(1001)); } + /// revoke と同じ秒に発行された Token も失効する。 + /// + /// 両者とも秒精度なので、`iat == cutoff` の Token が revoke の前に + /// 発行されたのか後なのかは区別できない。等値を valid 扱いにすると、 + /// 取り消したはずの相手の Token がそのまま生き残る + /// (「それ以前に発行されたすべての Token を失効」という revoke の定義への + /// 直接の反例)。 + #[test] + fn a_token_issued_in_the_same_second_as_the_revoke_is_invalidated() { + let mut ac = ContentAccessControl::new("content-1".to_string()); + let cutoff = 1_700_000_000; + + ac.invalidate_before(cutoff).expect("Should succeed"); + + assert!(!ac.is_token_valid(cutoff - 1), "before the revoke"); + assert!( + !ac.is_token_valid(cutoff), + "same second as the revoke: cannot be proven to postdate it" + ); + assert!(ac.is_token_valid(cutoff + 1), "strictly after the revoke"); + } + + /// 一度も revoke していない状態(cutoff = 0)は全 Token を受理する。 + /// 排他にしたことで `iat = 0` まで弾いてしまうと、意味のない挙動変更になる。 + #[test] + fn an_untouched_policy_accepts_every_token() { + let ac = ContentAccessControl::new("content-1".to_string()); + assert_eq!(ac.min_valid_issued_at(), 0); + assert!(ac.is_token_valid(0)); + assert!(ac.is_token_valid(u64::MAX)); + } + #[test] fn merge_higher_version_wins() { let mut ac1 = ContentAccessControl::with_values("content-1".to_string(), 100, 1, 1000); diff --git a/monas-state-node/src/domain/access_policy.rs b/monas-state-node/src/domain/access_policy.rs index 13d1800..a32dc31 100644 --- a/monas-state-node/src/domain/access_policy.rs +++ b/monas-state-node/src/domain/access_policy.rs @@ -26,7 +26,8 @@ pub struct AccessPolicy { created_at: u64, updated_at: u64, /// Minimum valid issued_at timestamp for AuthTokens. - /// Tokens with iat < min_valid_issued_at are considered invalidated. + /// Tokens with iat <= min_valid_issued_at are considered invalidated + /// (`0` means nothing has been revoked yet). See `is_token_valid`. #[serde(default)] min_valid_issued_at: u64, } @@ -76,12 +77,31 @@ impl AccessPolicy { self.min_valid_issued_at } - /// Check if a token with the given issued_at is valid + /// Check if a token with the given issued_at is valid. + /// + /// The cutoff is **exclusive**: a token stamped with exactly + /// `min_valid_issued_at` is treated as invalid. + /// + /// Both values have one-second resolution, so an inclusive cutoff would let + /// a token issued in the same second as the revoke survive it — the token's + /// `iat` equals the new cutoff, and revoke is supposed to invalidate + /// *everything issued before it*. Ordering within a second is not + /// observable here, so the only safe reading of an equal timestamp is "this + /// might predate the revoke". + /// + /// The cost is that a token issued in the same second *after* the revoke is + /// also rejected. That is the correct direction to err — the caller retries + /// a second later and gets a valid token, whereas the other direction hands + /// a revoked recipient continued access. + /// + /// `min_valid_issued_at == 0` means "never revoked" and accepts everything; + /// otherwise a token would have to be issued at second 1 or later, which is + /// meaningless but would still be a behaviour change for no benefit. pub fn is_token_valid(&self, issued_at: u64) -> bool { - issued_at >= self.min_valid_issued_at + self.min_valid_issued_at == 0 || issued_at > self.min_valid_issued_at } - /// Invalidate all tokens issued before the current time. + /// Invalidate every token issued at or before the current time. /// Sets min_valid_issued_at to the current timestamp and returns the new value. pub fn invalidate_tokens(&mut self) -> u64 { let now = current_timestamp(); @@ -157,8 +177,10 @@ mod tests { // Tokens issued before invalidation are now invalid assert!(!policy.is_token_valid(before - 1)); - // Tokens issued at or after invalidation are valid - assert!(policy.is_token_valid(new_min)); + // The cutoff is exclusive: a token stamped with the same second as the + // revoke may well predate it, and one-second resolution cannot tell. + // Treating it as valid would let a revoked recipient keep access. + assert!(!policy.is_token_valid(new_min)); assert!(policy.is_token_valid(new_min + 1)); } @@ -198,6 +220,8 @@ mod tests { let policy: AccessPolicy = serde_json::from_value(json).unwrap(); assert_eq!(policy.min_valid_issued_at(), 500); assert!(!policy.is_token_valid(499)); - assert!(policy.is_token_valid(500)); + // Exclusive cutoff — see `is_token_valid`. + assert!(!policy.is_token_valid(500)); + assert!(policy.is_token_valid(501)); } } diff --git a/monas-state-node/tests/integration_test.rs b/monas-state-node/tests/integration_test.rs index 7780bc2..53b8034 100644 --- a/monas-state-node/tests/integration_test.rs +++ b/monas-state-node/tests/integration_test.rs @@ -627,9 +627,17 @@ async fn test_access_control_update_and_verify() { let result = service.verify_access("content-1", 500).await.unwrap(); assert!(!result, "Old tokens should be denied"); - // Verify access with new token (should be allowed) + // The cutoff is exclusive: a token stamped with the same second as the + // revoke might have been issued just before it, and one-second resolution + // cannot tell. Accepting it would leave a revoked recipient with access. let result = service.verify_access("content-1", 1000).await.unwrap(); - assert!(result, "New tokens should be allowed"); + assert!(!result, "Tokens from the cutoff second should be denied"); + + let result = service.verify_access("content-1", 1001).await.unwrap(); + assert!( + result, + "Tokens issued strictly after the revoke should be allowed" + ); let result = service.verify_access("content-1", 1500).await.unwrap(); assert!(result, "Future tokens should be allowed"); From 8a6fc40e4484bc4c5424cf4382166f09dc7e54b9 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 29 Jul 2026 02:36:37 +0900 Subject: [PATCH 42/48] fix(sdk): read the CEK from the authoritative record, not the cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Putting the CEK inside `SenderKeyPin` made the (sender key, epoch, CEK) triple swap atomically, but the read path never looked at that record — it loaded from `cek_store`, and the cache write happens outside the CAS. So the invariant held where nothing read it, and the value reads actually used could still roll back: 1. handler N wins the CAS on the authoritative record, then stalls 2. handler N+1 wins its CAS and writes cache N+1 3. handler N resumes and writes cache N unconditionally 4. the record says N+1, the cache the read uses says N `verify_and_decrypt_relay_read` now takes an explicit CEK and only falls back to the store when none is given. The SDK passes the CEK from the sender pin, so a lagging cache cannot affect a share recipient's reads. Content the caller created itself has no sender pin, so that path still uses the store — there is no second writer there to race with. Tests: with a key-sensitive encryptor and a deliberately stale cache, the explicit CEK decrypts correctly while the fallback does not (fails if the parameter is ignored). An SDK-level test was written first and then removed: it passed with the fix disabled, because the interleaving needs two concurrent handlers and the public API cannot drive that. A test that cannot fail is worse than no test — it reads as coverage that is not there. --- docs/design.md | 2 +- .../content_service/service.rs | 128 +++++++++++++++++- monas-sdk/src/controller/state.rs | 22 +++ 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/docs/design.md b/docs/design.md index 74a4a2f..9e2a594 100644 --- a/docs/design.md +++ b/docs/design.md @@ -408,7 +408,7 @@ share受信者はKeyEnvelopeの復号成功時にunwrap済みCEKを自デバイ wrapのAADには `(content_id, recipient_key_id, key_epoch)` が束縛され、いずれかを書き換えたenvelopeは復号に失敗する。`key_epoch` はCEKの鍵世代(rotationごとに+1)で、受信者は記録済み世代より古いenvelopeを拒否する — rotation前の正規envelopeを再送して保存CEKを旧世代へ巻き戻すreplay攻撃はこれで防がれる。 -受信者側のローカル状態では、**送信者鍵・鍵世代・CEKの3つ組を1レコードにまとめ、単一のcompare-and-swapで入れ替える**。守るべき不変条件は「3つ組が常に整合していること」であって世代番号だけではないため、これらを別ストアに分けて別々にcommitすると、世代をCASで守っても壊れる — 世代Nの処理がピンを読んだ後に世代N+1の処理がピンとCEKを進め、その後で世代Nの処理がCEKだけを書き戻せば、`ピン=N+1 / CEK=N` という復号不能な状態が残る。3つ組が1レコードなら、この割り込みは構造的に起こり得ない。CEKストアはこの権威レコードから導出されるキャッシュとして扱い、書き損じてもKeyEnvelopeの再処理で埋め直せる。 +受信者側のローカル状態では、**送信者鍵・鍵世代・CEKの3つ組を1レコードにまとめ、単一のcompare-and-swapで入れ替える**。守るべき不変条件は「3つ組が常に整合していること」であって世代番号だけではないため、これらを別ストアに分けて別々にcommitすると、世代をCASで守っても壊れる — 世代Nの処理がピンを読んだ後に世代N+1の処理がピンとCEKを進め、その後で世代Nの処理がCEKだけを書き戻せば、`ピン=N+1 / CEK=N` という復号不能な状態が残る。3つ組が1レコードなら、この割り込みは構造的に起こり得ない。CEKストアはこの権威レコードから導出されるキャッシュとして扱い、書き損じてもKeyEnvelopeの再処理で埋め直せる。**readはこの権威レコードのCEKを優先して使う。** キャッシュへの書き込みはCASの外にあるため、世代Nのハンドラが権威レコードのCASを終えた直後に停止し、その間に世代N+1が権威レコードとキャッシュを進め、その後Nが再開してキャッシュだけをNへ戻す、という順序逆転が起こり得る。権威レコードから直接引けば、この巻き戻りはreadに影響しない(自分で作成したコンテンツには送信者ピンが無いので、その場合だけキャッシュを引く)。 アクセス取り消しの安全性は受信者の鍵破棄(強制不能)ではなくCEKローテーションに依存する。revoke時は再暗号化を先に行い、残存受信者にはローテーション後のCEK・進んだkey_epochでKeyEnvelopeを再発行する。受信者が再発行envelopeを処理すると保存済みCEKが更新され、旧CEKのままでは新しい版を復号できない。 diff --git a/monas-content/src/application_service/content_service/service.rs b/monas-content/src/application_service/content_service/service.rs index 6222680..c480cb0 100644 --- a/monas-content/src/application_service/content_service/service.rs +++ b/monas-content/src/application_service/content_service/service.rs @@ -304,11 +304,17 @@ where /// /// Returns the plaintext, and the verified node's parent CIDs (for the /// caller's monotonicity check). + /// `cek` を渡した場合はそれを使い、`None` の場合だけローカルの CEK ストアを + /// 引く。呼び出し側が「どの CEK が正しいか」をより確実に知っている場合 + /// (share 受信者は送信者ピンの権威レコードに CEK を持つ)、ストアより + /// そちらを優先させるための引数である。ストアは書き込み順が入れ替わると + /// 巻き戻り得るキャッシュに過ぎない。 pub fn verify_and_decrypt_relay_read( &self, node_bytes: &[u8], expected_version_cid: &str, local_content_id: ContentId, + cek: Option, ) -> Result { let verified = crate::infrastructure::node_verification::verify_and_extract( node_bytes, @@ -316,11 +322,14 @@ where ) .map_err(VerifiedReadError::NodeVerification)?; - let key = self - .cek_store - .load(&local_content_id) - .map_err(VerifiedReadError::KeyStore)? - .ok_or(VerifiedReadError::MissingKey)?; + let key = match cek { + Some(key) => key, + None => self + .cek_store + .load(&local_content_id) + .map_err(VerifiedReadError::KeyStore)? + .ok_or(VerifiedReadError::MissingKey)?, + }; let plaintext = self .decrypt_with_cek(local_content_id, key, verified.ciphertext) @@ -1055,6 +1064,39 @@ mod tests { } } + /// crsl-lib `Node` と同じ CBOR 形状のバイト列を作る + /// (`node_verification` が受理する最小構成)。 + fn make_test_node_bytes(ciphertext: &[u8]) -> Vec { + #[derive(serde::Serialize)] + struct Payload<'a> { + data: &'a [u8], + access_policy: Option<()>, + } + #[derive(serde::Serialize)] + struct Metadata { + policy_type: Option<()>, + } + #[derive(serde::Serialize)] + struct Node<'a> { + payload: Payload<'a>, + parents: Vec<()>, + genesis: Option<()>, + timestamp: u64, + metadata: Metadata, + } + serde_cbor::to_vec(&Node { + payload: Payload { + data: ciphertext, + access_policy: None, + }, + parents: vec![], + genesis: None, + timestamp: 0, + metadata: Metadata { policy_type: None }, + }) + .unwrap() + } + fn build_service( repo: R, key_gen: K, @@ -1561,6 +1603,82 @@ mod tests { assert_eq!(result, plaintext); } + /// `verify_and_decrypt_relay_read` は、明示的に渡された CEK を + /// ローカルの CEK ストアより優先する。 + /// + /// ストアは権威レコード(送信者ピン)から導出されるキャッシュに過ぎず、 + /// CAS の外で書かれるため書き込み順が入れ替わると古い世代へ巻き戻り得る + /// (世代 N の handler が CAS 後に停止し、その間に N+1 が権威レコードと + /// キャッシュを進め、その後 N が再開してキャッシュだけを N へ戻す)。 + /// read が権威レコード側の CEK を使えば、その巻き戻りは影響しない。 + #[test] + fn verified_read_prefers_the_explicit_cek_over_the_store() { + /// 鍵を実際に見る暗号化器。鍵の 1 バイト目を XOR するだけだが、 + /// 「どの CEK で復号したか」がテストから観測できるようになる。 + struct KeySensitiveEncryptor; + impl ContentEncryption for KeySensitiveEncryptor { + fn encrypt( + &self, + key: &ContentEncryptionKey, + plaintext: &[u8], + ) -> Result, ContentError> { + Ok(plaintext.iter().map(|b| b ^ key.0[0]).collect()) + } + fn decrypt( + &self, + key: &ContentEncryptionKey, + ciphertext: &[u8], + ) -> Result, ContentError> { + Ok(ciphertext.iter().map(|b| b ^ key.0[0]).collect()) + } + } + + let (repo, _storage) = TestContentRepository::new(false); + let (key_store, _key_storage) = TestKeyStore::new(false, false); + let service = build_service(repo, TestKeyGenerator, KeySensitiveEncryptor, key_store); + + // 権威レコード側の(正しい)CEK と、巻き戻ったキャッシュ側の CEK + let authoritative = ContentEncryptionKey(vec![0x11]); + let stale_cache = ContentEncryptionKey(vec![0x22]); + + let plaintext = b"authoritative-cek-wins".to_vec(); + let ciphertext = service + .encryptor + .encrypt(&authoritative, &plaintext) + .expect("encrypt"); + let content_id = service.content_id_generator.generate(&plaintext); + + // キャッシュには古い世代の CEK が入っている + service + .cek_store + .save(&content_id, &stale_cache) + .expect("save stale cache"); + + let node_bytes = make_test_node_bytes(&ciphertext); + let version = + crate::infrastructure::node_verification::recompute_node_cid(&node_bytes).unwrap(); + + // 権威レコードの CEK を渡せば復号できる + let read = service + .verify_and_decrypt_relay_read( + &node_bytes, + &version, + content_id.clone(), + Some(authoritative), + ) + .expect("the explicit CEK must be used"); + assert_eq!(read.plaintext, plaintext); + + // 渡さなければ巻き戻ったキャッシュが使われ、平文が一致しない + // (= この経路に依存していると read が壊れる) + let fallback = + service.verify_and_decrypt_relay_read(&node_bytes, &version, content_id, None); + assert!( + fallback.is_err() || fallback.unwrap().plaintext != plaintext, + "the stale cache must not yield the correct plaintext" + ); + } + #[test] fn decrypt_with_cek_returns_mismatch_error_when_content_id_differs() { let (repo, _storage) = TestContentRepository::new(false); diff --git a/monas-sdk/src/controller/state.rs b/monas-sdk/src/controller/state.rs index efffbb6..2c791e7 100644 --- a/monas-sdk/src/controller/state.rs +++ b/monas-sdk/src/controller/state.rs @@ -334,12 +334,34 @@ impl MonasController { } // CEK ロード + AES-GCM 復号 + plain CID 照合 + // + // CEK は「送信者ピンの権威レコード」を優先する。CEK ストアは、その + // レコードから導出されるキャッシュに過ぎず、CAS 成功後の書き込み順が + // 入れ替わると古い世代へ巻き戻り得る(世代 N の handler が CAS 後に + // 停止し、その間に N+1 が権威レコードとキャッシュを進め、その後 N が + // 再開してキャッシュだけを N に戻す)。権威レコードから直接引けば、 + // その巻き戻りは read に影響しない。 + // + // 自分で作成した content には送信者ピンが存在しないので、その場合は + // 従来どおりストアを引く。 let local_content_id = monas_content::domain::content_id::ContentId::new(input.local_content_id.clone()); + let pinned_cek = match self.sender_pin_store.load(&input.local_content_id) { + Ok(pin) => pin + .and_then(|p| p.cek) + .map(monas_content::domain::content::ContentEncryptionKey), + Err(e) => { + return ApiResponse::error( + ApiError::Internal(format!("sender key pin store error: {e}")), + trace_id, + ); + } + }; let plaintext = match self.content_service.verify_and_decrypt_relay_read( &node_bytes, &version, local_content_id, + pinned_cek, ) { Ok(read) => read.plaintext, Err(e) => { From 3ca4e506ce0364b0d5a9c46e2d5025e7f451c453 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 29 Jul 2026 04:07:39 +0900 Subject: [PATCH 43/48] fix: derive the replay identity from the canonical principal, not the raw token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix moved the mutation request ID off the request signature and onto "the signed message plus the signer" — but the signer it used was `token.as_str()`, the raw token string. A delegated JWT ends in an ECDSA signature over its own header and payload, and ECDSA is malleable, so the same JWT has more than one byte representation. Converting `s` to `n - s` leaves the claims, the `aud`, and the request signature untouched, still passes verification, and yields a different request ID — re-applying the mutation on the very same node. The bypass the earlier commit closed on the request signature was still open one layer up, on the token. Confirmed empirically: both JWT encodings verify against the same key and the same signing input, the token bytes differ, and the resulting request IDs differ. The ID now uses the canonical principal — the key the request signature is actually verified against, mirroring `verify_request_signature`: the token itself for a self-contained key id, the `aud` claim for a delegated JWT. No signature bytes of any kind reach the hash. A malformed JWT falls back to the whole token; such a token cannot authenticate anyway, so the value only has to be deterministic. Also close two membership arms that had no publisher authorization at all, found by enumerating every arm rather than only the ones reported: - `ContentNetworkManagerRemoved` had no check whatsoever, so any peer could delete our `ContentNetwork` record by naming us as `removed_node_id`. - `ContentDeleted` called `verify_source_peer_id` against `deleted_by_node_id`, which the event names itself — so any authenticated peer satisfied it. Both now require the publisher to be a member of the network being changed, the same rule as `ContentNetworkManagerAdded`. `auth_verdict_is_authoritative` now always returns false. It returned true for `LocalRecord` on the grounds that a listed member evaluated the caller against the real policy, but that does not hold while the record itself can be planted: the first record for a content is accepted with no prior membership to check against, so an attacker winning that race lands in the list and its 403 would end the failover loop — the same permanent denial of service the DhtGuess case already guards against. The denial is still returned if nothing better appears; only the early exit is gone. Restoring it needs owner-signed membership (#63). Docs corrected to match the code: - `docs/design.md` still described the replay ID as the request signature digest, which has been false since the previous round. - `ReadContentFromStateNodeInput` promised a monotonicity check on latest reads; that check was removed and never existed in this form. - `verify_and_decrypt_relay_read` said the SDK layers a monotonicity check around it. It does not — nothing in the SDK reads `parents`. All four new tests were confirmed to fail with their fixes disabled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c --- docs/design.md | 4 +- .../content_service/service.rs | 18 +- monas-sdk/src/models/state.rs | 12 +- .../application_service/state_node_service.rs | 306 ++++++++++++++++-- 4 files changed, 303 insertions(+), 37 deletions(-) diff --git a/docs/design.md b/docs/design.md index fcbeb88..e9bc7c6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -351,7 +351,9 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。 役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別・bodyの有無によらず同一構造で、domain separationタグに続けて操作・リソース・timestamp・body digestを長さ前置で連結する(`monas-request-v1::<操作>::<リソース>:::`)。**bodyを伴う書き込みでも操作とリソースに束縛される**ため、あるコンテンツ向けに取得した署名を別コンテンツや別操作へ転用することはできない。リプレイ防御は2層で担う。第1に署名内のtimestampの鮮度チェック(5分窓)で、これは「古い署名を無限に使い回せない」ことを保証する。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。第2に、**mutationについては受理した署名を記録して2度目の提示を拒否する**。鮮度チェックだけでは窓の中で同じ署名を何度でも通せてしまい、update・delete・invalidate・manageは冪等でないため、それは単なる重複ではなく状態の巻き戻しになる — 署名済みの旧ciphertext更新を正規の更新の後に再送すると、サーバはそれを「現在のheadを親とする新しい操作」としてcommitし、古い内容が最新版になる。 -リクエストの同一性には**リクエスト署名そのもののdigest**を使う。署名は既に操作・リソース・timestamp・body digestすべてに束縛されているので、digestが一致する=完全に同じリクエストの再送であり、nonceのような新しいフィールドをワイヤ形式へ足す必要がない。記録の保持期間は鮮度窓と同じでよい(窓の外へ出た署名は記録が無くても鮮度チェックで落ちる)ため、記録は無制限には育たない。読み取りは冪等なのでこの記録の対象外である。 +リクエストの同一性には**署名対象メッセージと、検証後のcanonical principal**を使う(`SHA256(len(principal) ‖ principal ‖ signing_message)`、長さ前置はprincipalとメッセージの境界を付け替えられないようにするため)。principalは「リクエスト署名の検証に使う鍵」そのもので、自己完結型の鍵IDならその値、委譲JWTなら`aud`である。メッセージは既に操作・リソース・timestamp・body digestすべてに束縛されているので、nonceのような新しいフィールドをワイヤ形式へ足す必要がない。記録の保持期間は鮮度窓と同じでよい(窓の外へ出た署名は記録が無くても鮮度チェックで落ちる)ため、記録は無制限には育たない。読み取りは冪等なのでこの記録の対象外である。 + +**同一性の入力に署名バイト列を一切含めないことが要点である。** ECDSAはmalleableで、有効な`(r, s)`に対し`(r, n−s)`も同じメッセージ・同じ鍵で検証を通る。したがって署名のdigestをIDにすると、1つの承認済みリクエストが2つのIDを持ち、`s`を反転した署名を送り直すだけで消費記録をすり抜けて再適用できてしまう。これは**リクエスト署名だけの話ではない** — 委譲JWTの末尾セグメントもまたECDSA署名なので、生のトークン文字列をIDの入力にすると同じ迂回が成立する(claims・`aud`・リクエスト署名はすべて同じまま、トークンのバイト列だけが変わる)。principalを使うのはこのためである。 この2層構成は[RFC 9449(DPoP)§11.1](https://www.rfc-editor.org/rfc/rfc9449.html#section-11.1)と同じ形である。同RFCはサーバに対し、proofを「秒〜分のオーダーの比較的短い期間」だけ受理することを要求したうえで、**別途**その期間中はproofの識別子を保存して二重使用を防ぐことを推奨し、「厳格に運用すればこのsingle-useチェックはreplayに対する非常に強い防御となる」と述べている。鮮度チェックだけでは不十分であることが、仕様レベルで明示されている。 diff --git a/monas-content/src/application_service/content_service/service.rs b/monas-content/src/application_service/content_service/service.rs index c480cb0..d7b77a3 100644 --- a/monas-content/src/application_service/content_service/service.rs +++ b/monas-content/src/application_service/content_service/service.rs @@ -298,12 +298,20 @@ where /// 3. Loads the CEK for `local_content_id` and AES-GCM-decrypts. /// /// This is the client-side core of the verified read path - /// (`docs/design.md` §10「read応答の完全性検証」). It does NOT do the - /// monotonicity check — that is layered by the caller (SDK) around this - /// call, which owns the last-seen state. + /// (`docs/design.md` §10「read応答の完全性検証」). It verifies **payload + /// authenticity only**: that these bytes are the ones named by + /// `expected_version_cid`. It does not establish that the version is the + /// canonical head, the latest, or the work of an authorized writer. /// - /// Returns the plaintext, and the verified node's parent CIDs (for the - /// caller's monotonicity check). + /// There is **no monotonicity check anywhere** — neither here nor in the + /// SDK above. One existed and was removed: forged `parents` bypass it, and + /// it could not tell a legitimate sync lag from an attack, so it broke + /// honest reads without stopping dishonest ones. Version authenticity needs + /// an owner/writer-signed trust anchor, tracked in issue #59. + /// + /// Returns the plaintext and the verified node's parent CIDs. The parents + /// are returned for callers that want to inspect the DAG; nothing in the + /// SDK currently consumes them. /// `cek` を渡した場合はそれを使い、`None` の場合だけローカルの CEK ストアを /// 引く。呼び出し側が「どの CEK が正しいか」をより確実に知っている場合 /// (share 受信者は送信者ピンの権威レコードに CEK を持つ)、ストアより diff --git a/monas-sdk/src/models/state.rs b/monas-sdk/src/models/state.rs index 7e5afc7..497f0a1 100644 --- a/monas-sdk/src/models/state.rs +++ b/monas-sdk/src/models/state.rs @@ -55,7 +55,17 @@ pub struct GetHistoryOutput { /// local↔remote の対応表は存在しないため、呼び出し側が両方を渡す /// (`VerifyIntegrityInput` と同じ設計)。 /// - `version`: 読む版 CID。省略時は State Node の履歴から最新版を読む。 -/// 最新読みのときのみ単調性チェック(ロールバック検出)が働く。 +/// +/// 保証されるのは**payloadの真正性まで**である。返ってきたバイト列が要求した版 +/// CIDに一致すること(CID再計算 + AES-GCM復号)は確認するが、その版が正規の +/// writerによるものか、そのcontent seriesのcanonical headか、本当に最新かは +/// 確認しない。読み取り専用の受信者もCEKを持つため、CEKで復号できることは +/// write権限の証明にならない。 +/// +/// 単調性チェック(ロールバック検出)は**実装されていない**。一度入れたが、 +/// 偽のparentsを詰めた版でbypassできて防御にならない一方、正当なsync遅延と +/// 攻撃を応答単体で区別できず正規readを壊す誤検知が残るため撤去した。 +/// 版の真正性にはowner/writer署名のtrust anchorが必要で、issue #59 で追跡している。 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReadContentFromStateNodeInput { pub content_id: String, diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 5a43d97..b752d8d 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -140,6 +140,10 @@ enum MemberProvenance { /// Relay candidates plus the provenance of the list. struct ResolvedMembers { members: Vec, + /// Kept for diagnostics and for #63: once membership is owner-signed, + /// `auth_verdict_is_authoritative` reads this again to restore the early + /// exit on a denial. Nothing branches on it today — see that method. + #[allow(dead_code)] provenance: MemberProvenance, } @@ -164,15 +168,32 @@ impl ResolvedMembers { } /// Whether a negative authorization verdict from these peers may be treated - /// as final. + /// as final — i.e. may end the failover loop early. /// - /// For unproven peers it must not be: a single hostile node squatting near - /// the DHT key could otherwise deny every read by answering 403 first, and - /// even an honest but partially-synced replica can answer 403 from a policy - /// it has not finished replicating. In both cases the right move is to keep - /// asking the remaining candidates. + /// **Currently always false.** A denial is still kept and returned if no + /// candidate produces anything better; what this disables is *stopping* at + /// the first one. + /// + /// For [`MemberProvenance::DhtGuess`] the reason is direct: a single + /// hostile node squatting near the DHT key could otherwise deny every read + /// by answering 403 first, and even an honest but partially-synced replica + /// can answer 403 from a policy it has not finished replicating. + /// + /// [`MemberProvenance::LocalRecord`] used to return true here, on the + /// grounds that a listed member evaluated the caller against the real + /// policy. That does not hold while the record itself can be planted: the + /// first record for a content is accepted with no prior membership to check + /// against, so an attacker who wins that race lands in the list and its 403 + /// would end the loop — a permanent denial of service against a legitimate + /// caller, which is exactly what the DhtGuess case is guarding against. + /// + /// Restoring the early exit needs owner-signed membership (#63). Until + /// then, the cost of always continuing is bounded: one extra round trip per + /// remaining candidate on a genuine denial, against an availability attack + /// that is otherwise unbounded. Erring toward availability is the right + /// direction — the caller is refused either way, just later. fn auth_verdict_is_authoritative(&self) -> bool { - self.provenance == MemberProvenance::LocalRecord + false } } @@ -404,25 +425,57 @@ where } } + /// The canonical identity of whoever must hold the private key for this + /// request — the key the request signature is verified against. + /// + /// For a self-contained key id the token *is* that key. For a delegated JWT + /// it is the `aud` claim, which is likewise a self-contained key id; this + /// mirrors `verify_request_signature`, which verifies the request signature + /// against exactly that key. + /// + /// Deliberately **not** the raw token string. A JWT ends in an ECDSA + /// signature over its own header and payload, and ECDSA is malleable, so + /// the same JWT — same claims, same `aud`, still passing verification — + /// has more than one byte representation. Feeding raw token bytes into the + /// request identity would therefore hand one authorized request two + /// identities, which is the very bypass the identity is meant to prevent. + /// + /// A malformed JWT falls back to the whole token: such a token cannot + /// authenticate anyway, so the value only has to be deterministic. + fn canonical_principal(token: &AuthToken) -> String { + let raw = token.as_str(); + if !raw.contains('.') { + return format!("key:{}", raw); + } + match crate::infrastructure::auth::auth_token::AuthToken::from_jwt(raw) { + Ok(parsed) => format!("aud:{}", parsed.payload.aud), + Err(_) => format!("raw:{}", raw), + } + } + /// Identity of a mutation request, for the single-use record. /// - /// Derived from the **signed message plus the signer**, never from the - /// signature bytes. ECDSA signatures are malleable: for a valid `(r, s)` - /// the value `(r, n - s)` verifies against the same message and key, so - /// hashing the signature would give one authorized request two different - /// identities — and re-sending the converted form would slip straight past - /// the consumption record and re-commit the mutation. + /// Derived from the **signed message plus the canonical signer identity**, + /// never from any signature bytes. ECDSA signatures are malleable: for a + /// valid `(r, s)` the value `(r, n - s)` verifies against the same message + /// and key. That applies to *both* signatures in play here — the request + /// signature and the JWT's own signature — so neither may reach this hash. + /// Otherwise one authorized request gets two identities, and re-sending the + /// converted form slips past the consumption record and re-commits the + /// mutation. /// /// The message already binds operation, resource, timestamp and body - /// digest. The token is mixed in so two different callers cannot collide on - /// one identity, which would let one of them consume the other's request. + /// digest. The principal is mixed in so two different callers cannot + /// collide on one identity, which would let one of them consume the + /// other's request. fn mutation_request_id(token: &AuthToken, signing_message: &str) -> Vec { use sha2::{Digest, Sha256}; + let principal = Self::canonical_principal(token); let mut hasher = Sha256::new(); - // Length-prefixed so a token ending in the message's leading bytes - // cannot be re-split into a different (token, message) pair. - hasher.update((token.as_str().len() as u64).to_be_bytes()); - hasher.update(token.as_str().as_bytes()); + // Length-prefixed so a principal ending in the message's leading bytes + // cannot be re-split into a different (principal, message) pair. + hasher.update((principal.len() as u64).to_be_bytes()); + hasher.update(principal.as_bytes()); hasher.update(signing_message.as_bytes()); hasher.finalize().to_vec() } @@ -490,12 +543,13 @@ where /// version whose parent is the current head, so the stale content becomes /// the latest version. /// - /// The request identity comes from the **signed message and the signer**, - /// never from the signature bytes — see [`Self::mutation_request_id`]. The - /// message already commits to operation, resource, timestamp and body - /// digest, so no new field has to be added to the wire format to carry a - /// nonce, and no encoding of the signature can masquerade as a second - /// request. + /// The request identity comes from the **signed message and the canonical + /// signer identity**, never from signature bytes of any kind — see + /// [`Self::mutation_request_id`]. The message already commits to operation, + /// resource, timestamp and body digest, so no new field has to be added to + /// the wire format to carry a nonce, and no re-encoding of either the + /// request signature or the token's own signature can masquerade as a + /// second request. /// /// Consumption happens after verification (an invalid signature must not be /// able to burn a legitimate request's identity) and before any state is @@ -2478,6 +2532,14 @@ where removed_node_id, .. } => { + // Removal deletes or rewrites the record that decides whether a + // relay treats a peer's 403 as final, so the publisher must be + // a member of the network it is changing — otherwise any peer + // could evict us from our own record just by naming us as + // `removed_node_id`. Same rule as the Added arm. + self.verify_source_is_existing_member(source_peer_id, content_id) + .await?; + // If we were removed, delete the local network metadata if removed_node_id == &self.local_node_id { tracing::info!( @@ -2590,6 +2652,13 @@ where // Verify source PeerID matches claimed node ID Self::verify_source_peer_id(source_peer_id, deleted_by_node_id)?; + // That alone only proves the publisher is who it says it is — + // it names *itself*, so any authenticated peer would satisfy + // it. Deleting our record requires being a member of the + // network being deleted. + self.verify_source_is_existing_member(source_peer_id, content_id) + .await?; + // Skip if we initiated the deletion if deleted_by_node_id == &self.local_node_id { return Ok(ApplyOutcome::Ignored); @@ -3612,12 +3681,15 @@ mod tests { )); } - /// 逆に、attested な member(ローカルの ContentNetwork レコード由来)の - /// 401/403 は権威がある。実 policy に対して評価した結果なので、他の member - /// に聞いても答えは変わらず、聞き続けるのは拒否済みの相手に対して - /// コンテンツの存在を漏らすだけになる。 + /// `record_relay_read_error` は「権威あり」と言われれば打ち切る。 + /// + /// ただし現在この `true` を渡す呼び出し側は無い + /// ([`ResolvedMembers::auth_verdict_is_authoritative`] は常に false)。 + /// レコード自体が最初の 1 通で植え付けられる間は、そこに載った peer の + /// 403 も最終判断にはできないためである。owner 署名付き membership + /// (#63)が入れば早期打ち切りを戻せるので、その配線だけは残してある。 #[test] - fn attested_member_auth_verdict_is_final() { + fn record_relay_read_error_breaks_when_told_the_verdict_is_authoritative() { use crate::port::peer_network::{RelayReadError, RelayReadErrorKind}; let mut best = None; @@ -3670,6 +3742,95 @@ mod tests { } } + /// 委譲 JWT **自身の署名**も malleable である。生のトークン文字列を ID の + /// 入力にすると、claims も `aud` も request signature も変えずに `s` を + /// 反転するだけで別 ID を作れてしまい、同一ノードでも再適用できる。 + /// + /// ID は検証後の canonical principal(`aud` の鍵 ID)から導くので、 + /// JWT のバイト列が変わっても ID は動かない。 + #[test] + fn mutation_request_id_ignores_the_jwt_signature_encoding() { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use p256::ecdsa::{signature::Signer, Signature, SigningKey}; + + // 実物と同じ形の JWT を組み立てる(header.payload を P-256 で署名)。 + let key = SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"JWT","ver":"1.0"}"#); + let payload = URL_SAFE_NO_PAD.encode( + br#"{"iss":"monas:user:alice","aud":"monas:user:bob","jti":"j1","iat":1700000000,"exp":1700003600,"att":[]}"#, + ); + let signing_input = format!("{}.{}", header, payload); + let sig: Signature = key.sign(signing_input.as_bytes()); + + // 同じ鍵・同じメッセージに対して有効な、もう一方の表現。 + let alt = Signature::from_scalars(*sig.r(), -*sig.s()).unwrap(); + + let jwt_a = format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(sig.to_vec()) + ); + let jwt_b = format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(alt.to_vec()) + ); + assert_ne!( + jwt_a, jwt_b, + "2 つの表現が同じでは、このテストは何も証明しない" + ); + // 実際に JWT として解釈できることを確かめる。ここが失敗すると + // `canonical_principal` が raw フォールバックに落ち、両者が別 ID に + // なるのを「malleability を防げていない」と誤読してしまう。 + assert!( + crate::infrastructure::auth::auth_token::AuthToken::from_jwt(&jwt_a).is_ok(), + "テスト用 JWT が本物のスキーマを満たしていない" + ); + + let msg = StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::build_signing_message( + "update", "content-1", 1_700_000_000, Some(b"payload") + ); + + let id_of = |t: &str| { + StateNodeService::< + MockNodeRegistry, + MockContentNetworkRepository, + MockPeerNetwork, + MockEventPublisher, + MockContentRepository, + >::mutation_request_id(&AuthToken::new(t.to_string()), &msg) + }; + + assert_eq!( + id_of(&jwt_a), + id_of(&jwt_b), + "JWT の署名表現を変えても同じリクエストとして識別されなければならない" + ); + + // 別の aud(=別の principal)なら当然 ID は変わる。 + let other_payload = URL_SAFE_NO_PAD.encode( + br#"{"iss":"monas:user:alice","aud":"monas:user:carol","jti":"j1","iat":1700000000,"exp":1700003600,"att":[]}"#, + ); + let other_input = format!("{}.{}", header, other_payload); + let other_sig: Signature = key.sign(other_input.as_bytes()); + let jwt_c = format!( + "{}.{}", + other_input, + URL_SAFE_NO_PAD.encode(other_sig.to_vec()) + ); + assert_ne!( + id_of(&jwt_a), + id_of(&jwt_c), + "aud が違えば別 principal なので ID も別でなければならない" + ); + } + /// mutation の同一性は「署名バイト列」ではなく「署名対象メッセージ + signer」 /// から導く。ECDSA は malleable なので、署名の digest を ID にすると、 /// 1 つの承認済みリクエストが 2 つの ID を持ってしまい、`s` を反転した @@ -4173,6 +4334,91 @@ mod tests { assert!(network.has_member_str("node-2")); } + /// A non-member cannot evict us from our own record by naming us as the + /// removed node. + /// + /// This arm had no publisher check at all, so a single event from any peer + /// deleted the `ContentNetwork` record — which is what decides whether a + /// relay treats a peer's 403 as final. + #[tokio::test] + async fn removal_from_a_non_member_cannot_delete_our_record() { + let content_repo = Arc::new(RwLock::new( + MockContentNetworkRepository::new() + .with_network(create_test_network("content-1", vec!["node-1", "node-2"])), + )); + let service: TestService = StateNodeService::new( + MockNodeRegistry::new(), + content_repo, + Arc::new(MockPeerNetwork::new().with_local_peer_id("node-1")), + MockEventPublisher::new(), + Arc::new(MockContentRepository::new()), + "node-1".to_string(), + ) + .with_authentication_service(TestAuthService) + .with_authorization_service(AllowAllAuthorizationService); + + let event = Event::ContentNetworkManagerRemoved { + content_id: "content-1".to_string(), + removed_node_id: "node-1".to_string(), + member_nodes: vec!["node-2".to_string()], + reason: "low_capacity".to_string(), + timestamp: 12345, + }; + + let result = service.handle_sync_event(&event, Some("node-9")).await; + assert!(result.is_err(), "non-member publisher must be rejected"); + + assert!( + service + .get_content_network_for_test("content-1") + .await + .unwrap() + .is_some(), + "our record must survive" + ); + } + + /// A non-member cannot delete our record with a `ContentDeleted` event. + /// + /// `verify_source_peer_id` alone does not help here: the event names its + /// own publisher, so any authenticated peer satisfies it. + #[tokio::test] + async fn content_deleted_from_a_non_member_cannot_delete_our_record() { + let content_repo = Arc::new(RwLock::new( + MockContentNetworkRepository::new() + .with_network(create_test_network("content-1", vec!["node-1", "node-2"])), + )); + let service: TestService = StateNodeService::new( + MockNodeRegistry::new(), + content_repo, + Arc::new(MockPeerNetwork::new().with_local_peer_id("node-1")), + MockEventPublisher::new(), + Arc::new(MockContentRepository::new()), + "node-1".to_string(), + ) + .with_authentication_service(TestAuthService) + .with_authorization_service(AllowAllAuthorizationService); + + // node-9 both publishes and names itself — the self-claim check passes. + let event = Event::ContentDeleted { + content_id: "content-1".to_string(), + deleted_by_node_id: "node-9".to_string(), + timestamp: 12345, + }; + + let result = service.handle_sync_event(&event, Some("node-9")).await; + assert!(result.is_err(), "non-member publisher must be rejected"); + + assert!( + service + .get_content_network_for_test("content-1") + .await + .unwrap() + .is_some(), + "our record must survive" + ); + } + /// A non-member cannot rewrite the member set of a network we already hold. #[tokio::test] async fn membership_change_from_a_non_member_is_rejected() { From 7bade46efa3aee22ac872c915bc53b3d16f00ede Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 29 Jul 2026 04:17:59 +0900 Subject: [PATCH 44/48] style: apply rustfmt to the JWT malleability test Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c --- .../src/application_service/state_node_service.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index b752d8d..2f01831 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -3765,16 +3765,8 @@ mod tests { // 同じ鍵・同じメッセージに対して有効な、もう一方の表現。 let alt = Signature::from_scalars(*sig.r(), -*sig.s()).unwrap(); - let jwt_a = format!( - "{}.{}", - signing_input, - URL_SAFE_NO_PAD.encode(sig.to_vec()) - ); - let jwt_b = format!( - "{}.{}", - signing_input, - URL_SAFE_NO_PAD.encode(alt.to_vec()) - ); + let jwt_a = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(sig.to_vec())); + let jwt_b = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(alt.to_vec())); assert_ne!( jwt_a, jwt_b, "2 つの表現が同じでは、このテストは何も証明しない" From 0badb16bf799e4fdfa3a9266b75632eaa3cb0e80 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 29 Jul 2026 23:00:49 +0900 Subject: [PATCH 45/48] fix(state-node): verify the signature before the relay consumes the request id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay recorded a mutation's request id before verifying anything, and the id is derived entirely from public inputs: operation, resource, timestamp, body digest, and the principal (`aud` or the key id). No private key and no valid signature are needed to compute it. Anyone could therefore burn a legitimate caller's request id with a garbage signature — send a `delete` for their content stamped with the current second (no body, so the signed message is fully predictable) and their real request comes back `RequestAlreadyApplied`. Repeat every second and the target cannot mutate anything through that relay. The relay now runs `verify_caller_signature` before touching the store. It can: that check is purely cryptographic — the token's own signature, then the request signature against the key the token designates. Only *authorization* needs the access policy, and that stays with the member. This is a bug I introduced. The previous doc comment argued the pre-consume was harmless because it only affected "a digest the attacker already has — if they hold the signature they can replay it themselves anyway". That was true while the id *was* the signature digest. It stopped being true when the id moved to the signed message, and I did not revisit the reasoning that depended on it. design.md also corrected on two counts: it now documents the verify-then-record ordering and why it is load-bearing, and it drops the claim that "CRDT tolerates duplicate application of the same operation" as cover for the network-wide gap. Each replica builds a *new* operation from its own head and author, so that property does not apply to this path — the rollback is real, and #65 tracks it. The new test was confirmed to fail with the verification disabled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c --- docs/design.md | 8 +- .../application_service/state_node_service.rs | 181 +++++++++++++++++- 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/docs/design.md b/docs/design.md index 7fbc280..7485300 100644 --- a/docs/design.md +++ b/docs/design.md @@ -361,7 +361,13 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。 つまりこの窓は**計測に基づいて詰めた値ではなく、緩めに置いた値**である。捕捉されたread署名がどれだけの間再利用可能かを直接決めるため、実際のリクエスト遅延を計測したうえで縮める価値はある。ただしrelayのfailover予算を下回ると正当なreadが落ち始めるので、そこが下限になる。 -したがってTokenはTTL内で何度でも再利用できる一方、**個々のリクエスト署名は使い切り**である。記録はノードごとに独立で、同じ署名を複数のレプリカへ送ればそれぞれで1回ずつ受理される(CRDTは同一操作の重複適用に耐えるが、ネットワーク全体で厳密に1回を保証するものではない)。またデフォルト実装はプロセス内に閉じており、再起動をまたいで5分以内に届いた同一署名までは防げない。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 +したがってTokenはTTL内で何度でも再利用できる一方、**個々のリクエスト署名は使い切り**である。JWT自体の署名検証は、受信したワイヤ上のバイト列(`header.payload`セグメント)に対して行う。 + +**relayも転送前に消費する。** relayはaccess policyを持たないのでcredentialをmemberへ転送して判断を委ねるが、それだとmember側にしか記録が残らず、同じ署名をrelayへ送り直すたびに「まだ見ていないmember」へ振り分けられて再適用できてしまう。ただし**消費記録を書く前に、relay自身が署名を暗号学的に検証する**。relayにできない(policyを要する)のは*認可*だけで、tokenの署名とリクエスト署名の検証は policy 無しで行えるためである。 + +この順序は防御の多重化ではなく必須である。request idは署名対象メッセージとprincipalから導かれ、その構成要素(操作・リソース・timestamp・body digest・`aud`)は**すべて公開情報**である。検証前に記録すると、鍵も有効な署名も持たない第三者が、対象ユーザーの正規リクエストのidをゴミ署名で先に焼き潰せる — bodyの無い`delete`は現在秒を入れるだけでメッセージが完全に予測でき、毎秒繰り返せばそのユーザーはこのrelay経由で何も更新できなくなる。 + +**ネットワーク全体での使い切りは未達である。** 記録はノードごとに独立で、同じ署名を複数のレプリカへ送ればそれぞれで1回ずつ受理される。各レプリカは受信のたびに*そのノードの*現在headとauthorで新しいoperationを生成するため、これは「同一CRDT operationの重複配送」ではなく**別々の新規operation**であり、CRDTの冪等性は当てはまらない。古い暗号文が新しいheadの子として再commitされ、最新版へ戻る巻き戻しは、別レプリカまたは再起動後には依然成立する。またデフォルト実装はプロセス内に閉じており、再起動をまたいで5分以内に届いた同一署名も防げない。protocol-levelのoperation IDによる全レプリカでのdedupが必要で、issue #65 で追跡している。 ### ビザンチン耐性 diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 2f01831..9a3f09e 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -605,8 +605,7 @@ where Ok(()) } - /// Consume a mutation signature on the **relay** path, where this node - /// cannot verify it. + /// Consume a mutation signature on the **relay** path. /// /// A relay holds no access policy, so it forwards the caller's credentials /// to a member and lets the member decide. That means the member is the only @@ -620,19 +619,54 @@ where /// (a caller can always talk to a different relay); it closes the specific /// hole where the *same* relay launders the *same* signature repeatedly. /// - /// No verification happens here, which means an unauthenticated caller can - /// burn an arbitrary signature digest on this node by presenting it once. - /// The cost of that is bounded: it only affects this node, only for the - /// freshness window, and only for a digest the attacker already has — if - /// they hold the signature they can replay it themselves anyway. - fn consume_relayed_mutation_signature( + /// **The signature is verified before anything is recorded.** The relay can + /// do this without a policy: `verify_caller_signature` is purely + /// cryptographic — the token's own signature, then the request signature + /// against the key the token designates. Only *authorization* needs the + /// policy, and that stays with the member. + /// + /// Verifying first is load-bearing, not defence in depth. The request id is + /// derived from the signed message and the principal — operation, resource, + /// timestamp, body digest, `aud` — every part of which is public. Recording + /// it before verification would let anyone burn a legitimate caller's id + /// with a garbage signature: send a `delete` for their content stamped with + /// the current second (no body, so the message is fully predictable) and + /// their real request comes back `RequestAlreadyApplied`. Repeat each second + /// and the target can never mutate anything through this relay. + /// + /// An earlier revision recorded first and argued the cost was bounded + /// because the id was "a digest the attacker already has". That was true + /// while the id *was* the signature digest; it stopped being true when the + /// id moved to the signed message, and this is the correction. + async fn consume_relayed_mutation_signature( &self, token: &AuthToken, + signature: &[u8], operation: &str, resource: &str, timestamp: Option, request_body: Option<&[u8]>, ) -> Result<(), StateNodeError> { + let auth_service = self.auth_service.as_ref().ok_or_else(|| { + StateNodeError::AuthenticationFailed( + "Authentication service is not configured".to_string(), + ) + })?; + + // Cryptographic verification only — authorization is the member's job. + // A forged or replayed-with-a-bad-signature request must not be able to + // reach the store at all. + self.verify_caller_signature( + auth_service.as_ref(), + token, + signature, + operation, + resource, + timestamp, + request_body, + ) + .await?; + // Same identity the verifying member will derive, so a request consumed // here is the same request there. Derived from the signed message, never // from the signature bytes — see `mutation_request_id`. @@ -1538,7 +1572,15 @@ where // 転送前にこのノードでも署名を消費する。member 側だけで消費すると、 // 同じ署名を relay へ送り直すたびに「まだ見ていない member」へ // 振り分けられて再適用できてしまう。 - self.consume_relayed_mutation_signature(token, "delete", content_id, timestamp, None)?; + self.consume_relayed_mutation_signature( + token, + request_signature, + "delete", + content_id, + timestamp, + None, + ) + .await?; // Resolve members from our local record, or via DHT discovery when // we hold no record (bug #93), then relay with failover. @@ -1719,11 +1761,13 @@ where // 振り分けられて再適用できてしまう。 self.consume_relayed_mutation_signature( token, + request_signature, "update", content_id, timestamp, Some(data), - )?; + ) + .await?; // Resolve members from our local record, or via DHT discovery when // we hold no record (bug #93), then relay with failover. @@ -3742,6 +3786,123 @@ mod tests { } } + /// 署名を検証しない認証サービス以外は全部通す mock。 + /// `bad` と完全一致する署名だけを拒否する。 + struct RejectsOneSignature { + bad: Vec, + } + + #[async_trait::async_trait] + impl AuthenticationService for RejectsOneSignature { + async fn authenticate( + &self, + token: &AuthToken, + _context: Option<&crate::port::auth_token::AuthContext>, + ) -> Result { + Identity::user(token.as_str().to_string()).map_err(|e| anyhow::anyhow!(e.to_string())) + } + + async fn is_valid(&self, token: &AuthToken) -> Result { + Ok(!token.is_empty()) + } + + async fn verify_request_signature( + &self, + _token: &AuthToken, + signature: &[u8], + _message: &str, + _timestamp: Option, + ) -> Result<()> { + if signature == self.bad.as_slice() { + anyhow::bail!("invalid signature"); + } + Ok(()) + } + + async fn verify_jwt_signature(&self, _token: &AuthToken) -> Result<()> { + Ok(()) + } + + async fn get_issuer(&self, token: &AuthToken) -> Result> { + Ok(Some( + Identity::user(token.as_str().to_string()) + .map_err(|e| anyhow::anyhow!(e.to_string()))?, + )) + } + } + + /// relay は**署名を検証してから**消費記録を書く。 + /// + /// request id は署名対象メッセージと principal から導かれ、その構成要素 + /// (操作・リソース・timestamp・body digest・`aud`)は**すべて公開情報**である。 + /// 検証前に記録すると、鍵も有効な署名も持たない第三者が、対象ユーザーの + /// 正規リクエストの id をゴミ署名で先に焼き潰せてしまう — + /// body の無い `delete` は現在秒を入れるだけでメッセージが完全に予測でき、 + /// 毎秒繰り返せばそのユーザーはこの relay 経由で何も更新できなくなる。 + #[tokio::test] + async fn an_invalid_signature_cannot_burn_a_legitimate_request_id() { + let token = AuthToken::new("user:04aaaa".to_string()); + let forged: Vec = vec![0xDE, 0xAD, 0xBE, 0xEF]; + let genuine: Vec = vec![0x01, 0x02, 0x03]; + + let service: TestService = StateNodeService::new( + MockNodeRegistry::new(), + Arc::new(RwLock::new(MockContentNetworkRepository::new())), + Arc::new(MockPeerNetwork::new().with_local_peer_id("node-1")), + MockEventPublisher::new(), + Arc::new(MockContentRepository::new()), + "node-1".to_string(), + ) + .with_authentication_service(RejectsOneSignature { + bad: forged.clone(), + }) + .with_authorization_service(AllowAllAuthorizationService); + + // 攻撃者が、被害者の principal・対象 content・現在秒で `delete` を + // 先回りして送る。署名は持っていないので出鱈目。 + let attack = service + .consume_relayed_mutation_signature( + &token, + &forged, + "delete", + "content-1", + Some(1_700_000_000), + None, + ) + .await; + assert!(attack.is_err(), "無効な署名は拒否されなければならない"); + + // 同じ principal・同じメッセージの正規リクエストが、まだ通ること。 + // ここが Err になるなら記録が先に書かれている= DoS が成立している。 + service + .consume_relayed_mutation_signature( + &token, + &genuine, + "delete", + "content-1", + Some(1_700_000_000), + None, + ) + .await + .expect("正規リクエストが先取りで潰されてはならない"); + + // 使い切りそのものは維持されている(2 度目は拒否)。 + let replay = service + .consume_relayed_mutation_signature( + &token, + &genuine, + "delete", + "content-1", + Some(1_700_000_000), + None, + ) + .await; + assert!( + matches!(replay, Err(StateNodeError::RequestAlreadyApplied(_))), + "同じ署名済みリクエストの 2 度目は拒否されなければならない" + ); + } + /// 委譲 JWT **自身の署名**も malleable である。生のトークン文字列を ID の /// 入力にすると、claims も `aud` も request signature も変えずに `s` を /// 反転するだけで別 ID を作れてしまい、同一ノードでも再適用できる。 From 5db6538bc1c9344ea0789dd950eb2c5783e8c527 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Wed, 29 Jul 2026 23:39:11 +0900 Subject: [PATCH 46/48] docs: correct design.md and two stale code comments against the implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited design.md's security-model claims against the code. Three drifted: - The single-use record covers six write operations (create, update, delete, invalidate, manage, revoke), not the four the document listed. A reader checking "is create replay-protected?" would have concluded it is not. - Publisher binding covers four event types, not two. The document omitted `ContentNetworkManagerRemoved` — the arm that *deletes* the local record, so strictly more destructive than the `Added` arm it did mention — and `ContentDeleted`, which needs both checks because it names its own publisher. `ContentUpdated` was also unlisted. - Publisher binding was presented as unconditional. It is skipped when the origin cannot be established (`source_peer_id == None`), which the document never mentioned alongside the first-record caveat it did. Two code comments contradicted the (correct) design doc and are fixed here: - `verify_caller_signature` still described the pre-#61 signing message format (`hex(sha256(body + timestamp_be_bytes))` / `{op}:{resource}:{timestamp}`). - `ucan_adapter`'s module doc said `iat >= min_valid_issued_at`; the cutoff has been exclusive since the same-second revoke fix. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c --- docs/design.md | 8 ++++++-- .../src/application_service/state_node_service.rs | 9 ++++++--- monas-state-node/src/infrastructure/auth/ucan_adapter.rs | 5 ++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/design.md b/docs/design.md index 7485300..dc6ad0f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -349,7 +349,7 @@ Token.att = [ Token失効は`min_valid_issued_at`による時刻ベースで管理される。オーナーがこの値を更新することで、それ以前に発行されたすべてのTokenを一括失効できる。判定は`iat > min_valid_issued_at`の**排他**であり、等値は無効とする — どちらも秒精度なので、失効と同じ秒に発行されたTokenが失効の前後どちらだったかは区別できず、等値を有効扱いにすると取り消したはずの相手のTokenが生き残る。誤る方向としては、失効直後の同一秒に発行されたTokenまで弾く方が安全である(呼び出し側は1秒後に取り直せば済むが、逆方向は取り消し済みの相手にアクセスを与え続ける)。なお`0`は「一度も失効していない」を意味し、全Tokenを受理する。 -役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別・bodyの有無によらず同一構造で、domain separationタグに続けて操作・リソース・timestamp・body digestを長さ前置で連結する(`monas-request-v1::<操作>::<リソース>:::`)。**bodyを伴う書き込みでも操作とリソースに束縛される**ため、あるコンテンツ向けに取得した署名を別コンテンツや別操作へ転用することはできない。リプレイ防御は2層で担う。第1に署名内のtimestampの鮮度チェック(5分窓)で、これは「古い署名を無限に使い回せない」ことを保証する。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。第2に、**mutationについては受理した署名を記録して2度目の提示を拒否する**。鮮度チェックだけでは窓の中で同じ署名を何度でも通せてしまい、update・delete・invalidate・manageは冪等でないため、それは単なる重複ではなく状態の巻き戻しになる — 署名済みの旧ciphertext更新を正規の更新の後に再送すると、サーバはそれを「現在のheadを親とする新しい操作」としてcommitし、古い内容が最新版になる。 +役割分担は「権限があること = Token(owner署名のケイパビリティ)」「今このリクエストを送っているのが宛先本人であること = リクエスト署名(Proof of Possession)」の2層である。リクエスト署名の対象はトークン種別・bodyの有無によらず同一構造で、domain separationタグに続けて操作・リソース・timestamp・body digestを長さ前置で連結する(`monas-request-v1::<操作>::<リソース>:::`)。**bodyを伴う書き込みでも操作とリソースに束縛される**ため、あるコンテンツ向けに取得した署名を別コンテンツや別操作へ転用することはできない。リプレイ防御は2層で担う。第1に署名内のtimestampの鮮度チェック(5分窓)で、これは「古い署名を無限に使い回せない」ことを保証する。timestampの無いリクエストは認証エラーとなる(サーバ時刻へのフォールバックはしない)。第2に、**mutationについては受理した署名を記録して2度目の提示を拒否する**。対象は書き込み系の6操作(`create` / `update` / `delete` / `invalidate` / `manage` / `revoke`)である。鮮度チェックだけでは窓の中で同じ署名を何度でも通せてしまい、これらは冪等でないため、それは単なる重複ではなく状態の巻き戻しになる — 署名済みの旧ciphertext更新を正規の更新の後に再送すると、サーバはそれを「現在のheadを親とする新しい操作」としてcommitし、古い内容が最新版になる。 リクエストの同一性には**署名対象メッセージと、検証後のcanonical principal**を使う(`SHA256(len(principal) ‖ principal ‖ signing_message)`、長さ前置はprincipalとメッセージの境界を付け替えられないようにするため)。principalは「リクエスト署名の検証に使う鍵」そのもので、自己完結型の鍵IDならその値、委譲JWTなら`aud`である。メッセージは既に操作・リソース・timestamp・body digestすべてに束縛されているので、nonceのような新しいフィールドをワイヤ形式へ足す必要がない。記録の保持期間は鮮度窓と同じでよい(窓の外へ出た署名は記録が無くても鮮度チェックで落ちる)ため、記録は無制限には育たない。読み取りは冪等なのでこの記録の対象外である。 @@ -385,10 +385,14 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。 ここで「ローカルレコード」は**owner署名によるattestationではない**。イベントにowner署名は無く、member集合は発行元の自己申告である。検証されているのは**発行元**の方で、Gossipsubを`MessageAuthenticity::Signed` + `ValidationMode::Strict`で運用しているため著者フィールドは必須かつ署名検証済みであり、これを次の2点に束縛している。 - `ContentCreated`は、名乗っている`creator_node_id`本人からの発行でなければ拒否する -- member集合の変更(`ContentNetworkManagerAdded`)は、こちらが保持しているそのネットワークの既存memberからの発行でなければ拒否する +- member集合を変える`ContentNetworkManagerAdded` / `ContentNetworkManagerRemoved`は、こちらが保持しているそのネットワークの既存memberからの発行でなければ拒否する +- `ContentDeleted`は上記に加えて、名乗っている`deleted_by_node_id`本人からの発行であることも確認する。ただしこのイベントは発行元を*自分で*名乗るので、その照合だけでは「認証済みなら誰でも通る」ことにしかならない。ローカルレコードを消せるのは既存memberだけである +- `ContentUpdated`は、名乗っている`updated_node_id`本人からの発行でなければ拒否する なお束縛に使うのはGossipsubの`Message::source`(**発行元**)であって`propagation_source`(直前の転送元)ではない。meshは多段転送するため、転送元で判定すると正規の多段配送を落としつつ偽装を通してしまう。 +**この束縛には2つの抜けがある。** 1つは後述する「最初の1通」で、照合すべき既存membershipが無いため受理せざるを得ない。もう1つは**発行元を特定できなかった場合**で、そのときorigin検証はスキップされる(`verify_source_is_existing_member`は`source_peer_id`が`None`ならそのまま`Ok`を返す)。Gossipsub経路は`Signed` + `Strict`運用なので実際には常に発行元が付くが、`handle_sync_event`を呼ぶ側が認証済みの値を渡す責任を負っている、という構造である。 + **候補が返した401/403は、出自によらず早期打ち切りの根拠にはしない。** 権威にすると、DHTキーの近くにPeer IDを置いた1台が403を返すだけであらゆるread/writeを止められてしまう(可用性への攻撃)。また正規のmemberであっても、policyの複製が終わっていない部分同期状態なら403を返し得るため、健全なレプリカへのfailoverを潰さないためにも継続が必要である。ただし答えとしては保持し、他の候補から何も得られなければそれを返す。 ローカルレコード由来のmemberについては、以前は「実policyに対する評価結果だから」として打ち切っていた。**これは撤回した。** レコード自体が最初の1通で植え付けられる(照合すべき既存membershipが無いため受理せざるを得ない)以上、その競争に勝った攻撃者は候補リストに載り、その403で正規callerのreadを恒久的に止められる — 未証明ピアについて防いでいるのと同じ攻撃が、ローカルレコード経路でも成立してしまう。早期打ち切りを戻せるのはowner署名付きmembership(#63)が入ってからである。継続のコストは「本当に拒否された場合に残り候補ぶんの往復が増える」ことに限られ、可用性側に倒すのが正しい方向である(callerはどのみち拒否され、それが少し遅くなるだけ)。 diff --git a/monas-state-node/src/application_service/state_node_service.rs b/monas-state-node/src/application_service/state_node_service.rs index 9a3f09e..38f2c12 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -381,9 +381,12 @@ where /// Verify the caller's request signature. /// - /// The signed message is identical for every token type (issue #61): - /// - If `request_body` is `Some(body)`: signs `hex(sha256(body + timestamp_be_bytes))` - /// - If `request_body` is `None`: signs `{operation}:{resource}:{timestamp}` + /// The signed message is identical for every token type (issue #61) and is + /// built by [`Self::build_signing_message`]: + /// `monas-request-v1:::::::` + /// where `digest` is `hex(sha256(body))` when a body is present and empty + /// otherwise. Every field is length-prefixed, so no two distinct requests + /// can produce the same message by shifting a boundary. /// /// The timestamp *inside* the signed message is checked for freshness by the /// auth service, so `timestamp` is mandatory — there is no server-clock diff --git a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs index 86622cb..e1d1e36 100644 --- a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs @@ -7,7 +7,10 @@ //! 1. Owner check: if the identity is the owner, access is granted immediately //! 2. AuthToken check: non-owners must provide a valid AuthToken (JWT) //! - Token signature is verified against the owner's public key -//! - Token's iat must be >= policy's min_valid_issued_at +//! - Token's iat must be > policy's min_valid_issued_at (exclusive cutoff: +//! both have one-second resolution, so a token stamped with the same +//! second as the revoke may well predate it). `min_valid_issued_at == 0` +//! means nothing has been revoked and accepts everything. //! - Token must grant the required capability use crate::domain::auth_capability::AuthCapability; From 4b747d51a32c98a43f6ba60dbaa0ea5017f88d7e Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Tue, 11 Aug 2026 07:52:15 +0900 Subject: [PATCH 47/48] fix(sdk): release content revoke locks, and depend on the pin-store port not its impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from @Yu-da-1's review of #56. **The revoke lock registry never shrank.** `mutex_for` inserted a per-content mutex and nothing ever removed it, so the map grew by one entry for every distinct content ever revoked and never gave any back. A gateway runs continuously, so this grew with uptime and with the number of contents handled. Each entry is only tens of bytes, but it had no upper bound at all. Reproduced against the old shape: 100 sequential revokes left 100 entries. Replaced the map-of-mutexes with a single mutex + condvar guarding a set of in-flight content ids. Holding the guard is what excludes others; dropping it removes the entry and wakes any waiter. Presence in the set *is* the lock, so release cannot forget to clean up, and the set is bounded by the number of revokes running concurrently rather than by the number ever performed. This was my code (0f8c35b). I documented that the lock was process-local but never considered that the registry backing it grew without bound. **The SDK reached into monas-content's infrastructure layer.** `SenderKeyPinStore` is a port — an abstraction over where the pin is persisted — but it lived in `infrastructure/` next to its two implementations, so every consumer had to name the infrastructure path. Moved the trait, `SenderKeyPin` and the error type to `application_service::share_service::sender_key_pin_port`, alongside the `ShareRepository` and `PublicKeyDirectory` ports that play the same role. `infrastructure/sender_key_pin_store.rs` keeps only the in-memory and Sled implementations. The SDK's type alias and construction now name the port. The composition root still names the concrete backends, which is where that choice belongs. Three tests cover the lock: entries are released, the same content is still mutually exclusive under 8 threads, and different contents do not block each other. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c --- .../application_service/share_service/mod.rs | 2 + .../share_service/sender_key_pin_port.rs | 86 ++++++++++ .../infrastructure/sender_key_pin_store.rs | 92 +--------- monas-sdk/src/controller/mod.rs | 161 +++++++++++++++++- monas-sdk/src/controller/share.rs | 9 +- 5 files changed, 252 insertions(+), 98 deletions(-) create mode 100644 monas-content/src/application_service/share_service/sender_key_pin_port.rs diff --git a/monas-content/src/application_service/share_service/mod.rs b/monas-content/src/application_service/share_service/mod.rs index 8291c92..561e7f1 100644 --- a/monas-content/src/application_service/share_service/mod.rs +++ b/monas-content/src/application_service/share_service/mod.rs @@ -1,7 +1,9 @@ mod command; mod port; +pub mod sender_key_pin_port; mod service; pub use command::*; pub use port::*; +pub use sender_key_pin_port::{SenderKeyPin, SenderKeyPinStore, SenderKeyPinStoreError}; pub use service::*; diff --git a/monas-content/src/application_service/share_service/sender_key_pin_port.rs b/monas-content/src/application_service/share_service/sender_key_pin_port.rs new file mode 100644 index 0000000..0e17643 --- /dev/null +++ b/monas-content/src/application_service/share_service/sender_key_pin_port.rs @@ -0,0 +1,86 @@ +//! 受信者側の送信者公開鍵ピン(TOFU)ストア。 +//! +//! share の KeyEnvelope は HPKE Auth モードでラップされており、受信者は +//! 「期待する送信者の公開鍵」で unwrap する(成功 = その鍵の持ち主が作った証明)。 +//! このストアは content ごとに、最初に unwrap に成功した送信者公開鍵を +//! ピン留めし(TOFU)、以後の envelope はピン済みの鍵でのみ検証する。 +//! +//! 併せて CEK の鍵世代(key_epoch)と、**その世代の CEK 自体**を記録する。 +//! 記録済み世代より古い envelope は拒否する(rotation 後に旧 envelope を +//! 再送して CEK を巻き戻す replay 攻撃の防止)。 +//! +//! ## なぜ CEK をここに置くのか +//! +//! 守るべき不変条件は「送信者鍵・世代・CEK の3つ組が常に整合していること」で +//! あって、世代番号だけではない。3つ組を別ストアに分けて別々に commit すると、 +//! 世代を CAS で守っても次の interleaving で壊れる: +//! +//! 1. epoch N の処理が pin(epoch N-1)を読む +//! 2. epoch N+1 の処理が pin を N+1 へ進め、新しい CEK を保存する +//! 3. epoch N の処理が「同一世代の再処理」等の経路で CEK だけを書き戻す +//! 4. 結果は `pin = N+1, CEK = N` となり、以後の復号が失敗する +//! +//! 3つ組を1レコードに入れて単一の compare-and-swap で入れ替えれば、この +//! interleaving は構造的に起こり得ない。CEK ストア側は、この権威レコードから +//! 導出されるキャッシュとして扱う(書き損じても再処理で回復できる)。 +//! +//! キーは受信者から見た(ローカルの) content id。 + +#[derive(Debug, thiserror::Error)] +pub enum SenderKeyPinStoreError { + #[error("sender key pin store error: {0}")] + Storage(String), +} + +/// ピン留めされた送信者公開鍵と、その送信者から受理した最新の鍵世代・CEK。 +/// +/// この3つは常に同じ commit で入れ替わる。個別に更新してはならない +/// (モジュール doc の interleaving を参照)。 +#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SenderKeyPin { + /// 送信者の公開鍵バイト列(P-256 uncompressed form)。 + pub sender_public_key: Vec, + /// 最後に unwrap に成功した envelope の key_epoch。 + pub key_epoch: u64, + /// `key_epoch` 世代の CEK。この端末のローカルにのみ存在し、ネットワークには出ない。 + /// + /// 旧レコード(CEK を持たない形式)から読んだ場合は `None` になる。 + /// その場合は次に受理した envelope で埋まる。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cek: Option>, +} + +/// CEK を含むため、`Debug` は鍵素材を出さない。ログや panic メッセージに +/// レコードが載っても CEK が漏れないようにする。 +impl std::fmt::Debug for SenderKeyPin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SenderKeyPin") + .field("sender_public_key", &self.sender_public_key) + .field("key_epoch", &self.key_epoch) + .field( + "cek", + &self.cek.as_ref().map(|_| "").unwrap_or("None"), + ) + .finish() + } +} + +/// `content_id -> (送信者公開鍵, 最終受理 key_epoch, その世代の CEK)` の永続化ポート。 +pub trait SenderKeyPinStore: Send + Sync { + fn load(&self, content_id: &str) -> Result, SenderKeyPinStoreError>; + fn save(&self, content_id: &str, pin: &SenderKeyPin) -> Result<(), SenderKeyPinStoreError>; + + /// compare-and-advance: 現在値が `expected` と一致する場合のみ `pin` へ進める。 + /// 戻り値は「進めたかどうか」。 + /// + /// envelope の並行処理(rotation 前後の epoch N / N+1 が同時に走る等)で、 + /// 「load した時点の pin」を前提に無条件 save すると、後から完了した古い + /// epoch が新しいレコードを巻き戻せる。3つ組は1レコードなので、この CAS が + /// 成功した時点で送信者鍵・世代・CEK は一括で入れ替わっている。 + fn compare_and_save( + &self, + content_id: &str, + expected: Option<&SenderKeyPin>, + pin: &SenderKeyPin, + ) -> Result; +} diff --git a/monas-content/src/infrastructure/sender_key_pin_store.rs b/monas-content/src/infrastructure/sender_key_pin_store.rs index 8d4b08f..5a231a8 100644 --- a/monas-content/src/infrastructure/sender_key_pin_store.rs +++ b/monas-content/src/infrastructure/sender_key_pin_store.rs @@ -1,92 +1,16 @@ -//! 受信者側の送信者公開鍵ピン(TOFU)ストア。 +//! [`SenderKeyPinStore`] の実装。 //! -//! share の KeyEnvelope は HPKE Auth モードでラップされており、受信者は -//! 「期待する送信者の公開鍵」で unwrap する(成功 = その鍵の持ち主が作った証明)。 -//! このストアは content ごとに、最初に unwrap に成功した送信者公開鍵を -//! ピン留めし(TOFU)、以後の envelope はピン済みの鍵でのみ検証する。 -//! -//! 併せて CEK の鍵世代(key_epoch)と、**その世代の CEK 自体**を記録する。 -//! 記録済み世代より古い envelope は拒否する(rotation 後に旧 envelope を -//! 再送して CEK を巻き戻す replay 攻撃の防止)。 -//! -//! ## なぜ CEK をここに置くのか -//! -//! 守るべき不変条件は「送信者鍵・世代・CEK の3つ組が常に整合していること」で -//! あって、世代番号だけではない。3つ組を別ストアに分けて別々に commit すると、 -//! 世代を CAS で守っても次の interleaving で壊れる: -//! -//! 1. epoch N の処理が pin(epoch N-1)を読む -//! 2. epoch N+1 の処理が pin を N+1 へ進め、新しい CEK を保存する -//! 3. epoch N の処理が「同一世代の再処理」等の経路で CEK だけを書き戻す -//! 4. 結果は `pin = N+1, CEK = N` となり、以後の復号が失敗する -//! -//! 3つ組を1レコードに入れて単一の compare-and-swap で入れ替えれば、この -//! interleaving は構造的に起こり得ない。CEK ストア側は、この権威レコードから -//! 導出されるキャッシュとして扱う(書き損じても再処理で回復できる)。 -//! -//! キーは受信者から見た(ローカルの) content id。 +//! ポート定義(トレイトと [`SenderKeyPin`])は +//! `application_service::share_service::sender_key_pin_port` にある。 +//! ここにあるのは保存先ごとの実装だけで、SDK など上位のレイヤーは +//! トレイトの方を参照する。 use std::collections::HashMap; use std::sync::{Arc, Mutex}; -#[derive(Debug, thiserror::Error)] -pub enum SenderKeyPinStoreError { - #[error("sender key pin store error: {0}")] - Storage(String), -} - -/// ピン留めされた送信者公開鍵と、その送信者から受理した最新の鍵世代・CEK。 -/// -/// この3つは常に同じ commit で入れ替わる。個別に更新してはならない -/// (モジュール doc の interleaving を参照)。 -#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct SenderKeyPin { - /// 送信者の公開鍵バイト列(P-256 uncompressed form)。 - pub sender_public_key: Vec, - /// 最後に unwrap に成功した envelope の key_epoch。 - pub key_epoch: u64, - /// `key_epoch` 世代の CEK。この端末のローカルにのみ存在し、ネットワークには出ない。 - /// - /// 旧レコード(CEK を持たない形式)から読んだ場合は `None` になる。 - /// その場合は次に受理した envelope で埋まる。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cek: Option>, -} - -/// CEK を含むため、`Debug` は鍵素材を出さない。ログや panic メッセージに -/// レコードが載っても CEK が漏れないようにする。 -impl std::fmt::Debug for SenderKeyPin { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SenderKeyPin") - .field("sender_public_key", &self.sender_public_key) - .field("key_epoch", &self.key_epoch) - .field( - "cek", - &self.cek.as_ref().map(|_| "").unwrap_or("None"), - ) - .finish() - } -} - -/// `content_id -> (送信者公開鍵, 最終受理 key_epoch, その世代の CEK)` の永続化ポート。 -pub trait SenderKeyPinStore: Send + Sync { - fn load(&self, content_id: &str) -> Result, SenderKeyPinStoreError>; - fn save(&self, content_id: &str, pin: &SenderKeyPin) -> Result<(), SenderKeyPinStoreError>; - - /// compare-and-advance: 現在値が `expected` と一致する場合のみ `pin` へ進める。 - /// 戻り値は「進めたかどうか」。 - /// - /// envelope の並行処理(rotation 前後の epoch N / N+1 が同時に走る等)で、 - /// 「load した時点の pin」を前提に無条件 save すると、後から完了した古い - /// epoch が新しいレコードを巻き戻せる。3つ組は1レコードなので、この CAS が - /// 成功した時点で送信者鍵・世代・CEK は一括で入れ替わっている。 - fn compare_and_save( - &self, - content_id: &str, - expected: Option<&SenderKeyPin>, - pin: &SenderKeyPin, - ) -> Result; -} +use crate::application_service::share_service::sender_key_pin_port::{ + SenderKeyPin, SenderKeyPinStore, SenderKeyPinStoreError, +}; /// プロセス内 `HashMap` 実装。テスト・開発用(再起動で揮発 = 毎回 TOFU に戻る)。 #[derive(Clone, Default)] diff --git a/monas-sdk/src/controller/mod.rs b/monas-sdk/src/controller/mod.rs index d1a5a39..ac5d23f 100644 --- a/monas-sdk/src/controller/mod.rs +++ b/monas-sdk/src/controller/mod.rs @@ -75,8 +75,11 @@ pub struct MonasController { } /// SDK が使う送信者鍵ピンストアの動的型。 +/// +/// 参照するのは application 層のポートで、実装(In-memory / Sled)がある +/// infrastructure 層ではない。SDK が特定の保存先実装に依存しないようにする。 pub(super) type DynSenderPinStore = - std::sync::Arc; + std::sync::Arc; /// content id ごとの相互排他ロック。 /// @@ -95,21 +98,89 @@ pub(super) type DynSenderPinStore = /// 直列化で「並行 revoke が状態を分岐させない」ことを保証する。 /// ロックはプロセス内のみで、複数 gateway プロセスからの並行 revoke は /// カバーしない(その場合は state node 側の CAS が必要)。 +#[derive(Default)] +struct ContentLocksState { + /// 現在 revoke 中の content id。エントリが無い = 誰も触っていない。 + held: std::collections::HashSet, +} + #[derive(Clone, Default)] pub(super) struct ContentLocks { - inner: Arc>>>>, + inner: Arc<(std::sync::Mutex, std::sync::Condvar)>, } impl ContentLocks { - /// `content_id` 専用の mutex を取得する。同じ id には常に同じ mutex を返す。 - pub(super) fn mutex_for(&self, content_id: &str) -> Arc> { - let mut map = self - .inner + /// `content_id` の revoke 権を取り、保持している間だけ他を待たせるガードを + /// 返す。同じ id への revoke は直列化され、異なる id は互いに待たない。 + /// + /// ガードを drop するとエントリが表から消える(待っている者がいれば、その + /// 相手が起きて自分のエントリを立て直す)。**表は revoke 中の content 数 + /// までしか伸びない** — 詳細は [`ContentLockGuard`]。 + pub(super) fn lock(&self, content_id: &str) -> ContentLockGuard { + let (mutex, condvar) = &*self.inner; + let mut state = mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + // 既に誰かが持っているなら空くまで待つ。`wait` は mutex を手放すので、 + // 待っている間に解放側が入れる。 + while state.held.contains(content_id) { + state = condvar + .wait(state) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + } + + // 空いていた: 自分が保持者になる。 + state.held.insert(content_id.to_string()); + drop(state); + + ContentLockGuard { + locks: self.inner.clone(), + content_id: content_id.to_string(), + } + } + + /// 現在このレジストリが保持しているエントリ数(テスト用)。 + #[cfg(test)] + pub(super) fn tracked_len(&self) -> usize { + let (mutex, _) = &*self.inner; + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .held + .len() + } +} + +/// 保持している間だけ、その content の revoke が直列化される。 +/// +/// drop 時にエントリを表から取り除き、待っている者を起こす。取り除かないと、 +/// revoke した content の数だけ表が伸び続けて二度と縮まない — gateway は +/// 動かしっぱなしなので、稼働時間と扱った content 数に比例してメモリを食う。 +/// 1 件あたりは数十バイトだが上限が無いのが問題で、PR #56 のレビューで +/// 指摘された。 +/// +/// エントリの有無そのものが「保持者がいるか」を表すので、drop では常に消す。 +/// 待っている者はこの削除を見て初めて自分が保持者になれる(`lock` の while は +/// `contains_key` が false になるまで回る)。判定も削除も同じ mutex の下で +/// 行うため、起きた側が保持者になるまでに別のリクエストが割り込む隙は無い。 +/// 結果として、**表のサイズは同時に revoke 中の content 数**で頭打ちになる。 +pub(super) struct ContentLockGuard { + locks: Arc<(std::sync::Mutex, std::sync::Condvar)>, + content_id: String, +} + +impl Drop for ContentLockGuard { + fn drop(&mut self) { + let (mutex, condvar) = &*self.locks; + let mut state = mutex .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - map.entry(content_id.to_string()) - .or_insert_with(|| Arc::new(std::sync::Mutex::new(()))) - .clone() + + // エントリを落とすことが「解放」そのもの。これが無いと表が伸び続ける。 + state.held.remove(&self.content_id); + drop(state); + condvar.notify_all(); } } @@ -356,6 +427,78 @@ impl MonasController { mod tests { use super::*; + /// ロックを手放したら、その content のエントリはレジストリから消える。 + /// + /// 消さないと revoke した content の数だけ表が伸び続け、gateway は + /// 動かしっぱなしなので稼働時間に比例してメモリを食う(PR #56 レビュー指摘)。 + #[test] + fn releasing_a_content_lock_drops_its_registry_entry() { + let locks = ContentLocks::default(); + assert_eq!(locks.tracked_len(), 0); + + { + let _guard = locks.lock("content-1"); + assert_eq!(locks.tracked_len(), 1, "保持中はエントリがある"); + } + assert_eq!(locks.tracked_len(), 0, "解放したら消える"); + + // 別々の content を順に触っても溜まらない。 + for i in 0..100 { + let _guard = locks.lock(&format!("content-{i}")); + } + assert_eq!( + locks.tracked_len(), + 0, + "順に revoke しただけでエントリが溜まってはならない" + ); + } + + /// 表が縮んでも、同じ content への同時 revoke は直列化されたままである。 + /// (エントリ削除で相互排他まで壊していないことの確認) + #[test] + fn same_content_locks_are_still_mutually_exclusive() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let locks = ContentLocks::default(); + let inside = Arc::new(AtomicUsize::new(0)); + let max_seen = Arc::new(AtomicUsize::new(0)); + + std::thread::scope(|scope| { + for _ in 0..8 { + let locks = locks.clone(); + let inside = inside.clone(); + let max_seen = max_seen.clone(); + scope.spawn(move || { + for _ in 0..50 { + let _guard = locks.lock("same-content"); + let now = inside.fetch_add(1, Ordering::SeqCst) + 1; + max_seen.fetch_max(now, Ordering::SeqCst); + std::thread::yield_now(); + inside.fetch_sub(1, Ordering::SeqCst); + } + }); + } + }); + + assert_eq!( + max_seen.load(Ordering::SeqCst), + 1, + "同じ content のクリティカルセクションに同時に 2 つ入ってはならない" + ); + assert_eq!(locks.tracked_len(), 0, "全部終われば空になる"); + } + + /// 異なる content は互いに待たない(id ごとに分けている意味の確認)。 + #[test] + fn different_contents_do_not_block_each_other() { + let locks = ContentLocks::default(); + let _held = locks.lock("content-a"); + // content-a を保持したまま content-b を取れる。ここで固まるなら + // レジストリ全体を 1 本のロックで守ってしまっている。 + let _other = locks.lock("content-b"); + assert_eq!(locks.tracked_len(), 2); + } + /// `combine_rollback_failure` は `primary` の variant を保ち、message に /// rollback 情報を suffix として追加する。 /// PR #29 review (design 軸) で指摘された「ApiError::Internal collapse」を diff --git a/monas-sdk/src/controller/share.rs b/monas-sdk/src/controller/share.rs index dc01e47..41da712 100644 --- a/monas-sdk/src/controller/share.rs +++ b/monas-sdk/src/controller/share.rs @@ -469,10 +469,9 @@ impl MonasController { // // ロックはプロセス内のみ。複数 gateway プロセスからの並行 revoke は // これでは防げず、state node 側の CAS が必要になる(現状の制約)。 - let revoke_lock = self.content_revoke_locks.mutex_for(content_id.as_str()); - let _revoke_guard = revoke_lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + // ガードを drop するとエントリは表から消えるので、revoke した content の + // 数だけレジストリが伸び続けることはない。 + let _revoke_guard = self.content_revoke_locks.lock(content_id.as_str()); let snapshot = match self.capture_revoke_share_snapshot(&content_id) { Ok(snapshot) => snapshot, @@ -855,7 +854,7 @@ impl MonasController { // // CAS が失敗した = 別の処理が先に同じかより新しい世代へ進めた、なので // こちらの(古い)3つ組は捨てる。 - let new_pin = monas_content::infrastructure::sender_key_pin_store::SenderKeyPin { + let new_pin = monas_content::application_service::share_service::SenderKeyPin { sender_public_key: effective_sender_public_key, key_epoch: input.key_envelope.key_epoch, cek: Some(cek.0.clone()), From de80a23cf4477c00c67f534376ca24f46e8ade97 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Thu, 13 Aug 2026 18:28:31 +0900 Subject: [PATCH 48/48] refactor(sdk): drop the duplicate CID verification in the read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `read_content_from_state_node` called `verify_and_extract` and threw the result away, then called `verify_and_decrypt_relay_read`, which runs the same `verify_and_extract` on the same bytes against the same version as its first step. The Node CID was recomputed twice per read. The error surfaced to the caller is unchanged: the removed block and `map_verified_read_error`'s `NodeVerification` arm produce the identical message, so `read_rejects_tampered_node` still passes untouched. This also removes one of the SDK's direct reachs into monas-content's infrastructure layer, leaving verification as the content layer's responsibility in one place. The remaining direct call is in `verify_integrity`, which consumes the extracted ciphertext for a byte comparison and does not go through `verify_and_decrypt_relay_read` — that one is a layering question, not a duplicate. Confirmed the surviving check is what catches tampering: weakening it to verify each node against its own recomputed CID makes `read_rejects_tampered_node` fail. Reported by @Yu-da-1 in review of #56. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c --- monas-sdk/src/controller/state.rs | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/monas-sdk/src/controller/state.rs b/monas-sdk/src/controller/state.rs index 2c791e7..ac5338e 100644 --- a/monas-sdk/src/controller/state.rs +++ b/monas-sdk/src/controller/state.rs @@ -317,23 +317,11 @@ impl MonasController { } }; - // CID 再計算による改ざん検証。ここを通れば payload は要求した版 CID に - // 対して真正(復号は下の verify_and_decrypt_relay_read が再度行う)。 - if let Err(e) = monas_content::infrastructure::node_verification::verify_and_extract( - &node_bytes, - &version, - ) { - { - return ApiResponse::error( - ApiError::Internal(format!( - "state node response failed CID verification (tampered response?): {e}" - )), - trace_id, - ); - } - } - - // CEK ロード + AES-GCM 復号 + plain CID 照合 + // CID 再計算による改ざん検証 + CEK ロード + AES-GCM 復号 + plain CID 照合 + // + // 検証は `verify_and_decrypt_relay_read` の中で必ず最初に走るので、 + // ここで先に `verify_and_extract` を呼ぶ必要はない(同じ引数で 2 回 + // 走らせていた)。検証は content 層の責務として一箇所に置く。 // // CEK は「送信者ピンの権威レコード」を優先する。CEK ストアは、その // レコードから導出されるキャッシュに過ぎず、CAS 成功後の書き込み順が