Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9ad680e
fix(sdk): sign state-node read requests via the account key
somasekimoto Jul 4, 2026
a2c5de6
fix(state-node): stop trusting phantom linear_history as local presence
somasekimoto Jul 4, 2026
7d7fbe8
feat(state-node): relay reads to member nodes with member-side author…
somasekimoto Jul 4, 2026
edd429f
fix(sdk): verify integrity against local ciphertext; sync revoke to s…
somasekimoto Jul 4, 2026
8f3870b
style(state-node): alias relay read reply sender to satisfy clippy ty…
somasekimoto Jul 5, 2026
207f0af
fix(state-node): harden relayed reads — typed verdicts, fail-closed a…
somasekimoto Jul 5, 2026
7880384
fix(security): bind read signatures to content_id; replace AES-CTR wi…
somasekimoto Jul 9, 2026
2ece36b
chore(ci): fix clippy for_kv_map lints (Rust 1.97) and sled test lock…
somasekimoto Jul 9, 2026
361bcc6
chore(ci): fix remaining Rust 1.97 clippy lint (useless_borrows_in_fo…
somasekimoto Jul 9, 2026
532b293
Merge remote-tracking branch 'origin/main' into fix/state-node-read-r…
somasekimoto Jul 25, 2026
c5afba0
fix(state-node): deny reads when no access policy is present (fail-cl…
somasekimoto Jul 28, 2026
dd2bdce
docs(design): correct content encryption to AES-256-GCM (matches impl…
somasekimoto Jul 28, 2026
9e05c59
fix(state-node): stop trusting unproven DHT peers as members
somasekimoto Jul 28, 2026
f66e648
fix(state-node): drop the unproven-candidate cap
somasekimoto Jul 28, 2026
8201797
fix(state-node): authenticate the publisher of membership events
somasekimoto Jul 28, 2026
746d1ad
fix(state-node): authorize every membership-changing event, and stop …
somasekimoto Jul 28, 2026
e1c8a30
docs(design): list every membership event whose publisher is authorized
somasekimoto Jul 29, 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
2 changes: 0 additions & 2 deletions Cargo.lock

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

39 changes: 37 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ presentation/ Axum HTTP API (port: 4002)

| 機能 | 実装 |
|------|------|
| コンテンツ暗号化 | AES-256-CTR(IVランダム生成) |
| コンテンツ暗号化 | AES-256-GCM(AEAD、12バイトランダムnonce。保存形式は `nonce \|\| ciphertext \|\| tag`) |
| 鍵生成・管理 | CEK(Content Encryption Key)の生成・保存・削除 |
| コンテンツアドレッシング | SHA-256によるCID生成 |
| 鍵共有 | HPKE(RFC 9180、DH-KEM P-256)によるCEKのラップ |
Expand All @@ -169,7 +169,7 @@ presentation/ Axum HTTP API (port: 4002)
domain/ Content, ContentId, Share, Permission, KeyEnvelope
application/ ContentService(CRUD + fetch + reencrypt)
ShareService(grant, revoke, unwrap_cek)
infrastructure/ AES-256-CTR, HPKE, Sled, monas-filesync
infrastructure/ AES-256-GCM, HPKE, Sled, monas-filesync
presentation/ Axum HTTP API (port: 4001)
```

Expand Down Expand Up @@ -344,6 +344,41 @@ Token失効は`min_valid_issued_at`による時刻ベースで管理される。

ネットワークはビザンチン耐性を前提として設計されている。悪意のあるノードが参加してもコンテンツの暗号化によって内容の漏洩は防がれる。XOR距離によるランダムなノード選択が一定の保護を提供する。

#### relay先の信頼度

コンテンツを保持しないノードがリクエストを受けた場合、実際のmemberへrelayする。このときのrelay先候補には**由来の異なる2種類**があり、扱いを分ける必要がある。

| 由来 | 内容 | 扱い |
|---|---|---|
| ローカルの`ContentNetwork`レコード | 自ノードをmemberとして名指しした`ContentCreated` / `ContentNetworkManagerAdded`イベント由来。**発行元は認証済みだが、member集合そのものは発行元の主張** | ローカルレコード |
| DHTの近傍探索 | `sha256(content_id)`に近いというだけ。コンテンツとの関連は何も示されていない | 未証明 |

ここで「ローカルレコード」は**owner署名によるattestationではない**。イベントにowner署名は無く、member集合は発行元の自己申告である。検証されているのは**発行元**の方で、Gossipsubを`MessageAuthenticity::Signed` + `ValidationMode::Strict`で運用しているため著者フィールドは必須かつ署名検証済みであり、これを次の2点に束縛している。

- `ContentCreated`は、名乗っている`creator_node_id`本人からの発行でなければ拒否する
- member集合を変える`ContentNetworkManagerAdded` / `ContentNetworkManagerRemoved`は、こちらが保持しているそのネットワークの既存memberからの発行でなければ拒否する
- `ContentDeleted`は上記に加えて、名乗っている`deleted_by_node_id`本人からの発行であることも確認する。ただしこのイベントは発行元を*自分で*名乗るので、その照合だけでは「認証済みなら誰でも通る」ことにしかならない。ローカルレコードを消せるのは既存memberだけである
- `ContentUpdated`は、名乗っている`updated_node_id`本人からの発行でなければ拒否する

なお束縛に使うのはGossipsubの`Message::source`(**発行元**)であって`propagation_source`(直前の転送元)ではない。meshは多段転送するため、転送元で判定すると正規の多段配送を落としつつ偽装を通してしまう。

**候補が返した401/403は、出自によらず早期打ち切りの根拠にはしない。** 権威にすると、DHTキーの近くにPeer IDを置いた1台が403を返すだけであらゆるread/writeを止められてしまう(可用性への攻撃)。また正規のmemberであっても、policyの複製が終わっていない部分同期状態なら403を返し得るため、健全なレプリカへのfailoverを潰さないためにも継続が必要である。ただし答えとしては保持し、他の候補から何も得られなければそれを返す。

ローカルレコード由来のmemberについては、以前は「実policyに対する評価結果だから」として打ち切っていた。**これは撤回した。** レコード自体が最初の1通で植え付けられる(照合すべき既存membershipが無いため受理せざるを得ない)以上、その競争に勝った攻撃者は候補リストに載り、その403で正規callerのreadを恒久的に止められる — 未証明ピアについて防いでいるのと同じ攻撃が、ローカルレコード経路でも成立してしまう。早期打ち切りを戻せるのはowner署名付きmembership(#63)が入ってからである。継続のコストは「本当に拒否された場合に残り候補ぶんの往復が増える」ことに限られ、可用性側に倒すのが正しい方向である(callerはどのみち拒否され、それが少し遅くなるだけ)。

一方で、**credentialは未証明の候補へ転送してよい。** relayはcallerのtokenとリクエスト署名をそのまま転送するが、これは設計どおりであり、認可判断はmember側が実policyに対して行う。転送しなければmember側で認可できない。

これが安全なのは、認可が**Proof of Possession**だからである。tokenは自己完結型の鍵ID(公開鍵そのもの)か委譲JWTで、後者の`aud`(宛先)もまた自己完結型の鍵IDである。リクエスト署名は**その`aud`の鍵に対して**検証されるため、tokenと署名の両方を傍受した相手も、`aud`の秘密鍵を持たない以上、新しいリクエストを作れない。傍受した署名そのものも操作・リソース・body digest・timestampに束縛されており、mutationについてはさらに使い切りである。したがって未証明の候補へ渡っても、その相手ができるのは「同じreadを鮮度窓の内に再実行する」ことに限られる — readは冪等で、しかもその候補はrelay経由で既に暗号文を見ているため、新たに得られる情報はない。

候補リストを未証明だからといって切り詰めることはしない。切り詰めれば正当なmemberへのfailoverが減って可用性が落ちる一方、DHT距離順で先頭に来る相手は上限があろうと credential を受け取るため、機密性は改善しないからである。

残る課題は**member discoveryそのもの**である。発行元の認証によって「無関係なノードが勝手にレコードを植え付ける」ことは防げるが、次の2つの穴はプロトコル変更(owner署名付きmembership)でしか塞げず、未実装である。

- そのcontentについて**最初の**レコードは、照合すべき既存membershipが無いため受け入れざるを得ない
- 認証済みのmemberであれば、任意のmember集合を主張できる

したがって「このノードが本当にこのcontentのmemberである」ことを暗号学的に確認する仕組みは依然として無く、ローカルレコード / 未証明の区別はそこへ至るまでの近似にとどまる。

---

## 11. CRSLとCRDT
Expand Down
2 changes: 0 additions & 2 deletions monas-content/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ path = "src/lib.rs"
[dependencies]
monas-filesync = { path = "../monas-filesync", optional = true }
aes-gcm = "0.10.3"
aes = "0.8"
ctr = "0.9"
rand_core = { version = "0.6.4", features = ["std"] }
rand = "0.8.5"
chrono = { version = "0.4.40", features = ["serde"] }
Expand Down
64 changes: 64 additions & 0 deletions monas-content/src/application_service/content_service/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,29 @@ where
})
}

/// ローカルに保存された「暗号化済み」バイト列を取得する。
///
/// State Node 整合性検証用途:State Node が保持するのは SDK が送信した
/// 暗号文なので、復号せずローカル暗号文とバイト比較することで
/// 「State Node が改ざんされていない同一の暗号文を保持しているか」を
/// 確認できる。
pub fn fetch_encrypted(&self, content_id: ContentId) -> Result<Vec<u8>, FetchError> {
let content = self
.content_repository
.find_by_id(&content_id)
.map_err(FetchError::Repository)?
.ok_or(FetchError::NotFound)?;

if content.is_deleted() {
return Err(FetchError::Deleted);
}

content
.encrypted_content()
.cloned()
.ok_or(FetchError::NotFound)
}

/// 外部でアンラップされた CEK と暗号化済みコンテンツを用いて復号するユースケース。
///
/// - 共有フロー(Share)で KeyEnvelope から CEK を取り出した後の復号処理を想定。
Expand Down Expand Up @@ -1111,6 +1134,47 @@ mod tests {
assert_eq!(stored.content_status(), &ContentStatus::Active);
}

#[test]
fn fetch_encrypted_returns_stored_ciphertext() {
let (repo, _storage) = TestContentRepository::new(false);
let (key_store, _key_storage) = TestKeyStore::new(false, false);
let service = build_service(repo, TestKeyGenerator, TestEncryptor, key_store);

let created = service
.create(CreateContentCommand {
name: "test".into(),
path: "path.txt".into(),
raw_content: b"hello".to_vec(),
provider: None,
})
.expect("create should succeed");

let encrypted = service
.fetch_encrypted(created.content_id.clone())
.expect("fetch_encrypted should succeed");
// Contract: exactly the ciphertext produced at create time — the same
// bytes create() sent to the state node, so the verify-integrity
// comparison holds. (The test encryptor may be identity, so comparing
// against the plaintext would be meaningless here.)
assert_eq!(encrypted, created.encrypted_content);

// Round-trip sanity: the plaintext fetch decrypts the same bytes.
let fetched = service
.fetch(created.content_id, None)
.expect("fetch should succeed");
assert_eq!(fetched.raw_content, b"hello".to_vec());
}

#[test]
fn fetch_encrypted_not_found_for_unknown_content() {
let (repo, _) = TestContentRepository::new(false);
let (key_store, _) = TestKeyStore::new(false, false);
let service = build_service(repo, TestKeyGenerator, TestEncryptor, key_store);

let result = service.fetch_encrypted(ContentId::new("missing".to_string()));
assert!(matches!(result, Err(FetchError::NotFound)));
}

#[test]
fn create_validation_error_when_name_is_empty() {
let (repo, _) = TestContentRepository::new(false);
Expand Down
Loading
Loading