feat(read-integrity): verified read path — payload authenticity, sender-authenticated CEK envelopes, single-use mutation signatures - #56
Merged
somasekimoto merged 57 commits intoAug 18, 2026
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
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
This was referenced Jul 28, 2026
… 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
somasekimoto
added a commit
that referenced
this pull request
Aug 3, 2026
… read Brings the Drive UI up to the read-integrity stack so every operation works from the browser again. Contract changes absorbed (all breaking, all previously silent 4xx/5xx): - `/share` and `/share/revoke` now require `sender_private_key` — the CEK is wrapped with HPKE in **Auth mode**, which mixes the sender's private key in. - `/share/decrypt` takes `sender_public_key` instead of the self-asserted `sender_key_id`; the recipient TOFU-pins it and rejects later envelopes that don't match. - `KeyEnvelope.key_epoch` is carried through untouched. Revoke rotates the CEK and bumps the epoch, and recipients reject older epochs as rollback replay. - Revoke returns `reissued_envelopes` for the *surviving* recipients. The UI swaps them into its registry — a recipient left on the pre-rotation envelope can no longer decrypt — and reports `token_invalidated_at`, which also voids tokens held by recipients that were not revoked. New capability surfaced: - `POST /state/read` (verified read) was entirely unused by the UI. The preview modal can now read any version back from the state node — relayed to a member when the contacted node isn't one — showing plaintext only after CID recomputation, AES-GCM decryption and a plain-CID recheck. The panel is explicit that this proves payload authenticity but *not* version freshness (issue #59). Also corrects the UI's crypto claims: content encryption moved to AES-256-GCM in #54, but 8 places still advertised the unauthenticated AES-256-CTR. The registry key moves to v3. There is nothing to migrate — the GCM switch invalidates every pre-existing ciphertext, so v2 entries could only fail on open. Verified against a real 4-node local cluster (gateway pointed at the non-member node so reads exercise the relay path): e2e-verify.mjs 17/17, zero error toasts, zero page errors. `tsc --noEmit` and `vite build` clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yu-da-1
reviewed
Aug 10, 2026
Yu-da-1
reviewed
Aug 10, 2026
Yu-da-1
reviewed
Aug 10, 2026
Yu-da-1
reviewed
Aug 10, 2026
…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
Yu-da-1
reviewed
Aug 11, 2026
`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
Yu-da-1
approved these changes
Aug 18, 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.
概要
PR #54(read relay)へのセキュリティ指摘(#55)対応。relay 経由の read 応答をクライアント側で検証できるようにし、実際に state node から読んで復号する read 経路まで実装した。あわせて share の KeyEnvelope に送信者認証を入れ、リクエスト署名の形式を統一し、署名済み mutation を使い切りにした。
設計は
docs/design.md§10 セキュリティモデルに統合済み。保証範囲(重要)
この PR で保証できるのは payload の真正性までで、版の真正性は保証しない。 #55 は本 PR では閉じない。
版の真正性は Node への owner(または権限を持つ writer)署名という trust anchor が必要で、crsl-lib に及ぶプロトコル変更のため issue #59 で追跡する。初回送信者鍵の owner 束縛も同じ trust anchor に依存するため、あわせて #59 で扱う。
クライアント側で「最後に受理した版」を記録して後退を拒否する単調性チェックも一度実装したが、撤去した。偽 parents を詰めた版で bypass できるため本質的な防御にならない一方、結果整合性のもとでは正当な sync 遅延(分散システムでは正常な挙動)と攻撃を応答単体で区別できず、正規 read を壊す誤検知(256 版超のギャップで復旧手段なく失敗、部分同期 member で walk 失敗)と、GC できないクライアント永続状態という負債だけが残るため。
設計判断
何を防ぐか
key_epochを持たせ AAD に束縛。記録済み世代より古い envelope を拒否min_valid_issued_atを進め既発行 Token を一括失効(下記)実 read 経路の詳細
SDK
read_content_from_state_node/ gatewayPOST /state/read:入力は remote(state node 側)と local(CEK 引き当て用)の両 content id。対応表は存在しないため呼び出し側が渡す(
VerifyIntegrityInputと同じ設計)。エラー写像(呼び出し側が対処を判断できるように):
share 受信者の CEK ライフサイクル
decrypt_shared_contentの復号成功時(= CEK の正しさが証明された時点)に、unwrap 済み CEK を受信者ローカルへ保存。以後、受信者も検証付き read で復号できるpin=N+1 / CEK=Nという復号不能な状態を作れる。CEK ストアはこの権威レコードから導出されるキャッシュ扱いRevokeShareOutput.reissued_envelopesで返却レビュー指摘への対応
署名済み mutation の再送 (Critical)
timestamp の鮮度チェックは「古い署名を無限に使い回せない」ことしか保証せず、5 分窓の中では同じ署名を何度でも通せた。書き込み系の 6 操作(
create/update/delete/invalidate/manage/revoke)は冪等でないため、これは重複ではなく状態の巻き戻しになる — 署名済みの旧 ciphertext 更新を正規の更新の後に再送すると、サーバはそれを「現在の head を親とする新しい操作」として commit し、古い内容が最新版になる。受理した mutation を記録し 2 度目を拒否する。識別子は署名対象メッセージと canonical principal から導出する(
SHA256(len(principal) ‖ principal ‖ signing_message)、長さ前置は境界を付け替えられないようにするため)。principal は「リクエスト署名の検証に使う鍵」で、自己完結型 key id ならその値、委譲 JWT ならaudである。署名は既に操作・リソース・timestamp・body に束縛されているため、ワイヤ形式に nonce を足す必要がない。保持期間は鮮度窓と同じでよい(窓の外の署名は記録が無くても鮮度チェックで落ちる)ので記録は無制限には育たない。read は冪等なので対象外。relay 側でも、署名を検証したうえで消費する。 relay は access policy を持たないので credential を転送して member に判断させる = 消費記録が member 側にしか残らず、同じ署名を relay へ送り直すと未見の member へ振り分けられて再適用できてしまう(実機 4 ノードで確認)。ただしこれは member 側チェックの代替ではなく、別の relay を使えば依然として通る。
消費記録を書く前に relay 自身が署名を暗号学的に検証する点が重要である(Round 4 で修正)。relay にできないのは policy を要する認可だけで、トークンの署名とリクエスト署名の検証は policy 無しで行える。検証を後回しにすると、request id の構成要素がすべて公開情報であるために、鍵を持たない第三者が正規リクエストの id を先に焼き潰せてしまう。
再送は認証失敗と別のエラー(409)にした。運用者にとって「偽造」と「正規リクエストの二重到達」は逆の意味を持ち、クライアントも "authentication failed" と言われると同じ署名で再試行し続けてしまうため。
取り消しが Token を失効させていなかった (High)
docs/design.mdは revoke でmin_valid_issued_atを進めて既発行 Token を一括失効すると定義しているが、SDK のrevoke_shareはその endpoint を呼んでいなかった。CEK ローテーションは復号を止めるだけで、取り消した相手の委譲 write Token は TTL 満了まで有効なまま残る。失効はローテーションの前に行う。逆順だと再暗号化から失効までの窓で取り消し済みの相手が書き込める。先に失効させれば後段の失敗は余分な失効を残すだけで、そのコスト(残存受信者への Token 再発行)は CEK ローテーションでどのみち必要になる。時刻ベースの一括失効なので残存受信者の Token も巻き添えになるため、
RevokeShareOutput.token_invalidated_atで失効時刻を返す。add-membersの count が署名対象外 (High)countは HTTP body 由来で実際の member 追加数を決めるのに、署名検証にはrequest_body = Noneが渡っていた。上限で clamp されるが1を上限まで増やす改ざんは成立していた。生の JSON は client ごとに揺れるため、両側で同じ canonical bytes を導出して署名対象に含める。並行 revoke の非トランザクション性 (High)
revoke は ACL・CEK・ローカル ciphertext・state node にまたがる load-modify-save で、どこにも version CAS が無い。
MonasControllerは gateway からArc共有され同時に呼ばれるため、2 つの revoke が同じ Share を読んで後勝ちで save し片方の受信者削除が消える(lost update)。content 単位の mutex で直列化した。これは完全な解決ではない: 本来は Share・CEK・ciphertext を 1 つの transactional CAS にまとめるべきで、現状のロックはプロセス内に閉じており複数 gateway プロセスからの並行 revoke は防げない。過大主張の訂正
実装の保証範囲より広い記述を
docs/design.mdから取り除いた。Round 2 — 4 本目のレビューへの対応
上記の各修正を再レビューして見つかった 5 件。いずれも実測で成立を確認してから直した。
再送防御が ECDSA malleability で完全に迂回できた (Critical,
d729fe9)上で入れた再送防御は、識別子を署名バイト列の digest にしていた。ECDSA は malleable で、有効な
(r, s)に対し(r, n−s)も同じメッセージ・同じ鍵で検証を通る。攻撃者は署名を捕捉してsを変換するだけで、別の digest を持つ「未見のリクエスト」として同じ mutation を再適用できた。p256 0.13 で再現し、original_valid=true / alternate_valid=true / different_bytes=true / digests_differ=trueを確認。識別子を署名対象メッセージ + signer から導出するよう変更した。署名の表現ではなく署名された内容を同一性の根拠にするので、どの符号化で来ても同じ ID になる。
なお high-S を拒否する案は採用しなかった。実装して既存テスト 4 件が落ち、調べたところ
monas-accountを含めこのリポジトリのどの署名器も low-S 正規化していない。S が high になるのは約半数なので、拒否すれば実クライアントの半分が壊れる。識別子をメッセージベースにした時点で署名表現の一意性に依存しなくなったため不要でもある。代わりに「両方の表現が検証を通る」ことをテストで固定した。replay cache の GC がクライアント時刻を使っていた (High,
d729fe9)保持期間の判定に、サーバの受理時刻ではなく署名されたリクエスト timestamp を渡していた。許容 clock skew の範囲でより新しい timestamp を先に提示すると、まだ鮮度窓の内にある記録を早期に evict でき、その後に古い署名を再提示すると
saturating_subによって記録を作り直せた。current_timestamp()を渡すよう変更。署名済み timestamp は鮮度検証にのみ使う。revoke の body が署名対象外だった (自分で発見、レビュー未指摘、
d729fe9)上の
add-membersと同じ穴が revoke にもあった。request_body = Noneを渡していたため、body の改ざんが署名検証を通っていた。Some(&update.signing_message())を渡すよう修正。同一秒に発行された Token が revoke を生き延びた (High,
0913529)is_token_validの cutoff が包含的(iat >= min_valid_issued_atを有効)だった。両者とも秒精度なので、revoke と同じ秒に発行された Token は revoke より前に発行された可能性があり、秒内順序は観測できない。等値を「revoke より前かもしれない」と読むのが唯一安全な解釈である。排他(>)に変更した。同じ秒の 後 に発行された Token も弾かれるが、誤る方向としてはこちらが正しい — 呼び出し側は 1 秒後に再取得すれば済む。min_valid_issued_at == 0は「一度も失効していない」として全受理を維持する(排他にするとiat=0を弾くため)。同じ規則がaccess_policy.rsとaccess_control.rsの 2 箇所にあり、両方修正。既存テスト 4 件が誤った意味論を固定していたのでこれも訂正した。read が CEK キャッシュを権威として使っていた (High,
8a6fc40)CEK は権威レコード(
SenderKeyPin)と派生キャッシュ(cek_store)の 2 箇所にあり、read はキャッシュ側を見ていた。並行する 2 つの share 受信ハンドラが interleave するとキャッシュだけが巻き戻り、read が古い CEK で復号を試みる。verify_and_decrypt_relay_readにcek: Option<_>を追加し、SDK 側が pin レコードの CEK を優先して渡すようにした。キャッシュも CAS 化する案より単純で、「キャッシュを権威にしない」という元の設計意図とも合う。Round 3 — 5 本目のレビューへの対応
再送防御の迂回が、リクエスト署名からトークンへ移っていただけだった (Critical)
Round 2 で「識別子を署名バイト列から署名対象メッセージ + signer へ移した」と書いたが、その signer は
token.as_str()— 生のトークン文字列だった。委譲 JWT の末尾セグメントは自身の header.payload に対する ECDSA 署名であり、これも malleable である。s → n−sを施すと claims もaudもリクエスト署名も変わらないまま検証を通り、request ID だけが変わって同一ノードで再適用できた。前ラウンドで塞いだはずの迂回が、一層上に残っていた。実測で確認: 両方の JWT 表現が同じ鍵・同じ signing input に対して有効、トークンのバイト列は異なり、結果の request ID も異なる。
識別子を canonical principal — リクエスト署名が実際に検証される鍵 — から導くよう変更した。
verify_request_signatureと同じ導出で、自己完結型 key id ならトークン自身、委譲 JWT ならaudである。いかなる署名バイト列も hash に入らない。membership を変える残り 2 アームが無防備だった (Critical, #54 側)
前ラウンドは指摘された 2 アームだけを直して止まっていた。
handle_sync_eventの 6 アームを全部列挙したところ、さらに 2 つが無認可でレコードを書き換えられた(ContentNetworkManagerRemovedは検証ゼロ、ContentDeletedは自己申告の node_id と照合していただけ)。詳細は #54 を参照。植え付け可能なレコードの verdict を最終判断にしていた (Critical, #54 側)
auth_verdict_is_authoritativeは常に false を返すようにした。最初の 1 通は照合すべき既存 membership が無いため受理せざるを得ず、その競争に勝った攻撃者の 403 で正規 caller の read を恒久的に止められた。ドキュメントの不整合 3 件
docs/design.mdが replay ID を「リクエスト署名そのものの digest」と説明したままだったReadContentFromStateNodeInputが「最新読みのときのみ単調性チェックが働く」と書いていたが、そのチェックは撤去済みで存在しないverify_and_decrypt_relay_readが「単調性チェックは SDK 側が被せる」と書いていたが、SDK はparentsを一切読んでいないRound 4 — relay の pre-consume poisoning (6 本目のレビュー)
自分が作り込んだ Critical。 relay は署名を検証する前に request id を消費記録へ書いていた。
request id は署名対象メッセージと principal から導かれ、その構成要素 — 操作・リソース・timestamp・body digest・
aud— はすべて公開情報である。秘密鍵も有効な署名も要らずに計算できる。したがって第三者が、対象ユーザーの正規リクエストの id をゴミ署名で先に焼き潰せた:audにした JWT)と、対象 content・現在秒でdeleteを送るRequestAlreadyAppliedになるdeleteは body が無いのでメッセージが完全に予測でき、毎秒繰り返せば対象ユーザーはこの relay 経由で何も更新できなくなる。relay は消費前に
verify_caller_signatureを通すようにした。relay にもこれは可能で、この検証は純粋に暗号学的 — トークン自身の署名と、トークンが指定する鍵に対するリクエスト署名の検証だけである。access policy を要するのは認可のみで、そちらは member に残る。これは前ラウンドの修正が生んだ退行である。 旧コメントは pre-consume を「攻撃者が既に持っている digest にしか影響しない — 署名を持っているなら自分で replay できる」と正当化していた。id が署名の digest だった頃には正しかった理屈だが、id を署名対象メッセージへ移した時点で前提が壊れており、その依存関係を見直さなかった。
design.md も 2 点訂正した。verify → record の順序とそれが必須である理由を明記し、ネットワーク全体の穴に対する「CRDT は同一操作の重複適用に耐える」という説明を撤回した。各レプリカは自分の head と author から新しい operation を作るので、この経路にその性質は当てはまらない — 巻き戻しは実際に成立し、#65 で追跡する。
過去ラウンドの対応
verify_integrityの CID 照合をクライアント選択の版に束縛: 従来は応答内の自己申告 version に対して照合しており、攻撃者が任意 Node + その CID を返すだけで検証が通っていた(自己整合性チェックに退化)。(content_id, recipient_key_id, key_epoch)を束縛。無意味だった自己申告sender_key_id入力は送信者公開鍵に置換。7880384にあり(本ブランチはそれを取り込んでいる)、docs/design.mdが CTR のままだった不整合も fix(state-node): relay reads to members with member-side authorization; fix read auth + phantom history #54 側で訂正した。認証まわりの修正(#60 / #61 同梱)
Closes #60, closes #61。(#55 は版の真正性が入るまで open のまま。issue #59 と合わせてクローズする)
#61: 委譲 JWT の PoP 署名対象が固定文字列
{iss}:{aud}:{jti}で、リクエストの新しさが署名に入っていなかった。その帳尻合わせの jti 単回消費が「委譲トークンを 1 個渡して TTL 内で再利用する」SDK 設計と矛盾し、履歴取得 → データ取得という通常の read すら成立しなかった。署名対象をトークン種別によらず統一し、jti 単回消費・nonce ストアを廃止、timestamp を構造的に必須化(サーバ時刻フォールバック廃止)。失効させる単位をトークンからリクエスト署名へ移したことで、トークンの再利用を妨げずに mutation の再送だけを止められるようになった。#60: JWT 署名検証がパース後構造体の再シリアライズに依存し、発行者の JSON フィールド順序が異なると正当なトークンを拒否する brittle な実装だった。受信したワイヤ上の
header.payloadセグメントを検証するverify_jwt_signature_wireに置換。テストツール側の canonical フィールド順ハックはこれにより不要となり削除した。e2e の 4 ノード標準化
create_content は creator ノードを意図的に member から除外し local CRDT を持たせないため、3 ノードでは member 定足数(
min_replication_factor = 3)を満たせず、非 member 経由の relay read も検証されない。e2e は常に 4 ノード(creator + 3 members)に統一し、非 member である creator 経由の read が data と version を返すことを明示的に assert する Step 2.6 を追加。あわせて
test-with-auth.shの既存バグを修正した。set -eと((TESTS_PASSED++))の組み合わせで最初の成功時に異常終了しており(後置インクリメントが 0 を返す)、このスクリプトのテストは 1 件も実行されていなかった。メンバー追加の期待値も誤っていた(作成ノードは非 member なので 403 が正常)。テスト
cargo test --workspace779 passed / FAILED した test binary 0(合計 passed だけ見ると打ち切られた binary を見落とすため、grep -c 'test result: FAILED'で確認している)cargo clippy --workspace --all-targets --deny warningsクリーン(r, n−s)を構成してAlreadyAppliedを assert) / 両方の署名表現が検証を通ること(バイト列を同一性の根拠にしない) / 順序が前後した提示が生きている記録を evict しないこと / revoke cutoff の差し替え / read が store より明示 CEK を優先すること /count差し替え / epoch と CEK の同時前進 / 旧形式レコードの互換 / CEK の Debug 非出力 / 並行 revoke の lost update / 失効失敗時の revoke 中断 / 未証明ピアの verdict で failover を止めない / ローカルレコード上の member の verdict は最終 / 出自によって候補リストを切り詰めない未解決として issue 化したもの
本 PR のスコープ外(いずれもプロトコル変更を要する)として明文化し、追跡先を作った。
🤖 Generated with Claude Code