diff --git a/Cargo.lock b/Cargo.lock index 475e451..4c87ae9 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", @@ -3189,6 +3193,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "cid", "dotenv", "hex", "mockito", @@ -3196,6 +3201,7 @@ dependencies = [ "monas-content", "monas-filesync", "serde", + "serde_cbor", "serde_json", "sha2", "sled", diff --git a/docs/design.md b/docs/design.md index e297fed..dc6ad0f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -246,15 +246,24 @@ 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も巻き添えで失効する**(判定は排他なので、取り消しと同じ秒に発行された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によるコンテンツアドレッシング @@ -338,7 +347,27 @@ 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度目の提示を拒否する**。対象は書き込み系の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のような新しいフィールドをワイヤ形式へ足す必要がない。記録の保持期間は鮮度窓と同じでよい(窓の外へ出た署名は記録が無くても鮮度チェックで落ちる)ため、記録は無制限には育たない。読み取りは冪等なのでこの記録の対象外である。 + +**同一性の入力に署名バイト列を一切含めないことが要点である。** 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に対する非常に強い防御となる」と述べている。鮮度チェックだけでは不十分であることが、仕様レベルで明示されている。 + +**鮮度窓の値(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内で何度でも再利用できる一方、**個々のリクエスト署名は使い切り**である。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 で追跡している。 ### ビザンチン耐性 @@ -362,6 +391,8 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。 なお束縛に使うのは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はどのみち拒否され、それが少し遅くなるだけ)。 @@ -379,6 +410,34 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。 したがって「このノードが本当にこのcontentのmemberである」ことを暗号学的に確認する仕組みは依然として無く、ローカルレコード / 未証明の区別はそこへ至るまでの近似にとどまる。 +### read応答の完全性検証 + +libp2pのトランスポート認証が保証するのは隣接ホップの相手が本物であることだけで、relay越しに返ってきたデータが正しいかは保証しない。read応答はクライアント側で以下を検証する。 + +- **payload真正性**: state-nodeはreadに対しcrsl-lib Node全体(CBOR)を返し、クライアントがCIDを再計算して要求した版CIDと照合する。CIDはバイト列そのもののハッシュなので、一致すれば応答は要求した版に束縛され、返した相手が誰か(memberか否か)の確認は不要。さらにCEKでのAES-GCM復号 + 平文CID照合により、payloadが正規のCEKで暗号化された本物であることまで検証される — CEKを持たない攻撃者は復号可能な偽payloadを注入できない。 + +member証明(ownerがmemberを認証するトークン)は採用しない。memberはDHT複製配置によりownerの関与なく増減するため「ownerがmember追加時に発行する」経路が成立せず、payload真正性があれば返した相手の身元確認は不要でもある。 + +**版の真正性とロールバック耐性は現時点では保証していない。** CID照合が保証するのは「バイト列が要求した版CIDに一致すること」であり、「その版が正規の書き込みとして作られたこと」「それが最新であること」ではない。relay上で暗号文を観測できる攻撃者は、観測済みの本物の暗号文を新しいNodeに包み直し、任意のparentsを詰めた「偽の版」を鋳造できる(payloadは本物なので復号も通る)。 + +クライアント側で「最後に受理した版」を記録して後退を拒否する単調性チェックも検討したが、採用しない。偽のparentsを詰めた版でbypassできるため本質的な防御にならない一方、結果整合性のもとでは正当なsync遅延(分散システムでは正常な挙動)と攻撃を応答単体で区別できず、正規のreadを壊す誤検知と、クライアント側の永続状態という負債だけが残るためである。 + +版の真正性は、Nodeまたは版メタデータへのowner(または権限を持つwriter)署名というtrust anchorで解決する。これはcrsl-libに及ぶプロトコル変更であり、別issueで追跡する。それが入るまで、readの保証範囲は「返されたpayloadが、要求した版に対して真正であること」までである。 + +### 共有コンテンツのCEKライフサイクル + +share受信者はKeyEnvelopeの復号成功時にunwrap済みCEKを自デバイスのローカルストアへ保存し、以後は自身もstate-nodeからの検証付きreadで復号できる。CEK・平文がデバイス外に出ることはない(state-nodeは常に暗号文のみを扱う)。 + +**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攻撃はこれで防がれる。 + +受信者側のローカル状態では、**送信者鍵・鍵世代・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のままでは新しい版を復号できない。 + --- ## 11. CRSLとCRDT 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/application_service/content_service/service.rs b/monas-content/src/application_service/content_service/service.rs index 3396cd7..d7b77a3 100644 --- a/monas-content/src/application_service/content_service/service.rs +++ b/monas-content/src/application_service/content_service/service.rs @@ -289,6 +289,66 @@ 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.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. + /// + /// 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 を持つ)、ストアより + /// そちらを優先させるための引数である。ストアは書き込み順が入れ替わると + /// 巻き戻り得るキャッシュに過ぎない。 + 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, + expected_version_cid, + ) + .map_err(VerifiedReadError::NodeVerification)?; + + 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) + .map_err(VerifiedReadError::Decrypt)?; + + Ok(VerifiedRead { + plaintext, + parents: verified.parents, + }) + } + /// コンテンツ削除ユースケース。 /// /// - 物理削除ではなく、ドメインオブジェクト上で `is_deleted` フラグとバッファをクリアして保存する「論理削除」 @@ -633,6 +693,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")] @@ -992,6 +1072,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, @@ -1498,6 +1611,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-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/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/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/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/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 ada51f0..1a9975b 100644 --- a/monas-content/src/infrastructure/mod.rs +++ b/monas-content/src/infrastructure/mod.rs @@ -2,7 +2,9 @@ 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 sender_key_pin_store; pub mod share_repository; #[cfg(feature = "filesync")] diff --git a/monas-content/src/infrastructure/node_verification.rs b/monas-content/src/infrastructure/node_verification.rs new file mode 100644 index 0000000..0b1dc7a --- /dev/null +++ b/monas-content/src/infrastructure/node_verification.rs @@ -0,0 +1,288 @@ +//! 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.md` §10「read応答の完全性検証」. +//! +//! 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 (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 +/// 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. + 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-content/src/infrastructure/sender_key_pin_store.rs b/monas-content/src/infrastructure/sender_key_pin_store.rs new file mode 100644 index 0000000..5a231a8 --- /dev/null +++ b/monas-content/src/infrastructure/sender_key_pin_store.rs @@ -0,0 +1,331 @@ +//! [`SenderKeyPinStore`] の実装。 +//! +//! ポート定義(トレイトと [`SenderKeyPin`])は +//! `application_service::share_service::sender_key_pin_port` にある。 +//! ここにあるのは保存先ごとの実装だけで、SDK など上位のレイヤーは +//! トレイトの方を参照する。 + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::application_service::share_service::sender_key_pin_port::{ + SenderKeyPin, SenderKeyPinStore, 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(()) + } + + 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。 +/// CEK / share / pubkey ストアと同じ `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(()) + } + + fn compare_and_save( + &self, + content_id: &str, + expected: Option<&SenderKeyPin>, + pin: &SenderKeyPin, + ) -> Result { + // 比較は保存形式(JSON バイト列)で行う。`SenderKeyPin` のフィールド順は + // 固定で serde_json も宣言順に出すため、同じ値は同じバイト列になる。 + // `cek: None` は `skip_serializing_if` で欄ごと省かれるが、これも + // 値ごとに一意なので比較は成立する(旧形式レコードとも一致する)。 + 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)] +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, + cek: None, + }; + 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()); + + // compare-and-advance: 期待値が現在値と一致すれば進む + 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 + .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, + cek: None, + }; + 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, + cek: None, + }; + assert!(store.compare_and_save("content-c", None, &first).unwrap()); + assert!(!store.compare_and_save("content-c", None, &first).unwrap()); + } + + #[test] + fn in_memory_roundtrip() { + 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, + cek: None, + }; + 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, + cek: None, + }; + let epoch1 = SenderKeyPin { + sender_public_key: key, + key_epoch: 1, + cek: None, + }; + + // 新しい世代が先に前進する + 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)); + } + + /// 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()); + } + + #[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(); + 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-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/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/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/content.rs b/monas-sdk/src/controller/content.rs index 5722d6e..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::{ @@ -96,15 +97,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 +224,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 +241,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 +263,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 +538,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 +630,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( @@ -653,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, @@ -1089,20 +1206,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!( + base, + MonasController::build_request_signature_message("delete", "c1", 43, None) + ); + } + + #[test] + 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!( - message, - MonasController::build_content_signature_message(b"abc", 43) + 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 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_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/src/controller/mod.rs b/monas-sdk/src/controller/mod.rs index 4ca0023..ac5d23f 100644 --- a/monas-sdk/src/controller/mod.rs +++ b/monas-sdk/src/controller/mod.rs @@ -67,6 +67,121 @@ pub struct MonasController { content_service: ContentServiceInstance, /// ShareService share_service: ShareServiceInstance, + /// share 受信者側の送信者公開鍵ピン(TOFU)と受理済み CEK 鍵世代の記録 + /// (KeyEnvelope の送信者認証と rotation 巻き戻し replay 防止) + sender_pin_store: DynSenderPinStore, + /// content 単位の revoke 直列化ロック。 + content_revoke_locks: ContentLocks, +} + +/// SDK が使う送信者鍵ピンストアの動的型。 +/// +/// 参照するのは application 層のポートで、実装(In-memory / Sled)がある +/// infrastructure 層ではない。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(Default)] +struct ContentLocksState { + /// 現在 revoke 中の content id。エントリが無い = 誰も触っていない。 + held: std::collections::HashSet, +} + +#[derive(Clone, Default)] +pub(super) struct ContentLocks { + inner: Arc<(std::sync::Mutex, std::sync::Condvar)>, +} + +impl ContentLocks { + /// `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()); + + // エントリを落とすことが「解放」そのもの。これが無いと表が伸び続ける。 + state.held.remove(&self.content_id); + drop(state); + condvar.notify_all(); + } } impl MonasController { @@ -159,7 +274,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, sender_pin_store) = Self::create_persistence(&config.persistence)?; let agent = Self::build_agent(&config); @@ -178,6 +293,8 @@ impl MonasController { share_repository, public_key_directory, ), + sender_pin_store, + content_revoke_locks: ContentLocks::default(), }) } @@ -210,13 +327,22 @@ impl MonasController { /// CEK / Share / Public key directory の 3 ストアに共有させる。sled は path 単位で /// 排他 flock を取るため、同じディレクトリを 2 度 open すると 2 個目が /// 失敗する (`MONAS_PERSISTENCE_DIR` 設定時の本番経路で必ず再現)。 - /// キー空間は `cek:` / `share:` / `pubkey:` プレフィックスで分離されている。 + /// キー空間は `cek:` / `share:` / `pubkey:` / `sender_pin:` プレフィックスで分離されている。 fn create_persistence( persistence: &PersistenceConfig, - ) -> Result<(DynCekStore, DynShareRepository, DynPublicKeyDirectory), ApiError> { + ) -> Result< + ( + DynCekStore, + DynShareRepository, + DynPublicKeyDirectory, + DynSenderPinStore, + ), + ApiError, + > { use monas_content::infrastructure::{ key_store::{InMemoryContentEncryptionKeyStore, SledContentEncryptionKeyStore}, public_key_directory::{InMemoryPublicKeyDirectory, SledPublicKeyDirectory}, + sender_key_pin_store::{InMemorySenderKeyPinStore, SledSenderKeyPinStore}, share_repository::{InMemoryShareRepository, SledShareRepository}, }; @@ -230,7 +356,8 @@ impl MonasController { 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 sender_pin: DynSenderPinStore = Arc::new(InMemorySenderKeyPinStore::default()); + Ok((cek, share, pkd, sender_pin)) } PersistenceConfig::Sled { dir } => { if let Err(e) = std::fs::create_dir_all(dir) { @@ -239,17 +366,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 sender_pin = SledSenderKeyPinStore::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 sender_pin: DynSenderPinStore = Arc::new(sender_pin); + Ok((cek, share, pkd, sender_pin)) } } } @@ -298,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 6e056dd..41da712 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::{ @@ -137,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(), } } @@ -297,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) { @@ -316,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) @@ -340,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(), }; @@ -361,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) { @@ -392,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, @@ -410,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, @@ -424,6 +445,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) { @@ -434,6 +456,23 @@ 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 が必要になる(現状の制約)。 + // ガードを 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, Err(e) => return ApiResponse::error(e, trace_id), @@ -446,6 +485,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) @@ -456,27 +502,63 @@ 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, + // 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 }; - 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 +568,29 @@ 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, + sender_private_key: sender_private_key_bytes, + 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, @@ -519,12 +602,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(), @@ -547,11 +626,25 @@ 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, + token_invalidated_at, }; ApiResponse::success(output, trace_id) @@ -562,13 +655,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, @@ -578,7 +675,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()), @@ -599,13 +696,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) { @@ -614,7 +711,52 @@ impl MonasController { }; let recipient_key_id = KeyId::new(recipient_key_id_bytes); - // 4. 秘密鍵をデコード + // 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) => { + 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, @@ -649,14 +791,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); } @@ -666,7 +824,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 +841,81 @@ impl MonasController { } }; + // 9. ローカル状態(送信者ピン・鍵世代・CEK)の更新。 + // + // unwrap + 復号の成功 = 送信者と鍵世代の正しさが暗号学的に確認できた + // 時点なので、ここで初めてローカルへ反映する。 + // + // 3つ組は 1 レコードにまとめて単一の compare-and-swap で入れ替える。 + // 以前は「pin を CAS してから CEK を別ストアへ save」していたが、 + // 2 つの commit に分かれている限り、間に別の世代の処理が割り込めば + // `pin = N+1, CEK = N` のような不整合が作れてしまう + // (`SenderKeyPin` のモジュール doc に interleaving を記載)。 + // + // CAS が失敗した = 別の処理が先に同じかより新しい世代へ進めた、なので + // こちらの(古い)3つ組は捨てる。 + 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()), + }; + // ここへ来る時点で、記録済み世代より古い envelope は step 4 で既に + // 拒否されている(`stale key envelope`)。よって残るのは「同じ世代」か + // 「より新しい世代」のどちらかで、どちらも CAS の期待値が + // 「今読んだレコードそのもの」なので巻き戻しにはならない。 + // + // 同一世代でも CAS を通すのは、旧レコードが CEK を持たない + // (この修正より前に作られた、あるいは CEK 保存に失敗した)場合に、 + // 同じ世代のまま CEK を埋め直して回復できるようにするため。 + // + // 権威レコードが既にこの3つ組そのものなら CAS 自体は不要。ただし + // CEK キャッシュだけが欠けている可能性はあるので、その更新は通す。 + let already_current = pinned.as_ref() == Some(&new_pin); + + 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, + ) { + 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, + ); + } + }; + // CAS に負けた場合は、勝った側がより新しい(または同じ)世代を + // 書いているので、こちらの CEK でキャッシュを上書きしてはいけない。 + advanced + }; + + // CEK ストアは上の権威レコードから導出されるキャッシュ。 + // CEK は受信者デバイスのローカルに留まり、ネットワークには出ない。 + if should_refresh_cek_cache { + if let Err(e) = self.content_service.cek_store.save(&content_id, &cek) { + // 権威レコードには CEK が入っているので、ここで失敗しても + // 再処理すればキャッシュを埋め直せる。ただし黙って成功を + // 返すと、呼び出し側は「以後この端末で検証付き read ができる」 + // と信じるのに実際は MissingKey で失敗するため、エラーにする。 + 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, + ); + } + } + let content_base64url = encode_base64url(&raw_content); let output = DecryptSharedContentOutput { diff --git a/monas-sdk/src/controller/state.rs b/monas-sdk/src/controller/state.rs index 91ea9a2..ac5338e 100644 --- a/monas-sdk/src/controller/state.rs +++ b/monas-sdk/src/controller/state.rs @@ -4,10 +4,13 @@ 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}; @@ -222,6 +225,191 @@ impl MonasController { ) } + /// State Node から content を読み、検証・復号して平文を返す(検証付き read)。 + /// + /// `docs/design.md` §10「read応答の完全性検証」の実 read 経路。処理フロー: + /// 1. `read:{content_id}:{timestamp}` 署名の認証コンテキストを解決 + /// 2. 版を決定(`input.version` 指定があればその版、無ければ履歴の最新) + /// 3. Node CBOR を取得し、CID 再計算で改ざん検証 + /// 4. ローカル cek_store から CEK を引き、AES-GCM 復号 + plain CID 照合 + /// + /// CEK は「自分が作成した content」または「share の KeyEnvelope を処理済みの + /// content」(`decrypt_shared_content` が保存する)についてローカルに存在する。 + /// + /// **保証範囲**: 検証できるのは「返された 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, + 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(); + + // 版の決定。明示指定が無ければ履歴の最新を読む。 + // 履歴は署名も系列検証も無いため「どの版を読むか」の選択にしか使えない。 + // 選ばれた版の payload は下の CID 検証が守るが、その版が最新である + // ことは保証されない(上記「保証範囲」を参照)。 + let version = match input.version.clone() { + Some(v) => v, + 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 + } + }; + + // 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, + ); + } + }; + + // CID 再計算による改ざん検証 + CEK ロード + AES-GCM 復号 + plain CID 照合 + // + // 検証は `verify_and_decrypt_relay_read` の中で必ず最初に走るので、 + // ここで先に `verify_and_extract` を呼ぶ必要はない(同じ引数で 2 回 + // 走らせていた)。検証は content 層の責務として一箇所に置く。 + // + // 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) => { + 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, + ) + } + + /// `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` が必要。 @@ -303,7 +491,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 +501,28 @@ impl MonasController { } }; + // State Node は read 応答として「Node 全体(CBOR)」を返す。まず CID を + // 再計算して version と一致することを検証し(改ざん検知)、その上で + // payload の暗号文を取り出す(§8.1)。照合先はクライアントが選択した + // version に固定する。応答内の version は自己申告なので、それに対して + // 照合すると任意の Node + その CID を返すだけで検証が通ってしまう。 + let state_bytes = match monas_content::infrastructure::node_verification::verify_and_extract( + &node_bytes, + &version_to_check, + ) { + 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-sdk/src/models/share.rs b/monas-sdk/src/models/share.rs index acb8935..4fa4916 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, } @@ -95,6 +109,28 @@ 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, + /// 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。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReissuedKeyEnvelope { + /// 再発行先の受信者 key id(base64url) + pub recipient_key_id: String, + pub key_envelope: KeyEnvelope, } // ============================================ @@ -106,7 +142,10 @@ pub struct RevokeShareOutput { 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")] @@ -143,6 +182,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\"")); @@ -155,6 +195,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(); @@ -166,6 +207,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"] }"#; @@ -178,12 +220,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(), @@ -205,9 +249,52 @@ mod tests { recipient_public_key: "recipient_key".into(), 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] + 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(), + key_epoch: 1, + }, + }], + token_invalidated_at: None, + }; + let json = serde_json::to_string(&output).unwrap(); + assert!(json.contains("\"reissued_envelopes\"")); + assert!(json.contains("\"recipient_key_id\":\"surviving-recipient\"")); } #[test] @@ -215,12 +302,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/src/models/state.rs b/monas-sdk/src/models/state.rs index c50f0b4..497f0a1 100644 --- a/monas-sdk/src/models/state.rs +++ b/monas-sdk/src/models/state.rs @@ -42,6 +42,49 @@ 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 の履歴から最新版を読む。 +/// +/// 保証されるのは**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, + 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 // ============================================ 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/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-sdk/tests/share_controller_integration_test.rs b/monas-sdk/tests/share_controller_integration_test.rs index 44f229a..d1763a5 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(); @@ -68,6 +84,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 +194,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 +205,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 +286,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 +297,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 +382,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 +395,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 +409,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 +433,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 +520,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 +534,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 +553,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, @@ -543,3 +570,410 @@ 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(); +} + +/// 同じ 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(); +} diff --git a/monas-sdk/tests/state_controller_integration_test.rs b/monas-sdk/tests/state_controller_integration_test.rs index 706a339..2b2592c 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, @@ -289,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 new file mode 100644 index 0000000..6190c3e --- /dev/null +++ b/monas-sdk/tests/state_read_integration_test.rs @@ -0,0 +1,611 @@ +// 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 不一致)は拒否される +//! +//! 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(), + sender_private_key: sender.private_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_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, + }); + 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(), + sender_private_key: sender.private_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_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!(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(), + sender_private_key: sender.private_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_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!(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 + ); + + // 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 + ); + + // 同一世代の 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(); +} + +#[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(); +} + +/// 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(); +} 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() + } +} 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..f22777f 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) @@ -84,7 +88,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 +101,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 +129,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 @@ -190,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 @@ -224,15 +231,38 @@ 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形式) - 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" \ @@ -244,16 +274,26 @@ 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 @@ -274,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 @@ -294,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 @@ -318,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 @@ -361,14 +401,14 @@ 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 fi else log_error "すべてのノードが起動していないため、同期テストを実行できません" - ((TESTS_FAILED++)) + TESTS_FAILED=$((TESTS_FAILED + 1)) fi # ============================================================================ @@ -393,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 # 署名なしのリクエスト @@ -410,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 f83d07a..9194e67 100644 --- a/monas-state-node/src/application_service/node.rs +++ b/monas-state-node/src/application_service/node.rs @@ -211,16 +211,15 @@ 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: 旧 jti nonce ストア(委譲トークンの TTL 内再利用と矛盾していた)は + // 廃止した(issue #61)。リプレイ防御は「署名内 timestamp の鮮度チェック」 + // と「mutation の署名を使い切りにする消費記録」の2層が担う。後者は + // `StateNodeService` が保持するので、ここで組み立てる必要はない。 + // 失効させる単位が *トークン* から *リクエスト署名* へ変わったのが要点で、 + // これならトークンの再利用を妨げずに mutation の再送だけを止められる。 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 9ad201b..38f2c12 100644 --- a/monas-state-node/src/application_service/state_node_service.rs +++ b/monas-state-node/src/application_service/state_node_service.rs @@ -10,12 +10,12 @@ 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}; 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}; @@ -93,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, } /// Where a relay candidate list came from, and therefore how much it can be @@ -272,9 +275,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. @@ -344,9 +359,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()) })?; @@ -366,15 +381,108 @@ 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. + /// 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 + /// 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` + /// (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(), + } + } + + /// 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. /// - /// For `type:id` tokens (e.g., `user:alice`), constructs a signing message - /// and delegates to `AuthenticationService::verify_request_signature`: - /// - If `request_body` is `Some(body)`: signs `hex(sha256(body + timestamp_be_bytes))` - /// - If `request_body` is `None`: signs `{operation}:{resource}:{timestamp}` + /// 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 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 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 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() + } + #[allow(clippy::too_many_arguments)] async fn verify_caller_signature( &self, @@ -386,7 +494,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) @@ -397,55 +506,22 @@ 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)) - 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 message = Self::build_signing_message(operation, resource, ts, request_body); auth_service .verify_request_signature(token, signature, &message, timestamp) @@ -458,6 +534,169 @@ 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 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 + /// 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?; + + // 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, current_timestamp()) + .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. + /// + /// 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. + /// + /// **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`. + 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, current_timestamp()) + .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 @@ -741,19 +980,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.md §10「read応答の完全性検証」). 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)), @@ -1022,7 +1264,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, @@ -1263,7 +1505,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, @@ -1330,6 +1572,19 @@ where return Err(StateNodeError::ContentNotFound(content_id_vo.clone())); } + // 転送前にこのノードでも署名を消費する。member 側だけで消費すると、 + // 同じ署名を relay へ送り直すたびに「まだ見ていない member」へ + // 振り分けられて再適用できてしまう。 + 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. let members = self.resolve_members(content_id).await?; @@ -1433,7 +1688,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, @@ -1504,6 +1759,19 @@ where return Err(StateNodeError::ContentNotFound(content_id_vo.clone())); } + // 転送前にこのノードでも署名を消費する。member 側だけで消費すると、 + // 同じ署名を relay へ送り直すたびに「まだ見ていない member」へ + // 振り分けられて再適用できてしまう。 + 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. let members = self.resolve_members(content_id).await?; @@ -1604,7 +1872,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, @@ -1725,6 +1993,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?; @@ -1803,15 +2076,18 @@ where .await .map_err(|e| StateNodeError::AuthenticationFailed(e.to_string()))?; - // Verify request signature - self.verify_caller_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_and_consume_mutation_signature( auth_service.as_ref(), token, request_signature, "manage", content_id, timestamp, - None, + Some(&crate::port::auth_token::add_members_signing_body(count)), ) .await?; @@ -2628,17 +2904,26 @@ 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, "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 - .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)?; @@ -2774,6 +3059,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, @@ -2875,6 +3171,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(); @@ -2897,7 +3219,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -2943,7 +3265,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -2980,7 +3302,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3000,7 +3322,7 @@ mod tests { b"test data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3046,7 +3368,7 @@ mod tests { b"new data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .unwrap(); @@ -3093,7 +3415,7 @@ mod tests { b"new data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3126,7 +3448,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3153,7 +3475,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3185,7 +3507,7 @@ mod tests { "content-1", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3220,7 +3542,7 @@ mod tests { b"data", Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -3264,7 +3586,7 @@ mod tests { None, &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(matches!(result, Err(StateNodeError::ContentNotFound(_)))); @@ -3299,7 +3621,7 @@ mod tests { None, &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), ) .await .expect("relayed read should succeed"); @@ -3340,7 +3662,7 @@ mod tests { None, &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(matches!( @@ -3467,6 +3789,302 @@ 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 を作れてしまい、同一ノードでも再適用できる。 + /// + /// 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` を反転した + /// 署名を送り直すだけで消費記録をすり抜けて再適用できてしまう。 + #[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"); @@ -3486,7 +4104,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-1", ) .await; @@ -3506,7 +4124,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-1", ) .await; @@ -3548,7 +4166,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-genesis-only", ) .await; @@ -3637,10 +4255,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}" ); } @@ -3692,7 +4326,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-1", ) .await; @@ -3713,7 +4347,7 @@ mod tests { .authorize_read( &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), "content-1", ) .await; @@ -3730,7 +4364,7 @@ mod tests { None, &test_token(), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(result.is_err()); @@ -4290,7 +4924,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 67fe9c4..61407b8 100644 --- a/monas-state-node/src/bin/test_auth_generator.rs +++ b/monas-state-node/src/bin/test_auth_generator.rs @@ -2,9 +2,9 @@ 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::Deserialize; use serde_json::json; use sha2::{Digest as Sha2Digest, Sha256}; use std::env; @@ -62,7 +62,8 @@ 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!(" [--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"); } @@ -96,18 +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 auth_token = String::new(); + let mut add_members_count: Option = None; let mut i = 0; while i < args.len() { @@ -142,10 +152,13 @@ fn sign_request(args: &[String]) { body_b64 = args[i].clone(); } } - "--auth-token" => { + "--add-members-count" => { i += 1; if i < args.len() { - auth_token = args[i].clone(); + add_members_count = Some(args[i].parse().unwrap_or_else(|e| { + eprintln!("Error: Invalid --add-members-count: {}", e); + std::process::exit(1); + })); } } _ => {} @@ -180,24 +193,35 @@ fn sign_request(args: &[String]) { std::process::exit(1); }); - // 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() { - // 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`. + 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| { 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()); @@ -209,31 +233,26 @@ fn sign_request(args: &[String]) { println!("MESSAGE={}", message); } -#[derive(Debug, Deserialize)] -struct DelegatedPayload { +/// Delegated-JWT payload used by `generate-share-token`. +/// +/// NOTE: 署名検証はワイヤ上の `header.payload` セグメントに対して行われる +/// ようになったため(issue #60)、フィールド順序に検証上の意味はもう無い。 +/// 発行側の形として monas-account の `DelegationClaims` に揃えている。 +#[derive(serde::Serialize)] +struct ShareTokenPayload { iss: String, aud: String, + exp: u64, + iat: u64, jti: String, + att: Vec, } -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) +#[derive(serde::Serialize)] +struct ShareTokenCapability { + with: String, + can: String, } - fn generate_auth_token(content_id: Option) { let signing_key = SigningKey::random(&mut OsRng); let verifying_key = signing_key.verifying_key(); @@ -360,14 +379,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 +400,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/domain/access_control.rs b/monas-state-node/src/domain/access_control.rs index 46a270a..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. @@ -161,6 +169,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 +190,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"), } } @@ -300,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/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/infrastructure/auth/monas_account_adapter.rs b/monas-state-node/src/infrastructure/auth/monas_account_adapter.rs index a7aa89f..840dcb5 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 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 { 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)")); @@ -227,8 +255,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 +525,247 @@ 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()); + } + + /// 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()); + } + + /// 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()); + } + + /// 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 3ddb1dc..b4de91c 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(()) @@ -58,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) @@ -128,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 @@ -193,3 +272,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 42e84c0..e1d1e36 100644 --- a/monas-state-node/src/infrastructure/auth/ucan_adapter.rs +++ b/monas-state-node/src/infrastructure/auth/ucan_adapter.rs @@ -7,14 +7,16 @@ //! 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; 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 +42,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 +208,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 +287,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 +338,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) } @@ -492,6 +481,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!() } @@ -736,7 +738,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); @@ -785,7 +794,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); @@ -833,7 +849,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 { @@ -859,64 +882,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" ); } @@ -950,7 +1014,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()); @@ -1003,7 +1074,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/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/infrastructure/persistence/sled_public_key_repository.rs b/monas-state-node/src/infrastructure/persistence/sled_public_key_repository.rs index 785095f..0d68c45 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/src/port/auth_token.rs b/monas-state-node/src/port/auth_token.rs index 58a2196..8286f43 100644 --- a/monas-state-node/src/port/auth_token.rs +++ b/monas-state-node/src/port/auth_token.rs @@ -53,14 +53,64 @@ 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, + ) } } +/// 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 { @@ -136,4 +186,96 @@ 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()); + } + + /// 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() { + 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()); + } } 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..411c4dd --- /dev/null +++ b/monas-state-node/src/port/consumed_request_store.rs @@ -0,0 +1,168 @@ +//! 一度受理した署名済みリクエストの記録(mutation の再送防止)。 +//! +//! 署名内 timestamp の鮮度チェック(5分窓)は「古い署名を無限に使い回せない」 +//! ことしか保証しない。窓の中では同じ署名を何度でも通せる。 +//! +//! update / delete は冪等ではないので、これは単なる重複ではなく**状態の +//! 巻き戻し**になる。攻撃者が署名済みの旧 ciphertext 更新 A を捕まえておき、 +//! 正規の更新 B が入った後に A を再送すると、サーバは A を「その時点の最新版を +//! 親とする新しい操作」として commit する。結果、古い ciphertext が最新版に +//! なってしまう。 +//! +//! そこで、受理した mutation リクエストを一意に識別する値を記録し、2度目の +//! 提示を拒否する。識別子は**署名対象メッセージと signer** から導く +//! (`StateNodeService::mutation_request_id`)。メッセージは operation / +//! resource / timestamp / body digest すべてに束縛されているので、これが +//! 一致する = 完全に同じリクエストの再送であり、新しいフィールドをワイヤ形式へ +//! 足す必要もない。 +//! +//! **署名バイト列の digest を識別子にしてはならない。** ECDSA は malleable で、 +//! 有効な `(r, s)` に対し `(r, n - s)` も同じメッセージ・同じ鍵で検証を通る。 +//! 署名を hash すると 1 つの承認済みリクエストが 2 つの識別子を持ち、攻撃者は +//! 捕捉した署名を 1 回変換するだけでこの記録をすり抜けられてしまう。 +//! +//! ## 保持期間 +//! +//! 記録は署名の鮮度窓(`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 秒)。期限切れ記録の掃除に使う。 + /// + /// caller が申告した署名内 timestamp を渡してはならない。許容 skew の範囲で + /// 未来寄りの timestamp を持つ有効なリクエストを先に出せば、まだ鮮度窓の + /// 中にある記録を早期に掃除させられ、その後で古い署名を再提示できてしまう。 + 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()); + } + + /// 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] + 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/content_repository.rs b/monas-state-node/src/port/content_repository.rs index dcbf0e4..f4fdb65 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.md` §10「read応答の完全性検証」. + 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/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 889fe16..1a7942e 100644 --- a/monas-state-node/src/presentation/http_api.rs +++ b/monas-state-node/src/presentation/http_api.rs @@ -193,7 +193,19 @@ 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() + } + // 再送であることは呼び出し側に伝えてよい。伝えないと、正規の + // クライアントは「認証に失敗した」と読んで同じ署名で延々と + // 再試行してしまう(正しい対処は新しい 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(), @@ -717,20 +729,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.md §10「read応答の完全性検証」) + 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 +831,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 { 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 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..53b8034 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; @@ -507,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) @@ -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(); @@ -616,14 +627,207 @@ 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"); } +/// 署名済み 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) + }; + + // 消費記録の 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]; + + 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" + ); +} + +/// 署名のバイト表現を変えても再送は通らない。 +/// +/// 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 まで巻き添えで失効する。 +#[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; @@ -657,7 +861,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 +1299,7 @@ async fn test_update_content_requires_authentication() { data, None, Some(&test_request_signature()), - None, + test_timestamp(), ) .await; assert!(result.is_err()); @@ -1277,7 +1481,7 @@ async fn test_authorization_denied_prevents_create_content() { data, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await; @@ -1347,7 +1551,7 @@ async fn test_access_control_update_signature_verification() { &update, Some(&test_token()), Some(&test_request_signature()), - None, + test_timestamp(), ) .await;