Skip to content

fix: #56/#67 の内容を main へ反映する (スタックのマージ先修正) - #69

Merged
somasekimoto merged 61 commits into
mainfrom
feature/read-response-signing
Aug 28, 2026
Merged

somasekimoto merged 61 commits into
mainfrom
feature/read-response-signing

Conversation

@somasekimoto

Copy link
Copy Markdown
Contributor

これは何

#56 と #67 の内容を main へ運ぶ PR。 新規コードは clippy 対応 1 commit を除いて含まない。

スタックのマージが内側で止まっていた:

#54: fix/state-node-read-relay     → main                       merged (この時点の main が現在の HEAD)
#56: feature/read-response-signing → fix/state-node-read-relay  merged (#54 の後)
#67: fix/state-node-peer-resilience → feature/read-response-signing  merged

#56 のマージは #54 が main に入った後だったため、#56/#67 の内容を main へ運ぶ PR が存在せず、main は #54 時点で止まっていた。GitHub 上では #56/#67 とも "Merged" と表示されるが、main からビルドすると read-response 署名も再収束修正も入らない状態だった。本 PR でスタックを完結させる。

運ばれる内容 (レビュー・検証済み)

本 PR で新規に追加した commit

検証

マージ後

🤖 Generated with Claude Code

somasekimoto and others added 30 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
… 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
…equired by #61 fix)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
単調性チェック(last_seen 記録 + 祖先 walk)を削除する。

理由:
- 本質的な防御になっていない。攻撃者は観測済みの正規暗号文を任意の
  parents(last_seen を含む)で包み直した Node を鋳造でき、CID 検証・
  復号・祖先 walk をすべて通過できる。版メタデータに真正性が無い以上、
  クライアント側の記憶では埋められない
- 一方で正規の read を壊す経路を持ち込む。結果整合性のもとで sync 遅延は
  正常な挙動であり、応答単体では攻撃と区別できない。さらに 256 版を超えて
  離れると復旧手段なく read が失敗し、部分同期 member に当たると walk が
  失敗する
- 永続 pin は GC できない負債になる(TTL で消すことは TOFU 窓の再オープンに
  等しい)。CAS 追加後も並行 read の完了順は後退し得た

版の真正性とロールバック耐性は owner 署名等の trust anchor で解決すべきで、
それが入れば本機構はいずれにせよ不要になる(別 issue)。payload 真正性
(Node CBOR + CID 再計算 + AES-GCM + plain CID 照合)、envelope 送信者認証、
PoP 統一はそのまま残る。

- monas-content: last_seen_version_store.rs 削除
- monas-sdk: walk_ancestors_for / check_read_monotonicity /
  fetch_verified_parents / MAX_MONOTONICITY_FETCHES / DynLastSeenStore 除去
- docs/design.md §10: 保証範囲を「payload 真正性まで」と明記し、単調性を
  採用しない理由と trust anchor による解決方針を記述

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
レビュー指摘: body を伴う create/update の署名対象が sha256(body||timestamp)
だけで、operation と resource が落ちていた。同じトークンで複数 content に
書ける場合、ある content の update 用に取得した body+署名を別 content へ、
あるいは create へ転用できた(create は認証後の resource 認可を skip する)。
design.md の「盗まれた署名は同じリソースへの同じ操作に限られる」も実装より
強い主張になっていた。

署名対象を body の有無・トークン種別によらず単一構造に統一する:

  monas-request-v1:<len>:<op>:<len>:<resource>:<timestamp>:<len>:<body_digest>

- domain separation タグを前置(構造変更時に旧署名を一括無効化できる)
- 長さ前置により、 を含む値でフィールド境界がずれない
- body 付きは digest を含め、body なしは空文字で同じ構造を保つ
- SDK / test-auth-generator の生成側も同一形式へ

テスト: cross-resource / cross-operation / body 改ざんの各転用が検証で
落ちること、フィールド境界の曖昧性が無いこと、read 署名が content id に
束縛されること。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
somasekimoto and others added 28 commits July 28, 2026 17:13
…ca test

#54 の fail-closed テストを取り込む際、#56 の timestamp 構造的必須化と
噛み合うよう明示的な timestamp を渡す。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`count` arrives in the HTTP body and decides how many nodes are added to a
content network, but `add_member_to_content` passed `request_body = None` to
`verify_caller_signature`. The same token, signature and timestamp could
therefore be replayed with a different `count`. It is clamped to
`max_add_member_count`, but raising `1` to the clamp is still a mutation the
caller never authorized — a direct counterexample to "every mutation body is
bound to the signature".

The raw JSON body is not signable as-is (whitespace and key order vary between
clients, producing different digests for the same request), so both sides derive
the same canonical bytes from the parsed value via `add_members_signing_body`.
The digest goes through the existing `signing_message_with_body_digest` path, so
the wire format is unchanged.

The clamp still happens after verification, which is correct: the signature
covers the pre-clamp value the caller actually sent.

test-auth-generator gains `--add-members-count` so scripts can produce the
matching signature, and its doc comment is updated — it still described the
pre-`monas-request-v1` message format.

Tests: canonical encoding is distinct per count; a count=1 signature is rejected
for count in {0, 2, 8, 1000} and for a body-less manage message; the shell
harness asserts HTTP 401 on count substitution.
`docs/design.md` defines revoke as advancing the state node's
`min_valid_issued_at` so every previously issued Token is invalidated. The SDK's
`revoke_share` never called that endpoint — it rotated the CEK, updated the ACL
and pushed the re-encrypted ciphertext, and stopped there.

CEK rotation only stops the revoked recipient from *decrypting*. Their delegated
write Token stayed valid until TTL expiry, so they could keep writing to the new
state. That is the opposite of what revoke is supposed to mean.

Invalidation runs *before* the rotation. The reverse order leaves a window
between re-encryption and invalidation in which the revoked party can still
write. Going first means a later failure only leaves an extra invalidation
behind, whose sole cost — remaining recipients need fresh tokens — is already
required by the CEK rotation itself. Nothing local has been touched at that
point, so an invalidation failure aborts the whole revoke with the existing
share left intact rather than half-applied.

Since `min_valid_issued_at` is a timestamp-based bulk revocation, it also
invalidates the surviving recipients' tokens. `RevokeShareOutput` therefore
reports `token_invalidated_at` alongside `reissued_envelopes`, so the caller
knows to reissue tokens dated after that instant.

The design doc's revoke diagram is corrected to the implemented order and now
states why the ordering matters and that surviving recipients need both a new
envelope and a new token.

Tests: revoke calls the invalidate endpoint with a signed request and reports
the returned `min_valid_issued_at`; a failing invalidation aborts revoke without
sending the ciphertext update and leaves the existing share decryptable. Both
fail if the invalidation call is removed.
The invariant that matters is that the sender key, the key epoch and the CEK
stay consistent with each other — not that the epoch number alone moves
forward. Keeping them in two stores with two commits breaks that even with the
epoch under CAS:

1. the epoch-N handler reads the pin (epoch N-1)
2. the epoch-N+1 handler advances the pin and saves the new CEK
3. the epoch-N handler takes the "same generation, re-process" branch, skips the
   CAS, and writes its older CEK unconditionally
4. the result is pin = N+1 with CEK = N, and every later read fails to decrypt

`SenderKeyPin` now carries the CEK, so one compare-and-swap swaps all three
together and that interleaving cannot be constructed. The CEK store becomes a
cache derived from this authoritative record: it is only refreshed when the CAS
wins, and a failed write is recoverable by re-processing the envelope (which is
what the error already tells the caller to do).

Same-generation re-processing still goes through the CAS so a record written
before this change — or one whose CEK write failed — can be filled in without
moving the epoch. That is not a rollback: the expected value is the record just
read. Older generations never reach this code; they are already rejected
upstream as stale envelopes.

`SenderKeyPin` gets a hand-written `Debug` that redacts the CEK so key material
cannot leak through logs or panic messages, and the `cek` field is optional so
existing pinned records keep deserializing (otherwise every recipient would fall
back to TOFU).

Also corrects a stale doc comment that still named AES-CTR as the expected
content encryption, and the design doc paragraph that described the superseded
two-store ordering.

Tests: epoch and CEK advance together under a losing CAS; a legacy record
without the CEK field deserializes; Debug redacts the CEK; re-processing the
current envelope stays idempotent and leaves reads working.
Revoke is a load-modify-save spanning the ACL, the CEK, the local ciphertext and
the state node, and none of those carry a version CAS. `MonasController` is
shared behind an `Arc` by the gateway and called from concurrent request
handlers, so two revokes on the same content really do interleave:

- both read the same Share and save it last-write-wins, dropping one recipient's
  removal entirely (lost update)
- different CEKs get handed out under the same key_epoch
- the local ACL/CEK and the remote ciphertext end up coming from different
  requests

The whole sequence now runs under a per-content mutex, taken before the snapshot
is captured. Taking it later would let the snapshot go stale between the read and
the lock, so a failed revoke's rollback would overwrite the other revoke's
result.

This is deliberately not the full fix. The real invariant wants Share, CEK and
ciphertext under one transactional CAS; the lock is process-local and does not
cover concurrent revokes from separate gateway processes. Both limits are stated
in the design doc rather than left implied.

Tests: two recipients revoked concurrently, both removals survive. Without the
lock the test fails in 4 of 5 runs; with it, 5 of 5 pass.
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
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.
… 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
A 4-node deployment reached a state it could not recover from on its own:
every node kept dialling a bootstrap address that no longer existed, one node
was down entirely, and the survivors held 0-1 connections each where 3 were
expected. Nothing was crash-looping — the cluster simply had no path back to
each other. Four separate gaps combined to make that unrecoverable.

**The bootstrap address was frozen at startup.** `entrypoint.sh` resolved
`BOOTSTRAP_DNS` with `getent hosts` and baked the resulting `/ip4/…` into
`--bootstrap`. When the bootstrap task was recreated with a new IP, every peer
kept dialling the old one forever. Because nodes had started at different
times, each had baked a *different*, mostly stale, address for the same peer —
the one node holding the correct address was the one that had died. Bootstrap
entries are now `/dns4/…`, which libp2p re-resolves on every dial, and
`BOOTSTRAP_ADDR`/`BOOTSTRAP_DNS` accept comma-separated lists so a node is not
tied to a single entry point. Parsing moved to `network::bootstrap` with tests,
and a literal IP now logs a warning explaining that it will not be re-resolved.

**Nothing ever dialled again.** `ConnectionClosed` removed the peer and logged;
Kademlia was only bootstrapped at startup and on identify. A node that lost its
peers stayed isolated indefinitely. A 30s maintenance tick now re-dials and
re-bootstraps whenever fewer than 3 peers are connected, and is skipped
entirely above that.

**A node could not rejoin without its bootstrap.** Addresses of peers we have
actually reached are now persisted to `known_peers.json` in the data dir
(alongside the peer key that already survives restarts) and re-dialled on
startup, so the bootstrap list is an entry point rather than a dependency —
which is what "anyone can run a node" requires. Loopback and unspecified
addresses are skipped, the file is written atomically, and a corrupt or
partially-unreadable store degrades to empty instead of blocking startup.

**A failed capacity query was read as "0 bytes free".** `capacities.get(id)
.unwrap_or(0)` made an unanswered query indistinguishable from an empty disk.
That produced the misleading `has low capacity (0 bytes)` line that sent this
investigation after a disk-space problem that did not exist — and, worse,
`low_capacity_nodes` drives *member removal*, so a partition could evict
healthy replicas for being unreachable. A missing response is now "unknown":
not counted as healthy, never removed. Verified by reverting the fix and
watching the new test evict node-3.

**mDNS was always on, so local runs could not reproduce any of this.**
`enable_mdns` existed in the config but nothing read it. That matters here
specifically: the first local reproduction "passed" only because mDNS
rediscovered the moved peer by broadcast — a mechanism that does not exist in
a VPC. `--disable-mdns` now makes a local cluster behave like a deployed one.

Verified on a local 4-node cluster **with mDNS disabled**: killing the
bootstrap node and restarting it brought all four back to 3 peers each, with
the logs showing the reconnection immediately following a maintenance re-dial.
Under `/ip4/`, the same scenario stays broken — which is the point of the DNS
change.

cargo test --workspace: 799 passed, 0 failed binaries. clippy (1.97) clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…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
`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
…S switch

Follow-up to the peer-reconvergence fix, addressing three defects that made it
land short of its own goal.

1. The peer store recorded undialable addresses.

`ConnectionEstablished` was recorded regardless of direction, but on an inbound
connection `get_remote_address()` is `send_back_addr` — the peer's *ephemeral
source port*, not the port it listens on. Measured on a real pair of swarms: a
node listening on /tcp/62417 is seen by its peer as /tcp/62418.

Since addresses are capped FIFO per peer, a few inbound reconnects also evicted
the one address that did work. In a 4-node mesh most connections are inbound for
somebody, so the store was majority-poisoned — which defeats the whole point of
"the bootstrap list is an entry point, not a dependency".

Record only outbound connections, via `reusable_addr`. Also reject `/p2p-circuit`
addresses (valid only while that relay connection lives, so persisting one means
re-dialling a dead path every tick — the same frozen-address class this store
exists to escape) and IPv6 link-local, which the Ip6 arm was missing while the
Ip4 arm already filtered it.

2. The deploy procedure defeated the /dns4/ change.

`BOOTSTRAP_ADDR` takes precedence over `BOOTSTRAP_DNS`, and the runbook still
instructed operators to pass `bootstrap_addr=/ip4/<NODE1_PRIVATE_IP>/...` — the
exact frozen address that caused the outage. Following the documentation would
have deployed the fix in a configuration where it does nothing.

`outputs.tf` was also unusable as the DNS alternative: it interpolated the
namespace *ID*, emitting `node2.ns-abc123` rather than `node2.monas.local`. Add
`service_discovery_namespace_name` (exported from foundation) and use it.

3. --disable-mdns was reachable from no deployment or test path.

The flag was implemented, read and unit-tested, but nothing set it. The previous
state was "a config field nothing reads"; this was "a flag nothing sets" — the
same defect one level up, leaving local runs still able to pass on a broadcast
path that does not exist in a VPC.

Add DISABLE_MDNS to entrypoint.sh (defaulting off, so existing behaviour is
unchanged) and set it in compose.yaml and terraform. ci-e2e.sh now runs its
4-node mesh with mDNS off, so the connectivity guard exercises the discovery
paths production actually depends on. compose.yaml also moves to /dns4/node1,
which Docker's embedded DNS re-resolves per dial; it previously baked the IP
itself and bypassed entrypoint.sh entirely.

Tests: 355 passed / 0 failed. clippy --deny warnings and cargo fmt clean.
`only_outbound_connections_yield_a_reusable_address` fails with the exact
ephemeral port if the direction check is removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(state-node): アドレス変更後にピアが自力で再収束できるようにする
Rust 1.98's clippy promotes result_large_err on `Result<(), Response>` —
axum's Response is over 128 bytes, so the happy path paid for carrying the
error by value. Box it, as the lint suggests. Pre-existing code
(verify_read_access, from 3cd2d55); surfaced now because CI installs the
latest stable toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@somasekimoto
somasekimoto merged commit 9700b1b into main Aug 28, 2026
5 checks passed
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