MNW SyncKit.

E2E encrypted cloud sync for indie apps. Ship it in an afternoon.
A Rust SDK + hosted API. Cursor-based changelog sync, blob storage, device management. Zero-knowledge server.
20+ SDK methods
E2E Encrypted by default
3 Apps shipping today
0 Plaintext on server
SyncKit is developer infrastructure for cloud sync. You bring your own schema — table names, row IDs, and data shapes are opaque to the server. Every payload is encrypted client-side with XChaCha20-Poly1305 before it leaves the device. The server stores ciphertext. No schema migrations on our end, no data access requests to worry about, no plaintext in your database logs.
Five lines to sync
let client = SyncKitClient::new(SyncKitConfig {
    server_url: "https://makenot.work".into(),
    api_key: "your-api-key".into(),
});
let (user_id, app_id) = client.authenticate("user@example.com", "pass").await?;
client.setup_encryption_new("pass").await?;           // generates + wraps master key
let device = client.register_device("MacBook", "macos").await?;
let cursor = client.push(device.id, changes).await?;     // encrypted, retried, done

What You Get

Changelog Sync

  • Push/pull with cursor-based pagination
  • INSERT, UPDATE, DELETE operations
  • Any JSON payload — server is schema-agnostic
  • 500 changes per push, 500 per pull page
  • Monotonic sequence numbers, deterministic ordering

E2E Encryption

  • XChaCha20-Poly1305 (256-bit, AEAD)
  • Argon2id key derivation (OWASP parameters)
  • Random 24-byte nonce per entry (no reuse risk)
  • 40 bytes overhead per encrypted payload
  • Server never sees plaintext data or master key

Blob Storage

  • Encrypted file upload/download via presigned S3 URLs
  • Content-addressed deduplication (SHA-256 hash)
  • 3-step flow: request URL, upload, confirm
  • Up to 500 MB per blob
  • Binary encryption (no base64 overhead on wire)

Device & Account Management

  • Register devices by name + platform (upsert semantics)
  • Per-device cursor tracking on client side
  • List and delete devices via API
  • Change password with automatic key re-wrapping
  • App registration for OAuth client credentials

Encryption Model

Step 1 Key Derivation
User password + random 32-byte salt. Argon2id (64 MB, 3 iterations). Produces a 256-bit wrapping key.
Step 2 Key Wrapping
Random 256-bit master key encrypted with the wrapping key. Envelope (salt + nonce + ciphertext) stored on server. New salt per wrap.
Step 3 Data Encryption
Each changelog entry encrypted with master key. Fresh random nonce per entry. Cached in OS keychain after first unlock.

Second device? Call setup_encryption_existing(password). The SDK downloads the encrypted envelope from the server, derives the wrapping key from the password, unwraps the master key, and caches it in the OS keychain. One password prompt, then it's automatic.

Server API

Method Endpoint Auth Description
POST /api/sync/auth Public Authenticate with MNW credentials + API key. Returns JWT (7-day expiry).
POST /api/sync/push JWT Push up to 500 encrypted changelog entries. Returns new cursor.
POST /api/sync/pull JWT Pull changes since cursor. Returns entries + new cursor + has_more flag.
GET /api/sync/status JWT Total change count and latest cursor for this user/app.
POST /api/sync/devices JWT Register or update a device. Upsert by (app, user, name).
GET /api/sync/devices JWT List all devices for the authenticated user.
PUT /api/sync/keys JWT Store encrypted master key envelope (max 4 KB).
GET /api/sync/keys JWT Retrieve encrypted key envelope + version number.
POST /api/sync/blobs/upload JWT Request presigned S3 upload URL. Returns already_exists flag.
POST /api/sync/blobs/confirm JWT Confirm blob upload. Idempotent.
POST /api/sync/blobs/download JWT Request presigned S3 download URL by content hash.

SDK Surface

Core Types

SyncKitClient — thread-safe (Send + Sync), share via Arc. All methods take &self. Internal parking_lot::RwLock for session and master key. Single reqwest::Client with connection pooling.

Change Model

ChangeEntry { table, op, row_id, timestamp, data } — your app defines table names and row IDs. ChangeOp: Insert, Update, Delete. Data is Option<serde_json::Value>, automatically encrypted before push.

Pull, process, push
// Pull remote changes (auto-decrypted)
let (changes, new_cursor, has_more) = client.pull(device.id, cursor).await?;
for change in &changes {
    match change.op {
        ChangeOp::Insert => db.upsert(&change.table, &change.row_id, &change.data),
        ChangeOp::Update => db.upsert(&change.table, &change.row_id, &change.data),
        ChangeOp::Delete => db.delete(&change.table, &change.row_id),
    }
}

// Push local changes (auto-encrypted)
let local_changes = db.pending_changes_since(last_push);
let cursor = client.push(device.id, local_changes).await?;

Resilience

Blob Workflow

// Upload: hash locally, request URL, upload encrypted, confirm
let resp = client.blob_upload_url(&hash, size).await?;
if !resp.already_exists {
    client.blob_upload(&resp.upload_url, data).await?;  // encrypted on wire
}
client.blob_confirm(&hash, size).await?;

// Download: request URL, download + decrypt
let url = client.blob_download_url(&hash).await?;
let plaintext = client.blob_download(&url).await?;

Blobs are content-addressed by SHA-256 hash. If already_exists returns true, the file is already on the server — skip the upload. Binary encryption uses raw bytes (no base64 wrapping), so overhead is exactly 40 bytes per file regardless of size.

Integration Pattern

App startup (session restore)
// Try keychain first (no password prompt needed)
if client.try_load_key_from_keychain().is_ok() {
    // Master key cached from previous session — ready to sync
} else if client.has_server_key().await? {
    // Existing account on new device — ask for password once
    client.setup_encryption_existing(&password).await?;
} else {
    // First device ever — generate master key, wrap with password
    client.setup_encryption_new(&password).await?;
}

// OAuth2 PKCE also supported (browser-based auth)
let url = client.build_authorize_url(port, state, challenge);
// ... redirect user, receive code ...
let (user_id, app_id) = client.authenticate_with_code(&code, &verifier, port).await?;

What Ships Today

Three Apps Shipping

  • GoingsOn: tasks, projects, events, contacts, email accounts
  • Balanced Breakfast: feeds, read/star state, preferences
  • audiofiles: sample metadata, VFS trees, tags, collections

Developer Experience

  • Typical integration: under a day from zero to syncing
  • Schema-agnostic — no server-side migrations needed
  • OAuth2 PKCE for browser-based auth (desktop + mobile)
  • Password auth also supported for headless / CLI apps

How It Compares

Capability SyncKit Firebase Supabase iCloud Custom
E2E encrypted Yes Partial You build it
Zero-knowledge server Yes You build it
Schema-agnostic Yes doc-store schema (Firestore enforces document model) Postgres CloudKit You build it
Blob storage S3 Yes Yes Yes You build it
Rust SDK Yes You build it
Cross-platform Yes Mobile focus Yes Apple only You build it
No vendor lock-in Yes Yes Yes
Source-available Yes Yes Yours
No per-user pricing Yes Pay per use Pay per use Free (Apple) Your infra
Time to integrate Hours Hours Hours Days Weeks

Pricing

SyncKit is a B2B service: customers are app developers, not end users. Pricing is usage-based on storage and transfer — no per-user fees, no per-seat licenses. Your end users authenticate with their own MNW accounts at no cost to you.

Monthly cost = (GB stored × $0.15) + (burst multiplier × GB stored × $0.03)

Burst is the transfer budget relative to storage. Simple mode uses a default burst of 5×; Builder mode lets you choose any multiplier. Same billing engine for both.

Config / state sync $0.30 / month
Example: 1 GB stored, 5× burst. Settings, read state, light metadata.
Media metadata $15.00 / month
Example: 50 GB stored, 5× burst. Sample libraries, playlists.
Large file sync $60.00 / month
Example: 200 GB stored, 5× burst. Audio, video, courses.

No per-user fees. No per-request fees. No egress charges. Your end users authenticate with their own MNW accounts. Pricing scales with what you actually store and transfer — not with how many users or devices sync through your app. No overages: the bill is known before the billing period starts; at limits, sync degrades but the bill does not change. Source-available under PolyForm Noncommercial 1.0.0.