# SyncKit Client SDK -- Architecture ## Overview The SyncKit Client SDK (`synckit-client`) is a Rust crate that provides end-to-end encrypted cloud sync against the MNW SyncKit server. Consumer apps (GoingsOn, Balanced Breakfast, audiofiles) use this crate to push and pull changelog entries without the server ever seeing plaintext data. Version: 0.2.1. Rust edition: 2021. ## Crate structure ``` src/ lib.rs Crate root. Re-exports public types, quick-start doc example. client.rs SyncKitClient struct and all HTTP methods (auth, push/pull, blobs, devices, encryption setup). Retry logic and token expiry detection live here. crypto.rs Encryption engine. Key derivation (Argon2id), key wrapping (XChaCha20-Poly1305), per-entry encrypt/decrypt, blob encrypt/decrypt, ZeroizeOnDrop guard. error.rs SyncKitError enum (thiserror). 12 variants: Http, Server, Json, NoMasterKey, DecryptionFailed, InvalidEnvelope, Crypto, Base64, NotAuthenticated, TokenExpired, Internal, and Keychain (feature-gated behind `keychain`). keystore.rs OS keychain integration (macOS Keychain, Linux secret-service, Windows Credential Manager) via the `keyring` crate. Feature-gated behind `keychain` (default on). No-op stubs when disabled. types.rs Request/response types matching the server wire protocol. Public types: ChangeEntry, ChangeOp, Device, SyncStatus, BlobUploadUrlResponse. Internal types: auth, push/pull, key, OAuth, blob requests. Public re-exports from lib.rs: SyncKitClient, SyncKitConfig, SessionInfo, ChangeEntry, ChangeOp, Device, SyncStatus, SyncKitError, Result ``` ## Key lifecycle ``` password | v Argon2id (64 MB, 3 iterations, parallelism 1) + random 32-byte salt (stored in envelope) | v wrapping_key (256-bit) | v XChaCha20-Poly1305 encrypt/decrypt | v master_key (256-bit, randomly generated on first device) | v XChaCha20-Poly1305 per-entry / per-blob encrypt/decrypt ``` - **First device**: generates a random master key, wraps it with the password, pushes the encrypted envelope to the server, caches plaintext in OS keychain. - **Subsequent devices**: pulls the encrypted envelope from the server, unwraps with the password, caches in OS keychain. - **Subsequent launches**: loads the master key directly from the OS keychain (no password prompt, no server call). - **Password change**: decrypts master key with old password, re-wraps with new password (fresh random salt), pushes new envelope to server. The master key itself does not change, so existing ciphertext remains valid. ## Authentication flow ### Email/password 1. `authenticate(email, password)` POSTs to `/api/sync/auth` with the app API key. 2. Server validates credentials and returns a JWT, user ID, and app ID. 3. Client stores the session in an internal `Mutex>`. ### OAuth2 PKCE 1. Caller generates a PKCE code verifier and challenge. 2. `build_authorize_url()` constructs the `/oauth/authorize` URL with the challenge. 3. Caller opens the URL in a browser and starts a localhost callback server. 4. After user authorizes, `authenticate_with_code(code, verifier, port)` exchanges the authorization code for a JWT via `/oauth/token`. 5. Session is stored identically to email/password auth. ### Session restoration `restore_session(token, user_id, app_id)` populates the session from stored credentials (e.g., OS keychain) without making any HTTP calls. The caller is responsible for checking `is_token_expired()` and re-authenticating if needed. ### Session utilities - `session_info()` returns `Option` (token, user_id, app_id) without making HTTP calls. Returns `None` if not authenticated. - `is_token_expired()` checks the JWT `exp` claim with a 30-second buffer. Returns `true` if no session exists or the token is about to expire. - `clear_session()` clears in-memory session and master key. Does not affect OS keychain (call `keystore::delete_key` separately). - `config()` returns a reference to the `SyncKitConfig`. ## Encryption setup flow ``` has_server_key()? | +-- false --> setup_encryption_new(password) | Generate master key, wrap, push envelope, cache in keychain | +-- true --> setup_encryption_existing(password) Pull envelope, unwrap with password, cache in keychain ``` On subsequent launches, `try_load_key_from_keychain()` loads the master key from the OS keychain without any server interaction or password prompt. ## Push/Pull protocol ### Push 1. Caller provides a `Vec` with plaintext `data` fields. 2. Client serializes each `data` field to JSON bytes, encrypts with the master key (XChaCha20-Poly1305, random nonce), and base64-encodes the result. 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID. 4. Server appends entries to the changelog and returns the new cursor (i64). 5. Retries on transient failures (see below). ### Pull 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor. 2. Server returns changelog entries since that cursor, a new cursor, and a `has_more` flag for pagination. 3. Client decrypts each entry's `data` field and returns plaintext `ChangeEntry` values to the caller. 4. Retries on transient failures. ### Sequence numbers The server assigns a monotonically increasing sequence number (`seq`) to each changelog entry. The cursor is the `seq` of the last entry the client has seen. Pulling with cursor 0 fetches from the beginning. ## Blob encryption Binary blobs (files, images, audio samples) are encrypted client-side before upload: 1. `blob_upload_url(hash, size)` requests a presigned S3 PUT URL from the server. If the blob already exists (content-addressed by hash), no upload is needed. 2. `blob_upload(presigned_url, data)` encrypts the plaintext bytes with the master key and PUTs the ciphertext to S3. 3. `blob_confirm(hash, size)` tells the server the upload completed. 4. `blob_download_url(hash)` requests a presigned GET URL. 5. `blob_download(presigned_url)` fetches the ciphertext from S3 and decrypts it. Blob encryption uses `encrypt_bytes`/`decrypt_bytes` (raw bytes, no base64) to avoid the ~33% base64 overhead on large files. Encryption overhead is fixed at 40 bytes per blob: 24-byte XChaCha20 nonce + 16-byte Poly1305 authentication tag. ## Keychain integration The `keychain` feature (default on) uses the `keyring` crate (v3) to store the master key in the OS credential store: - **macOS**: Keychain Services - **Linux**: secret-service (D-Bus Secret Service API) - **Windows**: Windows Credential Manager Keychain entries are keyed by `synckit:` (service) and `` (account). When the feature is disabled, store/load/delete are no-ops that return `Ok`. ## Retry and resilience Push and pull use `retry_request()` with exponential backoff: - **Max retries**: 3 - **Delays**: 1s, 2s, 4s (BASE_DELAY * 2^attempt) - **Transient errors** (retried): network failures (timeout, DNS, connection refused), server errors (5xx), rate limiting (429) - **Permanent errors** (not retried): client errors (4xx except 429), serialization errors, encryption errors, missing session/key Token expiry is detected client-side by decoding the JWT `exp` claim with a 30-second buffer. If the token is about to expire, the client returns `SyncKitError::TokenExpired` so the caller can re-authenticate before sending a request that would fail with 401. ## Key design decisions ### Why XChaCha20-Poly1305 - 192-bit nonces are large enough to generate randomly without realistic collision risk (unlike AES-GCM's 96-bit nonces). - Removes the need for a nonce counter or nonce-misuse resistance scheme. - Performance is comparable to AES-GCM on modern hardware. ### Why Argon2id - OWASP-recommended for password hashing. Resists both side-channel (Argon2i) and GPU brute-force (Argon2d) attacks. - Parameters (64 MB memory, 3 iterations) meet the OWASP interactive minimum. ### Why random salt per wrap Each `wrap_master_key` call generates a fresh 32-byte random salt. This means re-wrapping with the same password produces a completely different envelope, preventing precomputation attacks and ensuring password changes are cryptographically distinct. ### Why no token refresh The server currently issues short-lived JWTs without refresh tokens. The client detects expiry and returns `TokenExpired`, leaving re-authentication to the caller. This keeps the SDK stateless with respect to refresh logic and avoids storing long-lived credentials in memory. ## Security properties - **Server-zero-knowledge**: The server stores only ciphertext (envelope, sync entries, blobs). It never receives the plaintext master key or user data. - **Key zeroization**: The `ZeroizeOnDrop` wrapper uses volatile writes to clear the master key from memory when the guard is dropped. - **No key material in logs**: Tracing statements log events (key stored, key loaded) but never log key bytes or ciphertext. - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks. - **Envelope versioning**: The key envelope includes a version field (`v: 1`) to support future algorithm upgrades without breaking existing envelopes.