diff --git a/Cargo.lock b/Cargo.lock index 4c87ae9..4e81e90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,7 +872,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1039,7 +1039,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crsl-lib" version = "0.1.0" -source = "git+https://github.com/Monas-project/crsl-lib?rev=e13b86ce6d6a9c27ebd01a9b4fe82d6bc18f8a01#e13b86ce6d6a9c27ebd01a9b4fe82d6bc18f8a01" +source = "git+https://github.com/Monas-project/crsl-lib?rev=0fbabbf#0fbabbf0c054444dc284f7b0f90fc8ae56a52798" dependencies = [ "bincode 2.0.1", "cid", diff --git a/docs/design.md b/docs/design.md index dc6ad0f..5b31767 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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を置く必要がある。 @@ -456,10 +458,22 @@ 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) | LWW — ただし**親から本体を変えたheadの中で**timestamp最大 | 最後の書き込みが正。policyだけを進めたhead(revoke等)は本体を親からコピーしているだけで「書いて」いないので、並行する本物の書き込みに勝ってはならない | +| `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で追跡する。 + +コンテンツ本体の意味的なマージ(同じフィールド内での両立)は現時点で未実装であり、研究課題として位置づけられている。 ### 将来のCRDT拡張 diff --git a/docs/revoke-write-bypass-investigation.md b/docs/revoke-write-bypass-investigation.md new file mode 100644 index 0000000..468316f --- /dev/null +++ b/docs/revoke-write-bypass-investigation.md @@ -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` が出ることを確認する。 diff --git a/example-ui/README.md b/example-ui/README.md index 3221ec4..fd642b8 100644 --- a/example-ui/README.md +++ b/example-ui/README.md @@ -151,13 +151,13 @@ so the suite cannot go non-deterministic on a model update. | Action | Gateway call | SDK model | | ----------------- | ------------------------------------- | --------------------------------- | -| Create identity | `POST /keypair` | `GenerateKeypair{Input,Output}` | +| Create account | `POST /account-api/accounts` | (monas-account) | | New file / Upload | `POST /content` | `CreateContent{Input,Output}` | | Open / preview | `GET /content/{id}` | `GetContent{Input,Output}` | | Edit contents | `PUT /content/{id}` | `UpdateContent{Input,Output}` | | Delete | `DELETE /content/{id}` | `DeleteContent{Input,Output}` | | Share | `POST /share` | `ShareContent{Input,Output}` | -| Prove access | `POST /share/decrypt` | `DecryptSharedContent{Input,Out}` | +| Import shared | `POST /share/decrypt` | `DecryptSharedContent{Input,Out}` | | Revoke | `POST /share/revoke` | `RevokeShare{Input,Output}` | | Verified read | `POST /state/read` | `ReadContentFromStateNode{In,Out}`| | (history/version) | `POST /state/history`, `/state/...` | `state` models | @@ -197,22 +197,46 @@ Notes on the contract: ## Accounts & the signing key -Create your account from the UI: open the identity chip (top-right) → **Create -account**. With *Register as signing account* checked, the UI sends -`POST /accounts` to **monas-account** (via the `/account-api` proxy), which -registers a **P-256** key. The SDK uses that key to sign state-node requests for -**create / edit / delete**. - -This is needed because the gateway's `/keypair` is stateless — it returns a -fresh keypair (handy for share recipients) but does **not** register a signing -key. So: - -- **Create account** (signing) → `POST /account-api/accounts` → monas-account. -- **Add identity** (keypair-only, e.g. a share recipient) → `POST /api/keypair` - → gateway. - -Sharing (`/share`, `/share/decrypt`, `/share/revoke`) only uses the keypairs the -UI holds, so a recipient identity doesn't need to be a signing account. +A device has **one account**. Open the identity chip (top-right) → **Create +account**: the UI sends `POST /accounts` to **monas-account** (via the +`/account-api` proxy), which generates and keeps a **P-256** key. The SDK signs +every state-node request with that key (create / edit / delete, and a +recipient's reads and writes under a delegated token), and it is the key +other people share *to* — a delegated token's audience is the recipient's +signing key, so a share addressed to any other key could open its envelope but +never read or write the state node. + +monas-account holds exactly one key, which is why the dialog does not offer a +second account or a keypair-only identity: creating another would overwrite +the key monas-account signs with and silently orphan the first. To start over, +remove the account and create a new one (content created under the old key can +then no longer be updated or deleted from this device). + +## Is my copy the newest? (sync status) + +Every synced row carries a sync badge, and the preview repeats it as a one-line +status: + +| Badge | Meaning | +| -------------------- | ----------------------------------------------------------------------- | +| `up to date` | the Content Network head is the version this device holds | +| `newer on network` | someone else wrote after this device's last save/import | +| `synced` | on a Content Network, head not compared yet | +| `can't reach network`| the last check failed (node down, token voided, …); hover for the error | + +The comparison is a **verified read** of the head (`POST /state/read` with +`accept_any_version`): the plaintext is re-derived and re-addressed, and the +resulting plain id is compared with the one this device holds — the owner's +local version, or for a recipient the version it last wrote (else the one the +envelope carried). It runs when a file is opened, on *Check now* / *Read from +state-node*, and in a background sweep every 30 s. When behind, the owner's +*Pull & edit* adopts the head into the local copy first (`POST /state/pull`); +a write-share recipient's *Edit contents* already starts from the head. + +*Verify integrity* is related but narrower: it byte-compares the ciphertext +this gateway stored with the head's. "Not the head" there is the same +*newer on network* condition, not a corruption; the reason string from the SDK +is shown under the badge. ## Sharing with someone on another device diff --git a/example-ui/src/App.tsx b/example-ui/src/App.tsx index ffd6304..2b4d892 100644 --- a/example-ui/src/App.tsx +++ b/example-ui/src/App.tsx @@ -23,6 +23,7 @@ import { folderPath, } from "./store/registry"; import { useIdentities, getActive } from "./store/identity"; +import { checkNetworkHead, checkAllNetworkHeads } from "./store/sync"; import { probeGateway } from "./api/http"; import { uuid, @@ -73,7 +74,7 @@ function mimeFromName(name: string): string { export default function App() { const entries = useEntries(); - const { identities, activeLabel } = useIdentities(); + const { identities } = useIdentities(); const active = getActive(); const [path, setPath] = useState("/"); @@ -96,6 +97,27 @@ export default function App() { return () => clearInterval(t); }, [poll]); + // Sync-status sweep: a verified read of every synced file's head, shortly + // after load and then every 30 s while the gateway is up. Sequential and + // slow on purpose — the state node rate-limits — but it is what lets the + // list say "newer on network" without anyone pressing a button. The first + // run is deferred a few seconds so it never races the initial render (and + // the tests' localStorage seeding, which reloads right after writing). + useEffect(() => { + if (!gatewayUp) return; + let stopped = false; + const sweep = async () => { + if (!stopped) await checkAllNetworkHeads(); + }; + const first = setTimeout(sweep, 5_000); + const t = setInterval(sweep, 30_000); + return () => { + stopped = true; + clearTimeout(first); + clearInterval(t); + }; + }, [gatewayUp]); + // ---- pipeline plumbing ---------------------------------------------- const upsertRun = useCallback((run: RunView) => { setRuns((prev) => { @@ -143,6 +165,14 @@ export default function App() { const liveEntry = (id: string) => allEntries().find((e) => e.id === id); + // Right after this device wrote a version, it *is* the head — record that + // so the row reads "up to date" without a round trip. The Node CID is not + // known here (writes return plain ids); the next check fills it in. + const ownHead = (localId: string) => ({ + networkHead: { localId, checkedAt: Date.now() }, + networkCheckError: undefined, + }); + // ---- actions -------------------------------------------------------- const createFromBytes = async ( name: string, @@ -168,6 +198,7 @@ export default function App() { syncedToStateNode: !!created.remote_content_id, versionCount: 1, shares: [], + ...(created.remote_content_id ? ownHead(created.content_id) : {}), }); // Drop back to folder browsing so the new file is visible at `path` // (it wouldn't match an active filter view yet). @@ -248,9 +279,12 @@ export default function App() { updateEntry(entry.id, { localContentId: pulled.local_content_id, versionCount: entry.versionCount + 1, + ...ownHead(pulled.local_content_id), }); entry = { ...entry, localContentId: pulled.local_content_id }; pushToast("Pulled a newer version written by a recipient into your copy", "info"); + } else { + updateEntry(entry.id, ownHead(pulled.local_content_id)); } text = pulled.content; } else { @@ -281,6 +315,7 @@ export default function App() { sizeBytes, versionCount: entry.versionCount + 1, receivedShare: { ...entry.receivedShare!, writtenVersionId: upd.version_id }, + ...ownHead(upd.version_id), }); pushToast(`“${entry.name}” updated on the owner's Content Network`, "success"); } else { @@ -308,6 +343,7 @@ export default function App() { sizeBytes, versionCount: entry.versionCount + 1, ...(renamed ? { name: renamed } : {}), + ...(entry.syncedToStateNode ? ownHead(upd.version_id) : {}), }); pushToast(`“${renamed || entry.name}” updated`, "success"); } else { @@ -360,6 +396,7 @@ export default function App() { if (ok && ctx.get) { const g = ctx.get as { content: string }; setModal({ type: "preview", entry, contentB64Url: g.content }); + void checkNetworkHead(entry); } else { pushToast("Could not open file", "error"); } @@ -435,6 +472,9 @@ export default function App() { setPath("/"); pushToast(`“${pkg.name}” unwrapped and added to your Drive`, "success"); setModal({ type: "preview", entry, contentB64Url: res.content }); + // The envelope carries the version the owner shared; whether that is + // still the head only the network knows. + void checkNetworkHead(entry); }; const handleShare = async (entry: Entry, input: ShareInput) => { @@ -450,7 +490,6 @@ export default function App() { recipientPublicKeyB64Url: input.recipientPublicKeyB64Url, recipientLabel: input.recipientLabel, permissions: input.permissions, - recipientPrivateKeyB64Url: input.recipientPrivateKeyB64Url, }); const { ok, ctx } = await run("Share", entry.name, specs); if (ok && ctx.share) { @@ -513,6 +552,9 @@ export default function App() { updateEntry(entry.id, { shares, ...(moved ? { localContentId: r.content_id, versionCount: entry.versionCount + 1 } : {}), + // The SDK just re-encrypted under the new CEK and wrote that as the + // head, so whatever id the local record now has is the network head. + ...(r && entry.syncedToStateNode ? ownHead(r.content_id) : {}), }); if (r?.head_pull_error) { @@ -532,6 +574,22 @@ export default function App() { // that is a real problem for the demo, so don't report it as success. stale > 0 ? "error" : "success", ); + // The cutoff is enforced per member, on each member's own copy of the + // policy; a member it did not reach still accepts writes under the + // voided tokens until its next sync. Say so — "revoked" alone would + // overstate what just happened. + const reach = r?.token_invalidation_reach; + if (reach?.relayed) { + pushToast( + "The revoke was relayed to a member node; which members enforce the cutoff yet is not known from here. Writes under the old token may land on members that have not synced.", + "error", + ); + } else if (reach && reach.unreached_members.length > 0) { + pushToast( + `Cutoff did not reach ${reach.unreached_members.length} member node(s). Until they sync (~30 s), a write under the revoked token can still land there.`, + "error", + ); + } if (stale > 0) { pushToast( `${stale} other recipient(s) got no reissued envelope and can no longer decrypt`, @@ -730,8 +788,6 @@ export default function App() { {modal.type === "share" && shareEntry && ( handleEditOpen(e)} onClose={() => setModal({ type: "none" })} /> )} diff --git a/example-ui/src/api/share.ts b/example-ui/src/api/share.ts index 7688d95..39b8388 100644 --- a/example-ui/src/api/share.ts +++ b/example-ui/src/api/share.ts @@ -83,6 +83,23 @@ export interface RevokeShareOutput { * local copy — revocation must not be blockable by a writer — and this * says why, so the caller knows the head may have been lost. */ head_pull_error?: string; + /** How far the token cutoff got. A state-node member authorizes against + * its own copy of the policy, so a member the cutoff has not reached + * keeps accepting writes under the voided tokens until its next sync. + * The revoke does not wait for that (a writer must not be able to block + * it); this is how the UI tells "revoked everywhere" from "revoked, N + * members still to hear". Absent when no state node was involved. */ + token_invalidation_reach?: TokenInvalidationReach; +} + +export interface TokenInvalidationReach { + /** Members that took the new cutoff during the call. */ + notified_members: string[]; + /** Members the push did not reach, with the last error. Empty = every + * known member has it (unless `relayed`). */ + unreached_members: { node_id: string; error: string }[]; + /** The contacted node relayed the request; the two lists are unknown. */ + relayed: boolean; } export function revokeShare(input: { diff --git a/example-ui/src/components/FileBrowser.tsx b/example-ui/src/components/FileBrowser.tsx index a5cb7a9..67ac50c 100644 --- a/example-ui/src/components/FileBrowser.tsx +++ b/example-ui/src/components/FileBrowser.tsx @@ -16,6 +16,30 @@ import { Cloud, Inbox, } from "./icons"; +import { syncStatusOf, describeSync } from "../store/sync"; + +// Where this copy stands against the Content Network head. One badge, four +// states, so a glance at the list says whether "Open" would show the newest +// version or whether someone (the owner, or a writer we shared with) has +// moved the file on since. The check itself runs on open and on a timer in +// App; this only renders what the entry records. +export function SyncBadge({ entry }: { entry: Entry }) { + const s = syncStatusOf(entry); + const d = describeSync(s); + const cls = + s.kind === "current" + ? "synced" + : s.kind === "behind" + ? "behind" + : s.kind === "unreachable" + ? "invalid" + : ""; + return ( + + {s.kind === "checking" ? : } {d.label} + + ); +} function FileTypeIcon({ entry }: { entry: Entry }) { if (entry.kind === "folder") @@ -78,15 +102,12 @@ function Row({ )} {isFile && !received && - (entry.syncedToStateNode ? ( - - synced - - ) : ( + !entry.syncedToStateNode && ( local - ))} + )} + {isFile && entry.syncedToStateNode && } {isFile && entry.shares.length > 0 && ( {entry.shares.length} diff --git a/example-ui/src/components/IdentityModal.tsx b/example-ui/src/components/IdentityModal.tsx index a81b06a..537d9cb 100644 --- a/example-ui/src/components/IdentityModal.tsx +++ b/example-ui/src/components/IdentityModal.tsx @@ -1,49 +1,52 @@ import { useState } from "react"; import { Modal } from "./Modal"; -import { Key, Plus, Check, Trash, Copy } from "./icons"; -import { generateKeypair, createSigningAccount } from "../api/account"; -import { - useIdentities, - addIdentity, - setActive, - removeIdentity, -} from "../store/identity"; +import { Key, Plus, Trash, Copy } from "./icons"; +import { createSigningAccount } from "../api/account"; +import { useIdentities, addIdentity, removeIdentity } from "../store/identity"; import { pushToast } from "./Toast"; import { copyText } from "../sharePackage"; +// One device, one account. monas-account holds exactly one signing key: it is +// what the SDK signs every state-node request with, and the audience of every +// delegated token a share package brings to this device. So there is nothing +// to "switch" between — a second Create would overwrite the key in +// monas-account and silently orphan the first — and a keypair-only identity +// (the gateway's stateless /keypair) can open an envelope but can never read +// or write the state node. The dialog therefore offers exactly one thing: +// this device's account, and a way to replace it. export function IdentityModal({ onClose }: { onClose: () => void }) { - const { identities, activeLabel } = useIdentities(); - const hasSigningAccount = identities.some((i) => i.isSigningAccount); + const { identities } = useIdentities(); + const account = identities.find((i) => i.isSigningAccount) ?? null; + // Identities minted before the dialog was reduced to one account. They + // still open envelopes addressed to them, so they stay removable, not hidden. + const legacy = identities.filter((i) => !i.isSigningAccount); const [label, setLabel] = useState(""); - const [asSigning, setAsSigning] = useState(!hasSigningAccount); const [busy, setBusy] = useState(false); const create = async () => { - const name = label.trim() || `account-${identities.length + 1}`; + const name = label.trim() || "me"; setBusy(true); try { // Always P-256: signing requires it, and the HPKE share envelopes are // DHKEM(P-256) — any other curve would mint a key that cannot receive // a share, a dead end this dialog should not offer. - const res = asSigning - ? await createSigningAccount("secp256r1") - : await generateKeypair("secp256r1"); + const res = await createSigningAccount("secp256r1"); + // monas-account now signs with the new key, so a previous account entry + // would only claim an authority it no longer has. Drop it. + for (const old of identities) if (old.isSigningAccount) removeIdentity(old.label); addIdentity( { label: name, keyType: res.key_type, publicKeyB64Url: res.public_key, privateKeyB64Url: res.private_key, - isSigningAccount: asSigning, + isSigningAccount: true, }, - identities.length === 0 || asSigning, + true, ); setLabel(""); - pushToast( - asSigning ? `Signing account “${name}” created` : `Identity “${name}” created`, - "success", - ); + pushToast(`Account “${name}” created`, "success"); } catch (e) { pushToast((e as Error).message, "error"); } finally { @@ -64,119 +67,121 @@ export function IdentityModal({ onClose }: { onClose: () => void }) { } }; + const renderRow = (id: { label: string; keyType: string; publicKeyB64Url: string }, signing: boolean) => ( +
+ {id.label.slice(0, 2).toUpperCase()} +
+
+ {id.label}{" "} + {signing ? ( + + signing + + ) : ( + + keypair only + + )} +
+
+ {id.keyType} · pub {id.publicKeyB64Url.slice(0, 22)}… +
+ {revealed === id.label && ( +