Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
2312377
feat(example-ui): minimal Drive-like UI on monas-sdk/gateway
somasekimoto May 27, 2026
19481b8
feat(example-ui): make sidebar items actionable, drop dead UI
somasekimoto May 27, 2026
5c39c24
Merge branch 'main' into feat/example-ui-monas-drive
somasekimoto Jul 4, 2026
e7d096a
fix(sdk): sign state-node read requests via the account key
somasekimoto Jul 4, 2026
5b70d2e
fix(state-node): stop trusting phantom linear_history as local presence
somasekimoto Jul 4, 2026
9fec34d
test(example-ui): add real-stack Playwright E2E verification script
somasekimoto Jul 4, 2026
fb74565
fix(example-ui): address state-node ciphertext semantics in verify & …
somasekimoto Jul 4, 2026
4aaffc5
Merge branch 'fix/state-node-read-relay' into feat/example-ui-monas-d…
somasekimoto Jul 4, 2026
6bf53dd
Merge branch 'fix/state-node-read-relay' into feat/example-ui-monas-d…
somasekimoto Jul 5, 2026
4f8d08c
Merge branch 'fix/state-node-read-relay' into feat/example-ui-monas-d…
somasekimoto Jul 5, 2026
a52351f
Merge remote-tracking branch 'origin/feature/read-response-signing' i…
somasekimoto Jul 29, 2026
2974466
fix(content): serialize KeyId as base64url so shares can persist
somasekimoto Jul 29, 2026
9cd4041
feat(example-ui): wire the UI to the #54/#56 contract, incl. verified…
somasekimoto Jul 29, 2026
670ed3e
test(example-ui): cover the UI surface the protocol e2e never touches
somasekimoto Aug 4, 2026
3118711
fix(example-ui): make the share button honest and stop probing from p…
somasekimoto Aug 7, 2026
0a99d2f
Merge remote-tracking branch 'origin/main' into feat/example-ui-monas…
somasekimoto Aug 29, 2026
defd655
feat(example-ui): trim dead controls and cover the real stack with jo…
somasekimoto Aug 29, 2026
677cc1c
Merge remote-tracking branch 'origin/main' into feat/example-ui-monas…
somasekimoto Sep 2, 2026
7fa0050
fix(example-ui): only offer the verified read where it can succeed
somasekimoto Sep 2, 2026
597506c
fix(example-ui): stop "prove access" from locking the owner out
somasekimoto Sep 3, 2026
f024c3a
feat(state-node): make sync and redundancy intervals configurable
somasekimoto Sep 3, 2026
d616e7e
chore(state-node): instrument periodic tasks to locate the CPU freeze
somasekimoto Sep 5, 2026
731affd
fix(state-node): rate limiter allowed one request per 20 seconds, not…
somasekimoto Sep 5, 2026
2ca60ec
fix(state-node): refresh the peer store from Identify so a moved peer…
somasekimoto Sep 5, 2026
b093f30
fix(state-node): forget peer addresses that fail to dial
somasekimoto Sep 5, 2026
3bdb638
feat(state-node): announce the Cloud Map name via EXTERNAL_ADDRESS
somasekimoto Sep 5, 2026
276e43b
fix(state-node): do not replay missed periodic ticks in a burst
somasekimoto Sep 5, 2026
cedfce4
fix(state-node): keep a peer's /dns4/ name when it merely fails to co…
somasekimoto Sep 5, 2026
59763a0
feat(example-ui): cross-device sharing — share package export and Imp…
somasekimoto Sep 5, 2026
a15190d
test(example-ui): J-4 — two devices share a file through a pasted pac…
somasekimoto Sep 5, 2026
9962daf
feat(sdk): let a share recipient read the state node — token resource…
somasekimoto Sep 6, 2026
5ea50b9
fix(sdk): carry the share ACL to the new version id on update
somasekimoto Sep 6, 2026
315be17
feat(example-ui): a share recipient reads the owner's file from their…
somasekimoto Sep 6, 2026
7ebb382
fix(sdk): key the sender pin by Content Network, not by the owner's v…
somasekimoto Sep 6, 2026
52b19f6
feat(sdk): let a share recipient write, and let the owner pull that v…
somasekimoto Sep 6, 2026
4e9c6d7
feat(example-ui): edit a received file as a recipient; pull their ver…
somasekimoto Sep 6, 2026
eb2dc77
chore(example-ui): untrack the vite dep-optimizer cache
somasekimoto Sep 6, 2026
094cb34
feat(example-ui): one account per device, sync-status badge, honest i…
somasekimoto Sep 13, 2026
3f648b9
feat(state-node,sdk,example-ui): report how far a revoke's token cuto…
somasekimoto Sep 13, 2026
8c07300
fix(state-node): merge concurrent versions field by field so a write …
somasekimoto Sep 13, 2026
5e0fb9b
Merge pull request #75 from Monas-project/feat/policy-aware-merge
somasekimoto Sep 17, 2026
565f431
fix: complete PR75 review fixes with convergence and UI regressions
somasekimoto Sep 17, 2026
62b47fb
docs: remove internal PR75 verification notes
somasekimoto Sep 17, 2026
34bec8e
test: preserve signed timestamps when replay crosses a second
somasekimoto Sep 17, 2026
ce6b389
Merge pull request #76 from Monas-project/fix/pr75-review-followup
somasekimoto Sep 17, 2026
0314a21
Merge remote-tracking branch 'origin/main' into deploy/state-nodes-pr76
somasekimoto Sep 17, 2026
3c03b25
deploy: reset state-node stores for body-order format and lock image …
somasekimoto Sep 17, 2026
7e05c69
Merge remote-tracking branch 'origin/main' into feat/example-ui-monas…
somasekimoto Sep 27, 2026
6ad1d5a
Merge branch 'deploy/state-nodes-pr76' into feat/example-ui-monas-drive
somasekimoto Sep 27, 2026
d4ebf5e
fix(state-node): refuse deleted content by the Delete operation in it…
somasekimoto Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 22 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ flowchart TD

失効を先に行うのは、逆順だと「再暗号化してから失効するまでの窓」で取り消し済みの相手が書き込めてしまうためである。先に失効させておけば、後段が失敗してローカル状態を巻き戻しても、余分な失効が残るだけで害はない。

ただし失効はstate-nodeの**1メンバーにcommitされた時点で成功**であり、他メンバーへの伝播はベストエフォートのpush + 定期syncである。各メンバーの認可は自分の持つpolicyに対するローカル判断なので、境界がまだ届いていないメンバーは旧Tokenのwriteをその間受理する。取り消しはこれを待たない(書き手が取り消しを妨げられてはならない)。代わりにstate-nodeは届いた/届かなかったメンバーを返し(`InvalidateTokensOutcome`)、SDKは`RevokeShareOutput::token_invalidation_reach`として呼び出し側に見せる。そうして受理されたwriteが失効を巻き戻さないことは、CRDTのフィールド別マージ(§11)が保証する。

`min_valid_issued_at`は時刻ベースの一括失効なので、**残存する受信者のTokenも巻き添えで失効する**(判定は排他なので、取り消しと同じ秒に発行されたTokenも失効する)。呼び出し側は取り消し後に、残存受信者へ新しいKeyEnvelopeと新しいTokenの両方を配り直す必要がある。SDKは`RevokeShareOutput`で再発行KeyEnvelope(`reissued_envelopes`)と失効時刻(`token_invalidated_at`)の両方を返す。

取り消しはACL・CEK・ローカルciphertext・state node状態にまたがるload-modify-saveであり、そのどれにもversion CASが無い。したがって**同じcontentへの取り消しはcontent単位で直列化する**。並行させると、双方が同じShareを読んで後勝ちでsaveし片方の受信者削除が消える(lost update)、異なるCEKが同じ`key_epoch`として配られる、といった分岐が起こる。SDKのコントローラはgatewayから共有され複数リクエストから同時に呼ばれるため、これは理論上の話ではない。現状の直列化はプロセス内に閉じており、複数gatewayプロセスからの並行取り消しには対応しない — そこまで守るにはShare・CEK・ciphertextを1つのtransactional CASにまとめるか、state node側にCASを置く必要がある。
Expand Down Expand Up @@ -456,10 +458,28 @@ crsl-libはMonasのために設計されたCIDネイティブなDAG CRDTライ
他のノードへ同期
│
▼
コンフリクト時はLWW(Last-Write-Wins)でマージ
コンフリクト時はフィールド別にマージ(下記)
```

コンテンツ本体の意味的なマージは現時点で未実装であり、研究課題として位置づけられている。
並行して進んだ版(複数のhead)は、次のcommitか、headを読む操作の直前に1つのMergeノードへ畳まれる。畳み方はcrsl-libが利用側から受け取るマージポリシーで決まり(`Repo::with_merge_policy`)、state-nodeは版のpayloadを**フィールドごとに別の規則**で畳む:

| フィールド | 規則 | 理由 |
|---|---|---|
| コンテンツ本体(ciphertext) | `(body_updated_at, data)` の辞書順 max | 明示的な本文更新でのみ順序を進め、policy-only 更新と Merge は本文と順序をそのまま引き継ぐ。head 自体の timestamp や直近の親との差分では選ばない |
| `access_policy.min_valid_issued_at` | 全headの**max** | 失効境界は単調にしか進まない。timestampで選ぶと、境界を知らないノードが受理した並行writeが境界を巻き戻す |
| `access_policy.owner` / `content_id` | 不変(genesisで確定) | — |

payload全体をtimestampで丸ごと選ぶ(純粋なLWW)と、revokeと並行するwriteの一方が必ず消える — writeがtimestampで勝てばrevokeが消え、revokeが勝てば正当なwriteが消える。どちらも「競合していないフィールドの変更が、競合したフィールドの勝敗に巻き込まれる」のが原因で、フィールド別に畳めば両方残る。マージポリシーはプロセスに焼かれておりデータとともには流れないため、**同じContent Networkの全メンバーが同じ規則を持つ**必要がある。

このマージが決めるのは「Mergeノードに何を入れるか」であり、「そのheadを受理してよかったか」ではない。失効境界を知らないメンバーが旧Tokenで受理したwriteは、最新のwriteであれば本体として残る(境界は残るので以後は書けない)。それを弾くにはwriteが自分のTokenを持ち歩き、マージ時に畳んだ境界に対して検証する必要がある — ワイヤ形式の変更を伴うため別issueで追跡する。

`body_updated_at` は本文と同じ payload に保存する論理的な更新順序であり、別 DAG ではない。本文更新ではローカルの単調 timestamp と観測済みの順序 + 1 の大きい方を採る。policy-only 更新、再マージ、再起動で順序を失わず、同値時は ciphertext の辞書順で決定する。観測・マージ・payload の生成・commit は同じ repository lock 内で行う。

同期 export は operation と DAG ノードを payload・parents・genesis・metadata で対応付け、実ノードの timestamp を送る。履歴の位置対応は使用しない。`since_version` はそのノードと祖先を既知とみなし、兄弟枝を省かず親から順に送る。曖昧な対応は推測せずエラーにする。

保存・wire 形式の変更: `body_updated_at` は必須で、旧形式を 0 等へ暗黙補完しない。現行デモは顧客利用前のため、全 state-node を同時更新し、新しいストアから開始してコンテンツを再作成する必要がある。既存ストアを維持する場合の移行は未実装。データ削除やデプロイは本変更では行わない。

コンテンツ本体の意味的なマージ(同じフィールド内での両立)は現時点で未実装であり、研究課題として位置づけられている。

### 将来のCRDT拡張

Expand Down
127 changes: 127 additions & 0 deletions docs/revoke-write-bypass-investigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Revoke後の書き込みバイパス調査 — 旧delegated tokenの書き込みがstate nodeに受理される

- 日付: 2026-09-10
- 発見経緯: example-ui のデモ録画(revoke/削除/権限変更ジャーニー)の自動実行中に検出。2回連続で再現(cutoff伝播待ち10秒を入れても再現)
- 対象: `monas-ui-wt` worktree(branch `feat/example-ui-monas-drive`)+ demoノード node1〜node4.monas-demo.net
- 深刻度: High — revoke の完全性保証(revoke後は書き込めない)がクラスタ全体で成立していない。機密性は CEK ローテーションで維持されている(後述)

## 症状

再現ジャーニー(`.demo-permissions.mjs` Phase D):

1. Alice (owner, gateway :3000 → node1) がファイルを作成し、Bob (gateway :3001 → node2) に read+write で共有
2. Alice が Bob を revoke
- UI/SDK 上は成功: 「Invalidate prior tokens: Token cutoff advanced on the state-node first — before rotation, so the revoked recipient cannot write in between」「token cutoff 1789033235」
3. Bob の旧tokenでの「Edit contents」の **読み込みは拒否される**(Could not load contents)
4. しかし **10秒以上待った後の保存(update)は受理される**:
`PUT bafkrei… accepted: token gP61Fq… grants write`
5. Alice の verified read でネットワーク head が Bob の書き込み("this must not land")に置き換わったことを確認
- 証跡スクリーンショット: `/tmp/monas-demo2-videos/bug-head-after-revoked-write.png` ほか(bug-write-accepted-{alice,bob}.png)

## 根本原因

**revoke の失効境界(`min_valid_issued_at`)はメンバーノード間でベストエフォート伝播であり、かつ write の relay は認可拒否(403)を受けても次のメンバーへフェイルオーバーし続けるため、「まだ revoke を知らないメンバー」が1台でもあれば旧tokenの書き込みがそこで受理される。**

### 経路の詳細

関連コードはすべて `monas-state-node/src/`。

1. **revoke時の失効伝播に保証がない** — `application_service/state_node_service.rs` `invalidate_tokens_inner()`
- genesis を持つノードが CRDT の access_policy に新 `min_valid_issued_at` をコミット
- 他メンバーへは `push_operations` で送るが、失敗しても `tracing::warn!(… will rely on sync)` のみ。呼び出しは成功として返る
- 追いつきは periodic sync(30s間隔、`application_service/node.rs`。前回runの遅延でさらに遅れうる)任せ

2. **writeのrelayは403でも止まらない** — 同ファイル `relay_with_failover()`(757行付近)
- `auth_verdict_is_authoritative()` は **常に false**(190〜197行)
- コメントにある設計判断: owner-signed membership (issue #63) が入るまで、メンバーであることを証明できない候補の403は「偽403で書き込みを封じる攻撃」でありうるため、拒否を受けても次の候補へ続行し、全滅した場合のみ最後に拒否を返す(availability優先)
- 結果として、revoke済みを知っているメンバーが拒否しても、**cutoff未達のメンバーを探し当てた時点で書き込み成功**になる

3. **受理側の認可はローカルビュー依存** — `infrastructure/auth/ucan_adapter.rs` `authorize()` / `verify_auth_token()`
- 検証は `content_repo.get_access_policy()`(= 自ノードのCRDT headのaccess_policy)の `min_valid_issued_at` に対する `iat > cutoff`(排他)チェック
- ロジック自体は正しい。**ローカル判定は正しいがビューが古い**、分散整合性の問題

4. **受理された書き込みは正当なheadとして伝播する** — `infrastructure/crdt_repository.rs` `update_content()`
- access_policy: None は既存policyを保存し、CRDT headが進む。以後のsyncで全ノードに伝播し、owner の verified read にも「recipient with write access が編集した新しい版」として見える

### 前提が崩れるポイント

`relay_with_failover` の「本物のメンバーなら全員同じ判定を返すはず」という前提は、revoke直後のポリシー不一致ウィンドウでは成立しない。このウィンドウ中、フェイルオーバーは「一番古いビューを持つメンバーを探し当てる」動作になる。relay固有の問題でもなく、revoked recipient が悪意クライアントとして各メンバーへ直接試行しても同じ。

### 補足: readが拒否されたのはなぜか

Phase D で Bob の read(Edit contents の読み込み)が拒否されたのは認可ではなく **CEKローテーション** のため(revokeで新CEKに再暗号化済み、旧CEKでは復号不能)。read の認可も同じ弱点を持つはずで、cutoff未達メンバーからは旧tokenで旧版ciphertextを読める可能性がある。つまり:

- 機密性(新しい版を読めない): CEKローテーションで守られている
- 完全性(revoke後に書けない): **破れている** ← 本バグ

UI/SDKの表示「Token cutoff advanced on the state-node first — before rotation, so the revoked recipient cannot write in between」は単一ノード内でのみ真で、クラスタ全体では成り立っていない。

## 対策案

1. **短期** — `invalidate_tokens` の完了条件強化
- cutoff適用を全メンバー(少なくとも過半数)への同期適用成功で完了とする
- `push_operations` 失敗を warn で飲まず、部分成功を SDK に返し、UI の「cannot write in between」の断定表示をやめる
2. **中期** — write受理時の再検証
- メンバーがcommit前に quorum read で最新cutoffを確認する、または「revoke操作が自ノードheadに含まれているか」を検証してから受理
3. **設計** — issue #63(owner-signed membership)の実装
- メンバーであることを owner 署名で証明できれば `auth_verdict_is_authoritative` を復活でき、attestedメンバーの403で即打ち切りできる(偽403攻撃と両立)

## 再現手順

前提: node1〜node4 が `/node/register` 済み(空なら全createが "No available member nodes found (HTTP 500)" で落ちる。登録は
`curl -X POST https://nodeN.monas-demo.net/node/register -H 'Content-Type: application/json' -d '{"total_capacity":1000000}'`)。
ローカルスタック: vite :5173/:5174、gateway :3000(node1)/:3001(node2)、account :4002/:4003。

```
cd /Users/soma/monas/monas-ui-wt/example-ui
node .demo-permissions.mjs # Phase D で "BUG: revoked recipient's write was accepted" で停止
```

スクリプト: `example-ui/.demo-permissions.mjs`(untracked、録画付きジャーニー)。
Phase A〜C(write共有での編集、AlreadyShared確認、revoke→再shareによる権限ダウングレード/アップグレード)は通過し、Phase D の「revoke後の書き込み拒否」検証で停止する。

## 関連する既知の設計・issue

- issue #63: owner-signed membership(`auth_verdict_is_authoritative` 復活の前提)
- issue #61: request署名のリプレイ防御をtimestamp鮮度チェックに一本化(jti単回消費の廃止)
- bug #93: 非メンバーノードのrelay(1-hop制限)— 本バグのwrite relay経路そのもの
- `docs/` の該当設計メモがあれば追記のこと

## 未確定事項

- 受理したメンバーへの `push_operations` が実際に失敗していたのか、それとも periodic sync の遅延だけで説明できるのか(demoノードのログ未確認)
- read側のバイパス(cutoff未達メンバーからの旧版read)の実地再現は未実施

## 決定(2026-09-10)

検討した3案:

- A. 入場審査を quorum に — revoke は過半数メンバーへの適用成功で完了、delegated write の受理は他メンバーの最新ビューを過半数確認できたときのみ。q+q>k で「成功した revoke 後の旧トークン write は必ずどこかで 403」が成立する。LWW マージ自体は変えない(認可は commit 前の入口チェック)。代償はメンバー過半数に届かないときの delegated write / revoke の可用性。
- B. 自己証明 op + policy-aware head — update op に token(iat・capability の証明)を埋め、head 導出を「op 集合内の最大 cutoff に対して認可が成立する op だけを LWW で畳む」に変える。cutoff は単調なので収束性は保たれる。crsl-lib の head 計算・node_verification・SDK の verified read まで波及する別 PR 規模。
- C. warn のみ — 保証は与えず、状態を正直に報告する。

**C を採用**(PR #47 内で完結させるため)。B は別 issue として起票する。

### 追記: マージ規則の欠陥(C の後に判明)

C の実装後、A/B/C のどれとも別に、**CRDT のマージ規則そのものが revoke を消す**ことが分かった。access_policy は版ノードの payload に本体と同居しており、crsl-lib の Merge は payload を timestamp で丸ごと選ぶ(純 LWW)。よって revoke と並行する write が timestamp で勝つと、Merge ノードの policy は write 側の古い `min_valid_issued_at` になり、失効境界が巻き戻る — 「窓の中で1回書ける」ではなく「窓の中で1回書ければ以後も書ける」だった。逆(revoke が timestamp で勝つ)では、本体を変えていない revoke ノードが並行する正当な write を消す。

これは A/B の代替ではなく前提で、分断や sync 遅延など「並行 head が生じる状況」すべてで起きる。修正は「policy を別 DAG に出す」のではなく、同じ payload のままフィールド別に畳む(本体は本体を変えた head の中で LWW、`min_valid_issued_at` は max)。crsl-lib に利用側からマージポリシーを注入する口(`Repo::with_merge_policy`)と、head を読む前に並行 head を畳む口(`Repo::merge_heads`)を足し、state-node で `MonasMergePolicy` を注入する。詳細は design.md §11。

- crsl-lib: PR (feat/injectable-merge-policy)
- monas: PR (feat/policy-aware-merge → feat/example-ui-monas-drive)

残るのは「窓の中の write が1回本体として残る」だけで、それは B で閉じる。

### C で入れたもの

- `monas-state-node` `invalidate_tokens_inner`: 各メンバーへの `push_operations` を1回リトライし、届いた/届かなかったメンバーを `InvalidateTokensOutcome { new_min_valid_issued_at, notified_members, unreached_members, relayed }` で返す。挙動(revoke は待たない・失敗しない)は変えない。relay 経路では伝播情報は「不明」(`relayed: true`)。
- HTTP `POST /content/:id/access/invalidate` レスポンスに `notified_members` / `unreached_members` / `relayed` を**常に**含める(旧ノードとの判別のため `skip_serializing_if` を使わない)。
- `monas-sdk` `RevokeShareOutput.token_invalidation_reach`(旧ノード応答では `None` = 不明。空リストを「全員到達」と誤読しない)。
- example-ui: revoke の Protocol activity に「Cutoff propagation」ステップを追加し、全員到達 / N 台未到達(+~30 s の窓の説明) / relay で不明 / 旧ノードで不明 を出し分け。未到達・不明のときはトーストでも警告。「cannot write in between」という断定文言は削除。
- テスト: state-node 単体(未到達メンバーの報告・リトライ回数・全員到達)、SDK 単体(旧/新レスポンスの判別)。

### 残課題

- B の起票(`docs/` にこのメモをリンク)。
- demo ノード(node1〜4)は旧バイナリのため、UI 上は「reach unknown」表示になる。新バイナリのデプロイ後に `.demo-permissions.mjs` Phase D を再実行し、node3 等を落とした状態で `unreached_members` が出ることを確認する。
11 changes: 11 additions & 0 deletions example-ui/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Dev-server proxy targets (used by vite.config.ts).
# These point the same-origin /api and /account-api paths at your local
# services so the browser never hits CORS during local development.
#
# Copy to `.env` and edit if your Docker maps different ports.

# monas-gateway — the main backend the UI calls (embeds monas-sdk).
VITE_GATEWAY_TARGET=http://127.0.0.1:3000

# monas-account — only used by "create account" to seed the P-256 signing key.
VITE_ACCOUNT_TARGET=http://127.0.0.1:4002
Loading
Loading