diff --git a/Cargo.lock b/Cargo.lock index ac4d340..745cedc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -5154,6 +5166,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -7528,12 +7551,16 @@ dependencies = [ name = "swarmnote-core" version = "0.1.0" dependencies = [ + "argon2", "async-trait", "blake3", + "chacha20poly1305", "chrono", "dashmap", + "ed25519-dalek", "entity", "futures", + "hkdf", "hostname", "migration", "rand 0.9.4", @@ -7544,6 +7571,7 @@ dependencies = [ "sha2", "similar", "specta", + "subtle", "swarm-p2p-core", "tempfile", "thiserror 2.0.18", @@ -7551,6 +7579,7 @@ dependencies = [ "tokio-util", "tracing", "uuid", + "x25519-dalek", "yrs", ] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index b81c5e6..95c2d67 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -58,6 +58,14 @@ yrs = { version = "0.25", features = ["sync"] } # Text diff (external .md sync) similar = "2" +# Cryptography (E2E sharing — workspace encryption / X25519 Lockbox / link invites) +chacha20poly1305 = "0.10" +x25519-dalek = { version = "2", features = ["static_secrets"] } +ed25519-dalek = "2" +hkdf = "0.12" +argon2 = "0.5" +subtle = "2" + # Optional: tauri-specta TS bindings — 仅桌面 src-tauri 启用,移动端走 uniffi 不开。 specta = { version = "=2.0.0-rc.25", features = ["derive", "uuid", "chrono", "serde_json"], optional = true } diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index 76b539e..5e83a6d 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -216,6 +216,24 @@ impl AppCore { pub async fn open_workspace( self: &Arc, path: impl Into, + ) -> AppResult> { + self.open_workspace_impl(path, true).await + } + + /// Like [`AppCore::open_workspace`] but for a workspace being synced/joined + /// from a peer: keys are NOT self-initialized — they arrive via the owner's + /// Lockbox during sync, so the joiner doesn't fork a divergent key. + pub async fn open_workspace_for_sync( + self: &Arc, + path: impl Into, + ) -> AppResult> { + self.open_workspace_impl(path, false).await + } + + async fn open_workspace_impl( + self: &Arc, + path: impl Into, + init_keys: bool, ) -> AppResult> { let path: PathBuf = path.into(); if !path.is_dir() { @@ -258,7 +276,8 @@ impl AppCore { fs, watcher, self.event_bus.clone(), - peer_id, + &self.identity, + init_keys, Arc::downgrade(self), ) .await?; diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs new file mode 100644 index 0000000..ffa1fcd --- /dev/null +++ b/crates/core/src/crypto.rs @@ -0,0 +1,82 @@ +//! Cryptography for E2E workspace sharing (v1). +//! +//! **Transit-only**: per-workspace symmetric keys encrypt GossipSub broadcasts; +//! X25519 Lockboxes distribute those keys to per-device public keys (each +//! derived from the device's Ed25519 identity). Authorized devices still write +//! plaintext `.md` locally (folder-is-truth). See +//! `dev-notes/design/08-e2e-encryption.md` and `11-threat-model.md`. +//! +//! Submodules: +//! * [`kdf`] — HKDF-SHA256 subkey derivation + key commitment (domain-separated) +//! * [`aead`] — XChaCha20-Poly1305 framed, key-committing seal/open +//! * [`keyx`] — Ed25519 → X25519 single-layer derivation + ECDH +//! * [`lockbox`] — X25519 sealed key envelope +//! * [`password`] — Argon2id link-password KDF +//! +//! All randomness comes from the OS-seeded CSPRNG via [`fill_random`]; we never +//! feed an RNG into the dalek APIs (avoids `rand_core` version coupling). + +pub mod aead; +pub mod kdf; +pub mod keyx; +pub mod lockbox; +pub mod password; + +use rand::RngCore; + +/// Symmetric key size (read_key / write_key / derived subkeys). +pub const KEY_LEN: usize = 32; +/// XChaCha20-Poly1305 nonce length (192-bit → safe random nonces, no counter). +pub const NONCE_LEN: usize = 24; +/// Key-commitment length. +pub const COMMITMENT_LEN: usize = 32; + +/// HKDF `info` purposes — domain-separate subkeys derived from one master key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Purpose { + /// Encrypt gossip doc-update / awareness broadcasts. + Gossip, + /// Encrypt asset chunk broadcasts. + Asset, + /// Key-commitment value. + Commit, + /// Lockbox key-encryption key. + Kek, +} + +impl Purpose { + pub(crate) const fn label(self) -> &'static [u8] { + match self { + Purpose::Gossip => b"swarmnote:v1:gossip", + Purpose::Asset => b"swarmnote:v1:asset", + Purpose::Commit => b"swarmnote:v1:commit", + Purpose::Kek => b"swarmnote:v1:kek", + } + } +} + +/// Fill `buf` with CSPRNG bytes (OS-seeded, periodically reseeding thread RNG). +pub fn fill_random(buf: &mut [u8]) { + rand::rng().fill_bytes(buf); +} + +/// Generate a fresh 32-byte symmetric key. +pub fn random_key() -> [u8; KEY_LEN] { + let mut k = [0u8; KEY_LEN]; + fill_random(&mut k); + k +} + +/// Generate `N` fresh CSPRNG bytes (nonces, salts, link secrets). +pub fn random_bytes() -> [u8; N] { + let mut b = [0u8; N]; + fill_random(&mut b); + b +} + +// Ergonomic re-exports — callers use `crypto::seal`, `crypto::seal_lockbox`, … +pub use aead::{frame_key_version, open, seal}; +pub use kdf::{derive_commitment, derive_subkey}; +pub use keyx::{derive_x25519_secret, ed25519_pub_to_x25519, x25519_dh}; +pub use lockbox::{open_lockbox, seal_lockbox}; +pub use password::derive_password_key; diff --git a/crates/core/src/crypto/aead.rs b/crates/core/src/crypto/aead.rs new file mode 100644 index 0000000..75da454 --- /dev/null +++ b/crates/core/src/crypto/aead.rs @@ -0,0 +1,161 @@ +//! XChaCha20-Poly1305 framed, key-committing seal/open. +//! +//! Frame: `[1B version][4B key_version BE][24B nonce][32B commitment][ciphertext]`. +//! The commitment binds the frame to the workspace master key; it is verified +//! (constant-time) before AEAD decryption so a ciphertext forged under another +//! key is rejected up front. + +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use subtle::ConstantTimeEq; + +use super::kdf::{derive_commitment, derive_subkey}; +use super::{fill_random, Purpose, COMMITMENT_LEN, KEY_LEN, NONCE_LEN}; +use crate::error::{AppError, AppResult}; + +const FRAME_VERSION: u8 = 1; +const HEADER_LEN: usize = 1 + 4 + NONCE_LEN + COMMITMENT_LEN; // 61 + +fn err(reason: impl Into) -> AppError { + AppError::Crypto { + context: "aead", + reason: reason.into(), + } +} + +/// Encrypt `plaintext` under a subkey derived from `master` for `purpose`, +/// producing a framed key-committing ciphertext. +pub fn seal( + master: &[u8; KEY_LEN], + purpose: Purpose, + workspace_id: &[u8; 16], + key_version: u32, + aad: &[u8], + plaintext: &[u8], +) -> AppResult> { + let subkey = derive_subkey(master, purpose, workspace_id, key_version); + let commitment = derive_commitment(master, workspace_id, key_version); + + let cipher = XChaCha20Poly1305::new_from_slice(&subkey).map_err(|_| err("bad key length"))?; + let mut nonce = [0u8; NONCE_LEN]; + fill_random(&mut nonce); + + let ct = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| err("encrypt failed"))?; + + let mut frame = Vec::with_capacity(HEADER_LEN + ct.len()); + frame.push(FRAME_VERSION); + frame.extend_from_slice(&key_version.to_be_bytes()); + frame.extend_from_slice(&nonce); + frame.extend_from_slice(&commitment); + frame.extend_from_slice(&ct); + Ok(frame) +} + +/// Read the `key_version` from a frame header (cheap — lets the receiver pick +/// the right master key from its key history before [`open`]). +pub fn frame_key_version(frame: &[u8]) -> AppResult { + if frame.len() < HEADER_LEN || frame[0] != FRAME_VERSION { + return Err(err("bad frame header")); + } + Ok(u32::from_be_bytes([frame[1], frame[2], frame[3], frame[4]])) +} + +/// Decrypt a framed ciphertext. Verifies the frame's `key_version` matches and +/// the key commitment matches `master` (constant-time) before AEAD decryption. +pub fn open( + master: &[u8; KEY_LEN], + purpose: Purpose, + workspace_id: &[u8; 16], + key_version: u32, + aad: &[u8], + frame: &[u8], +) -> AppResult> { + if frame.len() < HEADER_LEN || frame[0] != FRAME_VERSION { + return Err(err("bad frame header")); + } + let fv = u32::from_be_bytes([frame[1], frame[2], frame[3], frame[4]]); + if fv != key_version { + return Err(err("key_version mismatch")); + } + let nonce = &frame[5..5 + NONCE_LEN]; + let commitment = &frame[5 + NONCE_LEN..HEADER_LEN]; + let ct = &frame[HEADER_LEN..]; + + let expected = derive_commitment(master, workspace_id, key_version); + if expected.ct_eq(commitment).unwrap_u8() != 1 { + return Err(err("key commitment mismatch")); + } + + let subkey = derive_subkey(master, purpose, workspace_id, key_version); + let cipher = XChaCha20Poly1305::new_from_slice(&subkey).map_err(|_| err("bad key length"))?; + cipher + .decrypt(XNonce::from_slice(nonce), Payload { msg: ct, aad }) + .map_err(|_| err("decrypt/authenticate failed")) +} + +#[cfg(test)] +mod tests { + use super::*; + + const WS: [u8; 16] = [9u8; 16]; + const AAD: &[u8] = b"workspace||doc||1||ws"; + + #[test] + fn round_trip() { + let key = [3u8; 32]; + let msg = b"hello swarm \xe4\xbd\xa0\xe5\xa5\xbd"; // includes CJK bytes + let frame = seal(&key, Purpose::Gossip, &WS, 1, AAD, msg).unwrap(); + assert_eq!(frame_key_version(&frame).unwrap(), 1); + let out = open(&key, Purpose::Gossip, &WS, 1, AAD, &frame).unwrap(); + assert_eq!(out, msg); + } + + #[test] + fn wrong_master_rejected_by_commitment() { + let frame = seal(&[3u8; 32], Purpose::Gossip, &WS, 1, AAD, b"x").unwrap(); + let e = open(&[4u8; 32], Purpose::Gossip, &WS, 1, AAD, &frame).unwrap_err(); + assert!(matches!(e, AppError::Crypto { .. })); + } + + #[test] + fn tampered_ciphertext_rejected() { + let key = [3u8; 32]; + let mut frame = seal(&key, Purpose::Gossip, &WS, 1, AAD, b"payload").unwrap(); + let last = frame.len() - 1; + frame[last] ^= 0xff; + assert!(open(&key, Purpose::Gossip, &WS, 1, AAD, &frame).is_err()); + } + + #[test] + fn aad_mismatch_rejected() { + let key = [3u8; 32]; + let frame = seal(&key, Purpose::Gossip, &WS, 1, AAD, b"payload").unwrap(); + assert!(open(&key, Purpose::Gossip, &WS, 1, b"other-aad", &frame).is_err()); + } + + #[test] + fn key_version_mismatch_rejected() { + let key = [3u8; 32]; + let frame = seal(&key, Purpose::Gossip, &WS, 1, AAD, b"payload").unwrap(); + assert!(open(&key, Purpose::Gossip, &WS, 2, AAD, &frame).is_err()); + } + + #[test] + fn large_payload() { + let key = [5u8; 32]; + let msg = vec![0xabu8; 256 * 1024]; + let frame = seal(&key, Purpose::Asset, &WS, 7, AAD, &msg).unwrap(); + assert_eq!( + open(&key, Purpose::Asset, &WS, 7, AAD, &frame).unwrap(), + msg + ); + } +} diff --git a/crates/core/src/crypto/kdf.rs b/crates/core/src/crypto/kdf.rs new file mode 100644 index 0000000..46fd29f --- /dev/null +++ b/crates/core/src/crypto/kdf.rs @@ -0,0 +1,86 @@ +//! HKDF-SHA256 subkey derivation + key commitment. +//! +//! `info = purpose_label || workspace_id(16) || key_version(4, big-endian)`, +//! `salt = workspace_id` (non-secret, stable). Domain separation ensures the +//! gossip key, asset key and commitment derived from one `read_key` are +//! cryptographically independent. + +use hkdf::Hkdf; +use sha2::Sha256; + +use super::{Purpose, COMMITMENT_LEN, KEY_LEN}; + +fn info(purpose: Purpose, workspace_id: &[u8; 16], key_version: u32) -> Vec { + let label = purpose.label(); + let mut v = Vec::with_capacity(label.len() + 16 + 4); + v.extend_from_slice(label); + v.extend_from_slice(workspace_id); + v.extend_from_slice(&key_version.to_be_bytes()); + v +} + +/// Derive a 32-byte subkey from `master` for `purpose` in the given context. +pub fn derive_subkey( + master: &[u8; KEY_LEN], + purpose: Purpose, + workspace_id: &[u8; 16], + key_version: u32, +) -> [u8; KEY_LEN] { + let hk = Hkdf::::new(Some(workspace_id), master); + let mut out = [0u8; KEY_LEN]; + hk.expand(&info(purpose, workspace_id, key_version), &mut out) + .expect("HKDF expand of 32 bytes never fails"); + out +} + +/// Derive the key-commitment value binding a ciphertext to `master`. +/// Verified (constant-time) on decrypt to reject ciphertexts made under a +/// different master key (partitioning-oracle / invisible-salamander defense). +pub fn derive_commitment( + master: &[u8; KEY_LEN], + workspace_id: &[u8; 16], + key_version: u32, +) -> [u8; COMMITMENT_LEN] { + let hk = Hkdf::::new(Some(workspace_id), master); + let mut out = [0u8; COMMITMENT_LEN]; + hk.expand(&info(Purpose::Commit, workspace_id, key_version), &mut out) + .expect("HKDF expand of 32 bytes never fails"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic() { + let m = [7u8; 32]; + let ws = [1u8; 16]; + assert_eq!( + derive_subkey(&m, Purpose::Gossip, &ws, 1), + derive_subkey(&m, Purpose::Gossip, &ws, 1) + ); + } + + #[test] + fn domain_separated() { + let m = [7u8; 32]; + let ws = [1u8; 16]; + let gossip = derive_subkey(&m, Purpose::Gossip, &ws, 1); + let asset = derive_subkey(&m, Purpose::Asset, &ws, 1); + let next_ver = derive_subkey(&m, Purpose::Gossip, &ws, 2); + let commit = derive_commitment(&m, &ws, 1); + assert_ne!(gossip, asset); + assert_ne!(gossip, next_ver); + assert_ne!(gossip, commit); + } + + #[test] + fn commitment_changes_with_master() { + let ws = [1u8; 16]; + assert_ne!( + derive_commitment(&[1u8; 32], &ws, 1), + derive_commitment(&[2u8; 32], &ws, 1) + ); + } +} diff --git a/crates/core/src/crypto/keyx.rs b/crates/core/src/crypto/keyx.rs new file mode 100644 index 0000000..7df5894 --- /dev/null +++ b/crates/core/src/crypto/keyx.rs @@ -0,0 +1,76 @@ +//! Ed25519 → X25519 single-layer derivation + ECDH. +//! +//! Each device's long-lived X25519 keypair is derived from its Ed25519 identity +//! key (`SigningKey::to_scalar_bytes` for the secret, `VerifyingKey::to_montgomery` +//! for a peer's public). So a peer's X25519 public key can be computed from its +//! Ed25519 public key (≈ PeerId) alone — no extra key exchange at pairing time. +//! Clamp convention is fixed; we do **not** layer-derive. See 08-e2e-encryption.md. + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::error::{AppError, AppResult}; + +/// Derive this device's X25519 static secret from its Ed25519 seed (32 bytes). +pub fn derive_x25519_secret(ed25519_seed: &[u8; 32]) -> StaticSecret { + let sk = SigningKey::from_bytes(ed25519_seed); + StaticSecret::from(sk.to_scalar_bytes()) +} + +/// Compute a peer's X25519 public key from its Ed25519 public key (32 bytes). +pub fn ed25519_pub_to_x25519(ed25519_pub: &[u8; 32]) -> AppResult { + let vk = VerifyingKey::from_bytes(ed25519_pub).map_err(|e| AppError::Crypto { + context: "keyx", + reason: format!("bad ed25519 public key: {e}"), + })?; + Ok(PublicKey::from(vk.to_montgomery().to_bytes())) +} + +/// X25519 Diffie-Hellman shared secret (both keys are device-static). +pub fn x25519_dh(my_secret: &StaticSecret, their_public: &PublicKey) -> [u8; 32] { + my_secret.diffie_hellman(their_public).to_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ed_pub(seed: &[u8; 32]) -> [u8; 32] { + SigningKey::from_bytes(seed).verifying_key().to_bytes() + } + + #[test] + fn cross_device_dh_agrees() { + // Two devices, identities derived from distinct Ed25519 seeds. + let seed_a = [11u8; 32]; + let seed_b = [22u8; 32]; + + let sec_a = derive_x25519_secret(&seed_a); + let sec_b = derive_x25519_secret(&seed_b); + + // Each computes the OTHER's X25519 public from its Ed25519 public only. + let pub_a = ed25519_pub_to_x25519(&ed_pub(&seed_a)).unwrap(); + let pub_b = ed25519_pub_to_x25519(&ed_pub(&seed_b)).unwrap(); + + let ab = x25519_dh(&sec_a, &pub_b); + let ba = x25519_dh(&sec_b, &pub_a); + assert_eq!(ab, ba, "Ed25519→X25519 DH must agree across devices"); + } + + #[test] + fn derived_public_matches_secret() { + let seed = [33u8; 32]; + let sec = derive_x25519_secret(&seed); + let from_secret = PublicKey::from(&sec); + let from_ed = ed25519_pub_to_x25519(&ed_pub(&seed)).unwrap(); + assert_eq!(from_secret.as_bytes(), from_ed.as_bytes()); + } + + #[test] + fn distinct_seeds_distinct_secrets() { + // Sanity: different device identities yield different X25519 keys. + let a = derive_x25519_secret(&[1u8; 32]); + let b = derive_x25519_secret(&[2u8; 32]); + assert_ne!(a.to_bytes(), b.to_bytes()); + } +} diff --git a/crates/core/src/crypto/lockbox.rs b/crates/core/src/crypto/lockbox.rs new file mode 100644 index 0000000..27985ae --- /dev/null +++ b/crates/core/src/crypto/lockbox.rs @@ -0,0 +1,128 @@ +//! X25519 sealed key envelope ("Lockbox"). +//! +//! Distributes a workspace key bundle to a recipient device's X25519 public key. +//! Frame: `[1B version][24B nonce][32B commitment][ciphertext]`. +//! Shared secret = X25519(my_secret, their_public); both keys are device-static, +//! so the recipient authenticates the sender by deriving with the sender's +//! public key. KEK + commitment are HKDF-derived from the shared secret. + +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use hkdf::Hkdf; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use x25519_dalek::{PublicKey, StaticSecret}; + +use super::keyx::x25519_dh; +use super::{fill_random, Purpose, COMMITMENT_LEN, KEY_LEN, NONCE_LEN}; +use crate::error::{AppError, AppResult}; + +const FRAME_VERSION: u8 = 1; +const HEADER_LEN: usize = 1 + NONCE_LEN + COMMITMENT_LEN; // 57 +const AAD: &[u8] = b"swarmnote:v1:lockbox"; + +fn err(reason: impl Into) -> AppError { + AppError::Crypto { + context: "lockbox", + reason: reason.into(), + } +} + +fn derive(shared: &[u8; 32], purpose: Purpose) -> [u8; KEY_LEN] { + let hk = Hkdf::::new(None, shared); + let mut out = [0u8; KEY_LEN]; + hk.expand(purpose.label(), &mut out) + .expect("HKDF expand of 32 bytes never fails"); + out +} + +/// Seal `plaintext` (a workspace key bundle) for `recipient_public`. +pub fn seal_lockbox( + my_secret: &StaticSecret, + recipient_public: &PublicKey, + plaintext: &[u8], +) -> AppResult> { + let shared = x25519_dh(my_secret, recipient_public); + let kek = derive(&shared, Purpose::Kek); + let commitment = derive(&shared, Purpose::Commit); + + let cipher = XChaCha20Poly1305::new_from_slice(&kek).map_err(|_| err("bad kek length"))?; + let mut nonce = [0u8; NONCE_LEN]; + fill_random(&mut nonce); + let ct = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad: AAD, + }, + ) + .map_err(|_| err("encrypt failed"))?; + + let mut frame = Vec::with_capacity(HEADER_LEN + ct.len()); + frame.push(FRAME_VERSION); + frame.extend_from_slice(&nonce); + frame.extend_from_slice(&commitment[..COMMITMENT_LEN]); + frame.extend_from_slice(&ct); + Ok(frame) +} + +/// Open a Lockbox sealed by `sender_public` for this device's `my_secret`. +pub fn open_lockbox( + my_secret: &StaticSecret, + sender_public: &PublicKey, + frame: &[u8], +) -> AppResult> { + if frame.len() < HEADER_LEN || frame[0] != FRAME_VERSION { + return Err(err("bad frame header")); + } + let shared = x25519_dh(my_secret, sender_public); + let nonce = &frame[1..1 + NONCE_LEN]; + let commitment = &frame[1 + NONCE_LEN..HEADER_LEN]; + let ct = &frame[HEADER_LEN..]; + + let expected = derive(&shared, Purpose::Commit); + if expected[..COMMITMENT_LEN].ct_eq(commitment).unwrap_u8() != 1 { + return Err(err("key commitment mismatch")); + } + let kek = derive(&shared, Purpose::Kek); + let cipher = XChaCha20Poly1305::new_from_slice(&kek).map_err(|_| err("bad kek length"))?; + cipher + .decrypt(XNonce::from_slice(nonce), Payload { msg: ct, aad: AAD }) + .map_err(|_| err("decrypt/authenticate failed")) +} + +#[cfg(test)] +mod tests { + use super::super::keyx::derive_x25519_secret; + use super::*; + + fn device(seed: u8) -> (StaticSecret, PublicKey) { + let sec = derive_x25519_secret(&[seed; 32]); + let pubk = PublicKey::from(&sec); + (sec, pubk) + } + + #[test] + fn round_trip() { + let (a_sec, a_pub) = device(1); + let (b_sec, b_pub) = device(2); + let bundle = b"read_key||write_key||key_version=1"; + + let lb = seal_lockbox(&a_sec, &b_pub, bundle).unwrap(); + let out = open_lockbox(&b_sec, &a_pub, &lb).unwrap(); + assert_eq!(out, bundle); + } + + #[test] + fn wrong_recipient_rejected() { + let (a_sec, _a_pub) = device(1); + let (_b_sec, b_pub) = device(2); + let (c_sec, _c_pub) = device(3); + + let lb = seal_lockbox(&a_sec, &b_pub, b"secret").unwrap(); + // Device C (not the recipient) cannot open it. + let a_pub = PublicKey::from(&a_sec); + assert!(open_lockbox(&c_sec, &a_pub, &lb).is_err()); + } +} diff --git a/crates/core/src/crypto/password.rs b/crates/core/src/crypto/password.rs new file mode 100644 index 0000000..d1219d4 --- /dev/null +++ b/crates/core/src/crypto/password.rs @@ -0,0 +1,49 @@ +//! Argon2id KDF for optional link-share passwords. +//! +//! Parameters per RFC 9106 "second recommended": m = 64 MiB, t = 3, p = 4. +//! Derives a raw 32-byte key (not a PHC string) used to wrap the inner link DEK. + +use argon2::{Algorithm, Argon2, Params, Version}; + +use super::KEY_LEN; +use crate::error::{AppError, AppResult}; + +fn err(reason: impl Into) -> AppError { + AppError::Crypto { + context: "argon2", + reason: reason.into(), + } +} + +/// Derive a 32-byte key from `password` + `salt` using Argon2id. +pub fn derive_password_key(password: &[u8], salt: &[u8]) -> AppResult<[u8; KEY_LEN]> { + let params = Params::new(64 * 1024, 3, 4, Some(KEY_LEN)).map_err(|e| err(e.to_string()))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut out = [0u8; KEY_LEN]; + argon + .hash_password_into(password, salt, &mut out) + .map_err(|e| err(e.to_string()))?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_same_inputs() { + let salt = b"0123456789abcdef"; + let a = derive_password_key(b"hunter2", salt).unwrap(); + let b = derive_password_key(b"hunter2", salt).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn salt_and_password_separate_outputs() { + let k1 = derive_password_key(b"hunter2", b"0123456789abcdef").unwrap(); + let k2 = derive_password_key(b"hunter2", b"fedcba9876543210").unwrap(); + let k3 = derive_password_key(b"different", b"0123456789abcdef").unwrap(); + assert_ne!(k1, k2); + assert_ne!(k1, k3); + } +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 74a89bf..3237ba8 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -99,6 +99,17 @@ pub enum AppError { failures: Vec<(Uuid, String)>, }, + // ── Cryptography (E2E sharing) ──────────────────────────── + #[error("crypto error ({context}): {reason}")] + Crypto { + context: &'static str, + reason: String, + }, + + // ── Permissions / sharing ───────────────────────────────── + #[error("permission denied: {0}")] + PermissionDenied(String), + // ── Window (desktop shell) ──────────────────────────────── #[error("window error: {0}")] Window(String), @@ -154,6 +165,9 @@ impl Serialize for AppError { AppError::NoWorkspaceOpen => "NoWorkspaceOpen", AppError::WorkspaceCloseFailed { .. } => "WorkspaceCloseFailed", + AppError::Crypto { .. } => "Crypto", + AppError::PermissionDenied(_) => "PermissionDenied", + AppError::Window(_) => "Window", }; diff --git a/crates/core/src/identity.rs b/crates/core/src/identity.rs index 77d4c8c..fdd1cdf 100644 --- a/crates/core/src/identity.rs +++ b/crates/core/src/identity.rs @@ -111,6 +111,134 @@ impl IdentityManager { .to_protobuf_encoding() .map_err(|e| AppError::KeypairEncode(e.to_string())) } + + /// This device's long-lived X25519 secret, derived from the Ed25519 + /// identity (single-layer reuse). Used as the sender/recipient key for + /// workspace-key Lockboxes. See [`crate::crypto::keyx`]. + pub fn x25519_secret(&self) -> AppResult { + let ed = self + .keypair + .clone() + .try_into_ed25519() + .map_err(|e| AppError::KeypairDecode(e.to_string()))?; + let bytes = ed.to_bytes(); // [secret_seed(32) || public(32)] + let mut seed = [0u8; 32]; + seed.copy_from_slice(&bytes[..32]); + Ok(crate::crypto::keyx::derive_x25519_secret(&seed)) + } + + /// This device's X25519 public key (Lockbox recipient identity). + pub fn x25519_public(&self) -> AppResult { + Ok(x25519_dalek::PublicKey::from(&self.x25519_secret()?)) + } + + /// Sign `msg` with this device's Ed25519 identity key. Used to sign + /// permission operations so peers can verify the issuer. + pub fn sign(&self, msg: &[u8]) -> AppResult> { + self.keypair.sign(msg).map_err(|e| AppError::Crypto { + context: "sign", + reason: e.to_string(), + }) + } +} + +/// Recover a peer's libp2p Ed25519 [`PublicKey`] from its (inlined) PeerId. +fn peer_ed25519_public( + peer_id: &swarm_p2p_core::libp2p::PeerId, +) -> AppResult { + use swarm_p2p_core::libp2p::identity::PublicKey; + let mh = swarm_p2p_core::libp2p::multihash::Multihash::<64>::from_bytes(&peer_id.to_bytes()) + .map_err(|e| AppError::Crypto { + context: "peer-pubkey", + reason: e.to_string(), + })?; + if mh.code() != 0 { + return Err(AppError::Crypto { + context: "peer-pubkey", + reason: "peer id is a hash, not an inlined key".into(), + }); + } + PublicKey::try_decode_protobuf(mh.digest()).map_err(|e| AppError::Crypto { + context: "peer-pubkey", + reason: e.to_string(), + }) +} + +/// Derive a peer's X25519 public key from its (ed25519) PeerId. SwarmNote uses +/// ed25519 identities, whose public key is inlined in the PeerId's identity +/// multihash — so a Lockbox can be sealed to a paired device knowing only its +/// PeerId, with no extra key exchange. See [`crate::crypto::keyx`]. +pub fn peer_id_to_x25519_public( + peer_id: &swarm_p2p_core::libp2p::PeerId, +) -> AppResult { + let ed = peer_ed25519_public(peer_id)? + .try_into_ed25519() + .map_err(|e| AppError::Crypto { + context: "peer-x25519", + reason: e.to_string(), + })?; + crate::crypto::keyx::ed25519_pub_to_x25519(&ed.to_bytes()) +} + +/// Verify an Ed25519 signature against a peer's PeerId-derived public key. +/// Returns `false` on any decode/verify failure (never panics). +pub fn verify_peer_signature( + peer_id: &swarm_p2p_core::libp2p::PeerId, + msg: &[u8], + sig: &[u8], +) -> bool { + peer_ed25519_public(peer_id) + .map(|pk| pk.verify(msg, sig)) + .unwrap_or(false) +} + +#[cfg(test)] +impl IdentityManager { + /// Construct an `IdentityManager` with an ephemeral in-memory keypair, for + /// tests that need real Ed25519 signing. + pub(crate) async fn for_tests() -> Self { + struct MemKeychain(tokio::sync::Mutex>>); + #[async_trait::async_trait] + impl KeychainProvider for MemKeychain { + async fn get_or_create_keypair(&self) -> AppResult> { + let mut guard = self.0.lock().await; + if let Some(b) = guard.as_ref() { + return Ok(b.clone()); + } + let b = Keypair::generate_ed25519().to_protobuf_encoding().unwrap(); + *guard = Some(b.clone()); + Ok(b) + } + } + let config = GlobalConfig { + device_name: "Test Device".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + last_workspace_path: None, + recent_workspaces: Vec::new(), + }; + IdentityManager::new( + Arc::new(MemKeychain(tokio::sync::Mutex::new(None))), + &config, + ) + .await + .unwrap() + } +} + +#[cfg(test)] +mod x25519_tests { + use super::*; + use swarm_p2p_core::libp2p::identity::Keypair; + + #[test] + fn peer_id_x25519_matches_direct_derivation() { + let kp = Keypair::generate_ed25519(); + let peer_id = kp.public().to_peer_id(); + let ed = kp.try_into_ed25519().unwrap(); + let direct = crate::crypto::keyx::ed25519_pub_to_x25519(&ed.public().to_bytes()).unwrap(); + let via_peer = peer_id_to_x25519_public(&peer_id).unwrap(); + assert_eq!(direct.as_bytes(), via_peer.as_bytes()); + } } #[cfg(test)] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d810810..c15f8de 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -42,6 +42,7 @@ pub mod app; pub mod config; +pub mod crypto; pub mod device; pub mod document; pub mod error; @@ -58,6 +59,7 @@ pub mod yjs; // ── Host core ────────────────────────────────────────────────────────────── pub use app::{AppCore, AppCoreBuilder, FsFactory, WatcherFactory}; pub use config::RecentWorkspace; +pub use workspace::keys::{WorkspaceKeySet, WorkspaceKeys}; pub use workspace::{ensure_workspace_row, WorkspaceCore, WorkspaceInfo}; // ── Errors & events ──────────────────────────────────────────────────────── diff --git a/crates/core/src/network/event_loop.rs b/crates/core/src/network/event_loop.rs index fbe7cc9..426c940 100644 --- a/crates/core/src/network/event_loop.rs +++ b/crates/core/src/network/event_loop.rs @@ -17,10 +17,7 @@ use crate::pairing::PairingManager; use crate::protocol::{ AppRequest, AppResponse, WorkspaceMeta, WorkspaceRequest, WorkspaceResponse, }; -use crate::workspace::sync::{ - decode_ws_awareness, decode_ws_gossip, parse_sync_topic, parse_ws_awareness_topic, - parse_ws_topic, AppSyncCoordinator, -}; +use crate::workspace::sync::{parse_ws_awareness_topic, parse_ws_topic, AppSyncCoordinator}; /// 启动事件循环,持续读取 NodeEvent 并分发到 DeviceManager + EventBus。 /// @@ -175,31 +172,13 @@ async fn handle_event( } } } else if let Some(ws_uuid) = parse_ws_topic(&topic) { - // Workspace-level topic: decode doc_uuid from payload - if let Some((doc_uuid, update)) = decode_ws_gossip(&data) { - coordinator - .handle_ws_gossip_update(source, ws_uuid, doc_uuid, update.to_vec()) - .await; - } else { - warn!("Invalid workspace GossipSub payload on {topic}"); - } + // Encrypted workspace doc-update broadcast — decrypt + route. + coordinator + .handle_ws_gossip_update(source, ws_uuid, data) + .await; } else if let Some(ws_uuid) = parse_ws_awareness_topic(&topic) { - // Workspace-level awareness topic: pure fan-out, no apply. - if let Some((doc_uuid, update)) = decode_ws_awareness(&data) { - coordinator - .handle_ws_awareness_gossip(ws_uuid, doc_uuid, update.to_vec()) - .await; - } else { - warn!("Invalid awareness GossipSub payload on {topic}"); - } - } else if let Some(doc_uuid) = parse_sync_topic(&topic) { - // Legacy per-doc topic (backwards compat during transition). - // Attempt to route via any open workspace's YDocManager. - for ws in core.list_workspaces().await { - if let Some(Err(e)) = ws.ydoc().apply_sync_update(&doc_uuid, &data).await { - warn!("Failed to apply legacy gossip update for {doc_uuid}: {e}"); - } - } + // Encrypted workspace awareness broadcast — decrypt + fan-out. + coordinator.handle_ws_awareness_gossip(ws_uuid, data).await; } else { info!("GossipSub message on unknown topic: {topic}"); } @@ -241,7 +220,7 @@ async fn handle_inbound_request( AppRequest::Workspace(WorkspaceRequest::ListWorkspaces) => { info!("Received ListWorkspaces request from {peer_id}"); - let response = build_workspace_list(core).await; + let response = build_workspace_list(core, peer_id).await; if let Err(e) = client .send_response(pending_id, AppResponse::Workspace(response)) .await @@ -258,14 +237,25 @@ async fn handle_inbound_request( } } -/// 从 AppCore 的活工作区列表构建当前已打开工作区的元数据列表。 -async fn build_workspace_list(core: &Arc) -> WorkspaceResponse { +/// 构建工作区元数据列表,**只包含请求方被授权访问的工作区**(与 key 分发 / +/// 同步响应的权限 gating 一致,避免请求方"看得到却拉不动")。 +async fn build_workspace_list(core: &Arc, requester: PeerId) -> WorkspaceResponse { use entity::workspace::documents; + let requester_str = requester.to_string(); let workspaces = core.list_workspaces().await; let mut metas = Vec::with_capacity(workspaces.len()); for ws in &workspaces { + // Only advertise workspaces the requester is an authorized member of. + let authorized = matches!( + crate::workspace::permissions::role_of(ws.db(), ws.info.id, &requester_str).await, + Ok(Some(_)) + ); + if !authorized { + continue; + } + let doc_count = documents::Entity::find().count(ws.db()).await.unwrap_or(0) as u32; metas.push(WorkspaceMeta { diff --git a/crates/core/src/protocol/mod.rs b/crates/core/src/protocol/mod.rs index 0a95c17..79589e2 100644 --- a/crates/core/src/protocol/mod.rs +++ b/crates/core/src/protocol/mod.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; pub use os_info::OsInfo; pub use pairing::{PairingMethod, PairingRefuseReason, PairingRequest, PairingResponse}; -pub use sync::{AssetMeta, DocMeta, SyncRequest, SyncResponse}; +pub use sync::{AssetMeta, DocMeta, SealedWorkspaceKey, SyncRequest, SyncResponse}; pub use workspace::{WorkspaceMeta, WorkspaceRequest, WorkspaceResponse}; /// Top-level request envelope. diff --git a/crates/core/src/protocol/sync.rs b/crates/core/src/protocol/sync.rs index 91c7f58..b19042f 100644 --- a/crates/core/src/protocol/sync.rs +++ b/crates/core/src/protocol/sync.rs @@ -24,6 +24,8 @@ pub enum SyncRequest { name: String, chunk_index: u32, }, + /// Request this workspace's symmetric key, sealed to the requester device. + WorkspaceKey { workspace_uuid: Uuid }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,6 +52,27 @@ pub enum SyncResponse { data: Vec, is_last: bool, }, + /// The workspace key sealed to the requester device (`None` if the + /// responder has no key, or declines because the requester isn't an + /// authorized member). `ops` carries the workspace's signed permission + /// chain so the requester can materialize its own role. + WorkspaceKey { + workspace_uuid: Uuid, + sealed: Option, + #[serde(default)] + ops: Vec, + }, +} + +/// A workspace key set sealed (X25519 Lockbox) to a specific recipient device. +/// Each `sealed_*` blob is a self-contained Lockbox frame. A read-only +/// recipient receives `sealed_write = None`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SealedWorkspaceKey { + pub key_version: u32, + #[serde(with = "serde_bytes")] + pub sealed_read: Vec, + pub sealed_write: Option>, } /// Asset file metadata advertised via `AssetManifest`. diff --git a/crates/core/src/workspace/keys.rs b/crates/core/src/workspace/keys.rs new file mode 100644 index 0000000..0dc43d2 --- /dev/null +++ b/crates/core/src/workspace/keys.rs @@ -0,0 +1,492 @@ +//! Per-workspace key management: generate the symmetric `read_key`/`write_key` +//! set, seal it to this device via an X25519 Lockbox, persist it, and reload +//! the full `{key_version → keys}` history on open. +//! +//! v1 only ever creates **self-Lockboxes** (sealed by this device, for this +//! device). Distributing keys to other devices (deriving a sender's X25519 +//! public key from its PeerId) lands in the sharing phase. See +//! `dev-notes/design/{05-sharing,08-e2e-encryption}.md`. + +use std::collections::BTreeMap; +use std::str::FromStr; + +use chrono::Utc; +use entity::workspace::workspace_key_lockboxes::{self, Entity as Lockboxes}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, +}; +use swarm_p2p_core::libp2p::PeerId; +use uuid::Uuid; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::crypto::{open_lockbox, random_key, seal_lockbox, KEY_LEN}; +use crate::error::{AppError, AppResult}; +use crate::identity::peer_id_to_x25519_public; + +/// One key version's symmetric material. `write_key` is `None` for a future +/// read-only (Reader) device. +#[derive(Clone)] +pub struct WorkspaceKeySet { + pub read_key: [u8; KEY_LEN], + pub write_key: Option<[u8; KEY_LEN]>, +} + +/// In-memory `{key_version → keys}` history. Old versions are retained so old +/// CRDT updates / ciphertexts remain decryptable (no forward secrecy — see +/// threat model). +#[derive(Default, Clone)] +pub struct WorkspaceKeys { + versions: BTreeMap, +} + +impl WorkspaceKeys { + /// Highest (current) key version, if any. + pub fn current_version(&self) -> Option { + self.versions.keys().next_back().copied() + } + + /// The read key for `version`, if held. + pub fn read_key(&self, version: u32) -> Option<&[u8; KEY_LEN]> { + self.versions.get(&version).map(|s| &s.read_key) + } + + /// The write key for `version`, if held (a Reader holds none). + pub fn write_key(&self, version: u32) -> Option<&[u8; KEY_LEN]> { + self.versions + .get(&version) + .and_then(|s| s.write_key.as_ref()) + } + + /// Current version + its key set. + pub fn current(&self) -> Option<(u32, &WorkspaceKeySet)> { + self.versions.iter().next_back().map(|(v, s)| (*v, s)) + } + + pub fn is_empty(&self) -> bool { + self.versions.is_empty() + } + + fn insert(&mut self, version: u32, set: WorkspaceKeySet) { + self.versions.insert(version, set); + } + + /// Test-only constructor for a single key version. + #[cfg(test)] + pub(crate) fn test_single( + version: u32, + read_key: [u8; KEY_LEN], + write_key: Option<[u8; KEY_LEN]>, + ) -> Self { + let mut keys = Self::default(); + keys.insert( + version, + WorkspaceKeySet { + read_key, + write_key, + }, + ); + keys + } +} + +/// Generate `key_version = 1` keys for a brand-new workspace, seal a +/// self-Lockbox to this device, persist it, and return the in-memory set. +pub async fn initialize_workspace_keys( + db: &DatabaseConnection, + workspace_id: Uuid, + my_peer_id: &str, + my_secret: &StaticSecret, + my_public: &PublicKey, +) -> AppResult { + let read = random_key(); + let write = random_key(); + let version: i32 = 1; + + let sealed_read = seal_lockbox(my_secret, my_public, &read)?; + let sealed_write = seal_lockbox(my_secret, my_public, &write)?; + + workspace_key_lockboxes::ActiveModel { + workspace_id: Set(workspace_id), + key_version: Set(version), + recipient_peer_id: Set(my_peer_id.to_string()), + sealed_read_key: Set(sealed_read), + sealed_write_key: Set(Some(sealed_write)), + sealed_by_peer_id: Set(my_peer_id.to_string()), + created_at: Set(Utc::now()), + } + .insert(db) + .await?; + + let mut keys = WorkspaceKeys::default(); + keys.insert( + version as u32, + WorkspaceKeySet { + read_key: read, + write_key: Some(write), + }, + ); + Ok(keys) +} + +/// Load this device's keys, generating `key_version = 1` (self-Lockbox) if the +/// workspace has none yet. Idempotent + race-safe: if a concurrent open wins the +/// init, this reloads the persisted keys instead of returning divergent ones. +/// +/// NOTE: a workspace synced/joined from another device should receive its keys +/// via a shared Lockbox (sharing phase) rather than self-initializing — until +/// then a joined workspace self-inits its own (distinct) key, which only matters +/// once encrypted broadcast is switched on. +/// Returns `(keys, did_initialize)` where `did_initialize` is `true` only when +/// this call freshly self-initialized the workspace key — i.e. the owner-create +/// moment, used to seed the genesis permission op exactly once. +pub async fn load_or_initialize_workspace_keys( + db: &DatabaseConnection, + workspace_id: Uuid, + my_peer_id: &str, + my_secret: &StaticSecret, + my_public: &PublicKey, +) -> AppResult<(WorkspaceKeys, bool)> { + let existing = load_workspace_keys(db, workspace_id, my_peer_id, my_secret, my_public).await?; + if !existing.is_empty() { + return Ok((existing, false)); + } + match initialize_workspace_keys(db, workspace_id, my_peer_id, my_secret, my_public).await { + Ok(keys) => Ok((keys, true)), + // Lost an init race (PK conflict): use whatever was persisted. + Err(_) => Ok(( + load_workspace_keys(db, workspace_id, my_peer_id, my_secret, my_public).await?, + false, + )), + } +} + +/// Load every key version sealed for this device from the workspace DB. +pub async fn load_workspace_keys( + db: &DatabaseConnection, + workspace_id: Uuid, + my_peer_id: &str, + my_secret: &StaticSecret, + my_public: &PublicKey, +) -> AppResult { + let rows = Lockboxes::find() + .filter(workspace_key_lockboxes::Column::WorkspaceId.eq(workspace_id)) + .filter(workspace_key_lockboxes::Column::RecipientPeerId.eq(my_peer_id)) + .all(db) + .await?; + + let mut keys = WorkspaceKeys::default(); + for row in rows { + // The sender's X25519 public key: our own for a self-Lockbox, otherwise + // derived from the sealer's PeerId (ed25519 → X25519). Skip rows whose + // sealer PeerId can't be parsed/derived rather than failing the load. + let sender_public = if row.sealed_by_peer_id == my_peer_id { + *my_public + } else { + match PeerId::from_str(&row.sealed_by_peer_id) + .ok() + .and_then(|pid| peer_id_to_x25519_public(&pid).ok()) + { + Some(pk) => pk, + None => { + tracing::warn!( + workspace_id = %workspace_id, + sealed_by = %row.sealed_by_peer_id, + "skipping Lockbox: cannot derive sealer X25519 public key" + ); + continue; + } + } + }; + let read = to_key( + open_lockbox(my_secret, &sender_public, &row.sealed_read_key)?, + "read_key", + )?; + let write = match row.sealed_write_key { + Some(ref sealed) => Some(to_key( + open_lockbox(my_secret, &sender_public, sealed)?, + "write_key", + )?), + None => None, + }; + keys.insert( + row.key_version as u32, + WorkspaceKeySet { + read_key: read, + write_key: write, + }, + ); + } + Ok(keys) +} + +/// Seal this device's current workspace keys to a `recipient` X25519 public key +/// (pure crypto, no DB). Returns `(key_version, sealed_read, sealed_write)`. +/// A read-only (Reader) recipient is given only the read key. +pub fn seal_keys_for_recipient( + my_secret: &StaticSecret, + recipient_public: &PublicKey, + keys: &WorkspaceKeys, + include_write_key: bool, +) -> AppResult<(u32, Vec, Option>)> { + let (version, set) = keys.current().ok_or(AppError::Crypto { + context: "seal-keys", + reason: "workspace has no key to share".into(), + })?; + let sealed_read = seal_lockbox(my_secret, recipient_public, &set.read_key)?; + let sealed_write = match (include_write_key, set.write_key) { + (true, Some(write)) => Some(seal_lockbox(my_secret, recipient_public, &write)?), + _ => None, + }; + Ok((version, sealed_read, sealed_write)) +} + +/// Seal this device's current workspace keys to a paired `recipient` device +/// (X25519 public derived from its PeerId) and persist the Lockbox locally. +/// A read-only (Reader) recipient is given only the read key. +pub async fn share_workspace_keys_to_device( + db: &DatabaseConnection, + workspace_id: Uuid, + my_peer_id: &str, + my_secret: &StaticSecret, + recipient_peer_id: &str, + keys: &WorkspaceKeys, + include_write_key: bool, +) -> AppResult<()> { + let recipient_pid = PeerId::from_str(recipient_peer_id).map_err(|e| AppError::Crypto { + context: "share-keys", + reason: format!("bad recipient peer id: {e}"), + })?; + let recipient_public = peer_id_to_x25519_public(&recipient_pid)?; + let (version, sealed_read, sealed_write) = + seal_keys_for_recipient(my_secret, &recipient_public, keys, include_write_key)?; + + install_received_key( + db, + workspace_id, + recipient_peer_id, + my_peer_id, + version, + sealed_read, + sealed_write, + ) + .await +} + +/// Persist a received (or self-sealed) Lockbox row for `recipient_peer_id`. +/// Idempotent: a row already present for `(workspace, version, recipient)` is +/// left untouched. +pub async fn install_received_key( + db: &DatabaseConnection, + workspace_id: Uuid, + recipient_peer_id: &str, + sealed_by_peer_id: &str, + key_version: u32, + sealed_read: Vec, + sealed_write: Option>, +) -> AppResult<()> { + let pk = ( + workspace_id, + key_version as i32, + recipient_peer_id.to_string(), + ); + if Lockboxes::find_by_id(pk).one(db).await?.is_some() { + return Ok(()); + } + workspace_key_lockboxes::ActiveModel { + workspace_id: Set(workspace_id), + key_version: Set(key_version as i32), + recipient_peer_id: Set(recipient_peer_id.to_string()), + sealed_read_key: Set(sealed_read), + sealed_write_key: Set(sealed_write), + sealed_by_peer_id: Set(sealed_by_peer_id.to_string()), + created_at: Set(Utc::now()), + } + .insert(db) + .await?; + Ok(()) +} + +fn to_key(bytes: Vec, what: &'static str) -> AppResult<[u8; KEY_LEN]> { + bytes.try_into().map_err(|_| AppError::Crypto { + context: "workspace-keys", + reason: format!("{what} has wrong length"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::keyx::derive_x25519_secret; + use entity::workspace::workspaces; + use migration::{MigratorTrait, WorkspaceMigrator}; + use sea_orm::Database; + + async fn mem_db() -> DatabaseConnection { + let db = Database::connect("sqlite::memory:").await.unwrap(); + WorkspaceMigrator::up(&db, None).await.unwrap(); + db + } + + fn device(seed: u8) -> (String, StaticSecret, PublicKey) { + let sec = derive_x25519_secret(&[seed; 32]); + let pubk = PublicKey::from(&sec); + (format!("12D3KooW-test-{seed}"), sec, pubk) + } + + async fn insert_workspace(db: &DatabaseConnection, id: Uuid) { + // Insert via the entity so the Uuid PK serializes exactly as the + // Lockbox FK does (avoids a raw-SQL vs sea-orm encoding mismatch). + workspaces::ActiveModel { + id: Set(id), + name: Set("WS".to_string()), + created_by: Set("me".to_string()), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + } + .insert(db) + .await + .unwrap(); + } + + #[tokio::test] + async fn generate_persist_reload_round_trip() { + let db = mem_db().await; + let ws = Uuid::now_v7(); + insert_workspace(&db, ws).await; + let (peer, sec, pubk) = device(1); + + let created = initialize_workspace_keys(&db, ws, &peer, &sec, &pubk) + .await + .unwrap(); + assert_eq!(created.current_version(), Some(1)); + assert!(created.write_key(1).is_some()); + + // Simulate restart: reload purely from the DB. + let loaded = load_workspace_keys(&db, ws, &peer, &sec, &pubk) + .await + .unwrap(); + assert_eq!(loaded.current_version(), Some(1)); + assert_eq!(loaded.read_key(1), created.read_key(1)); + assert_eq!(loaded.write_key(1), created.write_key(1)); + } + + #[tokio::test] + async fn other_device_has_no_keys() { + let db = mem_db().await; + let ws = Uuid::now_v7(); + insert_workspace(&db, ws).await; + let (peer_a, sec_a, pub_a) = device(1); + initialize_workspace_keys(&db, ws, &peer_a, &sec_a, &pub_a) + .await + .unwrap(); + + // Device B has no Lockbox sealed for it → no keys (until shared). + let (peer_b, sec_b, pub_b) = device(2); + let loaded_b = load_workspace_keys(&db, ws, &peer_b, &sec_b, &pub_b) + .await + .unwrap(); + assert!(loaded_b.is_empty()); + } + + /// A real device with a genuine ed25519 PeerId (so `sealed_by` parses and + /// `peer_id_to_x25519_public` can derive the sender key cross-device). + fn real_device() -> (String, StaticSecret, PublicKey) { + use swarm_p2p_core::libp2p::identity::Keypair; + let kp = Keypair::generate_ed25519(); + let peer_id = kp.public().to_peer_id().to_string(); + let ed = kp.try_into_ed25519().unwrap(); + let seed: [u8; 32] = ed.to_bytes()[..32].try_into().unwrap(); + let sec = derive_x25519_secret(&seed); + let pubk = PublicKey::from(&sec); + (peer_id, sec, pubk) + } + + #[tokio::test] + async fn share_to_paired_device_round_trip() { + let db = mem_db().await; + let ws = Uuid::now_v7(); + insert_workspace(&db, ws).await; + + let (peer_a, sec_a, pub_a) = real_device(); + let (peer_b, sec_b, pub_b) = real_device(); + + // A initializes its own keys (self-Lockbox). + let a_keys = initialize_workspace_keys(&db, ws, &peer_a, &sec_a, &pub_a) + .await + .unwrap(); + + // A shares its current keys to paired device B (sealed to B's PeerId). + share_workspace_keys_to_device(&db, ws, &peer_a, &sec_a, &peer_b, &a_keys, true) + .await + .unwrap(); + + // B loads → opens A's Lockbox (sender X25519 derived from A's PeerId) and + // recovers the SAME read/write keys. + let b_keys = load_workspace_keys(&db, ws, &peer_b, &sec_b, &pub_b) + .await + .unwrap(); + assert_eq!(b_keys.read_key(1), a_keys.read_key(1)); + assert_eq!(b_keys.write_key(1), a_keys.write_key(1)); + } + + /// End-to-end proof of the encrypted-sync core (everything except the + /// libp2p transport itself): A initializes a key, distributes it to B via a + /// peer-sealed Lockbox, B installs + loads it, A encrypts a gossip payload, + /// **B decrypts it**, and an unauthorized device C (no key) cannot. + #[tokio::test] + async fn encrypted_sync_end_to_end_after_key_share() { + use crate::workspace::sync::{ + decode_encrypted_gossip, encode_encrypted_gossip, MSG_TYPE_DOC, + }; + + // Device A owns the workspace; B is a paired joiner; C is unauthorized. + let db_a = mem_db().await; + let db_b = mem_db().await; + let ws = Uuid::now_v7(); + insert_workspace(&db_a, ws).await; + insert_workspace(&db_b, ws).await; + + let (peer_a, sec_a, pub_a) = real_device(); + let (peer_b, sec_b, pub_b) = real_device(); + let (_peer_c, sec_c, pub_c) = real_device(); + + // 1. A initializes its workspace key (v1, self-Lockbox). + let a_keys = initialize_workspace_keys(&db_a, ws, &peer_a, &sec_a, &pub_a) + .await + .unwrap(); + + // 2. A seals its key to B's PeerId; B installs it into its own DB and loads. + let b_pub = peer_id_to_x25519_public(&PeerId::from_str(&peer_b).unwrap()).unwrap(); + let (ver, sealed_read, sealed_write) = + seal_keys_for_recipient(&sec_a, &b_pub, &a_keys, true).unwrap(); + install_received_key(&db_b, ws, &peer_b, &peer_a, ver, sealed_read, sealed_write) + .await + .unwrap(); + let b_keys = load_workspace_keys(&db_b, ws, &peer_b, &sec_b, &pub_b) + .await + .unwrap(); + assert_eq!( + b_keys.read_key(1), + a_keys.read_key(1), + "B must hold A's key" + ); + + // 3. A encrypts a gossip doc-update; B decrypts it back to plaintext. + let doc = Uuid::now_v7(); + let plaintext = b"yjs-update-\xf0\x9f\x90\x9d"; // arbitrary bytes incl. emoji + let wire = encode_encrypted_gossip(&a_keys, &ws, &doc, MSG_TYPE_DOC, plaintext).unwrap(); + let (got_doc, got) = decode_encrypted_gossip(&b_keys, &ws, MSG_TYPE_DOC, &wire).unwrap(); + assert_eq!(got_doc, doc); + assert_eq!(got, plaintext, "B must decrypt A's broadcast"); + + // 4. Unauthorized device C (never received the key) cannot decrypt. + let c_keys = load_workspace_keys(&mem_db().await, ws, "c", &sec_c, &pub_c) + .await + .unwrap(); + assert!(c_keys.is_empty()); + assert!( + decode_encrypted_gossip(&c_keys, &ws, MSG_TYPE_DOC, &wire).is_err(), + "device without the key must not decrypt" + ); + } +} diff --git a/crates/core/src/workspace/mod.rs b/crates/core/src/workspace/mod.rs index 392e325..80abdf6 100644 --- a/crates/core/src/workspace/mod.rs +++ b/crates/core/src/workspace/mod.rs @@ -6,6 +6,9 @@ //! shared across windows of the same workspace). Mobile holds at most one. pub mod db; +pub mod keys; +pub mod permissions; +pub mod sharing; pub mod sync; use std::path::Path; @@ -71,21 +74,66 @@ pub struct WorkspaceCore { /// Per-workspace sync runtime. `None` until P2P starts; torn down when /// the workspace closes or P2P stops. sync: tokio::sync::RwLock>>, + /// This workspace's symmetric key history (`{key_version → keys}`), loaded + /// on open. Used by the encrypted gossip codec for publish/receive. + keys: tokio::sync::RwLock, + /// Device identity material retained for reloading keys after a Lockbox is + /// installed (e.g. one received from a peer during sync). + dev_peer_id: String, + dev_x25519_secret: x25519_dalek::StaticSecret, + dev_x25519_public: x25519_dalek::PublicKey, } impl WorkspaceCore { /// Construct a new workspace runtime. Called by /// [`AppCore::open_workspace`] — not a public entry point. + #[allow(clippy::too_many_arguments)] // single internal call site; injecting deps explicitly pub(crate) async fn new( info: WorkspaceInfo, db: DatabaseConnection, fs: Arc, watcher: Option>, event_bus: Arc, - peer_id: String, + identity: &crate::identity::IdentityManager, + init_keys: bool, app: Weak, ) -> AppResult> { let db = Arc::new(db); + let peer_id = identity.peer_id()?; + let my_x25519_secret = identity.x25519_secret()?; + let my_x25519_public = identity.x25519_public()?; + + // Owner-opened workspaces self-initialize a key if absent; sync-joined + // workspaces (init_keys = false) stay keyless until the owner's Lockbox + // arrives via sync, so they don't fork a divergent key. + let workspace_keys = if init_keys { + let (workspace_keys, did_init) = keys::load_or_initialize_workspace_keys( + db.as_ref(), + info.id, + &peer_id, + &my_x25519_secret, + &my_x25519_public, + ) + .await?; + // Owner-create moment (key freshly self-initialized): seed the + // genesis Owner permission op so the chain has a root of trust. + if did_init { + permissions::ensure_genesis_owner(db.as_ref(), identity, info.id).await?; + } + workspace_keys + } else { + keys::load_workspace_keys( + db.as_ref(), + info.id, + &peer_id, + &my_x25519_secret, + &my_x25519_public, + ) + .await? + }; + let dev_peer_id = peer_id.clone(); + let dev_x25519_secret = my_x25519_secret.clone(); + let dev_x25519_public = my_x25519_public; let documents = Arc::new(DocumentCrud::new(Arc::clone(&db), peer_id.clone())); let ydoc = YDocManager::new( info.id, @@ -113,6 +161,10 @@ impl WorkspaceCore { event_bus, _app: app, sync: tokio::sync::RwLock::new(None), + keys: tokio::sync::RwLock::new(workspace_keys), + dev_peer_id, + dev_x25519_secret, + dev_x25519_public, })) } @@ -164,6 +216,27 @@ impl WorkspaceCore { self.sync.read().await.clone() } + /// This workspace's loaded symmetric key history (clone of the in-memory + /// `{key_version → keys}` map). Used by the encrypted gossip codec. + pub async fn keys(&self) -> crate::WorkspaceKeys { + self.keys.read().await.clone() + } + + /// Reload the key history from the DB. Called after installing a Lockbox + /// received from a peer during sync, so subsequent gossip can decrypt. + pub async fn reload_keys(&self) -> AppResult<()> { + let loaded = keys::load_workspace_keys( + self.db.as_ref(), + self.info.id, + &self.dev_peer_id, + &self.dev_x25519_secret, + &self.dev_x25519_public, + ) + .await?; + *self.keys.write().await = loaded; + Ok(()) + } + /// Broadcast an awareness (caret / presence) update for an open doc. /// Bytes are an opaque `y-protocols/awareness` encodeAwarenessUpdate /// payload — never decoded, never persisted. Silently no-op when P2P is @@ -379,3 +452,27 @@ pub async fn ensure_workspace_row( doc_count: 0, }) } + +/// Pin the workspace's authoritative owner (`created_by`) to `owner_peer_id`. +/// +/// A joiner creates its local workspace row with **itself** as `created_by` +/// (it doesn't know the owner at creation time). Once it receives the owner's +/// signed permission chain over the Noise-authenticated sync channel, it pins +/// `created_by` to the real owner so [`permissions::materialize`] binds the +/// genesis correctly (its own role then materializes, and forged genesis ops +/// are rejected the same way they are on the owner's device). No-op if already +/// set or if the row is missing. +pub async fn pin_workspace_owner( + db: &DatabaseConnection, + workspace_id: Uuid, + owner_peer_id: &str, +) -> AppResult<()> { + if let Some(row) = WorkspacesEntity::find_by_id(workspace_id).one(db).await? { + if row.created_by != owner_peer_id { + let mut model: workspaces::ActiveModel = row.into(); + model.created_by = Set(owner_peer_id.to_owned()); + model.update(db).await?; + } + } + Ok(()) +} diff --git a/crates/core/src/workspace/permissions.rs b/crates/core/src/workspace/permissions.rs new file mode 100644 index 0000000..0145d56 --- /dev/null +++ b/crates/core/src/workspace/permissions.rs @@ -0,0 +1,731 @@ +//! Signed, append-only permission operation chain (authorization DAG). +//! +//! Each [`PermissionOp`] is identified by its content hash (`op_id`) and signed +//! by the issuer device's Ed25519 key. Replaying the ops ([`materialize`]) +//! yields the current `peer → role` map, validating these invariants per op: +//! signature valid, issuer currently Owner (non-genesis), (two-tier) only Owner +//! may grant/revoke, and the owner is never demoted. The genesis op (no +//! `prev_hash`) is a self-grant of Owner that bootstraps the workspace owner — +//! but it is **bound to the workspace's authoritative creator** (`created_by`): +//! only that peer's genesis is honored, so a forged self-signed genesis from any +//! other device cannot establish a second Owner. See +//! `dev-notes/design/04-permissions.md`. +//! +//! v1 is two-tier (Owner / Collaborator). The op carries everything needed to +//! add a real Reader role later without a schema change. + +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use chrono::Utc; +use entity::workspace::permission_ops::{self, Entity as PermissionOps}; +use entity::workspace::workspaces::Entity as Workspaces; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, +}; +use serde::{Deserialize, Serialize}; +use swarm_p2p_core::libp2p::PeerId; +use uuid::Uuid; + +use crate::error::AppResult; +use crate::identity::{verify_peer_signature, IdentityManager}; + +/// Workspace role. v1 two-tier; `Reader` is reserved for v2. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "lowercase")] +pub enum Role { + Owner, + Collaborator, +} + +impl Role { + fn as_str(self) -> &'static str { + match self { + Role::Owner => "owner", + Role::Collaborator => "collaborator", + } + } + fn parse(s: &str) -> Option { + match s { + "owner" => Some(Role::Owner), + "collaborator" => Some(Role::Collaborator), + _ => None, + } + } +} + +/// Permission operation kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OpKind { + Grant, + Revoke, +} + +impl OpKind { + fn as_str(self) -> &'static str { + match self { + OpKind::Grant => "grant", + OpKind::Revoke => "revoke", + } + } + fn parse(s: &str) -> Option { + match s { + "grant" => Some(OpKind::Grant), + "revoke" => Some(OpKind::Revoke), + _ => None, + } + } + fn tag(self) -> u8 { + match self { + OpKind::Grant => 1, + OpKind::Revoke => 2, + } + } +} + +/// One node in the signed permission chain. Serializable for broadcast over +/// the ctrl GossipSub topic (`CtrlMessage::PermissionOpsUpdate`). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PermissionOp { + pub op_id: String, + pub op_kind: OpKind, + pub target_peer_id: String, + /// `Some` for `Grant`, `None` for `Revoke`. + pub new_role: Option, + pub issuer_peer_id: String, + pub key_version: i32, + pub prev_hash: Option, + pub signature: Vec, +} + +/// Deterministic byte encoding signed by the issuer + hashed into `op_id`. +fn canonical_bytes( + kind: OpKind, + target: &str, + role: Option, + issuer: &str, + key_version: i32, + prev_hash: Option<&str>, +) -> Vec { + let mut b = Vec::new(); + b.push(kind.tag()); + b.extend_from_slice(target.as_bytes()); + b.push(0); + b.extend_from_slice(role.map(|r| r.as_str()).unwrap_or("none").as_bytes()); + b.push(0); + b.extend_from_slice(issuer.as_bytes()); + b.push(0); + b.extend_from_slice(&key_version.to_be_bytes()); + b.push(0); + if let Some(ph) = prev_hash { + b.extend_from_slice(ph.as_bytes()); + } + b +} + +impl PermissionOp { + fn canonical(&self) -> Vec { + canonical_bytes( + self.op_kind, + &self.target_peer_id, + self.new_role, + &self.issuer_peer_id, + self.key_version, + self.prev_hash.as_deref(), + ) + } + + /// Verify the content hash (`op_id`) and the issuer's Ed25519 signature. + pub fn verify(&self) -> bool { + let canon = self.canonical(); + if blake3::hash(&canon).to_hex().to_string() != self.op_id { + return false; + } + match PeerId::from_str(&self.issuer_peer_id) { + Ok(pid) => verify_peer_signature(&pid, &canon, &self.signature), + Err(_) => false, + } + } +} + +/// Build + sign a permission op with this device as issuer. +pub fn build_signed_op( + identity: &IdentityManager, + op_kind: OpKind, + target_peer_id: &str, + new_role: Option, + key_version: i32, + prev_hash: Option, +) -> AppResult { + let issuer = identity.peer_id()?; + let canon = canonical_bytes( + op_kind, + target_peer_id, + new_role, + &issuer, + key_version, + prev_hash.as_deref(), + ); + let op_id = blake3::hash(&canon).to_hex().to_string(); + let signature = identity.sign(&canon)?; + Ok(PermissionOp { + op_id, + op_kind, + target_peer_id: target_peer_id.to_string(), + new_role, + issuer_peer_id: issuer, + key_version, + prev_hash, + signature, + }) +} + +/// Persist an op (idempotent on `op_id`). +pub async fn save_op( + db: &DatabaseConnection, + workspace_id: Uuid, + op: &PermissionOp, +) -> AppResult<()> { + if PermissionOps::find_by_id(op.op_id.clone()) + .one(db) + .await? + .is_some() + { + return Ok(()); + } + permission_ops::ActiveModel { + op_id: Set(op.op_id.clone()), + workspace_id: Set(workspace_id), + op_kind: Set(op.op_kind.as_str().to_string()), + target_peer_id: Set(op.target_peer_id.clone()), + new_role: Set(op + .new_role + .map(|r| r.as_str()) + .unwrap_or("none") + .to_string()), + issuer_peer_id: Set(op.issuer_peer_id.clone()), + key_version: Set(op.key_version), + prev_hash: Set(op.prev_hash.clone()), + signature: Set(op.signature.clone()), + created_at: Set(Utc::now()), + } + .insert(db) + .await?; + Ok(()) +} + +/// Load all permission ops for a workspace. +pub async fn load_ops(db: &DatabaseConnection, workspace_id: Uuid) -> AppResult> { + let rows = PermissionOps::find() + .filter(permission_ops::Column::WorkspaceId.eq(workspace_id)) + .all(db) + .await?; + Ok(rows + .into_iter() + .filter_map(|r| { + Some(PermissionOp { + op_id: r.op_id, + op_kind: OpKind::parse(&r.op_kind)?, + target_peer_id: r.target_peer_id, + new_role: Role::parse(&r.new_role), + issuer_peer_id: r.issuer_peer_id, + key_version: r.key_version, + prev_hash: r.prev_hash, + signature: r.signature, + }) + }) + .collect()) +} + +/// Load + replay this workspace's permission ops into a `peer → role` map. +/// +/// The workspace row's `created_by` is the **authoritative owner** and is +/// passed to [`materialize`] as the genesis trust anchor — only that peer's +/// self-grant-Owner genesis is honored, so a forged genesis from any other +/// device cannot bootstrap a second Owner. +pub async fn load_and_materialize( + db: &DatabaseConnection, + workspace_id: Uuid, +) -> AppResult> { + let expected_owner = workspace_owner(db, workspace_id).await?; + Ok(materialize( + &load_ops(db, workspace_id).await?, + &expected_owner, + )) +} + +/// The workspace's authoritative owner — the `created_by` peer id on the +/// workspace row. This binds the genesis op (see [`materialize`]). Returns an +/// empty string if the row is missing, which **fails closed**: no genesis is +/// accepted and the workspace materializes to an empty role map. +async fn workspace_owner(db: &DatabaseConnection, workspace_id: Uuid) -> AppResult { + Ok(Workspaces::find_by_id(workspace_id) + .one(db) + .await? + .map(|w| w.created_by) + .unwrap_or_default()) +} + +/// The sole authoritative-owner identity declared by a permission chain — the +/// issuer of its unique self-grant-Owner genesis op. A joiner uses this to pin +/// its local `created_by` to the real owner learned from the (Noise- +/// authenticated) chain it received, so its own genesis binding matches the +/// owner's. Returns `None` if there is zero or more than one such genesis +/// (ambiguous → caller should not pin). +pub fn genesis_owner(ops: &[PermissionOp]) -> Option { + let mut found: Option = None; + for op in ops.iter().filter(|o| o.verify()) { + if op.prev_hash.is_none() + && op.op_kind == OpKind::Grant + && op.new_role == Some(Role::Owner) + && op.issuer_peer_id == op.target_peer_id + { + if found.is_some() { + return None; // ambiguous — multiple genesis claims + } + found = Some(op.issuer_peer_id.clone()); + } + } + found +} + +/// This peer's current role in the workspace (if any). +pub async fn role_of( + db: &DatabaseConnection, + workspace_id: Uuid, + peer_id: &str, +) -> AppResult> { + Ok(load_and_materialize(db, workspace_id) + .await? + .get(peer_id) + .copied()) +} + +/// The current tip (leaf) of the op chain — the op_id no other op lists as its +/// `prev_hash`. For a linear chain this is the latest op; used as `prev_hash` +/// for a newly-issued op. Deterministic (smallest op_id) on a fork. +pub fn chain_tip(ops: &[PermissionOp]) -> Option { + let prevs: HashSet<&str> = ops.iter().filter_map(|o| o.prev_hash.as_deref()).collect(); + ops.iter() + .filter(|o| !prevs.contains(o.op_id.as_str())) + .map(|o| o.op_id.clone()) + .min() +} + +/// Seed the genesis Owner op for a freshly-created (owner) workspace if the +/// permission chain is empty. Idempotent. Called only on the owner's create +/// path (where this device self-initialized the workspace key). +pub async fn ensure_genesis_owner( + db: &DatabaseConnection, + identity: &IdentityManager, + workspace_id: Uuid, +) -> AppResult<()> { + if !load_ops(db, workspace_id).await?.is_empty() { + return Ok(()); + } + let me = identity.peer_id()?; + let op = build_signed_op(identity, OpKind::Grant, &me, Some(Role::Owner), 1, None)?; + save_op(db, workspace_id, &op).await?; + tracing::info!(workspace_id = %workspace_id, "seeded genesis owner permission op"); + Ok(()) +} + +/// Replay ops into a `peer → role` map, dropping any op that fails the +/// invariants: valid signature, **genesis bound to the authoritative owner**, +/// issuer currently Owner (non-genesis), only Owner grants/revokes, and the +/// owner is never demoted/revoked. Deterministic across devices. +/// +/// `expected_owner` is the workspace's authoritative creator (`created_by`). +/// A genesis op (no `prev_hash`) bootstraps Owner **only** when its +/// issuer == target == `expected_owner`. This is the trust anchor that closes +/// the second-genesis-forgery hole: any other device self-signing a genesis +/// (`issuer == target` but `!= expected_owner`) is rejected, so a paired-but- +/// unauthorized peer cannot inject a forged root, materialize itself as Owner, +/// and trick a key holder into sealing the workspace key to it. +pub fn materialize(ops: &[PermissionOp], expected_owner: &str) -> HashMap { + let valid: Vec<&PermissionOp> = ops.iter().filter(|o| o.verify()).collect(); + let ordered = order_by_chain(&valid); + + let mut roles: HashMap = HashMap::new(); + for op in ordered { + if op.prev_hash.is_none() { + // Genesis bootstraps the owner: a self-grant of Owner — but only for + // the workspace's authoritative creator. Any other self-signed + // genesis is a forgery and is dropped. + if op.op_kind == OpKind::Grant + && op.new_role == Some(Role::Owner) + && op.issuer_peer_id == op.target_peer_id + && op.issuer_peer_id == expected_owner + { + roles.insert(op.target_peer_id.clone(), Role::Owner); + } + continue; + } + // Non-genesis: the issuer must currently be Owner. + if roles.get(&op.issuer_peer_id).copied() != Some(Role::Owner) { + continue; + } + // The authoritative owner can never be revoked or downgraded — protects + // the founder and guarantees at least one Owner always survives. Without + // this, a (forged or peer) Owner could revoke the real owner and seize + // the chain. + if op.target_peer_id == expected_owner { + continue; + } + match op.op_kind { + OpKind::Grant => { + if let Some(role) = op.new_role { + roles.insert(op.target_peer_id.clone(), role); + } + } + OpKind::Revoke => { + roles.remove(&op.target_peer_id); + } + } + } + roles +} + +/// Order ops by their `prev_hash` chain (BFS by causal level, deterministic by +/// `op_id` within a level). Orphan ops (predecessor missing) are dropped. +fn order_by_chain<'a>(ops: &[&'a PermissionOp]) -> Vec<&'a PermissionOp> { + let mut applied: HashSet = HashSet::new(); + let mut result: Vec<&PermissionOp> = Vec::with_capacity(ops.len()); + loop { + let mut ready: Vec<&PermissionOp> = ops + .iter() + .copied() + .filter(|o| { + !applied.contains(&o.op_id) + && o.prev_hash + .as_ref() + .map(|p| applied.contains(p)) + .unwrap_or(true) + }) + .collect(); + if ready.is_empty() { + break; + } + ready.sort_by(|a, b| a.op_id.cmp(&b.op_id)); + for op in ready { + applied.insert(op.op_id.clone()); + result.push(op); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use swarm_p2p_core::libp2p::identity::Keypair; + + fn new_kp() -> Keypair { + Keypair::generate_ed25519() + } + fn peer_of(kp: &Keypair) -> String { + kp.public().to_peer_id().to_string() + } + fn signed( + kp: &Keypair, + kind: OpKind, + target: &str, + role: Option, + kv: i32, + prev: Option, + ) -> PermissionOp { + let issuer = peer_of(kp); + let canon = canonical_bytes(kind, target, role, &issuer, kv, prev.as_deref()); + let op_id = blake3::hash(&canon).to_hex().to_string(); + let signature = kp.sign(&canon).unwrap(); + PermissionOp { + op_id, + op_kind: kind, + target_peer_id: target.to_string(), + new_role: role, + issuer_peer_id: issuer, + key_version: kv, + prev_hash: prev, + signature, + } + } + + #[test] + fn genesis_establishes_owner() { + let owner = new_kp(); + let o = peer_of(&owner); + let g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + let roles = materialize(&[g], &o); + assert_eq!(roles.get(&o), Some(&Role::Owner)); + } + + #[test] + fn owner_grants_then_revokes_collaborator() { + let owner = new_kp(); + let bob = new_kp(); + let (o, b) = (peer_of(&owner), peer_of(&bob)); + let g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + let add = signed( + &owner, + OpKind::Grant, + &b, + Some(Role::Collaborator), + 1, + Some(g.op_id.clone()), + ); + let roles = materialize(&[g.clone(), add.clone()], &o); + assert_eq!(roles.get(&b), Some(&Role::Collaborator)); + + let rev = signed(&owner, OpKind::Revoke, &b, None, 2, Some(add.op_id.clone())); + let roles = materialize(&[g, add, rev], &o); + assert_eq!(roles.get(&b), None); + } + + #[test] + fn forged_self_escalation_rejected() { + let owner = new_kp(); + let bob = new_kp(); + let (o, b) = (peer_of(&owner), peer_of(&bob)); + let g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + let add = signed( + &owner, + OpKind::Grant, + &b, + Some(Role::Collaborator), + 1, + Some(g.op_id.clone()), + ); + // Bob validly signs an op making himself Owner — but Bob is not Owner. + let forged = signed( + &bob, + OpKind::Grant, + &b, + Some(Role::Owner), + 1, + Some(add.op_id.clone()), + ); + let roles = materialize(&[g, add, forged], &o); + assert_eq!( + roles.get(&b), + Some(&Role::Collaborator), + "escalation must be rejected" + ); + } + + #[test] + fn tampered_signature_dropped() { + let owner = new_kp(); + let o = peer_of(&owner); + let mut g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + g.signature[0] ^= 0xff; // tamper + assert!(!g.verify()); + assert!(materialize(&[g], &o).is_empty()); + } + + #[test] + fn forged_genesis_cannot_self_bootstrap_owner() { + // The authoritative owner's genesis + an attacker's own self-signed + // genesis. The attacker's signature/hash are internally valid, so + // `verify()` passes — but it is not the workspace's `created_by`, so + // `materialize` must drop it. This is the second-genesis-forgery hole. + let owner = new_kp(); + let attacker = new_kp(); + let (o, a) = (peer_of(&owner), peer_of(&attacker)); + let g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + let forged_genesis = signed(&attacker, OpKind::Grant, &a, Some(Role::Owner), 1, None); + assert!( + forged_genesis.verify(), + "forged genesis is internally valid" + ); + + let roles = materialize(&[g, forged_genesis], &o); + assert_eq!(roles.get(&o), Some(&Role::Owner), "real owner stands"); + assert_eq!( + roles.get(&a), + None, + "forged self-grant genesis must NOT establish a second Owner" + ); + } + + #[test] + fn lone_forged_genesis_yields_no_owner() { + // An attacker-only chain (no real owner present) still materializes to + // empty — there is no `expected_owner` match. Fails closed. + let attacker = new_kp(); + let owner = new_kp(); + let (a, o) = (peer_of(&attacker), peer_of(&owner)); + let forged = signed(&attacker, OpKind::Grant, &a, Some(Role::Owner), 1, None); + let roles = materialize(&[forged], &o); + assert!( + roles.is_empty(), + "no genesis matches the authoritative owner" + ); + } + + #[test] + fn owner_cannot_be_revoked_or_demoted() { + // Even a validly-signed op (here issued by a second Owner) targeting the + // authoritative owner must not remove/demote it — the founder is + // protected and at least one Owner always survives. + let owner = new_kp(); + let coowner = new_kp(); + let (o, c) = (peer_of(&owner), peer_of(&coowner)); + let g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + // Owner promotes coowner to Owner (no legitimate v1 API does this, but + // the DAG must stay sound even if such an op is constructed/injected). + let promote = signed( + &owner, + OpKind::Grant, + &c, + Some(Role::Owner), + 1, + Some(g.op_id.clone()), + ); + // coowner (a current Owner) tries to revoke the founding owner. + let revoke_owner = signed( + &coowner, + OpKind::Revoke, + &o, + None, + 1, + Some(promote.op_id.clone()), + ); + let roles = materialize(&[g, promote, revoke_owner], &o); + assert_eq!( + roles.get(&o), + Some(&Role::Owner), + "authoritative owner cannot be revoked" + ); + } + + #[test] + fn genesis_owner_extracts_unique_or_none() { + let owner = new_kp(); + let attacker = new_kp(); + let bob = new_kp(); + let (o, a, b) = (peer_of(&owner), peer_of(&attacker), peer_of(&bob)); + let g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + let add = signed( + &owner, + OpKind::Grant, + &b, + Some(Role::Collaborator), + 1, + Some(g.op_id.clone()), + ); + // Unique genesis → its issuer. + assert_eq!(genesis_owner(&[g.clone(), add.clone()]), Some(o.clone())); + // Two competing genesis claims → ambiguous → None (caller won't pin). + let forged_genesis = signed(&attacker, OpKind::Grant, &a, Some(Role::Owner), 1, None); + assert_eq!(genesis_owner(&[g, forged_genesis, add]), None); + } + + #[tokio::test] + async fn genesis_seeding_idempotent_and_db_round_trip() { + use crate::identity::IdentityManager; + use entity::workspace::workspaces; + use migration::{MigratorTrait, WorkspaceMigrator}; + use sea_orm::Database; + + let db = Database::connect("sqlite::memory:").await.unwrap(); + WorkspaceMigrator::up(&db, None).await.unwrap(); + + let identity = IdentityManager::for_tests().await; + let me = identity.peer_id().unwrap(); + + // The workspace's authoritative owner (`created_by`) is this device — + // it binds the genesis op that `ensure_genesis_owner` seeds. + let ws = Uuid::now_v7(); + workspaces::ActiveModel { + id: Set(ws), + name: Set("WS".to_string()), + created_by: Set(me.clone()), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + } + .insert(&db) + .await + .unwrap(); + + ensure_genesis_owner(&db, &identity, ws).await.unwrap(); + ensure_genesis_owner(&db, &identity, ws).await.unwrap(); // idempotent + + let ops = load_ops(&db, ws).await.unwrap(); + assert_eq!(ops.len(), 1, "genesis seeded exactly once"); + assert!( + ops[0].verify(), + "persisted genesis op signature round-trips" + ); + + let roles = load_and_materialize(&db, ws).await.unwrap(); + assert_eq!(roles.get(&me), Some(&Role::Owner), "creator is Owner"); + } + + #[tokio::test] + async fn forged_genesis_rejected_against_db_created_by() { + // End-to-end at the DB layer: a workspace owned by `owner`; an attacker + // injects (persists) its own self-signed genesis op. `load_and_materialize` + // reads `created_by` from the row and must reject the forged genesis. + use entity::workspace::workspaces; + use migration::{MigratorTrait, WorkspaceMigrator}; + use sea_orm::Database; + + let db = Database::connect("sqlite::memory:").await.unwrap(); + WorkspaceMigrator::up(&db, None).await.unwrap(); + + let owner = new_kp(); + let attacker = new_kp(); + let (o, a) = (peer_of(&owner), peer_of(&attacker)); + + let ws = Uuid::now_v7(); + workspaces::ActiveModel { + id: Set(ws), + name: Set("WS".to_string()), + created_by: Set(o.clone()), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + } + .insert(&db) + .await + .unwrap(); + + let g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + let forged = signed(&attacker, OpKind::Grant, &a, Some(Role::Owner), 1, None); + save_op(&db, ws, &g).await.unwrap(); + save_op(&db, ws, &forged).await.unwrap(); // attacker manages to persist it + + let roles = load_and_materialize(&db, ws).await.unwrap(); + assert_eq!(roles.get(&o), Some(&Role::Owner)); + assert_eq!( + roles.get(&a), + None, + "forged genesis persisted in DB still rejected by created_by binding" + ); + assert_eq!( + role_of(&db, ws, &a).await.unwrap(), + None, + "attacker is not an authorized member" + ); + } + + #[test] + fn deterministic_across_replays() { + let owner = new_kp(); + let bob = new_kp(); + let (o, b) = (peer_of(&owner), peer_of(&bob)); + let g = signed(&owner, OpKind::Grant, &o, Some(Role::Owner), 1, None); + let add = signed( + &owner, + OpKind::Grant, + &b, + Some(Role::Collaborator), + 1, + Some(g.op_id.clone()), + ); + let a = materialize(&[g.clone(), add.clone()], &o); + let c = materialize(&[add, g], &o); // different input order + assert_eq!(a, c); + } +} diff --git a/crates/core/src/workspace/sharing.rs b/crates/core/src/workspace/sharing.rs new file mode 100644 index 0000000..45879de --- /dev/null +++ b/crates/core/src/workspace/sharing.rs @@ -0,0 +1,168 @@ +//! Workspace sharing operations (owner side): grant/revoke a paired device's +//! membership and list current members. Grants/revokes are recorded as signed +//! [`permissions`] ops and broadcast over the ctrl topic so members converge. +//! +//! v1 grants the **Collaborator** role only (read+write); Reader is reserved +//! for v2. The granted device pulls the workspace via the existing sync flow — +//! its key request succeeds because the owner now recognizes its role +//! (see `coordinator::build_sealed_workspace_key`). + +use std::collections::HashMap; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::permissions::{self, OpKind, Role}; +use crate::app::AppCore; +use crate::device::{Device, DeviceFilter}; +use crate::error::{AppError, AppResult}; + +/// A workspace member (materialized role + device presence), for the +/// member-management UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct MemberInfo { + pub peer_id: String, + pub role: Role, + pub name: Option, + pub os: String, + pub is_online: bool, + /// `true` for this device's own row (the owner) — UI hides revoke/role edit. + pub is_self: bool, +} + +/// Owner-only: issue a signed permission op, persist it, and broadcast the +/// updated chain to workspace members. +async fn issue_owner_op( + core: &Arc, + ws_id: Uuid, + op_kind: OpKind, + target_peer_id: &str, + new_role: Option, +) -> AppResult<()> { + let ws = core + .get_workspace(&ws_id) + .await + .ok_or(AppError::NoWorkspaceOpen)?; + let db = ws.db(); + let identity = core.identity(); + let me = identity.peer_id()?; + + if permissions::role_of(db, ws_id, &me).await? != Some(Role::Owner) { + return Err(AppError::PermissionDenied( + "only the workspace owner can manage members".into(), + )); + } + if target_peer_id == me { + return Err(AppError::PermissionDenied( + "cannot change your own membership".into(), + )); + } + + let key_version = ws.keys().await.current_version().unwrap_or(1) as i32; + let prev = permissions::chain_tip(&permissions::load_ops(db, ws_id).await?); + let op = permissions::build_signed_op( + identity, + op_kind, + target_peer_id, + new_role, + key_version, + prev, + )?; + permissions::save_op(db, ws_id, &op).await?; + + // Broadcast the full chain so any subscribed member converges. + if let Some(sync) = ws.sync().await { + sync.publish_permission_ops(permissions::load_ops(db, ws_id).await?) + .await; + } + Ok(()) +} + +/// Grant a paired device Collaborator access to the workspace. +pub async fn grant_collaborator( + core: &Arc, + ws_id: Uuid, + target_peer_id: &str, +) -> AppResult<()> { + issue_owner_op( + core, + ws_id, + OpKind::Grant, + target_peer_id, + Some(Role::Collaborator), + ) + .await +} + +/// Revoke a member's access. Lazy: the device keeps any key it already holds +/// (so it can still read content it synced before), but its next key request +/// is denied and it receives no rotated key. True cut-off needs v2 key rotation. +pub async fn revoke_member( + core: &Arc, + ws_id: Uuid, + target_peer_id: &str, +) -> AppResult<()> { + issue_owner_op(core, ws_id, OpKind::Revoke, target_peer_id, None).await +} + +/// List the workspace's current members (materialized roles joined with device +/// presence). Pure read. +pub async fn list_members(core: &Arc, ws_id: Uuid) -> AppResult> { + let ws = core + .get_workspace(&ws_id) + .await + .ok_or(AppError::NoWorkspaceOpen)?; + let roles = permissions::load_and_materialize(ws.db(), ws_id).await?; + let me = core.identity().peer_id()?; + let my_info = core.identity().device_info()?; + + // Online set + device metadata from the running P2P node (if any). + let (online, devices): (Vec, Vec) = match core.net().await { + Some(net) => ( + net.device_manager + .connected_paired_peers() + .iter() + .map(|p| p.to_string()) + .collect(), + net.device_manager.get_devices(DeviceFilter::All), + ), + None => (Vec::new(), Vec::new()), + }; + let dev_by_peer: HashMap<&str, &Device> = + devices.iter().map(|d| (d.peer_id.as_str(), d)).collect(); + + let mut members: Vec = roles + .into_iter() + .map(|(peer_id, role)| { + let is_self = peer_id == me; + let dev = dev_by_peer.get(peer_id.as_str()); + MemberInfo { + name: if is_self { + Some(my_info.device_name.clone()) + } else { + dev.and_then(|d| d.name.clone()) + }, + os: if is_self { + my_info.os.clone() + } else { + dev.map(|d| d.os.clone()).unwrap_or_default() + }, + is_online: is_self || online.contains(&peer_id), + is_self, + peer_id, + role, + } + }) + .collect(); + + // Owner first, then stable by peer_id. + members.sort_by(|a, b| { + (a.role != Role::Owner) + .cmp(&(b.role != Role::Owner)) + .then(a.peer_id.cmp(&b.peer_id)) + }); + Ok(members) +} diff --git a/crates/core/src/workspace/sync/coordinator.rs b/crates/core/src/workspace/sync/coordinator.rs index bbe48a5..9802c27 100644 --- a/crates/core/src/workspace/sync/coordinator.rs +++ b/crates/core/src/workspace/sync/coordinator.rs @@ -19,7 +19,7 @@ use uuid::Uuid; use crate::app::AppCore; use crate::network::AppNetClient; -use crate::protocol::{AppResponse, SyncRequest, SyncResponse}; +use crate::protocol::{AppResponse, SealedWorkspaceKey, SyncRequest, SyncResponse}; use super::{asset_sync, doc_sync, full_sync}; @@ -67,6 +67,27 @@ impl AppSyncCoordinator { self.ensure_subscribed_and_sync(source, &ws).await; } } + super::CtrlMessage::PermissionOpsUpdate { + workspace_uuid, + ops, + } => { + let Some(ws) = self.core.get_workspace(&workspace_uuid).await else { + return; + }; + for op in &ops { + // Verify the signature before persisting so we never store + // forged ops (materialize would drop them anyway). + if !op.verify() { + warn!("Dropping invalid permission op from {source} for {workspace_uuid}"); + continue; + } + if let Err(e) = + crate::workspace::permissions::save_op(ws.db(), workspace_uuid, op).await + { + warn!("Failed to save permission op for {workspace_uuid}: {e}"); + } + } + } } } @@ -143,6 +164,12 @@ impl AppSyncCoordinator { match request { SyncRequest::DocList { workspace_uuid } => { info!("Inbound DocList request from {peer_id} for workspace {workspace_uuid}"); + if !self.is_authorized(workspace_uuid, peer_id).await { + warn!("Declining DocList to unauthorized peer {peer_id} for {workspace_uuid}"); + let resp = AppResponse::Sync(SyncResponse::DocList { docs: vec![] }); + let _ = self.client.send_response(pending_id, resp).await; + return; + } match full_sync::build_local_doc_list(&self.core, workspace_uuid).await { Ok(docs) => { let resp = AppResponse::Sync(SyncResponse::DocList { docs }); @@ -159,6 +186,10 @@ impl AppSyncCoordinator { info!("Inbound StateVector request from {peer_id} for doc {doc_id}"); match self.find_doc_context(doc_id).await.map(|(ws, _)| ws) { Some(ws_uuid) => { + if !self.is_authorized(ws_uuid, peer_id).await { + warn!("Declining StateVector to unauthorized {peer_id} for {ws_uuid}"); + return; + } if let Err(e) = doc_sync::handle_state_vector_request( &self.core, &self.client, @@ -179,6 +210,10 @@ impl AppSyncCoordinator { info!("Inbound FullSync request from {peer_id} for doc {doc_id}"); match self.find_doc_context(doc_id).await.map(|(ws, _)| ws) { Some(ws_uuid) => { + if !self.is_authorized(ws_uuid, peer_id).await { + warn!("Declining FullSync to unauthorized {peer_id} for {ws_uuid}"); + return; + } if let Err(e) = doc_sync::handle_full_sync_request( &self.core, &self.client, @@ -197,6 +232,9 @@ impl AppSyncCoordinator { SyncRequest::AssetManifest { doc_id } => { info!("Inbound AssetManifest request from {peer_id} for doc {doc_id}"); if let Some((ws_uuid, rel_path)) = self.find_doc_context(doc_id).await { + if !self.is_authorized(ws_uuid, peer_id).await { + return; + } if let Err(e) = asset_sync::handle_asset_manifest_request( &self.core, &self.client, @@ -217,6 +255,9 @@ impl AppSyncCoordinator { chunk_index, } => { if let Some((ws_uuid, rel_path)) = self.find_doc_context(doc_id).await { + if !self.is_authorized(ws_uuid, peer_id).await { + return; + } if let Err(e) = asset_sync::handle_asset_chunk_request( &self.core, &self.client, @@ -233,41 +274,142 @@ impl AppSyncCoordinator { } } } + SyncRequest::WorkspaceKey { workspace_uuid } => { + info!("Inbound WorkspaceKey request from {peer_id} for workspace {workspace_uuid}"); + let (sealed, ops) = self + .build_workspace_key_response(peer_id, workspace_uuid) + .await; + let resp = AppResponse::Sync(SyncResponse::WorkspaceKey { + workspace_uuid, + sealed, + ops, + }); + if let Err(e) = self.client.send_response(pending_id, resp).await { + warn!("Failed to send WorkspaceKey response to {peer_id}: {e}"); + } + } } } - /// Handle an incoming workspace-level GossipSub message. Routes to the - /// workspace's [`WorkspaceSync`] for open-doc apply or pending-buffer. + /// Whether `peer` is an authorized member (has any role) of the workspace — + /// the access-control gate for key distribution + sync responses. + async fn is_authorized(&self, workspace_uuid: Uuid, peer: PeerId) -> bool { + let Some(ws) = self.core.get_workspace(&workspace_uuid).await else { + return false; + }; + matches!( + crate::workspace::permissions::role_of(ws.db(), workspace_uuid, &peer.to_string()) + .await, + Ok(Some(_)) + ) + } + + /// Build the sealed key + permission chain for a requester. The key is + /// sealed **only if the requester is an authorized member** (has a role); + /// a paired-but-unauthorized peer gets `(None, [])`. `ops` lets an authorized + /// requester materialize its own role locally. + async fn build_workspace_key_response( + &self, + requester: PeerId, + workspace_uuid: Uuid, + ) -> ( + Option, + Vec, + ) { + let Some(ws) = self.core.get_workspace(&workspace_uuid).await else { + return (None, vec![]); + }; + let db = ws.db(); + let role = + crate::workspace::permissions::role_of(db, workspace_uuid, &requester.to_string()) + .await + .ok() + .flatten(); + if role.is_none() { + warn!("Declining WorkspaceKey to unauthorized peer {requester} for {workspace_uuid}"); + return (None, vec![]); + } + let ops = crate::workspace::permissions::load_ops(db, workspace_uuid) + .await + .unwrap_or_default(); + let keys = ws.keys().await; + let (Ok(recipient_public), Ok(my_secret)) = ( + crate::identity::peer_id_to_x25519_public(&requester), + self.core.identity().x25519_secret(), + ) else { + return (None, ops); + }; + match crate::workspace::keys::seal_keys_for_recipient( + &my_secret, + &recipient_public, + &keys, + true, + ) { + Ok((key_version, sealed_read, sealed_write)) => ( + Some(SealedWorkspaceKey { + key_version, + sealed_read, + sealed_write, + }), + ops, + ), + Err(_) => (None, ops), + } + } + + /// Handle an incoming **encrypted** workspace-level doc-update GossipSub + /// message: decrypt with the workspace key (drop if undecryptable — unknown + /// key_version / wrong key / tamper), then route to [`WorkspaceSync`]. pub async fn handle_ws_gossip_update( &self, source: Option, workspace_uuid: Uuid, - doc_uuid: Uuid, data: Vec, ) { let Some(ws) = self.core.get_workspace(&workspace_uuid).await else { return; }; + let keys = ws.keys().await; + let (doc_uuid, update) = match super::decode_encrypted_gossip( + &keys, + &workspace_uuid, + super::MSG_TYPE_DOC, + &data, + ) { + Ok(v) => v, + Err(e) => { + tracing::debug!("dropping undecryptable ws gossip for {workspace_uuid}: {e}"); + return; + } + }; if let Some(ws_sync) = ws.sync().await { ws_sync - .handle_gossip_update(&ws, source, doc_uuid, data) + .handle_gossip_update(&ws, source, doc_uuid, update) .await; } } - /// Handle an incoming workspace-level awareness GossipSub message. Pure - /// fan-out to the event bus — no persistence, no apply, no buffering. - pub async fn handle_ws_awareness_gossip( - &self, - workspace_uuid: Uuid, - doc_uuid: Uuid, - data: Vec, - ) { + /// Handle an incoming **encrypted** awareness GossipSub message: decrypt, + /// then pure fan-out to the event bus (no persistence/apply/buffer). + pub async fn handle_ws_awareness_gossip(&self, workspace_uuid: Uuid, data: Vec) { let Some(ws) = self.core.get_workspace(&workspace_uuid).await else { return; }; + let keys = ws.keys().await; + let (doc_uuid, update) = match super::decode_encrypted_gossip( + &keys, + &workspace_uuid, + super::MSG_TYPE_AWARENESS, + &data, + ) { + Ok(v) => v, + Err(e) => { + tracing::trace!("dropping undecryptable awareness for {workspace_uuid}: {e}"); + return; + } + }; if let Some(ws_sync) = ws.sync().await { - ws_sync.handle_awareness_gossip(&ws, doc_uuid, data); + ws_sync.handle_awareness_gossip(&ws, doc_uuid, update); } } diff --git a/crates/core/src/workspace/sync/full_sync.rs b/crates/core/src/workspace/sync/full_sync.rs index 16fa4f5..20e9eba 100644 --- a/crates/core/src/workspace/sync/full_sync.rs +++ b/crates/core/src/workspace/sync/full_sync.rs @@ -120,6 +120,105 @@ pub async fn request_doc_list( } } +/// Ensure we hold this workspace's key before syncing. Joined workspaces start +/// keyless; request the owner's key (sealed to us) over the Noise-encrypted +/// request-response channel and install it. Best-effort: on failure, RR-based +/// full sync still pulls document state — only real-time gossip stays +/// undecryptable until a key arrives. +async fn ensure_workspace_key( + core: &Arc, + client: &AppNetClient, + peer_id: PeerId, + workspace_uuid: Uuid, +) { + let Some(ws) = core.get_workspace(&workspace_uuid).await else { + return; + }; + if !ws.keys().await.is_empty() { + return; + } + + let request = AppRequest::Sync(SyncRequest::WorkspaceKey { workspace_uuid }); + let response = match tokio::time::timeout( + Duration::from_secs(5), + client.send_request(peer_id, request), + ) + .await + { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + warn!("WorkspaceKey request to {peer_id} failed: {e}"); + return; + } + Err(_) => { + warn!("WorkspaceKey request to {peer_id} timed out"); + return; + } + }; + + let AppResponse::Sync(SyncResponse::WorkspaceKey { sealed, ops, .. }) = response else { + warn!("Peer {peer_id} returned unexpected response for workspace key"); + return; + }; + + // Persist the permission chain so we can materialize our own role, even if + // the key itself was declined. + for op in &ops { + if op.verify() { + let _ = crate::workspace::permissions::save_op(ws.db(), workspace_uuid, op).await; + } + } + + // Pin our local `created_by` to the chain's authoritative owner. A fresh + // joiner's row was created with this device as `created_by`; without this + // pin, `materialize` would reject the owner's genesis (issuer != our + // `created_by`) and our role map would be empty. We trust the single + // genesis in the chain we just received over the Noise-authenticated RR + // channel from the peer we chose to sync from. + if let Some(owner) = crate::workspace::permissions::genesis_owner(&ops) { + if let Err(e) = crate::workspace::pin_workspace_owner(ws.db(), workspace_uuid, &owner).await + { + warn!("Failed to pin workspace owner for {workspace_uuid}: {e}"); + } + } + + let Some(sk) = sealed else { + warn!("Peer {peer_id} declined workspace key for {workspace_uuid} (not authorized?)"); + return; + }; + + let my_peer = match core.identity().peer_id() { + Ok(p) => p, + Err(e) => { + warn!("Cannot install workspace key: {e}"); + return; + } + }; + + if let Err(e) = crate::workspace::keys::install_received_key( + ws.db(), + workspace_uuid, + &my_peer, + &peer_id.to_string(), + sk.key_version, + sk.sealed_read, + sk.sealed_write, + ) + .await + { + warn!("Failed to install received workspace key: {e}"); + return; + } + + match ws.reload_keys().await { + Ok(()) => info!( + "Installed workspace key v{} for {workspace_uuid} from {peer_id}", + sk.key_version + ), + Err(e) => warn!("Failed to reload keys after install: {e}"), + } +} + /// Diff remote DocList against local state to produce a sync plan. /// /// Single-direction: decides what the **local** side needs to do based on @@ -281,6 +380,10 @@ async fn run_full_sync( workspace_uuid: Uuid, cancel: &CancellationToken, ) -> AppResult<()> { + // 0. Acquire the workspace key if we don't have one (joined workspaces + // start keyless). Best-effort — doesn't block doc sync. + ensure_workspace_key(core, client, peer_id, workspace_uuid).await; + // 1. Exchange DocLists let remote_docs = request_doc_list(client, peer_id, workspace_uuid).await?; let local_docs = build_local_doc_list(core, workspace_uuid).await?; diff --git a/crates/core/src/workspace/sync/mod.rs b/crates/core/src/workspace/sync/mod.rs index daa701d..7f0fc9a 100644 --- a/crates/core/src/workspace/sync/mod.rs +++ b/crates/core/src/workspace/sync/mod.rs @@ -36,6 +36,11 @@ pub const CTRL_TOPIC: &str = "swarmnote/ctrl"; pub enum CtrlMessage { /// A peer opened a workspace — receivers with the same workspace should subscribe + sync. WorkspaceOpened { uuid: Uuid }, + /// A peer broadcasts new/updated signed permission ops for a workspace. + PermissionOpsUpdate { + workspace_uuid: Uuid, + ops: Vec, + }, } pub fn encode_ctrl_message(msg: &CtrlMessage) -> Vec { @@ -130,3 +135,155 @@ pub fn decode_ws_awareness(data: &[u8]) -> Option<(Uuid, &[u8])> { let doc_uuid = Uuid::from_bytes(uuid_bytes); Some((doc_uuid, &data[16..])) } + +// ── Encrypted GossipSub payload codec (E2E) ── +// +// Wraps the legacy `[16B doc_uuid][bytes]` layout in workspace-key encryption: +// wire = [16B doc_uuid (plaintext, routing)] [XChaCha20-Poly1305 frame] +// The doc_uuid stays plaintext so a recipient can route before decrypting, and +// is bound into the AAD so it cannot be swapped. `msg_type` separates the +// doc-update and awareness channels (prevents cross-channel replay). The frame +// header carries `key_version`, so the recipient picks the right key from its +// `{key_version → key}` history. See `dev-notes/design/08-e2e-encryption.md`. + +use crate::crypto::{self, Purpose}; +use crate::error::{AppError, AppResult}; +use crate::workspace::keys::WorkspaceKeys; + +/// AAD `msg_type` for encrypted doc-update broadcasts (`ws` channel). +pub const MSG_TYPE_DOC: u8 = 1; +/// AAD `msg_type` for encrypted awareness broadcasts (`ws-aw` channel). +pub const MSG_TYPE_AWARENESS: u8 = 2; + +fn gossip_aad(workspace_id: &Uuid, doc_uuid: &Uuid, key_version: u32, msg_type: u8) -> Vec { + let mut aad = Vec::with_capacity(16 + 16 + 4 + 1); + aad.extend_from_slice(workspace_id.as_bytes()); + aad.extend_from_slice(doc_uuid.as_bytes()); + aad.extend_from_slice(&key_version.to_be_bytes()); + aad.push(msg_type); + aad +} + +/// Encrypt a workspace gossip payload under the current workspace read key. +pub fn encode_encrypted_gossip( + keys: &WorkspaceKeys, + workspace_id: &Uuid, + doc_uuid: &Uuid, + msg_type: u8, + plaintext: &[u8], +) -> AppResult> { + let (version, set) = keys.current().ok_or(AppError::Crypto { + context: "gossip-encode", + reason: "workspace has no key".into(), + })?; + let aad = gossip_aad(workspace_id, doc_uuid, version, msg_type); + let frame = crypto::seal( + &set.read_key, + Purpose::Gossip, + workspace_id.as_bytes(), + version, + &aad, + plaintext, + )?; + let mut wire = Vec::with_capacity(16 + frame.len()); + wire.extend_from_slice(doc_uuid.as_bytes()); + wire.extend_from_slice(&frame); + Ok(wire) +} + +/// Decrypt a workspace gossip payload, selecting the key by the frame's +/// `key_version`. Returns `(doc_uuid, plaintext)`. Fails (rejects) on unknown +/// key version, commitment mismatch, AAD/channel mismatch, or tamper. +pub fn decode_encrypted_gossip( + keys: &WorkspaceKeys, + workspace_id: &Uuid, + msg_type: u8, + wire: &[u8], +) -> AppResult<(Uuid, Vec)> { + if wire.len() <= 16 { + return Err(AppError::Crypto { + context: "gossip-decode", + reason: "payload too short".into(), + }); + } + let uuid_bytes: [u8; 16] = wire[..16].try_into().expect("checked len > 16"); + let doc_uuid = Uuid::from_bytes(uuid_bytes); + let frame = &wire[16..]; + + let version = crypto::frame_key_version(frame)?; + let read_key = keys.read_key(version).ok_or(AppError::Crypto { + context: "gossip-decode", + reason: format!("no read key for version {version}"), + })?; + let aad = gossip_aad(workspace_id, &doc_uuid, version, msg_type); + let plaintext = crypto::open( + read_key, + Purpose::Gossip, + workspace_id.as_bytes(), + version, + &aad, + frame, + )?; + Ok((doc_uuid, plaintext)) +} + +#[cfg(test)] +mod codec_tests { + use super::*; + + fn keys(read: u8) -> WorkspaceKeys { + WorkspaceKeys::test_single(1, [read; 32], Some([read.wrapping_add(1); 32])) + } + + #[test] + fn round_trip_recovers_doc_uuid_and_plaintext() { + let ks = keys(3); + let ws = Uuid::from_u128(0x1111); + let doc = Uuid::from_u128(0x2222); + let wire = encode_encrypted_gossip(&ks, &ws, &doc, MSG_TYPE_DOC, b"y-update").unwrap(); + // doc_uuid is plaintext-routable from the wire prefix. + assert_eq!(&wire[..16], doc.as_bytes()); + let (got_doc, pt) = decode_encrypted_gossip(&ks, &ws, MSG_TYPE_DOC, &wire).unwrap(); + assert_eq!(got_doc, doc); + assert_eq!(pt, b"y-update"); + } + + #[test] + fn wrong_workspace_key_rejected() { + let ws = Uuid::from_u128(1); + let doc = Uuid::from_u128(2); + let wire = encode_encrypted_gossip(&keys(3), &ws, &doc, MSG_TYPE_DOC, b"x").unwrap(); + // Different read key, same version → commitment mismatch. + assert!(decode_encrypted_gossip(&keys(9), &ws, MSG_TYPE_DOC, &wire).is_err()); + } + + #[test] + fn cross_channel_replay_rejected() { + let ws = Uuid::from_u128(1); + let doc = Uuid::from_u128(2); + let wire = encode_encrypted_gossip(&keys(3), &ws, &doc, MSG_TYPE_DOC, b"x").unwrap(); + // A doc-update frame must not decrypt as awareness (AAD msg_type differs). + assert!(decode_encrypted_gossip(&keys(3), &ws, MSG_TYPE_AWARENESS, &wire).is_err()); + } + + #[test] + fn unknown_key_version_rejected() { + let ws = Uuid::from_u128(1); + let doc = Uuid::from_u128(2); + let wire = encode_encrypted_gossip(&keys(3), &ws, &doc, MSG_TYPE_DOC, b"x").unwrap(); + // Recipient only holds version 2 → cannot decrypt a version-1 frame. + let only_v2 = WorkspaceKeys::test_single(2, [3; 32], Some([4; 32])); + assert!(decode_encrypted_gossip(&only_v2, &ws, MSG_TYPE_DOC, &wire).is_err()); + } + + #[test] + fn wrong_workspace_id_rejected() { + let doc = Uuid::from_u128(2); + let wire = encode_encrypted_gossip(&keys(3), &Uuid::from_u128(1), &doc, MSG_TYPE_DOC, b"x") + .unwrap(); + // Same key but different workspace_id in AAD → reject. + assert!( + decode_encrypted_gossip(&keys(3), &Uuid::from_u128(99), MSG_TYPE_DOC, &wire).is_err() + ); + } +} diff --git a/crates/core/src/workspace/sync/workspace_sync.rs b/crates/core/src/workspace/sync/workspace_sync.rs index bbbf149..5879f18 100644 --- a/crates/core/src/workspace/sync/workspace_sync.rs +++ b/crates/core/src/workspace/sync/workspace_sync.rs @@ -95,11 +95,53 @@ impl WorkspaceSync { } } - /// Broadcast a local edit to connected peers via GossipSub. On failure, - /// signals the coordinator to run urgent SV compensation. + /// Broadcast signed permission ops for this workspace to all peers via the + /// ctrl topic, so members converge on the same `peer → role` map. + pub async fn publish_permission_ops( + &self, + ops: Vec, + ) { + if ops.is_empty() { + return; + } + let payload = super::encode_ctrl_message(&super::CtrlMessage::PermissionOpsUpdate { + workspace_uuid: self.workspace_id, + ops, + }); + if let Err(e) = self.client.publish(super::CTRL_TOPIC, payload).await { + warn!( + "Failed to publish permission ops for {}: {e}", + self.workspace_id + ); + } + } + + /// Encrypt a gossip payload under the workspace's current key. Returns + /// `None` if the workspace is gone or holds no key yet (e.g. a joined + /// workspace still awaiting the owner's Lockbox — nothing to broadcast). + async fn encrypt_gossip(&self, doc_uuid: Uuid, msg_type: u8, update: &[u8]) -> Option> { + let ws = self.core.get_workspace(&self.workspace_id).await?; + let keys = ws.keys().await; + match super::encode_encrypted_gossip(&keys, &self.workspace_id, &doc_uuid, msg_type, update) + { + Ok(payload) => Some(payload), + Err(e) => { + warn!("Failed to encrypt gossip for doc {doc_uuid}: {e}"); + None + } + } + } + + /// Broadcast a local edit to connected peers via GossipSub (encrypted under + /// the workspace key). On failure, signals urgent SV compensation. pub async fn publish_doc_update(&self, doc_uuid: Uuid, update: Vec) { + let Some(payload) = self + .encrypt_gossip(doc_uuid, super::MSG_TYPE_DOC, &update) + .await + else { + return; + }; let topic = super::ws_topic(&self.workspace_id); - let payload = super::encode_ws_gossip(&doc_uuid, &update); if let Err(e) = self.client.publish(&topic, payload).await { tracing::debug!("Failed to publish doc update to {topic}: {e}"); // Schedule urgent SV compensation to ensure data consistency. @@ -109,13 +151,17 @@ impl WorkspaceSync { } } - /// Broadcast a local awareness update to peers. Awareness is ephemeral — - /// failure to publish only means peers won't see this presence beat; - /// no compensation logic is needed (the next beat or a full reconnect - /// will resync). + /// Broadcast a local awareness update to peers (encrypted). Awareness is + /// ephemeral — a dropped beat just means peers miss this presence update; + /// the next beat or a reconnect resyncs. pub async fn publish_awareness(&self, doc_uuid: Uuid, update: Vec) { + let Some(payload) = self + .encrypt_gossip(doc_uuid, super::MSG_TYPE_AWARENESS, &update) + .await + else { + return; + }; let topic = super::ws_awareness_topic(&self.workspace_id); - let payload = super::encode_ws_awareness(&doc_uuid, &update); if let Err(e) = self.client.publish(&topic, payload).await { tracing::debug!("Failed to publish awareness to {topic}: {e}"); } diff --git a/crates/entity/src/workspace/mod.rs b/crates/entity/src/workspace/mod.rs index 4ca7f00..73dfbde 100644 --- a/crates/entity/src/workspace/mod.rs +++ b/crates/entity/src/workspace/mod.rs @@ -2,7 +2,9 @@ pub mod deletion_log; pub mod doc_chunks; pub mod documents; pub mod folders; +pub mod permission_ops; pub mod permissions; pub mod share_invites; +pub mod workspace_key_lockboxes; pub mod workspace_keys; pub mod workspaces; diff --git a/crates/entity/src/workspace/permission_ops.rs b/crates/entity/src/workspace/permission_ops.rs new file mode 100644 index 0000000..96d3063 --- /dev/null +++ b/crates/entity/src/workspace/permission_ops.rs @@ -0,0 +1,26 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// One node in the signed, append-only permission operation chain (auth DAG). +/// `op_id` is the content hash of the canonical op; `signature` is the issuer +/// device's Ed25519 signature over the op fields; `prev_hash` links the causal +/// predecessor (None for the genesis op). Replay validates signature + issuer +/// authority + anti-escalation before materializing into `permissions`. +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "permission_ops")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub op_id: String, + pub workspace_id: Uuid, + pub op_kind: String, + pub target_peer_id: String, + pub new_role: String, + pub issuer_peer_id: String, + pub key_version: i32, + pub prev_hash: Option, + pub signature: Vec, + pub created_at: DateTimeUtc, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/entity/src/workspace/share_invites.rs b/crates/entity/src/workspace/share_invites.rs index cf1aec2..d6ac86b 100644 --- a/crates/entity/src/workspace/share_invites.rs +++ b/crates/entity/src/workspace/share_invites.rs @@ -18,6 +18,9 @@ pub struct Model { #[sea_orm(default_value = 0)] pub used_count: i32, pub password_hash: Option, + /// Key commitment over the secret/password-wrapped invite (partitioning- + /// oracle defense). Nullable for rows created before this column existed. + pub commitment: Option>, } impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/entity/src/workspace/workspace_key_lockboxes.rs b/crates/entity/src/workspace/workspace_key_lockboxes.rs new file mode 100644 index 0000000..de362ed --- /dev/null +++ b/crates/entity/src/workspace/workspace_key_lockboxes.rs @@ -0,0 +1,24 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// One per-device key Lockbox: the workspace `read_key` (and, for writers, +/// `write_key`) of a given `key_version`, sealed to a recipient device's X25519 +/// public key. `sealed_*` blobs are self-contained Lockbox frames (nonce + +/// commitment + ciphertext). A read-only recipient has `sealed_write_key = None`. +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "workspace_key_lockboxes")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub workspace_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub key_version: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub recipient_peer_id: String, + pub sealed_read_key: Vec, + pub sealed_write_key: Option>, + pub sealed_by_peer_id: String, + pub created_at: DateTimeUtc, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index 43fb5c8..ec98466 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -7,6 +7,7 @@ mod m20260331_000004_rel_path_unique; mod m20260401_000005_datetime_text_workspace; mod m20260401_000006_datetime_text_devices; mod m20260407_000007_add_device_name; +mod m20260607_000008_e2e_sharing; pub struct DevicesMigrator; @@ -31,6 +32,7 @@ impl MigratorTrait for WorkspaceMigrator { Box::new(m20260330_000003_uuid_stabilization::Migration), Box::new(m20260331_000004_rel_path_unique::Migration), Box::new(m20260401_000005_datetime_text_workspace::Migration), + Box::new(m20260607_000008_e2e_sharing::Migration), ] } } diff --git a/crates/migration/src/m20260607_000008_e2e_sharing.rs b/crates/migration/src/m20260607_000008_e2e_sharing.rs new file mode 100644 index 0000000..dfd7642 --- /dev/null +++ b/crates/migration/src/m20260607_000008_e2e_sharing.rs @@ -0,0 +1,75 @@ +//! E2E sharing schema: per-device key Lockboxes + signed permission op chain, +//! plus a `commitment` column on `share_invites` (key-committing link invites). +//! See `dev-notes/design/{04-permissions,05-sharing,08-e2e-encryption}.md`. + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // Per-device key Lockboxes: one row per (workspace, key_version, recipient + // device). `sealed_read_key` / `sealed_write_key` are independent X25519 + // sealed envelopes (each frame self-contains its nonce + commitment). + // A v2 read-only recipient has `sealed_write_key = NULL`. + db.execute_unprepared( + "CREATE TABLE IF NOT EXISTS workspace_key_lockboxes ( + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + key_version INTEGER NOT NULL, + recipient_peer_id TEXT NOT NULL, + sealed_read_key BLOB NOT NULL, + sealed_write_key BLOB, + sealed_by_peer_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, key_version, recipient_peer_id) + )", + ) + .await?; + + // Signed, append-only permission operation chain (auth DAG). Each op is + // identified by its content hash (`op_id`) and signed by the issuer + // device's Ed25519 key; `prev_hash` links the causal predecessor. + db.execute_unprepared( + "CREATE TABLE IF NOT EXISTS permission_ops ( + op_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + op_kind TEXT NOT NULL, + target_peer_id TEXT NOT NULL, + new_role TEXT NOT NULL, + issuer_peer_id TEXT NOT NULL, + key_version INTEGER NOT NULL, + prev_hash TEXT, + signature BLOB NOT NULL, + created_at TEXT NOT NULL + )", + ) + .await?; + db.execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_permission_ops_ws ON permission_ops(workspace_id)", + ) + .await?; + + // Link-invite key commitment (defends partitioning-oracle on the + // password/secret-wrapped invite). Nullable: pre-existing rows have none. + db.execute_unprepared("ALTER TABLE share_invites ADD COLUMN commitment BLOB") + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + db.execute_unprepared("DROP TABLE IF EXISTS permission_ops") + .await?; + db.execute_unprepared("DROP TABLE IF EXISTS workspace_key_lockboxes") + .await?; + // SQLite 3.35+ supports DROP COLUMN. + db.execute_unprepared("ALTER TABLE share_invites DROP COLUMN commitment") + .await?; + Ok(()) + } +} diff --git a/dev-notes/design/04-permissions.md b/dev-notes/design/04-permissions.md index 362d595..4342462 100644 --- a/dev-notes/design/04-permissions.md +++ b/dev-notes/design/04-permissions.md @@ -1,164 +1,74 @@ -# 权限模型 +# 权限模型(v1 定稿) -## 角色定义 +> **2026-06 调研定稿**,取代 2026-03 的三级(Owner/Editor/Reader)纸面版。v1 收敛为**两级 Owner/Collaborator**(真正的只读 Reader 后置 v2),并把权限变更做成**签名操作链**而非可变 DB 行。 +> 加密底层见 [08-e2e-encryption.md](08-e2e-encryption.md),分享流程见 [05-sharing.md](05-sharing.md)。 -| 角色 | 读 | 编辑 | 管理权限 | 删除 | 密码学能力 | -|------|:--:|:----:|:--------:|:----:|:----------:| -| Owner | ✓ | ✓ | ✓ | ✓ | 持有主密钥(read_key + write_key + admin_key) | -| Editor | ✓ | ✓ | | | 持有 read_key + write_key | -| Reader | ✓ | | | | 仅持有 read_key | +## 角色(v1 两级) -- **Owner**:资源创建者,唯一能管理权限(授权/撤销/转让)和删除资源的角色 -- **Editor**:可编辑文档内容(CRDT 双向同步) -- **Reader**:只读,只接收文档更新 +| 角色 | 读 | 写 | 管理成员 | 密钥持有 | +|------|:--:|:--:|:--------:|---------| +| **Owner** | ✓ | ✓ | ✓(授权/移除/轮换)| read_key + write_key(+ 管理权)| +| **Collaborator** | ✓ | ✓ | | read_key + write_key | -> 三级权限模型。不设 Commenter(评论系统对个人/小团队笔记场景优先级低,增加的复杂度不值得)。不设 Manager(飞书有但我们不做,单 Owner 足够)。 +- **v1 不做真正的只读 Reader**:有 key 即可读写。原因——真 Reader 必须每条 update 设备签名 + 合并前校验 + 丢弃越权写入,是巨大工作量。 +- **v2 Reader 是开关不是重构**:v1 已(a)read/write 分两把独立 key、(b)每条 update 携带设备 Ed25519 签名(携带但不强制校验)。v2 加 Reader = 「只发 read_key 的 lockbox + 打开签名校验丢弃越权 update」,无新表、无 re-key、无历史回填。 -## 密码学权限执行 +## 密码学执行(P2P 无服务器) -P2P 没有服务器强制执行权限,改用**密钥分发**控制访问: +没有服务器强制权限,用**密钥分发 + 签名**替代: -### 每个工作区的密钥体系 +- **读 = 持有 read_key**:能解密 = 能读。通过 X25519 Lockbox 分发(见 [05-sharing.md](05-sharing.md))。 +- **写 = 持有 write_key**:v1「有 key 即可写」,update 携带设备签名但**不强制校验**(为 v2 铺路)。 +- **管理 = Owner**:成员变更操作必须由 Owner 的设备 Ed25519 签名(见下)。 -```text -Workspace 创建时 Owner 生成: -├── read_key (对称密钥,ChaCha20-Poly1305) → 解密文档内容 -├── write_key (对称密钥) → 签署 CRDT 编辑操作 -└── admin_key (对称密钥) → 签署权限变更操作 -``` - -### 密钥分发规则 - -| 授予角色 | 分发的密钥 | -|---------|-----------| -| Reader | read_key | -| Editor | read_key + write_key | -| Owner(转让) | read_key + write_key + admin_key | - -### 密钥传输 - -通过已配对的 P2P 加密通道传输(libp2p Noise 协议已提供传输层加密)。链接分享场景下,密钥嵌入在邀请 token 中(见 [05-sharing.md](05-sharing.md))。 - -### 操作验证 - -收到远程操作时,本地验证: - -```text -收到 CRDT Update: - → 验证发送者持有 write_key(检查操作签名) - → 验证失败 → 丢弃该 update - -收到权限变更: - → 验证发送者持有 admin_key - → 验证失败 → 丢弃 -``` - -## 权限继承 - -```text -Workspace 权限 + 密钥 - └─ 向下继承到 Folder - └─ 向下继承到 Document -``` - -- 子级**默认继承**父级权限和密钥 -- 子级可以**覆盖**继承的权限(提升或降低) -- 冲突解决:**直接授权优先于继承**,与 Notion 类似 - - 同一设备通过多条路径获得权限时,取**最高权限**(Notion 的"最高权限胜出"规则) -- 覆盖记录单独存储,删除覆盖则恢复继承 - -示例: +## 权限表 = 签名操作链(v1 就做) -- Workspace 授予 B 设备 Editor → B 可编辑该工作区下所有文档 -- 某个 Folder 将 B 降为 Reader → B 只能查看该文件夹下的文档 -- 该 Folder 下某篇 Document 将 B 提升为 Editor → B 可编辑这篇文档 - -### Folder/Document 级别独立密钥(可选扩展) - -默认所有文档共享工作区密钥。如果需要更细粒度控制(如某个 Folder 有独立的 write_key),可为该 Folder 生成独立密钥组,降级用户只分发 read_key。 - -此特性复杂度较高,MVP 阶段建议工作区级别统一密钥,后续按需扩展。 - -## 权限数据结构 +**不能**把成员/角色当普通可变 DB 行在 P2P 间同步——任何节点都能伪造一行把自己设成 Owner。做成 **append-only 的签名操作 DAG**(对齐 Matrix auth chain / Jazz 角色 transaction / p2panda Causal-Length CRDT): ```rust -enum ResourceType { - Workspace, - Folder, - Document, -} - -enum Role { - Owner, - Editor, - Reader, -} - -/// 一条权限记录 -struct Permission { - resource_type: ResourceType, - resource_id: Uuid, // workspace/folder/document 的 ID - peer_id: PeerId, // 被授权的设备 - role: Role, - granted_by: PeerId, // 授权者 - granted_at: i64, +// 新增 permission_ops 表(append-only) +struct PermissionOp { + op: OpKind, // Add / Remove / Promote / TransferOwner + target_peer_id: String, // 被操作设备 + new_role: Role, + issuer_peer_id: String, // 发起设备 + key_version: i32, // 关联的 workspace key 版本 + prev_hash: Vec, // 因果前序(构成 DAG/链) + issuer_signature: Vec, // issuer 设备 Ed25519 签名(覆盖以上全部字段) } ``` -## 权限解析算法 +每个节点收到后**本地重放并校验**三条不变量: -查询某设备对某文档的有效权限: +1. **签名有效**:`issuer_signature` 由 `issuer_peer_id` 对应的 Ed25519 公钥验证通过。 +2. **issuer 有权**:在该操作的因果前序里,`issuer` 确实是 Owner(或对该操作有权)。 +3. **不得提权越级**:不能把任何人设到高于 issuer 自己的等级(Matrix 防提权不变量)。 -```text -1. 查该文档是否有直接授权 → 有则返回 -2. 查父文件夹是否有直接授权 → 有则返回 -3. 递归向上查到工作区 → 有则返回 -4. 无任何授权 → 拒绝访问 -``` +任一不满足 → 丢弃该 op。改一条记录就得伪造整条签名链(不可行)。这使无中心、离线可判定。 -如果通过多条路径获得不同角色(如工作区 Editor + 文件夹 Reader),取最高权限。 +> **当前 schema 缺口**:现有 `permissions` 表是普通行结构(`id/resource_type/resource_id/peer_id/role/granted_by/granted_at`)。v1 需新增 `permission_ops` 表承载签名操作链;`permissions` 可作为重放后的物化视图(可重建索引)。 -## 权限撤销与密钥轮换 +## 撤销与密钥轮换(lazy re-encryption) -### 核心挑战 +移除某 Collaborator 设备时: -P2P 场景下权限撤销比中心化产品困难: -- 无服务器可以立即切断访问 -- 被撤销者已持有解密密钥,本地已有数据无法收回 -- 被撤销者可能离线,无法实时通知 +1. Owner 生成新 `read_key` + `write_key`,`key_version + 1`。 +2. **只**为剩余设备重新封 Lockbox(新增 `workspace_key_lockboxes` 行),不给被移除设备。 +3. 此后新 gossip update / awareness / 资产用新 key 加密,payload 头部 `key_version` 标新版本。 +4. **旧密文不重写**——去中心化下旧 update 已散落各设备、各人本地有旧 key,重写既不干净又破坏 CRDT 历史连续性(SecSync 能丢旧 snapshot 是因为有中心 relay,SwarmNote 没有)。 +5. permissions 记一条签名的 `Remove` op(非裸删行)。 -### 撤销策略 +**key 历史**永久保留 `{key_version → (read_key, write_key)}`(CRDT 必须能重放全历史)。 -#### 即时效果 +### 并发撤销收敛 -```text -Owner 撤销 B 的权限: -1. 从本地权限表删除 B 的记录 -2. 广播 PermissionRevoked 消息给所有在线协作者 -3. 所有在线节点停止向 B 发送文档更新 -4. B 的本地数据保留,但不再接收新内容 -``` +去中心化下两设备可能并发触发轮换,致 `key_version` 冲突。用确定性收敛——`key_version` 用 `(counter, 触发者 PeerId)` 做全序 tie-break,或并发时两个新 key 都保留(谁都能解)直到下次轮换合并(借鉴 BeeKEM coordination-free revocation 思想,不上 BeeKEM 本体)。 -#### 密钥轮换(彻底撤销) +### 诚实声明(写进威胁模型) -当需要确保被撤销者无法解密后续内容时: +撤销是 **lazy** 的:被移除设备保留它离开前的 read_key,**对它离开前能看到的明文 `.md` / 旧密文永久可读**。撤销只保证「读不到轮换之后的新内容」。这是所有本地优先 E2EE 方案的固有属性(Jazz/SecSync/Keyhive 均如此),与「不防丢设备」决策自洽。**别向用户承诺「踢人即焚」。** 详见 [11-threat-model.md](11-threat-model.md)。 -```text -Owner 轮换密钥: -1. 生成新的 read_key / write_key -2. 向所有仍有权限的设备分发新密钥(通过 P2P 加密通道) -3. 后续文档更新用新密钥加密 -4. 被撤销者的旧密钥只能解密轮换前的内容 -``` +## 权限粒度 -> 参考 Google Docs 的做法:移除协作者后,对方保留已下载的内容但无法访问新内容。密钥轮换是 P2P 下的等价实现。 - -### Owner 转让 - -```text -A 转让 Owner 给 B: -1. A 将 admin_key 通过加密通道发送给 B -2. A 本地将自己的角色降为 Editor(或其他指定角色) -3. 广播 OwnerTransferred 消息 -4. 同一资源始终只有一个 Owner(避免飞书/Notion 中的多管理员冲突) -``` +- **v1 = 工作区级统一密钥**:一个 workspace 一组 read/write key,覆盖其下所有文档。 +- **Folder/Document 级独立密钥 + 继承(向下级联 + 可覆盖 + 最高权限胜出)**:复杂度高,后置 v2+。届时用 HKDF 从 workspace key 派生 folder/doc key。 diff --git a/dev-notes/design/05-sharing.md b/dev-notes/design/05-sharing.md index e32774f..5aac00c 100644 --- a/dev-notes/design/05-sharing.md +++ b/dev-notes/design/05-sharing.md @@ -1,85 +1,92 @@ -# 分享机制 +# 分享机制(v1 定稿) -## 已配对设备分享 +> **2026-06 调研定稿**。两条路径:① 已配对设备/人之间用 **X25519 Lockbox** 直传密钥;② 给陌生人用 **Mega 式链接分享**(URL fragment 内嵌密钥 + DHT 签名邀请包)。 +> 加密底层见 [08-e2e-encryption.md](08-e2e-encryption.md),权限见 [04-permissions.md](04-permissions.md),边界见 [11-threat-model.md](11-threat-model.md)。 -前提:双方已完成设备配对。 +## 路径一:配对设备/人分享(Lockbox) -``` -A 选择 Workspace/Folder/Document → 选择已配对设备 B → 选择角色 -→ 根据角色分发对应密钥(通过 P2P 加密通道) -→ 写入本地权限表 -→ 通过 P2P 通知 B:"你被授权访问资源 X,角色为 Y" -→ B 收到密钥 + 权限信息 → 存储到本地 → 开始同步 -``` +前提:双方已完成现有配对流程(`PairingManager` 已交换 PeerId/Ed25519 公钥并建立信任)。 -## 链接分享 +```text +A(Owner) 选 workspace + 选已配对设备 B + 选角色(Owner/Collaborator) + 1. A 由 B 的 Ed25519 公钥算出 B 的 X25519 公钥(to_montgomery) + 2. A 用 X25519(A_sk, B_pk) ECDH → HKDF 派生封装 KEK + 3. 用 KEK + XChaCha20-Poly1305 把当前 read_key+write_key(+key_version) 封成一个 Lockbox(带 key commitment) + 4. Lockbox 经 request-response(Noise 已加密)发给 B,或随 workspace_keys 同步落 B 的 workspace.db + 5. B 用自己的 X25519 私钥解开 Lockbox → 写入本地 {key_version→key} map → 加入同步 + 6. A 记一条签名的 Add(Collaborator) permission_op +``` -不需要预先配对,适合分享给不在同一网络的人。 +- 新增协作设备 = 多生成一个 Lockbox 行;不重写历史。复杂度 O(剩余设备数),2-3 人无所谓。 +- 这就是 Jazz 的 read key reveal / Tresorit 的非对称 share key 分发 / p2panda HPKE-X25519 的成品形态。 -### 邀请链接格式 +### Schema gap(必补) -``` -swarmnote://invite/ -``` - -### 邀请数据(发布到 DHT) +当前 `workspace_keys.read_key_enc` 是**单 blob**,表达不了"每设备一份 Lockbox"。新增: ```rust -struct ShareInvite { - token: String, // 随机生成的邀请令牌 - resource_type: ResourceType, - resource_id: Uuid, - role: Role, // 链接授予的角色 - encrypted_keys: Vec, // 用 invite_secret 加密的密钥包 - creator_peer_id: PeerId, // 创建者 - creator_addrs: Vec, - created_at: i64, - expires_at: i64, // 过期时间 - max_uses: Option, // 最大使用次数,None = 无限 - password_hash: Option, // 密码保护(bcrypt hash) +// workspace.db 新表 +struct WorkspaceKeyLockbox { + workspace_id: Uuid, + key_version: i32, + recipient_peer_id: String, // 收方设备 + sealed_read_key: Vec, // 用收方 X25519 公钥封装 + sealed_write_key: Vec, // v2 Reader 时此字段留空 + commitment: Vec, + sealed_by_peer_id: String, // 封装方 } ``` -### 链接分享流程 - +## 路径二:链接分享给陌生人(Mega 式) + +`swarmnote://invite/#` —— `token` 是 DHT 寻址句柄(可走任何信道),`#secret`(fragment)是真正的解密密钥,**永不上 DHT/日志/剪贴板**。 + +### 生成(Owner 设备) + +```text +1. OsRng 生成高熵 secret(≥256-bit) 与 token(DHT 寻址句柄) +2. 从 secret HKDF 派生:① 内层 DEK(封 workspace key) ② DHT 寻址 HMAC 材料 ③ commitment + 铁律:token 与 #secret 不同源;DHT key 与 #secret 解耦 +3. 邀请包 payload = { + role(owner/collaborator), resource_type, resource_id, key_version, + sealed_workspace_keys(DEK + XChaCha20-Poly1305 封装 read_key+write_key), + commitment, expires_at(签进包内) + } +4. 若设密码:再用 Argon2id(password, salt) 派生 KEK 把内层 DEK 再包一层 + (正交叠加 = 拿到链接 + 知道密码缺一不可) +5. 整包用 Owner 设备 Ed25519 签名 +6. 发布到 DHT:key = HMAC(secret 派生材料, 固定 label)(不可逆/不可枚举,非明文 token/非 workspace_id) + value = 签名的不透明密文邀请包;注册 custom validator(默认 validator 只收 pk/ipns),retrieval 验签防投毒 +7. share_invites 落库(token/resource/role/encrypted_keys/expires_at/password_hash + 新增 commitment 列) ``` -A 创建邀请: - 1. 生成 token + invite_secret(随机 32 字节) - 2. 用 invite_secret 加密角色对应的密钥包 - 3. 构建 ShareInvite - 4. 发布到 DHT: key = SHA256("/swarmnote/invite/" + token) - 5. 生成链接 swarmnote://invite/# - (invite_secret 在 URL fragment 中,不会被 DHT 存储) - -B 使用邀请: - 1. 打开链接 → 应用解析 token + invite_secret - 2. DHT 查询 → 获取 ShareInvite - 3. 检查:未过期、未超最大使用次数 - 4. 如果有密码保护 → 提示输入密码 → 验证 password_hash - 5. 用 invite_secret 解密密钥包 → 获得 read_key / write_key 等 - 6. 连接 A(通过 creator_addrs) - 7. 发送 InviteRedeemRequest { token, peer_id } - 8. A 验证 → 双方建立信任 + 记录权限 - 9. B 用解密得到的密钥开始同步 + +### 兑换(陌生人设备) + +```text +1. deep link 处理器把整条 URL 交给 Rust 后端解析,解析后立即清掉 fragment +2. token → 派生 DHT key → DHT get → 验 Owner 签名 → 本地校验 expires_at(过期拒) +3. 若有密码:提示输入(强提示用户密码走另一条信道,与链接分开传)→ Argon2id 解外层 +4. #secret 派生 DEK → 常量时间比对 commitment → 解 sealed_workspace_keys + → 得 workspace key + key_version → 写本地 map → 加入工作区同步 ``` -### 链接安全性 +### 安全约束与诚实声明 -- **invite_secret** 在 URL fragment(`#` 后)中,类似 Mega.nz 的做法 - - DHT 只存储加密后的密钥包,无法解密 - - 只有拿到完整链接的人才能解密 -- **密码保护**(参考语雀):额外一层验证,即使链接泄露也需要密码 -- **有效期**(参考腾讯文档):支持自定义过期时间 -- **使用次数**:可限制链接最多被使用 N 次 +- `#secret` 永不进 DHT value / tracing 日志(整条 URL 当机密脱敏)/ 剪贴板历史 / 配置 store。 +- **不做 max_uses 强制**:无中心下计数不可靠,会给用户「限了次数」的安全错觉。保留 schema 列但不承诺;真正撤销 = key rotation。 +- **有效期是软过期**(DHT TTL + republish,恶意/缓存节点可保留),故 `expires_at` 签进包内本地强制 + 设短一点。 +- **撤销链接 = 轮换 workspace key**:已保存完整链接(含 fragment 密钥)的人对那个 key_version 下的内容永久可读,删 DHT 记录不可靠(会被 republish/缓存)。Mega 自己也只能建议"换链接/移到新文件夹"。 +- `token#secret` 当不可分割的不透明整体,UI 禁止用户手动截断(Tahoe-LAFS 编辑式降权血泪)。 +- **Argon2id 参数**:RFC 9106 参数二 t=3 / m=64MiB / p=4 / 128-bit salt / 256-bit tag(移动端友好;桌面可上参数一)。 -### 链接分享 vs 配对分享的区别 +## 两种分享对比 | | 配对分享 | 链接分享 | |--|---------|---------| -| 前提 | 需要先配对 | 不需要 | -| 信任 | 已有设备信任 | 通过邀请链接建立 | -| 密钥传输 | P2P 加密通道直传 | 嵌入链接 fragment,DHT 存加密包 | -| 安全性 | 高(端到端加密) | 中(依赖链接不泄露) | -| 密码保护 | 不需要 | 可选 | -| 有效期 | 无(持久授权) | 可设过期时间 | -| 场景 | 自己的多台设备 / 信任的人 | 分享给同事/朋友 | +| 前提 | 已配对 | 不需要 | +| 密钥传输 | X25519 Lockbox 经 Noise 通道 | fragment 内嵌 + DHT 存签名加密包 | +| 双方离线异步兑换 | — | ✓(DHT 发布) | +| 密码保护 | 不需要 | 可选(Argon2id) | +| 有效期 | 持久授权 | 软过期 | +| 撤销 | 移除 + 轮换 | 轮换(删链接不可靠) | +| 场景 | 自己的多设备 / 信任的人 | 发给同事/朋友 | diff --git a/dev-notes/design/08-e2e-encryption.md b/dev-notes/design/08-e2e-encryption.md index e876033..0377c98 100644 --- a/dev-notes/design/08-e2e-encryption.md +++ b/dev-notes/design/08-e2e-encryption.md @@ -1,109 +1,88 @@ -# E2E 加密设计方案 +# E2E 加密设计(v1 定稿) -> 参考架构:SecSync(Serenity Notes 使用的 E2E 加密 CRDT 方案,NLnet 资助)。 -> 本文档描述底层加密实现细节,权限模型层面的密钥分发规则见 [04-permissions.md](04-permissions.md)。 +> **2026-06 调研定稿**,取代 2026-03 基于 SecSync 的初稿。主要变化:加密主体由"用户"改为**设备**、密钥存储由 Stronghold 改为 **OS keychain**、X25519 由设备 Ed25519 **复用派生**、新增 **key commitment**。 +> 本文描述加密底层;权限/角色见 [04-permissions.md](04-permissions.md),分享流程见 [05-sharing.md](05-sharing.md),威胁边界见 [11-threat-model.md](11-threat-model.md)。 +> +> 业界共识:SwarmNote 这种「无服务器 + Yjs CRDT + 2-3 人 + 偶尔分享」的场景,正解是 **per-workspace 对称群 key + per-device X25519 Lockbox + 移除时 lazy 轮换**——与 Jazz/cojson、p2panda Data Encryption、Tresorit、SecSync 同构。MLS/BeeKEM/CGKA 对此规模是过度工程,后置 v2/v3。 ## 设计原则 -- **设备信任 ≠ 文档授权**:设备信任允许网络连接,文档授权通过密钥分发控制访问权限 -- **中继节点不可读**:引导节点和 Relay 节点不能解密任何文档内容 -- **前向安全性**:移除协作者后,新内容不可读(但无法阻止保留已解密的旧数据) +- **仅传输加密**:加密只发生在网络层(GossipSub 广播 / 资产传输 / 链接邀请包)。授权设备本地仍写明文 `.md`,folder-is-truth 不变;丢设备的风险交给 OS 全盘加密。 +- **加密主体 = 设备**:SwarmNote 无账号、无用户注册表,唯一稳定密码学身份是 per-device 的 libp2p Ed25519 keypair(`crates/core/src/identity.rs`,已作 PeerId + Noise 静态身份持久化在 OS keychain)。Lockbox 收方、`permissions.peer_id` 全是设备粒度;逻辑上的"用户"只是 UI 把若干 PeerId 归组,密码学上不存在用户主体。 +- **不追前向保密(FS)**:CRDT 必须重放全部历史 update 才能收敛 → 必须保留所有历史 key,应用层 FS 名存实亡(Ink & Switch Keyhive 已论证)。保留 `{key_version → key}` 全历史是正确取舍。 -## 加密算法 - -**XChaCha20-Poly1305** +## 密钥层级 -| 维度 | 选择理由 | -|------|---------| -| Nonce | 24 字节,P2P 无法协调计数器,必须随机生成,192-bit 空间消除碰撞风险 | -| 跨平台 | 纯 Rust 实现,无 C 依赖 | -| 侧信道 | 不依赖硬件加速,任何设备上都是常数时间 | -| 验证 | SecSync 同款选择,已在生产环境验证 | +```text +第0层 设备根(OS keychain) + Ed25519 设备密钥 ──复用派生──▶ X25519 设备密钥(Lockbox 收方) -## 密钥层级 +第1层 workspace 对称密钥(每 workspace 一组,带 key_version) + ├── read_key (32B 随机) → 加密 gossip doc-update / awareness / 资产 + └── write_key (32B 随机,独立生成,绝不从 read_key 派生) + → v1 仅作 Collaborator 写凭证占位;v2 演化为逐 update 签名授权根 + (admin_key_enc 列保留,v1 不用) -``` -用户主密钥(Stronghold 保护) - ├── Ed25519 签名密钥对(验证消息来源、签名文档更新) - ├── X25519 密钥交换密钥对(与其他用户安全交换文档对称密钥) - ├── 工作区密钥组(见 04-permissions.md) - │ ├── read_key → 解密文档内容 - │ ├── write_key → 签署编辑操作 - │ └── admin_key → 签署权限变更 - └── 文件夹级密钥(可选扩展,HKDF 派生文档密钥) +第2层 子用途派生(每次加密前 HKDF-Expand) + HKDF-SHA256(read_key, info = b"swarmnote:v1:" || workspace_id || key_version) + purpose ∈ { gossip, asset, commit } salt = workspace_id ``` -## 加密消息格式 +**为什么 v1 就分两把独立 key**:v1 虽不强制只读,但若 read/write 合成一把或派生,v2 给 Reader 发 read_key 就等于泄露能推 write 凭证的种子,被迫 re-key 重构。两把独立 ⇒ v2 加 Reader = 「不发 write_key 的 lockbox + 打开签名校验开关」,schema/密钥布局零改动。这是把「2 级 → 3 级」从重构降为开关的关键。 -对每个同步消息整体加密后传输: +**key 历史**:本地保存 `{key_version → (read_key, write_key)}` 全历史 map。解旧密文按 payload 头部携带的 `key_version` 取对应 key。不做 Plutus 式单向 key 链(2-3 人是过度优化)。 -``` -+--------+-------+---------+----------------+----------+ -| doc_id | nonce | key_id | ciphertext | auth_tag | -| 32B | 24B | 4B | variable | 16B | -+--------+-------+---------+----------------+----------+ -``` +## Ed25519 → X25519 派生 -- doc_id 明文:网络层需要据此路由消息 -- nonce 随机生成:每条消息唯一 -- key_id 支持密钥轮换 -- auth_tag AEAD 认证标签,防篡改 +采用 **复用 + 严格 HKDF 域分离**,而非每设备独立生成第二把长期 X25519: -## 密钥分发(Lockbox) +- 本机 X25519 私钥 = `SigningKey::to_scalar_bytes()`(ed25519-dalek 2.x);对端 X25519 公钥 = `verifying_key().to_montgomery()`(或等价 libsodium `crypto_sign_ed25519_*_to_curve25519`)。 +- **理由**:配对时对端只交换 PeerId/Ed25519 公钥即可算出其 X25519 公钥,零额外密钥分发/签名绑定;Thormarker 2021/509 在 ROM 下证明 joint security,libsodium/GNUnet 生产在用。 +- **硬约束**:① 全局钉死同一 clamp 约定(`to_scalar_bytes` 配 `to_montgomery`,跨端必须一致否则 DH 不匹配);② **只做单层转换,不做层级派生**(层级派生需乘 cofactor,否则触发 hidden-number-problem);③ Lockbox 对称封装必须 key-committing(见下)。 -文档密钥用每个协作者的 X25519 公钥分别加密,形成 Lockbox: +## 对称加密原语 -1. Alice 获取 Bob 的 X25519 公钥 -2. Alice 计算 `shared_secret = X25519(alice_sk, bob_pk)` -3. Alice 用 shared_secret 加密文档密钥,创建 Lockbox -4. 发送 Lockbox 给 Bob -5. Bob 计算相同 `shared_secret = X25519(bob_sk, alice_pk)` -6. Bob 解密 Lockbox 获得文档密钥 +- **XChaCha20-Poly1305**:192-bit nonce,每条消息 `OsRng` 随机生成 nonce 前置于密文——免去跨设备 nonce 计数器协调(P2P 多写的唯一现实选择;约 2^80 条消息才到 2^-32 碰撞概率)。这正是 SecSync/p2panda Data Encryption 都选它的原因。 +- **key commitment**(纸面遗漏,必补):裸 AEAD 不是 key-committing——同一密文可被构造成不同 key 解出不同明文,链接分享是这类 partitioning-oracle / invisible-salamander 攻击(USENIX'21)的靶心。方案:HKDF 多挤 32B(`info = b"swarmnote:v1:commit"...`)作 commitment 与密文同存,解密时用 `subtle` 常量时间比对。零新依赖。 -## 文件夹级密钥派生(HKDF) +## 逐通道加密策略 -``` -文件夹密钥(256-bit 随机) - ├── HKDF(folder_key, "doc:" + doc_id_1) → 文档 1 密钥 - ├── HKDF(folder_key, "doc:" + doc_id_2) → 文档 2 密钥 - └── HKDF(folder_key, "doc:" + doc_id_3) → 文档 3 密钥 -``` +| 通道 | 是否应用层加密 | 说明 | +|------|:---:|------| +| **gossip doc-update**(`ws` topic) | ✅ 必须 | 真正裸奔点:mesh 内任何订阅 topic 的转发节点解开逐跳 Noise 后读到明文。现状 `[16B uuid][明文 update]` 零保护 | +| **awareness**(`ws-aw` topic) | ✅ 必须 | 不加密会泄露光标/在线/用户名/颜色给整个 mesh。AAD 的 `msg_type` 区分 ws/ws-aw 防跨通道重放 | +| **资产分块**(走 gossip 时) | ✅ 必须 | 各分块独立 nonce + AAD(含 `asset_id+chunk_index`)防重排/跨资产重放 | +| **SV 交换 / 全量拉取 / 资产 RPC** | ❌ 不叠 | 走 request-response,已被 libp2p Noise 端到端加密 + 对端认证(点对点直连不经 mesh 转发)。应用层加密预算全砸 GossipSub | +| **DocList 元数据(路径/标题)** | ⚠️ 真正泄漏在 topic 名 | payload 走 RR 由 Noise 护住;但 `swarmnote/ws/{明文uuid}` topic 名泄露"谁关注哪个工作区"→ 改 HMAC 不可逆派生 topic(见 [11-threat-model.md](11-threat-model.md)) | +| **online 宣告(DHT)** | 现状即可 | 本就是公开存在性信息;但**分享邀请包**发 DHT 必须加密+签名(见 [05-sharing.md](05-sharing.md)) | -```rust -fn derive_doc_key(folder_key: &[u8; 32], doc_id: &str) -> [u8; 32] { - let hk = Hkdf::::new(None, folder_key); - let mut doc_key = [0u8; 32]; - let info = format!("doc:{}", doc_id); - hk.expand(info.as_bytes(), &mut doc_key).unwrap(); - doc_key -} -``` +## 加密 wire 格式(GossipSub payload) -## 密钥轮换(移除协作者时) - -1. Owner 移除协作者 Charlie -2. 生成新密钥组(key_version + 1) -3. 用新密钥加密当前状态快照 -4. 为剩余协作者创建新 Lockbox -5. 后续所有消息使用新密钥加密 -6. Charlie 仍持有旧密钥,可读取轮换前的数据(P2P 系统无法避免) - -## 网络层集成 - -| 数据 | 是否加密 | 原因 | -|------|---------|------| -| yjs Update / FastCDC chunk | 加密 | 文档内容 | -| 资源文件传输 | 加密 | 用户数据 | -| Awareness 数据 | 不加密 | 不含文档内容(光标位置等) | -| DHT Provider Records | 不加密 | 仅含 hash(doc_id),不暴露原始 ID | - -## Rust 依赖 - -```toml -chacha20poly1305 = "0.10" -x25519-dalek = { version = "2", features = ["static_secrets"] } -ed25519-dalek = { version = "2", features = ["rand_core"] } -hkdf = "0.12" -sha2 = "0.10" -rand = "0.8" +```text +[1B version][4B key_version][24B nonce][32B commitment][ciphertext] +AAD = workspace_id(16B) || doc_uuid(16B) || key_version(4B) || msg_type(1B) ``` + +- `doc_uuid` 仍需明文(路由用),放进 AAD 绑定(防混淆)。 +- 改造点:在 `ydoc.on('update')` 拿到 update 后**先 `mergeUpdates` 合并、再加密广播**(别逐 keypress 加密——会让文档膨胀且无法压缩,Keyhive/Automerge 反面教训)。 +- 解密失败 / `key_version` 不符 / 反序列化失败 → GossipSub v1.1 **Extended Validator 返回 `Reject`**(触发 P4 评分惩罚 + graylist),顺带补上"入站 gossip 无来源鉴权"的审计缺口。 + +## Rust 依赖(钉死稳定线,不上 rc) + +| 用途 | 选择 | crate | +|------|------|-------| +| 对称 AEAD | XChaCha20-Poly1305 | `chacha20poly1305 = "0.10"` | +| 非对称 DH | X25519 ECDH | `x25519-dalek = "2"` | +| 设备签名 + Ed25519→X25519 | Ed25519 | `ed25519-dalek = "2"`(复用 libp2p 同一把设备密钥)| +| 密钥派生 + key commitment | HKDF-SHA256 | `hkdf = "0.13"` + `sha2 = "0.10"` | +| 链接密码 KDF | Argon2id(RFC 9106 参数二 t=3/m=64MiB/p=4)| `argon2 = "0.5"` | +| 常量时间比较 | — | `subtle = "2"` | +| CSPRNG | OS 熵 | `getrandom`(经 `rand`)| + +## 与 2026-03 纸面设计的分歧 + +1. **主体设备非用户**:无账号系统就没有"用户"这个密码学锚点,强造用户主体反而要引入账号/同步用户密钥的复杂度。 +2. **OS keychain 非 Stronghold**:主体是设备 + 不防丢设备,Stronghold 的"用户主密钥"前提不成立且强度过度。 +3. **Ed25519 复用派生 X25519**:纸面未明确 X25519 来源;定为单层复用派生(钉死 clamp)。 +4. **新增 key commitment**:纸面漏了,链接分享非补不可。 +5. **撤销是 lazy 的**,不是即时彻底(见 [04-permissions.md](04-permissions.md))。 diff --git a/dev-notes/design/09-decisions.md b/dev-notes/design/09-decisions.md index d31f78e..4fb032e 100644 --- a/dev-notes/design/09-decisions.md +++ b/dev-notes/design/09-decisions.md @@ -37,12 +37,36 @@ | 权限撤销 | 停止同步 + 可选密钥轮换 | Google Docs(移除后保留已有) | 平衡安全性和实现复杂度 | | Owner 模型 | 单 Owner,可转让 | Google Docs | 避免多 Owner 冲突,P2P 下难以仲裁 | +## E2E 加密/分享/权限 v1 决策(2026-06 定稿) + +> 以下决策取代上方「权限决策」表中关于角色数量、密钥存储、撤销语义的纸面假设。完整设计见 [08-e2e-encryption.md](08-e2e-encryption.md) / [04-permissions.md](04-permissions.md) / [05-sharing.md](05-sharing.md) / [11-threat-model.md](11-threat-model.md)。 + +| 决策 | 选择 | 参考 | 理由 | +|------|------|------|------| +| 加密范围 | 仅传输加密(本地写明文 .md)| Tresorit、p2panda Data Encryption | 保住 folder-is-truth;丢设备交给 OS 全盘加密 | +| 加密主体 | **设备**(per-device Ed25519),非用户 | — | 无账号系统,唯一稳定密码学锚点是设备 | +| 密钥存储 | OS keychain(现有),非 Stronghold | `identity.rs` 已用 | 主体是设备 + 不防丢设备,Stronghold 前提不成立 | +| 群密钥方案 | per-workspace 对称 key + X25519 Lockbox + lazy 轮换 | Jazz/cojson、SecSync | 业界共识;MLS/BeeKEM 对 2-3 人 overkill | +| X25519 来源 | 从设备 Ed25519 复用派生(单层,钉死 clamp)| libsodium、GNUnet | 配对只换 PeerId 即可算对端公钥,零额外分发 | +| key commitment | 必加(HKDF-extra-output + 常量时间比对)| USENIX'21 partitioning oracle | 链接分享是攻击靶心,裸 AEAD 非 key-committing | +| 角色 | v1 两级 Owner/Collaborator | Obsidian Sync | 真 Reader 需逐 update 签名,后置 v2(已铺两把独立 key + 携带签名,v2 仅开开关)| +| 权限表 | 签名操作链(append-only `permission_ops`)| Matrix auth chain、Jazz | 防伪造提权,无中心可离线判定 | +| 撤销语义 | lazy re-encryption(被移除者旧数据永久可读)| Google Docs、Keyhive | CRDT 必留历史 key,FS 无意义;别承诺「踢人即焚」| +| 链接邀请传输 | 上 DHT + Owner 签名 + custom validator + HMAC key | Mega.nz | 支持双方离线异步兑换;#secret 永不上 DHT | +| 链接 max_uses | 不做强制(保留列不承诺)| — | 无中心计数不可靠,避免安全错觉;撤销靠 key rotation | +| 加密通道 | 只加密 GossipSub 广播;RR 同步靠 Noise | — | gossip 是真正裸奔点,收窄实现面 | + ## 开放问题 -1. **Folder 级独立密钥**:MVP 用工作区统一密钥,后续是否需要 Folder 级密钥?(复杂度高,影响密钥分发流程) -2. **离线时长限制**:设备离线多久后自动撤销权限?还是不自动撤销? -3. **密钥备份**:用户丢失设备后如何恢复工作区密钥?助记词?密钥导出文件? -4. **多设备同用户**:同一个人的多台设备是否共享同一身份?还是每台设备独立身份?(SwarmDrop 是设备级身份) +1. **密钥备份/恢复**:用户丢失全部设备后如何恢复工作区密钥?助记词?加密导出文件?(v1 暂不做,需规划) +2. **多设备 UI 归组**:密码学上主体是设备(每设备一份 Lockbox),但用户心智是"我和协作者们"。UI 是否把同一人多 PeerId 聚合展示?可信依据是什么(无账号,只能手动标注/配对时自声明)? +3. **WorkspaceOpened 派生标识匹配**:`CtrlMessage::WorkspaceOpened` 改派生标识后,接收方如何判断"这是我也有的工作区"——需为每个本地工作区预计算派生标识做匹配集。 +4. **Folder/Document 级独立密钥 + 继承**:v1 工作区级统一密钥,后续按需用 HKDF 派生(复杂度高)。 + +### 已解决(原开放问题) + +- ~~离线时长限制自动撤销~~ → **不自动撤销**(撤销是显式 Owner 操作 + lazy 轮换)。 +- ~~多设备同用户是否共享身份~~ → **每台设备独立身份**(设备级,与 SwarmDrop 一致);"用户"仅 UI 归组。 ## 分阶段实现 diff --git a/dev-notes/design/11-threat-model.md b/dev-notes/design/11-threat-model.md new file mode 100644 index 0000000..1f8daed --- /dev/null +++ b/dev-notes/design/11-threat-model.md @@ -0,0 +1,57 @@ +# 威胁模型(E2E 分享 v1) + +> **2026-06 定稿**。配套 [08-e2e-encryption.md](08-e2e-encryption.md) / [04-permissions.md](04-permissions.md) / [05-sharing.md](05-sharing.md)。 +> 本文显式声明 SwarmNote v1 加密**防什么、不防什么**,避免给用户错误的安全预期。 + +## 加密主体与信任锚点 + +- 加密/授权主体是**设备**(per-device libp2p Ed25519,= PeerId = Noise 静态身份)。"用户"仅 UI 归组多 PeerId。 +- 信任锚点:GossipSub 入站消息的 `from(PeerId)` 由 `StrictSign`(`MessageAuthenticity::Signed` + `ValidationMode::Strict`)已认证。**所有 UI 身份展示(尤其 awareness 的光标/用户名/在线)以 PeerId ↔ 已配对设备映射为准,绝不信任 payload 里自报字段**(Yjs awareness 不签名、可任意 spoof)。 + +## 防护边界(按对手) + +| 对手 | 能看到 | 看不到 | +|------|--------|--------| +| **中继 / 引导节点(Relay/Bootstrap)** | 加密流量的存在、PeerId、连接元数据 | 任何文档明文(点对点 Noise + gossip payload 加密双重护住)| +| **GossipSub mesh 内转发节点(未授权)** | 加密 payload(密文)、消息大小/频率、派生后的 topic 标识 | 文档内容、光标/在线(payload 用 read_key 加密,无 key 解不开)| +| **网络窃听者** | TCP/QUIC 元数据 | 内容(Noise + payload 加密)| +| **DHT 存储节点** | online 宣告(公开存在性)、"存在一条邀请记录"、签名加密邀请包密文 | 邀请内的 workspace key(被 fragment secret 派生 key 加密,secret 不上 DHT)| +| **被移除的前协作者** | 它离开前已获取的明文/旧密文(**永久**,lazy 撤销)| 轮换之后用新 key 加密的新内容 | +| **持有完整链接(含 #secret)的人** | 该 key_version 下的工作区内容 | 轮换之后的新内容 | + +## 明确**不防**的(写清楚,别承诺) + +1. **丢设备 / 本地磁盘取证**:授权设备本地写明文 `.md`(folder-is-truth 的代价),交给 OS 全盘加密。v1 不做本地静态加密、不上 StrongBox/锁屏门控。 +2. **前向保密(FS)/ 后泄露安全(PCS)**:CRDT 必须重放全历史 → 必须保留全部历史 key,应用层 FS 名存实亡(Keyhive 已论证)。不做 ratchet。 +3. **"踢人即焚"**:撤销是 lazy 的——被移除设备/已存链接者对其能访问过的旧内容**永久可读**。真正切断 = 轮换 key 让新内容对其失效。 +4. **恶意已授权设备**:v1「有 key 即可写」,逐 update 签名携带但不强制校验。拿到 write_key 的设备可注入伪造/损坏 update(CRDT 仍会合并)。真正的写权限强制(丢弃越权 update)= v2 Reader 时一并打开。 +5. **元数据完全隐匿**:已配对设备能看到对方该工作区的**完整路径树**(rel_path/title 经 Noise RR 传,对端可见)。文件名加密是 v2+ 的元数据最小化。 + +## 已治理的元数据泄漏面 + +- **GossipSub topic 名**:现状 `swarmnote/ws/{明文 workspace_uuid}` 泄露"哪些 PeerId 关注哪个工作区"的协作兴趣图 + 工作区存在性 → 改 `swarmnote/ws/{HMAC(workspace_root_secret, "topic") 的 Base32}`;`CtrlMessage::WorkspaceOpened` 的 uuid 同样改派生标识。 + - 注意:topic 派生只**降低**元数据泄漏,**不替代** payload 加密(topic ≠ 访问凭证,Iroh 教训)。 +- **DHT 邀请 key**:现状 `SHA256(ns||id)` 的 preimage 随记录上网可被存储节点看到、可枚举 → 改 HMAC 加盐不可逆派生,使存储节点无法反推语义/批量枚举。 +- **awareness DoS**:即使加密,仍需出站本地节流(光标 debounce)+ 入站对每 PeerId 设频率上限。 + +## 权限链的信任根(authorization DAG genesis anchor) + +权限链由 genesis op(自封 Owner,`prev_hash=None`)引导。**genesis 必须绑定到工作区的权威创建者**——`materialize` 只认 `issuer == target == workspaces.created_by` 的 genesis;任何其他设备自签的 genesis(签名/哈希自洽但 `issuer != created_by`)一律丢弃。这堵住了「伪造第二个 genesis 自封 Owner → 骗 key 持有者把 workspace key 封给攻击者」的越权面(一个仅配对、未授权的设备无法借此成为 Owner)。owner(`created_by`)还受保护永不被 revoke/降级(保证至少一个 Owner 存活)。 + +- **owner 设备**:`created_by` = 自己,创建时即正确。 +- **joiner 设备**:本地行创建时 `created_by` = 自己;首次同步(`ensure_workspace_key`)收到经 Noise 认证的对端返回的链后,用链中唯一 genesis 的 issuer 把 `created_by` 钉成真 owner(**trust-on-first-use**)。 + +## 残留风险(接受并记录) + +- DHT 邀请**存在性**无法完全隐藏(存储节点知道"有这么一条记录");已用 HMAC key + 签名 + custom validator 缓解枚举与投毒。 +- State vector 即使被 Noise 护住,仍泄露"有哪些 client、各做了多少操作"的协作图——因走点对点对账(不让盲中继看 SV 算 delta)、对端是已认证已配对设备,风险可接受。 +- 并发成员变更可能把 key 泄露给本不该给的新人(p2panda 告警的去中心化竞态);用 strong-removal 语义 + key_version 确定性收敛缓解。 +- **joiner 的 owner-pin 是 TOFU**:joiner 首次同步时信任「它主动选择去同步的、经 Noise 认证的对端」所给链里的 genesis。若该对端恶意(M 给出只含 M 自签 genesis 的链),joiner 会把**它从 M 拉取的那个工作区副本**的 `created_by` 钉成 M。后果**仅限 joiner 自身本地副本**被污染(装入对方给的 key、无法解密真 owner 的内容)= 自伤式 DoS/错状态,**不构成 key 外泄**(M 没有真 workspace key;真 owner 的 `build_workspace_key_response` 用正确链拒绝非成员)、**不在诚实设备上升权**(真 owner 与其他成员的 `created_by` 仍拒伪造 genesis)。这是「从谁那同步就得到谁的工作区」的 P2P 固有性质。升级路径(v1.1+):pin 前用邀请 token / 带外确认的成员身份作可信 anchor,或提供本地"重置 owner-pin"恢复手段。 +- **入站 op 仅验签、不验来源成员身份**:`coordinator::handle_ctrl_message` 的 `PermissionOpsUpdate` 与 full_sync 收链只 `op.verify()` 即落库。已 pin 的设备安全(`materialize` 按 `created_by` 拒伪造 genesis);但被邀请的恶意 Collaborator 可灌入大量自签合法 op 造成存储写放大 + 每次 `materialize` 重放的 CPU 开销(DoS,非 key 泄露)。升级路径:落库前校验来源 `role_of(source).is_some()` + 单消息 op 数上限。 + +## 升级路径(威胁模型若升级再做) + +- 防恶意写入 / 真只读 → v2:逐 update 签名强制校验 + Reader 角色。 +- 防丢设备 → 本地静态加密 / Megolm 式 ratchet。 +- 真 PCS / 大群 → MLS(RFC 9420)/ BeeKEM(Keyhive)。 +- 元数据最小化 → 文件名/路径加密、区间访问控制(Grappa)。 diff --git a/dev-notes/design/README.md b/dev-notes/design/README.md index 53ee47c..0e2dc73 100644 --- a/dev-notes/design/README.md +++ b/dev-notes/design/README.md @@ -9,12 +9,13 @@ | [01-device-identity.md](01-device-identity.md) | 设备身份(Stronghold + Ed25519)与 6 位配对码流程 | | [02-storage-architecture.md](02-storage-architecture.md) | 存储架构:Markdown 优先、目录结构、工作区发现/移动、容灾恢复 | | [03-sync-architecture.md](03-sync-architecture.md) | 三级同步:L1 yjs 实时协作 / L2 FastCDC 分块同步 / L3 资源全量同步 | -| [04-permissions.md](04-permissions.md) | 三级权限模型(Owner/Editor/Reader)、密码学执行、权限继承、撤销与密钥轮换 | -| [05-sharing.md](05-sharing.md) | 配对分享与链接分享(DHT 邀请、密码保护、有效期) | +| [04-permissions.md](04-permissions.md) | **(v1 定稿)** 两级权限(Owner/Collaborator)、签名操作链、lazy 撤销与密钥轮换 | +| [05-sharing.md](05-sharing.md) | **(v1 定稿)** 配对分享(X25519 Lockbox)与链接分享(DHT 签名邀请、密码、有效期) | | [07-data-model.md](07-data-model.md) | SQLite 数据模型(全局 db + 工作区 db) | -| [08-e2e-encryption.md](08-e2e-encryption.md) | E2E 加密底层实现(XChaCha20、Lockbox、HKDF 派生) | +| [08-e2e-encryption.md](08-e2e-encryption.md) | **(v1 定稿)** E2E 加密底层(主体=设备、Ed25519→X25519、XChaCha20、Lockbox、HKDF、key commitment) | | [09-decisions.md](09-decisions.md) | 设计决策记录、开放问题、分阶段实现路线 | | [10-ui-requirements.md](10-ui-requirements.md) | UI 设计需求:页面清单、交互细节、快捷键 | +| [11-threat-model.md](11-threat-model.md) | **(v1 定稿)** 威胁模型:防什么/不防什么、各通道边界、元数据泄漏治理 | ## 设计原则 diff --git a/dev-notes/knowledge/rust-backend.md b/dev-notes/knowledge/rust-backend.md index a00cf90..7e5ff5e 100644 --- a/dev-notes/knowledge/rust-backend.md +++ b/dev-notes/knowledge/rust-backend.md @@ -452,3 +452,48 @@ app.emit("peer-connected", PeerPayload { ... })?; 前端 `listen(eventName, handler)` 订阅。 **约定**:事件名以模块前缀命名(`yjs:*`、`network:*`、`pairing:*` 等)。 + +## 密码学(E2E sharing,`crates/core/src/crypto/`) + +E2E 分享的密码学地基在 `crypto/`(entry `crypto.rs` + 子模块 `kdf`/`aead`/`keyx`/`lockbox`/`password`)。设计见 `dev-notes/design/{08-e2e-encryption,04-permissions,05-sharing,11-threat-model}.md`。 + +### 依赖版本坑:hkdf 用 0.12 不用 0.13 + +`hkdf 0.13` 升级到 `digest 0.11`,与 workspace 钉死的 `sha2 0.10`(`digest 0.10`)类型不兼容(`Hkdf::` 会因 digest 版本不匹配编译失败)。 + +**正确做法**:`hkdf = "0.12"` 配 `sha2 = "0.10"`。要升 0.13 必须同时把 workspace `sha2` 升 0.11。 + +### 随机数:用项目 `rand 0.9`,不要喂 RNG 给 dalek + +`x25519-dalek 2` / `ed25519-dalek 2` / `chacha20poly1305 0.10` 内部用 `rand_core 0.6`,与项目的 `rand 0.9`(`rand_core 0.9`)trait 不兼容——把 `rand 0.9` 的 RNG 传进 dalek 的 `*_from_rng` API 会编译失败。 + +**正确做法**:所有随机材料(key/nonce/secret/salt)用 `crypto::fill_random`(内部 `rand::rng().fill_bytes`,OS 种子 CSPRNG),自己填字节数组;DH/密钥派生全部走静态密钥,不需要给 dalek 喂 RNG。 + +**不要做**:`XChaCha20Poly1305::generate_key(&mut OsRng)` / dalek 的 `generate(&mut rng)`——会拉进 rand_core 0.6 冲突。 + +### Ed25519 → X25519 复用派生 + +设备只有一把 libp2p Ed25519 keypair(`IdentityManager`)。X25519(Lockbox 收发方)从它单层派生,不存第二把: + +**正确做法**: +- 本机私钥:`keypair.clone().try_into_ed25519()?.to_bytes()` 取前 32B 作 seed → `ed25519_dalek::SigningKey::from_bytes(seed).to_scalar_bytes()` → `x25519_dalek::StaticSecret::from(..)`(`keyx::derive_x25519_secret`)。 +- 对端公钥:从对端 Ed25519 公钥 `VerifyingKey::from_bytes()?.to_montgomery().to_bytes()` → `x25519_dalek::PublicKey::from(..)`(`keyx::ed25519_pub_to_x25519`)。配对时对端只需给 PeerId/Ed25519 公钥即可算出其 X25519 公钥。 +- 全局钉死这一条 clamp 约定,**只单层、不层级派生**(层级派生需乘 cofactor,否则 hidden-number-problem)。`IdentityManager::x25519_secret()` / `x25519_public()` 是入口。 + +### key-committing AEAD(裸 XChaCha20-Poly1305 不是 key-committing) + +链接分享是 partitioning-oracle / invisible-salamander 攻击靶心。所有对称封装(`aead::seal`、`lockbox::seal_lockbox`)都额外存一个 HKDF 多挤 32B 的 commitment,解密前用 `subtle::ConstantTimeEq` 常量时间比对——错 key 在 AEAD 之前就被拒。 + +**wire 帧**:`aead` = `[1B version][4B key_version BE][24B nonce][32B commitment][ciphertext]`,AAD 由调用方传(同步层用 `workspace_id||doc_uuid||key_version||msg_type`);`lockbox` = `[1B version][24B nonce][32B commitment][ciphertext]`。`frame_key_version()` 先廉价读 key_version 再按 `{key_version→key}` 历史取 key 解密。 + +**相关文件**:`crates/core/src/crypto/`、`crates/core/src/identity.rs`(X25519 暴露)、`crates/core/src/error.rs`(`AppError::Crypto { context, reason }`) + +### permission_ops 缺少 genesis owner op(Phase 5 前置) + +`permissions.rs` 的 `materialize()` 要求第一条 op 是 owner 的 **genesis self-grant**(`prev_hash=None` + `Grant` + `new_role=Owner` + `issuer==target`)才能 bootstrap owner,后续非 genesis op 的 issuer 必须当前为 Owner 才生效。但当前**没有任何代码创建这条 genesis op**——`ensure_workspace_row` / `WorkspaceCore::new` / `create_workspace_for_sync` 都只建 workspace 行 + self-Lockbox key,从不调 `build_signed_op`/`save_op`。 + +**后果**:每个 workspace 的 `load_ops()` 返回空,`materialize()` 返回空 map,没有任何设备被认定为 Owner。Phase 5 把 `build_sealed_workspace_key` 从 `is_paired` 改成权限 gating **之前**,必须先在 owner 创建 workspace 时种下 genesis op,否则 key 分发会全部被拒。 + +**正确做法**:在 owner 首次创建 workspace(`init_keys=true` 路径,即 `WorkspaceCore::new` 里 key 刚 self-init 那一步)后,若 `load_ops` 为空则 `build_signed_op(identity, Grant, my_peer_id, Some(Owner), key_version=1, prev_hash=None)` + `save_op`。幂等。sync-joined workspace(`init_keys=false`)不种 genesis——它的 owner op 随 permission_ops 广播到达。 + +**相关文件**:`crates/core/src/workspace/permissions.rs`(`materialize` 三不变式)、`crates/core/src/workspace/mod.rs`(`WorkspaceCore::new` key-init 分支) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 640c90b..60e72f9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ pub mod fs; pub mod identity; pub mod network; pub mod pairing; +pub mod share; pub mod sync; pub mod workspace; pub mod yjs; diff --git a/src-tauri/src/commands/share.rs b/src-tauri/src/commands/share.rs new file mode 100644 index 0000000..3c84f09 --- /dev/null +++ b/src-tauri/src/commands/share.rs @@ -0,0 +1,51 @@ +//! Tauri IPC commands for workspace sharing + member management. +//! +//! Thin wrappers over [`swarmnote_core::workspace::sharing`]. The owner grants +//! a paired device Collaborator access (a signed permission op, broadcast to +//! members); the granted device then pulls the workspace via the normal sync +//! flow (its key request succeeds because the owner now recognizes its role). + +use std::sync::Arc; + +use swarmnote_core::workspace::sharing::{self, MemberInfo}; +use swarmnote_core::AppCore; +use tauri::State; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; + +fn parse_uuid(s: &str) -> AppResult { + Uuid::parse_str(s).map_err(|e| AppError::InvalidPath(format!("Invalid UUID: {e}"))) +} + +/// Grant a paired device Collaborator access to the workspace (owner only). +#[tauri::command] +#[specta::specta] +pub async fn share_workspace_to_device( + workspace_uuid: String, + target_peer_id: String, + core: State<'_, Arc>, +) -> AppResult<()> { + sharing::grant_collaborator(core.inner(), parse_uuid(&workspace_uuid)?, &target_peer_id).await +} + +/// List the workspace's current members (role + device presence). +#[tauri::command] +#[specta::specta] +pub async fn list_workspace_members( + workspace_uuid: String, + core: State<'_, Arc>, +) -> AppResult> { + sharing::list_members(core.inner(), parse_uuid(&workspace_uuid)?).await +} + +/// Revoke a member's access (owner only). +#[tauri::command] +#[specta::specta] +pub async fn revoke_workspace_member( + workspace_uuid: String, + target_peer_id: String, + core: State<'_, Arc>, +) -> AppResult<()> { + sharing::revoke_member(core.inner(), parse_uuid(&workspace_uuid)?, &target_peer_id).await +} diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index d1a73bc..62023e9 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -358,7 +358,13 @@ pub async fn create_workspace_for_sync( drop(conn); // release before open_workspace re-opens it // Stash the Arc until `trigger_workspace_sync` runs — see `SyncPendingMap` docs. - let ws_core = core.inner().clone().open_workspace(ws_path.clone()).await?; + // Joined workspace: keys arrive via the owner's Lockbox during sync, so we + // open with `for_sync` (no self-init of a divergent key). + let ws_core = core + .inner() + .clone() + .open_workspace_for_sync(ws_path.clone()) + .await?; sync_pending.stash(ws_uuid, ws_core).await; // Record in recent_workspaces. diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs index 9a8f196..4bb1c94 100644 --- a/src-tauri/src/setup.rs +++ b/src-tauri/src/setup.rs @@ -79,6 +79,10 @@ pub fn specta_builder() -> SpectaBuilder { commands::yjs::hydrate_workspace, // 同步 commands::sync::trigger_workspace_sync, + // 共享 / 成员 + commands::share::share_workspace_to_device, + commands::share::list_workspace_members, + commands::share::revoke_workspace_member, ]) .events(collect_events![ // YDoc / 文档 diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 09956c5..3b149c9 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -8,11 +8,14 @@ import { PanelLeft, Search, Settings, + Share2, Square, X, } from "lucide-react"; +import { useState } from "react"; import { PresenceAvatars } from "@/components/editor/PresenceAvatars"; import { OPEN_COMMAND_PALETTE } from "@/components/layout/CommandPalette"; +import { ShareDialog } from "@/components/share/ShareDialog"; import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -32,130 +35,157 @@ export function TitleBar() { const setSidebarTab = useUIStore((s) => s.setSidebarTab); const workspace = useWorkspaceStore((s) => s.workspace); const awareness = useEditorStore((s) => s.awareness); + const [shareOpen, setShareOpen] = useState(false); const needsTrafficLightPadding = isMac; return ( -
- {/* Left: Logo + Workspace + Sidebar Controls */} -
+
- {/* Logo */} -
- SwarmNote - SwarmNote -
- -
+ {/* Left: Logo + Workspace + Sidebar Controls */} +
+ {/* Logo */} +
+ SwarmNote + SwarmNote +
- {/* Workspace switcher */} - - - +
-
+ {/* Workspace switcher */} + + + - {/* Sidebar toggle + view switch + actions */} - - - - - - {sidebarOpen ? t`收起侧边栏` : t`展开侧边栏`} ({modKey}B) - - +
- {sidebarOpen && ( - { - if (v) setSidebarTab(v as SidebarTab); - }} - > - - - - - - - - )} -
+ {/* Sidebar toggle + view switch + actions */} + + + + + + {sidebarOpen ? t`收起侧边栏` : t`展开侧边栏`} ({modKey}B) + + - {/* Right: Presence + Command Palette + Settings + Window Controls */} -
- - - - - - - {t`命令面板`} ({modKey}P) - - + + + + + + + + )} +
- - - - - {t`设置`} - + {/* Right: Presence + Command Palette + Settings + Window Controls */} +
+ + {workspace && ( + + + + + {t`共享工作区`} + + )} + + + + + + {t`命令面板`} ({modKey}P) + + - {!isMac && ( - <> -
- - - - - )} -
-
+ + + + + {t`设置`} + + + {!isMac && ( + <> +
+ + + + + )} +
+
+ {workspace && ( + + )} + ); } diff --git a/src/components/share/MemberRow.tsx b/src/components/share/MemberRow.tsx new file mode 100644 index 0000000..70ff2c3 --- /dev/null +++ b/src/components/share/MemberRow.tsx @@ -0,0 +1,61 @@ +import { Trans } from "@lingui/react/macro"; +import { UserMinus } from "lucide-react"; +import { DeviceAvatar } from "@/components/pairing/DeviceAvatar"; +import { Button } from "@/components/ui/button"; +import type { MemberInfo } from "@/lib/bindings"; +import { cn } from "@/lib/utils"; +import { RoleBadge } from "./RoleBadge"; + +interface MemberRowProps { + member: MemberInfo; + /** Viewer is the workspace owner — show the revoke action on hover. */ + canManage: boolean; + isLast?: boolean; + onRevoke: (member: MemberInfo) => void; +} + +/** A workspace member row — same layout/spacing as PairedDeviceCard. */ +export function MemberRow({ member, canManage, isLast, onRevoke }: MemberRowProps) { + const label = member.name ?? member.peerId.slice(0, 12); + return ( +
+ +
+
+ {label} + {member.isSelf && ( + + (你) + + )} + +
+
+ + {member.isOnline ? 在线 : 离线} +
+
+ {canManage && !member.isSelf && ( + + )} +
+ ); +} diff --git a/src/components/share/RevokeMemberDialog.tsx b/src/components/share/RevokeMemberDialog.tsx new file mode 100644 index 0000000..fbe0528 --- /dev/null +++ b/src/components/share/RevokeMemberDialog.tsx @@ -0,0 +1,69 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useAsyncAction } from "@/hooks/useAsyncAction"; +import { commands, type MemberInfo } from "@/lib/bindings"; + +interface RevokeMemberDialogProps { + /** Target member, or `null` when closed. */ + member: MemberInfo | null; + workspaceId: string; + onOpenChange: (open: boolean) => void; + onDone: () => void; +} + +export function RevokeMemberDialog({ + member, + workspaceId, + onOpenChange, + onDone, +}: RevokeMemberDialogProps) { + const { t } = useLingui(); + const { loading, run } = useAsyncAction(); + const name = member?.name ?? member?.peerId.slice(0, 12) ?? ""; + + async function handleConfirm() { + if (!member) return; + await run(async () => { + await commands.revokeWorkspaceMember(workspaceId, member.peerId); + toast.success(t`已移除 ${name}`); + onDone(); + }); + } + + return ( + + + + + 移除成员? + + {/* Honest about lazy revocation: revoking stops future content, not + already-synced content (no forward secrecy in v1). */} + + + 移除 {name} 后,该设备将无法获取此工作区的新内容;它此前已同步的内容仍保留在其本地。 + + + + + + 取消 + + + 移除 + + + + + ); +} diff --git a/src/components/share/RoleBadge.tsx b/src/components/share/RoleBadge.tsx new file mode 100644 index 0000000..fdb556b --- /dev/null +++ b/src/components/share/RoleBadge.tsx @@ -0,0 +1,16 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@/components/ui/badge"; +import type { Role } from "@/lib/bindings"; + +/** Role pill, matching ConnectionBadge's rounded-full chip style. Colors go + * through theme variables (Owner = primary, Collaborator = secondary). */ +export function RoleBadge({ role }: { role: Role }) { + return ( + + {role === "owner" ? 所有者 : 协作者} + + ); +} diff --git a/src/components/share/ShareDialog.tsx b/src/components/share/ShareDialog.tsx new file mode 100644 index 0000000..e250237 --- /dev/null +++ b/src/components/share/ShareDialog.tsx @@ -0,0 +1,165 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { Share2, UserPlus } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DeviceAvatar } from "@/components/pairing/DeviceAvatar"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ErrorMessage } from "@/components/ui/error-message"; +import { useAsyncAction } from "@/hooks/useAsyncAction"; +import { commands, type MemberInfo } from "@/lib/bindings"; +import { useNetworkStore } from "@/stores/networkStore"; +import { MemberRow } from "./MemberRow"; +import { RevokeMemberDialog } from "./RevokeMemberDialog"; + +interface ShareDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; + workspaceName: string; +} + +/** Workspace sharing + member management. Owner can invite paired devices as + * Collaborators and revoke members; non-owners see a read-only member list. + * A granted device picks up the workspace via the normal sync flow. */ +export function ShareDialog({ open, onOpenChange, workspaceId, workspaceName }: ShareDialogProps) { + const { t } = useLingui(); + const [members, setMembers] = useState([]); + const [revokeTarget, setRevokeTarget] = useState(null); + const { loading, error, run } = useAsyncAction(); + + const devices = useNetworkStore((s) => s.devices); + const pairedDevices = useMemo(() => devices.filter((d) => d.isPaired), [devices]); + + const refreshMembers = useCallback(async () => { + setMembers(await commands.listWorkspaceMembers(workspaceId)); + }, [workspaceId]); + + useEffect(() => { + if (!open) return; + void useNetworkStore.getState().refreshDevices(); + void run(refreshMembers); + }, [open, run, refreshMembers]); + + const amOwner = members.find((m) => m.isSelf)?.role === "owner"; + const memberPeers = useMemo(() => new Set(members.map((m) => m.peerId)), [members]); + const addable = pairedDevices.filter((d) => !memberPeers.has(d.peerId)); + + async function handleShare(peerId: string, name: string) { + await run(async () => { + await commands.shareWorkspaceToDevice(workspaceId, peerId); + toast.success(t`已分享给 ${name}`); + await refreshMembers(); + }); + } + + return ( + <> + + + + + + 共享「{workspaceName}」 + + + {amOwner ? ( + 邀请已配对的设备协作编辑此工作区 + ) : ( + 此工作区的成员 + )} + + + + {/* Members */} +
+

+ 成员 +

+
+ {members.length === 0 ? ( +

+ {loading ? 加载中… : 暂无成员} +

+ ) : ( + members.map((m, i) => ( + + )) + )} +
+
+ + {/* Add devices (owner only) */} + {amOwner && ( +
+

+ 添加设备 +

+ {addable.length === 0 ? ( +
+ +

+ 没有可添加的设备 +

+

+ 先在「设备」设置里配对设备 +

+
+ ) : ( +
+ {addable.map((d, i) => ( +
+ + + {d.name ?? d.peerId.slice(0, 12)} + + +
+ ))} +
+ )} +
+ )} + + +
+
+ + { + if (!o) setRevokeTarget(null); + }} + onDone={() => { + setRevokeTarget(null); + void run(refreshMembers); + }} + /> + + ); +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index a8dbd1c..5966ebf 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -2,157 +2,127 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, type Channel } from "@tauri-apps/api/core"; +import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"; import * as __TAURI_EVENT from "@tauri-apps/api/event"; /** Commands */ export const commands = { - /** Return current device info. */ - getDeviceInfo: () => __TAURI_INVOKE("get_device_info"), - /** - * Update device name and persist to config; restart P2P node if running - * so the new name propagates via libp2p Identify agent_version. - */ - setDeviceName: (name: string) => __TAURI_INVOKE("set_device_name", { name }), - /** Idempotently open / create a workspace and bind it to the invoking window. */ - openWorkspace: (path: string) => __TAURI_INVOKE("open_workspace", { path }), - /** Return info for the workspace currently bound to this window. */ - getWorkspaceInfo: () => - __TAURI_INVOKE<{ - id: string; - name: string; - path: string; - created_by: string; - created_at: string; - updated_at: string; - /** - * Number of document rows in this workspace's DB. Populated at construction - * time (0) and refreshed on demand via [`WorkspaceCore::fresh_info`] — the - * `info()` getter returns the last-cached snapshot without hitting the DB. - */ - doc_count?: number; - } | null>("get_workspace_info"), - getRecentWorkspaces: () => __TAURI_INVOKE("get_recent_workspaces"), - /** Open a workspace window: reuse existing / bind to caller / create new. */ - openWorkspaceWindow: (path: string, bindToWindow: string | null, closeWindow: string | null) => - __TAURI_INVOKE("open_workspace_window", { - path, - bindToWindow, - closeWindow, - }), - finishOnboarding: () => __TAURI_INVOKE("finish_onboarding"), - removeRecentWorkspace: (path: string) => - __TAURI_INVOKE("remove_recent_workspace", { path }), - openWorkspaceManagerWindow: () => __TAURI_INVOKE("open_workspace_manager_window"), - openSettingsWindow: (route: string | null) => - __TAURI_INVOKE("open_settings_window", { route }), - /** Create a workspace for sync (no window, pre-assigned UUID). */ - createWorkspaceForSync: (uuid: string, name: string, basePath: string) => - __TAURI_INVOKE("create_workspace_for_sync", { uuid, name, basePath }), - dbUpsertDocument: (input: UpsertDocumentInput) => - __TAURI_INVOKE("db_upsert_document", { input }), - deleteDocumentByRelPath: (relPath: string) => - __TAURI_INVOKE("delete_document_by_rel_path", { relPath }), - deleteDocumentsByPrefix: (prefix: string) => - __TAURI_INVOKE("delete_documents_by_prefix", { prefix }), - renameDocument: (input: RenameDocumentInput) => - __TAURI_INVOKE("rename_document", { input }), - /** - * Atomically move a document or folder. - * - * Uses [`swarmnote_core::fs::ops::move_node`] for the physical move, then - * rebases DB rows + in-memory YDocManager entries accordingly. - */ - moveDocument: (input: MoveDocumentInput) => - __TAURI_INVOKE("move_document", { input }), - dbGetFolders: (workspaceId: string) => - __TAURI_INVOKE("db_get_folders", { workspaceId }), - dbCreateFolder: (input: CreateFolderInput) => - __TAURI_INVOKE("db_create_folder", { input }), - dbDeleteFolder: (id: string) => __TAURI_INVOKE("db_delete_folder", { id }), - scanWorkspaceTree: () => __TAURI_INVOKE("scan_workspace_tree"), - fsCreateFile: (parentRel: string, name: string) => - __TAURI_INVOKE("fs_create_file", { parentRel, name }), - fsCreateDir: (parentRel: string, name: string) => - __TAURI_INVOKE("fs_create_dir", { parentRel, name }), - fsDeleteFile: (relPath: string) => __TAURI_INVOKE("fs_delete_file", { relPath }), - fsDeleteDir: (relPath: string) => __TAURI_INVOKE("fs_delete_dir", { relPath }), - fsRename: (relPath: string, newName: string) => - __TAURI_INVOKE("fs_rename", { relPath, newName }), - loadDocument: (relPath: string) => __TAURI_INVOKE("load_document", { relPath }), - saveDocument: (relPath: string, content: string) => - __TAURI_INVOKE("save_document", { relPath, content }), - saveMedia: (relPath: string, fileName: string, data: number[]) => - __TAURI_INVOKE("save_media", { relPath, fileName, data }), - /** 启动 P2P 节点。 */ - startP2pNode: () => __TAURI_INVOKE("start_p2p_node"), - /** 停止 P2P 节点。 */ - stopP2pNode: () => __TAURI_INVOKE("stop_p2p_node"), - /** 查询 P2P 节点当前运行状态。 */ - getNetworkStatus: () => __TAURI_INVOKE("get_network_status"), - /** 获取已连接的设备列表。 */ - getConnectedPeers: () => __TAURI_INVOKE("get_connected_peers"), - generatePairingCode: (expiresInSecs: number | null) => - __TAURI_INVOKE("generate_pairing_code", { expiresInSecs }), - getDeviceByCode: (code: string) => - __TAURI_INVOKE("get_device_by_code", { code }), - requestPairing: ( - peerId: string, - method: PairingMethod, - remoteOsInfo: { - /** User-set device name, propagated via the `agent_version` `name=` field. */ - name: string | null; - hostname: string; - os: string; - platform: string; - arch: string; - } | null, - ) => __TAURI_INVOKE("request_pairing", { peerId, method, remoteOsInfo }), - respondPairingRequest: (pendingId: number, accept: boolean) => - __TAURI_INVOKE("respond_pairing_request", { pendingId, accept }), - getPairedDevices: () => __TAURI_INVOKE("get_paired_devices"), - unpairDevice: (peerId: string) => __TAURI_INVOKE("unpair_device", { peerId }), - getNearbyDevices: () => __TAURI_INVOKE("get_nearby_devices"), - listDevices: (filter: "all" | "connected" | "paired" | null) => - __TAURI_INVOKE("list_devices", { filter }), - /** 并发查询所有已配对在线 peer 的工作区列表,标记 is_local。 */ - getRemoteWorkspaces: () => __TAURI_INVOKE("get_remote_workspaces"), - openYdoc: (relPath: string, workspaceId: string) => - __TAURI_INVOKE("open_ydoc", { relPath, workspaceId }), - applyYdocUpdate: (docUuid: string, update: number[]) => - __TAURI_INVOKE("apply_ydoc_update", { docUuid, update }), - broadcastAwareness: (docUuid: string, update: number[]) => - __TAURI_INVOKE("broadcast_awareness", { docUuid, update }), - closeYdoc: (docUuid: string) => __TAURI_INVOKE("close_ydoc", { docUuid }), - renameYdoc: (docUuid: string, newRelPath: string) => - __TAURI_INVOKE("rename_ydoc", { docUuid, newRelPath }), - reloadYdocConfirmed: (docUuid: string) => - __TAURI_INVOKE("reload_ydoc_confirmed", { docUuid }), - hydrateWorkspace: (workspaceUuid: string, onProgress: Channel) => - __TAURI_INVOKE("hydrate_workspace", { workspaceUuid, onProgress }), - triggerWorkspaceSync: (workspaceUuid: string, peerId: string) => - __TAURI_INVOKE("trigger_workspace_sync", { workspaceUuid, peerId }), + /** Return current device info. */ + getDeviceInfo: () => __TAURI_INVOKE("get_device_info"), + /** + * Update device name and persist to config; restart P2P node if running + * so the new name propagates via libp2p Identify agent_version. + */ + setDeviceName: (name: string) => __TAURI_INVOKE("set_device_name", { name }), + /** Idempotently open / create a workspace and bind it to the invoking window. */ + openWorkspace: (path: string) => __TAURI_INVOKE("open_workspace", { path }), + /** Return info for the workspace currently bound to this window. */ + getWorkspaceInfo: () => __TAURI_INVOKE<{ + id: string, + name: string, + path: string, + created_by: string, + created_at: string, + updated_at: string, + /** + * Number of document rows in this workspace's DB. Populated at construction + * time (0) and refreshed on demand via [`WorkspaceCore::fresh_info`] — the + * `info()` getter returns the last-cached snapshot without hitting the DB. + */ + doc_count?: number, +} | null>("get_workspace_info"), + getRecentWorkspaces: () => __TAURI_INVOKE("get_recent_workspaces"), + /** Open a workspace window: reuse existing / bind to caller / create new. */ + openWorkspaceWindow: (path: string, bindToWindow: string | null, closeWindow: string | null) => __TAURI_INVOKE("open_workspace_window", { path, bindToWindow, closeWindow }), + finishOnboarding: () => __TAURI_INVOKE("finish_onboarding"), + removeRecentWorkspace: (path: string) => __TAURI_INVOKE("remove_recent_workspace", { path }), + openWorkspaceManagerWindow: () => __TAURI_INVOKE("open_workspace_manager_window"), + openSettingsWindow: (route: string | null) => __TAURI_INVOKE("open_settings_window", { route }), + /** Create a workspace for sync (no window, pre-assigned UUID). */ + createWorkspaceForSync: (uuid: string, name: string, basePath: string) => __TAURI_INVOKE("create_workspace_for_sync", { uuid, name, basePath }), + dbUpsertDocument: (input: UpsertDocumentInput) => __TAURI_INVOKE("db_upsert_document", { input }), + deleteDocumentByRelPath: (relPath: string) => __TAURI_INVOKE("delete_document_by_rel_path", { relPath }), + deleteDocumentsByPrefix: (prefix: string) => __TAURI_INVOKE("delete_documents_by_prefix", { prefix }), + renameDocument: (input: RenameDocumentInput) => __TAURI_INVOKE("rename_document", { input }), + /** + * Atomically move a document or folder. + * + * Uses [`swarmnote_core::fs::ops::move_node`] for the physical move, then + * rebases DB rows + in-memory YDocManager entries accordingly. + */ + moveDocument: (input: MoveDocumentInput) => __TAURI_INVOKE("move_document", { input }), + dbGetFolders: (workspaceId: string) => __TAURI_INVOKE("db_get_folders", { workspaceId }), + dbCreateFolder: (input: CreateFolderInput) => __TAURI_INVOKE("db_create_folder", { input }), + dbDeleteFolder: (id: string) => __TAURI_INVOKE("db_delete_folder", { id }), + scanWorkspaceTree: () => __TAURI_INVOKE("scan_workspace_tree"), + fsCreateFile: (parentRel: string, name: string) => __TAURI_INVOKE("fs_create_file", { parentRel, name }), + fsCreateDir: (parentRel: string, name: string) => __TAURI_INVOKE("fs_create_dir", { parentRel, name }), + fsDeleteFile: (relPath: string) => __TAURI_INVOKE("fs_delete_file", { relPath }), + fsDeleteDir: (relPath: string) => __TAURI_INVOKE("fs_delete_dir", { relPath }), + fsRename: (relPath: string, newName: string) => __TAURI_INVOKE("fs_rename", { relPath, newName }), + loadDocument: (relPath: string) => __TAURI_INVOKE("load_document", { relPath }), + saveDocument: (relPath: string, content: string) => __TAURI_INVOKE("save_document", { relPath, content }), + saveMedia: (relPath: string, fileName: string, data: number[]) => __TAURI_INVOKE("save_media", { relPath, fileName, data }), + /** 启动 P2P 节点。 */ + startP2pNode: () => __TAURI_INVOKE("start_p2p_node"), + /** 停止 P2P 节点。 */ + stopP2pNode: () => __TAURI_INVOKE("stop_p2p_node"), + /** 查询 P2P 节点当前运行状态。 */ + getNetworkStatus: () => __TAURI_INVOKE("get_network_status"), + /** 获取已连接的设备列表。 */ + getConnectedPeers: () => __TAURI_INVOKE("get_connected_peers"), + generatePairingCode: (expiresInSecs: number | null) => __TAURI_INVOKE("generate_pairing_code", { expiresInSecs }), + getDeviceByCode: (code: string) => __TAURI_INVOKE("get_device_by_code", { code }), + requestPairing: (peerId: string, method: PairingMethod, remoteOsInfo: { + /** User-set device name, propagated via the `agent_version` `name=` field. */ + name: string | null, + hostname: string, + os: string, + platform: string, + arch: string, +} | null) => __TAURI_INVOKE("request_pairing", { peerId, method, remoteOsInfo }), + respondPairingRequest: (pendingId: number, accept: boolean) => __TAURI_INVOKE("respond_pairing_request", { pendingId, accept }), + getPairedDevices: () => __TAURI_INVOKE("get_paired_devices"), + unpairDevice: (peerId: string) => __TAURI_INVOKE("unpair_device", { peerId }), + getNearbyDevices: () => __TAURI_INVOKE("get_nearby_devices"), + listDevices: (filter: "all" | "connected" | "paired" | null) => __TAURI_INVOKE("list_devices", { filter }), + /** 并发查询所有已配对在线 peer 的工作区列表,标记 is_local。 */ + getRemoteWorkspaces: () => __TAURI_INVOKE("get_remote_workspaces"), + openYdoc: (relPath: string, workspaceId: string) => __TAURI_INVOKE("open_ydoc", { relPath, workspaceId }), + applyYdocUpdate: (docUuid: string, update: number[]) => __TAURI_INVOKE("apply_ydoc_update", { docUuid, update }), + broadcastAwareness: (docUuid: string, update: number[]) => __TAURI_INVOKE("broadcast_awareness", { docUuid, update }), + closeYdoc: (docUuid: string) => __TAURI_INVOKE("close_ydoc", { docUuid }), + renameYdoc: (docUuid: string, newRelPath: string) => __TAURI_INVOKE("rename_ydoc", { docUuid, newRelPath }), + reloadYdocConfirmed: (docUuid: string) => __TAURI_INVOKE("reload_ydoc_confirmed", { docUuid }), + hydrateWorkspace: (workspaceUuid: string, onProgress: Channel) => __TAURI_INVOKE("hydrate_workspace", { workspaceUuid, onProgress }), + triggerWorkspaceSync: (workspaceUuid: string, peerId: string) => __TAURI_INVOKE("trigger_workspace_sync", { workspaceUuid, peerId }), + /** Grant a paired device Collaborator access to the workspace (owner only). */ + shareWorkspaceToDevice: (workspaceUuid: string, targetPeerId: string) => __TAURI_INVOKE("share_workspace_to_device", { workspaceUuid, targetPeerId }), + /** List the workspace's current members (role + device presence). */ + listWorkspaceMembers: (workspaceUuid: string) => __TAURI_INVOKE("list_workspace_members", { workspaceUuid }), + /** Revoke a member's access (owner only). */ + revokeWorkspaceMember: (workspaceUuid: string, targetPeerId: string) => __TAURI_INVOKE("revoke_workspace_member", { workspaceUuid, targetPeerId }), }; /** Events */ export const events = { - devicesChanged: makeEvent("devices-changed"), - docFlushed: makeEvent("doc-flushed"), - externalAwarenessUpdate: makeEvent("external-awareness-update"), - externalConflict: makeEvent("external-conflict"), - externalUpdate: makeEvent("external-update"), - fileTreeChanged: makeEvent("file-tree-changed"), - navigate: makeEvent("navigate"), - networkStatusChanged: makeEvent("network-status-changed"), - nodeStarted: makeEvent("node-started"), - nodeStopped: makeEvent("node-stopped"), - pairedDeviceAdded: makeEvent("paired-device-added"), - pairedDeviceRemoved: makeEvent("paired-device-removed"), - pairingRequestReceived: makeEvent("pairing-request-received"), - syncCompleted: makeEvent("sync-completed"), - syncProgress: makeEvent("sync-progress"), - syncStarted: makeEvent("sync-started"), - workspaceReady: makeEvent("workspace-ready"), + devicesChanged: makeEvent("devices-changed"), + docFlushed: makeEvent("doc-flushed"), + externalAwarenessUpdate: makeEvent("external-awareness-update"), + externalConflict: makeEvent("external-conflict"), + externalUpdate: makeEvent("external-update"), + fileTreeChanged: makeEvent("file-tree-changed"), + navigate: makeEvent("navigate"), + networkStatusChanged: makeEvent("network-status-changed"), + nodeStarted: makeEvent("node-started"), + nodeStopped: makeEvent("node-stopped"), + pairedDeviceAdded: makeEvent("paired-device-added"), + pairedDeviceRemoved: makeEvent("paired-device-removed"), + pairingRequestReceived: makeEvent("pairing-request-received"), + syncCompleted: makeEvent("sync-completed"), + syncProgress: makeEvent("sync-progress"), + syncStarted: makeEvent("sync-started"), + workspaceReady: makeEvent("workspace-ready"), }; /* Types */ @@ -161,32 +131,32 @@ export type ConnectionType = "lan" | "dcutr" | "relay"; /** Input for [`DocumentCrud::create_folder`]. */ export type CreateFolderInput = { - workspace_id: string; - parent_folder_id: string | null; - name: string; - rel_path: string; + workspace_id: string, + parent_folder_id: string | null, + name: string, + rel_path: string, }; /** 统一的设备输出类型(发送给前端) */ export type Device = { - peerId: string; - name: string | null; - hostname: string; - os: string; - platform: string; - arch: string; - status: DeviceStatus; - connection: ConnectionType | null; - latency: number | null; - isPaired: boolean; - pairedAt: string | null; - lastSeen: string | null; + peerId: string, + name: string | null, + hostname: string, + os: string, + platform: string, + arch: string, + status: DeviceStatus, + connection: ConnectionType | null, + latency: number | null, + isPaired: boolean, + pairedAt: string | null, + lastSeen: string | null, }; /** `get_device_by_code` 的类型化返回值。 */ export type DeviceByCodeResult = { - peerId: string; - osInfo: OsInfo; + peerId: string, + osInfo: OsInfo, }; /** 设备过滤器 */ @@ -197,19 +167,19 @@ export type DeviceFilter = "all" | "connected" | "paired"; * `get_device_info`. */ export type DeviceInfo = { - peer_id: string; - device_name: string; - hostname: string; - os: string; - platform: string; - arch: string; - created_at: string; + peer_id: string, + device_name: string, + hostname: string, + os: string, + platform: string, + arch: string, + created_at: string, }; /** 设备列表查询结果 */ export type DeviceListResult = { - devices: Device[]; - total: number; + devices: Device[], + total: number, }; /** 设备状态 */ @@ -218,31 +188,31 @@ export type DeviceStatus = "online" | "offline"; export type DevicesChanged = Device[]; export type DocFlushed = { - docUuid: string; + docUuid: string, }; export type ExternalAwarenessUpdate = { - docUuid: string; - update: number[]; + docUuid: string, + update: number[], }; export type ExternalConflict = { - docUuid: string; - relPath: string; + docUuid: string, + relPath: string, }; export type ExternalUpdate = { - docUuid: string; - update: number[]; + docUuid: string, + update: number[], }; export type FileTreeChanged = { - workspaceId: string; + workspaceId: string, }; /** * A node in the workspace file tree returned by [`FileSystem::scan_tree`]. - * + * * Matches the shape emitted to the frontend — do not change field names * without coordinating with `src/commands/fs.ts` and the file-tree store. */ @@ -250,75 +220,89 @@ export type FileTreeNode = FileTreeNode_Serialize | FileTreeNode_Deserialize; /** * A node in the workspace file tree returned by [`FileSystem::scan_tree`]. - * + * * Matches the shape emitted to the frontend — do not change field names * without coordinating with `src/commands/fs.ts` and the file-tree store. */ export type FileTreeNode_Deserialize = { - /** Workspace-relative path (stable ID in the frontend tree). */ - id: string; - /** Display name. For `.md` files the extension is stripped. */ - name: string; - /** `Some(children)` for directories, `None` for files. */ - children: FileTreeNode_Deserialize[] | null; + /** Workspace-relative path (stable ID in the frontend tree). */ + id: string, + /** Display name. For `.md` files the extension is stripped. */ + name: string, + /** `Some(children)` for directories, `None` for files. */ + children: FileTreeNode_Deserialize[] | null, }; /** * A node in the workspace file tree returned by [`FileSystem::scan_tree`]. - * + * * Matches the shape emitted to the frontend — do not change field names * without coordinating with `src/commands/fs.ts` and the file-tree store. */ export type FileTreeNode_Serialize = { - /** Workspace-relative path (stable ID in the frontend tree). */ - id: string; - /** Display name. For `.md` files the extension is stripped. */ - name: string; - /** `Some(children)` for directories, `None` for files. */ - children?: FileTreeNode_Serialize[] | null; + /** Workspace-relative path (stable ID in the frontend tree). */ + id: string, + /** Display name. For `.md` files the extension is stripped. */ + name: string, + /** `Some(children)` for directories, `None` for files. */ + children?: FileTreeNode_Serialize[] | null, }; /** * 文件夹行 —— `db_create_folder` / `db_get_folders` 的 IPC 返回类型。 - * + * * `entity::folders::Model` 的 struct 名硬编码为 `Model`,与 `documents::Model` * 撞名后会在 TS bindings 里冲突。这里定义投影 DTO 解耦 sea-orm relation 字段。 */ export type FolderRow = { - id: string; - workspaceId: string; - parentFolderId: string | null; - name: string; - relPath: string; - createdBy: string; - createdAt: string; - updatedAt: string; + id: string, + workspaceId: string, + parentFolderId: string | null, + name: string, + relPath: string, + createdBy: string, + createdAt: string, + updatedAt: string, }; /** Progress tick emitted during hydration. */ export type HydrateProgress = { - current: number; - total: number; + current: number, + total: number, }; /** Summary returned when hydration completes. */ export type HydrateResult = { - generated: number; - merged: number; - skipped: number; - failed: number; + generated: number, + merged: number, + skipped: number, + failed: number, +}; + +/** + * A workspace member (materialized role + device presence), for the + * member-management UI. + */ +export type MemberInfo = { + peerId: string, + role: Role, + name: string | null, + os: string, + isOnline: boolean, + /** `true` for this device's own row (the owner) — UI hides revoke/role edit. */ + isSelf: boolean, }; export type MoveDocumentInput = { - /** 源路径(文件或目录),相对工作区根。 */ - fromRelPath: string; - /** 目标完整路径(不是目标父目录),相对工作区根。 */ - toRelPath: string; + /** 源路径(文件或目录),相对工作区根。 */ + fromRelPath: string, + /** 目标完整路径(不是目标父目录),相对工作区根。 */ + toRelPath: string, }; export type MoveDocumentResult = { - newRelPath: string; - isDir: boolean; + newRelPath: string, + isDir: boolean, }; /** @@ -328,44 +312,38 @@ export type MoveDocumentResult = { export type Navigate = string; export type NetworkStatusChanged = { - natStatus: string; - publicAddr: string | null; + natStatus: string, + publicAddr: string | null, }; export type NodeStarted = null; /** P2P 节点状态——Rust/前端共用的 single source of truth。 */ -export type NodeStatus = - | { kind: "stopped" } - | { kind: "running" } - | { kind: "error"; message: string }; +export type NodeStatus = { kind: "stopped" } | { kind: "running" } | { kind: "error"; message: string }; export type NodeStopped = null; /** Returned by [`YDocManager::open_doc`] so the frontend knows the stable UUID. */ export type OpenDocResult = { - /** Stable document UUID (database primary key). */ - doc_uuid: string; - /** Full Y.Doc state as binary v1 update. */ - yjs_state: number[]; + /** Stable document UUID (database primary key). */ + doc_uuid: string, + /** Full Y.Doc state as binary v1 update. */ + yjs_state: number[], }; -export type OpenWorkspaceWindowResult = - | { kind: "bound_to_caller"; info: WorkspaceInfo } - | { kind: "focused_existing" } - | { kind: "new_window" }; +export type OpenWorkspaceWindowResult = { kind: "bound_to_caller"; info: WorkspaceInfo } | { kind: "focused_existing" } | { kind: "new_window" }; /** * Device operating system + user-facing name, embedded in the * `agent_version` string libp2p advertises via Identify. */ export type OsInfo = { - /** User-set device name, propagated via the `agent_version` `name=` field. */ - name: string | null; - hostname: string; - os: string; - platform: string; - arch: string; + /** User-set device name, propagated via the `agent_version` `name=` field. */ + name: string | null, + hostname: string, + os: string, + platform: string, + arch: string, }; /** @@ -391,41 +369,41 @@ export type PairedDeviceInfo = PairedDeviceInfo_Serialize | PairedDeviceInfo_Des /** 已配对设备信息,同时用于运行时缓存和 Tauri Event payload。 */ export type PairedDeviceInfo_Deserialize = { - peerId: string; - name: string | null; - hostname: string; - os: string; - platform: string; - arch: string; - pairedAt: string; - lastSeen: string | null; - isOnline: boolean | null; - rttMs: number | null; + peerId: string, + name: string | null, + hostname: string, + os: string, + platform: string, + arch: string, + pairedAt: string, + lastSeen: string | null, + isOnline: boolean | null, + rttMs: number | null, }; /** 已配对设备信息,同时用于运行时缓存和 Tauri Event payload。 */ export type PairedDeviceInfo_Serialize = { - peerId: string; - name?: string | null; - hostname: string; - os: string; - platform: string; - arch: string; - pairedAt: string; - lastSeen: string | null; - isOnline?: boolean | null; - rttMs?: number | null; + peerId: string, + name?: string | null, + hostname: string, + os: string, + platform: string, + arch: string, + pairedAt: string, + lastSeen: string | null, + isOnline?: boolean | null, + rttMs?: number | null, }; export type PairedDeviceRemoved = { - peerId: string; + peerId: string, }; /** 配对码信息,包含生成的 6 位数字码及其有效期。 */ export type PairingCodeInfo = { - code: string; - createdAt: string; - expiresAt: string; + code: string, + createdAt: string, + expiresAt: string, }; export type PairingMethod = { type: "Code"; code: string } | { type: "Direct" }; @@ -433,66 +411,67 @@ export type PairingMethod = { type: "Code"; code: string } | { type: "Direct" }; export type PairingRefuseReason = "UserRejected" | "CodeExpired" | "CodeInvalid"; export type PairingRequestReceived = { - pendingId: number; - peerId: string; - osInfo: OsInfo; - method: PairingMethod; - expiresAt: string; + pendingId: number, + peerId: string, + osInfo: OsInfo, + method: PairingMethod, + expiresAt: string, }; -export type PairingResponse = - | { status: "Success" } - | { status: "Refused"; reason: PairingRefuseReason }; +export type PairingResponse = { status: "Success" } | { status: "Refused"; reason: PairingRefuseReason }; export type RecentWorkspace = { - path: string; - name: string; - last_opened_at: string; - /** Workspace UUID, used by the frontend to match live sync state. */ - uuid?: string | null; + path: string, + name: string, + last_opened_at: string, + /** Workspace UUID, used by the frontend to match live sync state. */ + uuid?: string | null, }; /** 远程工作区信息(合并来源 peer 信息) */ export type RemoteWorkspaceInfo = { - uuid: string; - name: string; - docCount: number; - updatedAt: number; - peerId: string; - peerName: string; - isLocal: boolean; + uuid: string, + name: string, + docCount: number, + updatedAt: number, + peerId: string, + peerName: string, + isLocal: boolean, }; export type RenameDocumentInput = { - oldRelPath: string; - newRelPath: string; - newTitle: string; + oldRelPath: string, + newRelPath: string, + newTitle: string, }; +/** Workspace role. v1 two-tier; `Reader` is reserved for v2. */ +export type Role = "owner" | "collaborator"; + export type SaveDocumentResult = { - /** blake3 hash hex string */ - fileHash: string; + /** blake3 hash hex string */ + fileHash: string, }; export type SyncCompleted = { - workspaceUuid: string; - peerId: string; - result: SyncResult; - error: string | null; + workspaceUuid: string, + peerId: string, + result: SyncResult, + error: string | null, }; export type SyncProgress = { - workspaceUuid: string; - peerId: string; - completed: number; - total: number; + workspaceUuid: string, + peerId: string, + completed: number, + total: number, }; export type SyncResult = "success" | "error" | "cancelled"; export type SyncStarted = { - workspaceUuid: string; - peerId: string; + workspaceUuid: string, + peerId: string, }; /** @@ -500,12 +479,12 @@ export type SyncStarted = { * shape (see `src/commands/document.ts`). */ export type UpsertDocumentInput = { - id: string | null; - workspace_id: string; - folder_id: string | null; - title: string; - rel_path: string; - file_hash: string | null; + id: string | null, + workspace_id: string, + folder_id: string | null, + title: string, + rel_path: string, + file_hash: string | null, }; /** @@ -514,18 +493,18 @@ export type UpsertDocumentInput = { * own metadata snapshot. */ export type WorkspaceInfo = { - id: string; - name: string; - path: string; - created_by: string; - created_at: string; - updated_at: string; - /** - * Number of document rows in this workspace's DB. Populated at construction - * time (0) and refreshed on demand via [`WorkspaceCore::fresh_info`] — the - * `info()` getter returns the last-cached snapshot without hitting the DB. - */ - doc_count?: number; + id: string, + name: string, + path: string, + created_by: string, + created_at: string, + updated_at: string, + /** + * Number of document rows in this workspace's DB. Populated at construction + * time (0) and refreshed on demand via [`WorkspaceCore::fresh_info`] — the + * `info()` getter returns the last-cached snapshot without hitting the DB. + */ + doc_count?: number, }; /** @@ -538,29 +517,22 @@ export type WorkspaceReady = WorkspaceInfo; /* Tauri Specta runtime */ type EventEmit = [T] extends [null] ? () => Promise : (payload: T) => Promise; -function makeEvent( - name: string, - serialize?: (payload: T) => unknown, - deserialize?: (payload: any) => T, -) { - const mapEvent = (cb: __TAURI_EVENT.EventCallback) => (event: __TAURI_EVENT.Event) => - cb({ ...event, payload: deserialize ? deserialize(event.payload) : event.payload }); - const mapPayload = (payload: T) => (serialize ? serialize(payload) : payload); - - const base = { - listen: (cb: __TAURI_EVENT.EventCallback) => __TAURI_EVENT.listen(name, mapEvent(cb)), - once: (cb: __TAURI_EVENT.EventCallback) => __TAURI_EVENT.once(name, mapEvent(cb)), - emit: ((payload: T) => - __TAURI_EVENT.emit(name, mapPayload(payload)) as unknown) as EventEmit, - }; - - const fn = ( - target: import("@tauri-apps/api/webview").Webview | import("@tauri-apps/api/window").Window, - ) => ({ - listen: (cb: __TAURI_EVENT.EventCallback) => target.listen(name, mapEvent(cb)), - once: (cb: __TAURI_EVENT.EventCallback) => target.once(name, mapEvent(cb)), - emit: ((payload: T) => target.emit(name, mapPayload(payload)) as unknown) as EventEmit, - }); - - return Object.assign(fn, base); +function makeEvent(name: string, serialize?: (payload: T) => unknown, deserialize?: (payload: any) => T) { + const mapEvent = (cb: __TAURI_EVENT.EventCallback) => (event: __TAURI_EVENT.Event) => cb({ ...event, payload: deserialize ? deserialize(event.payload) : event.payload }); + const mapPayload = (payload: T) => serialize ? serialize(payload) : payload; + + const base = { + listen: (cb: __TAURI_EVENT.EventCallback) => __TAURI_EVENT.listen(name, mapEvent(cb)), + once: (cb: __TAURI_EVENT.EventCallback) => __TAURI_EVENT.once(name, mapEvent(cb)), + emit: ((payload: T) => __TAURI_EVENT.emit(name, mapPayload(payload)) as unknown) as EventEmit + }; + + const fn = (target: import("@tauri-apps/api/webview").Webview | import("@tauri-apps/api/window").Window) => ({ + listen: (cb: __TAURI_EVENT.EventCallback) => target.listen(name, mapEvent(cb)), + once: (cb: __TAURI_EVENT.EventCallback) => target.once(name, mapEvent(cb)), + emit: ((payload: T) => target.emit(name, mapPayload(payload)) as unknown) as EventEmit + }); + + return Object.assign(fn, base); } + diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index d039a5a..54ebc8e 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -44,61 +44,65 @@ msgid "新建笔记" msgstr "New note" #. placeholder {0}: event.payload.relPath -#: src/components/editor/NoteEditor.tsx:425 +#: src/components/editor/NoteEditor.tsx:466 msgid "\"{0}\" 已被外部修改。是否重新加载?当前未保存的编辑将丢失。" msgstr "\"{0}\" was modified externally. Reload? Unsaved edits will be lost." +#: src/components/share/MemberRow.tsx:33 +msgid "(你)" +msgstr "" + #. placeholder {0}: pairedDevices.length -#: src/routes/settings/devices.tsx:217 +#: src/routes/settings/devices.tsx:218 msgid "{0} 台" msgstr "{0} devices" #. placeholder {0}: onlineDevices.length -#: src/components/workspace/WorkspacePicker.tsx:84 +#: src/components/workspace/WorkspacePicker.tsx:79 msgid "{0} 台设备在线,可同步工作区" msgstr "{0} devices online, workspaces available to sync" #. placeholder {0}: formatSeconds(remaining) -#: src/components/pairing/CodePairingCard.tsx:89 +#: src/components/pairing/CodePairingCard.tsx:80 msgid "{0} 后过期" msgstr "Expires in {0}" #. placeholder {0}: item.ws.docCount #. placeholder {0}: ws.docCount -#: src/components/workspace/WorkspaceSyncDialog.tsx:109 -#: src/components/workspace/WorkspaceSyncDialog.tsx:345 +#: src/components/workspace/WorkspaceSyncDialog.tsx:103 +#: src/components/workspace/WorkspaceSyncDialog.tsx:339 msgid "{0} 篇文档" msgstr "{0} documents" -#: src/components/workspace/WorkspaceSyncDialog.tsx:418 +#: src/components/workspace/WorkspaceSyncDialog.tsx:412 msgid "{doneCount} 个成功,{errorCount} 个失败" msgstr "{doneCount} succeeded, {errorCount} failed" -#: src/components/layout/SyncStatusBar.tsx:34 +#: src/components/layout/SyncStatusBar.tsx:32 msgid "{peerCount} 台 · 同步中 {completed}/{total}" msgstr "{peerCount} devices · syncing {completed}/{total}" -#: src/components/layout/SyncStatusBar.tsx:42 +#: src/components/layout/SyncStatusBar.tsx:40 msgid "{peerCount} 台设备在线" msgstr "{peerCount} devices online" -#: src/components/layout/SyncStatusBar.tsx:38 +#: src/components/layout/SyncStatusBar.tsx:36 msgid "{peerCount} 台设备在线 · 已同步" msgstr "{peerCount} devices online · synced" -#: src/routes/settings/general.tsx:85 +#: src/routes/settings/general.tsx:88 msgid "Admonition" msgstr "Admonition" -#: src/routes/settings/general.tsx:92 +#: src/routes/settings/general.tsx:95 msgid "fenced 代码块渲染与高亮" msgstr "Fenced code block rendering and highlighting" -#: src/routes/settings/general.tsx:103 +#: src/routes/settings/general.tsx:106 msgid "HTML 渲染" msgstr "HTML rendering" -#: src/routes/settings/general.tsx:79 +#: src/routes/settings/general.tsx:82 msgid "Mermaid 图表" msgstr "Mermaid diagrams" @@ -110,7 +114,7 @@ msgstr "P2P Sync" msgid "P2P 网络" msgstr "P2P Network" -#: src/components/onboarding/PairingStep.tsx:172 +#: src/components/onboarding/PairingStep.tsx:174 msgid "P2P 节点启动失败" msgstr "Failed to start P2P node" @@ -118,6 +122,18 @@ msgstr "Failed to start P2P node" msgid "P2P 节点未运行" msgstr "P2P node not running" +#: src/routes/settings/general.tsx:130 +msgid "Selection 工具栏" +msgstr "" + +#: src/routes/settings/general.tsx:118 +msgid "Slash 命令" +msgstr "" + +#: src/routes/settings/general.tsx:124 +msgid "Wikilink" +msgstr "" + #: src/components/onboarding/DeviceNameStep.tsx:58 msgid "上一步" msgstr "Back" @@ -132,7 +148,7 @@ msgstr "Next" msgid "下载中..." msgstr "Downloading..." -#: src/routes/settings/general.tsx:136 +#: src/routes/settings/general.tsx:157 msgid "中文" msgstr "Chinese" @@ -144,20 +160,20 @@ msgstr "Relay" msgid "为你的设备取个名字,方便在 P2P 网络中识别。" msgstr "Give your device a name to identify it on the P2P network." -#: src/components/settings/WorkspaceSyncList.tsx:75 +#: src/components/settings/WorkspaceSyncList.tsx:74 msgid "仅本地" msgstr "Local only" -#: src/routes/workspace-manager.tsx:64 +#: src/routes/workspace-manager.tsx:58 msgid "从列表移除" msgstr "Remove from list" #. placeholder {0}: onlineDevices.length -#: src/routes/workspace-manager.tsx:272 +#: src/routes/workspace-manager.tsx:266 msgid "从已配对设备同步工作区到本地。{0} 台设备在线。" msgstr "Sync workspace from paired devices to local. {0} devices online." -#: src/routes/settings/general.tsx:91 +#: src/routes/settings/general.tsx:94 msgid "代码块" msgstr "Code block" @@ -165,11 +181,11 @@ msgstr "Code block" msgid "任务列表" msgstr "Task list" -#: src/components/onboarding/CompleteStep.tsx:36 +#: src/components/onboarding/CompleteStep.tsx:35 msgid "你可以在工作区管理窗口中选择要同步的工作区" msgstr "You can choose workspaces to sync in the Workspace Manager window" -#: src/components/onboarding/CompleteStep.tsx:38 +#: src/components/onboarding/CompleteStep.tsx:37 msgid "你可以稍后在设置 → 设备中配对设备" msgstr "You can pair devices later in Settings → Devices" @@ -177,10 +193,14 @@ msgstr "You can pair devices later in Settings → Devices" msgid "你是如何开始的?" msgstr "How are you getting started?" -#: src/components/onboarding/CompleteStep.tsx:40 +#: src/components/onboarding/CompleteStep.tsx:39 msgid "你的设备身份已建立,可以开始使用 SwarmNote 了。" msgstr "Your device identity is set up. You can start using SwarmNote." +#: src/components/share/ShareDialog.tsx:117 +msgid "先在「设备」设置里配对设备" +msgstr "" + #: src/components/onboarding/PathChoiceStep.tsx:37 msgid "全新开始" msgstr "Start fresh" @@ -189,11 +209,19 @@ msgstr "Start fresh" msgid "全选" msgstr "Select all" +#: src/components/share/ShareDialog.tsx:69 +msgid "共享「{workspaceName}」" +msgstr "" + +#: src/components/layout/TitleBar.tsx:122 +msgid "共享工作区" +msgstr "" + #: src/routes/settings.tsx:29 msgid "关于" msgstr "About" -#: src/components/pairing/CodePairingCard.tsx:72 +#: src/components/pairing/CodePairingCard.tsx:63 msgid "关闭" msgstr "Close" @@ -205,7 +233,7 @@ msgstr "Closing P2P network will disconnect all devices and stop syncing notes." msgid "关闭网络" msgstr "Stop Network" -#: src/routes/settings/general.tsx:208 +#: src/routes/settings/general.tsx:229 msgid "内联" msgstr "Inline" @@ -214,7 +242,7 @@ msgstr "Inline" msgid "最后在线 {0}" msgstr "Last online {0}" -#: src/components/workspace/WorkspacePicker.tsx:93 +#: src/components/workspace/WorkspacePicker.tsx:88 msgid "最近打开" msgstr "Recently opened" @@ -223,15 +251,19 @@ msgid "最近文件" msgstr "Recent Files" #. placeholder {0}: item.ws.docCount -#: src/components/workspace/WorkspaceSyncDialog.tsx:114 +#: src/components/workspace/WorkspaceSyncDialog.tsx:108 msgid "准备同步 · {0} 篇文档" msgstr "Preparing to sync · {0} documents" -#: src/components/onboarding/CompleteStep.tsx:32 +#: src/components/onboarding/CompleteStep.tsx:31 msgid "准备就绪!" msgstr "Ready to Go!" -#: src/routes/settings/general.tsx:210 +#: src/components/share/ShareDialog.tsx:139 +msgid "分享" +msgstr "" + +#: src/routes/settings/general.tsx:231 msgid "切换" msgstr "Toggle" @@ -239,7 +271,7 @@ msgstr "Toggle" msgid "切换侧边栏" msgstr "Toggle Sidebar" -#: src/routes/settings/general.tsx:191 +#: src/routes/settings/general.tsx:212 msgid "切换插件启用状态后,需要重新打开文档或重启应用以生效。" msgstr "Plugin toggles take effect on next document open or app restart." @@ -251,7 +283,7 @@ msgstr "" msgid "列" msgstr "" -#: src/routes/workspace-manager.tsx:247 +#: src/routes/workspace-manager.tsx:241 msgid "创建" msgstr "Create" @@ -259,7 +291,7 @@ msgstr "Create" msgid "创建你的第一篇笔记,开始记录想法" msgstr "Create your first note and start capturing ideas" -#: src/components/workspace/WorkspacePicker.tsx:64 +#: src/components/workspace/WorkspacePicker.tsx:59 msgid "创建新工作区" msgstr "Create new workspace" @@ -284,11 +316,12 @@ msgstr "" msgid "删除表格" msgstr "" -#: src/routes/settings/devices.tsx:254 +#: src/components/pairing/CodePairingCard.tsx:96 +#: src/routes/settings/devices.tsx:255 msgid "刷新" msgstr "Refresh" -#: src/components/onboarding/PairingStep.tsx:292 +#: src/components/onboarding/PairingStep.tsx:294 msgid "刷新码" msgstr "Refresh Code" @@ -300,10 +333,18 @@ msgstr "Cut" msgid "加粗" msgstr "Bold" -#: src/components/editor/NoteEditor.tsx:122 +#: src/components/editor/NoteEditor.tsx:141 msgid "加载中..." msgstr "Loading..." +#: src/components/share/ShareDialog.tsx:88 +msgid "加载中…" +msgstr "" + +#: src/components/share/RoleBadge.tsx:13 +msgid "协作者" +msgstr "" + #: src/routes/settings/about.tsx:67 msgid "去中心化、本地优先的 P2P 笔记应用" msgstr "Decentralized, local-first P2P note-taking app" @@ -321,13 +362,14 @@ msgid "发现新版本" msgstr "Update Available" #: src/components/filetree/FileTree.tsx:84 -#: src/components/onboarding/PairingStep.tsx:271 -#: src/components/onboarding/PairingStep.tsx:305 +#: src/components/onboarding/PairingStep.tsx:273 +#: src/components/onboarding/PairingStep.tsx:307 #: src/components/pairing/FoundDeviceDialog.tsx:72 -#: src/components/pairing/InputCodeDialog.tsx:89 +#: src/components/pairing/InputCodeDialog.tsx:85 #: src/components/pairing/UnpairConfirmDialog.tsx:55 #: src/components/settings/NetworkStatusCard.tsx:125 -#: src/components/workspace/WorkspaceSyncDialog.tsx:379 +#: src/components/share/RevokeMemberDialog.tsx:60 +#: src/components/workspace/WorkspaceSyncDialog.tsx:373 msgid "取消" msgstr "Cancel" @@ -340,7 +382,7 @@ msgid "取消配对" msgstr "Unpair" #: src/components/editor/EditorContextMenu.tsx:284 -#: src/routes/settings/general.tsx:161 +#: src/routes/settings/general.tsx:182 msgid "可读行宽" msgstr "Readable line width" @@ -348,42 +390,42 @@ msgstr "Readable line width" msgid "右对齐" msgstr "" -#: src/routes/workspace-manager.tsx:284 +#: src/routes/workspace-manager.tsx:278 msgid "同步" msgstr "Sync" #. placeholder {0}: syncState.completed #. placeholder {1}: syncState.total -#: src/components/settings/WorkspaceSyncList.tsx:25 +#: src/components/settings/WorkspaceSyncList.tsx:24 msgid "同步中 · {0}/{1} 篇" msgstr "Syncing · {0}/{1}" -#: src/components/workspace/WorkspaceSyncDialog.tsx:364 +#: src/components/workspace/WorkspaceSyncDialog.tsx:358 msgid "同步位置" msgstr "Sync location" -#: src/components/workspace/WorkspaceSyncDialog.tsx:264 +#: src/components/workspace/WorkspaceSyncDialog.tsx:258 msgid "同步完成" msgstr "Sync complete" #. placeholder {0}: item.ws.docCount -#: src/components/workspace/WorkspaceSyncDialog.tsx:122 +#: src/components/workspace/WorkspaceSyncDialog.tsx:116 msgid "同步完成 · {0} 篇文档" msgstr "Sync complete · {0} documents" -#: src/components/workspace/WorkspaceSyncDialog.tsx:266 +#: src/components/workspace/WorkspaceSyncDialog.tsx:260 msgid "同步工作区" msgstr "Sync workspace" -#: src/components/workspace/WorkspacePicker.tsx:81 +#: src/components/workspace/WorkspacePicker.tsx:76 msgid "同步已配对设备工作区" msgstr "Sync workspace from paired devices" -#: src/routes/workspace-manager.tsx:269 +#: src/routes/workspace-manager.tsx:263 msgid "同步远程工作区" msgstr "Sync remote workspace" -#: src/components/workspace/WorkspaceSyncDialog.tsx:398 +#: src/components/workspace/WorkspaceSyncDialog.tsx:392 msgid "后台运行" msgstr "Run in background" @@ -395,7 +437,7 @@ msgstr "Start P2P network to sync workspaces" msgid "启动中..." msgstr "Starting..." -#: src/routes/settings/general.tsx:178 +#: src/routes/settings/general.tsx:199 msgid "启动时自动打开上次使用的工作区" msgstr "Automatically open the last-used workspace on startup" @@ -403,15 +445,15 @@ msgstr "Automatically open the last-used workspace on startup" msgid "启动网络" msgstr "Start Network" -#: src/routes/settings/general.tsx:172 +#: src/routes/settings/general.tsx:193 msgid "启动行为" msgstr "Startup behavior" -#: src/components/layout/TitleBar.tsx:115 +#: src/components/layout/TitleBar.tsx:136 msgid "命令面板" msgstr "Command palette" -#: src/routes/settings/general.tsx:97 +#: src/routes/settings/general.tsx:100 msgid "图片渲染" msgstr "Image rendering" @@ -423,7 +465,7 @@ msgstr "" msgid "在下方新增行" msgstr "" -#: src/components/pairing/CodePairingCard.tsx:93 +#: src/components/pairing/CodePairingCard.tsx:84 msgid "在另一台设备输入此码" msgstr "Enter this code on another device" @@ -435,19 +477,20 @@ msgstr "" msgid "在左侧新增列" msgstr "" -#: src/routes/workspace-manager.tsx:244 +#: src/routes/workspace-manager.tsx:238 msgid "在指定文件夹下创建一个新的工作区。" msgstr "Create a new workspace in the specified folder." -#: src/routes/workspace-manager.tsx:55 +#: src/routes/workspace-manager.tsx:49 msgid "在文件管理器中打开" msgstr "Open in file manager" -#: src/components/editor/DocumentOutline.tsx:152 +#: src/components/layout/Sidebar.tsx:183 msgid "在文档中添加标题即可看到大纲导航" msgstr "Add headings in your document to see the outline navigation" -#: src/components/workspace/WorkspaceSyncDialog.tsx:328 +#: src/components/share/MemberRow.tsx:45 +#: src/components/workspace/WorkspaceSyncDialog.tsx:322 msgid "在线" msgstr "Online" @@ -456,8 +499,8 @@ msgid "在表头下方新增行" msgstr "" #: src/components/editor/EditorContextMenu.tsx:268 -#: src/components/pairing/CodePairingCard.tsx:101 -#: src/components/pairing/CodePairingCard.tsx:104 +#: src/components/pairing/CodePairingCard.tsx:102 +#: src/components/pairing/CodePairingCard.tsx:105 msgid "复制" msgstr "Copy" @@ -465,16 +508,16 @@ msgstr "Copy" msgid "复制为 Markdown" msgstr "" -#: src/routes/workspace-manager.tsx:59 +#: src/routes/workspace-manager.tsx:53 msgid "复制路径" msgstr "Copy path" -#: src/components/onboarding/PairingStep.tsx:289 +#: src/components/onboarding/PairingStep.tsx:291 msgid "复制配对码" msgstr "Copy Pairing Code" -#: src/routes/settings/general.tsx:126 -#: src/routes/settings/general.tsx:143 +#: src/routes/settings/general.tsx:147 +#: src/routes/settings/general.tsx:164 msgid "外观" msgstr "Appearance" @@ -482,7 +525,7 @@ msgstr "Appearance" msgid "多端协作" msgstr "Multi-device" -#: src/components/layout/TitleBar.tsx:94 +#: src/components/layout/TitleBar.tsx:105 msgid "大纲" msgstr "Outline" @@ -494,24 +537,24 @@ msgstr "Characters" msgid "安全加密" msgstr "Encrypted" -#: src/components/workspace/WorkspaceSyncDialog.tsx:424 +#: src/components/workspace/WorkspaceSyncDialog.tsx:418 msgid "完成" msgstr "Done" -#: src/routes/settings/general.tsx:98 +#: src/routes/settings/general.tsx:101 msgid "将 Markdown 图片渲染为内联 / 块级 widget" msgstr "Render Markdown images as inline / block widgets" -#: src/routes/workspace-manager.tsx:255 +#: src/routes/workspace-manager.tsx:249 msgid "将一个本地文件夹作为工作区打开。" msgstr "Open a local folder as a workspace." -#: src/routes/workspace-manager.tsx:274 +#: src/routes/workspace-manager.tsx:268 msgid "将已配对设备的工作区同步到本地。需先启动 P2P 网络。" msgstr "Sync workspaces from paired devices to local. The P2P network must be running first." #. placeholder {0}: formatSeconds(remaining) -#: src/components/onboarding/PairingStep.tsx:280 +#: src/components/onboarding/PairingStep.tsx:282 msgid "将此配对码告知对方设备,配对码将在 {0} 后过期" msgstr "Share this code with the other device. It expires in {0}." @@ -523,15 +566,15 @@ msgstr "LAN" msgid "居中对齐" msgstr "" -#: src/components/layout/TitleBar.tsx:77 +#: src/components/layout/TitleBar.tsx:88 msgid "展开侧边栏" msgstr "Expand Sidebar" -#: src/components/workspace/WorkspacePicker.tsx:118 +#: src/components/workspace/WorkspacePicker.tsx:113 msgid "工作区管理" msgstr "Workspace manager" -#: src/components/workspace/WorkspacePopover.tsx:78 +#: src/components/workspace/WorkspacePopover.tsx:72 msgid "工作区管理..." msgstr "Workspace manager..." @@ -540,7 +583,7 @@ msgid "左对齐" msgstr "" #. placeholder {0}: device.name ?? device.hostname -#: src/components/pairing/NearbyDeviceCard.tsx:35 +#: src/components/pairing/NearbyDeviceCard.tsx:34 msgid "已与 {0} 配对" msgstr "Paired with {0}" @@ -552,15 +595,19 @@ msgstr "Saved" msgid "已停止" msgstr "Stopped" +#: src/components/share/ShareDialog.tsx:57 +msgid "已分享给 {name}" +msgstr "" + #: src/components/pairing/UnpairConfirmDialog.tsx:37 msgid "已取消与 {deviceName} 的配对" msgstr "Unpaired from {deviceName}" -#: src/components/workspace/WorkspaceSyncDialog.tsx:350 +#: src/components/workspace/WorkspaceSyncDialog.tsx:344 msgid "已同步" msgstr "Synced" -#: src/components/settings/WorkspaceSyncList.tsx:57 +#: src/components/settings/WorkspaceSyncList.tsx:56 msgid "已同步 · 最后同步 {timeStr}" msgstr "Synced · last synced {timeStr}" @@ -568,6 +615,10 @@ msgstr "Synced · last synced {timeStr}" msgid "已是最新" msgstr "Up to Date" +#: src/components/share/RevokeMemberDialog.tsx:38 +msgid "已移除 {name}" +msgstr "" + #: src/components/settings/NetworkStatusCard.tsx:64 msgid "已连接 {connectedCount} 台设备" msgstr "Connected to {connectedCount} device(s)" @@ -577,11 +628,11 @@ msgid "已连接,暂无设备在线" msgstr "Connected, no devices online" #. placeholder {0}: onlineDevices.length -#: src/components/layout/SyncStatusBar.tsx:66 +#: src/components/layout/SyncStatusBar.tsx:64 msgid "已连接设备 ({0})" msgstr "Connected devices ({0})" -#: src/routes/settings/devices.tsx:213 +#: src/routes/settings/devices.tsx:214 msgid "已配对设备" msgstr "Paired Devices" @@ -589,7 +640,7 @@ msgstr "Paired Devices" msgid "开始使用" msgstr "Get Started" -#: src/components/workspace/WorkspaceSyncDialog.tsx:382 +#: src/components/workspace/WorkspaceSyncDialog.tsx:376 msgid "开始同步" msgstr "Start sync" @@ -605,14 +656,18 @@ msgstr "Blockquote" msgid "当前版本 {currentVersion} 已不再支持,请更新到 {latestVersion}" msgstr "Version {currentVersion} is no longer supported. Please update to {latestVersion}" -#: src/routes/settings/devices.tsx:186 +#: src/routes/settings/devices.tsx:187 msgid "当前设备" msgstr "Current device" -#: src/routes/settings/general.tsx:177 +#: src/routes/settings/general.tsx:198 msgid "恢复上次工作区" msgstr "Restore last workspace" +#: src/components/share/ShareDialog.tsx:83 +msgid "成员" +msgstr "" + #: src/components/onboarding/PathChoiceStep.tsx:60 msgid "我已有其他设备,想要同步笔记" msgstr "I have other devices and want to sync notes" @@ -621,7 +676,7 @@ msgstr "I have other devices and want to sync notes" msgid "我的设备" msgstr "My Device" -#: src/components/onboarding/PairingStep.tsx:221 +#: src/components/onboarding/PairingStep.tsx:223 msgid "或使用配对码" msgstr "Or use a pairing code" @@ -629,13 +684,17 @@ msgstr "Or use a pairing code" msgid "或按 {modKey}N 快速创建" msgstr "or press {modKey}N to quick create" -#: src/components/workspace/WorkspaceSyncDialog.tsx:131 -#: src/routes/workspace-manager.tsx:262 +#: src/components/share/RoleBadge.tsx:13 +msgid "所有者" +msgstr "" + +#: src/components/workspace/WorkspaceSyncDialog.tsx:125 +#: src/routes/workspace-manager.tsx:256 msgid "打开" msgstr "Open" -#: src/components/workspace/WorkspacePicker.tsx:47 -#: src/routes/workspace-manager.tsx:260 +#: src/components/workspace/WorkspacePicker.tsx:42 +#: src/routes/workspace-manager.tsx:254 msgid "打开工作区文件夹" msgstr "Open workspace folder" @@ -643,15 +702,15 @@ msgstr "Open workspace folder" msgid "打开工作区时自动启动 P2P 节点" msgstr "Automatically start P2P node when opening a workspace" -#: src/components/workspace/WorkspacePicker.tsx:68 +#: src/components/workspace/WorkspacePicker.tsx:63 msgid "打开文件夹" msgstr "Open folder" -#: src/components/editor/DocumentOutline.tsx:141 +#: src/components/layout/Sidebar.tsx:182 msgid "打开文档以查看大纲" msgstr "Open a document to see its outline" -#: src/routes/workspace-manager.tsx:254 +#: src/routes/workspace-manager.tsx:248 msgid "打开本地工作区" msgstr "Open local workspace" @@ -667,7 +726,7 @@ msgstr "Hole-punch" msgid "找到设备" msgstr "Devices found" -#: src/routes/settings/general.tsx:80 +#: src/routes/settings/general.tsx:83 msgid "把 mermaid 代码块渲染为 SVG" msgstr "Render mermaid code blocks as SVG" @@ -699,7 +758,7 @@ msgstr "Insert table" msgid "插入链接" msgstr "Insert link" -#: src/components/layout/Sidebar.tsx:120 +#: src/components/layout/Sidebar.tsx:123 msgid "搜索文件..." msgstr "Search files..." @@ -707,19 +766,19 @@ msgstr "Search files..." msgid "操作" msgstr "Actions" -#: src/components/layout/TitleBar.tsx:77 +#: src/components/layout/TitleBar.tsx:88 msgid "收起侧边栏" msgstr "Collapse Sidebar" -#: src/routes/settings/general.tsx:67 +#: src/routes/settings/general.tsx:70 msgid "数学公式" msgstr "Math formulas" -#: src/components/editor/NoteEditor.tsx:426 +#: src/components/editor/NoteEditor.tsx:467 msgid "文件已修改" msgstr "File Modified" -#: src/components/layout/TitleBar.tsx:91 +#: src/components/layout/TitleBar.tsx:102 msgid "文件树" msgstr "File tree" @@ -735,18 +794,18 @@ msgstr "Document" msgid "斜体" msgstr "Italic" -#: src/routes/workspace-manager.tsx:243 +#: src/routes/workspace-manager.tsx:237 msgid "新建工作区" msgstr "New workspace" -#: src/components/layout/Sidebar.tsx:145 +#: src/components/layout/Sidebar.tsx:148 msgid "新建文件" msgstr "New File" #: src/components/filetree/FileTree.tsx:67 #: src/components/filetree/FileTreeContextMenu.tsx:43 -#: src/components/layout/Sidebar.tsx:84 -#: src/components/layout/Sidebar.tsx:160 +#: src/components/layout/Sidebar.tsx:87 +#: src/components/layout/Sidebar.tsx:163 msgid "新建文件夹" msgstr "New Folder" @@ -754,7 +813,7 @@ msgstr "New Folder" #: src/components/filetree/FileTreeContextMenu.tsx:39 #: src/components/layout/EmptyState.tsx:24 #: src/components/layout/EmptyState.tsx:27 -#: src/components/layout/Sidebar.tsx:80 +#: src/components/layout/Sidebar.tsx:83 #: src/lib/commands.ts:49 #: src/lib/commands.ts:55 msgid "新建笔记" @@ -768,7 +827,7 @@ msgstr "Version {latestVersion} is available. Current version: {currentVersion}" msgid "无序列表" msgstr "Bullet list" -#: src/routes/settings/general.tsx:109 +#: src/routes/settings/general.tsx:112 msgid "智能粘贴" msgstr "Smart paste" @@ -776,19 +835,23 @@ msgstr "Smart paste" msgid "暂无工作区" msgstr "No workspaces" -#: src/components/layout/SyncStatusBar.tsx:71 +#: src/components/layout/SyncStatusBar.tsx:69 msgid "暂无已连接设备" msgstr "No connected devices" +#: src/components/share/ShareDialog.tsx:88 +msgid "暂无成员" +msgstr "" + #: src/components/filetree/EmptyTreeState.tsx:12 msgid "暂无笔记" msgstr "No notes yet" -#: src/routes/settings/devices.tsx:235 +#: src/routes/settings/devices.tsx:236 msgid "暂无配对设备" msgstr "No paired devices" -#: src/components/workspace/WorkspaceSyncDialog.tsx:371 +#: src/components/workspace/WorkspaceSyncDialog.tsx:365 msgid "更改" msgstr "Change" @@ -801,7 +864,7 @@ msgstr "What's New" msgid "更新到 v{0}" msgstr "Update to v{0}" -#: src/routes/settings/devices.tsx:174 +#: src/routes/settings/devices.tsx:175 msgid "更新名称失败" msgstr "Failed to update name" @@ -817,19 +880,19 @@ msgstr "Numbered list" msgid "未保存" msgstr "Unsaved" -#: src/routes/settings/devices.tsx:271 +#: src/routes/settings/devices.tsx:272 msgid "未发现附近设备" msgstr "No nearby devices found" -#: src/components/workspace/WorkspaceSyncDialog.tsx:313 +#: src/components/workspace/WorkspaceSyncDialog.tsx:307 msgid "未找到可同步的工作区" msgstr "No workspaces to sync" -#: src/components/pairing/InputCodeDialog.tsx:93 +#: src/components/pairing/InputCodeDialog.tsx:89 msgid "查找中..." msgstr "Searching..." -#: src/components/pairing/InputCodeDialog.tsx:97 +#: src/components/pairing/InputCodeDialog.tsx:93 msgid "查找设备" msgstr "Find devices" @@ -874,7 +937,7 @@ msgstr "Downloading v{0}" msgid "正在准备工作区..." msgstr "Preparing workspace..." -#: src/components/workspace/WorkspaceSyncDialog.tsx:262 +#: src/components/workspace/WorkspaceSyncDialog.tsx:256 msgid "正在同步..." msgstr "Syncing..." @@ -884,15 +947,15 @@ msgstr "Syncing..." msgid "正在同步文档 {0}/{1}..." msgstr "Syncing document {0}/{1}..." -#: src/components/onboarding/PairingStep.tsx:161 +#: src/components/onboarding/PairingStep.tsx:163 msgid "正在启动 P2P 节点..." msgstr "Starting P2P node..." -#: src/components/onboarding/PairingStep.tsx:205 +#: src/components/onboarding/PairingStep.tsx:207 msgid "正在搜索附近设备..." msgstr "Searching for nearby devices..." -#: src/components/workspace/WorkspaceSyncDialog.tsx:295 +#: src/components/workspace/WorkspaceSyncDialog.tsx:289 msgid "正在获取可同步的工作区..." msgstr "Fetching syncable workspaces..." @@ -905,6 +968,10 @@ msgstr "Restarting..." msgid "正文" msgstr "Body text" +#: src/components/share/ShareDialog.tsx:75 +msgid "此工作区的成员" +msgstr "" + #: src/components/upgrade/ForceUpdateDialog.tsx:66 msgid "此版本为必须更新,将自动下载并安装。" msgstr "This is a mandatory update and will be downloaded and installed automatically." @@ -917,60 +984,65 @@ msgstr "Paragraph" msgid "没有匹配的文件" msgstr "No matching files" +#: src/components/share/ShareDialog.tsx:114 +msgid "没有可添加的设备" +msgstr "" + #: src/components/layout/CommandPalette.tsx:41 msgid "没有找到匹配的命令" msgstr "No matching commands found" -#: src/routes/settings/general.tsx:152 +#: src/routes/settings/general.tsx:173 msgid "浅色" msgstr "Light" -#: src/routes/settings/general.tsx:153 +#: src/routes/settings/general.tsx:174 msgid "深色" msgstr "Dark" #: src/components/onboarding/PathChoiceStep.tsx:57 +#: src/components/share/ShareDialog.tsx:108 msgid "添加设备" msgstr "Add device" -#: src/routes/settings/general.tsx:86 +#: src/routes/settings/general.tsx:89 msgid "渲染 GFM / Obsidian 风格的提示块" msgstr "Render GFM / Obsidian style admonitions" -#: src/routes/settings/general.tsx:68 +#: src/routes/settings/general.tsx:71 msgid "渲染 KaTeX 数学公式 ($...$ / $$...$$)" msgstr "Render KaTeX math ($...$ / $$...$$)" -#: src/routes/workspace-manager.tsx:235 +#: src/routes/workspace-manager.tsx:229 msgid "版本" msgstr "Version" -#: src/components/pairing/CodePairingCard.tsx:124 +#: src/components/pairing/CodePairingCard.tsx:126 msgid "生成" msgstr "Generate" -#: src/components/pairing/CodePairingCard.tsx:120 +#: src/components/pairing/CodePairingCard.tsx:122 msgid "生成 6 位配对码,在另一台设备输入即可配对" msgstr "Generate a 6-digit pairing code; enter it on another device to pair" -#: src/components/onboarding/PairingStep.tsx:233 +#: src/components/onboarding/PairingStep.tsx:235 msgid "生成配对码" msgstr "Generate Pairing Code" -#: src/components/onboarding/PairingStep.tsx:127 -#: src/components/pairing/CodePairingCard.tsx:55 +#: src/components/onboarding/PairingStep.tsx:129 +#: src/components/pairing/CodePairingCard.tsx:46 msgid "生成配对码失败" msgstr "Failed to generate pairing code" -#: src/routes/settings/devices.tsx:236 +#: src/routes/settings/devices.tsx:237 msgid "生成配对码或发现附近设备来配对" msgstr "Generate a pairing code or discover nearby devices to pair" -#: src/routes/settings/devices.tsx:272 +#: src/routes/settings/devices.tsx:273 msgid "确保其他设备在同一局域网内" msgstr "Make sure other devices are on the same LAN" -#: src/components/layout/SyncStatusBar.tsx:73 +#: src/components/layout/SyncStatusBar.tsx:71 msgid "确保其他设备已配对并在同一网络中" msgstr "Make sure other devices are paired and on the same network" @@ -1016,6 +1088,22 @@ msgstr "Confirm pairing" msgid "视图" msgstr "View" +#: src/components/share/MemberRow.tsx:45 +msgid "离线" +msgstr "" + +#: src/components/share/RevokeMemberDialog.tsx:63 +msgid "移除" +msgstr "" + +#: src/components/share/RevokeMemberDialog.tsx:53 +msgid "移除 {name} 后,该设备将无法获取此工作区的新内容;它此前已同步的内容仍保留在其本地。" +msgstr "" + +#: src/components/share/RevokeMemberDialog.tsx:48 +msgid "移除成员?" +msgstr "" + #: src/components/upgrade/PromptUpdateDialog.tsx:90 msgid "稍后提醒" msgstr "Remind Me Later" @@ -1024,27 +1112,27 @@ msgstr "Remind Me Later" msgid "立即更新" msgstr "Update Now" -#: src/components/onboarding/PairingStep.tsx:298 +#: src/components/onboarding/PairingStep.tsx:300 msgid "等待对方连接..." msgstr "Waiting for connection..." -#: src/components/layout/SyncStatusBar.tsx:31 +#: src/components/layout/SyncStatusBar.tsx:29 msgid "等待设备连接" msgstr "Waiting for device to connect" -#: src/routes/settings/general.tsx:74 +#: src/routes/settings/general.tsx:77 msgid "管道表格渲染为可视化卡片" msgstr "Render pipe tables as visual cards" -#: src/routes/settings/general.tsx:110 +#: src/routes/settings/general.tsx:113 msgid "粘贴 URL 转链接,拖放 / 粘贴文件上传为图片" msgstr "Paste URLs as links; drag/paste files upload as images" -#: src/routes/settings/devices.tsx:201 +#: src/routes/settings/devices.tsx:202 msgid "编辑名称" msgstr "Edit name" -#: src/routes/settings/general.tsx:188 +#: src/routes/settings/general.tsx:209 msgid "编辑器插件" msgstr "Editor plugins" @@ -1053,16 +1141,16 @@ msgstr "Editor plugins" msgid "网络" msgstr "Network" -#: src/components/layout/SyncStatusBar.tsx:29 +#: src/components/layout/SyncStatusBar.tsx:27 #: src/components/settings/WorkspaceSyncList.tsx:98 msgid "网络未启动" msgstr "Network not started" -#: src/components/layout/SyncStatusBar.tsx:106 +#: src/components/layout/SyncStatusBar.tsx:104 msgid "网络设置" msgstr "Network settings" -#: src/routes/settings/general.tsx:209 +#: src/routes/settings/general.tsx:230 msgid "自动" msgstr "Auto" @@ -1078,7 +1166,7 @@ msgstr "" msgid "行内代码" msgstr "Inline code" -#: src/routes/settings/general.tsx:73 +#: src/routes/settings/general.tsx:76 msgid "表格" msgstr "Tables" @@ -1086,65 +1174,73 @@ msgstr "Tables" msgid "设备" msgstr "Device" -#: src/components/onboarding/CompleteStep.tsx:69 +#: src/components/onboarding/CompleteStep.tsx:68 msgid "设备 ID" msgstr "Device ID" -#: src/components/onboarding/CompleteStep.tsx:59 +#: src/components/onboarding/CompleteStep.tsx:58 #: src/components/onboarding/DeviceNameStep.tsx:38 msgid "设备名称" msgstr "Device Name" -#: src/routes/settings/devices.tsx:172 +#: src/routes/settings/devices.tsx:173 msgid "设备名称已更新,网络身份已同步" msgstr "Device name updated; network identity synced" -#: src/routes/settings/devices.tsx:148 +#: src/routes/settings/devices.tsx:149 msgid "设备管理" msgstr "Device Management" -#: src/components/layout/TitleBar.tsx:125 +#: src/components/layout/TitleBar.tsx:150 #: src/routes/settings.tsx:50 #: src/routes/settings/network.tsx:34 msgid "设置" msgstr "Settings" -#: src/routes/settings/general.tsx:130 +#: src/routes/settings/general.tsx:151 msgid "语言" msgstr "Language" -#: src/components/onboarding/PairingStep.tsx:133 +#: src/components/onboarding/PairingStep.tsx:135 msgid "请输入6位配对码" msgstr "Please enter a 6-digit pairing code" -#: src/components/layout/Sidebar.tsx:187 +#: src/components/layout/Sidebar.tsx:196 msgid "调整侧边栏宽度 (当前 {sidebarWidth} 像素)" msgstr "Adjust sidebar width (currently {sidebarWidth} pixels)" -#: src/routes/settings/general.tsx:154 +#: src/routes/settings/general.tsx:175 msgid "跟随系统" msgstr "System" -#: src/components/onboarding/PairingStep.tsx:182 -#: src/components/onboarding/PairingStep.tsx:318 +#: src/components/onboarding/PairingStep.tsx:184 +#: src/components/onboarding/PairingStep.tsx:320 msgid "跳过,稍后设置" msgstr "Skip, set up later" +#: src/routes/settings/general.tsx:125 +msgid "输入 [[ 触发笔记选择,插入 [[note-title]] 链接" +msgstr "" + +#: src/routes/settings/general.tsx:119 +msgid "输入 / 触发候选菜单,快速插入或跳转笔记" +msgstr "" + #: src/components/layout/CommandPalette.tsx:38 msgid "输入命令..." msgstr "Type a command..." -#: src/components/pairing/InputCodeDialog.tsx:62 +#: src/components/pairing/InputCodeDialog.tsx:58 msgid "输入对方设备上显示的 6 位数字" msgstr "Enter the 6-digit number shown on the other device" -#: src/components/onboarding/PairingStep.tsx:252 +#: src/components/onboarding/PairingStep.tsx:254 msgid "输入对方设备生成的6位配对码" msgstr "Enter the 6-digit code generated on the other device" -#: src/components/onboarding/PairingStep.tsx:243 -#: src/components/pairing/InputCodeDialog.tsx:59 -#: src/routes/settings/devices.tsx:151 +#: src/components/onboarding/PairingStep.tsx:245 +#: src/components/pairing/InputCodeDialog.tsx:55 +#: src/routes/settings/devices.tsx:152 msgid "输入配对码" msgstr "Enter Pairing Code" @@ -1152,11 +1248,11 @@ msgstr "Enter Pairing Code" msgid "运行中" msgstr "Running" -#: src/routes/workspace-manager.tsx:220 +#: src/routes/workspace-manager.tsx:214 msgid "还没有工作区" msgstr "No workspaces yet" -#: src/components/workspace/WorkspacePicker.tsx:105 +#: src/components/workspace/WorkspacePicker.tsx:100 msgid "还没有工作区,创建一个开始使用吧。" msgstr "No workspaces yet — create one to get started." @@ -1168,86 +1264,94 @@ msgstr "No Notes Yet" msgid "这是我的第一台 SwarmNote 设备" msgstr "This is my first SwarmNote device" -#: src/components/onboarding/CompleteStep.tsx:77 +#: src/components/onboarding/CompleteStep.tsx:76 msgid "进入 SwarmNote" msgstr "Enter SwarmNote" -#: src/components/onboarding/PairingStep.tsx:264 +#: src/components/onboarding/PairingStep.tsx:266 msgid "连接" msgstr "Connect" -#: src/components/layout/SyncStatusBar.tsx:25 -#: src/components/onboarding/PairingStep.tsx:264 +#: src/components/layout/SyncStatusBar.tsx:23 +#: src/components/onboarding/PairingStep.tsx:266 msgid "连接中..." msgstr "Connecting..." -#: src/components/onboarding/PairingStep.tsx:195 +#: src/components/onboarding/PairingStep.tsx:197 msgid "连接你的其他设备以同步笔记" msgstr "Connect your other devices to sync notes" -#: src/components/layout/SyncStatusBar.tsx:27 +#: src/components/layout/SyncStatusBar.tsx:25 msgid "连接失败" msgstr "Connection Failed" +#: src/routes/settings/general.tsx:131 +msgid "选中文字时浮出格式化工具栏(粗体 / 斜体 / 链接 等)" +msgstr "" + #: src/components/onboarding/PathChoiceStep.tsx:21 msgid "选择最适合你当前情况的选项" msgstr "Choose the option that best fits your situation" -#: src/components/workspace/WorkspaceSyncDialog.tsx:206 +#: src/components/workspace/WorkspaceSyncDialog.tsx:200 msgid "选择同步目标目录" msgstr "Choose sync target folder" -#: src/components/workspace/WorkspacePicker.tsx:53 -#: src/routes/workspace-manager.tsx:246 +#: src/components/workspace/WorkspacePicker.tsx:48 +#: src/routes/workspace-manager.tsx:240 msgid "选择新工作区目录" msgstr "Choose new workspace folder" -#: src/routes/settings/general.tsx:143 +#: src/routes/settings/general.tsx:164 msgid "选择明亮或暗色主题" msgstr "Choose light or dark theme" -#: src/routes/settings/general.tsx:130 +#: src/routes/settings/general.tsx:151 msgid "选择界面显示语言" msgstr "Choose the interface language" #: src/routes/settings.tsx:26 -#: src/routes/settings/general.tsx:118 +#: src/routes/settings/general.tsx:139 msgid "通用" msgstr "General" -#: src/routes/settings/general.tsx:104 +#: src/routes/settings/general.tsx:107 msgid "通过 DOMPurify 渲染嵌入的原生 HTML" msgstr "Render embedded HTML via DOMPurify" -#: src/components/pairing/NearbyDeviceCard.tsx:59 +#: src/components/share/ShareDialog.tsx:73 +msgid "邀请已配对的设备协作编辑此工作区" +msgstr "" + +#: src/components/pairing/NearbyDeviceCard.tsx:58 msgid "配对" msgstr "Pair" #: src/components/pairing/FoundDeviceDialog.tsx:76 -#: src/components/pairing/NearbyDeviceCard.tsx:59 +#: src/components/pairing/NearbyDeviceCard.tsx:58 msgid "配对中..." msgstr "Pairing..." -#: src/components/onboarding/CompleteStep.tsx:32 +#: src/components/onboarding/CompleteStep.tsx:31 msgid "配对成功!" msgstr "Paired successfully!" -#: src/components/pairing/CodePairingCard.tsx:80 -#: src/components/pairing/CodePairingCard.tsx:116 +#: src/components/pairing/CodePairingCard.tsx:71 +#: src/components/pairing/CodePairingCard.tsx:118 msgid "配对码" msgstr "Pairing code" -#: src/components/pairing/CodePairingCard.tsx:62 +#: src/components/pairing/CodePairingCard.tsx:53 msgid "配对码已复制" msgstr "Pairing code copied" -#: src/components/onboarding/PairingStep.tsx:144 +#: src/components/onboarding/PairingStep.tsx:146 #: src/components/pairing/FoundDeviceDialog.tsx:43 -#: src/components/pairing/NearbyDeviceCard.tsx:38 +#: src/components/pairing/NearbyDeviceCard.tsx:37 msgid "配对被拒绝" msgstr "Pairing rejected" -#: src/components/onboarding/PairingStep.tsx:192 +#: src/components/onboarding/PairingStep.tsx:194 msgid "配对设备" msgstr "Paired devices" @@ -1255,9 +1359,13 @@ msgstr "Paired devices" msgid "重命名" msgstr "Rename" -#: src/components/onboarding/PairingStep.tsx:175 -#: src/components/workspace/WorkspaceSyncDialog.tsx:307 -#: src/components/workspace/WorkspaceSyncDialog.tsx:316 +#: src/components/pairing/CodePairingCard.tsx:94 +msgid "重新生成" +msgstr "" + +#: src/components/onboarding/PairingStep.tsx:177 +#: src/components/workspace/WorkspaceSyncDialog.tsx:301 +#: src/components/workspace/WorkspaceSyncDialog.tsx:310 msgid "重试" msgstr "Retry" @@ -1265,11 +1373,11 @@ msgstr "Retry" msgid "错误" msgstr "Error" -#: src/routes/settings/devices.tsx:245 +#: src/routes/settings/devices.tsx:246 msgid "附近设备" msgstr "Nearby Devices" -#: src/routes/settings/general.tsx:162 +#: src/routes/settings/general.tsx:183 msgid "限制编辑器内容宽度以提升阅读体验" msgstr "Limit editor content width for a more comfortable reading experience" diff --git a/src/locales/zh/messages.po b/src/locales/zh/messages.po index 7cd4770..8b69248 100644 --- a/src/locales/zh/messages.po +++ b/src/locales/zh/messages.po @@ -44,61 +44,65 @@ msgid "新建笔记" msgstr "新建笔记" #. placeholder {0}: event.payload.relPath -#: src/components/editor/NoteEditor.tsx:425 +#: src/components/editor/NoteEditor.tsx:466 msgid "\"{0}\" 已被外部修改。是否重新加载?当前未保存的编辑将丢失。" msgstr "\"{0}\" 已被外部修改。是否重新加载?当前未保存的编辑将丢失。" +#: src/components/share/MemberRow.tsx:33 +msgid "(你)" +msgstr "(你)" + #. placeholder {0}: pairedDevices.length -#: src/routes/settings/devices.tsx:217 +#: src/routes/settings/devices.tsx:218 msgid "{0} 台" msgstr "{0} 台" #. placeholder {0}: onlineDevices.length -#: src/components/workspace/WorkspacePicker.tsx:84 +#: src/components/workspace/WorkspacePicker.tsx:79 msgid "{0} 台设备在线,可同步工作区" msgstr "{0} 台设备在线,可同步工作区" #. placeholder {0}: formatSeconds(remaining) -#: src/components/pairing/CodePairingCard.tsx:89 +#: src/components/pairing/CodePairingCard.tsx:80 msgid "{0} 后过期" msgstr "{0} 后过期" #. placeholder {0}: item.ws.docCount #. placeholder {0}: ws.docCount -#: src/components/workspace/WorkspaceSyncDialog.tsx:109 -#: src/components/workspace/WorkspaceSyncDialog.tsx:345 +#: src/components/workspace/WorkspaceSyncDialog.tsx:103 +#: src/components/workspace/WorkspaceSyncDialog.tsx:339 msgid "{0} 篇文档" msgstr "{0} 篇文档" -#: src/components/workspace/WorkspaceSyncDialog.tsx:418 +#: src/components/workspace/WorkspaceSyncDialog.tsx:412 msgid "{doneCount} 个成功,{errorCount} 个失败" msgstr "{doneCount} 个成功,{errorCount} 个失败" -#: src/components/layout/SyncStatusBar.tsx:34 +#: src/components/layout/SyncStatusBar.tsx:32 msgid "{peerCount} 台 · 同步中 {completed}/{total}" msgstr "{peerCount} 台 · 同步中 {completed}/{total}" -#: src/components/layout/SyncStatusBar.tsx:42 +#: src/components/layout/SyncStatusBar.tsx:40 msgid "{peerCount} 台设备在线" msgstr "{peerCount} 台设备在线" -#: src/components/layout/SyncStatusBar.tsx:38 +#: src/components/layout/SyncStatusBar.tsx:36 msgid "{peerCount} 台设备在线 · 已同步" msgstr "{peerCount} 台设备在线 · 已同步" -#: src/routes/settings/general.tsx:85 +#: src/routes/settings/general.tsx:88 msgid "Admonition" msgstr "Admonition" -#: src/routes/settings/general.tsx:92 +#: src/routes/settings/general.tsx:95 msgid "fenced 代码块渲染与高亮" msgstr "fenced 代码块渲染与高亮" -#: src/routes/settings/general.tsx:103 +#: src/routes/settings/general.tsx:106 msgid "HTML 渲染" msgstr "HTML 渲染" -#: src/routes/settings/general.tsx:79 +#: src/routes/settings/general.tsx:82 msgid "Mermaid 图表" msgstr "Mermaid 图表" @@ -110,7 +114,7 @@ msgstr "P2P 同步" msgid "P2P 网络" msgstr "P2P 网络" -#: src/components/onboarding/PairingStep.tsx:172 +#: src/components/onboarding/PairingStep.tsx:174 msgid "P2P 节点启动失败" msgstr "P2P 节点启动失败" @@ -118,6 +122,18 @@ msgstr "P2P 节点启动失败" msgid "P2P 节点未运行" msgstr "P2P 节点未运行" +#: src/routes/settings/general.tsx:130 +msgid "Selection 工具栏" +msgstr "Selection 工具栏" + +#: src/routes/settings/general.tsx:118 +msgid "Slash 命令" +msgstr "Slash 命令" + +#: src/routes/settings/general.tsx:124 +msgid "Wikilink" +msgstr "Wikilink" + #: src/components/onboarding/DeviceNameStep.tsx:58 msgid "上一步" msgstr "上一步" @@ -132,7 +148,7 @@ msgstr "下一步" msgid "下载中..." msgstr "下载中..." -#: src/routes/settings/general.tsx:136 +#: src/routes/settings/general.tsx:157 msgid "中文" msgstr "中文" @@ -144,20 +160,20 @@ msgstr "中继" msgid "为你的设备取个名字,方便在 P2P 网络中识别。" msgstr "为你的设备取个名字,方便在 P2P 网络中识别。" -#: src/components/settings/WorkspaceSyncList.tsx:75 +#: src/components/settings/WorkspaceSyncList.tsx:74 msgid "仅本地" msgstr "仅本地" -#: src/routes/workspace-manager.tsx:64 +#: src/routes/workspace-manager.tsx:58 msgid "从列表移除" msgstr "从列表移除" #. placeholder {0}: onlineDevices.length -#: src/routes/workspace-manager.tsx:272 +#: src/routes/workspace-manager.tsx:266 msgid "从已配对设备同步工作区到本地。{0} 台设备在线。" msgstr "从已配对设备同步工作区到本地。{0} 台设备在线。" -#: src/routes/settings/general.tsx:91 +#: src/routes/settings/general.tsx:94 msgid "代码块" msgstr "代码块" @@ -165,11 +181,11 @@ msgstr "代码块" msgid "任务列表" msgstr "任务列表" -#: src/components/onboarding/CompleteStep.tsx:36 +#: src/components/onboarding/CompleteStep.tsx:35 msgid "你可以在工作区管理窗口中选择要同步的工作区" msgstr "你可以在工作区管理窗口中选择要同步的工作区" -#: src/components/onboarding/CompleteStep.tsx:38 +#: src/components/onboarding/CompleteStep.tsx:37 msgid "你可以稍后在设置 → 设备中配对设备" msgstr "你可以稍后在设置 → 设备中配对设备" @@ -177,10 +193,14 @@ msgstr "你可以稍后在设置 → 设备中配对设备" msgid "你是如何开始的?" msgstr "你是如何开始的?" -#: src/components/onboarding/CompleteStep.tsx:40 +#: src/components/onboarding/CompleteStep.tsx:39 msgid "你的设备身份已建立,可以开始使用 SwarmNote 了。" msgstr "你的设备身份已建立,可以开始使用 SwarmNote 了。" +#: src/components/share/ShareDialog.tsx:117 +msgid "先在「设备」设置里配对设备" +msgstr "先在「设备」设置里配对设备" + #: src/components/onboarding/PathChoiceStep.tsx:37 msgid "全新开始" msgstr "全新开始" @@ -189,11 +209,19 @@ msgstr "全新开始" msgid "全选" msgstr "全选" +#: src/components/share/ShareDialog.tsx:69 +msgid "共享「{workspaceName}」" +msgstr "共享「{workspaceName}」" + +#: src/components/layout/TitleBar.tsx:122 +msgid "共享工作区" +msgstr "共享工作区" + #: src/routes/settings.tsx:29 msgid "关于" msgstr "关于" -#: src/components/pairing/CodePairingCard.tsx:72 +#: src/components/pairing/CodePairingCard.tsx:63 msgid "关闭" msgstr "关闭" @@ -205,7 +233,7 @@ msgstr "关闭 P2P 网络将断开与所有设备的连接,笔记将停止同 msgid "关闭网络" msgstr "关闭网络" -#: src/routes/settings/general.tsx:208 +#: src/routes/settings/general.tsx:229 msgid "内联" msgstr "内联" @@ -214,7 +242,7 @@ msgstr "内联" msgid "最后在线 {0}" msgstr "最后在线 {0}" -#: src/components/workspace/WorkspacePicker.tsx:93 +#: src/components/workspace/WorkspacePicker.tsx:88 msgid "最近打开" msgstr "最近打开" @@ -223,15 +251,19 @@ msgid "最近文件" msgstr "最近文件" #. placeholder {0}: item.ws.docCount -#: src/components/workspace/WorkspaceSyncDialog.tsx:114 +#: src/components/workspace/WorkspaceSyncDialog.tsx:108 msgid "准备同步 · {0} 篇文档" msgstr "准备同步 · {0} 篇文档" -#: src/components/onboarding/CompleteStep.tsx:32 +#: src/components/onboarding/CompleteStep.tsx:31 msgid "准备就绪!" msgstr "准备就绪!" -#: src/routes/settings/general.tsx:210 +#: src/components/share/ShareDialog.tsx:139 +msgid "分享" +msgstr "分享" + +#: src/routes/settings/general.tsx:231 msgid "切换" msgstr "切换" @@ -239,7 +271,7 @@ msgstr "切换" msgid "切换侧边栏" msgstr "切换侧边栏" -#: src/routes/settings/general.tsx:191 +#: src/routes/settings/general.tsx:212 msgid "切换插件启用状态后,需要重新打开文档或重启应用以生效。" msgstr "切换插件启用状态后,需要重新打开文档或重启应用以生效。" @@ -251,7 +283,7 @@ msgstr "切换源码" msgid "列" msgstr "列" -#: src/routes/workspace-manager.tsx:247 +#: src/routes/workspace-manager.tsx:241 msgid "创建" msgstr "创建" @@ -259,7 +291,7 @@ msgstr "创建" msgid "创建你的第一篇笔记,开始记录想法" msgstr "创建你的第一篇笔记,开始记录想法" -#: src/components/workspace/WorkspacePicker.tsx:64 +#: src/components/workspace/WorkspacePicker.tsx:59 msgid "创建新工作区" msgstr "创建新工作区" @@ -284,11 +316,12 @@ msgstr "删除行" msgid "删除表格" msgstr "删除表格" -#: src/routes/settings/devices.tsx:254 +#: src/components/pairing/CodePairingCard.tsx:96 +#: src/routes/settings/devices.tsx:255 msgid "刷新" msgstr "刷新" -#: src/components/onboarding/PairingStep.tsx:292 +#: src/components/onboarding/PairingStep.tsx:294 msgid "刷新码" msgstr "刷新码" @@ -300,10 +333,18 @@ msgstr "剪切" msgid "加粗" msgstr "加粗" -#: src/components/editor/NoteEditor.tsx:122 +#: src/components/editor/NoteEditor.tsx:141 msgid "加载中..." msgstr "加载中..." +#: src/components/share/ShareDialog.tsx:88 +msgid "加载中…" +msgstr "加载中…" + +#: src/components/share/RoleBadge.tsx:13 +msgid "协作者" +msgstr "协作者" + #: src/routes/settings/about.tsx:67 msgid "去中心化、本地优先的 P2P 笔记应用" msgstr "去中心化、本地优先的 P2P 笔记应用" @@ -321,13 +362,14 @@ msgid "发现新版本" msgstr "发现新版本" #: src/components/filetree/FileTree.tsx:84 -#: src/components/onboarding/PairingStep.tsx:271 -#: src/components/onboarding/PairingStep.tsx:305 +#: src/components/onboarding/PairingStep.tsx:273 +#: src/components/onboarding/PairingStep.tsx:307 #: src/components/pairing/FoundDeviceDialog.tsx:72 -#: src/components/pairing/InputCodeDialog.tsx:89 +#: src/components/pairing/InputCodeDialog.tsx:85 #: src/components/pairing/UnpairConfirmDialog.tsx:55 #: src/components/settings/NetworkStatusCard.tsx:125 -#: src/components/workspace/WorkspaceSyncDialog.tsx:379 +#: src/components/share/RevokeMemberDialog.tsx:60 +#: src/components/workspace/WorkspaceSyncDialog.tsx:373 msgid "取消" msgstr "取消" @@ -340,7 +382,7 @@ msgid "取消配对" msgstr "取消配对" #: src/components/editor/EditorContextMenu.tsx:284 -#: src/routes/settings/general.tsx:161 +#: src/routes/settings/general.tsx:182 msgid "可读行宽" msgstr "可读行宽" @@ -348,42 +390,42 @@ msgstr "可读行宽" msgid "右对齐" msgstr "右对齐" -#: src/routes/workspace-manager.tsx:284 +#: src/routes/workspace-manager.tsx:278 msgid "同步" msgstr "同步" #. placeholder {0}: syncState.completed #. placeholder {1}: syncState.total -#: src/components/settings/WorkspaceSyncList.tsx:25 +#: src/components/settings/WorkspaceSyncList.tsx:24 msgid "同步中 · {0}/{1} 篇" msgstr "同步中 · {0}/{1} 篇" -#: src/components/workspace/WorkspaceSyncDialog.tsx:364 +#: src/components/workspace/WorkspaceSyncDialog.tsx:358 msgid "同步位置" msgstr "同步位置" -#: src/components/workspace/WorkspaceSyncDialog.tsx:264 +#: src/components/workspace/WorkspaceSyncDialog.tsx:258 msgid "同步完成" msgstr "同步完成" #. placeholder {0}: item.ws.docCount -#: src/components/workspace/WorkspaceSyncDialog.tsx:122 +#: src/components/workspace/WorkspaceSyncDialog.tsx:116 msgid "同步完成 · {0} 篇文档" msgstr "同步完成 · {0} 篇文档" -#: src/components/workspace/WorkspaceSyncDialog.tsx:266 +#: src/components/workspace/WorkspaceSyncDialog.tsx:260 msgid "同步工作区" msgstr "同步工作区" -#: src/components/workspace/WorkspacePicker.tsx:81 +#: src/components/workspace/WorkspacePicker.tsx:76 msgid "同步已配对设备工作区" msgstr "同步已配对设备工作区" -#: src/routes/workspace-manager.tsx:269 +#: src/routes/workspace-manager.tsx:263 msgid "同步远程工作区" msgstr "同步远程工作区" -#: src/components/workspace/WorkspaceSyncDialog.tsx:398 +#: src/components/workspace/WorkspaceSyncDialog.tsx:392 msgid "后台运行" msgstr "后台运行" @@ -395,7 +437,7 @@ msgstr "启动 P2P 网络后即可同步工作区" msgid "启动中..." msgstr "启动中..." -#: src/routes/settings/general.tsx:178 +#: src/routes/settings/general.tsx:199 msgid "启动时自动打开上次使用的工作区" msgstr "启动时自动打开上次使用的工作区" @@ -403,15 +445,15 @@ msgstr "启动时自动打开上次使用的工作区" msgid "启动网络" msgstr "启动网络" -#: src/routes/settings/general.tsx:172 +#: src/routes/settings/general.tsx:193 msgid "启动行为" msgstr "启动行为" -#: src/components/layout/TitleBar.tsx:115 +#: src/components/layout/TitleBar.tsx:136 msgid "命令面板" msgstr "命令面板" -#: src/routes/settings/general.tsx:97 +#: src/routes/settings/general.tsx:100 msgid "图片渲染" msgstr "图片渲染" @@ -423,7 +465,7 @@ msgstr "在上方新增行" msgid "在下方新增行" msgstr "在下方新增行" -#: src/components/pairing/CodePairingCard.tsx:93 +#: src/components/pairing/CodePairingCard.tsx:84 msgid "在另一台设备输入此码" msgstr "在另一台设备输入此码" @@ -435,19 +477,20 @@ msgstr "在右侧新增列" msgid "在左侧新增列" msgstr "在左侧新增列" -#: src/routes/workspace-manager.tsx:244 +#: src/routes/workspace-manager.tsx:238 msgid "在指定文件夹下创建一个新的工作区。" msgstr "在指定文件夹下创建一个新的工作区。" -#: src/routes/workspace-manager.tsx:55 +#: src/routes/workspace-manager.tsx:49 msgid "在文件管理器中打开" msgstr "在文件管理器中打开" -#: src/components/editor/DocumentOutline.tsx:152 +#: src/components/layout/Sidebar.tsx:183 msgid "在文档中添加标题即可看到大纲导航" msgstr "在文档中添加标题即可看到大纲导航" -#: src/components/workspace/WorkspaceSyncDialog.tsx:328 +#: src/components/share/MemberRow.tsx:45 +#: src/components/workspace/WorkspaceSyncDialog.tsx:322 msgid "在线" msgstr "在线" @@ -456,8 +499,8 @@ msgid "在表头下方新增行" msgstr "在表头下方新增行" #: src/components/editor/EditorContextMenu.tsx:268 -#: src/components/pairing/CodePairingCard.tsx:101 -#: src/components/pairing/CodePairingCard.tsx:104 +#: src/components/pairing/CodePairingCard.tsx:102 +#: src/components/pairing/CodePairingCard.tsx:105 msgid "复制" msgstr "复制" @@ -465,16 +508,16 @@ msgstr "复制" msgid "复制为 Markdown" msgstr "复制为 Markdown" -#: src/routes/workspace-manager.tsx:59 +#: src/routes/workspace-manager.tsx:53 msgid "复制路径" msgstr "复制路径" -#: src/components/onboarding/PairingStep.tsx:289 +#: src/components/onboarding/PairingStep.tsx:291 msgid "复制配对码" msgstr "复制配对码" -#: src/routes/settings/general.tsx:126 -#: src/routes/settings/general.tsx:143 +#: src/routes/settings/general.tsx:147 +#: src/routes/settings/general.tsx:164 msgid "外观" msgstr "外观" @@ -482,7 +525,7 @@ msgstr "外观" msgid "多端协作" msgstr "多端协作" -#: src/components/layout/TitleBar.tsx:94 +#: src/components/layout/TitleBar.tsx:105 msgid "大纲" msgstr "大纲" @@ -494,24 +537,24 @@ msgstr "字符" msgid "安全加密" msgstr "安全加密" -#: src/components/workspace/WorkspaceSyncDialog.tsx:424 +#: src/components/workspace/WorkspaceSyncDialog.tsx:418 msgid "完成" msgstr "完成" -#: src/routes/settings/general.tsx:98 +#: src/routes/settings/general.tsx:101 msgid "将 Markdown 图片渲染为内联 / 块级 widget" msgstr "将 Markdown 图片渲染为内联 / 块级 widget" -#: src/routes/workspace-manager.tsx:255 +#: src/routes/workspace-manager.tsx:249 msgid "将一个本地文件夹作为工作区打开。" msgstr "将一个本地文件夹作为工作区打开。" -#: src/routes/workspace-manager.tsx:274 +#: src/routes/workspace-manager.tsx:268 msgid "将已配对设备的工作区同步到本地。需先启动 P2P 网络。" msgstr "将已配对设备的工作区同步到本地。需先启动 P2P 网络。" #. placeholder {0}: formatSeconds(remaining) -#: src/components/onboarding/PairingStep.tsx:280 +#: src/components/onboarding/PairingStep.tsx:282 msgid "将此配对码告知对方设备,配对码将在 {0} 后过期" msgstr "将此配对码告知对方设备,配对码将在 {0} 后过期" @@ -523,15 +566,15 @@ msgstr "局域网" msgid "居中对齐" msgstr "居中对齐" -#: src/components/layout/TitleBar.tsx:77 +#: src/components/layout/TitleBar.tsx:88 msgid "展开侧边栏" msgstr "展开侧边栏" -#: src/components/workspace/WorkspacePicker.tsx:118 +#: src/components/workspace/WorkspacePicker.tsx:113 msgid "工作区管理" msgstr "工作区管理" -#: src/components/workspace/WorkspacePopover.tsx:78 +#: src/components/workspace/WorkspacePopover.tsx:72 msgid "工作区管理..." msgstr "工作区管理..." @@ -540,7 +583,7 @@ msgid "左对齐" msgstr "左对齐" #. placeholder {0}: device.name ?? device.hostname -#: src/components/pairing/NearbyDeviceCard.tsx:35 +#: src/components/pairing/NearbyDeviceCard.tsx:34 msgid "已与 {0} 配对" msgstr "已与 {0} 配对" @@ -552,15 +595,19 @@ msgstr "已保存" msgid "已停止" msgstr "已停止" +#: src/components/share/ShareDialog.tsx:57 +msgid "已分享给 {name}" +msgstr "已分享给 {name}" + #: src/components/pairing/UnpairConfirmDialog.tsx:37 msgid "已取消与 {deviceName} 的配对" msgstr "已取消与 {deviceName} 的配对" -#: src/components/workspace/WorkspaceSyncDialog.tsx:350 +#: src/components/workspace/WorkspaceSyncDialog.tsx:344 msgid "已同步" msgstr "已同步" -#: src/components/settings/WorkspaceSyncList.tsx:57 +#: src/components/settings/WorkspaceSyncList.tsx:56 msgid "已同步 · 最后同步 {timeStr}" msgstr "已同步 · 最后同步 {timeStr}" @@ -568,6 +615,10 @@ msgstr "已同步 · 最后同步 {timeStr}" msgid "已是最新" msgstr "已是最新" +#: src/components/share/RevokeMemberDialog.tsx:38 +msgid "已移除 {name}" +msgstr "已移除 {name}" + #: src/components/settings/NetworkStatusCard.tsx:64 msgid "已连接 {connectedCount} 台设备" msgstr "已连接 {connectedCount} 台设备" @@ -577,11 +628,11 @@ msgid "已连接,暂无设备在线" msgstr "已连接,暂无设备在线" #. placeholder {0}: onlineDevices.length -#: src/components/layout/SyncStatusBar.tsx:66 +#: src/components/layout/SyncStatusBar.tsx:64 msgid "已连接设备 ({0})" msgstr "已连接设备 ({0})" -#: src/routes/settings/devices.tsx:213 +#: src/routes/settings/devices.tsx:214 msgid "已配对设备" msgstr "已配对设备" @@ -589,7 +640,7 @@ msgstr "已配对设备" msgid "开始使用" msgstr "开始使用" -#: src/components/workspace/WorkspaceSyncDialog.tsx:382 +#: src/components/workspace/WorkspaceSyncDialog.tsx:376 msgid "开始同步" msgstr "开始同步" @@ -605,14 +656,18 @@ msgstr "引用块" msgid "当前版本 {currentVersion} 已不再支持,请更新到 {latestVersion}" msgstr "当前版本 {currentVersion} 已不再支持,请更新到 {latestVersion}" -#: src/routes/settings/devices.tsx:186 +#: src/routes/settings/devices.tsx:187 msgid "当前设备" msgstr "当前设备" -#: src/routes/settings/general.tsx:177 +#: src/routes/settings/general.tsx:198 msgid "恢复上次工作区" msgstr "恢复上次工作区" +#: src/components/share/ShareDialog.tsx:83 +msgid "成员" +msgstr "成员" + #: src/components/onboarding/PathChoiceStep.tsx:60 msgid "我已有其他设备,想要同步笔记" msgstr "我已有其他设备,想要同步笔记" @@ -621,7 +676,7 @@ msgstr "我已有其他设备,想要同步笔记" msgid "我的设备" msgstr "我的设备" -#: src/components/onboarding/PairingStep.tsx:221 +#: src/components/onboarding/PairingStep.tsx:223 msgid "或使用配对码" msgstr "或使用配对码" @@ -629,13 +684,17 @@ msgstr "或使用配对码" msgid "或按 {modKey}N 快速创建" msgstr "或按 {modKey}N 快速创建" -#: src/components/workspace/WorkspaceSyncDialog.tsx:131 -#: src/routes/workspace-manager.tsx:262 +#: src/components/share/RoleBadge.tsx:13 +msgid "所有者" +msgstr "所有者" + +#: src/components/workspace/WorkspaceSyncDialog.tsx:125 +#: src/routes/workspace-manager.tsx:256 msgid "打开" msgstr "打开" -#: src/components/workspace/WorkspacePicker.tsx:47 -#: src/routes/workspace-manager.tsx:260 +#: src/components/workspace/WorkspacePicker.tsx:42 +#: src/routes/workspace-manager.tsx:254 msgid "打开工作区文件夹" msgstr "打开工作区文件夹" @@ -643,15 +702,15 @@ msgstr "打开工作区文件夹" msgid "打开工作区时自动启动 P2P 节点" msgstr "打开工作区时自动启动 P2P 节点" -#: src/components/workspace/WorkspacePicker.tsx:68 +#: src/components/workspace/WorkspacePicker.tsx:63 msgid "打开文件夹" msgstr "打开文件夹" -#: src/components/editor/DocumentOutline.tsx:141 +#: src/components/layout/Sidebar.tsx:182 msgid "打开文档以查看大纲" msgstr "打开文档以查看大纲" -#: src/routes/workspace-manager.tsx:254 +#: src/routes/workspace-manager.tsx:248 msgid "打开本地工作区" msgstr "打开本地工作区" @@ -667,7 +726,7 @@ msgstr "打洞" msgid "找到设备" msgstr "找到设备" -#: src/routes/settings/general.tsx:80 +#: src/routes/settings/general.tsx:83 msgid "把 mermaid 代码块渲染为 SVG" msgstr "把 mermaid 代码块渲染为 SVG" @@ -699,7 +758,7 @@ msgstr "插入表格" msgid "插入链接" msgstr "插入链接" -#: src/components/layout/Sidebar.tsx:120 +#: src/components/layout/Sidebar.tsx:123 msgid "搜索文件..." msgstr "搜索文件..." @@ -707,19 +766,19 @@ msgstr "搜索文件..." msgid "操作" msgstr "操作" -#: src/components/layout/TitleBar.tsx:77 +#: src/components/layout/TitleBar.tsx:88 msgid "收起侧边栏" msgstr "收起侧边栏" -#: src/routes/settings/general.tsx:67 +#: src/routes/settings/general.tsx:70 msgid "数学公式" msgstr "数学公式" -#: src/components/editor/NoteEditor.tsx:426 +#: src/components/editor/NoteEditor.tsx:467 msgid "文件已修改" msgstr "文件已修改" -#: src/components/layout/TitleBar.tsx:91 +#: src/components/layout/TitleBar.tsx:102 msgid "文件树" msgstr "文件树" @@ -735,18 +794,18 @@ msgstr "文档" msgid "斜体" msgstr "斜体" -#: src/routes/workspace-manager.tsx:243 +#: src/routes/workspace-manager.tsx:237 msgid "新建工作区" msgstr "新建工作区" -#: src/components/layout/Sidebar.tsx:145 +#: src/components/layout/Sidebar.tsx:148 msgid "新建文件" msgstr "新建文件" #: src/components/filetree/FileTree.tsx:67 #: src/components/filetree/FileTreeContextMenu.tsx:43 -#: src/components/layout/Sidebar.tsx:84 -#: src/components/layout/Sidebar.tsx:160 +#: src/components/layout/Sidebar.tsx:87 +#: src/components/layout/Sidebar.tsx:163 msgid "新建文件夹" msgstr "新建文件夹" @@ -754,7 +813,7 @@ msgstr "新建文件夹" #: src/components/filetree/FileTreeContextMenu.tsx:39 #: src/components/layout/EmptyState.tsx:24 #: src/components/layout/EmptyState.tsx:27 -#: src/components/layout/Sidebar.tsx:80 +#: src/components/layout/Sidebar.tsx:83 #: src/lib/commands.ts:49 #: src/lib/commands.ts:55 msgid "新建笔记" @@ -768,7 +827,7 @@ msgstr "新版本 {latestVersion} 可用,当前版本 {currentVersion}" msgid "无序列表" msgstr "无序列表" -#: src/routes/settings/general.tsx:109 +#: src/routes/settings/general.tsx:112 msgid "智能粘贴" msgstr "智能粘贴" @@ -776,19 +835,23 @@ msgstr "智能粘贴" msgid "暂无工作区" msgstr "暂无工作区" -#: src/components/layout/SyncStatusBar.tsx:71 +#: src/components/layout/SyncStatusBar.tsx:69 msgid "暂无已连接设备" msgstr "暂无已连接设备" +#: src/components/share/ShareDialog.tsx:88 +msgid "暂无成员" +msgstr "暂无成员" + #: src/components/filetree/EmptyTreeState.tsx:12 msgid "暂无笔记" msgstr "暂无笔记" -#: src/routes/settings/devices.tsx:235 +#: src/routes/settings/devices.tsx:236 msgid "暂无配对设备" msgstr "暂无配对设备" -#: src/components/workspace/WorkspaceSyncDialog.tsx:371 +#: src/components/workspace/WorkspaceSyncDialog.tsx:365 msgid "更改" msgstr "更改" @@ -801,7 +864,7 @@ msgstr "更新内容" msgid "更新到 v{0}" msgstr "更新到 v{0}" -#: src/routes/settings/devices.tsx:174 +#: src/routes/settings/devices.tsx:175 msgid "更新名称失败" msgstr "更新名称失败" @@ -817,19 +880,19 @@ msgstr "有序列表" msgid "未保存" msgstr "未保存" -#: src/routes/settings/devices.tsx:271 +#: src/routes/settings/devices.tsx:272 msgid "未发现附近设备" msgstr "未发现附近设备" -#: src/components/workspace/WorkspaceSyncDialog.tsx:313 +#: src/components/workspace/WorkspaceSyncDialog.tsx:307 msgid "未找到可同步的工作区" msgstr "未找到可同步的工作区" -#: src/components/pairing/InputCodeDialog.tsx:93 +#: src/components/pairing/InputCodeDialog.tsx:89 msgid "查找中..." msgstr "查找中..." -#: src/components/pairing/InputCodeDialog.tsx:97 +#: src/components/pairing/InputCodeDialog.tsx:93 msgid "查找设备" msgstr "查找设备" @@ -874,7 +937,7 @@ msgstr "正在下载 v{0}" msgid "正在准备工作区..." msgstr "正在准备工作区..." -#: src/components/workspace/WorkspaceSyncDialog.tsx:262 +#: src/components/workspace/WorkspaceSyncDialog.tsx:256 msgid "正在同步..." msgstr "正在同步..." @@ -884,15 +947,15 @@ msgstr "正在同步..." msgid "正在同步文档 {0}/{1}..." msgstr "正在同步文档 {0}/{1}..." -#: src/components/onboarding/PairingStep.tsx:161 +#: src/components/onboarding/PairingStep.tsx:163 msgid "正在启动 P2P 节点..." msgstr "正在启动 P2P 节点..." -#: src/components/onboarding/PairingStep.tsx:205 +#: src/components/onboarding/PairingStep.tsx:207 msgid "正在搜索附近设备..." msgstr "正在搜索附近设备..." -#: src/components/workspace/WorkspaceSyncDialog.tsx:295 +#: src/components/workspace/WorkspaceSyncDialog.tsx:289 msgid "正在获取可同步的工作区..." msgstr "正在获取可同步的工作区..." @@ -905,6 +968,10 @@ msgstr "正在重启..." msgid "正文" msgstr "正文" +#: src/components/share/ShareDialog.tsx:75 +msgid "此工作区的成员" +msgstr "此工作区的成员" + #: src/components/upgrade/ForceUpdateDialog.tsx:66 msgid "此版本为必须更新,将自动下载并安装。" msgstr "此版本为必须更新,将自动下载并安装。" @@ -917,60 +984,65 @@ msgstr "段落设置" msgid "没有匹配的文件" msgstr "没有匹配的文件" +#: src/components/share/ShareDialog.tsx:114 +msgid "没有可添加的设备" +msgstr "没有可添加的设备" + #: src/components/layout/CommandPalette.tsx:41 msgid "没有找到匹配的命令" msgstr "没有找到匹配的命令" -#: src/routes/settings/general.tsx:152 +#: src/routes/settings/general.tsx:173 msgid "浅色" msgstr "浅色" -#: src/routes/settings/general.tsx:153 +#: src/routes/settings/general.tsx:174 msgid "深色" msgstr "深色" #: src/components/onboarding/PathChoiceStep.tsx:57 +#: src/components/share/ShareDialog.tsx:108 msgid "添加设备" msgstr "添加设备" -#: src/routes/settings/general.tsx:86 +#: src/routes/settings/general.tsx:89 msgid "渲染 GFM / Obsidian 风格的提示块" msgstr "渲染 GFM / Obsidian 风格的提示块" -#: src/routes/settings/general.tsx:68 +#: src/routes/settings/general.tsx:71 msgid "渲染 KaTeX 数学公式 ($...$ / $$...$$)" msgstr "渲染 KaTeX 数学公式 ($...$ / $$...$$)" -#: src/routes/workspace-manager.tsx:235 +#: src/routes/workspace-manager.tsx:229 msgid "版本" msgstr "版本" -#: src/components/pairing/CodePairingCard.tsx:124 +#: src/components/pairing/CodePairingCard.tsx:126 msgid "生成" msgstr "生成" -#: src/components/pairing/CodePairingCard.tsx:120 +#: src/components/pairing/CodePairingCard.tsx:122 msgid "生成 6 位配对码,在另一台设备输入即可配对" msgstr "生成 6 位配对码,在另一台设备输入即可配对" -#: src/components/onboarding/PairingStep.tsx:233 +#: src/components/onboarding/PairingStep.tsx:235 msgid "生成配对码" msgstr "生成配对码" -#: src/components/onboarding/PairingStep.tsx:127 -#: src/components/pairing/CodePairingCard.tsx:55 +#: src/components/onboarding/PairingStep.tsx:129 +#: src/components/pairing/CodePairingCard.tsx:46 msgid "生成配对码失败" msgstr "生成配对码失败" -#: src/routes/settings/devices.tsx:236 +#: src/routes/settings/devices.tsx:237 msgid "生成配对码或发现附近设备来配对" msgstr "生成配对码或发现附近设备来配对" -#: src/routes/settings/devices.tsx:272 +#: src/routes/settings/devices.tsx:273 msgid "确保其他设备在同一局域网内" msgstr "确保其他设备在同一局域网内" -#: src/components/layout/SyncStatusBar.tsx:73 +#: src/components/layout/SyncStatusBar.tsx:71 msgid "确保其他设备已配对并在同一网络中" msgstr "确保其他设备已配对并在同一网络中" @@ -1016,6 +1088,22 @@ msgstr "确认配对" msgid "视图" msgstr "视图" +#: src/components/share/MemberRow.tsx:45 +msgid "离线" +msgstr "离线" + +#: src/components/share/RevokeMemberDialog.tsx:63 +msgid "移除" +msgstr "移除" + +#: src/components/share/RevokeMemberDialog.tsx:53 +msgid "移除 {name} 后,该设备将无法获取此工作区的新内容;它此前已同步的内容仍保留在其本地。" +msgstr "移除 {name} 后,该设备将无法获取此工作区的新内容;它此前已同步的内容仍保留在其本地。" + +#: src/components/share/RevokeMemberDialog.tsx:48 +msgid "移除成员?" +msgstr "移除成员?" + #: src/components/upgrade/PromptUpdateDialog.tsx:90 msgid "稍后提醒" msgstr "稍后提醒" @@ -1024,27 +1112,27 @@ msgstr "稍后提醒" msgid "立即更新" msgstr "立即更新" -#: src/components/onboarding/PairingStep.tsx:298 +#: src/components/onboarding/PairingStep.tsx:300 msgid "等待对方连接..." msgstr "等待对方连接..." -#: src/components/layout/SyncStatusBar.tsx:31 +#: src/components/layout/SyncStatusBar.tsx:29 msgid "等待设备连接" msgstr "等待设备连接" -#: src/routes/settings/general.tsx:74 +#: src/routes/settings/general.tsx:77 msgid "管道表格渲染为可视化卡片" msgstr "管道表格渲染为可视化卡片" -#: src/routes/settings/general.tsx:110 +#: src/routes/settings/general.tsx:113 msgid "粘贴 URL 转链接,拖放 / 粘贴文件上传为图片" msgstr "粘贴 URL 转链接,拖放 / 粘贴文件上传为图片" -#: src/routes/settings/devices.tsx:201 +#: src/routes/settings/devices.tsx:202 msgid "编辑名称" msgstr "编辑名称" -#: src/routes/settings/general.tsx:188 +#: src/routes/settings/general.tsx:209 msgid "编辑器插件" msgstr "编辑器插件" @@ -1053,16 +1141,16 @@ msgstr "编辑器插件" msgid "网络" msgstr "网络" -#: src/components/layout/SyncStatusBar.tsx:29 +#: src/components/layout/SyncStatusBar.tsx:27 #: src/components/settings/WorkspaceSyncList.tsx:98 msgid "网络未启动" msgstr "网络未启动" -#: src/components/layout/SyncStatusBar.tsx:106 +#: src/components/layout/SyncStatusBar.tsx:104 msgid "网络设置" msgstr "网络设置" -#: src/routes/settings/general.tsx:209 +#: src/routes/settings/general.tsx:230 msgid "自动" msgstr "自动" @@ -1078,7 +1166,7 @@ msgstr "行" msgid "行内代码" msgstr "行内代码" -#: src/routes/settings/general.tsx:73 +#: src/routes/settings/general.tsx:76 msgid "表格" msgstr "表格" @@ -1086,65 +1174,73 @@ msgstr "表格" msgid "设备" msgstr "设备" -#: src/components/onboarding/CompleteStep.tsx:69 +#: src/components/onboarding/CompleteStep.tsx:68 msgid "设备 ID" msgstr "设备 ID" -#: src/components/onboarding/CompleteStep.tsx:59 +#: src/components/onboarding/CompleteStep.tsx:58 #: src/components/onboarding/DeviceNameStep.tsx:38 msgid "设备名称" msgstr "设备名称" -#: src/routes/settings/devices.tsx:172 +#: src/routes/settings/devices.tsx:173 msgid "设备名称已更新,网络身份已同步" msgstr "设备名称已更新,网络身份已同步" -#: src/routes/settings/devices.tsx:148 +#: src/routes/settings/devices.tsx:149 msgid "设备管理" msgstr "设备管理" -#: src/components/layout/TitleBar.tsx:125 +#: src/components/layout/TitleBar.tsx:150 #: src/routes/settings.tsx:50 #: src/routes/settings/network.tsx:34 msgid "设置" msgstr "设置" -#: src/routes/settings/general.tsx:130 +#: src/routes/settings/general.tsx:151 msgid "语言" msgstr "语言" -#: src/components/onboarding/PairingStep.tsx:133 +#: src/components/onboarding/PairingStep.tsx:135 msgid "请输入6位配对码" msgstr "请输入6位配对码" -#: src/components/layout/Sidebar.tsx:187 +#: src/components/layout/Sidebar.tsx:196 msgid "调整侧边栏宽度 (当前 {sidebarWidth} 像素)" msgstr "调整侧边栏宽度 (当前 {sidebarWidth} 像素)" -#: src/routes/settings/general.tsx:154 +#: src/routes/settings/general.tsx:175 msgid "跟随系统" msgstr "跟随系统" -#: src/components/onboarding/PairingStep.tsx:182 -#: src/components/onboarding/PairingStep.tsx:318 +#: src/components/onboarding/PairingStep.tsx:184 +#: src/components/onboarding/PairingStep.tsx:320 msgid "跳过,稍后设置" msgstr "跳过,稍后设置" +#: src/routes/settings/general.tsx:125 +msgid "输入 [[ 触发笔记选择,插入 [[note-title]] 链接" +msgstr "输入 [[ 触发笔记选择,插入 [[note-title]] 链接" + +#: src/routes/settings/general.tsx:119 +msgid "输入 / 触发候选菜单,快速插入或跳转笔记" +msgstr "输入 / 触发候选菜单,快速插入或跳转笔记" + #: src/components/layout/CommandPalette.tsx:38 msgid "输入命令..." msgstr "输入命令..." -#: src/components/pairing/InputCodeDialog.tsx:62 +#: src/components/pairing/InputCodeDialog.tsx:58 msgid "输入对方设备上显示的 6 位数字" msgstr "输入对方设备上显示的 6 位数字" -#: src/components/onboarding/PairingStep.tsx:252 +#: src/components/onboarding/PairingStep.tsx:254 msgid "输入对方设备生成的6位配对码" msgstr "输入对方设备生成的6位配对码" -#: src/components/onboarding/PairingStep.tsx:243 -#: src/components/pairing/InputCodeDialog.tsx:59 -#: src/routes/settings/devices.tsx:151 +#: src/components/onboarding/PairingStep.tsx:245 +#: src/components/pairing/InputCodeDialog.tsx:55 +#: src/routes/settings/devices.tsx:152 msgid "输入配对码" msgstr "输入配对码" @@ -1152,11 +1248,11 @@ msgstr "输入配对码" msgid "运行中" msgstr "运行中" -#: src/routes/workspace-manager.tsx:220 +#: src/routes/workspace-manager.tsx:214 msgid "还没有工作区" msgstr "还没有工作区" -#: src/components/workspace/WorkspacePicker.tsx:105 +#: src/components/workspace/WorkspacePicker.tsx:100 msgid "还没有工作区,创建一个开始使用吧。" msgstr "还没有工作区,创建一个开始使用吧。" @@ -1168,86 +1264,94 @@ msgstr "还没有笔记" msgid "这是我的第一台 SwarmNote 设备" msgstr "这是我的第一台 SwarmNote 设备" -#: src/components/onboarding/CompleteStep.tsx:77 +#: src/components/onboarding/CompleteStep.tsx:76 msgid "进入 SwarmNote" msgstr "进入 SwarmNote" -#: src/components/onboarding/PairingStep.tsx:264 +#: src/components/onboarding/PairingStep.tsx:266 msgid "连接" msgstr "连接" -#: src/components/layout/SyncStatusBar.tsx:25 -#: src/components/onboarding/PairingStep.tsx:264 +#: src/components/layout/SyncStatusBar.tsx:23 +#: src/components/onboarding/PairingStep.tsx:266 msgid "连接中..." msgstr "连接中..." -#: src/components/onboarding/PairingStep.tsx:195 +#: src/components/onboarding/PairingStep.tsx:197 msgid "连接你的其他设备以同步笔记" msgstr "连接你的其他设备以同步笔记" -#: src/components/layout/SyncStatusBar.tsx:27 +#: src/components/layout/SyncStatusBar.tsx:25 msgid "连接失败" msgstr "连接失败" +#: src/routes/settings/general.tsx:131 +msgid "选中文字时浮出格式化工具栏(粗体 / 斜体 / 链接 等)" +msgstr "选中文字时浮出格式化工具栏(粗体 / 斜体 / 链接 等)" + #: src/components/onboarding/PathChoiceStep.tsx:21 msgid "选择最适合你当前情况的选项" msgstr "选择最适合你当前情况的选项" -#: src/components/workspace/WorkspaceSyncDialog.tsx:206 +#: src/components/workspace/WorkspaceSyncDialog.tsx:200 msgid "选择同步目标目录" msgstr "选择同步目标目录" -#: src/components/workspace/WorkspacePicker.tsx:53 -#: src/routes/workspace-manager.tsx:246 +#: src/components/workspace/WorkspacePicker.tsx:48 +#: src/routes/workspace-manager.tsx:240 msgid "选择新工作区目录" msgstr "选择新工作区目录" -#: src/routes/settings/general.tsx:143 +#: src/routes/settings/general.tsx:164 msgid "选择明亮或暗色主题" msgstr "选择明亮或暗色主题" -#: src/routes/settings/general.tsx:130 +#: src/routes/settings/general.tsx:151 msgid "选择界面显示语言" msgstr "选择界面显示语言" #: src/routes/settings.tsx:26 -#: src/routes/settings/general.tsx:118 +#: src/routes/settings/general.tsx:139 msgid "通用" msgstr "通用" -#: src/routes/settings/general.tsx:104 +#: src/routes/settings/general.tsx:107 msgid "通过 DOMPurify 渲染嵌入的原生 HTML" msgstr "通过 DOMPurify 渲染嵌入的原生 HTML" -#: src/components/pairing/NearbyDeviceCard.tsx:59 +#: src/components/share/ShareDialog.tsx:73 +msgid "邀请已配对的设备协作编辑此工作区" +msgstr "邀请已配对的设备协作编辑此工作区" + +#: src/components/pairing/NearbyDeviceCard.tsx:58 msgid "配对" msgstr "配对" #: src/components/pairing/FoundDeviceDialog.tsx:76 -#: src/components/pairing/NearbyDeviceCard.tsx:59 +#: src/components/pairing/NearbyDeviceCard.tsx:58 msgid "配对中..." msgstr "配对中..." -#: src/components/onboarding/CompleteStep.tsx:32 +#: src/components/onboarding/CompleteStep.tsx:31 msgid "配对成功!" msgstr "配对成功!" -#: src/components/pairing/CodePairingCard.tsx:80 -#: src/components/pairing/CodePairingCard.tsx:116 +#: src/components/pairing/CodePairingCard.tsx:71 +#: src/components/pairing/CodePairingCard.tsx:118 msgid "配对码" msgstr "配对码" -#: src/components/pairing/CodePairingCard.tsx:62 +#: src/components/pairing/CodePairingCard.tsx:53 msgid "配对码已复制" msgstr "配对码已复制" -#: src/components/onboarding/PairingStep.tsx:144 +#: src/components/onboarding/PairingStep.tsx:146 #: src/components/pairing/FoundDeviceDialog.tsx:43 -#: src/components/pairing/NearbyDeviceCard.tsx:38 +#: src/components/pairing/NearbyDeviceCard.tsx:37 msgid "配对被拒绝" msgstr "配对被拒绝" -#: src/components/onboarding/PairingStep.tsx:192 +#: src/components/onboarding/PairingStep.tsx:194 msgid "配对设备" msgstr "配对设备" @@ -1255,9 +1359,13 @@ msgstr "配对设备" msgid "重命名" msgstr "重命名" -#: src/components/onboarding/PairingStep.tsx:175 -#: src/components/workspace/WorkspaceSyncDialog.tsx:307 -#: src/components/workspace/WorkspaceSyncDialog.tsx:316 +#: src/components/pairing/CodePairingCard.tsx:94 +msgid "重新生成" +msgstr "重新生成" + +#: src/components/onboarding/PairingStep.tsx:177 +#: src/components/workspace/WorkspaceSyncDialog.tsx:301 +#: src/components/workspace/WorkspaceSyncDialog.tsx:310 msgid "重试" msgstr "重试" @@ -1265,11 +1373,11 @@ msgstr "重试" msgid "错误" msgstr "错误" -#: src/routes/settings/devices.tsx:245 +#: src/routes/settings/devices.tsx:246 msgid "附近设备" msgstr "附近设备" -#: src/routes/settings/general.tsx:162 +#: src/routes/settings/general.tsx:183 msgid "限制编辑器内容宽度以提升阅读体验" msgstr "限制编辑器内容宽度以提升阅读体验"