Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
3f80337
docs(design): add read-response integrity design draft (#55)
somasekimoto Jul 14, 2026
8ff8363
docs(design): resolve open questions with codebase-grounded recommend…
somasekimoto Jul 15, 2026
aab0da2
docs(design): make metadata privacy a hard constraint; redesign for s…
somasekimoto Jul 17, 2026
1a500ed
docs(design): finalize approach — owner-issued single member proof + …
somasekimoto Jul 17, 2026
30bb2af
docs(design): add single-PR implementation plan; no backward compat (…
somasekimoto Jul 17, 2026
ffe1db3
docs(design): place verification in monas-content, not SDK; owner key…
somasekimoto Jul 17, 2026
ec26c00
feat(read-integrity): return whole Node CBOR for relay reads + client…
somasekimoto Jul 17, 2026
7970b35
feat(read-integrity): unify state-node read format + E2E verify-decry…
somasekimoto Jul 17, 2026
3a64a5a
feat(read-integrity): owner-issued member proof — issuance + client v…
somasekimoto Jul 17, 2026
b41f454
docs(design): drop member proof (owner can't know membership); add re…
somasekimoto Jul 17, 2026
ffd9d56
docs(design): add full task checklist + user-confirmed constraints to…
somasekimoto Jul 17, 2026
4edbcba
Revert "feat(read-integrity): owner-issued member proof — issuance + …
somasekimoto Jul 17, 2026
169b00f
feat(read-integrity): add last-seen version store for read monotonici…
somasekimoto Jul 18, 2026
ab1296c
feat(read-integrity): persist recipient CEK on share decrypt; rotate-…
somasekimoto Jul 18, 2026
9c70265
feat(read-integrity): verified read endpoint — SDK read_content_from_…
somasekimoto Jul 18, 2026
03074c2
test(read-integrity): state-read integration tests + Node CBOR mirror…
somasekimoto Jul 18, 2026
fa8c12a
docs(design): mark read-response-integrity implemented; record CEK sc…
somasekimoto Jul 18, 2026
5f18e5e
docs(design): remove session handoff doc; rewrite design as final-for…
somasekimoto Jul 18, 2026
4cd34aa
test(e2e): standardize on 4-node topology; assert relay read via non-…
somasekimoto Jul 18, 2026
dfadd10
docs(design): fold read-integrity design into design.md security mode…
somasekimoto Jul 18, 2026
8112ed2
fix(sdk): bind CID checks to client-selected version; record last_see…
somasekimoto Jul 25, 2026
5d9062e
docs(design): commit read-integrity sections into design.md with hone…
somasekimoto Jul 25, 2026
c2724b9
feat(share): sender-authenticated CEK envelopes — HPKE Auth mode + AA…
somasekimoto Jul 25, 2026
769c2c4
Merge branch 'fix/state-node-read-relay' into feature/read-response-s…
somasekimoto Jul 25, 2026
b031e93
fix(test-auth-generator): emit share-token payload in canonical field…
somasekimoto Jul 25, 2026
b676287
fix(state-node): unify request PoP as {op}:{resource}:{timestamp}; dr…
somasekimoto Jul 26, 2026
aa0435d
Merge branch 'fix/jwt-pop-request-freshness' into feature/read-respon…
somasekimoto Jul 26, 2026
2fe4162
test(state-node): pass explicit timestamps in authorize_read tests (r…
somasekimoto Jul 26, 2026
e6433af
refactor(read-integrity): drop client-side monotonicity check (#55)
somasekimoto Jul 28, 2026
16d9cd4
fix(state-node): bind every request signature to operation and resource
somasekimoto Jul 28, 2026
531f451
fix(share): advance sender pin before publishing the CEK (atomic epoc…
somasekimoto Jul 28, 2026
00b6afc
docs(design): correct content encryption to AES-256-GCM (matches impl…
somasekimoto Jul 28, 2026
671b0cc
docs(design): scope the KeyEnvelope sender-authentication claim to po…
somasekimoto Jul 28, 2026
8694169
Merge remote-tracking branch 'origin/fix/state-node-read-relay' into …
somasekimoto Jul 28, 2026
e59cce2
test(state-node): use explicit timestamp in merged genesis-only repli…
somasekimoto Jul 28, 2026
ebb3f82
Revert "docs(design): correct content encryption to AES-256-GCM (matc…
somasekimoto Jul 28, 2026
694534a
Merge remote-tracking branch 'origin/fix/state-node-read-relay' into …
somasekimoto Jul 28, 2026
436867f
fix(state-node): bind add-members count to the request signature
somasekimoto Jul 28, 2026
5a6cde9
fix(sdk): invalidate previously issued tokens as part of revoke
somasekimoto Jul 28, 2026
7551af4
fix(sdk): commit sender key, epoch and CEK as one atomic record
somasekimoto Jul 28, 2026
0f8c35b
fix(sdk): serialize revokes per content
somasekimoto Jul 28, 2026
eb739e7
fix(state-node): make signed mutations single-use
somasekimoto Jul 28, 2026
5a6cf14
Merge remote-tracking branch 'origin/fix/state-node-read-relay' into …
somasekimoto Jul 28, 2026
d6d0e7c
Merge remote-tracking branch 'origin/fix/state-node-read-relay' into …
somasekimoto Jul 28, 2026
ad7efd5
docs: record why the signature freshness window is 300 seconds
somasekimoto Jul 28, 2026
d729fe9
fix(state-node): derive the replay identity from the signed message, …
somasekimoto Jul 28, 2026
0913529
fix(state-node): invalidate tokens issued in the same second as the r…
somasekimoto Jul 28, 2026
8a6fc40
fix(sdk): read the CEK from the authoritative record, not the cache
somasekimoto Jul 28, 2026
3e6a3c8
Merge remote-tracking branch 'origin/fix/state-node-read-relay' into …
somasekimoto Jul 28, 2026
3ca4e50
fix: derive the replay identity from the canonical principal, not the…
somasekimoto Jul 28, 2026
d147275
Merge remote-tracking branch 'origin/fix/state-node-read-relay' into …
somasekimoto Jul 28, 2026
7bade46
style: apply rustfmt to the JWT malleability test
somasekimoto Jul 28, 2026
0badb16
fix(state-node): verify the signature before the relay consumes the r…
somasekimoto Jul 29, 2026
5db6538
docs: correct design.md and two stale code comments against the imple…
somasekimoto Jul 29, 2026
6fa195b
Merge remote-tracking branch 'origin/fix/state-node-read-relay' into …
somasekimoto Jul 29, 2026
4b747d5
fix(sdk): release content revoke locks, and depend on the pin-store p…
somasekimoto Aug 10, 2026
de80a23
refactor(sdk): drop the duplicate CID verification in the read path
somasekimoto Aug 13, 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
6 changes: 6 additions & 0 deletions Cargo.lock

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

71 changes: 65 additions & 6 deletions docs/design.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions monas-content/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,20 @@ base64 = "0.22"
sled = "0.34"
hpke-rs = { version = "0.4", features = ["hazmat"] }
hpke-rs-rust-crypto = "0.3"
# For verifying relay-read responses: recompute the crsl-lib Node CID from the
# returned Node CBOR (docs/design/read-response-integrity.md §8). Versions must
# match crsl-lib's (cid 0.11, multihash 0.19, serde_cbor 0.11) so the recomputed
# CID string is byte-identical.
serde_cbor = "0.11"
cid = { version = "0.11", features = ["serde"] }
multihash = "0.19"

[features]
default = ["filesync"]
filesync = ["monas-filesync", "monas-filesync/cloud-connectivity"]

[dev-dependencies]
tempfile = "3.19.1"
# Parity test only: build a real crsl-lib Node and confirm our from-CBOR CID
# recompute matches Node::content_id() exactly. Same rev as monas-state-node.
crsl-lib = { git = "https://github.com/Monas-project/crsl-lib", rev = "e13b86ce6d6a9c27ebd01a9b4fe82d6bc18f8a01" }
189 changes: 189 additions & 0 deletions monas-content/src/application_service/content_service/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,66 @@ where
Ok(plaintext)
}

/// Verify and decrypt a relay-read response fetched from a state node.
///
/// The state node returns the whole crsl-lib `Node` (CBOR). This:
/// 1. Recomputes the Node CID and confirms it equals `expected_version_cid`
/// (tamper detection — a relay peer cannot fabricate the payload).
/// 2. Extracts the ciphertext from the Node payload.
/// 3. Loads the CEK for `local_content_id` and AES-GCM-decrypts.
///
/// This is the client-side core of the verified read path
/// (`docs/design.md` §10「read応答の完全性検証」). It verifies **payload
/// authenticity only**: that these bytes are the ones named by
/// `expected_version_cid`. It does not establish that the version is the
/// canonical head, the latest, or the work of an authorized writer.
///
/// There is **no monotonicity check anywhere** — neither here nor in the
/// SDK above. One existed and was removed: forged `parents` bypass it, and
/// it could not tell a legitimate sync lag from an attack, so it broke
/// honest reads without stopping dishonest ones. Version authenticity needs
/// an owner/writer-signed trust anchor, tracked in issue #59.
///
/// Returns the plaintext and the verified node's parent CIDs. The parents
/// are returned for callers that want to inspect the DAG; nothing in the
/// SDK currently consumes them.
/// `cek` を渡した場合はそれを使い、`None` の場合だけローカルの CEK ストアを
/// 引く。呼び出し側が「どの CEK が正しいか」をより確実に知っている場合
/// (share 受信者は送信者ピンの権威レコードに CEK を持つ)、ストアより
/// そちらを優先させるための引数である。ストアは書き込み順が入れ替わると
/// 巻き戻り得るキャッシュに過ぎない。
pub fn verify_and_decrypt_relay_read(
&self,
node_bytes: &[u8],
expected_version_cid: &str,
local_content_id: ContentId,
cek: Option<ContentEncryptionKey>,
) -> Result<VerifiedRead, VerifiedReadError> {
let verified = crate::infrastructure::node_verification::verify_and_extract(
node_bytes,
expected_version_cid,
)
.map_err(VerifiedReadError::NodeVerification)?;

let key = match cek {
Some(key) => key,
None => self
.cek_store
.load(&local_content_id)
.map_err(VerifiedReadError::KeyStore)?
.ok_or(VerifiedReadError::MissingKey)?,
};

let plaintext = self
.decrypt_with_cek(local_content_id, key, verified.ciphertext)
.map_err(VerifiedReadError::Decrypt)?;

Ok(VerifiedRead {
plaintext,
parents: verified.parents,
})
}

/// コンテンツ削除ユースケース。
///
/// - 物理削除ではなく、ドメインオブジェクト上で `is_deleted` フラグとバッファをクリアして保存する「論理削除」
Expand Down Expand Up @@ -633,6 +693,26 @@ pub enum DecryptWithCekError {
Domain(ContentError),
}

/// Result of a verified relay read: the plaintext plus the verified node's
/// parent version CIDs (used by the caller for the monotonicity check).
#[derive(Debug)]
pub struct VerifiedRead {
pub plaintext: Vec<u8>,
pub parents: Vec<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum VerifiedReadError {
#[error("node verification failed: {0}")]
NodeVerification(crate::infrastructure::node_verification::NodeVerificationError),
#[error("key store error: {0:?}")]
KeyStore(ContentEncryptionKeyStoreError),
#[error("no content encryption key for this content")]
MissingKey,
#[error("decrypt failed: {0}")]
Decrypt(DecryptWithCekError),
}

#[derive(Debug, thiserror::Error)]
pub enum ReencryptError {
#[error("content not found")]
Expand Down Expand Up @@ -992,6 +1072,39 @@ mod tests {
}
}

/// crsl-lib `Node` と同じ CBOR 形状のバイト列を作る
/// (`node_verification` が受理する最小構成)。
fn make_test_node_bytes(ciphertext: &[u8]) -> Vec<u8> {
#[derive(serde::Serialize)]
struct Payload<'a> {
data: &'a [u8],
access_policy: Option<()>,
}
#[derive(serde::Serialize)]
struct Metadata {
policy_type: Option<()>,
}
#[derive(serde::Serialize)]
struct Node<'a> {
payload: Payload<'a>,
parents: Vec<()>,
genesis: Option<()>,
timestamp: u64,
metadata: Metadata,
}
serde_cbor::to_vec(&Node {
payload: Payload {
data: ciphertext,
access_policy: None,
},
parents: vec![],
genesis: None,
timestamp: 0,
metadata: Metadata { policy_type: None },
})
.unwrap()
}

fn build_service<R, K, E, S>(
repo: R,
key_gen: K,
Expand Down Expand Up @@ -1498,6 +1611,82 @@ mod tests {
assert_eq!(result, plaintext);
}

/// `verify_and_decrypt_relay_read` は、明示的に渡された CEK を
/// ローカルの CEK ストアより優先する。
///
/// ストアは権威レコード(送信者ピン)から導出されるキャッシュに過ぎず、
/// CAS の外で書かれるため書き込み順が入れ替わると古い世代へ巻き戻り得る
/// (世代 N の handler が CAS 後に停止し、その間に N+1 が権威レコードと
/// キャッシュを進め、その後 N が再開してキャッシュだけを N へ戻す)。
/// read が権威レコード側の CEK を使えば、その巻き戻りは影響しない。
#[test]
fn verified_read_prefers_the_explicit_cek_over_the_store() {
/// 鍵を実際に見る暗号化器。鍵の 1 バイト目を XOR するだけだが、
/// 「どの CEK で復号したか」がテストから観測できるようになる。
struct KeySensitiveEncryptor;
impl ContentEncryption for KeySensitiveEncryptor {
fn encrypt(
&self,
key: &ContentEncryptionKey,
plaintext: &[u8],
) -> Result<Vec<u8>, ContentError> {
Ok(plaintext.iter().map(|b| b ^ key.0[0]).collect())
}
fn decrypt(
&self,
key: &ContentEncryptionKey,
ciphertext: &[u8],
) -> Result<Vec<u8>, ContentError> {
Ok(ciphertext.iter().map(|b| b ^ key.0[0]).collect())
}
}

let (repo, _storage) = TestContentRepository::new(false);
let (key_store, _key_storage) = TestKeyStore::new(false, false);
let service = build_service(repo, TestKeyGenerator, KeySensitiveEncryptor, key_store);

// 権威レコード側の(正しい)CEK と、巻き戻ったキャッシュ側の CEK
let authoritative = ContentEncryptionKey(vec![0x11]);
let stale_cache = ContentEncryptionKey(vec![0x22]);

let plaintext = b"authoritative-cek-wins".to_vec();
let ciphertext = service
.encryptor
.encrypt(&authoritative, &plaintext)
.expect("encrypt");
let content_id = service.content_id_generator.generate(&plaintext);

// キャッシュには古い世代の CEK が入っている
service
.cek_store
.save(&content_id, &stale_cache)
.expect("save stale cache");

let node_bytes = make_test_node_bytes(&ciphertext);
let version =
crate::infrastructure::node_verification::recompute_node_cid(&node_bytes).unwrap();

// 権威レコードの CEK を渡せば復号できる
let read = service
.verify_and_decrypt_relay_read(
&node_bytes,
&version,
content_id.clone(),
Some(authoritative),
)
.expect("the explicit CEK must be used");
assert_eq!(read.plaintext, plaintext);

// 渡さなければ巻き戻ったキャッシュが使われ、平文が一致しない
// (= この経路に依存していると read が壊れる)
let fallback =
service.verify_and_decrypt_relay_read(&node_bytes, &version, content_id, None);
assert!(
fallback.is_err() || fallback.unwrap().plaintext != plaintext,
"the stale cache must not yield the correct plaintext"
);
}

#[test]
fn decrypt_with_cek_returns_mismatch_error_when_content_id_differs() {
let (repo, _storage) = TestContentRepository::new(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ use crate::domain::share::{KeyEnvelope, KeyId, Permission};
pub struct GrantShareCommand {
pub content_id: ContentId,
pub sender_key_id: KeyId,
/// 送信者の秘密鍵バイト列。HPKE Auth モードの wrap(送信者認証)に用いる。
/// 保存はされず、この呼び出しの間だけ使われる。
pub sender_private_key: Vec<u8>,
pub recipient_public_key: Vec<u8>,
pub permission: Permission,
}
Expand All @@ -24,6 +27,9 @@ pub struct GrantShareResult {
pub struct RevokeShareCommand {
pub content_id: ContentId,
pub sender_key_id: KeyId,
/// 送信者の秘密鍵バイト列。残存受信者向け KeyEnvelope 再発行の
/// HPKE Auth モード wrap(送信者認証)に用いる。
pub sender_private_key: Vec<u8>,
pub recipient_key_id: KeyId,
}

Expand Down
2 changes: 2 additions & 0 deletions monas-content/src/application_service/share_service/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
mod command;
mod port;
pub mod sender_key_pin_port;
mod service;

pub use command::*;
pub use port::*;
pub use sender_key_pin_port::{SenderKeyPin, SenderKeyPinStore, SenderKeyPinStoreError};
pub use service::*;
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//! 受信者側の送信者公開鍵ピン(TOFU)ストア。
//!
//! share の KeyEnvelope は HPKE Auth モードでラップされており、受信者は
//! 「期待する送信者の公開鍵」で unwrap する(成功 = その鍵の持ち主が作った証明)。
//! このストアは content ごとに、最初に unwrap に成功した送信者公開鍵を
//! ピン留めし(TOFU)、以後の envelope はピン済みの鍵でのみ検証する。
//!
//! 併せて CEK の鍵世代(key_epoch)と、**その世代の CEK 自体**を記録する。
//! 記録済み世代より古い envelope は拒否する(rotation 後に旧 envelope を
//! 再送して CEK を巻き戻す replay 攻撃の防止)。
//!
//! ## なぜ CEK をここに置くのか
//!
//! 守るべき不変条件は「送信者鍵・世代・CEK の3つ組が常に整合していること」で
//! あって、世代番号だけではない。3つ組を別ストアに分けて別々に commit すると、
//! 世代を CAS で守っても次の interleaving で壊れる:
//!
//! 1. epoch N の処理が pin(epoch N-1)を読む
//! 2. epoch N+1 の処理が pin を N+1 へ進め、新しい CEK を保存する
//! 3. epoch N の処理が「同一世代の再処理」等の経路で CEK だけを書き戻す
//! 4. 結果は `pin = N+1, CEK = N` となり、以後の復号が失敗する
//!
//! 3つ組を1レコードに入れて単一の compare-and-swap で入れ替えれば、この
//! interleaving は構造的に起こり得ない。CEK ストア側は、この権威レコードから
//! 導出されるキャッシュとして扱う(書き損じても再処理で回復できる)。
//!
//! キーは受信者から見た(ローカルの) content id。

#[derive(Debug, thiserror::Error)]
pub enum SenderKeyPinStoreError {
#[error("sender key pin store error: {0}")]
Storage(String),
}

/// ピン留めされた送信者公開鍵と、その送信者から受理した最新の鍵世代・CEK。
///
/// この3つは常に同じ commit で入れ替わる。個別に更新してはならない
/// (モジュール doc の interleaving を参照)。
#[derive(Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SenderKeyPin {
/// 送信者の公開鍵バイト列(P-256 uncompressed form)。
pub sender_public_key: Vec<u8>,
/// 最後に unwrap に成功した envelope の key_epoch。
pub key_epoch: u64,
/// `key_epoch` 世代の CEK。この端末のローカルにのみ存在し、ネットワークには出ない。
///
/// 旧レコード(CEK を持たない形式)から読んだ場合は `None` になる。
/// その場合は次に受理した envelope で埋まる。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cek: Option<Vec<u8>>,
}

/// CEK を含むため、`Debug` は鍵素材を出さない。ログや panic メッセージに
/// レコードが載っても CEK が漏れないようにする。
impl std::fmt::Debug for SenderKeyPin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SenderKeyPin")
.field("sender_public_key", &self.sender_public_key)
.field("key_epoch", &self.key_epoch)
.field(
"cek",
&self.cek.as_ref().map(|_| "<redacted>").unwrap_or("None"),
)
.finish()
}
}

/// `content_id -> (送信者公開鍵, 最終受理 key_epoch, その世代の CEK)` の永続化ポート。
pub trait SenderKeyPinStore: Send + Sync {
fn load(&self, content_id: &str) -> Result<Option<SenderKeyPin>, SenderKeyPinStoreError>;
fn save(&self, content_id: &str, pin: &SenderKeyPin) -> Result<(), SenderKeyPinStoreError>;

/// compare-and-advance: 現在値が `expected` と一致する場合のみ `pin` へ進める。
/// 戻り値は「進めたかどうか」。
///
/// envelope の並行処理(rotation 前後の epoch N / N+1 が同時に走る等)で、
/// 「load した時点の pin」を前提に無条件 save すると、後から完了した古い
/// epoch が新しいレコードを巻き戻せる。3つ組は1レコードなので、この CAS が
/// 成功した時点で送信者鍵・世代・CEK は一括で入れ替わっている。
fn compare_and_save(
&self,
content_id: &str,
expected: Option<&SenderKeyPin>,
pin: &SenderKeyPin,
) -> Result<bool, SenderKeyPinStoreError>;
}
Loading
Loading