feat(pro): mandatory zero-knowledge cloud sync + Pro upgrade surfaces

- cloud-sync client now REQUIRES VESTIGE_CLOUD_ENCRYPTION_KEY: construction
  fails without it, every upload is encrypted, downloads reject plaintext
- release.yml: Windows + Intel Mac binaries now build with cloud-sync
- ci.yml: the paid path is now compiled and tested in CI
- Second-Machine Wall: unentitled 'vestige sync --cloud' now explains Pro
  ($19/mo) with a subscribe link instead of a dead-end error
- vestige --help / health footers, npm postinstall note, npm README Pro section
- .gitignore: exclude private business docs (contact lists) from the repo

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-25 21:40:28 +08:00
parent 89da758985
commit 89b0b35665
7 changed files with 202 additions and 87 deletions

View file

@ -59,6 +59,9 @@ jobs:
- name: Test
run: cargo test --workspace
- name: Test paid cloud-sync path
run: cargo test --locked --package vestige-core --features cloud-sync cloud --lib
release-build:
name: Release Build (${{ matrix.target }})
runs-on: ${{ matrix.os }}
@ -81,7 +84,7 @@ jobs:
# runtime linking is a user concern documented in INSTALL-INTEL-MAC.md.
- os: macos-14
target: x86_64-apple-darwin
cargo_flags: "--no-default-features --features ort-dynamic,vector-search"
cargo_flags: "--no-default-features --features ort-dynamic,vector-search,cloud-sync"
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
cargo_flags: ""

View file

@ -38,7 +38,7 @@ jobs:
# generates 0 embeddings, and no model download is ever attempted.
# usearch builds cleanly on MSVC because vestige-core pins it with
# features=["fp16lib"] (see crates/vestige-core/Cargo.toml).
cargo_flags: "--no-default-features --features embeddings,ort-download,vector-search"
cargo_flags: "--no-default-features --features embeddings,ort-download,vector-search,cloud-sync"
needs_onnxruntime: false
# Intel Mac uses the ort-dynamic feature to runtime-link against a
# system libonnxruntime (Homebrew), sidestepping the missing
@ -48,7 +48,7 @@ jobs:
- target: x86_64-apple-darwin
os: macos-latest
archive: tar.gz
cargo_flags: "--no-default-features --features ort-dynamic,vector-search"
cargo_flags: "--no-default-features --features ort-dynamic,vector-search,cloud-sync"
- target: aarch64-apple-darwin
os: macos-latest
archive: tar.gz

14
.gitignore vendored
View file

@ -142,3 +142,17 @@ fastembed-rs/
.claude/
.codebase-memory/
# Internal AI-council planning scratch + session dumps — never public
.council/
# Private business material: pricing strategy, sales playbooks, and outreach
# lists containing third parties' names and contact details. These sit in the
# working tree untracked; without this rule a single `git add -A` would publish
# real people's personal data to a public repo.
docs/business/
*.session.jsonl
blackbox-proof-*/*.jsonl
# Hermes agent-runtime conversation dumps (dated hex, tens of MB, embed system prompts)
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9]_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]

View file

@ -24,9 +24,9 @@
use std::cell::RefCell;
use std::time::Duration;
use reqwest::StatusCode;
use reqwest::blocking::Client;
use reqwest::header::{AUTHORIZATION, ETAG, IF_MATCH};
use reqwest::StatusCode;
use super::portable::PortableArchive;
use super::sqlite::{PortableSyncBackend, Result, StorageError};
@ -49,10 +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>,
/// Required zero-knowledge passphrase. The archive is always encrypted
/// before upload and decrypted after download; the server never receives
/// this passphrase or a plaintext archive.
encryption_key: String,
/// Blocking HTTP client (the trait is synchronous).
client: Client,
/// ETag captured on the most recent successful read, used as the `If-Match`
@ -62,19 +62,19 @@ pub struct HttpPortableSyncBackend {
}
impl HttpPortableSyncBackend {
/// Build a cloud sync backend for `endpoint` authenticated with `sync_key`,
/// with no client-side encryption (plaintext upload).
/// Plaintext cloud sync is deliberately disabled.
///
/// 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.
/// Build a cloud sync backend with required 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.
/// The portable archive is encrypted with XChaCha20-Poly1305
/// (Argon2id-derived key) before upload and decrypted on download. The
/// passphrase never leaves this process. Missing or blank passphrases are
/// rejected rather than silently uploading plaintext.
pub fn new_with_encryption(
endpoint: impl Into<String>,
sync_key: impl Into<String>,
@ -92,7 +92,15 @@ 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 encryption_key = encryption_key
.filter(|k| !k.trim().is_empty())
.ok_or_else(|| {
StorageError::Init(
"cloud sync encryption key is required; set \
VESTIGE_CLOUD_ENCRYPTION_KEY before syncing"
.to_string(),
)
})?;
let client = Client::builder()
.timeout(REQUEST_TIMEOUT)
.user_agent(concat!("vestige-cloud-sync/", env!("CARGO_PKG_VERSION")))
@ -109,7 +117,7 @@ impl HttpPortableSyncBackend {
/// Whether this backend encrypts client-side (zero-knowledge).
pub fn is_encrypted(&self) -> bool {
self.encryption_key.is_some()
true
}
/// Full blob URL for this backend.
@ -150,27 +158,18 @@ impl PortableSyncBackend for HttpPortableSyncBackend {
.bytes()
.map_err(|e| StorageError::Init(format!("cloud sync read body failed: {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)
};
if !super::cloud_crypto::is_encrypted(&bytes) {
return Err(StorageError::Init(
"refusing plaintext cloud archive: Vestige Pro requires \
end-to-end encrypted sync"
.to_string(),
));
}
let plaintext = super::cloud_crypto::decrypt(&self.encryption_key, &bytes)?;
let archive: PortableArchive =
serde_json::from_slice(&plaintext).map_err(|e| {
StorageError::Init(format!("failed to parse cloud sync archive: {e}"))
})?;
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(
@ -188,21 +187,15 @@ impl PortableSyncBackend for HttpPortableSyncBackend {
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"),
};
// Zero-knowledge is mandatory for hosted sync: plaintext never leaves
// this process and every successful upload is an encrypted envelope.
let body = super::cloud_crypto::encrypt(&self.encryption_key, &plaintext)?;
let mut req = self
.client
.put(self.blob_url())
.header(AUTHORIZATION, format!("Bearer {}", self.sync_key))
.header(reqwest::header::CONTENT_TYPE, content_type)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.body(body);
// Optimistic concurrency: only overwrite the object we pulled. If the
@ -240,13 +233,15 @@ impl PortableSyncBackend for HttpPortableSyncBackend {
#[cfg(test)]
mod tests {
use super::super::portable::{PORTABLE_ARCHIVE_FORMAT, PortableArchive, PortableTable};
use super::*;
use super::super::portable::{PortableArchive, PortableTable, PORTABLE_ARCHIVE_FORMAT};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
use std::thread;
const TEST_PASSPHRASE: &str = "correct horse battery staple";
fn sample_archive() -> PortableArchive {
PortableArchive {
archive_format: PORTABLE_ARCHIVE_FORMAT.to_string(),
@ -268,6 +263,7 @@ mod tests {
method: String,
authorization: Option<String>,
if_match: Option<String>,
content_type: Option<String>,
}
/// Minimal one-shot HTTP mock. `responder` builds the raw HTTP response
@ -275,7 +271,7 @@ mod tests {
/// URL and a receiver for the captured request.
fn spawn_mock<F>(responder: F) -> (String, mpsc::Receiver<CapturedRequest>)
where
F: Fn(&CapturedRequest) -> String + Send + 'static,
F: Fn(&CapturedRequest) -> Vec<u8> + Send + 'static,
{
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
let addr = listener.local_addr().expect("addr");
@ -293,10 +289,12 @@ mod tests {
cap.authorization = Some(v.trim().to_string());
} else if let Some(v) = line.strip_prefix("if-match: ") {
cap.if_match = Some(v.trim().to_string());
} else if let Some(v) = line.strip_prefix("content-type: ") {
cap.content_type = Some(v.trim().to_string());
}
}
let response = responder(&cap);
let _ = stream.write_all(response.as_bytes());
let _ = stream.write_all(&response);
let _ = stream.flush();
let _ = tx.send(cap);
}
@ -304,30 +302,61 @@ mod tests {
(format!("http://{addr}"), rx)
}
fn http_response(status: &str, extra_headers: &str, body: &str) -> String {
format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
fn http_response(status: &str, extra_headers: &str, body: &str) -> Vec<u8> {
http_response_bytes(status, extra_headers, body.as_bytes())
}
fn http_response_bytes(status: &str, extra_headers: &str, body: &[u8]) -> Vec<u8> {
let mut response = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n",
body.len()
)
.into_bytes();
response.extend_from_slice(body);
response
}
fn encrypted_backend(endpoint: impl Into<String>, sync_key: &str) -> HttpPortableSyncBackend {
HttpPortableSyncBackend::new_with_encryption(
endpoint,
sync_key,
Some(TEST_PASSPHRASE.to_string()),
)
.unwrap()
}
#[test]
fn new_rejects_empty_endpoint_and_key() {
assert!(HttpPortableSyncBackend::new("", "key").is_err());
assert!(HttpPortableSyncBackend::new("https://x", "").is_err());
assert!(HttpPortableSyncBackend::new("https://x", "key").is_ok());
fn new_rejects_plaintext_empty_endpoint_and_empty_key() {
assert!(HttpPortableSyncBackend::new("https://x", "key").is_err());
assert!(
HttpPortableSyncBackend::new_with_encryption(
"",
"key",
Some(TEST_PASSPHRASE.to_string())
)
.is_err()
);
assert!(
HttpPortableSyncBackend::new_with_encryption(
"https://x",
"",
Some(TEST_PASSPHRASE.to_string())
)
.is_err()
);
assert!(encrypted_backend("https://x", "key").is_encrypted());
}
#[test]
fn endpoint_trailing_slash_trimmed() {
let be = HttpPortableSyncBackend::new("https://sync.example/", "k").unwrap();
let be = encrypted_backend("https://sync.example/", "k");
assert_eq!(be.blob_url(), "https://sync.example/v1/blob");
}
#[test]
fn read_404_returns_none() {
let (base, rx) = spawn_mock(|_| http_response("404 Not Found", "", ""));
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
let be = encrypted_backend(base, "secret");
let got = be.read_archive().expect("read ok");
assert!(got.is_none());
let cap = rx.recv().unwrap();
@ -338,21 +367,31 @@ mod tests {
#[test]
fn read_200_parses_and_captures_etag() {
let archive = sample_archive();
let body = serde_json::to_string(&archive).unwrap();
let plaintext = serde_json::to_vec(&archive).unwrap();
let body = super::super::cloud_crypto::encrypt(TEST_PASSPHRASE, &plaintext).unwrap();
let (base, _rx) = spawn_mock(move |_| {
http_response("200 OK", "ETag: \"v1-abc\"\r\n", &body)
http_response_bytes("200 OK", "ETag: \"v1-abc\"\r\n", &body)
});
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
let be = encrypted_backend(base, "secret");
let got = be.read_archive().expect("read ok").expect("some archive");
assert_eq!(got.archive_format, PORTABLE_ARCHIVE_FORMAT);
// ETag captured for the next If-Match write.
assert_eq!(be.last_etag.borrow().as_deref(), Some("\"v1-abc\""));
}
#[test]
fn read_200_rejects_plaintext_downgrade() {
let body = serde_json::to_string(&sample_archive()).unwrap();
let (base, _rx) = spawn_mock(move |_| http_response("200 OK", "", &body));
let be = encrypted_backend(base, "secret");
let err = be.read_archive().unwrap_err();
assert!(err.to_string().contains("refusing plaintext"));
}
#[test]
fn read_401_is_error() {
let (base, _rx) = spawn_mock(|_| http_response("401 Unauthorized", "", ""));
let be = HttpPortableSyncBackend::new(base, "bad").unwrap();
let be = encrypted_backend(base, "bad");
assert!(be.read_archive().is_err());
}
@ -360,19 +399,20 @@ mod tests {
fn write_sends_if_match_when_etag_present() {
// Seed an etag as if a prior read happened.
let (base, rx) = spawn_mock(|_| http_response("200 OK", "", ""));
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
let be = encrypted_backend(base, "secret");
*be.last_etag.borrow_mut() = Some("\"v1-abc\"".to_string());
be.write_archive(&sample_archive()).expect("write ok");
let cap = rx.recv().unwrap();
assert_eq!(cap.method, "PUT");
assert_eq!(cap.authorization.as_deref(), Some("Bearer secret"));
assert_eq!(cap.if_match.as_deref(), Some("\"v1-abc\""));
assert_eq!(cap.content_type.as_deref(), Some("application/octet-stream"));
}
#[test]
fn write_omits_if_match_for_first_create() {
let (base, rx) = spawn_mock(|_| http_response("201 Created", "", ""));
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
let be = encrypted_backend(base, "secret");
// No prior read → no etag → no If-Match (allow create).
be.write_archive(&sample_archive()).expect("write ok");
let cap = rx.recv().unwrap();
@ -383,7 +423,7 @@ mod tests {
#[test]
fn write_412_is_conflict_error() {
let (base, _rx) = spawn_mock(|_| http_response("412 Precondition Failed", "", ""));
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
let be = encrypted_backend(base, "secret");
*be.last_etag.borrow_mut() = Some("\"stale\"".to_string());
let err = be.write_archive(&sample_archive()).unwrap_err();
assert!(err.to_string().contains("conflict"));

View file

@ -26,6 +26,9 @@ use vestige_core::{IngestInput, PortableImportMode, Storage};
#[command(
long_about = "Vestige is a cognitive memory system based on 130 years of memory research.\n\nIt implements FSRS-6, spreading activation, synaptic tagging, and more."
)]
#[command(
after_help = "Vestige Pro: your memory on every machine, end-to-end encrypted. $19/mo -> https://github.com/samvallad33/vestige#vestige-pro"
)]
struct Cli {
/// Use a specific Vestige data directory for this command.
#[arg(long, global = true, value_name = "DIR")]
@ -1751,6 +1754,15 @@ fn run_health() -> anyhow::Result<()> {
println!(" {} {}", icon, text);
}
println!();
println!(
"{} {}",
"Pro:".cyan().bold(),
"sync this memory across machines, end-to-end encrypted ($19/mo) — \
https://github.com/samvallad33/vestige#vestige-pro"
.white()
);
Ok(())
}
@ -2323,8 +2335,10 @@ fn run_sync_cloud(endpoint: Option<String>) -> anyhow::Result<()> {
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"no cloud endpoint: pass --endpoint or set VESTIGE_CLOUD_ENDPOINT \
(e.g. https://sync.vestige.dev)"
"Vestige Pro syncs your agent's memory across machines, end-to-end encrypted \
($19/month).\n Subscribe: https://github.com/samvallad33/vestige#vestige-pro\n \
Already subscribed? Pass --endpoint or set VESTIGE_CLOUD_ENDPOINT from your \
welcome email."
)
})?;
let sync_key = std::env::var("VESTIGE_CLOUD_SYNC_KEY")
@ -2332,35 +2346,37 @@ fn run_sync_cloud(endpoint: Option<String>) -> anyhow::Result<()> {
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"no sync key: set VESTIGE_CLOUD_SYNC_KEY (issued when you subscribe to \
Vestige Cloud)"
"no sync key set. Your local memory is free forever; syncing it across machines \
is Vestige Pro ($19/month, zero-knowledge encrypted).\n Subscribe: \
https://github.com/samvallad33/vestige#vestige-pro\n Already subscribed? Set \
VESTIGE_CLOUD_SYNC_KEY from your welcome email."
)
})?;
// Optional zero-knowledge encryption passphrase. Never sent to the server.
// Required 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());
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"no encryption key: set VESTIGE_CLOUD_ENCRYPTION_KEY to a strong passphrase. \
Vestige Pro refuses plaintext cloud sync. Use the same passphrase on every \
device; it cannot be recovered by Vestige."
)
})?;
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()
);
}
println!(
"{}: {}",
"Encryption".white().bold(),
"zero-knowledge (XChaCha20-Poly1305) — your data is encrypted before upload".green()
);
let storage = open_storage()?;
let report = storage.sync_portable_archive_cloud(&endpoint, &sync_key, encryption_key)?;
let report =
storage.sync_portable_archive_cloud(&endpoint, &sync_key, Some(encryption_key))?;
print_sync_report(&report);
Ok(())
}
@ -2368,8 +2384,9 @@ fn run_sync_cloud(endpoint: Option<String>) -> anyhow::Result<()> {
#[cfg(not(feature = "cloud-sync"))]
fn run_sync_cloud(_endpoint: Option<String>) -> anyhow::Result<()> {
anyhow::bail!(
"this build was compiled without the `cloud-sync` feature; rebuild with \
--features cloud-sync to use Vestige Cloud"
"this build was compiled without the `cloud-sync` feature. Official binaries from \
v2.3.0 include it: run `vestige update` or `npm update -g vestige-mcp-server`, \
then retry. Building from source? Add --features cloud-sync."
)
}
@ -2593,6 +2610,21 @@ fn run_ingest(
let storage = open_storage()?;
// Warm up the embedding model so smart_ingest's is_ready() gate passes and
// real vector search runs (instead of silently falling back to keyword-only
// regular ingest). is_ready() is side-effect free by design, so a fresh CLI
// process must init explicitly. Non-fatal: fall back to keyword on failure.
#[cfg(feature = "embeddings")]
{
if let Err(e) = storage.init_embeddings() {
eprintln!(
" {} Embeddings unavailable: {} (ingest will use keyword-only)",
"!".yellow(),
e
);
}
}
// Try smart_ingest (PE Gating) if available, otherwise regular ingest
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
{

View file

@ -22,6 +22,29 @@ This refreshes the binaries only. Optional Claude Code Cognitive Sandwich
companion files are refreshed with `vestige update --sandwich-companion` or
`vestige sandwich install`.
## Vestige Pro — your memory on every machine
The entire cognitive engine is free forever, local, and never metered. Vestige
Pro is for when that memory needs to follow you:
- **End-to-end encrypted continuity** across every machine you work on: your
memory graph plus your accountability history (Black Box traces, receipts,
memory PRs).
- **Zero-knowledge by construction**: your passphrase never leaves your machine.
The server only ever stores ciphertext. The client refuses to sync without
encryption.
- **$19/month.** No annual lock-in, no lifetime gimmicks.
```bash
npm update -g vestige-mcp-server # get the Pro-capable client (v2.3.0+)
vestige sync --cloud # walks you through the rest
```
Subscribe: [github.com/samvallad33/vestige#vestige-pro](https://github.com/samvallad33/vestige#vestige-pro)
One passphrase, chosen by you, for every device. Vestige never receives it. A
lost passphrase means the encrypted data is unrecoverable — that is the point.
### What gets installed
| Command | Description |

View file

@ -262,6 +262,9 @@ async function main() {
console.log(' 2. Restart your MCP client.');
console.log(' 3. Test with: "remember that my preferred editor is VS Code"');
console.log('');
console.log('Local memory is free forever. Vestige Pro syncs it across machines,');
console.log('end-to-end encrypted ($19/mo): https://github.com/samvallad33/vestige#vestige-pro');
console.log('');
} catch (err) {
console.error('');