mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
feat(cloud-sync): zero-knowledge client-side encryption (XChaCha20-Poly1305)
The portable archive is encrypted on the client before upload and decrypted after download, so the hosted service only ever stores ciphertext — true zero-knowledge. The passphrase (VESTIGE_CLOUD_ENCRYPTION_KEY) is independent of the bearer sync key and never leaves the device. - new cloud_crypto module: Argon2id KDF + XChaCha20-Poly1305 AEAD, self- describing envelope (MAGIC|version|salt|nonce|ciphertext+tag) - HttpPortableSyncBackend encrypts on write / decrypts on read; transparent upgrade of legacy plaintext archives; clear error if remote is encrypted but no passphrase is set - sync_portable_archive_cloud takes optional encryption_key - CLI surfaces encryption status (on/off) on sync - 6 crypto tests (roundtrip, wrong-key, tamper detection, non-determinism, envelope detection); E2E verified: server blob is ciphertext, passphrase device recovers, no-passphrase device cannot decrypt 491 core tests green, clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fdd6b98180
commit
b8212feb15
7 changed files with 396 additions and 12 deletions
134
Cargo.lock
generated
134
Cargo.lock
generated
|
|
@ -8,6 +8,16 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aead"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
|
|
@ -143,6 +153,18 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
|
@ -317,6 +339,15 @@ dependencies = [
|
|||
"core2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.4"
|
||||
|
|
@ -527,6 +558,30 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20poly1305"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"chacha20",
|
||||
"cipher",
|
||||
"poly1305",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
|
|
@ -568,6 +623,17 @@ dependencies = [
|
|||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
|
|
@ -821,6 +887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
|
|
@ -1025,6 +1092,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
|||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1379,6 +1447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2229,6 +2298,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "interpolate_name"
|
||||
version = "0.2.4"
|
||||
|
|
@ -3012,6 +3090,12 @@ version = "11.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
|
|
@ -3137,6 +3221,17 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[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"
|
||||
|
|
@ -3223,6 +3318,17 @@ dependencies = [
|
|||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
|
||||
dependencies = [
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
|
|
@ -3404,7 +3510,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3435,7 +3541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3445,7 +3551,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3627,6 +3742,7 @@ dependencies = [
|
|||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
|
|
@ -4626,6 +4742,16 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||
|
||||
[[package]]
|
||||
name = "universal-hash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
|
@ -4773,8 +4899,10 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||
name = "vestige-core"
|
||||
version = "2.1.27"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"blake3",
|
||||
"candle-core",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"directories",
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ connectors = ["dep:reqwest"]
|
|||
# this is the ONLY thing that links an HTTP client — the default local-first
|
||||
# build stays network-free. Uses `reqwest`'s blocking client because the
|
||||
# `PortableSyncBackend` trait methods are synchronous.
|
||||
cloud-sync = ["dep:reqwest", "reqwest/blocking"]
|
||||
cloud-sync = ["dep:reqwest", "reqwest/blocking", "dep:chacha20poly1305", "dep:argon2"]
|
||||
|
||||
# Metal GPU acceleration on Apple Silicon (significantly faster inference)
|
||||
metal = ["fastembed/metal"]
|
||||
|
|
@ -154,6 +154,15 @@ blake3 = "1"
|
|||
# `connectors` feature — the default local-first build does not link reqwest.
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
||||
|
||||
# ============================================================================
|
||||
# OPTIONAL: Zero-knowledge client-side encryption for Vestige Cloud sync
|
||||
# ============================================================================
|
||||
# XChaCha20-Poly1305 AEAD + Argon2id passphrase KDF. The archive is encrypted on
|
||||
# the client before upload, so the hosted service only ever stores ciphertext.
|
||||
# Behind the `cloud-sync` feature — the default local-first build links neither.
|
||||
chacha20poly1305 = { version = "0.10", optional = true }
|
||||
argon2 = { version = "0.5", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
|
|
|||
164
crates/vestige-core/src/storage/cloud_crypto.rs
Normal file
164
crates/vestige-core/src/storage/cloud_crypto.rs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
//! Zero-knowledge client-side encryption for Vestige Cloud sync.
|
||||
//!
|
||||
//! The portable archive is encrypted on the client **before** it is uploaded
|
||||
//! and decrypted **after** it is downloaded, so the hosted service only ever
|
||||
//! stores ciphertext. The encryption passphrase is supplied by the user
|
||||
//! (`VESTIGE_CLOUD_ENCRYPTION_KEY`) and is **never** sent to the server — it is
|
||||
//! independent of the bearer sync key. This is what makes the "we hold no keys"
|
||||
//! guarantee literally true: a server breach yields only random noise.
|
||||
//!
|
||||
//! Construction:
|
||||
//! - KDF: Argon2id over (passphrase, random 16-byte salt) → 32-byte key.
|
||||
//! - AEAD: XChaCha20-Poly1305 (192-bit nonce) over the archive bytes.
|
||||
//! - Envelope (all non-secret framing prepended to the ciphertext):
|
||||
//! `MAGIC(8) | VERSION(1) | salt(16) | nonce(24) | ciphertext+tag`
|
||||
//!
|
||||
//! Tradeoff (by design, and a selling point): if the user loses the passphrase,
|
||||
//! the synced data is unrecoverable. We cannot reset it — we never have it.
|
||||
|
||||
use argon2::Argon2;
|
||||
use chacha20poly1305::aead::rand_core::RngCore;
|
||||
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
|
||||
|
||||
use super::sqlite::{Result, StorageError};
|
||||
|
||||
/// Magic marker identifying a Vestige zero-knowledge envelope.
|
||||
const MAGIC: &[u8; 8] = b"VSTGENC1";
|
||||
const VERSION: u8 = 1;
|
||||
const SALT_LEN: usize = 16;
|
||||
const NONCE_LEN: usize = 24; // XChaCha20-Poly1305 nonce is 192-bit.
|
||||
const KEY_LEN: usize = 32;
|
||||
const HEADER_LEN: usize = MAGIC.len() + 1 + SALT_LEN + NONCE_LEN;
|
||||
|
||||
/// Derive a 32-byte key from the passphrase and salt using Argon2id (defaults).
|
||||
fn derive_key(passphrase: &[u8], salt: &[u8]) -> Result<[u8; KEY_LEN]> {
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
Argon2::default()
|
||||
.hash_password_into(passphrase, salt, &mut key)
|
||||
.map_err(|e| StorageError::Init(format!("key derivation failed: {e}")))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` under `passphrase`, returning the self-describing envelope.
|
||||
///
|
||||
/// A fresh random salt and nonce are generated per call, so re-encrypting the
|
||||
/// same archive yields different ciphertext (no deterministic leakage).
|
||||
pub fn encrypt(passphrase: &str, plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||
let mut salt = [0u8; SALT_LEN];
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
|
||||
let key_bytes = derive_key(passphrase.as_bytes(), &salt)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&key_bytes));
|
||||
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|_| StorageError::Init("encryption failed".to_string()))?;
|
||||
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.push(VERSION);
|
||||
out.extend_from_slice(&salt);
|
||||
out.extend_from_slice(nonce.as_slice());
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// True if `bytes` start with the Vestige encryption magic. Lets the sync
|
||||
/// backend distinguish an encrypted envelope from a legacy plaintext archive.
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= MAGIC.len() && &bytes[..MAGIC.len()] == MAGIC
|
||||
}
|
||||
|
||||
/// Decrypt a Vestige envelope under `passphrase`. Fails on a wrong passphrase or
|
||||
/// any tampering (the Poly1305 tag is verified).
|
||||
pub fn decrypt(passphrase: &str, envelope: &[u8]) -> Result<Vec<u8>> {
|
||||
if envelope.len() < HEADER_LEN {
|
||||
return Err(StorageError::Init(
|
||||
"cloud archive is too short to be a valid encrypted envelope".to_string(),
|
||||
));
|
||||
}
|
||||
if &envelope[..MAGIC.len()] != MAGIC {
|
||||
return Err(StorageError::Init(
|
||||
"cloud archive is not a Vestige encrypted envelope".to_string(),
|
||||
));
|
||||
}
|
||||
let version = envelope[MAGIC.len()];
|
||||
if version != VERSION {
|
||||
return Err(StorageError::Init(format!(
|
||||
"unsupported cloud encryption version {version}"
|
||||
)));
|
||||
}
|
||||
|
||||
let salt_start = MAGIC.len() + 1;
|
||||
let nonce_start = salt_start + SALT_LEN;
|
||||
let ct_start = nonce_start + NONCE_LEN;
|
||||
let salt = &envelope[salt_start..nonce_start];
|
||||
let nonce = XNonce::from_slice(&envelope[nonce_start..ct_start]);
|
||||
let ciphertext = &envelope[ct_start..];
|
||||
|
||||
let key_bytes = derive_key(passphrase.as_bytes(), salt)?;
|
||||
let cipher = XChaCha20Poly1305::new(Key::from_slice(&key_bytes));
|
||||
|
||||
cipher.decrypt(nonce, ciphertext).map_err(|_| {
|
||||
StorageError::Init(
|
||||
"cloud decryption failed: wrong VESTIGE_CLOUD_ENCRYPTION_KEY or corrupted data"
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let pass = "correct horse battery staple";
|
||||
let msg = b"the entire cognitive graph, in plaintext, before upload";
|
||||
let env = encrypt(pass, msg).unwrap();
|
||||
assert!(is_encrypted(&env));
|
||||
assert_ne!(&env[..], &msg[..], "envelope must not contain plaintext");
|
||||
let back = decrypt(pass, &env).unwrap();
|
||||
assert_eq!(back, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_passphrase_fails() {
|
||||
let env = encrypt("right-pass", b"secret").unwrap();
|
||||
assert!(decrypt("wrong-pass", &env).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tamper_is_detected() {
|
||||
let mut env = encrypt("pass", b"important memory").unwrap();
|
||||
// Flip a byte in the ciphertext region.
|
||||
let last = env.len() - 1;
|
||||
env[last] ^= 0xff;
|
||||
assert!(decrypt("pass", &env).is_err(), "AEAD must reject tampering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ciphertext_is_nondeterministic() {
|
||||
// Same input encrypted twice → different envelopes (random salt+nonce).
|
||||
let a = encrypt("p", b"x").unwrap();
|
||||
let b = encrypt("p", b"x").unwrap();
|
||||
assert_ne!(a, b);
|
||||
// Both still decrypt correctly.
|
||||
assert_eq!(decrypt("p", &a).unwrap(), b"x");
|
||||
assert_eq!(decrypt("p", &b).unwrap(), b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_is_not_misdetected_as_envelope() {
|
||||
assert!(!is_encrypted(b"{\"archiveFormat\":\"vestige.portable.v1\"}"));
|
||||
assert!(!is_encrypted(b""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_or_foreign_envelope() {
|
||||
assert!(decrypt("p", b"too short").is_err());
|
||||
assert!(decrypt("p", &[0u8; 100]).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,10 @@ pub struct HttpPortableSyncBackend {
|
|||
endpoint: String,
|
||||
/// Per-user sync key, presented as `Authorization: Bearer <key>`.
|
||||
sync_key: String,
|
||||
/// Optional zero-knowledge passphrase. When set, the archive is encrypted
|
||||
/// before upload and decrypted after download — the server never sees
|
||||
/// plaintext, and this passphrase is never sent to the server.
|
||||
encryption_key: Option<String>,
|
||||
/// Blocking HTTP client (the trait is synchronous).
|
||||
client: Client,
|
||||
/// ETag captured on the most recent successful read, used as the `If-Match`
|
||||
|
|
@ -58,10 +62,24 @@ pub struct HttpPortableSyncBackend {
|
|||
}
|
||||
|
||||
impl HttpPortableSyncBackend {
|
||||
/// Build a cloud sync backend for `endpoint` authenticated with `sync_key`.
|
||||
/// Build a cloud sync backend for `endpoint` authenticated with `sync_key`,
|
||||
/// with no client-side encryption (plaintext upload).
|
||||
///
|
||||
/// A trailing slash on `endpoint` is trimmed so URL joining is predictable.
|
||||
pub fn new(endpoint: impl Into<String>, sync_key: impl Into<String>) -> Result<Self> {
|
||||
Self::new_with_encryption(endpoint, sync_key, None)
|
||||
}
|
||||
|
||||
/// Build a cloud sync backend with optional zero-knowledge encryption.
|
||||
///
|
||||
/// When `encryption_key` is `Some`, the portable archive is encrypted with
|
||||
/// XChaCha20-Poly1305 (Argon2id-derived key) before upload and decrypted on
|
||||
/// download. The passphrase never leaves this process.
|
||||
pub fn new_with_encryption(
|
||||
endpoint: impl Into<String>,
|
||||
sync_key: impl Into<String>,
|
||||
encryption_key: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let endpoint = endpoint.into().trim_end_matches('/').to_string();
|
||||
let sync_key = sync_key.into();
|
||||
if endpoint.is_empty() {
|
||||
|
|
@ -74,6 +92,7 @@ impl HttpPortableSyncBackend {
|
|||
"cloud sync key is empty (set VESTIGE_CLOUD_SYNC_KEY)".to_string(),
|
||||
));
|
||||
}
|
||||
let encryption_key = encryption_key.filter(|k| !k.is_empty());
|
||||
let client = Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.user_agent(concat!("vestige-cloud-sync/", env!("CARGO_PKG_VERSION")))
|
||||
|
|
@ -82,11 +101,17 @@ impl HttpPortableSyncBackend {
|
|||
Ok(Self {
|
||||
endpoint,
|
||||
sync_key,
|
||||
encryption_key,
|
||||
client,
|
||||
last_etag: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this backend encrypts client-side (zero-knowledge).
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
self.encryption_key.is_some()
|
||||
}
|
||||
|
||||
/// Full blob URL for this backend.
|
||||
fn blob_url(&self) -> String {
|
||||
format!("{}{}", self.endpoint, BLOB_PATH)
|
||||
|
|
@ -124,9 +149,28 @@ impl PortableSyncBackend for HttpPortableSyncBackend {
|
|||
let bytes = resp
|
||||
.bytes()
|
||||
.map_err(|e| StorageError::Init(format!("cloud sync read body failed: {e}")))?;
|
||||
let archive: PortableArchive = serde_json::from_slice(&bytes).map_err(|e| {
|
||||
StorageError::Init(format!("failed to parse cloud sync archive: {e}"))
|
||||
})?;
|
||||
|
||||
// Decrypt if this is a zero-knowledge envelope. If a passphrase
|
||||
// is configured but the remote is still plaintext (legacy), parse
|
||||
// it directly — the next push will encrypt it (transparent upgrade).
|
||||
let plaintext: std::borrow::Cow<'_, [u8]> =
|
||||
if super::cloud_crypto::is_encrypted(&bytes) {
|
||||
let pass = self.encryption_key.as_deref().ok_or_else(|| {
|
||||
StorageError::Init(
|
||||
"remote archive is encrypted but VESTIGE_CLOUD_ENCRYPTION_KEY is \
|
||||
not set on this device"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
std::borrow::Cow::Owned(super::cloud_crypto::decrypt(pass, &bytes)?)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(&bytes)
|
||||
};
|
||||
|
||||
let archive: PortableArchive =
|
||||
serde_json::from_slice(&plaintext).map_err(|e| {
|
||||
StorageError::Init(format!("failed to parse cloud sync archive: {e}"))
|
||||
})?;
|
||||
Ok(Some(archive))
|
||||
}
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(StorageError::Init(
|
||||
|
|
@ -141,14 +185,24 @@ impl PortableSyncBackend for HttpPortableSyncBackend {
|
|||
}
|
||||
|
||||
fn write_archive(&self, archive: &PortableArchive) -> Result<()> {
|
||||
let body = serde_json::to_vec(archive)
|
||||
let plaintext = serde_json::to_vec(archive)
|
||||
.map_err(|e| StorageError::Init(format!("failed to serialize archive: {e}")))?;
|
||||
|
||||
// Zero-knowledge: encrypt before upload when a passphrase is set, so the
|
||||
// server only ever stores ciphertext. Content type reflects the payload.
|
||||
let (body, content_type) = match self.encryption_key.as_deref() {
|
||||
Some(pass) => (
|
||||
super::cloud_crypto::encrypt(pass, &plaintext)?,
|
||||
"application/octet-stream",
|
||||
),
|
||||
None => (plaintext, "application/json"),
|
||||
};
|
||||
|
||||
let mut req = self
|
||||
.client
|
||||
.put(self.blob_url())
|
||||
.header(AUTHORIZATION, format!("Bearer {}", self.sync_key))
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.header(reqwest::header::CONTENT_TYPE, content_type)
|
||||
.body(body);
|
||||
|
||||
// Optimistic concurrency: only overwrite the object we pulled. If the
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
//!
|
||||
//! Backend-agnostic memory store abstraction plus SQLite reference impl.
|
||||
|
||||
#[cfg(feature = "cloud-sync")]
|
||||
mod cloud_crypto;
|
||||
#[cfg(feature = "cloud-sync")]
|
||||
mod cloud_sync;
|
||||
mod memory_store;
|
||||
|
|
|
|||
|
|
@ -6336,13 +6336,22 @@ impl SqliteMemoryStore {
|
|||
/// service. `endpoint` is the base URL (e.g. `https://sync.vestige.dev`) and
|
||||
/// `sync_key` is the per-user key issued at purchase. Pull-merge-push is
|
||||
/// identical to file sync — only the transport differs.
|
||||
///
|
||||
/// When `encryption_key` is `Some`, the archive is encrypted client-side
|
||||
/// (XChaCha20-Poly1305) before upload, so the server only stores ciphertext
|
||||
/// (zero-knowledge). The passphrase never leaves this process.
|
||||
#[cfg(feature = "cloud-sync")]
|
||||
pub fn sync_portable_archive_cloud(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
sync_key: &str,
|
||||
encryption_key: Option<String>,
|
||||
) -> Result<PortableSyncReport> {
|
||||
let backend = super::cloud_sync::HttpPortableSyncBackend::new(endpoint, sync_key)?;
|
||||
let backend = super::cloud_sync::HttpPortableSyncBackend::new_with_encryption(
|
||||
endpoint,
|
||||
sync_key,
|
||||
encryption_key,
|
||||
)?;
|
||||
self.sync_portable_archive(&backend)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2256,12 +2256,30 @@ fn run_sync_cloud(endpoint: Option<String>) -> anyhow::Result<()> {
|
|||
)
|
||||
})?;
|
||||
|
||||
// Optional zero-knowledge encryption passphrase. Never sent to the server.
|
||||
let encryption_key = std::env::var("VESTIGE_CLOUD_ENCRYPTION_KEY")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
|
||||
println!("{}", "=== Vestige Cloud Sync ===".cyan().bold());
|
||||
println!();
|
||||
println!("{}: {}", "Endpoint".white().bold(), endpoint);
|
||||
if encryption_key.is_some() {
|
||||
println!(
|
||||
"{}: {}",
|
||||
"Encryption".white().bold(),
|
||||
"zero-knowledge (XChaCha20-Poly1305) — your data is encrypted before upload".green()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}: {}",
|
||||
"Encryption".white().bold(),
|
||||
"OFF — set VESTIGE_CLOUD_ENCRYPTION_KEY for zero-knowledge sync".yellow()
|
||||
);
|
||||
}
|
||||
|
||||
let storage = open_storage()?;
|
||||
let report = storage.sync_portable_archive_cloud(&endpoint, &sync_key)?;
|
||||
let report = storage.sync_portable_archive_cloud(&endpoint, &sync_key, encryption_key)?;
|
||||
print_sync_report(&report);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue