Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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

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

8 changes: 8 additions & 0 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
21 changes: 20 additions & 1 deletion crates/core/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,24 @@ impl AppCore {
pub async fn open_workspace(
self: &Arc<Self>,
path: impl Into<PathBuf>,
) -> AppResult<Arc<WorkspaceCore>> {
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<Self>,
path: impl Into<PathBuf>,
) -> AppResult<Arc<WorkspaceCore>> {
self.open_workspace_impl(path, false).await
}

async fn open_workspace_impl(
self: &Arc<Self>,
path: impl Into<PathBuf>,
init_keys: bool,
) -> AppResult<Arc<WorkspaceCore>> {
let path: PathBuf = path.into();
if !path.is_dir() {
Expand Down Expand Up @@ -258,7 +276,8 @@ impl AppCore {
fs,
watcher,
self.event_bus.clone(),
peer_id,
&self.identity,
init_keys,
Arc::downgrade(self),
)
.await?;
Expand Down
82 changes: 82 additions & 0 deletions crates/core/src/crypto.rs
Original file line number Diff line number Diff line change
@@ -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<const N: usize>() -> [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;
161 changes: 161 additions & 0 deletions crates/core/src/crypto/aead.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> 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<Vec<u8>> {
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<u32> {
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<Vec<u8>> {
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
);
}
}
Loading
Loading