Skip to content

feat(read-integrity): verified read path — payload authenticity, sender-authenticated CEK envelopes, single-use mutation signatures - #56

Merged
somasekimoto merged 57 commits into
fix/state-node-read-relayfrom
feature/read-response-signing
Aug 18, 2026
Merged

somasekimoto merged 57 commits into
fix/state-node-read-relayfrom
feature/read-response-signing

Conversation

@somasekimoto

@somasekimoto somasekimoto commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

概要

PR #54(read relay)へのセキュリティ指摘(#55)対応。relay 経由の read 応答をクライアント側で検証できるようにし、実際に state node から読んで復号する read 経路まで実装した。あわせて share の KeyEnvelope に送信者認証を入れ、リクエスト署名の形式を統一し、署名済み mutation を使い切りにした。

設計は docs/design.md §10 セキュリティモデルに統合済み。

保証範囲(重要)

この PR で保証できるのは payload の真正性までで、版の真正性は保証しない。 #55 は本 PR では閉じない。

保証する 保証しない
返された Node の payload が、要求した版 CID に対して真正であること(CID 再計算 + AES-GCM + plain CID 照合)。CEK を持たない攻撃者は復号可能な偽 payload を注入できない その版が最新であること / 正規の writer が書いた版であること。版メタデータ(parents 等)に真正性が無く、relay 上で正規暗号文を観測した攻撃者は任意の parents で包み直した「偽の版」を鋳造でき、CID 検証も復号も通過する
KeyEnvelope がピン済みの送信者によって作られたこと(HPKE Auth)、rotation 前の envelope の replay 拒否 初回の送信者鍵の真正性(TOFU なので、最初に届いた鍵をピンする)
リクエスト署名が operation / resource / timestamp / body に束縛されること —
署名済み mutation が同じノードで 2 度適用されないこと。署名の符号化を変えても(ECDSA malleability)同一と判定されること ネットワーク全体で厳密に 1 回であること(記録はノードごと独立)。プロセス再起動をまたいだ再送(デフォルト実装は揮発)。→ #65
— read 署名の鮮度窓(300 秒)内の再利用。read は冪等で状態を壊さず、その署名を持つ相手は既に暗号文を見ているため新たな情報は得られないが、窓自体は計測に基づく値ではない(下記)
取り消し時に既発行 Token が失効すること(revoke と同じ秒に発行された Token を含む) revoke が 分散トランザクションであること。remote commit 後の event publish 失敗で local と remote の状態が分岐しうる。→ #66

版の真正性は Node への owner(または権限を持つ writer)署名という trust anchor が必要で、crsl-lib に及ぶプロトコル変更のため issue #59 で追跡する。初回送信者鍵の owner 束縛も同じ trust anchor に依存するため、あわせて #59 で扱う。

クライアント側で「最後に受理した版」を記録して後退を拒否する単調性チェックも一度実装したが、撤去した。偽 parents を詰めた版で bypass できるため本質的な防御にならない一方、結果整合性のもとでは正当な sync 遅延(分散システムでは正常な挙動)と攻撃を応答単体で区別できず、正規 read を壊す誤検知(256 版超のギャップで復旧手段なく失敗、部分同期 member で walk 失敗)と、GC できないクライアント永続状態という負債だけが残るため。

設計判断

  • member 証明は不採用。owner は member を知り得ない(member は DHT 複製で owner の関与なく増減する)ため「owner が member 証明を発行する」経路は Monas に存在しない。payload が暗号学的に検証できれば返した相手が誰かの確認は不要。検討途中で実装したものは revert した。
  • share 経由 read も本 PR に含める(基本ユースケースは production レベルで実装する方針)。CEK・平文は受信者デバイスから一切出ず、state node は終始 ciphertext-only(E2E 暗号化の思想は不変)。

何を防ぐか

攻撃 防御
非 member / 中間ノードによる偽 payload の注入 state node は read で crsl-lib Node 全体(CBOR)を返し、クライアントが CID を再計算して照合。さらに CEK での AES-GCM 復号 + 平文 CID 照合まで通す
第三者による受信者 CEK ストアの汚染(2 通目以降) KeyEnvelope を HPKE Auth モードでラップし、受信者はピン済み送信者鍵でのみ unwrap する
rotation 前 envelope の replay(CEK 巻き戻し) envelope に key_epoch を持たせ AAD に束縛。記録済み世代より古い envelope を拒否
cross-resource / cross-operation の署名転用 リクエスト署名を domain separation + 長さ前置で operation / resource / timestamp / body digest に束縛
署名済み mutation の再送による状態巻き戻し 受理した署名の digest を記録し 2 度目を 409 で拒否(下記)
取り消し後の書き込み継続 revoke で state node の min_valid_issued_at を進め既発行 Token を一括失効(下記)

実 read 経路の詳細

SDK read_content_from_state_node / gateway POST /state/read:

  1. read 署名の認証コンテキストを解決(gateway は auth ヘッダを転送)
  2. 版を決定(明示指定、または履歴の最新)。履歴自体は無検証で、「どの版を読むか選ぶ」ためだけに使う。選ばれた版が最新である保証は無い(上記「保証範囲」)
  3. Node CBOR を取得し CID 検証
  4. ローカル cek_store の CEK で AES-GCM 復号 + plain CID 照合

入力は remote(state node 側)と local(CEK 引き当て用)の両 content id。対応表は存在しないため呼び出し側が渡す(VerifyIntegrityInput と同じ設計)。

エラー写像(呼び出し側が対処を判断できるように):

状況 エラー
CID 不一致(改ざん) Internal(検証失敗を明示)
CEK がローカルに無い NotFound(share envelope の処理を案内)
CEK で復号失敗 Forbidden(rotation 後の鍵世代ずれ / revoke の可能性を案内)
plain CID 不一致 Conflict(content 更新後の古い local id の可能性を案内)

share 受信者の CEK ライフサイクル

  • decrypt_shared_content の復号成功時(= CEK の正しさが証明された時点)に、unwrap 済み CEK を受信者ローカルへ保存。以後、受信者も検証付き read で復号できる
  • 送信者鍵・鍵世代・CEK は 1 レコードにまとめ単一の compare-and-swap で入れ替える。守るべき不変条件は 3 つ組全体であって世代番号だけではなく、別ストア別 commit だと世代 N の処理が CEK だけを書き戻して pin=N+1 / CEK=N という復号不能な状態を作れる。CEK ストアはこの権威レコードから導出されるキャッシュ扱い
  • revoke の順序バグ修正: SDK が revoke(その時点の旧 CEK で envelope 再発行)→ reencrypt の順で呼んでいた。service 層の「再暗号化後の CEK を配る」想定と逆で、envelope を出力で捨てていたため露見していなかった。reencrypt → revoke に修正
  • 再発行 envelope を RevokeShareOutput.reissued_envelopes で返却

レビュー指摘への対応

署名済み mutation の再送 (Critical)

timestamp の鮮度チェックは「古い署名を無限に使い回せない」ことしか保証せず、5 分窓の中では同じ署名を何度でも通せた。書き込み系の 6 操作(create / update / delete / invalidate / manage / revoke)は冪等でないため、これは重複ではなく状態の巻き戻しになる — 署名済みの旧 ciphertext 更新を正規の更新の後に再送すると、サーバはそれを「現在の head を親とする新しい操作」として commit し、古い内容が最新版になる。

受理した mutation を記録し 2 度目を拒否する。識別子は署名対象メッセージと canonical principal から導出する(SHA256(len(principal) ‖ principal ‖ signing_message)、長さ前置は境界を付け替えられないようにするため)。principal は「リクエスト署名の検証に使う鍵」で、自己完結型 key id ならその値、委譲 JWT なら aud である。署名は既に操作・リソース・timestamp・body に束縛されているため、ワイヤ形式に nonce を足す必要がない。保持期間は鮮度窓と同じでよい(窓の外の署名は記録が無くても鮮度チェックで落ちる)ので記録は無制限には育たない。read は冪等なので対象外。

この識別子は当初「リクエスト署名そのものの digest」だった。ECDSA は malleable で (r, s) と (r, n−s) が同じメッセージ・同じ鍵に対して両方検証を通るため、その設計では防御を完全に迂回できた。実測でも original_valid=true / alternate_valid=true / digests_differ=true を確認している。詳細は Round 2 を参照。

relay 側でも、署名を検証したうえで消費する。 relay は access policy を持たないので credential を転送して member に判断させる = 消費記録が member 側にしか残らず、同じ署名を relay へ送り直すと未見の member へ振り分けられて再適用できてしまう(実機 4 ノードで確認)。ただしこれは member 側チェックの代替ではなく、別の relay を使えば依然として通る。

消費記録を書く前に relay 自身が署名を暗号学的に検証する点が重要である(Round 4 で修正)。relay にできないのは policy を要する認可だけで、トークンの署名とリクエスト署名の検証は policy 無しで行える。検証を後回しにすると、request id の構成要素がすべて公開情報であるために、鍵を持たない第三者が正規リクエストの id を先に焼き潰せてしまう。

再送は認証失敗と別のエラー(409)にした。運用者にとって「偽造」と「正規リクエストの二重到達」は逆の意味を持ち、クライアントも "authentication failed" と言われると同じ署名で再試行し続けてしまうため。

取り消しが Token を失効させていなかった (High)

docs/design.md は revoke で min_valid_issued_at を進めて既発行 Token を一括失効すると定義しているが、SDK の revoke_share はその endpoint を呼んでいなかった。CEK ローテーションは復号を止めるだけで、取り消した相手の委譲 write Token は TTL 満了まで有効なまま残る。

失効はローテーションの前に行う。逆順だと再暗号化から失効までの窓で取り消し済みの相手が書き込める。先に失効させれば後段の失敗は余分な失効を残すだけで、そのコスト(残存受信者への Token 再発行)は CEK ローテーションでどのみち必要になる。時刻ベースの一括失効なので残存受信者の Token も巻き添えになるため、RevokeShareOutput.token_invalidated_at で失効時刻を返す。

add-members の count が署名対象外 (High)

count は HTTP body 由来で実際の member 追加数を決めるのに、署名検証には request_body = None が渡っていた。上限で clamp されるが 1 を上限まで増やす改ざんは成立していた。生の JSON は client ごとに揺れるため、両側で同じ canonical bytes を導出して署名対象に含める。

並行 revoke の非トランザクション性 (High)

revoke は ACL・CEK・ローカル ciphertext・state node にまたがる load-modify-save で、どこにも version CAS が無い。MonasController は gateway から Arc 共有され同時に呼ばれるため、2 つの revoke が同じ Share を読んで後勝ちで save し片方の受信者削除が消える(lost update)。content 単位の mutex で直列化した。これは完全な解決ではない: 本来は Share・CEK・ciphertext を 1 つの transactional CAS にまとめるべきで、現状のロックはプロセス内に閉じており複数 gateway プロセスからの並行 revoke は防げない。

過大主張の訂正

実装の保証範囲より広い記述を docs/design.md から取り除いた。

  • 「リプレイ防御は timestamp の鮮度チェックが担う」→ 鮮度だけでは replay 防御にならないことを明記し、2 層構成に書き換え
  • 「第三者の CEK 上書き破壊を防ぐ」→ 初回は TOFU なのでまさにその攻撃が成立する。2 通目以降と書き分け
  • 「盗まれた署名でできるのは同じ操作を 5 分以内に再実行するだけ」→ mutation が非冪等なので状態巻き戻しになる
  • アクセス取り消しの図が実装と逆順(再暗号化 → 失効)だった → 実装に合わせ、順序が重要な理由も記載

Round 2 — 4 本目のレビューへの対応

上記の各修正を再レビューして見つかった 5 件。いずれも実測で成立を確認してから直した。

再送防御が ECDSA malleability で完全に迂回できた (Critical, d729fe9)

上で入れた再送防御は、識別子を署名バイト列の digest にしていた。ECDSA は malleable で、有効な (r, s) に対し (r, n−s) も同じメッセージ・同じ鍵で検証を通る。攻撃者は署名を捕捉して s を変換するだけで、別の digest を持つ「未見のリクエスト」として同じ mutation を再適用できた。p256 0.13 で再現し、original_valid=true / alternate_valid=true / different_bytes=true / digests_differ=true を確認。

識別子を署名対象メッセージ + signer から導出するよう変更した。署名の表現ではなく署名された内容を同一性の根拠にするので、どの符号化で来ても同じ ID になる。

なお high-S を拒否する案は採用しなかった。実装して既存テスト 4 件が落ち、調べたところ monas-account を含めこのリポジトリのどの署名器も low-S 正規化していない。S が high になるのは約半数なので、拒否すれば実クライアントの半分が壊れる。識別子をメッセージベースにした時点で署名表現の一意性に依存しなくなったため不要でもある。代わりに「両方の表現が検証を通る」ことをテストで固定した。

replay cache の GC がクライアント時刻を使っていた (High, d729fe9)

保持期間の判定に、サーバの受理時刻ではなく署名されたリクエスト timestamp を渡していた。許容 clock skew の範囲でより新しい timestamp を先に提示すると、まだ鮮度窓の内にある記録を早期に evict でき、その後に古い署名を再提示すると saturating_sub によって記録を作り直せた。current_timestamp() を渡すよう変更。署名済み timestamp は鮮度検証にのみ使う。

revoke の body が署名対象外だった (自分で発見、レビュー未指摘、d729fe9)

上の add-members と同じ穴が revoke にもあった。request_body = None を渡していたため、body の改ざんが署名検証を通っていた。Some(&update.signing_message()) を渡すよう修正。

同一秒に発行された Token が revoke を生き延びた (High, 0913529)

is_token_valid の cutoff が包含的(iat >= min_valid_issued_at を有効)だった。両者とも秒精度なので、revoke と同じ秒に発行された Token は revoke より前に発行された可能性があり、秒内順序は観測できない。等値を「revoke より前かもしれない」と読むのが唯一安全な解釈である。排他(>)に変更した。同じ秒の 後 に発行された Token も弾かれるが、誤る方向としてはこちらが正しい — 呼び出し側は 1 秒後に再取得すれば済む。min_valid_issued_at == 0 は「一度も失効していない」として全受理を維持する(排他にすると iat=0 を弾くため)。同じ規則が access_policy.rs と access_control.rs の 2 箇所にあり、両方修正。既存テスト 4 件が誤った意味論を固定していたのでこれも訂正した。

read が CEK キャッシュを権威として使っていた (High, 8a6fc40)

CEK は権威レコード(SenderKeyPin)と派生キャッシュ(cek_store)の 2 箇所にあり、read はキャッシュ側を見ていた。並行する 2 つの share 受信ハンドラが interleave するとキャッシュだけが巻き戻り、read が古い CEK で復号を試みる。verify_and_decrypt_relay_read に cek: Option<_> を追加し、SDK 側が pin レコードの CEK を優先して渡すようにした。キャッシュも CAS 化する案より単純で、「キャッシュを権威にしない」という元の設計意図とも合う。

この件で SDK レベルのテストを 1 件書いて捨てた。修正を外しても通ってしまったためである(interleave には並行ハンドラ 2 つが必要で、公開 API からは再現できない)。落ちないテストは無いより悪いので、monas-content 層で書き直した。

Round 3 — 5 本目のレビューへの対応

再送防御の迂回が、リクエスト署名からトークンへ移っていただけだった (Critical)

Round 2 で「識別子を署名バイト列から署名対象メッセージ + signer へ移した」と書いたが、その signer は token.as_str() — 生のトークン文字列だった。委譲 JWT の末尾セグメントは自身の header.payload に対する ECDSA 署名であり、これも malleable である。s → n−s を施すと claims も aud もリクエスト署名も変わらないまま検証を通り、request ID だけが変わって同一ノードで再適用できた。前ラウンドで塞いだはずの迂回が、一層上に残っていた。

実測で確認: 両方の JWT 表現が同じ鍵・同じ signing input に対して有効、トークンのバイト列は異なり、結果の request ID も異なる。

識別子を canonical principal — リクエスト署名が実際に検証される鍵 — から導くよう変更した。verify_request_signature と同じ導出で、自己完結型 key id ならトークン自身、委譲 JWT なら aud である。いかなる署名バイト列も hash に入らない。

membership を変える残り 2 アームが無防備だった (Critical, #54 側)

前ラウンドは指摘された 2 アームだけを直して止まっていた。handle_sync_event の 6 アームを全部列挙したところ、さらに 2 つが無認可でレコードを書き換えられた(ContentNetworkManagerRemoved は検証ゼロ、ContentDeleted は自己申告の node_id と照合していただけ)。詳細は #54 を参照。

植え付け可能なレコードの verdict を最終判断にしていた (Critical, #54 側)

auth_verdict_is_authoritative は常に false を返すようにした。最初の 1 通は照合すべき既存 membership が無いため受理せざるを得ず、その競争に勝った攻撃者の 403 で正規 caller の read を恒久的に止められた。

ドキュメントの不整合 3 件

  • docs/design.md が replay ID を「リクエスト署名そのものの digest」と説明したままだった
  • ReadContentFromStateNodeInput が「最新読みのときのみ単調性チェックが働く」と書いていたが、そのチェックは撤去済みで存在しない
  • verify_and_decrypt_relay_read が「単調性チェックは SDK 側が被せる」と書いていたが、SDK は parents を一切読んでいない

Round 4 — relay の pre-consume poisoning (6 本目のレビュー)

自分が作り込んだ Critical。 relay は署名を検証する前に request id を消費記録へ書いていた。

request id は署名対象メッセージと principal から導かれ、その構成要素 — 操作・リソース・timestamp・body digest・aud — はすべて公開情報である。秘密鍵も有効な署名も要らずに計算できる。したがって第三者が、対象ユーザーの正規リクエストの id をゴミ署名で先に焼き潰せた:

  1. 被害者の key ID(または被害者を aud にした JWT)と、対象 content・現在秒で delete を送る
  2. relay は検証せずに id を記録してから member へ転送する
  3. member は無効署名を拒否するが、同じ秒の正規リクエストは relay で RequestAlreadyApplied になる

delete は body が無いのでメッセージが完全に予測でき、毎秒繰り返せば対象ユーザーはこの relay 経由で何も更新できなくなる。

relay は消費前に verify_caller_signature を通すようにした。relay にもこれは可能で、この検証は純粋に暗号学的 — トークン自身の署名と、トークンが指定する鍵に対するリクエスト署名の検証だけである。access policy を要するのは認可のみで、そちらは member に残る。

これは前ラウンドの修正が生んだ退行である。 旧コメントは pre-consume を「攻撃者が既に持っている digest にしか影響しない — 署名を持っているなら自分で replay できる」と正当化していた。id が署名の digest だった頃には正しかった理屈だが、id を署名対象メッセージへ移した時点で前提が壊れており、その依存関係を見直さなかった。

design.md も 2 点訂正した。verify → record の順序とそれが必須である理由を明記し、ネットワーク全体の穴に対する「CRDT は同一操作の重複適用に耐える」という説明を撤回した。各レプリカは自分の head と author から新しい operation を作るので、この経路にその性質は当てはまらない — 巻き戻しは実際に成立し、#65 で追跡する。

過去ラウンドの対応

  1. verify_integrity の CID 照合をクライアント選択の版に束縛: 従来は応答内の自己申告 version に対して照合しており、攻撃者が任意 Node + その CID を返すだけで検証が通っていた(自己整合性チェックに退化)。
  2. CEK 保存失敗を stderr 警告からエラー返却に変更: silent degradation の除去。
  3. KeyEnvelope の送信者認証: HPKE を Base → Auth モードに変更。送信者秘密鍵が wrap 計算に混ざり、受信者は送信者公開鍵(初回 share で TOFU ピン留め)で unwrap する。AAD に (content_id, recipient_key_id, key_epoch) を束縛。無意味だった自己申告 sender_key_id 入力は送信者公開鍵に置換。
  4. コンテンツ暗号化を AES-256-CTR → AES-256-GCM。CTR は非認証で改ざん検知が無く、暗号文の 1 bit 反転が平文の同じ位置に通る。破壊的変更だが本番データは無い。実装は fix(state-node): relay reads to members with member-side authorization; fix read auth + phantom history #54 の 7880384 にあり(本ブランチはそれを取り込んでいる)、docs/design.md が CTR のままだった不整合も fix(state-node): relay reads to members with member-side authorization; fix read auth + phantom history #54 側で訂正した。

認証まわりの修正(#60 / #61 同梱)

Closes #60, closes #61。(#55 は版の真正性が入るまで open のまま。issue #59 と合わせてクローズする)

#61: 委譲 JWT の PoP 署名対象が固定文字列 {iss}:{aud}:{jti} で、リクエストの新しさが署名に入っていなかった。その帳尻合わせの jti 単回消費が「委譲トークンを 1 個渡して TTL 内で再利用する」SDK 設計と矛盾し、履歴取得 → データ取得という通常の read すら成立しなかった。署名対象をトークン種別によらず統一し、jti 単回消費・nonce ストアを廃止、timestamp を構造的に必須化(サーバ時刻フォールバック廃止)。失効させる単位をトークンからリクエスト署名へ移したことで、トークンの再利用を妨げずに mutation の再送だけを止められるようになった。

#60: JWT 署名検証がパース後構造体の再シリアライズに依存し、発行者の JSON フィールド順序が異なると正当なトークンを拒否する brittle な実装だった。受信したワイヤ上の header.payload セグメントを検証する verify_jwt_signature_wire に置換。テストツール側の canonical フィールド順ハックはこれにより不要となり削除した。

e2e の 4 ノード標準化

create_content は creator ノードを意図的に member から除外し local CRDT を持たせないため、3 ノードでは member 定足数(min_replication_factor = 3)を満たせず、非 member 経由の relay read も検証されない。e2e は常に 4 ノード(creator + 3 members)に統一し、非 member である creator 経由の read が data と version を返すことを明示的に assert する Step 2.6 を追加。

あわせて test-with-auth.sh の既存バグを修正した。set -e と ((TESTS_PASSED++)) の組み合わせで最初の成功時に異常終了しており(後置インクリメントが 0 を返す)、このスクリプトのテストは 1 件も実行されていなかった。メンバー追加の期待値も誤っていた(作成ノードは非 member なので 403 が正常)。

テスト

  • cargo test --workspace 779 passed / FAILED した test binary 0(合計 passed だけ見ると打ち切られた binary を見落とすため、grep -c 'test result: FAILED' で確認している)
  • Rust 1.97 cargo clippy --workspace --all-targets --deny warnings クリーン
  • 実機 4 ノードで e2e 8/8、認証スクリプト 9/9(relay 経由の update 再送が 409 で拒否されることを含む)
  • 各ラウンドの後に実機 4 ノードを再実行している。最新(Round 4)でも e2e 8/8・認証スクリプト 9/9・4 ノード全てへのコンテンツ伝播を確認。特に relay 経由の update 再送が 409 で拒否されることを実機で再確認しており、検証を先に回しても使い切りの正常系は壊れていない
  • 各修正について「修正を外すとテストが落ちる」ことを確認済み(Round 2 の 5 件、Round 3 の 3 件、Round 4 の 1 件を含む)
  • 追加した adversarial テスト: 署名再送(A → B → replay)/ 同一署名の二重適用 / ECDSA malleability による再送(実際に (r, n−s) を構成して AlreadyApplied を assert) / 両方の署名表現が検証を通ること(バイト列を同一性の根拠にしない) / 順序が前後した提示が生きている記録を evict しないこと / revoke cutoff の差し替え / read が store より明示 CEK を優先すること / count 差し替え / epoch と CEK の同時前進 / 旧形式レコードの互換 / CEK の Debug 非出力 / 並行 revoke の lost update / 失効失敗時の revoke 中断 / 未証明ピアの verdict で failover を止めない / ローカルレコード上の member の verdict は最終 / 出自によって候補リストを切り詰めない

未解決として issue 化したもの

本 PR のスコープ外(いずれもプロトコル変更を要する)として明文化し、追跡先を作った。

🤖 Generated with Claude Code

somasekimoto and others added 23 commits July 14, 2026 18:50
Design memo for signed read responses with E2E client verification,
grounding the two-layer model (membership + response integrity) in the
actual codebase: node_key (P-256) as the signing key, crsl-lib Node
genesis/parents chain for series verification, and the Option-based
backward-compatible signature field across the CBOR wire / JSON HTTP
response path.

Captures the existing weakness where member checks compare libp2p PeerID
strings against P-256-derived NodeIds, and the open design questions
(member pubkey distribution, membership signing authority, series-check
cost, monotonicity state, key rotation, staged rollout).

Design-phase only; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ations (#55)

Key finding: crsl-lib Node exposes to_bytes/from_bytes (CBOR) and
content_id() = SHA-256 of that CBOR, so returning the whole Node lets the
client verify data authenticity and parent/genesis chain by recomputing
the CID — no signature needed for version-pinned reads. Signatures are
only essential for the negative fact "this is the latest", splitting the
design into (A) pinned read = content-addressed, (B) latest/history =
node-key signature + monotonicity.

Clarified the two key layers: membership authority = owner user key
(AccessPolicy.owner is an Identity/P-256 pubkey), response signer =
member node key. Filled in recommendations for pubkey distribution (in
the owner-signed ContentNetwork record), series-check cost (monotonicity
only by default), monotonicity state (SDK sled store; append-only DAG
confirmed so no false positives), rotation, and a 3-mode staged rollout.
Flagged the product decisions (owner-offline delegation, enforcement
timing, PR split) for the user.

Design-phase only; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ingle-node proof (#55)

User caught that the earlier direction (return the whole Node / sign with
member node key / distribute the member list) trades away metadata
privacy — "who hosts which content". Revealing the full member set is
qualitatively worse than today's relay (which exposes only the single
responding node): it defeats replication as availability protection,
enables node co-occurrence correlation, and (with signatures) leaves a
permanent non-repudiable record.

New hard constraint (§5.0.0): adding integrity must not widen the
metadata leak beyond the current single-node level. Redesign:

- Version-pinned reads (A): return the Node, client recomputes the CID.
  No signature, zero extra leak.
- "Is this the latest" (B): split into (a) monotonicity/TOFU rollback
  check — pure client-local, zero metadata impact, can ship to enforce
  early; and (b) a single, owner-issued member proof token (reuse the
  existing owner ES256 delegation token, aud = that node) so a responding
  node proves membership WITHOUT exposing the set. Drop the signed member
  list and member node-key signatures entirely.

Confirmed append-only DAG (new_child, immutable nodes) so monotonicity
won't false-positive. Open product decisions updated: proof attach
frequency (non-repudiation vs verifiability), owner-offline delegation,
whether to enforce monotonicity immediately.

Design-phase only; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…monotonicity (#55)

Settled the design after working through why owner has to be the root of
trust: a member's "I am a member" proof must anchor to something the
reader already trusts, and the only such anchor is the owner key (the
reader's own read grant is an owner-signed delegation). Member self-key,
NodeID hash, and CEK were each ruled out as roots. Owner signs the proof
once at member-add time and never sits in the read path.

Decided:
- (A) version-pinned read: return Node, client recomputes CID. No sig.
- member responds with an owner-issued per-node membership proof token
  (reuse existing ES256 owner delegation, aud = that node) — proves
  "a legit member answered" WITHOUT exposing the member set.
- monotonicity/TOFU for best-effort rollback detection.
- Explicitly document what this does NOT prevent: a genuine member
  serving a stale/rolled-back version (impossible to prevent over the
  network — negative fact), accepted as a known threat-model limit.

Recorded rejected alternatives (signed member list, member node-key raw
sig, CID-in-envelope, authenticated mutable "latest pointer", CEK-based
proof) with reasons, to prevent re-litigating. Envelope investigation
confirmed envelopes are distributed once at share-grant time and never
on update, killing the static-CID-in-envelope idea.

Design-phase only; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
)

Production usage is zero (test-only), so drop all backward-compat and
staged-rollout machinery: verification is mandatory from the start, wire
types are replaced directly (no Option coexistence), single PR.

§8 implementation plan, grounded in the §4 investigations:
- A: repo returns whole Node (CBOR) instead of raw payload; client
  recomputes CID to detect tampering.
- B: SDK sled store of last-seen version CID per content; ancestor check
  rejects rollback; TOFU on first read.
- C: reuse owner ES256 delegation (service.rs:98-133) as a per-node
  membership proof (aud = node, can = "host"); member attaches it to the
  read response; SDK verifies against owner key. List never exposed.

Pre-resolved two blockers: SDK has no crsl-lib dep (→ ship a lightweight
self-contained CID-recompute helper, not the whole DAG lib), and SDK
doesn't know the owner key (→ likely derivable from the delegation
token's iss; confirm before adding an API).

Design-phase only; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… from token iss (#55)

Correcting the layer: verification belongs in monas-content (which
monas-sdk depends on and where decryption, CID calc, CEK, and share
already live), not in the thin SDK. SDK just calls into it.

Two findings:
- monas-content's existing Sha256ContentIdGenerator is SHA-256(raw) hex,
  NOT crsl-lib's Node CID (SHA-256(CBOR(node)) -> CIDv1). So version-CID
  recompute needs a crsl-compatible impl; content_id.rs already carries a
  "todo: use crsl cid" — implement it in monas-content and retire the TODO
  (lightweight serde_cbor+sha2+cid, no full crsl-lib dep).
- owner key_id is user:{hex(pubkey)} (service.rs:160) — self-contained,
  so the owner public key is recoverable straight from the delegation
  token's iss the reader already holds. No owner-key fetch API needed.
  This answers the "does the SDK check iss?" question: yes, but it's just
  a hex-decode of a token already in hand.

Design-phase only; no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… CID verify (#55, comp A)

State node relay-read now returns the serialized crsl-lib Node (CBOR)
instead of raw payload bytes, so a client can recompute the CID and detect
tampering with no signature.

- content_repository: add get_latest_node_bytes_with_version /
  get_version_node_bytes (Node CBOR); read_content_via_relay uses them.
- monas-content: new node_verification module recomputes the Node CID
  (CIDv1 RAW/SHA2-256 over the CBOR) and rejects on mismatch, extracting
  payload ciphertext + parents.
- Parity tests build REAL crsl-lib Nodes (genesis + child) and confirm our
  from-CBOR recompute equals Node::content_id() exactly — the key risk,
  now validated, incl. Cid CBOR encoding in parents/genesis.

Not backward compatible (test-only deployment), per design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pt core (#55)

- state-node HTTP read endpoints (local + relay branches) now uniformly
  return Node CBOR with the served version CID, so the client always
  verifies the same format.
- verify_integrity now runs verify_and_extract on the Node CBOR (CID
  recompute) before comparing ciphertext — previously it byte-compared raw
  payload, which the Node-CBOR change would have silently broken.
- monas-content: add ContentService::verify_and_decrypt_relay_read — the
  reusable client core that verifies the Node CID, loads the CEK, and
  AES-GCM-decrypts, returning plaintext + parent CIDs for the caller's
  monotonicity check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erify (#55, comp C core)

- monas-account: AccountService::issue_member_proof issues an owner-signed
  ES256 JWT (iss=owner, aud=member node, att=[{content, can:"host"}]).
- monas-content: member_proof::verify_member_proof verifies it against the
  owner public key recovered from the owner key_id (user:{hex(pubkey)}) the
  reader already holds — no key-fetch API. Checks signature, issuer,
  audience (responding node), content, host capability, expiry.
- Parity test issues a proof via the REAL AccountService and verifies it,
  plus rejection tests for wrong owner/node/content/capability/expiry and a
  tampered payload.

This proves membership WITHOUT exposing the member list (§5.1.b).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sume handoff (#55)

Design correction: the owner-issued member proof (component C) assumed the
owner knows/authorizes membership, but members change autonomously via DHT
replication with no owner involvement — the premise doesn't hold in Monas.
Member verification isn't needed anyway: component A (Node CBOR + CID
recompute) already rejects fabricated data/versions regardless of who
answered. Scope reduces to A (done) + B (monotonicity, todo).

Adds read-response-integrity-HANDOFF.md so a fresh session can resume:
exact commit hashes, what to keep, that 3a64a5a (component C) must be
reverted, remaining work (monotonicity + real read endpoint + tests + PR),
and file:line references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… handoff (#55)

Persist the task list (TaskCreate state doesn't carry across sessions),
the user-confirmed constraints (1 PR, no backward-compat, base = #54, must
build a real usable read path not just verification, member proof
dropped), and the open CEK question for the real read endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…client verify (#55, comp C core)"

This reverts commit 3a64a5a.
…ty (#55, comp B)

remote_content_id -> 最後に受理した版CID を記録するクライアント側ストア。
sled 実装(キー prefix last_seen:、CEK 等と同一 DB 共有可)と in-memory 実装。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…safe revoke (#55)

- decrypt_shared_content 成功時に unwrap 済み CEK を受信者ローカル cek_store へ
  保存。share 受信者も state node 経由の検証付き read で復号できるようになる。
  CEK はデバイス外に出ない(state node は終始 ciphertext-only)。
- revoke_share の順序を reencrypt -> revoke に修正。従来は revoke(この時点の
  旧 CEK で envelope 再発行)-> reencrypt の順で、service 層の「再暗号化後の
  CEK を配る」想定と逆だった(envelope を捨てていたため露見せず)。
- 再発行 envelope を RevokeShareOutput.reissued_envelopes として返却。owner が
  残存受信者へ配布し、受信者が再処理すると保存済み CEK がローテーション後の
  ものへ上書き更新される。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…state_node + gateway /state/read (#55)

実 read 経路。SDK が state node から Node CBOR を取得し、
1. CID 再計算で改ざん検証(A)
2. 最新読みでは単調性チェック(B): 前回受理版が CID 検証済み parents の
   祖先に居なければ後退として Conflict で拒否(TOFU 初回受理、
   fail-closed、fetch 上限 256)。明示版指定 read は A のみ。
3. ローカル cek_store の CEK で AES-GCM 復号 + plain CID 照合
を経て平文を返す。CEK 欠落/鍵世代ずれ/local id 不一致は対処方法が
分かるエラー(NotFound/Forbidden/Conflict)に写像。
gateway に POST /state/read を追加し auth ヘッダを転送。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… helper (#55)

- 統合テスト 5 本: 作成者 read 往復 / share 受信者 read(CEK 永続化)/
  改ざん Node 拒否 / 単調性(TOFU・前進・後退・明示版・last_seen 不変)/
  CEK ローテーション追従(旧 CEK read は Forbidden -> 再発行 envelope 処理 ->
  read 成功)
- 祖先探索 walk_ancestors_for の単体テスト 7 本(直接親・深い祖先・後退・
  genesis・上限打ち切り・diamond DAG 重複排除・エラー伝播)
- Node CBOR ミラー生成を tests/support へ共通化(crsl-lib パリティは
  monas-content 側テストで担保)
- 旧「生暗号文」形式前提だった verify_integrity テストを Node CBOR に更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ope decision (#55)

share 経由 read を本 PR に含める決定(ユーザー確認済み)、CEK 即時破棄が
セキュリティ前提でないこと(revoke の安全性は CEK ローテーション由来)、
revoke 順序修正と reissued_envelopes の追加を反映。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m documentation (#55)

HANDOFF はセッション引き継ぎ用の作業メモ(環境固有パス・作業ログ・
チェックリスト)でありリポジトリに残す内容ではないため削除。
設計本体は作業過程の訂正履歴構造を排し、最終形のみ
(脅威モデル / A・B の設計 / member 証明不採用の理由 / 実 read 経路と
CEK ライフサイクル / 既知の限界)に書き直した。経緯は git history を参照。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…member creator (#55)

e2e は常に 4 ノード(creator + 3 members, MIN_REPLICATION_FACTOR=3)で行う。
create_content は creator を意図的に member から除外し local CRDT も持たせない
ため、3 ノードでは member 定足数を満たせず、また非 member 経由の relay read
経路(#54/#55 の本題)が一度も検証されない。

- start-local-nodes.sh: 3 -> 4 ノード起動(ci-e2e.sh と同一トポロジに統一)
- e2e-test.sh: 全ノードループを NODE_PORTS(4 ポート)に統一。Step 2.6 を新設し、
  非 member である creator ノード経由の read が data と version を返すことを
  明示的に assert(relay read の直接の回帰テスト。スモーク範囲に含まれるので
  CI でも毎回検証される)
- read 応答が Node CBOR になったため、ログはバイナリを直接表示せず
  サイズのみ出す。relay がある以上「data が返る = member」ではないので
  誤解を招くラベルも修正
- cleanup/test-local-nodes/test-with-auth も 4 ノード対応

検証: 4 ノードを別ポートで実起動し、ポート置換のみのコピーでスモーク実行
(3/3 成功)。member 3 ノード停止後は creator への read が失敗することも確認
(= creator はローカル非保持で、成功していた read は relay 経由だった証明)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l; drop docs/design/ (#55)

docs/design/ ディレクトリはこのブランチで新設したものだが、リポジトリの
ドキュメント運用は docs/design.md 単一ファイル + 各クレート README であり、
運用変更の合意なくディレクトリを増やすべきではなかった。

設計の要点(read 応答の完全性検証 / 共有コンテンツの CEK ライフサイクル)は
design.md §10 セキュリティモデルへ既存のトーンで統合し、詳細な設計判断・
実装フローは PR #56 の説明文に記載する。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n only after full verification (#55)

Three hardening fixes to the verified read path, from deep-review blockers:

- verify_integrity now verifies the returned Node against the
  client-selected version instead of the response's self-reported
  version field. Verifying against the self-reported version degraded
  the check to self-consistency: any forged Node passes by shipping its
  own CID. Regression test included.

- last_seen (read monotonicity pin) is now recorded only after every
  verification step including decryption succeeds, via compare-and-swap
  on the store. Previously the pin advanced before decryption, so one
  forged-but-CID-consistent undecryptable Node would poison the pin and
  permanently Conflict all subsequent legitimate reads (self-inflicted
  DoS on the monotonicity check). The CAS also stops a slow read from
  rolling the pin back over a concurrent read that advanced it.

- decrypt_shared_content now returns an error when persisting the
  unwrapped CEK fails, instead of logging to stderr and reporting
  success while later state-node reads would fail with MissingKey.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J
…st limits; repoint stale doc refs (#55)

The design.md §10 integration announced in earlier commits was never
actually committed (the edit sat unstaged while docs/design/ was
removed). This commits it, with the framing corrected per review:

- "版真正性" is now "payload真正性": CID verification binds the
  response to the requested version and AES-GCM + plain-CID check
  proves the payload is genuine, but it does NOT prove the version
  itself was created by a legitimate writer.
- New known limitation: version metadata (parents / version CIDs) can
  be forged by re-wrapping observed ciphertext into a new Node, letting
  a relay observer bypass the monotonicity check with forged parents.
  The real fix is an owner-signature trust anchor on Nodes (protocol
  change reaching crsl-lib), tracked separately.

Also repoints the nine code references to the deleted
docs/design/read-response-integrity.md at design.md §10, and drops the
stale membership-proof mention (member proof was not adopted).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J
…D-bound key epoch (#55)

Fixes the CEK-store poisoning blocker: KeyEnvelope previously used HPKE
Base mode, so any third party who knew the plaintext could mint a
consistent envelope and silently overwrite a recipient's stored CEK
(breaking their verified reads). The self-reported sender_key_id field
provided no authentication.

Design (agreed in review discussion):

- KeyEnvelope wrap switches to HPKE Auth mode: the sender's private key
  enters the KEM computation and the recipient unwraps with the
  sender's public key — an envelope not created by that sender fails to
  decrypt. No separate signature infrastructure needed.
- AAD binds (content_id, recipient_key_id, key_epoch) into the wrap;
  tampering with any of them makes decryption fail.
- key_epoch is the CEK generation counter, stored on the Share
  aggregate and bumped on each revoke-triggered rotation. Recipients
  record the last accepted epoch and reject older envelopes, so a
  replayed pre-rotation envelope cannot roll their stored CEK back.
- Recipients TOFU-pin the sender public key per content in a new
  sender-key pin store (in-memory + sled, `sender_pin:` prefix); after
  pinning, envelopes are only verified against the pinned key and the
  pin/epoch advance only after unwrap + decrypt succeed.

API changes: share_content / revoke_share now take sender_private_key
(used transiently, never stored); decrypt_shared_content takes
sender_public_key instead of the meaningless self-reported
sender_key_id; envelopes carry key_epoch; ShareContentOutput echoes
sender_public_key for distribution to recipients.

Tests: HPKE Auth unit tests (roundtrip, forged sender, tampered
epoch/content_id/recipient), pin-store roundtrips, SDK integration
tests for wrong-sender rejection, TOFU pin mismatch rejection, and
pre-rotation envelope replay rejection after rotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J
somasekimoto and others added 2 commits July 25, 2026 20:34
… order; log redacted auth failures

generate-share-token built its JWT payload with serde_json::json!, whose
map keys serialize alphabetically (att, aud, exp, iat, iss, jti). The
state node verifies JWT signatures by re-serializing the parsed
AuthTokenPayload struct (field order iss, aud, exp, iat, jti, att), so
every token the tool minted failed signature verification — which is
also why the delegated-JWT read path had never been exercised end to
end. The payload is now a serde struct matching monas-account's
DelegationClaims field order, verified against a live 4-node mesh:
recipient reads with a delegated JWT succeed on both member and
non-member (relay) nodes.

Also logs the detailed reason (tracing::warn) when an authentication
failure is redacted to the generic HTTP "Authentication failed" body —
without it, diagnosing JWT verification failures on a running node is
guesswork.

Note for a follow-up: verifying JWTs by re-serializing parsed structs
is brittle (field order, whitespace, unknown fields all break genuine
tokens). Verification should use the original wire segments captured in
from_jwt instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J
…op jti single-use; verify JWT over wire bytes

Closes #61, closes #60.

問題(#61): 委譲 JWT の PoP 署名対象が固定文字列 {iss}:{aud}:{jti} で、
リクエストの新しさが署名に入っていなかった。その帳尻合わせの jti 単回消費が
「委譲トークンを 1 個渡して TTL 内で再利用する」SDK の設計と矛盾し、
履歴取得 → データ取得という通常の read すら成立しなかった(nonce 記録は
ノードごとに独立で一貫性もない)。

修正(案 A): 署名対象をトークン種別によらず {operation}:{resource}:{timestamp}
(書き込みは body hash + timestamp)に統一。リプレイ防御は署名内 timestamp の
鮮度チェック(5 分窓)に一本化し、jti 単回消費と nonce ストアを廃止。
timestamp はサーバ時刻フォールバックをやめ構造的に必須とした(欠如 = 認証
エラー)。盗まれた署名でできることは「同じリソースへの同じ操作を 5 分以内に
再実行」のみで、owner 用の非 JWT パスと同水準。

問題(#60): JWT 署名検証がパース後構造体の再シリアライズに依存し、発行者の
JSON フィールド順序が異なると正当なトークンを拒否する brittle な実装だった。

修正: 受信したワイヤ上の header.payload セグメントに対して検証する
verify_jwt_signature_wire を導入し、JWT 検証 2 箇所をこれに置換。

- PoP 検証は verify_caller_signature に一本化(全経路が authorize 前に通る)。
  ucan_adapter は権限判定に専念し、署名は存在チェックのみ(検証は上流で済)
- test-auth-generator / テストヘルパも統一形式へ。フィールド順序非依存・
  改ざん拒否・トークン再利用・timestamp 必須の回帰テストを追加

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…se-signing

# Conflicts:
#	monas-state-node/src/bin/test_auth_generator.rs
The timestamp freshness check only bounds how long a captured signature stays
usable. Inside that five-minute window the same signature can be presented any
number of times, and `update`, `delete`, `invalidate` and `manage` are not
idempotent — so a replay is not a duplicate, it is a state rollback. Replaying a
signed update of old ciphertext after a legitimate update re-commits the old
bytes as a *new* version parented on the current head, making the stale content
the latest version.

Accepted mutation requests are now recorded and a second presentation is
rejected. The request identity is the digest of the request signature itself:
the signature already commits to operation, resource, timestamp and body digest,
so an identical digest means an identical request, and no nonce field has to be
added to the wire format. Records only need to outlive the freshness window
(outside it the signature fails the freshness check anyway), so the store is
pruned on insert and cannot grow without bound. Reads are idempotent and are
deliberately not covered.

Relays consume too. A relay holds no access policy, so it forwards credentials
and lets a member decide — which means the member is the only one consuming, and
re-sending to the relay gets the request applied again whenever the relay picks
a member that has not seen it. Recording on the relay closes that path. It is
not a substitute for the member-side check (a caller can always use a different
relay), and because the relay cannot verify what it records, an unauthenticated
caller can burn a digest on that node — bounded to one node, one window, and a
signature the attacker already holds and could replay directly anyway.

Replay rejection gets its own error rather than collapsing into
"Authentication failed", and returns 409. The two mean opposite things to an
operator — a forged request versus a genuine one arriving twice — and a client
told "authentication failed" will retry the same signature forever instead of
re-signing.

Corrects three places that asserted freshness alone was replay protection: the
design doc, `verify_caller_signature`'s doc comment, and the note left when the
jti nonce store was removed in #61. The scope this actually buys is now stated,
including what it does not cover (per-node records; a volatile default store
that forgets across a restart).

Also fixes a pre-existing bug in the auth script: `set -e` plus
`((TESTS_PASSED++))` aborted the run at the first passing test, because the
post-increment returns the old value 0. The member-add expectations were wrong
too — the creating node is never a member, so 403 is the correct outcome there.

Tests: a replayed signature is rejected and reported as a replay, and the state
does not roll back; the same signature never applies twice. Both fail without
the consumption gate. Verified on four real nodes — e2e 8/8, auth 9/9 including
a replayed update rejected with 409 through a relay.
…feature/read-response-signing

# Conflicts:
#	docs/design.md
@somasekimoto somasekimoto changed the title feat(read-integrity): verified read path — payload authenticity, sender-authenticated CEK envelopes, unified request signatures feat(read-integrity): verified read path — payload authenticity, sender-authenticated CEK envelopes, single-use mutation signatures Jul 28, 2026
The constant was introduced with a bare "Reject if timestamp is older than 5
minutes" comment and no rationale anywhere, which makes it impossible to tell
whether it may be changed.

RFC 9449 (DPoP) §11.1 splits the problem exactly the way this code now does:
accept a proof only for "a relatively brief period on the order of seconds or
minutes", and *separately* store its identifier for that window so it cannot be
used twice — a single-use check being what actually defends against replay. That
the freshness window alone is insufficient is stated at the specification level,
not just here.

The 300s ceiling matches what AWS SigV4 uses for the same job. The floor comes
from how long a legitimate request takes to arrive: a gateway hop, then a relay
with a 30s per-peer budget (`PEER_NETWORK_TIMEOUT`), possibly after DHT
discovery, with failover retrying across candidates.

So the window is loose rather than measured. Worth tightening once real latency
is known — it directly bounds how long a captured read signature stays
replayable — but never below the failover budget, or legitimate reads start
failing. Recorded so that trade-off is visible to whoever touches it next.
…not the signature

The consumed-request record was keyed on `sha256(signature_bytes)`. ECDSA
signatures are malleable: for a valid `(r, s)` the value `(r, n - s)` verifies
against the same message and the same key, so one authorized request has two
distinct signature encodings — and therefore had two distinct identities. An
attacker only had to convert a captured signature once to walk straight past the
single-use check and re-commit the mutation, which is exactly the state rollback
the record exists to prevent. Verified against p256 0.13: both encodings verify,
the bytes differ, the digests differ.

The identity now comes from the signed message plus the signer. The message
already binds operation, resource, timestamp and body digest, so it identifies
the request without depending on how the signature happens to be encoded. The
token is mixed in, length-prefixed, so two callers cannot collide on one identity
and consume each other's requests. `build_signing_message` is now the single
construction used by both verification and consumption, so the two cannot drift.

High-S is deliberately still accepted at verification. Rejecting it would break
every existing caller — no signer in this repo, nor monas-account which real
clients use, normalizes before sending, and S is high about half the time.
Requiring low-S is the stricter end state but has to land in the signers first.
Nothing depends on the encoding being unique any more; a test pins that both
encodings verify, so the assumption cannot creep back in.

Fixing this surfaced a second gap of the same shape as the unsigned
`add-members.count`: `revoke` passed `None` as its request body, leaving
`new_min_valid_issued_at` — which decides *which* tokens get revoked — outside
the signature. It now signs over `AccessControlUpdate::signing_message()`, the
canonical encoding the owner already signs.

Also stops feeding the caller's timestamp to the consumed-request store. Retention
is now measured on the node's own clock; using the signed timestamp let a caller
inside the allowed skew present a future-dated request to evict entries that were
still live, then re-present an older signature whose record had just been dropped.

Tests: a re-encoded signature does not buy a second application (fails if the ID
reverts to the signature digest); the request ID is stable across encodings and
distinct per operation/resource/timestamp/body/signer, with token and message
separated so they cannot be re-split; a revoke cutoff cannot be substituted; a
live record cannot be evicted by out-of-order presentations.
…evoke

`is_token_valid` accepted `iat >= min_valid_issued_at`, and both values have
one-second resolution. A token issued in the same second as a revoke therefore
had `iat == cutoff` and survived it — a direct counterexample to revoke's stated
guarantee of invalidating everything issued before it. Two existing tests pinned
that behaviour as correct.

The cutoff is now exclusive. Within one second the ordering is simply not
observable, so the only safe reading of an equal timestamp is "this might
predate the revoke". Erring this way costs a caller who is issued a token in the
same second right after a revoke one retry; erring the other way leaves a
revoked recipient with working credentials.

`0` keeps meaning "never revoked" and accepts everything, so making the
comparison strict does not start rejecting `iat = 0` on untouched policies.

The rule lived in two places — `AccessPolicy` and `ContentAccessControl` — and
both are fixed, along with the struct doc comments that still described the
inclusive rule.

Tests: a token stamped exactly at the cutoff is rejected while `cutoff + 1` is
accepted (fails if the comparison reverts to `>=`); an untouched policy still
accepts everything. The three existing tests that asserted the inclusive
boundary now assert the exclusive one.
Putting the CEK inside `SenderKeyPin` made the (sender key, epoch, CEK) triple
swap atomically, but the read path never looked at that record — it loaded from
`cek_store`, and the cache write happens outside the CAS. So the invariant held
where nothing read it, and the value reads actually used could still roll back:

1. handler N wins the CAS on the authoritative record, then stalls
2. handler N+1 wins its CAS and writes cache N+1
3. handler N resumes and writes cache N unconditionally
4. the record says N+1, the cache the read uses says N

`verify_and_decrypt_relay_read` now takes an explicit CEK and only falls back to
the store when none is given. The SDK passes the CEK from the sender pin, so a
lagging cache cannot affect a share recipient's reads. Content the caller created
itself has no sender pin, so that path still uses the store — there is no second
writer there to race with.

Tests: with a key-sensitive encryptor and a deliberately stale cache, the
explicit CEK decrypts correctly while the fallback does not (fails if the
parameter is ignored).

An SDK-level test was written first and then removed: it passed with the fix
disabled, because the interleaving needs two concurrent handlers and the public
API cannot drive that. A test that cannot fail is worse than no test — it reads
as coverage that is not there.
somasekimoto and others added 6 commits July 29, 2026 04:07
… raw token

The previous fix moved the mutation request ID off the request signature and
onto "the signed message plus the signer" — but the signer it used was
`token.as_str()`, the raw token string. A delegated JWT ends in an ECDSA
signature over its own header and payload, and ECDSA is malleable, so the same
JWT has more than one byte representation. Converting `s` to `n - s` leaves the
claims, the `aud`, and the request signature untouched, still passes
verification, and yields a different request ID — re-applying the mutation on
the very same node. The bypass the earlier commit closed on the request
signature was still open one layer up, on the token.

Confirmed empirically: both JWT encodings verify against the same key and the
same signing input, the token bytes differ, and the resulting request IDs
differ.

The ID now uses the canonical principal — the key the request signature is
actually verified against, mirroring `verify_request_signature`: the token
itself for a self-contained key id, the `aud` claim for a delegated JWT. No
signature bytes of any kind reach the hash. A malformed JWT falls back to the
whole token; such a token cannot authenticate anyway, so the value only has to
be deterministic.

Also close two membership arms that had no publisher authorization at all,
found by enumerating every arm rather than only the ones reported:

- `ContentNetworkManagerRemoved` had no check whatsoever, so any peer could
  delete our `ContentNetwork` record by naming us as `removed_node_id`.
- `ContentDeleted` called `verify_source_peer_id` against `deleted_by_node_id`,
  which the event names itself — so any authenticated peer satisfied it.

Both now require the publisher to be a member of the network being changed,
the same rule as `ContentNetworkManagerAdded`.

`auth_verdict_is_authoritative` now always returns false. It returned true for
`LocalRecord` on the grounds that a listed member evaluated the caller against
the real policy, but that does not hold while the record itself can be planted:
the first record for a content is accepted with no prior membership to check
against, so an attacker winning that race lands in the list and its 403 would
end the failover loop — the same permanent denial of service the DhtGuess case
already guards against. The denial is still returned if nothing better appears;
only the early exit is gone. Restoring it needs owner-signed membership (#63).

Docs corrected to match the code:

- `docs/design.md` still described the replay ID as the request signature
  digest, which has been false since the previous round.
- `ReadContentFromStateNodeInput` promised a monotonicity check on latest
  reads; that check was removed and never existed in this form.
- `verify_and_decrypt_relay_read` said the SDK layers a monotonicity check
  around it. It does not — nothing in the SDK reads `parents`.

All four new tests were confirmed to fail with their fixes disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c
…equest id

The relay recorded a mutation's request id before verifying anything, and the
id is derived entirely from public inputs: operation, resource, timestamp, body
digest, and the principal (`aud` or the key id). No private key and no valid
signature are needed to compute it. Anyone could therefore burn a legitimate
caller's request id with a garbage signature — send a `delete` for their content
stamped with the current second (no body, so the signed message is fully
predictable) and their real request comes back `RequestAlreadyApplied`. Repeat
every second and the target cannot mutate anything through that relay.

The relay now runs `verify_caller_signature` before touching the store. It can:
that check is purely cryptographic — the token's own signature, then the request
signature against the key the token designates. Only *authorization* needs the
access policy, and that stays with the member.

This is a bug I introduced. The previous doc comment argued the pre-consume was
harmless because it only affected "a digest the attacker already has — if they
hold the signature they can replay it themselves anyway". That was true while
the id *was* the signature digest. It stopped being true when the id moved to
the signed message, and I did not revisit the reasoning that depended on it.

design.md also corrected on two counts: it now documents the verify-then-record
ordering and why it is load-bearing, and it drops the claim that "CRDT tolerates
duplicate application of the same operation" as cover for the network-wide gap.
Each replica builds a *new* operation from its own head and author, so that
property does not apply to this path — the rollback is real, and #65 tracks it.

The new test was confirmed to fail with the verification disabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c
…mentation

Audited design.md's security-model claims against the code. Three drifted:

- The single-use record covers six write operations (create, update, delete,
  invalidate, manage, revoke), not the four the document listed. A reader
  checking "is create replay-protected?" would have concluded it is not.
- Publisher binding covers four event types, not two. The document omitted
  `ContentNetworkManagerRemoved` — the arm that *deletes* the local record, so
  strictly more destructive than the `Added` arm it did mention — and
  `ContentDeleted`, which needs both checks because it names its own publisher.
  `ContentUpdated` was also unlisted.
- Publisher binding was presented as unconditional. It is skipped when the
  origin cannot be established (`source_peer_id == None`), which the document
  never mentioned alongside the first-record caveat it did.

Two code comments contradicted the (correct) design doc and are fixed here:

- `verify_caller_signature` still described the pre-#61 signing message format
  (`hex(sha256(body + timestamp_be_bytes))` / `{op}:{resource}:{timestamp}`).
- `ucan_adapter`'s module doc said `iat >= min_valid_issued_at`; the cutoff has
  been exclusive since the same-second revoke fix.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c
somasekimoto added a commit that referenced this pull request Aug 3, 2026
… read

Brings the Drive UI up to the read-integrity stack so every operation works
from the browser again.

Contract changes absorbed (all breaking, all previously silent 4xx/5xx):

- `/share` and `/share/revoke` now require `sender_private_key` — the CEK is
  wrapped with HPKE in **Auth mode**, which mixes the sender's private key in.
- `/share/decrypt` takes `sender_public_key` instead of the self-asserted
  `sender_key_id`; the recipient TOFU-pins it and rejects later envelopes that
  don't match.
- `KeyEnvelope.key_epoch` is carried through untouched. Revoke rotates the CEK
  and bumps the epoch, and recipients reject older epochs as rollback replay.
- Revoke returns `reissued_envelopes` for the *surviving* recipients. The UI
  swaps them into its registry — a recipient left on the pre-rotation envelope
  can no longer decrypt — and reports `token_invalidated_at`, which also voids
  tokens held by recipients that were not revoked.

New capability surfaced:

- `POST /state/read` (verified read) was entirely unused by the UI. The preview
  modal can now read any version back from the state node — relayed to a member
  when the contacted node isn't one — showing plaintext only after CID
  recomputation, AES-GCM decryption and a plain-CID recheck. The panel is
  explicit that this proves payload authenticity but *not* version freshness
  (issue #59).

Also corrects the UI's crypto claims: content encryption moved to AES-256-GCM
in #54, but 8 places still advertised the unauthenticated AES-256-CTR.

The registry key moves to v3. There is nothing to migrate — the GCM switch
invalidates every pre-existing ciphertext, so v2 entries could only fail on
open.

Verified against a real 4-node local cluster (gateway pointed at the non-member
node so reads exercise the relay path): e2e-verify.mjs 17/17, zero error
toasts, zero page errors. `tsc --noEmit` and `vite build` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread monas-content/src/infrastructure/node_verification.rs
Comment thread monas-sdk/src/controller/share.rs
Comment thread monas-sdk/src/controller/mod.rs
Comment thread monas-sdk/src/controller/mod.rs Outdated
…ort not its impl

Both from @Yu-da-1's review of #56.

**The revoke lock registry never shrank.** `mutex_for` inserted a per-content
mutex and nothing ever removed it, so the map grew by one entry for every
distinct content ever revoked and never gave any back. A gateway runs
continuously, so this grew with uptime and with the number of contents handled.
Each entry is only tens of bytes, but it had no upper bound at all. Reproduced
against the old shape: 100 sequential revokes left 100 entries.

Replaced the map-of-mutexes with a single mutex + condvar guarding a set of
in-flight content ids. Holding the guard is what excludes others; dropping it
removes the entry and wakes any waiter. Presence in the set *is* the lock, so
release cannot forget to clean up, and the set is bounded by the number of
revokes running concurrently rather than by the number ever performed.

This was my code (0f8c35b). I documented that the lock was process-local but
never considered that the registry backing it grew without bound.

**The SDK reached into monas-content's infrastructure layer.** `SenderKeyPinStore`
is a port — an abstraction over where the pin is persisted — but it lived in
`infrastructure/` next to its two implementations, so every consumer had to name
the infrastructure path. Moved the trait, `SenderKeyPin` and the error type to
`application_service::share_service::sender_key_pin_port`, alongside the
`ShareRepository` and `PublicKeyDirectory` ports that play the same role.
`infrastructure/sender_key_pin_store.rs` keeps only the in-memory and Sled
implementations.

The SDK's type alias and construction now name the port. The composition root
still names the concrete backends, which is where that choice belongs.

Three tests cover the lock: entries are released, the same content is still
mutually exclusive under 8 threads, and different contents do not block
each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c
Comment thread monas-sdk/src/controller/state.rs Outdated
`read_content_from_state_node` called `verify_and_extract` and threw the result
away, then called `verify_and_decrypt_relay_read`, which runs the same
`verify_and_extract` on the same bytes against the same version as its first
step. The Node CID was recomputed twice per read.

The error surfaced to the caller is unchanged: the removed block and
`map_verified_read_error`'s `NodeVerification` arm produce the identical
message, so `read_rejects_tampered_node` still passes untouched.

This also removes one of the SDK's direct reachs into monas-content's
infrastructure layer, leaving verification as the content layer's
responsibility in one place. The remaining direct call is in
`verify_integrity`, which consumes the extracted ciphertext for a byte
comparison and does not go through `verify_and_decrypt_relay_read` — that one is
a layering question, not a duplicate.

Confirmed the surviving check is what catches tampering: weakening it to verify
each node against its own recomputed CID makes `read_rejects_tampered_node`
fail.

Reported by @Yu-da-1 in review of #56.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8xhYR7ZuFkiFMS91NUW6c
@somasekimoto
somasekimoto merged commit 6835b42 into fix/state-node-read-relay Aug 18, 2026
5 checks passed
somasekimoto added a commit that referenced this pull request Aug 28, 2026
fix: #56/#67 の内容を main へ反映する (スタックのマージ先修正)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants