fix: #56/#67 の内容を main へ反映する (スタックのマージ先修正) - #69
Merged
Merged
Conversation
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>
…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>
…feature/read-response-signing
…hes implementation)" This reverts commit 00b6afc.
…feature/read-response-signing
`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
…feature/read-response-signing
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.
…feature/read-response-signing
… 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
…feature/read-response-signing
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
…feature/read-response-signing
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>
Yu-da-1
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
これは何
#56 と #67 の内容を main へ運ぶ PR。 新規コードは clippy 対応 1 commit を除いて含まない。
スタックのマージが内側で止まっていた:
#56 のマージは #54 が main に入った後だったため、#56/#67 の内容を main へ運ぶ PR が存在せず、main は #54 時点で止まっていた。GitHub 上では #56/#67 とも "Merged" と表示されるが、main からビルドすると read-response 署名も再収束修正も入らない状態だった。本 PR でスタックを完結させる。
運ばれる内容 (レビュー・検証済み)
/dns4/bootstrap、30 秒保守 tick、known_peers.json、容量クエリ失敗の誤診修正、--disable-mdns、および deep review で検出した blocker 3 件の修正 (outbound のみ記録する peer store、deploy 手順の/dns4/化、mDNS 無効の検証経路配線)本 PR で新規に追加した commit
6971e9e— Rust 1.98 clippy (result_large_err) 対応。CI が最新 stable を使うため、1.98 リリース (2026-08-18) 以降このブランチの lint が落ちる状態だった。verify_read_accessのResult<(), Response>をBox<Response>化 (既存コード由来、feat(state-node): NAT 配下のノードも参加できるようにする (AutoNAT v2 + circuit relay v2 + DCUtR) #68 ブランチの3c73f51と同一内容)検証
cargo test --workspace802 passed / 0 failed (fix(state-node): アドレス変更後にピアが自力で再収束できるようにする #67 時点)--deny warningsクリーン、cargo fmtクリーンマージ後
feat/state-node-nat-traversal(feat(state-node): NAT 配下のノードも参加できるようにする (AutoNAT v2 + circuit relay v2 + DCUtR) #68) の base が自動で main に付け替わるBOOTSTRAP_ADDRが空であることを確認 (非空だとBOOTSTRAP_DNSより優先され再収束修正が効かない)。deploy 順は member 先・bootstrap 最後🤖 Generated with Claude Code