Skip to main content

max / synckit

Extract synckit from the MNW monorepo synckit-client and synckit-config move here from MNW/shared/ so the sync SDK versions and deploys on its own cadence rather than MNW's. The two crates are unchanged; only their home moved. Consumers repoint their path deps, and the Alloy console — which builds in an isolated container and so cannot reach a sibling path — takes synckit-config as a git dependency, which needs no crates.io publish. The server side (the synckit routes in the MNW server) stays put. Splitting that into a standalone service is the separate mnw-strategy-synckit-vps-separation plan, triggered by the first external developer. History for these crates before this commit is in the MNW repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-24 21:29 UTC
Commit: 844128efd3cf7111591ede717742ece7edadf03a
46 files changed, +21824 insertions, -0 deletions
A .gitignore +3
@@ -0,0 +1,3 @@
1 + /target
2 + **/target
3 + Cargo.lock
A README.md +21
@@ -0,0 +1,21 @@
1 + # synckit
2 +
3 + End-to-end-encrypted sync: the client SDK and the local config store.
4 +
5 + Extracted from the MNW monorepo (2026-07-24) so synckit can version and deploy
6 + independently. Two standalone crates, no root workspace (each builds on its own,
7 + matching the ecosystem convention):
8 +
9 + - **`synckit-client`** — the sync SDK: transport, crypto, the declarative
10 + `SyncStore` engine over SQLite. Internal; consumed by path or git dependency,
11 + never published to crates.io.
12 + - **`synckit-config`** — a local key/value settings store with per-key sync
13 + postures. The store half of portable config; usable with no network, so a
14 + no-sync consumer (a TUI remembering a theme) links it without the SDK. The
15 + sync adapter that turns a `ConfigSpec` into a `SyncTable` lives in
16 + `synckit-client` behind its `store` feature.
17 +
18 + The synckit **server** still lives in the MNW server. Splitting it into a
19 + standalone service on its own VPS is a separate, later effort — see the wiki
20 + note `mnw-strategy-synckit-vps-separation`, triggered by the first external
21 + developer.
@@ -0,0 +1,14 @@
1 + /target
2 +
3 + # Environment
4 + .env
5 + .env.*
6 +
7 + # OS
8 + .DS_Store
9 +
10 + # IDE
11 + .idea/
12 + .vscode/
13 + *.swp
14 + *.swo
@@ -0,0 +1,123 @@
1 + [package]
2 + name = "synckit-client"
3 + version = "0.6.0"
4 + edition = "2024"
5 + description = "SyncKit client SDK with end-to-end encryption"
6 + license-file = "LICENSE"
7 +
8 + [features]
9 + default = ["keychain", "store"]
10 + keychain = ["dep:keyring"]
11 + # The higher-level SyncStore engine (owns a rusqlite connection to the app's DB).
12 + # Default-on so app consumers get it for free; a consumer that only needs the
13 + # transport/crypto SDK (e.g. mnw-cli, which sets `default-features = false`) skips
14 + # it and avoids compiling bundled SQLite.
15 + store = ["dep:rusqlite", "dep:synckit-config"]
16 + # Exposes test-only constructors (`set_master_key_raw`, `with_http_client`) that
17 + # bypass key derivation. Enabled automatically for this crate's own test builds
18 + # via the self dev-dependency below; never part of `default`, so a real consumer
19 + # cannot reach a raw-key injection point.
20 + testing = []
21 +
22 + [dependencies]
23 + # Encryption
24 + chacha20poly1305 = "0.11"
25 + argon2 = "0.5"
26 + rand = "0.10"
27 + base64 = "0.22"
28 + zeroize = "1"
29 + # X25519 ECDH — the only asymmetric primitive in the crate, used solely by the
30 + # group-key layer (`identity.rs`) to seal a Group Content Key to a member's public
31 + # key. Pure-Rust (curve25519-dalek), no aws-lc/ring. Deliberately just the curve op:
32 + # the GCK itself is sealed with the XChaCha20-Poly1305 above, not a sealed-box crate.
33 + # `static_secrets` enables the reusable `StaticSecret` (a member's long-lived key).
34 + x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "zeroize"] }
35 +
36 + # PKCE (OAuth2) challenge hashing
37 + sha2 = "0.11"
38 + hex = "0.4"
39 +
40 + # HTTP
41 + # rustls-no-provider: rustls TLS with the OS-native trust store (rustls-platform-verifier),
42 + # no bundled crypto provider — the consuming app installs one process default (ring).
43 + reqwest = { version = "0.13", default-features = false, features = ["json", "rustls-no-provider", "stream", "form", "charset", "http2", "system-proxy"] }
44 + bytes = "1"
45 + tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "fs", "io-util"] }
46 + tokio-stream = "0.1"
47 +
48 + # Serialization
49 + serde = { version = "1", features = ["derive"] }
50 + serde_json = "1"
51 + chrono = { version = "0.4", features = ["serde"] }
52 + uuid = { version = "1", features = ["v4", "serde"] }
53 +
54 + # OS keychain (optional)
55 + keyring = { version = "4", optional = true }
56 +
57 + # URL encoding
58 + urlencoding = "2"
59 +
60 + # Unicode
61 + unicode-normalization = "0.1"
62 +
63 + # Synchronization
64 + parking_lot = "0.12"
65 +
66 + # Error handling & logging
67 + thiserror = "2"
68 + tracing = "0.1"
69 +
70 + # SyncStore engine (feature = "store"). Owns the app's SQLite connection. Bundled
71 + # so no system libsqlite is needed; `functions` to register the `hash_row_id` UDF.
72 + rusqlite = { version = "0.39", features = ["bundled", "functions"], optional = true }
73 + # The local config store (posture types + KV store). The config sync adapter
74 + # (src/store/config.rs) turns a ConfigSpec into a SyncTable. Path dep: sibling in
75 + # the MNW tree, versioned together.
76 + synckit-config = { path = "../synckit-config", optional = true }
77 +
78 + [dev-dependencies]
79 + wiremock = "0.6"
80 + # reqwest is built `rustls-no-provider`, so real consumers install a rustls crypto
81 + # provider at startup (audiofiles installs ring). The test suite has no such app, so
82 + # it installs ring itself before building any client. Dev-only: never in a consumer build.
83 + rustls = { version = "0.23", default-features = false, features = ["ring"] }
84 + # Self dev-dependency: turns on the `testing` feature for this crate's own
85 + # unit/integration test builds without leaking it into a consumer's default set.
86 + # `default-features = false` so it only ADDS `testing` to whatever the test run
87 + # already selected — under a plain `cargo test` the package still builds with its
88 + # own defaults (keychain + store), but `cargo test --no-default-features` can turn
89 + # keychain OFF (making `keystore::store_key` the no-op stub) to run the keychain-
90 + # free rotation orchestration test below.
91 + synckit-client = { path = ".", default-features = false, features = ["testing"] }
92 +
93 + [lints.rust]
94 + unused = "warn"
95 + unreachable_pub = "warn"
96 +
97 + [lints.clippy]
98 + pedantic = { level = "warn", priority = -1 }
99 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
100 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
101 + # else in `pedantic` stays a warning. Keep this block identical across repos.
102 + module_name_repetitions = "allow"
103 + # Doc lints. No docs-completeness push is underway.
104 + missing_errors_doc = "allow"
105 + missing_panics_doc = "allow"
106 + doc_markdown = "allow"
107 + # Numeric casts. Endemic and mostly intentional in size and byte math.
108 + cast_possible_truncation = "allow"
109 + cast_sign_loss = "allow"
110 + cast_precision_loss = "allow"
111 + cast_possible_wrap = "allow"
112 + cast_lossless = "allow"
113 + # Subjective structure and style nags. High churn, low signal.
114 + must_use_candidate = "allow"
115 + too_many_lines = "allow"
116 + struct_excessive_bools = "allow"
117 + similar_names = "allow"
118 + items_after_statements = "allow"
119 + single_match_else = "allow"
120 + # Frequent false-positives in TUI and router-heavy code.
121 + match_same_arms = "allow"
122 + unnecessary_wraps = "allow"
123 + type_complexity = "allow"
@@ -0,0 +1,120 @@
1 + Required Notice: Copyright 2026 Make Creative, LLC (https://makenot.work)
2 +
3 + PolyForm Noncommercial License 1.0.0
4 +
5 + <https://polyformproject.org/licenses/noncommercial/1.0.0>
6 +
7 + Acceptance
8 +
9 + In order to get any license under these terms, you must agree to them as
10 + both strict obligations and conditions to all your licenses.
11 +
12 + Copyright License
13 +
14 + The licensor grants you a copyright license for the software to do
15 + everything you might do with the software that would otherwise infringe
16 + the licensor's copyright in it for any permitted purpose. However, you
17 + may only distribute the software according to Distribution License and
18 + make changes or new works based on the software according to Changes and
19 + New Works License.
20 +
21 + Distribution License
22 +
23 + The licensor grants you an additional copyright license to distribute
24 + copies of the software. Your license to distribute covers distributing
25 + the software with changes and new works permitted by Changes and New
26 + Works License.
27 +
28 + Notices
29 +
30 + You must ensure that anyone who gets a copy of any part of the software
31 + from you also gets a copy of these terms or the URL for them above, as
32 + well as copies of any plain-text lines beginning with "Required Notice:"
33 + that the licensor provided with the software. For example:
34 +
35 + Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
36 +
37 + Changes and New Works License
38 +
39 + The licensor grants you an additional copyright license to make changes
40 + and new works based on the software for any permitted purpose.
41 +
42 + Patent License
43 +
44 + The licensor grants you a patent license for the software that covers
45 + patent claims the licensor can license, or becomes able to license, that
46 + you would infringe by using the software.
47 +
48 + Noncommercial Purposes
49 +
50 + Any noncommercial purpose is a permitted purpose.
51 +
52 + Personal Uses
53 +
54 + Personal use for research, experiment, and testing for the benefit of
55 + public knowledge, personal study, private entertainment, hobby projects,
56 + amateur pursuits, or religious observance, without any anticipated
57 + commercial application, is use for a permitted purpose.
58 +
59 + Noncommercial Organizations
60 +
61 + Use by any charitable organization, educational institution, public
62 + research organization, public safety or health organization,
63 + environmental protection organization, or government institution is use
64 + for a permitted purpose regardless of the source of funding or
65 + obligations resulting from the funding.
66 +
67 + Fair Use
68 +
69 + You may have "fair use" rights for the software under the law. These
70 + terms do not limit them.
71 +
72 + No Other Rights
73 +
74 + These terms do not allow you to sublicense or transfer any of your
75 + licenses to anyone else, or prevent the licensor from granting licenses
76 + to anyone else. These terms do not imply any other licenses.
77 +
78 + Patent Defense
79 +
80 + If you make any written claim that the software infringes or contributes
81 + to infringement of any patent, your patent license for the software
82 + granted under these terms ends immediately. If your company makes such a
83 + claim, your patent license ends immediately for work on behalf of your
84 + company.
85 +
86 + Violations
87 +
88 + The first time you are notified in writing that you have violated any of
89 + these terms, or done anything with the software not covered by your
90 + licenses, your licenses can nonetheless continue if you come into full
91 + compliance with these terms, and take practical steps to correct past
92 + violations, within 32 days of receiving notice. Otherwise, all your
93 + licenses end immediately.
94 +
95 + No Liability
96 +
97 + As far as the law allows, the software comes as is, without any warranty
98 + or condition, and the licensor will not be liable to you for any damages
99 + arising out of these terms or the use or nature of the software, under
100 + any kind of legal claim.
101 +
102 + Definitions
103 +
104 + The licensor is the individual or entity offering these terms, and the
105 + software is the software the licensor makes available under these terms.
106 +
107 + You refers to the individual or entity agreeing to these terms.
108 +
109 + Your company is any legal entity, sole proprietorship, or other kind of
110 + organization that you work for, plus all organizations that have control
111 + over, are under the control of, or are under common control with that
112 + organization. Control means ownership of substantially all the assets of
113 + an entity, or the power to direct its management and policies by vote,
114 + contract, or otherwise. Control can be direct or indirect.
115 +
116 + Your licenses are all the licenses granted to you for the software under
117 + these terms.
118 +
119 + Use means anything you do with the software requiring one of your
120 + licenses.
@@ -0,0 +1,79 @@
1 + # synckit-client
2 +
3 + End-to-end encrypted cloud sync SDK for Rust applications, built for the [MNW SyncKit](https://makenot.work) server.
4 +
5 + All row data and binary blobs are encrypted client-side (XChaCha20-Poly1305) before leaving the device. The server only ever stores ciphertext.
6 +
7 + ## Features
8 +
9 + - **E2E encryption** -- XChaCha20-Poly1305 with Argon2id key derivation (64 MB, 3 iterations)
10 + - **OS keychain integration** -- master key cached in macOS Keychain, Linux secret-service, or Windows Credential Manager
11 + - **Blob encryption** -- binary files encrypted with fixed 40-byte overhead (no base64 expansion)
12 + - **Retry with backoff** -- transient failures (network, 5xx, 429) retried up to 3 times with exponential delay
13 + - **OAuth2 PKCE** -- browser-based auth flow alongside email/password
14 + - **Token expiry detection** -- client-side JWT check with 30-second buffer
15 +
16 + ## Quick Start
17 +
18 + ```rust
19 + use synckit_client::{SyncKitClient, SyncKitConfig, ChangeEntry, ChangeOp};
20 + use chrono::Utc;
21 +
22 + let client = SyncKitClient::new(SyncKitConfig {
23 + server_url: "https://makenot.work".into(),
24 + api_key: "your-api-key".into(),
25 + });
26 +
27 + // Authenticate. The third arg is the developer-defined SDK key for billing
28 + // attribution — typically one key per workspace/org/end-user.
29 + let (user_id, app_id) = client.authenticate("user@example.com", "password", "workspace-42").await?;
30 +
31 + // Set up encryption (first device)
32 + client.setup_encryption_new("password").await?;
33 +
34 + // Register this device
35 + let device = client.register_device("MacBook Pro", "macos").await?;
36 +
37 + // Push encrypted data
38 + let cursor = client.push(device.id, vec![
39 + ChangeEntry {
40 + table: "tasks".into(),
41 + op: ChangeOp::Insert,
42 + row_id: uuid::Uuid::new_v4().to_string(),
43 + timestamp: Utc::now(),
44 + data: Some(serde_json::json!({"title": "Buy milk"})),
45 + },
46 + ]).await?;
47 +
48 + // Pull and auto-decrypt
49 + let (changes, cursor, has_more) = client.pull(device.id, 0).await?;
50 + ```
51 +
52 + ## Crate Structure
53 +
54 + | File | Role |
55 + |------|------|
56 + | `lib.rs` | Crate root, re-exports, doc example |
57 + | `client.rs` | `SyncKitClient` -- HTTP methods, retry logic, token expiry detection |
58 + | `crypto.rs` | Key derivation (Argon2id), key wrapping, per-entry and per-blob encrypt/decrypt |
59 + | `error.rs` | `SyncKitError` enum (10 variants: HTTP, server, JSON, crypto, keychain, auth) |
60 + | `keystore.rs` | OS keychain read/write/delete, feature-gated with no-op stubs |
61 + | `types.rs` | Wire protocol types (`ChangeEntry`, `ChangeOp`, `Device`, `SyncStatus`) |
62 +
63 + ## Feature Flags
64 +
65 + | Flag | Default | Description |
66 + |------|---------|-------------|
67 + | `keychain` | on | OS keychain storage via the `keyring` crate. Disable with `default-features = false` for headless/CI environments. |
68 +
69 + ## Security Properties
70 +
71 + - **Server-zero-knowledge** -- the server never receives the plaintext master key or user data
72 + - **Key zeroization** -- volatile writes clear the master key from memory on drop
73 + - **Random salt per wrap** -- re-wrapping with the same password produces a different envelope
74 + - **Minimum ciphertext validation** -- decryption rejects inputs shorter than 40 bytes (24-byte nonce + 16-byte tag)
75 + - **No key material in logs** -- tracing events never include key bytes or ciphertext
76 +
77 + ## License
78 +
79 + PolyForm Noncommercial 1.0.0
@@ -0,0 +1,13 @@
1 + # A raw `Response::json`/`text`/`bytes` reads the whole body with no size limit,
2 + # so a hostile or buggy server can stream an arbitrarily large body and OOM the
3 + # client (worst case: the public, unauthenticated OTA updater check). Every body
4 + # read must go through `client::helpers::read_body_capped` (or its `read_json_capped`
5 + # / `read_text_capped` wrappers), which drains `bytes_stream()` under a hard cap.
6 + # `blob_download` streams `bytes_stream()` directly under its own 4 GiB cap.
7 + # This seal makes a new uncapped read a compile-time (clippy) error, not a latent
8 + # OOM lever someone has to remember to avoid.
9 + disallowed-methods = [
10 + { path = "reqwest::Response::json", reason = "unbounded body read; use client::helpers::read_json_capped" },
11 + { path = "reqwest::Response::text", reason = "unbounded body read; use client::helpers::read_text_capped" },
12 + { path = "reqwest::Response::bytes", reason = "unbounded body read; use client::helpers::read_body_capped (or bytes_stream() under an explicit cap)" },
13 + ]
@@ -0,0 +1,944 @@
1 + # SyncKit Client SDK, Architecture
2 +
3 + ## Overview
4 +
5 + The SyncKit Client SDK (`synckit-client`) is a Rust crate that provides
6 + end-to-end encrypted cloud sync against the MNW SyncKit server. Consumer apps
7 + (GoingsOn, Balanced Breakfast, audiofiles) use this crate to push and pull
8 + changelog entries without the server ever seeing plaintext data.
9 +
10 + Version: 0.5.0.
11 +
12 + ## Crate structure
13 +
14 + ```
15 + src/
16 + lib.rs Crate root. Re-exports public types, quick-start doc example.
17 + client/ SyncKitClient struct and all HTTP methods, split across
18 + submodules (auth, sync, blob, encryption, rotation,
19 + subscribe, subscription, helpers). Retry logic and token
20 + expiry detection live here.
21 + crypto.rs Encryption engine. Key derivation (Argon2id), key wrapping
22 + (XChaCha20-Poly1305), per-entry encrypt/decrypt, blob
23 + encrypt/decrypt, ZeroizeOnDrop guard.
24 + error.rs SyncKitError enum (thiserror, #[non_exhaustive]): Http, Server,
25 + Json, NoMasterKey, DecryptionFailed, IntegrityFailed,
26 + InvalidEnvelope, Crypto, Base64, NotAuthenticated, TokenExpired,
27 + InvalidArgument, Internal, and Keychain (feature-gated behind
28 + `keychain`).
29 + keystore.rs OS keychain integration (macOS Keychain, Linux secret-service,
30 + Windows Credential Manager) via the `keyring` crate.
31 + Feature-gated behind `keychain` (default on). No-op stubs
32 + when disabled.
33 + types.rs Request/response types matching the server wire protocol.
34 + Public types: ChangeEntry, ChangeOp, Device, SyncStatus,
35 + BlobUploadUrlResponse. Internal types: auth, push/pull, key,
36 + OAuth, blob requests.
37 +
38 + Public re-exports from lib.rs:
39 + SyncKitClient, SyncKitConfig, SessionInfo,
40 + ChangeEntry, ChangeOp, Device, SyncStatus,
41 + SyncKitError, Result
42 + ```
43 +
44 + ## Key lifecycle
45 +
46 + ```
47 + password
48 + |
49 + v
50 + Argon2id (64 MB, 3 iterations, parallelism 1)
51 + + random 32-byte salt (stored in envelope)
52 + |
53 + v
54 + wrapping_key (256-bit)
55 + |
56 + v
57 + XChaCha20-Poly1305 encrypt/decrypt
58 + |
59 + v
60 + master_key (256-bit, randomly generated on first device)
61 + |
62 + v
63 + XChaCha20-Poly1305 per-entry / per-blob encrypt/decrypt
64 + ```
65 +
66 + - **First device**: generates a random master key, wraps it with the password,
67 + pushes the encrypted envelope to the server, caches plaintext in OS keychain.
68 + - **Subsequent devices**: pulls the encrypted envelope from the server, unwraps
69 + with the password, caches in OS keychain.
70 + - **Subsequent launches**: loads the master key directly from the OS keychain
71 + (no password prompt, no server call).
72 + - **Password change**: decrypts master key with old password, re-wraps with new
73 + password (fresh random salt), pushes new envelope to server. The master key
74 + itself does not change, so existing ciphertext remains valid.
75 +
76 + ## Authentication flow
77 +
78 + ### Email/password
79 +
80 + 1. `authenticate(email, password)` POSTs to `/api/sync/auth` with the app API key.
81 + 2. Server validates credentials and returns a JWT, user ID, and app ID.
82 + 3. Client stores the session in an internal `Mutex<Option<Session>>`.
83 +
84 + ### OAuth2 PKCE
85 +
86 + 1. Caller generates a PKCE code verifier and challenge.
87 + 2. `build_authorize_url()` constructs the `/oauth/authorize` URL with the challenge.
88 + 3. Caller opens the URL in a browser and starts a localhost callback server.
89 + 4. After user authorizes, `authenticate_with_code(code, verifier, port)` exchanges
90 + the authorization code for a JWT via `/oauth/token`.
91 + 5. Session is stored identically to email/password auth.
92 +
93 + ### Session restoration
94 +
95 + `restore_session(token, user_id, app_id)` populates the session from stored
96 + credentials (e.g., OS keychain) without making any HTTP calls. The caller is
97 + responsible for checking `is_token_expired()` and re-authenticating if needed.
98 +
99 + ### Session utilities
100 +
101 + - `session_info()` returns `Option<SessionInfo>` (token, user_id, app_id)
102 + without making HTTP calls. Returns `None` if not authenticated.
103 + - `is_token_expired()` checks the JWT `exp` claim with a 30-second buffer.
104 + Returns `true` if no session exists or the token is about to expire.
105 + - `clear_session()` clears in-memory session and master key. Does not
106 + affect OS keychain (call `keystore::delete_key` separately).
107 + - `config()` returns a reference to the `SyncKitConfig`.
108 +
109 + ## Encryption setup flow
110 +
111 + ```
112 + has_server_key()?
113 + |
114 + +-- false --> setup_encryption_new(password)
115 + | Generate master key, wrap, push envelope, cache in keychain
116 + |
117 + +-- true --> setup_encryption_existing(password)
118 + Pull envelope, unwrap with password, cache in keychain
119 + ```
120 +
121 + On subsequent launches, `try_load_key_from_keychain()` loads the master key
122 + from the OS keychain without any server interaction or password prompt.
123 +
124 + ## Push/Pull protocol
125 +
126 + ### The HLC envelope
127 +
128 + Every change, **including Deletes**, is encrypted as a JSON *envelope*, not as
129 + the bare row data:
130 +
131 + ```json
132 + { "__skver": 2, "__skhlc": { "wall_ms": …, "counter": …, "node": "…" }, "data": <row|null> }
133 + ```
134 +
135 + Encrypting the envelope (rather than just the row) is what carries the hybrid
136 + logical clock (HLC) inside the E2E ciphertext, so the server can order entries by
137 + `seq` but never sees or orders by the clock. A Delete has `data: null` but still
138 + seals an envelope, so its HLC travels too. On decrypt, a payload whose embedded
139 + `__skhlc` parses as an `Hlc` is treated as an envelope; anything else is a legacy
140 + bare row whose HLC is synthesized from `node` + the entry's `client_timestamp`.
141 +
142 + ### Wire version tag and AAD binding
143 +
144 + The encrypted `data` string is **position-bound**. v2 ciphertext is prefixed
145 + `sk2:` and sealed with AEAD associated data `aad_for_entry(table, row_id)` =
146 + `table` `0x1f` `row_id`. Because the tag is read before decryption, the reader
147 + always knows which AAD to apply:
148 +
149 + - `sk2:`-tagged → open with the entry's `(table, row_id)` AAD.
150 + - untagged (legacy, pre-v2) → open with empty AAD.
151 +
152 + This means a malicious or compromised server cannot relocate a valid ciphertext
153 + to a different row or table: the AAD no longer matches and the open fails closed.
154 + Legacy and v2 ciphertext coexist with no flag-day; key rotation re-encrypts
155 + legacy entries into the v2 form opportunistically.
156 +
157 + The AAD is never passed as raw bytes. Callers name a context, `AeadContext::Entry
158 + { table, row_id }` or `AeadContext::Blob { hash }`, and the crypto layer derives
159 + the AAD from it. The byte-level builder is private, so an empty or mismatched AAD
160 + is not expressible at the call site.
161 +
162 + ### Envelope version dispatch (`__skver`)
163 +
164 + The decrypted entry payload is an envelope `{ __skver: 2, __skhlc, data }`.
165 + Splitting it back into `(hlc, data)` dispatches on the explicit `__skver` tag via
166 + a typed `WireVersion`: an unknown future version is a hard error, not a silent
167 + fall-through to a bare-row read. A tag-less payload carrying an embedded `__skhlc`
168 + is a gen-1 envelope; anything else is a legacy bare row.
169 +
170 + ### Chunked blob format (`sk3:`)
171 +
172 + Blobs use a chunked AEAD format so a reader decrypts and verifies one chunk at a
173 + time. Layout: `sk3:` `version(u8=3)` `chunk_size(u32 LE)` `total_len(u64 LE)`
174 + followed by sealed 1 MiB chunks. Each chunk's AAD binds `(content_hash,
175 + chunk_index, chunk_count)`, so reorder, truncation, duplication, and cross-blob
176 + substitution all fail closed. `blob_download` streams the body and decrypts
177 + chunk-by-chunk (peak memory is the plaintext plus one in-flight chunk, not
178 + ciphertext *and* plaintext together); legacy `sk2:`/untagged blobs still decrypt
179 + whole-buffer with no flag-day.
180 +
181 + ### Push
182 +
183 + 1. Caller provides a `Vec<ChangeEntry>` with plaintext `data` fields and an HLC.
184 + 2. Client wraps each `(hlc, data)` in an envelope, encrypts it with the master
185 + key (XChaCha20-Poly1305, random nonce, `(table, row_id)` AAD), and emits the
186 + `sk2:`-tagged base64 string.
187 + 3. The encrypted payload is POSTed to `/api/sync/push` with the device ID and a
188 + client-generated `batch_id` (idempotent push: a replayed batch returns the
189 + existing cursor).
190 + 4. Server appends entries to the changelog and returns the new cursor (i64).
191 + 5. Retries on transient failures (see below).
192 +
193 + ### Pull
194 +
195 + 1. Client POSTs to `/api/sync/pull` with the device ID and last-known cursor.
196 + 2. Server returns changelog entries since that cursor, a new cursor, and a
197 + `has_more` flag for pagination.
198 + 3. Client decrypts each entry's envelope (auto-detecting the wire version) and
199 + returns plaintext `ChangeEntry` values, HLC included, to the caller.
200 + 4. Retries on transient failures.
201 +
202 + **Caller contract.** The SDK does not persist the cursor or dedup re-delivered
203 + changes. Persist the returned cursor only *after* applying the returned changes
204 + (same transaction where possible), and make apply idempotent per
205 + `(table, row_id, hlc)`, a reconnect can re-deliver a batch.
206 +
207 + ### Conflict resolution
208 +
209 + Conflicts are resolved client-side (the server cannot read plaintext).
210 + `resolve_lww` orders by HLC, operation-agnostically: the higher `(wall_ms,
211 + counter, node)` wins, so a newer edit beats an older delete and vice-versa, and
212 + every device converges on the same winner. An *exactly* equal HLC normally means
213 + a same-device echo; if two installs share a `node` UUID (cloned config), the tie
214 + is broken deterministically on the canonical payload bytes so both devices still
215 + converge.
216 +
217 + `detect_conflicts` only flags rows with an *un-pushed* local edit. Its clean set
218 + is returned as an opaque `CleanChanges`, not a bare `Vec`: the only way to read
219 + the applicable entries out is `gated(committed_hlc)`, which drops any clean change
220 + older-or-equal to the row's committed HLC. (`row_keys()` lets the caller pre-fetch
221 + those clocks first.) This makes "apply a clean change without checking the
222 + committed clock", which would let an older remote edit clobber a newer local
223 + value, unrepresentable rather than a contract the caller has to remember.
224 +
225 + ### Sequence numbers
226 +
227 + The server assigns a monotonically increasing sequence number (`seq`) to each
228 + changelog entry. The cursor is the `seq` of the last entry the client has seen.
229 + Pulling with cursor 0 fetches from the beginning.
230 +
231 + ## Blob encryption
232 +
233 + Binary blobs (files, images, audio samples) are encrypted client-side before
234 + upload:
235 +
236 + 1. `blob_upload_url(hash, plaintext_size)` requests a presigned S3 PUT URL from
237 + the server. If the blob already exists (content-addressed by hash), no upload
238 + is needed. The declared size is converted to the ciphertext length before it
239 + is sent, because the server signs it as `Content-Length` and the PUT carries
240 + sealed bytes.
241 + 2. `blob_upload(hash, presigned_url, data)` encrypts the plaintext bytes with the
242 + master key, **binding the content hash as AEAD associated data**, and PUTs the
243 + ciphertext to S3.
244 + 3. `blob_confirm(hash, size)` tells the server the upload completed.
245 +
246 + Steps 1 and 2 are the in-memory path, for bytes with no file behind them. Every
247 + consumer app instead calls `blob_upload_streaming(hash, path)` (below), which
248 + covers the same ground with bounded memory and no size ceiling, then step 3.
249 + 4. `blob_download_url(hash)` requests a presigned GET URL.
250 + 5. `blob_download(expected_hash, presigned_url)` fetches the ciphertext, decrypts
251 + with the hash as AAD, then **re-verifies** that the plaintext hashes to
252 + `expected_hash` (returning `IntegrityFailed` otherwise). The AEAD tag proves
253 + the bytes were not forged; the re-hash proves the server served the bytes for
254 + *this* address, closing the substitution/rollback gap.
255 +
256 + Blob encryption uses `encrypt_bytes_aad`/`decrypt_bytes_aad` (raw bytes, no
257 + base64) to avoid the ~33% base64 overhead on large files. v2 overhead is 44 bytes
258 + per blob: a 4-byte `sk2:` wire tag + 24-byte XChaCha20 nonce + 16-byte Poly1305
259 + tag.
260 +
261 + ### Large blobs: the multipart path
262 +
263 + Steps 1 and 2 buffer the whole ciphertext, and the server refuses a one-shot PUT
264 + above its own ceiling. For a blob already on disk, `blob_upload_streaming(hash,
265 + path)` replaces both with a multipart session and holds one part at a time:
266 +
267 + 1. It computes the exact ciphertext length from the file size alone
268 + (`blob_encrypted_len`) and declares it to `blobs/multipart/start`, which
269 + returns the part geometry both sides then derive identically.
270 + 2. It seals 1 MiB v3 chunks straight out of the file, staging them until a full
271 + part is ready, and PUTs each part to a presigned URL signed for that exact
272 + `Content-Length`. Each presigned part URL is requested on its own, one per
273 + part, since a part's checksum only exists once it has been sealed and only one
274 + part is ever held in memory.
275 + 3. It recomputes the SHA-256 as it reads, so a file that changed since the
276 + caller hashed it fails rather than landing under a stale content address.
277 + 4. Any failure past `start` aborts the session, since uploaded parts bill until
278 + released. `blob_confirm` is still the caller's next step, unchanged.
279 +
280 + It works at any size (a small blob is a single part), so prefer it whenever the
281 + blob is file-backed. Not yet resumable across a process restart: the session id
282 + is not persisted.
283 +
284 + ## Keychain integration
285 +
286 + The `keychain` feature (default on) uses the `keyring` crate (v4) to store
287 + the master key in the OS credential store:
288 +
289 + - **macOS**: Keychain Services
290 + - **Linux**: secret-service (D-Bus Secret Service API)
291 + - **Windows**: Windows Credential Manager
292 +
293 + Keychain entries are keyed by `synckit:<app_id>` (service) and `<user_id>`
294 + (account). When the feature is disabled, store/load/delete are no-ops that
295 + return `Ok`.
296 +
297 + ## Retry and resilience
298 +
299 + Push and pull use `retry_request()` with exponential backoff:
300 +
301 + - **Max retries**: 3
302 + - **Delays**: 1s, 2s, 4s (BASE_DELAY * 2^attempt)
303 + - **Transient errors** (retried): network failures (timeout, DNS, connection
304 + refused), server errors (5xx), rate limiting (429)
305 + - **Permanent errors** (not retried): client errors (4xx except 429),
306 + serialization errors, encryption errors, missing session/key
307 +
308 + Token expiry is detected client-side by decoding the JWT `exp` claim with a
309 + 30-second buffer. If the token is about to expire, the client returns
310 + `SyncKitError::TokenExpired` so the caller can re-authenticate before sending
311 + a request that would fail with 401.
312 +
313 + ## Key design decisions
314 +
315 + ### Why XChaCha20-Poly1305
316 +
317 + - 192-bit nonces are large enough to generate randomly without realistic
318 + collision risk (unlike AES-GCM's 96-bit nonces).
319 + - Removes the need for a nonce counter or nonce-misuse resistance scheme.
320 + - Performance is comparable to AES-GCM on modern hardware.
321 +
322 + ### Why Argon2id
323 +
324 + - OWASP-recommended for password hashing. Resists both side-channel (Argon2i)
325 + and GPU brute-force (Argon2d) attacks.
326 + - Parameters (64 MB memory, 3 iterations) meet the OWASP interactive minimum.
327 +
328 + ### Why random salt per wrap
329 +
330 + Each `wrap_master_key` call generates a fresh 32-byte random salt. This means
331 + re-wrapping with the same password produces a completely different envelope,
332 + preventing precomputation attacks and ensuring password changes are
333 + cryptographically distinct.
334 +
335 + ### Why no token refresh
336 +
337 + The server currently issues short-lived JWTs without refresh tokens. The client
338 + detects expiry and returns `TokenExpired`, leaving re-authentication to the
339 + caller. This keeps the SDK stateless with respect to refresh logic and avoids
340 + storing long-lived credentials in memory.
341 +
342 + ## Security properties
343 +
344 + - **Server-zero-knowledge**: The server stores only ciphertext (envelope, sync
345 + entries, blobs). It never receives the plaintext master key or user data.
346 + - **Key zeroization**: The `ZeroizeOnDrop` wrapper uses volatile writes to
347 + clear the master key from memory when the guard is dropped.
348 + - **No key material in logs**: Tracing statements log events (key stored, key
349 + loaded) but never log key bytes or ciphertext.
350 + - **Minimum ciphertext size**: Decryption rejects inputs shorter than 40 bytes
351 + (24-byte nonce + 16-byte tag), preventing trivial malformed-input attacks.
352 + - **Ciphertext position binding (AAD)**: v2 entry ciphertext is sealed with
353 + `(table, row_id)` as AEAD associated data, and blob ciphertext with the content
354 + hash; downloaded blobs are additionally re-hashed and rejected if they do not
355 + match their requested address. So the untrusted server cannot relocate or
356 + substitute a valid ciphertext without the open (or the hash check) failing
357 + closed. See "Wire version tag and AAD binding".
358 + - **Envelope versioning**: The key envelope includes a version field (`v: 1`)
359 + and travels with its Argon2 cost parameters, so the work factor can be raised
360 + later without breaking existing envelopes.
361 +
362 + ---
363 +
364 + # The `SyncStore` higher-level helper
365 +
366 + > Status: shipped in 0.6 behind the default-on `store` feature (`src/store/`).
367 + > Everything above describes the base SDK (transport + crypto + HLC + conflict
368 + > primitives); this section describes the layer *above* those primitives that
369 + > absorbs the SQLite plumbing the three first-party apps otherwise hand-write.
370 + > The design was reverse-engineered from the GoingsOn, audiofiles, and Balanced
371 + > Breakfast sync services; a consumer that only needs the transport/crypto SDK
372 + > (e.g. mnw-cli) sets `default-features = false` and skips it.
373 +
374 + ## Motivation
375 +
376 + Without this layer the SDK stops at "encrypt/decrypt a `Vec<ChangeEntry>` and
377 + move it over the wire," and each consuming app writes ~700–1,000 lines of
378 + *identical* engine on top of it: a `sync_changelog` + `sync_state` schema, per-table triggers, a
379 + drain-loop push, a cursor-loop pull, an FK-ordered apply with JSON→SQL binding,
380 + HLC stamping, an initial snapshot, retention/cleanup, and a scheduler
381 + (tick + SSE + backoff + status events). Measured across the three apps this is
382 + the overwhelming majority of their sync code, and it is mechanical.
383 +
384 + The single largest boilerplate source is **column-list triplication**: every
385 + syncable table names its synced columns three or four times, in the trigger DDL
386 + (`json_object(...)`), in the initial-snapshot projection, and in the apply-side
387 + whitelist, kept in agreement only by hand and a round-trip drift test. When they
388 + drift, a column silently arrives NULL (this is a real shipped bug class). One
389 + declared column list should generate all of them.
390 +
391 + The goal of `SyncStore` is: an app **declares its tables and policy once**, and
392 + the engine owns everything mechanical. This is the "simple" pillar of the product
393 + (see the private roadmap), the differentiators (private, secure, economical) are
394 + already in the 0.6 primitives; this layer is what makes them easy to adopt.
395 +
396 + ## What is generic (absorbed) vs. what is policy (declared)
397 +
398 + The three apps agree, byte-for-byte in spirit, on all of this, it moves into the
399 + engine:
400 +
401 + - `sync_state` KV + the transactional `applying_remote` echo-suppression flag.
402 + - `sync_changelog` schema, the `pushed` flag, cursor bookkeeping.
403 + - Push: HLC-stamp unpushed rows, batch ≤500, map to `ChangeEntry`, `client.push`,
404 + mark pushed, skip-and-retain corrupt/unknown-op rows, drain until empty.
405 + - Pull: paginated `client.pull_rich` loop, apply-then-persist-cursor (the SDK's
406 + documented crash-safe contract), auto-decrypt.
407 + - Apply: partition into upserts/deletes, iterate the declared table order
408 + (parents-first upserts, children-first deletes), JSON→SQL type coercion,
409 + per-row constraint-violation *skip-and-log* (a poison row never wedges the
410 + cursor), HLC gating against committed clocks, `resolve_lww` for true conflicts.
411 + - Initial snapshot, `cleanup_changelog` (prune pushed > N days), changelog
412 + retention cap, pending-change count.
413 + - The scheduler skeleton: 60 s tick raced against the SSE `changed` stream, the
414 + auth/key/enabled gate chain, a shared sync mutex, exponential backoff, and the
415 + 402 → subscription-required special case.
416 + - Device registration and the `perform_sync` orchestration order.
417 +
418 + Everything an app genuinely varies is small and enumerable. It becomes a
419 + declarative `SyncSchema` plus a couple of trait hooks:
420 +
421 + | Policy | Seen in | Expressed as |
422 + |---|---|---|
423 + | Which tables, their columns, FK order | all | `SyncSchema { tables: [SyncTable…] }`, declaration order = FK order |
424 + | Primary key shape (single / composite) | BB tag tables, AF `tags` | `PrimaryKey::{Single, Composite}` |
425 + | Partial-column, UPDATE-only sync | BB `feed_items` (is_read/is_starred) | `SyncMode::PartialUpdate { set }` |
426 + | Ignore remote deletes | BB `feed_items` | `DeleteMode::Ignore` |
427 + | Soft-delete (tombstone) instead of hard | AF `samples.deleted_at` (CASCADE safety) | `DeleteMode::Tombstone { column }` |
428 + | Preserve local-only columns on upsert | GO `email_accounts` credentials | `preserve_local: &[col]` |
429 + | Column defaults on first insert | GO `password=''` for NOT NULL | `insert_defaults: &[(col, val)]` |
430 + | Row references an *unsynced* table (relax FK) | GO `tasks.source_email_id` | `references_unsynced: true` |
431 + | Row-id privacy hashing on the wire | AF content tables (`hash_row_id(salt,…)`) | `RowIdScheme::Hashed` |
432 + | Exclude some rows/keys from sync | AF `user_config` `sync_*`, `loose_files` | `exclude_where: Option<&str>` (a SQL predicate, see AF example; must be SQL because it also compiles into the generated trigger's WHEN clause, not just the apply guard) |
433 + | Conflict model | GO/AF (HLC), BB (server-order) | `ConflictStrategy::{HybridLogicalClock, ServerOrder}` |
434 + | Blob-bearing tables | AF only | `BlobPolicy` trait (below) |
435 + | Retention cap, cleanup window, batch, interval | all (different numbers) | `SyncConfig` |
436 + | Device-name source, status sink, error mapping | all (framework-specific) | `SyncObserver` trait |
437 +
438 + User scoping (a multi-tenant `WHERE user_id = ?` filter) is deliberately **not**
439 + an engine-level concern. The GoingsOn worked example (below) confirmed the single-
440 + user desktop apps never scope the *changelog* by user, the only place a user
441 + filter appears is inside GO's blob queries, which is policy-local to its
442 + `BlobPolicy`. A changelog-level user filter belongs to the future multi-tenant
443 + work (a third-party-phase prerequisite), not this layer.
444 +
445 + ## The declarative schema
446 +
447 + ```rust
448 + // Abridged from the shipped `store::schema` types. The real fields are private
449 + // (`pub(crate)`) and constructed through the `SyncSchema`/`SyncTable` builders
450 + // (`.table(...)`, `.pk(&[...])`, `.conflict(...)`); shown here public for shape.
451 + pub struct SyncSchema {
452 + /// Declaration order IS the FK order: parents first. The engine upserts in
453 + /// this order and deletes in the reverse. No hand-maintained reversed list.
454 + pub tables: Vec<SyncTable>,
455 + /// Schema-wide conflict model (HLC or server-order).
456 + pub conflict: ConflictStrategy,
457 + }
458 +
459 + pub struct SyncTable {
460 + pub name: &'static str,
461 + /// The one column whitelist. Drives generated triggers, the snapshot
462 + /// projection, and apply-side binding — killing the triplication.
463 + pub columns: &'static [&'static str],
464 + /// PK column(s): one element = single key, more = composite (concatenated
465 + /// with ':' for the wire row-id). A slice, not a Single/Composite enum —
466 + /// simpler and matches the `.pk(&[...])` builder. Defaults to `["id"]`.
467 + pub pk: &'static [&'static str],
468 + pub mode: SyncMode,
469 + pub deletes: DeleteMode,
470 + pub row_id: RowIdScheme,
471 + /// Columns to leave untouched on upsert (secrets that stay per-device).
472 + pub preserve_local: &'static [&'static str],
473 + /// Values to inject only on first INSERT (satisfy NOT NULL on preserved cols).
474 + pub insert_defaults: &'static [(&'static str, &'static str)],
475 + /// This table carries FKs into a table that is not synced; relax FK
476 + /// enforcement while applying it.
477 + pub references_unsynced: bool,
478 + /// Optional SQL predicate that excludes rows from sync (AF config-key
479 + /// denylist). A SQL string, not a closure, because it also compiles into
480 + /// the generated trigger's WHEN clause — not just the apply-side guard.
481 + pub exclude_where: Option<&'static str>,
482 + }
483 +
484 + pub enum SyncMode {
485 + /// Upsert every whitelisted column (INSERT … ON CONFLICT DO UPDATE).
486 + Full,
487 + /// UPDATE only `set` columns where PK matches; never insert, never replace.
488 + /// BB feed_items: sync just is_read/is_starred, never the article body.
489 + PartialUpdate { set: &'static [&'static str] },
490 + }
491 +
492 + pub enum DeleteMode {
493 + Hard,
494 + /// Remote deletes are dropped on the floor (BB feed_items — content
495 + /// re-fetches from source).
496 + Ignore,
497 + /// Apply as `UPDATE … SET <column> = now()` instead of DELETE, so local
498 + /// ON DELETE CASCADE can't wipe organization (AF samples).
499 + Tombstone { column: &'static str },
500 + }
Lines truncated
@@ -0,0 +1,307 @@
1 + # SyncKit Client SDK: Integration Patterns
2 +
3 + How GoingsOn, Balanced Breakfast, and audiofiles consume the SyncKit client SDK. Use this guide to add sync to a new app.
4 +
5 + ## Common Architecture
6 +
7 + All three apps follow the same pattern:
8 +
9 + ```
10 + App (SQLite)
11 + ├── Syncable tables with INSERT/UPDATE/DELETE triggers
12 + ├── sync_changelog table (captures local mutations)
13 + ├── sync_state table (key-value: device_id, cursor, flags)
14 + ├── Sync service module (push/pull logic)
15 + └── Scheduler (background timer, exponential backoff)
16 + ```
17 +
18 + ### Trigger-Based Changelog
19 +
20 + Each syncable table has SQL triggers that write to `sync_changelog`:
21 + - Triggers fire on INSERT, UPDATE, DELETE
22 + - When `applying_remote = '1'` in sync_state, triggers are suppressed (prevents echo-back)
23 + - Only whitelisted columns are included in the JSON payload
24 + - Row IDs are UUIDs (GO, BB) or content hashes (AF)
25 +
26 + ### Push/Pull Cycle
27 +
28 + 1. **Push**: Read unpushed changelog entries (batch 500) → encrypt via SDK → send to MNW → mark as pushed
29 + 2. **Pull**: Fetch remote changes from MNW → decrypt via SDK → apply locally with triggers suppressed → update cursor
30 + 3. **Cleanup**: Delete pushed entries older than 7 days
31 +
32 + ### FK-Safe Ordering
33 +
34 + Both push and pull use foreign-key-safe ordering:
35 + - **Upserts**: Parents first (projects before tasks, VFS before nodes)
36 + - **Deletes**: Children first (tasks before projects, nodes before VFS)
37 +
38 + ---
39 +
40 + ## Per-App Integration
41 +
42 + ### GoingsOn (13 tables)
43 +
44 + **Tables synced:**
45 + ```
46 + projects → milestones, tasks, events
47 + tasks → annotations, subtasks
48 + contacts → contact_emails, contact_phones, contact_social_handles, contact_custom_fields
49 + email_accounts (16 config columns only — credentials excluded)
50 + ```
51 +
52 + **Special handling:**
53 + - Email account passwords and OAuth tokens are excluded from the column whitelist, never leave the device
54 + - Tasks with `source_email_id` referencing unsynced emails: FK enforcement relaxed during remote apply
55 +
56 + **Location:** `src-tauri/src/sync_service.rs` (1814 lines, 43 tests)
57 +
58 + ### Balanced Breakfast (5 tables)
59 +
60 + **Tables synced:**
61 + ```
62 + feeds → feed_tags, query_feeds
63 + user_config
64 + feed_items (partial: is_read + is_starred only)
65 + ```
66 +
67 + **Special handling:**
68 + - `feed_items`: Uses UPDATE (not INSERT OR REPLACE), only syncs user read/star state, never full content
69 + - `feed_items` deletes are ignored, content re-fetches from source feeds
70 + - Changelog retention cap: MAX_CHANGELOG_ENTRIES = 10,000 (prevents unbounded growth)
71 +
72 + **Location:** `src-tauri/src/sync_service.rs` (1062 lines, 30 tests)
73 +
74 + ### audiofiles (9 tables)
75 +
76 + **Tables synced:**
77 + ```
78 + vfs → vfs_nodes
79 + samples → audio_analysis, tags, collection_members
80 + collections → collection_members
81 + smart_folders
82 + user_config
83 + ```
84 +
85 + **Special handling (blob sync):**
86 + 1. VFS entries have a `sync_files` flag controlling blob sync
87 + 2. Samples marked `cloud_only = 1` if blob doesn't exist locally
88 + 3. After push/pull, upload pending blobs (local files in sync-enabled VFS)
89 + 4. Download missing blobs (cloud_only samples where file is needed)
90 +
91 + **Location:** `crates/audiofiles-sync/src/service.rs` (1438 lines, 48 tests)
92 +
93 + ---
94 +
95 + ## SDK Public API
96 +
97 + ### Authentication
98 +
99 + ```rust
100 + use synckit_client::SyncKitClient;
101 +
102 + // Create client
103 + let client = SyncKitClient::new(SyncKitConfig {
104 + server_url: "https://makenot.work".into(),
105 + api_key: "your-app-api-key".into(),
106 + });
107 +
108 + // OAuth2 PKCE flow
109 + let auth_url = client.build_authorize_url(port, state, code_challenge);
110 + // ... user completes browser flow ...
111 + let (user_id, app_id) = client.authenticate_with_code(code, code_verifier, port, sdk_key).await?;
112 +
113 + // Session management
114 + client.is_token_expired() -> bool
115 + client.session_info() -> Option<SessionInfo>
116 + client.clear_session() -> Result<()>
117 + ```
118 +
119 + ### Encryption Setup
120 +
121 + ```rust
122 + // First device: generate new master key
123 + client.setup_encryption_new(password).await?;
124 +
125 + // Subsequent devices: decrypt existing key from server
126 + client.setup_encryption_existing(password).await?;
127 +
128 + // Try restore from OS keychain (macOS Keychain, Linux secret-service, Windows Credential Manager)
129 + client.try_load_key_from_keychain().await? -> bool
130 +
131 + // Check if server has encrypted key (determines new vs existing flow)
132 + client.has_server_key().await? -> bool
133 + client.has_master_key() -> bool
134 + ```
135 +
136 + ### Device Management
137 +
138 + ```rust
139 + client.register_device(hostname, platform).await? -> Device
140 + client.list_devices().await? -> Vec<Device>
141 + ```
142 +
143 + ### Push/Pull
144 +
145 + ```rust
146 + use synckit_client::{ChangeEntry, ChangeOp};
147 +
148 + // Push encrypted changes to server
149 + client.push(device_id, changes: Vec<ChangeEntry>).await?;
150 +
151 + // Pull decrypted changes from server
152 + let (changes, new_cursor, has_more) = client.pull(device_id, cursor).await?;
153 + ```
154 +
155 + `ChangeEntry` fields:
156 + - `table`: Table name (string)
157 + - `op`: `ChangeOp::Insert`, `Update`, or `Delete`
158 + - `row_id`: Primary key (string)
159 + - `timestamp`: When the change was made
160 + - `data`: `Option<serde_json::Value>` (None for deletes)
161 +
162 + ### Blob Operations (audiofiles only)
163 +
164 + ```rust
165 + // Upload a file-backed blob. Streams it a part at a time, so peak memory is
166 + // one part rather than the whole file, and dedups server-side before reading.
167 + if client.blob_upload_streaming(hash, &path).await? == BlobUploadOutcome::Uploaded {
168 + client.blob_confirm(hash, size).await?;
169 + }
170 +
171 + // Download
172 + let url = client.blob_download_url(hash).await?;
173 + let data = client.blob_download(url).await?;
174 + ```
175 +
176 + For bytes already in memory (no file to stream), the one-shot pair still exists:
177 + `blob_upload_url(hash, plaintext_size)` then `blob_upload(hash, url, data)`, then
178 + `blob_confirm`. It buffers the whole ciphertext and the server refuses it above
179 + its one-shot ceiling, so prefer the streaming call whenever there is a path.
180 +
181 + ---
182 +
183 + ## First-Run Sequence
184 +
185 + ### First Device
186 +
187 + 1. User clicks "Connect to Sync"
188 + 2. App builds auth URL with PKCE challenge → opens browser
189 + 3. User logs in on MNW, approves scopes
190 + 4. Browser redirects to `localhost:PORT` with authorization code
191 + 5. App exchanges code for JWT via SDK
192 + 6. App detects no server key → shows "Set Encryption Password" dialog
193 + 7. User enters password → `setup_encryption_new(password)` generates and uploads encrypted key
194 + 8. App registers device → stores device_id in sync_state
195 + 9. App creates initial snapshot (INSERT all existing rows to changelog)
196 + 10. First sync cycle runs
197 +
198 + ### Same Device, Later Run
199 +
200 + 1. App calls `try_load_key_from_keychain()` → restores session + key
201 + 2. If success, ready to sync
202 + 3. If keychain empty, re-run OAuth flow
203 +
204 + ### Additional Device
205 +
206 + 1. OAuth flow as above
207 + 2. App detects server has key → shows "Enter Encryption Password" dialog
208 + 3. `setup_encryption_existing(password)` decrypts server key → saves to local keychain
209 + 4. Register new device, create initial snapshot, sync
210 +
211 + ---
212 +
213 + ## Adding Sync to a New App
214 +
215 + ### 1. Database Schema
216 +
217 + Add these tables:
218 +
219 + ```sql
220 + CREATE TABLE sync_changelog (
221 + id INTEGER PRIMARY KEY AUTOINCREMENT,
222 + table_name TEXT NOT NULL,
223 + op TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
224 + row_id TEXT NOT NULL,
225 + timestamp INTEGER NOT NULL,
226 + data TEXT, -- JSON, NULL for DELETE
227 + pushed INTEGER DEFAULT 0
228 + );
229 +
230 + CREATE TABLE sync_state (
231 + key TEXT PRIMARY KEY,
232 + value TEXT NOT NULL
233 + );
234 +
235 + -- Seed defaults
236 + INSERT INTO sync_state VALUES ('pull_cursor', '0');
237 + INSERT INTO sync_state VALUES ('applying_remote', '0');
238 + INSERT INTO sync_state VALUES ('initial_snapshot_done', '0');
239 + INSERT INTO sync_state VALUES ('auto_sync_enabled', '1');
240 + INSERT INTO sync_state VALUES ('sync_interval_minutes', '15');
241 + ```
242 +
243 + ### 2. Triggers
244 +
245 + For each syncable table, add three triggers:
246 +
247 + ```sql
248 + CREATE TRIGGER after_insert_my_table AFTER INSERT ON my_table
249 + BEGIN
250 + SELECT CASE
251 + WHEN (SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'
252 + THEN (INSERT INTO sync_changelog (table_name, op, row_id, timestamp, data)
253 + VALUES ('my_table', 'INSERT', NEW.id, strftime('%s','now'),
254 + json_object('col1', NEW.col1, 'col2', NEW.col2)))
255 + END;
256 + END;
257 + -- Repeat for UPDATE and DELETE (DELETE uses OLD.id, data = NULL)
258 + ```
259 +
260 + ### 3. Core Module
261 +
262 + Implement these functions:
263 +
264 + | Function | Purpose |
265 + |----------|---------|
266 + | `get_sync_state(key)` | Read from sync_state |
267 + | `set_sync_state(key, value)` | Write to sync_state |
268 + | `ensure_device_registered(client)` | Cache device_id after first registration |
269 + | `create_initial_snapshot()` | One-time: INSERT all existing rows to changelog |
270 + | `push_changes(client, device_id)` | Batch 500, read/encrypt/send/mark-pushed |
271 + | `pull_changes(client, device_id)` | Loop until no more, decrypt/apply/save-cursor |
272 + | `apply_upsert(table, row_id, data)` | INSERT OR REPLACE with FK order |
273 + | `apply_delete(table, row_id)` | DELETE with FK order |
274 + | `cleanup_changelog()` | Prune pushed entries older than 7 days |
275 +
276 + ### 4. Scheduler
277 +
278 + - Check every 60 seconds if sync is due
279 + - On first run: `create_initial_snapshot()` if needed
280 + - Exponential backoff on failure (2^N minutes, capped at 15)
281 + - Emit status events for UI updates
282 +
283 + ### 5. Commands / UI
284 +
285 + Expose these to the frontend:
286 + - `sync_status()`: configured, authenticated, encryption, device, pending changes
287 + - `sync_start_auth()`: returns auth URL + state + verifier
288 + - `sync_complete_auth(code, state, verifier)`: exchanges for JWT
289 + - `sync_setup_encryption_new/existing(password)`: calls SDK method
290 + - `sync_now()`: triggers immediate cycle
291 + - `sync_disconnect()`: clears credentials and session
292 +
293 + ---
294 +
295 + ## Key Files
296 +
297 + | What | Where |
298 + |------|-------|
299 + | SDK source | `MNW/shared/synckit-client/src/` |
300 + | SDK auth | `MNW/shared/synckit-client/src/client/auth.rs` |
301 + | SDK push/pull | `MNW/shared/synckit-client/src/client/sync.rs` |
302 + | SDK encryption | `MNW/shared/synckit-client/src/crypto.rs` |
303 + | GO sync service | `Apps/goingson/src-tauri/src/sync_service.rs` |
304 + | BB sync service | `Apps/balanced_breakfast/src-tauri/src/sync_service.rs` |
305 + | AF sync service | `Apps/audiofiles/crates/audiofiles-sync/src/service.rs` |
306 + | Server endpoints | `MNW/server/src/routes/synckit.rs` |
307 + | Server DB | `MNW/server/src/db/synckit.rs` |
@@ -0,0 +1,454 @@
1 + //! Authentication and session lifecycle.
2 + //!
3 + //! Password and OAuth login, session restore from a stored token, and the
4 + //! local JWT-expiry check the API methods use to fail fast before a request.
5 +
6 + use bytes::Bytes;
7 + use std::sync::Arc;
8 + use tracing::instrument;
9 + #[cfg(test)]
10 + use uuid::Uuid;
11 +
12 + use crate::{
13 + error::Result,
14 + ids::{AppId, UserId},
15 + types::{AuthRequest, AuthResponse, OAuthTokenResponse},
16 + };
17 +
18 + use super::helpers::{Idempotency, check_response, jwt_exp};
19 + use super::{SecretToken, Session, SyncKitClient, TOKEN_EXPIRY_BUFFER_SECS};
20 +
21 + impl SyncKitClient {
22 + /// Authenticate with the MNW server. Returns (user_id, app_id).
23 + ///
24 + /// `key` is the developer-defined SDK key this session's storage counts
25 + /// against. The dev's backend chooses it, typically one key per
26 + /// workspace/org/end-user.
27 + ///
28 + /// Stores the session internally. If called concurrently from multiple
29 + /// threads, the last write wins, earlier sessions are silently overwritten.
30 + ///
31 + /// # Errors
32 + ///
33 + /// Returns `Server { status: 401, .. }` for wrong credentials.
34 + #[instrument(skip(self, email, password, key))]
35 + pub async fn authenticate(
36 + &self,
37 + email: &str,
38 + password: &str,
39 + key: &str,
40 + ) -> Result<(UserId, AppId)> {
41 + let body = Bytes::from(serde_json::to_vec(&AuthRequest {
42 + email,
43 + password,
44 + api_key: &self.config.api_key,
45 + key,
46 + })?);
47 +
48 + let resp = self
49 + .retry_request(
50 + Idempotency::IdempotentWrite {
51 + on: "credentials; re-auth reissues an equivalent token",
52 + },
53 + || {
54 + let req = self
55 + .http
56 + .post(&self.endpoints.auth)
57 + .header("content-type", "application/json")
58 + .body(body.clone());
59 + async move { check_response(req.send().await?).await }
60 + },
61 + )
62 + .await?;
63 + let auth: AuthResponse = crate::client::helpers::read_json_capped(
64 + resp,
65 + crate::client::helpers::MAX_CONTROL_BODY_BYTES,
66 + )
67 + .await?;
68 +
69 + let user_id = auth.user_id;
70 + let app_id = auth.app_id;
71 + let token_exp = jwt_exp(&auth.token);
72 +
73 + *self.session.write() = Some(Session {
74 + token: Arc::new(SecretToken::new(auth.token)),
75 + token_exp,
76 + user_id,
77 + app_id,
78 + });
79 +
80 + tracing::info!("Authenticated as user {user_id} for app {app_id}");
81 + Ok((user_id, app_id))
82 + }
83 +
84 + /// Restore a session from previously stored credentials (e.g. OS keychain).
85 + ///
86 + /// Sets the internal session state without making any HTTP calls.
87 + /// Used on app startup to restore from stored credentials without re-authenticating.
88 + pub fn restore_session(&self, token: &str, user_id: UserId, app_id: AppId) {
89 + let token_exp = jwt_exp(token);
90 + *self.session.write() = Some(Session {
91 + token: Arc::new(SecretToken::new(token.to_string())),
92 + token_exp,
93 + user_id,
94 + app_id,
95 + });
96 + tracing::info!("Session restored for user {user_id}, app {app_id}");
97 + }
98 +
99 + /// Clear the in-memory session and master key.
100 + ///
101 + /// After calling this, the client will need to re-authenticate and set up
102 + /// encryption again. Does not affect OS keychain storage, call
103 + /// `keystore::delete_master_key` separately if needed.
104 + pub fn clear_session(&self) {
105 + *self.session.write() = None;
106 + *self.master_key.write() = None;
107 + tracing::info!("Session and master key cleared");
108 + }
109 +
110 + /// Check whether the current session token has expired (or will expire
111 + /// within a 30-second buffer). Returns `true` if there is no session or
112 + /// if the token's `exp` claim is in the past. Returns `false` if the
113 + /// token cannot be decoded (assumes not expired, the server will reject
114 + /// it with a 401 if it actually is).
115 + pub fn is_token_expired(&self) -> bool {
116 + let guard = self.session.read();
117 + let Some(session) = guard.as_ref() else {
118 + return true;
119 + };
120 + match session.token_exp {
121 + Some(exp) => {
122 + let now = chrono::Utc::now().timestamp();
123 + now >= exp - TOKEN_EXPIRY_BUFFER_SECS
124 + }
125 + None => false,
126 + }
127 + }
128 +
129 + // ── OAuth ──
130 +
131 + /// Build the authorization URL for the OAuth2 PKCE flow.
132 + ///
133 + /// The caller is responsible for generating the PKCE verifier/challenge,
134 + /// starting the localhost callback server, and opening the browser.
135 + ///
136 + /// Sends `scope=sync` explicitly: this is the SyncKit pairing flow and the
137 + /// full sync-API token is what it needs. The server treats an omitted scope
138 + /// as least-privilege userinfo (not sync), so the scope must be sent for the
139 + /// token exchange to yield a sync-capable token.
140 + pub fn build_authorize_url(
141 + &self,
142 + redirect_port: u16,
143 + state: &str,
144 + code_challenge: &str,
145 + ) -> String {
146 + format!(
147 + "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync",
148 + self.config.server_url,
149 + urlencoding::encode(&self.config.api_key),
150 + urlencoding::encode(&format!("http://127.0.0.1:{redirect_port}/")),
151 + urlencoding::encode(state),
152 + urlencoding::encode(code_challenge),
153 + )
154 + }
155 +
156 + /// Exchange an OAuth2 authorization code for a SyncKit JWT.
157 + ///
158 + /// `key` is the developer-defined SDK key for billing attribution, see
159 + /// [`authenticate`](Self::authenticate).
160 + ///
161 + /// Call this after receiving the code from the localhost callback server.
162 + /// On success, stores the session internally (same as `authenticate()`).
163 + #[instrument(skip(self, code, code_verifier, key))]
164 + pub async fn authenticate_with_code(
165 + &self,
166 + code: &str,
167 + code_verifier: &str,
168 + redirect_port: u16,
169 + key: &str,
170 + ) -> Result<(UserId, AppId)> {
171 + let redirect_uri = format!("http://127.0.0.1:{redirect_port}/");
172 +
173 + let form_params = [
174 + ("grant_type", "authorization_code"),
175 + ("code", code),
176 + ("redirect_uri", &redirect_uri),
177 + ("code_verifier", code_verifier),
178 + ("client_id", &self.config.api_key),
179 + ("key", key),
180 + ];
181 +
182 + // OAuth authorization codes are single-use. Retrying after a
183 + // network error would send an already-consumed code, producing a
184 + // permanent 400. Send exactly once and let the caller restart the
185 + // OAuth flow on failure.
186 + let resp = check_response(
187 + self.http
188 + .post(&self.endpoints.oauth_token)
189 + .form(&form_params)
190 + .send()
191 + .await?,
192 + )
193 + .await?;
194 + let token_resp: OAuthTokenResponse = crate::client::helpers::read_json_capped(
195 + resp,
196 + crate::client::helpers::MAX_CONTROL_BODY_BYTES,
197 + )
198 + .await?;
199 +
200 + let user_id = token_resp.user_id;
201 + let app_id = token_resp.app_id;
202 + let token_exp = jwt_exp(&token_resp.access_token);
203 +
204 + *self.session.write() = Some(Session {
205 + token: Arc::new(SecretToken::new(token_resp.access_token)),
206 + token_exp,
207 + user_id,
208 + app_id,
209 + });
210 +
211 + tracing::info!("Authenticated via OAuth as user {user_id} for app {app_id}");
212 + Ok((user_id, app_id))
213 + }
214 + }
215 +
216 + #[cfg(test)]
217 + mod tests {
218 + use super::*;
219 + use base64::Engine;
220 + use chrono::Utc;
221 +
222 + use crate::error::SyncKitError;
223 +
224 + fn test_config() -> super::super::SyncKitConfig {
225 + super::super::SyncKitConfig {
226 + server_url: "https://example.com".to_string(),
227 + api_key: "test-api-key-123".to_string(),
228 + }
229 + }
230 +
231 + fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
232 + (
233 + crate::ids::AppId::new(
234 + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
235 + ),
236 + crate::ids::UserId::new(
237 + Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
238 + ),
239 + )
240 + }
241 +
242 + fn fake_jwt(exp: i64) -> String {
243 + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
244 + .encode(r#"{"alg":"HS256","typ":"JWT"}"#);
245 + let payload_json = serde_json::json!({
246 + "sub": "550e8400-e29b-41d4-a716-446655440000",
247 + "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
248 + "exp": exp,
249 + "iat": exp - 3600,
250 + });
251 + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
252 + .encode(payload_json.to_string().as_bytes());
253 + let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature");
254 + format!("{header}.{payload}.{signature}")
255 + }
256 +
257 + // ── restore_session ──
258 +
259 + #[test]
260 + fn restore_session_makes_client_authenticated() {
261 + let client = SyncKitClient::new(test_config());
262 + let (app_id, user_id) = test_ids();
263 +
264 + client.restore_session("fake-token", user_id, app_id);
265 +
266 + let info = client.session_info().expect("session should exist");
267 + assert_eq!(info.token.as_str(), "fake-token");
268 + assert_eq!(info.user_id, user_id);
269 + assert_eq!(info.app_id, app_id);
270 + }
271 +
272 + #[test]
273 + fn restore_session_overwrites_previous_session() {
274 + let client = SyncKitClient::new(test_config());
275 + let (app_id, user_id) = test_ids();
276 +
277 + client.restore_session("first-token", user_id, app_id);
278 + client.restore_session("second-token", user_id, app_id);
279 +
280 + let info = client.session_info().unwrap();
281 + assert_eq!(info.token.as_str(), "second-token");
282 + }
283 +
284 + // ── build_authorize_url ──
285 +
286 + #[test]
287 + fn build_authorize_url_includes_all_params() {
288 + let client = SyncKitClient::new(test_config());
289 + let url = client.build_authorize_url(8080, "random-state", "challenge123");
290 +
291 + assert!(url.starts_with("https://example.com/oauth/authorize?"));
292 + assert!(url.contains("response_type=code"));
293 + assert!(url.contains("client_id=test-api-key-123"));
294 + assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2F"));
295 + assert!(url.contains("state=random-state"));
296 + assert!(url.contains("code_challenge=challenge123"));
297 + assert!(url.contains("code_challenge_method=S256"));
298 + // The pairing flow must request the sync scope explicitly, the server
299 + // treats an omitted scope as least-privilege userinfo, not sync.
300 + assert!(url.contains("scope=sync"));
301 + }
302 +
303 + #[test]
304 + fn build_authorize_url_encodes_special_chars() {
305 + let config = super::super::SyncKitConfig {
306 + server_url: "https://example.com".to_string(),
307 + api_key: "key with spaces&special=chars".to_string(),
308 + };
309 + let client = SyncKitClient::new(config);
310 + let url = client.build_authorize_url(9090, "state/with/slashes", "ch+all=enge");
311 +
312 + assert!(url.contains("key%20with%20spaces%26special%3Dchars"));
313 + assert!(url.contains("state%2Fwith%2Fslashes"));
314 + assert!(url.contains("ch%2Ball%3Denge"));
315 + }
316 +
317 + #[test]
318 + fn build_authorize_url_different_ports() {
319 + let client = SyncKitClient::new(test_config());
320 +
321 + let url_low = client.build_authorize_url(1234, "s", "c");
322 + assert!(url_low.contains("127.0.0.1%3A1234"));
323 +
324 + let url_high = client.build_authorize_url(65535, "s", "c");
325 + assert!(url_high.contains("127.0.0.1%3A65535"));
326 + }
327 +
328 + // ── is_token_expired ──
329 +
330 + #[test]
331 + fn is_token_expired_true_without_session() {
332 + let client = SyncKitClient::new(test_config());
333 + assert!(client.is_token_expired());
334 + }
335 +
336 + #[test]
337 + fn is_token_expired_true_with_expired_token() {
338 + let client = SyncKitClient::new(test_config());
339 + let (app_id, user_id) = test_ids();
340 + let token = fake_jwt(Utc::now().timestamp() - 3600);
341 + client.restore_session(&token, user_id, app_id);
342 + assert!(client.is_token_expired());
343 + }
344 +
345 + #[test]
346 + fn is_token_expired_false_with_fresh_token() {
347 + let client = SyncKitClient::new(test_config());
348 + let (app_id, user_id) = test_ids();
349 + let token = fake_jwt(Utc::now().timestamp() + 3600);
350 + client.restore_session(&token, user_id, app_id);
351 + assert!(!client.is_token_expired());
352 + }
353 +
354 + // ── require_token with expiry ──
355 +
356 + #[test]
357 + fn require_token_returns_token_expired_for_expired_token() {
358 + let client = SyncKitClient::new(test_config());
359 + let (app_id, user_id) = test_ids();
360 + let token = fake_jwt(Utc::now().timestamp() - 3600);
361 + client.restore_session(&token, user_id, app_id);
362 +
363 + let err = client.require_token().unwrap_err();
364 + assert!(matches!(err, SyncKitError::TokenExpired));
365 + }
366 +
367 + #[test]
368 + fn require_token_succeeds_with_fresh_token() {
369 + let client = SyncKitClient::new(test_config());
370 + let (app_id, user_id) = test_ids();
371 + let token = fake_jwt(Utc::now().timestamp() + 3600);
372 + client.restore_session(&token, user_id, app_id);
373 +
374 + assert!(client.require_token().is_ok());
375 + }
376 +
377 + // ── clear_session ──
378 +
379 + #[test]
380 + fn clear_session_clears_master_key() {
381 + let client = SyncKitClient::new(test_config());
382 + let (app_id, user_id) = test_ids();
383 + client.restore_session("token", user_id, app_id);
384 + client.set_master_key_raw([42u8; 32]);
385 +
386 + assert!(client.session_info().is_some());
387 + assert!(client.has_master_key());
388 +
389 + client.clear_session();
390 +
391 + assert!(client.session_info().is_none());
392 + assert!(!client.has_master_key());
393 + }
394 +
395 + // ── OAuth types ──
396 +
397 + #[test]
398 + fn oauth_token_response_deserialization() {
399 + let json = r#"{
400 + "access_token": "jwt-access-token",
401 + "token_type": "Bearer",
402 + "expires_in": 3600,
403 + "user_id": "550e8400-e29b-41d4-a716-446655440000",
404 + "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
405 + }"#;
406 +
407 + let resp: OAuthTokenResponse = serde_json::from_str(json).unwrap();
408 + assert_eq!(resp.access_token, "jwt-access-token");
409 + assert_eq!(resp.token_type, "Bearer");
410 + assert_eq!(resp.expires_in, 3600);
411 + }
412 +
413 + // ── Auth types ──
414 +
415 + #[test]
416 + fn auth_request_serialization() {
417 + let req = AuthRequest {
418 + email: "user@example.com",
419 + password: "secret123",
420 + api_key: "ak_test",
421 + key: "session-key-abc",
422 + };
423 +
424 + let json = serde_json::to_string(&req).unwrap();
425 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
426 + assert_eq!(parsed["email"], "user@example.com");
427 + assert_eq!(parsed["password"], "secret123");
428 + assert_eq!(parsed["api_key"], "ak_test");
429 + assert_eq!(parsed["key"], "session-key-abc");
430 + }
431 +
432 + #[test]
433 + fn auth_response_deserialization() {
434 + let json = r#"{
435 + "token": "jwt.token.here",
436 + "user_id": "550e8400-e29b-41d4-a716-446655440000",
437 + "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
438 + }"#;
439 +
440 + let resp: AuthResponse = serde_json::from_str(json).unwrap();
441 + assert_eq!(resp.token, "jwt.token.here");
442 + assert_eq!(
443 + resp.user_id.as_uuid(),
444 + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
445 + );
446 + }
447 +
448 + #[test]
449 + fn auth_response_missing_token_fails() {
450 + let json = r#"{"user_id": "550e8400-e29b-41d4-a716-446655440000", "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"}"#;
451 + let result = serde_json::from_str::<AuthResponse>(json);
452 + assert!(result.is_err());
453 + }
454 + }
@@ -0,0 +1,718 @@
1 + //! Content-addressed blob upload and download.
2 + //!
3 + //! Blobs are encrypted client-side and stored under the SHA-256 of their
4 + //! plaintext. Uploads go through a presigned URL after a size/quota check;
5 + //! downloads are decrypted and re-hashed on arrival and discarded on any
6 + //! mismatch, so a server that swaps or rolls back a blob cannot slip it past
7 + //! the client.
8 +
9 + use std::path::Path;
10 +
11 + use base64::Engine;
12 + use bytes::{Buf, Bytes, BytesMut};
13 + use sha2::{Digest, Sha256};
14 + use tokio::io::AsyncReadExt;
15 + use tokio_stream::StreamExt;
16 + use tracing::instrument;
17 +
18 + use crate::{
19 + crypto,
20 + error::{Result, SyncKitError},
21 + types::{
22 + BlobConfirmRequest, BlobDownloadUrlRequest, BlobDownloadUrlResponse,
23 + BlobMultipartAbortRequest, BlobMultipartCompleteRequest, BlobMultipartCompletedPart,
24 + BlobMultipartPartsRequest, BlobMultipartPartsResponse, BlobMultipartStartRequest,
25 + BlobMultipartStartResponse, BlobUploadUrlRequest, BlobUploadUrlResponse,
26 + },
27 + };
28 +
29 + use super::SyncKitClient;
30 + use super::helpers::{Idempotency, check_response};
31 +
32 + /// Upper bound on a single decrypted blob held in memory, guarding against a
33 + /// hostile server returning an absurdly large body that would OOM the client.
34 + /// Generous (4 GiB) so legitimate large media still flow.
35 + const MAX_BLOB_BYTES: usize = 4 * 1024 * 1024 * 1024;
36 +
37 + /// What [`SyncKitClient::blob_upload_streaming`] did.
38 + ///
39 + /// The caller needs to tell the two apart: only an upload that sent bytes has
40 + /// anything to confirm.
41 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
42 + pub enum BlobUploadOutcome {
43 + /// The parts were uploaded and assembled. Call [`SyncKitClient::blob_confirm`]
44 + /// next to record the blob server-side.
45 + Uploaded,
46 + /// The server already held this content address, so no bytes were sent and
47 + /// there is nothing to confirm. Server-asserted, not verified, see
48 + /// [`BlobUploadUrlResponse::already_exists`] for what a wrong `true` costs.
49 + AlreadyPresent,
50 + }
51 +
52 + impl SyncKitClient {
53 + /// Request a presigned upload URL for a blob.
54 + /// Returns (upload_url, already_exists). If `already_exists` is true,
55 + /// the blob is already on the server and no upload is needed.
56 + ///
57 + /// `size_bytes` is the **plaintext** length, the size the caller sees on
58 + /// disk. What [`Self::blob_upload`] actually PUTs is the sealed ciphertext,
59 + /// which is longer by the v3 framing, and the server signs the declared
60 + /// size into the presigned URL as `Content-Length` (a SignedHeader), so
61 + /// declaring the plaintext length makes every PUT fail the signature. This
62 + /// method converts, keeping the wire-format arithmetic inside the SDK
63 + /// instead of asking three consumer apps to know it.
64 + ///
65 + /// `already_exists` is the server's claim, not a verified fact. See
66 + /// [`BlobUploadUrlResponse::already_exists`] for what a false `true` costs
67 + /// (a withheld blob, never a substituted one).
68 + #[instrument(skip(self))]
69 + pub async fn blob_upload_url(
70 + &self,
71 + hash: &str,
72 + size_bytes: i64,
73 + ) -> Result<BlobUploadUrlResponse> {
74 + let plaintext_len = usize::try_from(size_bytes)
75 + .map_err(|_| SyncKitError::InvalidArgument("size_bytes must be non-negative".into()))?;
76 + let token = self.require_token()?;
77 +
78 + let body = Bytes::from(serde_json::to_vec(&BlobUploadUrlRequest {
79 + hash: hash.to_string(),
80 + size_bytes: crypto::blob_encrypted_len(plaintext_len) as i64,
81 + })?);
82 +
83 + self.retry_request_json(Idempotency::ReadOnly, || {
84 + let req = self
85 + .http
86 + .post(&self.endpoints.blobs_upload)
87 + .bearer_auth(&token)
88 + .header("content-type", "application/json")
89 + .body(body.clone());
90 + async move { check_response(req.send().await?).await }
91 + })
92 + .await
93 + }
94 +
95 + /// Upload blob data directly to S3 via a presigned PUT URL.
96 + ///
97 + /// Encrypts the data with the master key before uploading, binding the
98 + /// content `hash` as AEAD associated data so the ciphertext is tied to its
99 + /// content address. The plaintext is never sent to the server, preserving
100 + /// the E2E encryption guarantee.
101 + ///
102 + /// This is the in-memory path: it buffers the whole ciphertext, so the input
103 + /// is capped at [`MAX_BLOB_BYTES`] rather than risking an unbounded
104 + /// allocation. For anything file-backed, prefer
105 + /// [`Self::blob_upload_streaming`], which holds one part at a time and is
106 + /// the only path the server accepts above its one-shot PUT ceiling.
107 + #[instrument(skip(self, presigned_url, data))]
108 + pub async fn blob_upload(&self, hash: &str, presigned_url: &str, data: Vec<u8>) -> Result<()> {
109 + if data.len() > MAX_BLOB_BYTES {
110 + return Err(SyncKitError::InvalidArgument(format!(
111 + "blob is {} bytes, over the {MAX_BLOB_BYTES}-byte in-memory cap; use blob_upload_streaming for a file this size",
112 + data.len()
113 + )));
114 + }
115 + let master_key = self.require_master_key()?;
116 + // v3 chunked format: each chunk sealed and bound to (hash, index, count),
117 + // so the reader verifies and decrypts one chunk at a time.
118 + let encrypted = Bytes::from(crypto::encrypt_blob_chunked(&data, &master_key, hash)?);
119 + // Release the plaintext before the (potentially long, multi-GB) network
120 + // send so the sustained peak during transfer is ~1x the blob, not ~2x.
121 + // The per-attempt `encrypted.clone()` below is a cheap `Bytes` refcount
122 + // bump, not a copy. Never materializing the full ciphertext at all is
123 + // what `blob_upload_streaming` does, for callers holding a file.
124 + drop(data);
125 +
126 + self.retry_request(
127 + Idempotency::IdempotentWrite {
128 + on: "content hash (S3 key is content-addressed)",
129 + },
130 + || {
131 + let req = self
132 + .http_stream
133 + .put(presigned_url)
134 + .header("content-type", "application/octet-stream")
135 + .body(encrypted.clone());
136 + async move { check_response(req.send().await?).await }
137 + },
138 + )
139 + .await?;
140 +
141 + Ok(())
142 + }
143 +
144 + /// Upload a file-backed blob through the multipart session, never holding
145 + /// more than one part in memory.
146 + ///
147 + /// This is the bounded-memory counterpart to [`Self::blob_upload`], and the
148 + /// only path for blobs above the server's one-shot PUT ceiling. It works at
149 + /// any size (a small blob is a single part), so it is the better default
150 + /// whenever the blob is already a file on disk.
151 + ///
152 + /// The v3 chunk geometry is what makes it possible. A presigned PUT needs a
153 + /// signed `Content-Length` up front, and `blob_encrypted_len` gives the
154 + /// exact ciphertext length from the plaintext length alone, so the server
155 + /// signs every part boundary before a single byte has been read, and this
156 + /// method fills those boundaries by sealing 1 MiB chunks as it streams the
157 + /// file. Peak memory is one part plus one chunk, not the whole blob.
158 + ///
159 + /// `hash` must be the SHA-256 of the file's plaintext, as elsewhere. It is
160 + /// recomputed while streaming and the upload is abandoned on mismatch: the
161 + /// caller hashed the file in an earlier pass, so a file edited in between
162 + /// would otherwise be stored under an address that does not describe it.
163 + ///
164 + /// Returns [`BlobUploadOutcome`]: on `Uploaded` the caller calls
165 + /// [`Self::blob_confirm`] next, exactly as with the one-shot path (this
166 + /// method replaces the transport only); on `AlreadyPresent` the server
167 + /// already held the content, nothing was read or sent, and there is nothing
168 + /// to confirm. The dedup check happens before any file read or sealing, so
169 + /// a re-sync of content the server already has costs one round trip.
170 + #[instrument(skip(self, path), fields(path = %path.display()))]
171 + pub async fn blob_upload_streaming(
172 + &self,
173 + hash: &str,
174 + path: &Path,
175 + ) -> Result<BlobUploadOutcome> {
176 + let master_key = self.require_master_key()?;
177 +
178 + let meta = tokio::fs::metadata(path).await.map_err(|e| {
179 + SyncKitError::Internal(format!("stat blob file {}: {e}", path.display()))
180 + })?;
181 + let plaintext_len = usize::try_from(meta.len())
182 + .map_err(|_| SyncKitError::InvalidArgument("blob file is larger than usize".into()))?;
183 + if plaintext_len > MAX_BLOB_BYTES {
184 + return Err(SyncKitError::InvalidArgument(format!(
185 + "blob is {plaintext_len} bytes, over the {MAX_BLOB_BYTES}-byte client cap"
186 + )));
187 + }
188 + // The session is sized in ciphertext, which is knowable from the
189 + // plaintext length alone, that is the whole trick.
190 + let size_bytes = i64::try_from(crypto::blob_encrypted_len(plaintext_len))
191 + .map_err(|_| SyncKitError::InvalidArgument("blob ciphertext exceeds i64".into()))?;
192 +
193 + let token = self.require_token()?;
194 + let start_body = Bytes::from(serde_json::to_vec(&BlobMultipartStartRequest {
195 + hash: hash.to_string(),
196 + size_bytes,
197 + })?);
198 + let start: BlobMultipartStartResponse = self
199 + .retry_request_json(Idempotency::ReadOnly, || {
200 + let req = self
201 + .http
202 + .post(&self.endpoints.blobs_multipart_start)
203 + .bearer_auth(&token)
204 + .header("content-type", "application/json")
205 + .body(start_body.clone());
206 + async move { check_response(req.send().await?).await }
207 + })
208 + .await?;
209 +
210 + if start.already_exists {
211 + return Ok(BlobUploadOutcome::AlreadyPresent);
212 + }
213 +
214 + // Every failure past this point leaves uploaded parts that S3 bills for
215 + // until the session is aborted, so the abort lives in exactly one place:
216 + // here, around the whole transfer. The server-side orphan reaper is the
217 + // backstop for a client that dies outright.
218 + let transfer = self
219 + .stream_blob_parts(hash, &start, path, plaintext_len, size_bytes, &master_key)
220 + .await;
221 + let parts = match transfer {
222 + Ok(parts) => parts,
223 + Err(e) => {
224 + if let Err(abort_err) = self.blob_multipart_abort(hash, &start.upload_id).await {
225 + tracing::warn!(
226 + error = %abort_err,
227 + "failed to abort multipart blob session; the server reaper will release it"
228 + );
229 + }
230 + return Err(e);
231 + }
232 + };
233 +
234 + let complete_body = Bytes::from(serde_json::to_vec(&BlobMultipartCompleteRequest {
235 + hash: hash.to_string(),
236 + upload_id: start.upload_id.clone(),
237 + parts,
238 + })?);
239 + self.retry_request(
240 + Idempotency::IdempotentWrite {
241 + on: "upload_id (completing twice assembles the same object)",
242 + },
243 + || {
244 + let req = self
245 + .http
246 + .post(&self.endpoints.blobs_multipart_complete)
247 + .bearer_auth(&token)
248 + .header("content-type", "application/json")
249 + .body(complete_body.clone());
250 + async move { check_response(req.send().await?).await }
251 + },
252 + )
253 + .await?;
254 +
255 + Ok(BlobUploadOutcome::Uploaded)
256 + }
257 +
258 + /// Seal the file chunk by chunk, cutting the ciphertext at the server's
259 + /// signed part boundaries and PUTting each part. Returns the completed
260 + /// `(part_number, etag)` pairs.
261 + ///
262 + /// Peak memory is one part plus one chunk: the staging buffer never holds
263 + /// more than a part's worth, because a full part is drained and sent the
264 + /// moment it is complete.
265 + async fn stream_blob_parts(
266 + &self,
267 + hash: &str,
268 + start: &BlobMultipartStartResponse,
269 + path: &Path,
270 + plaintext_len: usize,
271 + size_bytes: i64,
272 + master_key: &[u8; 32],
273 + ) -> Result<Vec<BlobMultipartCompletedPart>> {
274 + let part_size = usize::try_from(start.part_size).map_err(|_| {
275 + SyncKitError::Internal(format!(
276 + "server part_size {} exceeds usize",
277 + start.part_size
278 + ))
279 + })?;
280 + if part_size == 0 || start.part_count == 0 {
281 + return Err(SyncKitError::Internal(
282 + "server returned an empty multipart plan".into(),
283 + ));
284 + }
285 +
286 + // The part geometry is server-supplied, so bound it before it drives any
287 + // allocation. Without this a hostile part_count aborts the client on an
288 + // over-large `Vec`/`BytesMut` reservation, and a part_count/part_size that
289 + // does not match the blob defeats the one-part-plus-one-chunk memory bound.
290 + // The plan must describe exactly the ciphertext we are about to upload
291 + // (the server derives it from this same `size_bytes`), so recompute and
292 + // compare rather than trust it.
293 + const MAX_PART_SIZE: usize = 1 << 30; // 1 GiB; real plan parts are well under this
294 + const MAX_PART_COUNT: u32 = 10_000; // S3's hard multipart-part ceiling
295 + if part_size > MAX_PART_SIZE {
296 + return Err(SyncKitError::Internal(format!(
297 + "server part_size {part_size} exceeds the {MAX_PART_SIZE}-byte ceiling"
298 + )));
299 + }
300 + if start.part_count > MAX_PART_COUNT {
301 + return Err(SyncKitError::Internal(format!(
302 + "server part_count {} exceeds the {MAX_PART_COUNT}-part ceiling",
303 + start.part_count
304 + )));
305 + }
306 + let total = usize::try_from(size_bytes).map_err(|_| {
307 + SyncKitError::Internal(format!("blob size {size_bytes} is not a valid length"))
308 + })?;
309 + let expected_parts = total.div_ceil(part_size);
310 + if start.part_count as usize != expected_parts {
311 + return Err(SyncKitError::Internal(format!(
312 + "server multipart plan (part_count {}, part_size {part_size}) does not match the \
313 + {total}-byte blob (expected {expected_parts} parts)",
314 + start.part_count
315 + )));
316 + }
317 +
318 + let mut file = tokio::fs::File::open(path).await.map_err(|e| {
319 + SyncKitError::Internal(format!("open blob file {}: {e}", path.display()))
320 + })?;
321 +
322 + let chunk_count = crypto::blob_chunk_count_for(plaintext_len);
323 + let mut hasher = Sha256::new();
324 + // Plaintext scratch: zeroized on drop so a decrypted chunk does not linger.
325 + let mut plain = zeroize::Zeroizing::new(vec![0u8; crypto::BLOB_CHUNK_SIZE]);
326 + let mut staged = BytesMut::with_capacity(part_size + crypto::BLOB_CHUNK_SIZE);
327 + staged.extend_from_slice(&crypto::blob_header_bytes(plaintext_len));
328 +
329 + let mut completed = Vec::with_capacity(start.part_count as usize);
330 + let mut next_part = 1u32;
331 +
332 + for index in 0..chunk_count {
333 + let offset = index as usize * crypto::BLOB_CHUNK_SIZE;
334 + let want = (plaintext_len - offset).min(crypto::BLOB_CHUNK_SIZE);
335 + file.read_exact(&mut plain[..want]).await.map_err(|e| {
336 + SyncKitError::Internal(format!(
337 + "read blob file {} at offset {offset}: {e}",
338 + path.display()
339 + ))
340 + })?;
341 + hasher.update(&plain[..want]);
342 + staged.extend_from_slice(&crypto::seal_blob_chunk(
343 + &plain[..want],
344 + master_key,
345 + hash,
346 + index,
347 + chunk_count,
348 + )?);
349 +
350 + // Every part but the last is exactly `part_size`; the remainder is
351 + // the final part, so it is never sent from inside this loop.
352 + while staged.len() >= part_size && next_part < start.part_count {
353 + let body = staged.split_to(part_size).freeze();
354 + let etag = self
355 + .upload_blob_part(hash, start, size_bytes, next_part, body)
356 + .await?;
357 + completed.push(BlobMultipartCompletedPart {
358 + part_number: next_part as i32,
359 + etag,
360 + });
361 + next_part += 1;
362 + }
363 + }
364 +
365 + // Verify the content address before the object can be assembled. A
366 + // mismatch here aborts the session, so a file that changed after the
367 + // caller hashed it is never stored under the stale address.
368 + let actual = hex::encode(hasher.finalize());
369 + if actual != hash {
370 + return Err(SyncKitError::IntegrityFailed {
371 + expected: hash.to_string(),
372 + actual,
373 + });
374 + }
375 +
376 + if next_part != start.part_count {
377 + return Err(SyncKitError::Internal(format!(
378 + "sealed stream produced {} parts, server planned {}",
379 + next_part, start.part_count
380 + )));
381 + }
382 + let body = staged.split().freeze();
383 + let etag = self
384 + .upload_blob_part(hash, start, size_bytes, next_part, body)
385 + .await?;
386 + completed.push(BlobMultipartCompletedPart {
387 + part_number: next_part as i32,
388 + etag,
389 + });
390 +
391 + Ok(completed)
392 + }
393 +
394 + /// PUT one part to its presigned URL and return the ETag S3 assigned it.
395 + ///
396 + /// The URL is requested for this part alone, and carries the part's SHA-256
397 + /// bound as `x-amz-checksum-sha256`, so S3 rehashes what it receives and
398 + /// rejects a corrupted part at write time instead of letting it sit until a
399 + /// download fails to decrypt. That is the reason parts are not requested in
400 + /// windows: a digest only exists once the part has been sealed, and only one
401 + /// part is ever in memory. The cost is one small round trip per part.
402 + async fn upload_blob_part(
403 + &self,
404 + hash: &str,
405 + start: &BlobMultipartStartResponse,
406 + size_bytes: i64,
407 + part_number: u32,
408 + body: Bytes,
409 + ) -> Result<String> {
410 + let checksum = base64::engine::general_purpose::STANDARD.encode(Sha256::digest(&body));
411 +
412 + let token = self.require_token()?;
413 + let req_body = Bytes::from(serde_json::to_vec(&BlobMultipartPartsRequest {
414 + hash: hash.to_string(),
415 + upload_id: start.upload_id.clone(),
416 + size_bytes,
417 + first_part: part_number,
418 + count: 1,
419 + checksums: vec![checksum.clone()],
420 + })?);
421 + let window: BlobMultipartPartsResponse = self
422 + .retry_request_json(Idempotency::ReadOnly, || {
423 + let req = self
424 + .http
425 + .post(&self.endpoints.blobs_multipart_parts)
426 + .bearer_auth(&token)
427 + .header("content-type", "application/json")
428 + .body(req_body.clone());
429 + async move { check_response(req.send().await?).await }
430 + })
431 + .await?;
432 +
433 + let part = window.parts.into_iter().next().ok_or_else(|| {
434 + SyncKitError::Internal(format!("server returned no URL for part {part_number}"))
435 + })?;
436 + // The two sides derive the plan independently, so a disagreement means
437 + // one of them is wrong about the geometry. Sending anyway would fail the
438 + // signed Content-Length at S3 with a far less legible error.
439 + if part.part_number != part_number as i32 || part.content_length != body.len() as u64 {
440 + return Err(SyncKitError::Internal(format!(
441 + "part geometry mismatch: server signed part {} for {} bytes, client has part {part_number} of {} bytes",
442 + part.part_number,
443 + part.content_length,
444 + body.len()
445 + )));
446 + }
447 +
448 + let resp = self
449 + .retry_request(
450 + Idempotency::IdempotentWrite {
451 + on: "(upload_id, part_number), re-PUTting a part replaces it",
452 + },
453 + || {
454 + let req = self
455 + .http_stream
456 + .put(&part.url)
457 + .header("content-type", "application/octet-stream")
458 + // Mandatory, not optional: the checksum is a signed
459 + // header, so omitting it fails the signature.
460 + .header("x-amz-checksum-sha256", &checksum)
461 + .body(body.clone());
462 + async move { check_response(req.send().await?).await }
463 + },
464 + )
465 + .await?;
466 +
467 + // S3 identifies each part by the ETag it returns; complete cannot be
468 + // assembled without it.
469 + resp.headers()
470 + .get(reqwest::header::ETAG)
471 + .and_then(|v| v.to_str().ok())
472 + .map(std::string::ToString::to_string)
473 + .ok_or_else(|| {
474 + SyncKitError::Internal(format!("part {part_number} upload returned no ETag"))
475 + })
476 + }
477 +
478 + /// Release an abandoned multipart session and the parts it holds.
479 + async fn blob_multipart_abort(&self, hash: &str, upload_id: &str) -> Result<()> {
480 + let token = self.require_token()?;
481 + let body = Bytes::from(serde_json::to_vec(&BlobMultipartAbortRequest {
482 + hash: hash.to_string(),
483 + upload_id: upload_id.to_string(),
484 + })?);
485 + self.retry_request(
486 + Idempotency::IdempotentWrite {
487 + on: "upload_id (aborting twice is a no-op)",
488 + },
489 + || {
490 + let req = self
491 + .http
492 + .post(&self.endpoints.blobs_multipart_abort)
493 + .bearer_auth(&token)
494 + .header("content-type", "application/json")
495 + .body(body.clone());
496 + async move { check_response(req.send().await?).await }
497 + },
498 + )
499 + .await?;
500 + Ok(())
Lines truncated
@@ -0,0 +1,278 @@
1 + //! Master-key setup and password lifecycle.
2 + //!
3 + //! Establishes the master key on a first or subsequent device by wrapping and
4 + //! unwrapping it against the server-held key envelope, caches it in the OS
5 + //! keychain when available, and re-wraps it on a password change.
6 +
7 + use bytes::Bytes;
8 + use std::sync::Arc;
9 + use tracing::instrument;
10 +
11 + use crate::{
12 + crypto,
13 + error::Result,
14 + keystore,
15 + types::{GetKeyResponse, PutKeyRequest},
16 + };
17 +
18 + use super::helpers::{Idempotency, check_response};
19 + use super::{SecretToken, SyncKitClient};
20 +
21 + impl SyncKitClient {
22 + /// Check if the server has an encrypted master key for this user.
23 + #[instrument(skip(self))]
24 + pub async fn has_server_key(&self) -> Result<bool> {
25 + let (url, token) = self.key_url_and_token()?;
26 +
27 + let result = self
28 + .retry_request(Idempotency::ReadOnly, || {
29 + let req = self.http.get(url).bearer_auth(&token);
30 + async move {
31 + let resp = req.send().await?;
32 + match resp.status().as_u16() {
33 + 200 | 404 => Ok(resp),
34 + status => {
35 + let message = crate::client::helpers::read_text_capped(
36 + resp,
37 + crate::client::helpers::MAX_CONTROL_BODY_BYTES,
38 + )
39 + .await;
40 + Err(crate::error::SyncKitError::Server {
41 + status,
42 + message,
43 + retry_after_secs: None,
44 + })
45 + }
46 + }
47 + }
48 + })
49 + .await?;
50 +
51 + Ok(result.status().as_u16() == 200)
52 + }
53 +
54 + /// First device: generate a new master key, encrypt it, push to server, cache in keychain.
55 + #[instrument(skip(self, password))]
56 + pub async fn setup_encryption_new(&self, password: &str) -> Result<()> {
57 + let (app_id, user_id) = self.require_session_ids()?;
58 +
59 + // Wrap the fresh key immediately so it is scrubbed on every early-return
60 + // path below (wrap/put/store can each fail), not left as a bare array on
61 + // the stack until the final move into the lock.
62 + let master_key = crypto::ZeroizeOnDrop(crypto::generate_master_key());
63 + let envelope = crypto::wrap_master_key(&master_key.0, password)?;
64 +
65 + // Push to server (expected_version 0 = first key)
66 + self.put_server_key(&envelope, 0).await?;
67 +
68 + // Cache in OS keychain
69 + keystore::store_key(app_id, user_id, &master_key.0)?;
70 +
71 + // Store in memory
72 + *self.master_key.write() = Some(master_key);
73 +
74 + tracing::info!("New master key generated and stored");
75 + Ok(())
76 + }
77 +
78 + /// Second device: pull encrypted master key from server, decrypt with password, cache.
79 + #[instrument(skip(self, password))]
80 + pub async fn setup_encryption_existing(&self, password: &str) -> Result<()> {
81 + let (app_id, user_id) = self.require_session_ids()?;
82 +
83 + let (envelope_json, _key_version) = self.get_server_key().await?;
84 + // Wrap immediately so a failed keychain store scrubs the key rather than
85 + // dropping a bare array.
86 + let master_key =
87 + crypto::ZeroizeOnDrop(crypto::unwrap_master_key(&envelope_json, password)?);
88 +
89 + // Cache in OS keychain
90 + keystore::store_key(app_id, user_id, &master_key.0)?;
91 +
92 + // Store in memory
93 + *self.master_key.write() = Some(master_key);
94 +
95 + tracing::info!("Master key recovered from server");
96 + Ok(())
97 + }
98 +
99 + /// Subsequent launches: try to load the master key from the OS keychain.
100 + /// Returns true if a key was found.
101 + pub fn try_load_key_from_keychain(&self) -> Result<bool> {
102 + let (app_id, user_id) = self.require_session_ids()?;
103 +
104 + if let Some(key) = keystore::load_key(app_id, user_id)? {
105 + *self.master_key.write() = Some(key);
106 + tracing::info!("Master key loaded from keychain");
107 + Ok(true)
108 + } else {
109 + Ok(false)
110 + }
111 + }
112 +
113 + /// Change the encryption password. Always validates the old password
114 + /// against the server envelope before re-encrypting with the new password.
115 + ///
116 + /// Even when the master key is cached in memory (normal case, user is
117 + /// logged in), the old password is verified by attempting to unwrap the
118 + /// server envelope. This prevents an attacker with session access from
119 + /// changing the password without knowing the current one.
120 + #[instrument(skip(self, old_password, new_password))]
121 + pub async fn change_password(&self, old_password: &str, new_password: &str) -> Result<()> {
122 + // Always fetch the envelope from the server and verify old_password
123 + // can decrypt it, regardless of whether we have a cached key.
124 + let (envelope_json, key_version) = self.get_server_key().await?;
125 + let verified_key = crypto::verify_password_against_envelope(&envelope_json, old_password)?;
126 +
127 + let master_key = crypto::ZeroizeOnDrop(verified_key);
128 +
129 + // If we hold the master key in memory, the envelope the server just handed
130 + // us MUST wrap that same key. A hostile server could otherwise substitute a
131 + // *different* envelope that also unwraps under `old_password` (e.g. one it
132 + // captured), making us re-wrap the wrong key under the new password and
133 + // lock the user out of their own (original-key-encrypted) data. Refuse.
134 + if let Some(cached) = self.master_key.read().as_ref()
135 + && cached.0 != master_key.0
136 + {
137 + return Err(crate::error::SyncKitError::Crypto(
138 + "server key envelope does not match the in-memory master key; refusing to change password".into(),
139 + ));
140 + }
141 +
142 + // Re-wrap with new password (generates fresh random salt)
143 + let new_envelope = crypto::wrap_master_key(&master_key.0, new_password)?;
144 +
145 + // Optimistic lock: reject if another device changed the password
146 + // between our GET and this PUT.
147 + self.put_server_key(&new_envelope, key_version).await?;
148 +
149 + tracing::info!("Encryption password changed");
150 + Ok(())
151 + }
152 +
153 + /// Build the key endpoint URL and extract the bearer token.
154 + pub(super) fn key_url_and_token(&self) -> Result<(&str, Arc<SecretToken>)> {
155 + let token = self.require_token()?;
156 + Ok((&self.endpoints.keys, token))
157 + }
158 +
159 + /// Upload the encrypted master key envelope to the server (PUT /api/sync/keys).
160 + ///
161 + /// `expected_version` is the key version the client expects. The server
162 + /// rejects with 409 if the current version doesn't match (another device
163 + /// changed the password). Use 0 for the initial key upload.
164 + pub(super) async fn put_server_key(
165 + &self,
166 + envelope_json: &str,
167 + expected_version: i32,
168 + ) -> Result<()> {
169 + let (url, token) = self.key_url_and_token()?;
170 +
171 + let body = Bytes::from(serde_json::to_vec(&PutKeyRequest {
172 + encrypted_key: envelope_json.to_string(),
173 + expected_version,
174 + })?);
175 +
176 + self.retry_request(
177 + Idempotency::IdempotentWrite {
178 + on: "expected_version optimistic lock",
179 + },
180 + || {
181 + let req = self
182 + .http
183 + .put(url)
184 + .bearer_auth(&token)
185 + .header("content-type", "application/json")
186 + .body(body.clone());
187 + async move { check_response(req.send().await?).await }
188 + },
189 + )
190 + .await?;
191 + Ok(())
192 + }
193 +
194 + /// Download the encrypted master key envelope from the server (GET /api/sync/keys).
195 + /// Returns `(envelope_json, key_version)`.
196 + pub(super) async fn get_server_key(&self) -> Result<(String, i32)> {
197 + let resp = self.get_server_key_full().await?;
198 + Ok((resp.encrypted_key, resp.key_version.unwrap_or(0)))
199 + }
200 +
201 + /// Download the full key state, including any in-progress rotation's
202 + /// `pending_key`. Used by `rotate_key` to resume an interrupted rotation
203 + /// with the already-committed key rather than minting a fresh one.
204 + pub(super) async fn get_server_key_full(&self) -> Result<GetKeyResponse> {
205 + let (url, token) = self.key_url_and_token()?;
206 +
207 + self.retry_request_json(Idempotency::ReadOnly, || {
208 + let req = self.http.get(url).bearer_auth(&token);
209 + async move { check_response(req.send().await?).await }
210 + })
211 + .await
212 + }
213 + }
214 +
215 + #[cfg(test)]
216 + mod tests {
217 + use crate::error::SyncKitError;
218 +
219 + use super::*;
220 +
221 + fn test_config() -> super::super::SyncKitConfig {
222 + super::super::SyncKitConfig {
223 + server_url: "https://example.com".to_string(),
224 + api_key: "test-api-key-123".to_string(),
225 + }
226 + }
227 +
228 + fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
229 + (
230 + crate::ids::AppId::new(
231 + uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
232 + ),
233 + crate::ids::UserId::new(
234 + uuid::Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
235 + ),
236 + )
237 + }
238 +
239 + #[test]
240 + fn key_url_and_token_builds_correct_url() {
241 + let client = SyncKitClient::new(test_config());
242 + let (app_id, user_id) = test_ids();
243 + client.restore_session("bearer-token", user_id, app_id);
244 +
245 + let (url, token) = client.key_url_and_token().unwrap();
246 + assert_eq!(url, "https://example.com/api/v1/sync/keys");
247 + assert_eq!(token.as_str(), "bearer-token");
248 + }
249 +
250 + #[test]
251 + fn key_url_and_token_fails_without_session() {
252 + let client = SyncKitClient::new(test_config());
253 + let err = client.key_url_and_token().unwrap_err();
254 + assert!(matches!(err, SyncKitError::NotAuthenticated));
255 + }
256 +
257 + // ── Key types ──
258 +
259 + #[test]
260 + fn put_key_request_serialization() {
261 + let req = PutKeyRequest {
262 + encrypted_key: "envelope-json-here".to_string(),
263 + expected_version: 1,
264 + };
265 +
266 + let json = serde_json::to_string(&req).unwrap();
267 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
268 + assert_eq!(parsed["encrypted_key"], "envelope-json-here");
269 + assert_eq!(parsed["expected_version"], 1);
270 + }
271 +
272 + #[test]
273 + fn get_key_response_deserialization() {
274 + let json = r#"{"encrypted_key": "{\"v\":1,\"salt\":\"...\",\"nonce\":\"...\",\"ciphertext\":\"...\"}"}"#;
275 + let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
276 + assert!(resp.encrypted_key.contains("\"v\":1"));
277 + }
278 + }
@@ -0,0 +1,481 @@
1 + //! Group sync: list the caller's groups, fetch their sealed GCK grant, and
2 + //! push/pull a group's shared changelog under its Group Content Key.
3 + //!
4 + //! The GCK is supplied by the caller, the `SyncStore` resolves it from the grant
5 + //! via the identity key (a later slice); these methods only encrypt/decrypt with
6 + //! it. Group entries bind `(group_id, table, row_id)` as AEAD associated data, so
7 + //! a ciphertext cannot be relocated across groups. Design: wiki
8 + //! synckit-multiscope-design.
9 +
10 + use bytes::Bytes;
11 + use tracing::instrument;
12 + use uuid::Uuid;
13 +
14 + use crate::{
15 + crypto,
16 + error::Result,
17 + identity::{IdentityKeypair, IdentityPublicKey, generate_group_key, seal_gck_to_member},
18 + ids::{DeviceId, GroupId, UserId},
19 + types::{
20 + ChangeEntry, GroupGrant, GroupMember, PullRequest, PullResponse, PulledChange,
21 + PushResponse, SyncGroup, WirePushRequest,
22 + },
23 + };
24 +
25 + use super::SyncKitClient;
26 + use super::helpers::{Idempotency, check_response};
27 +
28 + /// Request body for `POST /groups`. The id is client-generated so the admin's
29 + /// grant can be sealed bound to it before the group exists.
30 + #[derive(serde::Serialize)]
31 + struct CreateGroupBody<'a> {
32 + id: GroupId,
33 + name: &'a str,
34 + admin_sealed_gck: String,
35 + admin_pubkey: String,
36 + }
37 +
38 + /// Request body for `POST /groups/{id}/members`.
39 + #[derive(serde::Serialize)]
40 + struct AddMemberBody<'a> {
41 + member_email: &'a str,
42 + sealed_gck: String,
43 + member_pubkey: &'a str,
44 + #[serde(skip_serializing_if = "Option::is_none")]
45 + role: Option<&'a str>,
46 + }
47 +
48 + impl SyncKitClient {
49 + /// List the groups the authenticated user belongs to within this app.
50 + #[instrument(skip(self))]
51 + pub async fn list_groups(&self) -> Result<Vec<SyncGroup>> {
52 + let token = self.require_token()?;
53 + self.retry_request_json(Idempotency::ReadOnly, || {
54 + let req = self.http.get(self.endpoints.groups()).bearer_auth(&token);
55 + async move { check_response(req.send().await?).await }
56 + })
57 + .await
58 + }
59 +
60 + /// This user's identity public key (base64), derived from their master key.
61 + /// A member shares it with a group admin so the admin can seal the GCK to it
62 + /// (the paste-a-public-key model). Requires the master key to be loaded.
63 + pub fn my_identity_public_key(&self) -> Result<String> {
64 + let master = self.require_master_key()?;
65 + Ok(IdentityKeypair::from_master_key(&master)
66 + .public_key()
67 + .to_base64())
68 + }
69 +
70 + /// Create a group with the caller as its admin and first member. Mints a
71 + /// fresh Group Content Key, seals it to the caller's own identity public key
72 + /// (derived from the master key), and registers the group.
73 + ///
74 + /// The group id is generated client-side: the admin's grant is sealed with the
75 + /// group id bound as associated data, so the id must be known before the seal,
76 + /// before the server round-trip. Generation starts at 1.
77 + #[instrument(skip(self))]
78 + pub async fn create_group(&self, name: &str) -> Result<SyncGroup> {
79 + let token = self.require_token()?;
80 + let master = self.require_master_key()?;
81 + let identity = IdentityKeypair::from_master_key(&master);
82 + let group_id = GroupId::new(Uuid::new_v4());
83 + let gck = generate_group_key();
84 + let sealed = seal_gck_to_member(&gck, &identity.public_key(), &group_id.to_string(), 1)?;
85 +
86 + let body = Bytes::from(serde_json::to_vec(&CreateGroupBody {
87 + id: group_id,
88 + name,
89 + admin_sealed_gck: sealed,
90 + admin_pubkey: identity.public_key().to_base64(),
91 + })?);
92 + let url = self.endpoints.groups().to_string();
93 +
94 + // The client-chosen id makes this safe to retry: a replay hits the PK and
95 + // the group already exists, but the sealed grant is identical.
96 + self.retry_request_json(Idempotency::Keyed, || {
97 + let req = self
98 + .http
99 + .post(&url)
100 + .bearer_auth(&token)
101 + .header("content-type", "application/json")
102 + .body(body.clone());
103 + async move { check_response(req.send().await?).await }
104 + })
105 + .await
106 + }
107 +
108 + /// Add a member to a group by account email, sealing the group's current GCK
109 + /// to the member's identity public key (obtained out of band, the member
110 + /// pastes their public key from [`my_identity_public_key`]). Admin only.
111 + #[instrument(skip(self, member_pubkey_b64))]
112 + pub async fn add_member(
113 + &self,
114 + group_id: GroupId,
115 + member_email: &str,
116 + member_pubkey_b64: &str,
117 + ) -> Result<()> {
118 + let token = self.require_token()?;
119 + // Resolve the group's current GCK (and its generation) from our own grant.
120 + let grant = self.group_grant(group_id).await?;
121 + let master = self.require_master_key()?;
122 + let gck = Self::open_group_grant(&grant, &master, group_id)?;
123 +
124 + let member_pubkey = IdentityPublicKey::from_base64(member_pubkey_b64)?;
125 + let sealed = seal_gck_to_member(
126 + &gck,
127 + &member_pubkey,
128 + &group_id.to_string(),
129 + grant.gck_version,
130 + )?;
131 +
132 + let body = Bytes::from(serde_json::to_vec(&AddMemberBody {
133 + member_email,
134 + sealed_gck: sealed,
135 + member_pubkey: member_pubkey_b64,
136 + role: None,
137 + })?);
138 + let url = self.endpoints.group_members(group_id);
139 +
140 + self.retry_request(Idempotency::Keyed, || {
141 + let req = self
142 + .http
143 + .post(&url)
144 + .bearer_auth(&token)
145 + .header("content-type", "application/json")
146 + .body(body.clone());
147 + async move { check_response(req.send().await?).await }
148 + })
149 + .await?;
150 + Ok(())
151 + }
152 +
153 + /// List a group's members (id, role, joined-at). Admin only (the server gates
154 + /// it); use to show who can be removed.
155 + #[instrument(skip(self))]
156 + pub async fn list_members(&self, group_id: GroupId) -> Result<Vec<GroupMember>> {
157 + let token = self.require_token()?;
158 + let url = self.endpoints.group_members(group_id);
159 + self.retry_request_json(Idempotency::ReadOnly, || {
160 + let req = self.http.get(&url).bearer_auth(&token);
161 + async move { check_response(req.send().await?).await }
162 + })
163 + .await
164 + }
165 +
166 + /// Remove a member from a group by user id. Admin only. The server membership
167 + /// ACL revokes the member's group read/write access immediately.
168 + ///
169 + /// Forward secrecy for writes made *after* removal requires rotating the GCK
170 + /// (re-mint, re-seal to the remaining members, bump the generation), which the
171 + /// server does not yet expose, a later slice. This method performs the
172 + /// membership revocation only; data the member already pulled is in their hands.
173 + #[instrument(skip(self))]
174 + pub async fn remove_member(&self, group_id: GroupId, member: UserId) -> Result<()> {
175 + let token = self.require_token()?;
176 + let url = self.endpoints.group_member(group_id, member);
177 + self.retry_request(Idempotency::Keyed, || {
178 + let req = self.http.delete(&url).bearer_auth(&token);
179 + async move { check_response(req.send().await?).await }
180 + })
181 + .await?;
182 + Ok(())
183 + }
184 +
185 + /// Fetch the caller's own sealed GCK grant for a group. Open it with the
186 + /// member's identity private key to recover the GCK.
187 + #[instrument(skip(self))]
188 + pub async fn group_grant(&self, group_id: GroupId) -> Result<GroupGrant> {
189 + let token = self.require_token()?;
190 + let url = self.endpoints.group_grant(group_id);
191 + self.retry_request_json(Idempotency::ReadOnly, || {
192 + let req = self.http.get(&url).bearer_auth(&token);
193 + async move { check_response(req.send().await?).await }
194 + })
195 + .await
196 + }
197 +
198 + /// Push encrypted changes to a group's shared changelog under its GCK.
199 + /// Returns the server cursor after the push.
200 + #[instrument(skip(self, gck, changes))]
201 + pub async fn group_push(
202 + &self,
203 + group_id: GroupId,
204 + gck: &[u8; 32],
205 + device_id: DeviceId,
206 + changes: Vec<ChangeEntry>,
207 + ) -> Result<i64> {
208 + let token = self.require_token()?;
209 + let group_str = group_id.to_string();
210 + let wire_changes = changes
211 + .into_iter()
212 + .map(|c| Self::encrypt_group_change_with_key(&group_str, c, gck))
213 + .collect::<Result<Vec<_>>>()?;
214 +
215 + let body = Bytes::from(serde_json::to_vec(&WirePushRequest {
216 + device_id,
217 + batch_id: Uuid::new_v4(),
218 + changes: wire_changes,
219 + })?);
220 + let url = self.endpoints.group_push(group_id);
221 +
222 + let push_resp: PushResponse = self
223 + .retry_request_json(Idempotency::Keyed, || {
224 + let req = self
225 + .http
226 + .post(&url)
227 + .bearer_auth(&token)
228 + .header("content-type", "application/json")
229 + .body(body.clone());
230 + async move { check_response(req.send().await?).await }
231 + })
232 + .await?;
233 + Ok(push_resp.cursor)
234 + }
235 +
236 + /// Pull a group's changes since `cursor`, decrypting under its GCK. Returns
237 + /// `(changes, new_cursor, has_more)` with per-row device/seq metadata, ready
238 + /// for conflict resolution. Group pull has no master-key-rotation window (a
239 + /// group's key rotates server-side, re-encrypted in place), so the decrypt is
240 + /// a single-key pass.
241 + #[instrument(skip(self, gck))]
242 + pub async fn group_pull_rich(
243 + &self,
244 + group_id: GroupId,
245 + gck: &[u8; 32],
246 + device_id: DeviceId,
247 + cursor: i64,
248 + ) -> Result<(Vec<PulledChange>, i64, bool)> {
249 + let token = self.require_token()?;
250 + let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
251 + let url = self.endpoints.group_pull(group_id);
252 +
253 + let pull_resp: PullResponse = self
254 + .retry_request_json(Idempotency::ReadOnly, || {
255 + let req = self
256 + .http
257 + .post(&url)
258 + .bearer_auth(&token)
259 + .header("content-type", "application/json")
260 + .body(body.clone());
261 + async move { check_response(req.send().await?).await }
262 + })
263 + .await?;
264 +
265 + let group_str = group_id.to_string();
266 + let changes = pull_resp
267 + .changes
268 + .into_iter()
269 + .map(|c| Self::decrypt_group_change_to_pulled(&group_str, c, gck))
270 + .collect::<Result<Vec<_>>>()?;
271 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
272 + }
273 +
274 + /// Resolve a group's decrypted Group Content Key for `gck_version`, fetching
275 + /// and opening the sealed grant on a cache miss.
276 + ///
277 + /// Fast path: a cached GCK at the requested version is returned without a
278 + /// network call. On a miss (or a version mismatch, the group's key rotated),
279 + /// the sealed grant is fetched, opened with the identity keypair derived from
280 + /// this client's master key, and cached under the grant's actual version. The
281 + /// master key and identity secret never leave the client, the `SyncStore`
282 + /// only ever receives the resolved GCK.
283 + // Consumed by `sync_now`'s scope iteration in slice 4.
284 + #[allow(dead_code)]
285 + pub(crate) async fn group_content_key(
286 + &self,
287 + group_id: GroupId,
288 + gck_version: i32,
289 + ) -> Result<crypto::ZeroizeOnDrop> {
290 + if let Some((v, gck)) = self.gck_cache.read().get(&group_id)
291 + && *v == gck_version
292 + {
293 + return Ok(crypto::ZeroizeOnDrop(**gck));
294 + }
295 +
296 + let grant = self.group_grant(group_id).await?;
297 + let master = self.require_master_key()?;
298 + let gck = Self::open_group_grant(&grant, &master, group_id)?;
299 + let out = crypto::ZeroizeOnDrop(*gck);
300 + self.gck_cache
301 + .write()
302 + .insert(group_id, (grant.gck_version, gck));
303 + Ok(out)
304 + }
305 +
306 + /// Open a sealed grant with the identity keypair derived from `master_key`.
307 + /// Pure (no I/O), so the derive-and-open path is unit-testable.
308 + fn open_group_grant(
309 + grant: &GroupGrant,
310 + master_key: &[u8; 32],
311 + group_id: GroupId,
312 + ) -> Result<crypto::ZeroizeOnDrop> {
313 + let identity = crate::identity::IdentityKeypair::from_master_key(master_key);
314 + let gck = crate::identity::open_gck_grant(
315 + &grant.sealed_gck,
316 + &identity,
317 + &group_id.to_string(),
318 + grant.gck_version,
319 + )?;
320 + Ok(crypto::ZeroizeOnDrop(gck))
321 + }
322 +
323 + /// Drop a group's cached GCK, forcing the next
324 + /// [`group_content_key`](Self::group_content_key) to re-fetch its grant. Call
325 + /// on a decrypt failure (a stale key after a rotation this client missed).
326 + // Consumed by `sync_now`'s scope iteration in slice 4.
327 + #[allow(dead_code)]
328 + pub(crate) fn invalidate_gck(&self, group_id: GroupId) {
329 + self.gck_cache.write().remove(&group_id);
330 + }
331 + }
332 +
333 + #[cfg(test)]
334 + mod tests {
335 + use super::*;
336 + use crate::error::SyncKitError;
337 + use crate::ids::DeviceId;
338 + use crate::types::{ChangeOp, Hlc, PullChangeEntry, WireChangeEntry};
339 + use chrono::Utc;
340 +
341 + fn insert(table: &str, row: &str, data: serde_json::Value) -> ChangeEntry {
342 + ChangeEntry {
343 + table: table.into(),
344 + op: ChangeOp::Insert,
345 + row_id: row.into(),
346 + timestamp: Utc::now(),
347 + hlc: Hlc::zero(DeviceId::nil()),
348 + data: Some(data),
349 + extra: serde_json::Map::default(),
350 + }
351 + }
352 +
353 + fn to_pull(wire: WireChangeEntry, device: DeviceId, seq: i64) -> PullChangeEntry {
354 + PullChangeEntry {
355 + seq,
356 + device_id: device,
357 + table: wire.table,
358 + op: wire.op,
359 + row_id: wire.row_id,
360 + timestamp: wire.timestamp,
361 + data: wire.data,
362 + key_id: None,
363 + }
364 + }
365 +
366 + #[test]
367 + fn group_change_encrypt_decrypt_roundtrip_preserves_data_and_hlc() {
368 + let gck = crypto::generate_master_key();
369 + let device = DeviceId::new(uuid::Uuid::new_v4());
370 + let hlc = Hlc {
371 + wall_ms: 7,
372 + counter: 1,
373 + node: device,
374 + };
375 + let mut e = insert("tasks", "r1", serde_json::json!({ "title": "shared" }));
376 + e.hlc = hlc;
377 +
378 + let wire = SyncKitClient::encrypt_group_change_with_key("grp-1", e, &gck).unwrap();
379 + let pulled =
380 + SyncKitClient::decrypt_group_change_to_pulled("grp-1", to_pull(wire, device, 3), &gck)
381 + .unwrap();
382 +
383 + assert_eq!(pulled.seq, 3);
384 + assert_eq!(pulled.device_id, device);
385 + assert_eq!(
386 + pulled.entry.data.unwrap(),
387 + serde_json::json!({ "title": "shared" })
388 + );
389 + assert_eq!(pulled.entry.hlc, hlc);
390 + }
391 +
392 + #[test]
393 + fn group_ciphertext_cannot_be_opened_under_another_group() {
394 + let gck = crypto::generate_master_key();
395 + let wire = SyncKitClient::encrypt_group_change_with_key(
396 + "grp-A",
397 + insert("t", "r", serde_json::json!(1)),
398 + &gck,
399 + )
400 + .unwrap();
401 + // Same GCK, but a different group id in the AAD: the open fails closed.
402 + let err = SyncKitClient::decrypt_group_change_to_pulled(
403 + "grp-B",
404 + to_pull(wire, DeviceId::nil(), 1),
405 + &gck,
406 + )
407 + .unwrap_err();
408 + assert!(matches!(err, SyncKitError::DecryptionFailed));
409 + }
410 +
411 + #[test]
412 + fn wrong_gck_fails_closed() {
413 + let gck = crypto::generate_master_key();
414 + let other = crypto::generate_master_key();
415 + let wire = SyncKitClient::encrypt_group_change_with_key(
416 + "grp-1",
417 + insert("t", "r", serde_json::json!(1)),
418 + &gck,
419 + )
420 + .unwrap();
421 + let err = SyncKitClient::decrypt_group_change_to_pulled(
422 + "grp-1",
423 + to_pull(wire, DeviceId::nil(), 1),
424 + &other,
425 + )
426 + .unwrap_err();
427 + assert!(matches!(err, SyncKitError::DecryptionFailed));
428 + }
429 +
430 + #[test]
431 + fn create_group_admin_self_grant_is_openable() {
432 + // Mirrors create_group's sealing path: a client-generated group id, the
433 + // GCK sealed to the admin's own derived identity at generation 1. The
434 + // admin's device must be able to open its own grant back to the same GCK,
435 + // or it would be locked out of the group it just created.
436 + let master = crypto::generate_master_key();
437 + let identity = crate::identity::IdentityKeypair::from_master_key(&master);
438 + let group = GroupId::new(uuid::Uuid::new_v4());
439 + let gck = crate::identity::generate_group_key();
440 + let sealed = crate::identity::seal_gck_to_member(
441 + &gck,
442 + &identity.public_key(),
443 + &group.to_string(),
444 + 1,
445 + )
446 + .unwrap();
447 + let grant = GroupGrant {
448 + sealed_gck: sealed,
449 + gck_version: 1,
450 + };
451 + let opened = SyncKitClient::open_group_grant(&grant, &master, group).unwrap();
452 + assert_eq!(*opened, gck);
453 + }
454 +
455 + #[test]
456 + fn open_group_grant_recovers_gck_via_derived_identity() {
457 + // The admin seals the GCK to the member's derived public key; the member's
458 + // client re-derives the identity from its master key and opens the grant.
459 + let group = GroupId::new(uuid::Uuid::new_v4());
460 + let master_key = crypto::generate_master_key();
461 + let member = crate::identity::IdentityKeypair::from_master_key(&master_key);
462 + let gck = crate::identity::generate_group_key();
463 + let sealed =
464 + crate::identity::seal_gck_to_member(&gck, &member.public_key(), &group.to_string(), 4)
465 + .unwrap();
466 + let grant = GroupGrant {
467 + sealed_gck: sealed,
468 + gck_version: 4,
469 + };
470 +
471 + let opened = SyncKitClient::open_group_grant(&grant, &master_key, group).unwrap();
472 + assert_eq!(*opened, gck);
473 +
474 + // A wrong-version grant (AAD mismatch) fails closed.
475 + let bad = GroupGrant {
476 + sealed_gck: grant.sealed_gck.clone(),
477 + gck_version: 5,
478 + };
479 + assert!(SyncKitClient::open_group_grant(&bad, &master_key, group).is_err());
480 + }
481 + }
@@ -0,0 +1,1455 @@
1 + //! Internal HTTP plumbing shared across the client modules.
2 + //!
3 + //! Response-status to [`SyncKitError`] mapping, idempotency-key generation,
4 + //! size-capped body reads (so a hostile server cannot exhaust memory on a
5 + //! control response), wire-version negotiation, and JWT-expiry parsing. This
6 + //! module is `pub(crate)`: none of it is part of the SDK's public API.
7 +
8 + use base64::Engine;
9 +
10 + use crate::{
11 + crypto,
12 + error::{Result, SyncKitError},
13 + types::{ChangeEntry, Hlc, PullChangeEntry, WireChangeEntry},
14 + };
15 +
16 + use super::{BASE_DELAY, MAX_RETRIES, SyncKitClient};
17 +
18 + /// Proof, supplied at the call site, that retrying an operation is sound.
19 + ///
20 + /// Retry replays the request, so it is only safe when the server treats the
21 + /// operation idempotently. Requiring this argument makes that judgement an
22 + /// explicit, reviewable decision, and the judgement has runtime teeth:
23 + /// [`may_retry`](Self::may_retry) gates the retry loop, so an
24 + /// [`Unsafe`](Self::Unsafe) operation physically cannot be auto-replayed.
25 + ///
26 + /// The variants split "safe to replay" into a **read** and a **write that the
27 + /// server dedups**, and the write variant must *name* what makes replay
28 + /// idempotent. That is the forcing function: a mutating create tagged
29 + /// auto-retryable without naming its dedup key does not compile, so the
30 + /// `ota_create_release`/`ota_register_artifact` class, a `CREATE` that silently
31 + /// relied on a server unique constraint nobody had written down, cannot recur
32 + /// unnoticed.
33 + #[derive(Clone, Copy, Debug)]
34 + pub(super) enum Idempotency {
35 + /// A read with no server-side effect (a `GET`, or a `POST` that only reads).
36 + /// Replay is trivially safe.
37 + ReadOnly,
38 + /// A write whose replay collapses to a single effect because the server
39 + /// dedups on `on`, a content-addressed key, an optimistic version, or a
40 + /// `UNIQUE` constraint. `on` documents *why* replay is harmless, at the call
41 + /// site, and naming it is mandatory.
42 + IdempotentWrite {
43 + /// The key/constraint the server dedups on (e.g. `"(app_id, version)
44 + /// unique"`). Documentation, surfaced in review and logs.
45 + on: &'static str,
46 + },
47 + /// Carries a client-generated idempotency key (e.g. push's `batch_id`) so
48 + /// the server collapses duplicate deliveries into one effect.
49 + Keyed,
50 + /// Not idempotent and not idempotency-keyed: replaying it would mint
51 + /// duplicate external state (e.g. a second Stripe Checkout session). The
52 + /// retry helpers attempt it exactly once.
53 + Unsafe,
54 + }
55 +
56 + impl Idempotency {
57 + /// Whether the retry helpers may replay this operation on a transient error.
58 + fn may_retry(self) -> bool {
59 + matches!(
60 + self,
61 + Idempotency::ReadOnly | Idempotency::IdempotentWrite { .. } | Idempotency::Keyed
62 + )
63 + }
64 +
65 + /// The documented dedup basis for an idempotent write (`None` for the other
66 + /// variants), surfaced in the retry log so a replayed request records *why*
67 + /// replay was safe.
68 + fn dedup_basis(self) -> Option<&'static str> {
69 + match self {
70 + Idempotency::IdempotentWrite { on } => Some(on),
71 + _ => None,
72 + }
73 + }
74 + }
75 +
76 + /// Upper bound on a control/JSON/error response body read into memory. Blob
77 + /// bodies stream through [`super::SyncKitClient::blob_download`]'s own 4 GiB cap;
78 + /// every *other* response, JSON control replies, OTA manifests, SSE-open errors,
79 + /// 4xx/5xx error bodies, is small, so a hostile or buggy server streaming a
80 + /// multi-gigabyte body into one of them is a pure OOM lever. 8 MiB is generous
81 + /// for any legitimate control body.
82 + pub(super) const MAX_CONTROL_BODY_BYTES: usize = 8 * 1024 * 1024;
83 +
84 + /// Read a response body into memory with a hard byte cap, the ONE sanctioned
85 + /// way to buffer a non-blob body.
86 + ///
87 + /// `reqwest`'s `Response::json`/`text`/`bytes` read the whole body with no size
88 + /// limit, so a hostile server can stream an arbitrarily large body and OOM the
89 + /// client (worst case: the public, unauthenticated OTA updater check). This
90 + /// drains `bytes_stream()` and aborts the instant the running total exceeds
91 + /// `limit`, and fast-rejects on an honest oversized `Content-Length` before
92 + /// reading a byte. Those raw reader methods are banned by `clippy.toml`
93 + /// (`disallowed-methods`) so a new call site cannot reintroduce an uncapped read.
94 + pub(super) async fn read_body_capped(
95 + resp: reqwest::Response,
96 + limit: usize,
97 + ) -> Result<bytes::Bytes> {
98 + use tokio_stream::StreamExt;
99 + if let Some(len) = resp.content_length()
100 + && len > limit as u64
101 + {
102 + return Err(SyncKitError::Internal(format!(
103 + "response Content-Length {len} exceeds {limit}-byte cap"
104 + )));
105 + }
106 + let mut stream = resp.bytes_stream();
107 + let mut buf = bytes::BytesMut::new();
108 + while let Some(chunk) = stream.next().await {
109 + let chunk = chunk.map_err(SyncKitError::Http)?;
110 + if buf.len() + chunk.len() > limit {
111 + return Err(SyncKitError::Internal(format!(
112 + "response body exceeds {limit}-byte cap"
113 + )));
114 + }
115 + buf.extend_from_slice(&chunk);
116 + }
117 + Ok(buf.freeze())
118 + }
119 +
120 + /// [`read_body_capped`] + JSON deserialize, the capped replacement for
121 + /// `resp.json::<T>()`.
122 + pub(super) async fn read_json_capped<T: serde::de::DeserializeOwned>(
123 + resp: reqwest::Response,
124 + limit: usize,
125 + ) -> Result<T> {
126 + let bytes = read_body_capped(resp, limit).await?;
127 + Ok(serde_json::from_slice(&bytes)?)
128 + }
129 +
130 + /// [`read_body_capped`] + lossy UTF-8, the capped replacement for `resp.text()`
131 + /// on error bodies (which are only logged, so lossy decoding and a `""` fallback
132 + /// on an oversized/failed read are fine).
133 + pub(super) async fn read_text_capped(resp: reqwest::Response, limit: usize) -> String {
134 + match read_body_capped(resp, limit).await {
135 + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
136 + Err(_) => String::new(),
137 + }
138 + }
139 +
140 + /// Current on-wire HLC-envelope version, written into the `__skver` tag.
141 + const ENVELOPE_VERSION: u64 = 2;
142 +
143 + /// The on-wire HLC-envelope version, parsed from the `__skver` tag.
144 + ///
145 + /// Parsing is the explicit dispatch point: an unknown (future) version is a
146 + /// hard error, never a silent fallback. That closes the X2 hazard, a protocol
147 + /// bump that an older client cannot understand fails loudly instead of being
148 + /// mis-decoded as a bare row and corrupting the clock.
149 + #[derive(Clone, Copy, PartialEq, Eq, Debug)]
150 + enum WireVersion {
151 + /// The current envelope: `{ __skver: 2, __skhlc, data }`.
152 + V2,
153 + }
154 +
155 + impl WireVersion {
156 + fn parse(tag: u64) -> Result<Self> {
157 + match tag {
158 + 2 => Ok(WireVersion::V2),
159 + other => Err(SyncKitError::Crypto(format!(
160 + "unknown sync envelope version {other}; this client is too old to read it"
161 + ))),
162 + }
163 + }
164 + }
165 +
166 + impl SyncKitClient {
167 + /// Retry an async HTTP operation with exponential backoff.
168 + ///
169 + /// Retries on transient errors (network failures, 5xx, 429) up to [`MAX_RETRIES`]
170 + /// times with delays of 1s, 2s, 4s. Returns the last error if all attempts fail.
171 + /// Client errors (4xx except 429) are considered permanent and returned immediately.
172 + ///
173 + /// `idempotency` is the caller's proof that replay is safe, see [`Idempotency`].
174 + pub(super) async fn retry_request<F, Fut>(
175 + &self,
176 + idempotency: Idempotency,
177 + mut operation: F,
178 + ) -> Result<reqwest::Response>
179 + where
180 + F: FnMut() -> Fut,
181 + Fut: std::future::Future<Output = Result<reqwest::Response>>,
182 + {
183 + // A non-idempotent operation gets exactly one attempt: zero retries.
184 + let max_attempts = if idempotency.may_retry() {
185 + MAX_RETRIES
186 + } else {
187 + 0
188 + };
189 + let mut last_err = None;
190 +
191 + for attempt in 0..=max_attempts {
192 + match operation().await {
193 + Ok(resp) => return Ok(resp),
194 + Err(err) => {
195 + if !is_transient(&err) {
196 + return Err(err);
197 + }
198 +
199 + if attempt < max_attempts {
200 + let delay = retry_delay(&err, attempt);
201 + tracing::debug!(
202 + attempt = attempt + 1,
203 + max_retries = MAX_RETRIES,
204 + delay_ms = delay.as_millis() as u64,
205 + error = %err,
206 + idempotency_basis = idempotency.dedup_basis(),
207 + "Transient error, retrying after backoff",
208 + );
209 + tokio::time::sleep(delay).await;
210 + }
211 +
212 + last_err = Some(err);
213 + }
214 + }
215 + }
216 +
217 + Err(last_err.expect("loop ran at least once"))
218 + }
219 +
220 + /// Retry an HTTP operation and deserialize the JSON response body inside
221 + /// the retry loop. This ensures a transient body-read failure (truncated
222 + /// response, connection reset mid-body) is retried rather than surfacing
223 + /// as a permanent error after the server already committed the operation.
224 + ///
225 + /// `idempotency` is the caller's proof that replay is safe, see [`Idempotency`].
226 + pub(super) async fn retry_request_json<F, Fut, T>(
227 + &self,
228 + idempotency: Idempotency,
229 + mut operation: F,
230 + ) -> Result<T>
231 + where
232 + F: FnMut() -> Fut,
233 + Fut: std::future::Future<Output = Result<reqwest::Response>>,
234 + T: serde::de::DeserializeOwned,
235 + {
236 + // A non-idempotent operation gets exactly one attempt: zero retries.
237 + let max_attempts = if idempotency.may_retry() {
238 + MAX_RETRIES
239 + } else {
240 + 0
241 + };
242 + let mut last_err = None;
243 +
244 + for attempt in 0..=max_attempts {
245 + match operation().await {
246 + Ok(resp) => match read_json_capped::<T>(resp, MAX_CONTROL_BODY_BYTES).await {
247 + Ok(parsed) => return Ok(parsed),
248 + Err(e) => {
249 + let err = e;
250 + if attempt < max_attempts {
251 + let delay = retry_delay(&err, attempt);
252 + tracing::debug!(
253 + attempt = attempt + 1,
254 + max_retries = MAX_RETRIES,
255 + delay_ms = delay.as_millis() as u64,
256 + error = %err,
257 + "Response body read failed, retrying",
258 + );
259 + tokio::time::sleep(delay).await;
260 + }
261 + last_err = Some(err);
262 + }
263 + },
264 + Err(err) => {
265 + if !is_transient(&err) {
266 + return Err(err);
267 + }
268 +
269 + if attempt < max_attempts {
270 + let delay = retry_delay(&err, attempt);
271 + tracing::debug!(
272 + attempt = attempt + 1,
273 + max_retries = max_attempts,
274 + delay_ms = delay.as_millis() as u64,
275 + error = %err,
276 + "Transient error, retrying after backoff",
277 + );
278 + tokio::time::sleep(delay).await;
279 + }
280 +
281 + last_err = Some(err);
282 + }
283 + }
284 + }
285 +
286 + Err(last_err.expect("loop ran at least once"))
287 + }
288 +
289 + /// Encrypt a change entry for the wire. Every change now seals an HLC
290 + /// envelope (Deletes included), so the master key is always required.
291 + #[cfg(test)]
292 + pub(super) fn encrypt_change(&self, entry: ChangeEntry) -> Result<WireChangeEntry> {
293 + let master_key = self.require_master_key()?;
294 + Self::encrypt_change_with_key(entry, &master_key)
295 + }
296 +
297 + /// Decrypt a pulled legacy entry that has no encrypted payload (a pre-HLC
298 + /// Delete). The HLC is synthesized from the entry's `client_timestamp`. No key
299 + /// needed.
300 + #[cfg(test)]
301 + pub(super) fn decrypt_change_no_data(entry: PullChangeEntry) -> Result<ChangeEntry> {
302 + debug_assert!(entry.data.is_none());
303 + Ok(ChangeEntry {
304 + table: entry.table,
305 + op: entry.op,
306 + row_id: entry.row_id,
307 + hlc: Hlc::from_legacy(entry.timestamp.timestamp_millis(), entry.device_id),
308 + timestamp: entry.timestamp,
309 + data: None,
310 + extra: serde_json::Map::default(),
311 + })
312 + }
313 +
314 + /// Wrap a change's HLC and payload into one JSON envelope. Encrypting the
315 + /// envelope (rather than the bare row payload) is what carries the HLC inside
316 + /// the E2E ciphertext, including for Deletes, which have no row payload. The
317 + /// server stores the ciphertext opaquely and never sees the clock.
318 + ///
319 + /// The `__skver` tag is a positive, explicit version marker. A reader
320 + /// dispatches on it (see [`WireVersion`]) rather than structurally guessing,
321 + /// so a future format bump is rejected loudly instead of silently misread.
322 + fn hlc_envelope(hlc: &Hlc, data: Option<&serde_json::Value>) -> serde_json::Value {
323 + serde_json::json!({ "__skver": ENVELOPE_VERSION, "__skhlc": hlc, "data": data })
324 + }
325 +
326 + /// Split a decrypted payload back into `(hlc, data)`.
327 + ///
328 + /// Dispatch is explicit, not structural:
329 + /// - A `__skver` tag means a versioned envelope; the version is parsed via
330 + /// [`WireVersion::parse`], which **errors loudly** on an unknown future
331 + /// version rather than silently falling back to a bare-row read (which
332 + /// would corrupt the clock, the X2 hazard).
333 + /// - No `__skver` but an embedded `__skhlc` that parses is a gen-1 envelope
334 + /// (predates the version tag).
335 + /// - Anything else is a legacy bare-row payload whose HLC is synthesized
336 + /// from `node` + `timestamp_ms`.
337 + fn split_hlc_envelope(
338 + decrypted: serde_json::Value,
339 + node: crate::ids::DeviceId,
340 + timestamp_ms: i64,
341 + ) -> Result<(Hlc, Option<serde_json::Value>)> {
342 + if let Some(obj) = decrypted.as_object() {
343 + if let Some(tag) = obj.get("__skver") {
344 + // Explicit version present: dispatch, rejecting unknown loudly.
345 + let tag = tag.as_u64().ok_or_else(|| {
346 + SyncKitError::Crypto("envelope __skver tag is not an integer".into())
347 + })?;
348 + return match WireVersion::parse(tag)? {
349 + WireVersion::V2 => {
350 + let hlc = obj
351 + .get("__skhlc")
352 + .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
353 + .ok_or_else(|| {
354 + SyncKitError::Crypto("v2 envelope missing __skhlc".into())
355 + })?;
356 + let data = obj.get("data").cloned().filter(|v| !v.is_null());
357 + Ok((hlc, data))
358 + }
359 + };
360 + }
361 + // gen-1 envelope: embedded HLC, no version tag.
362 + if let Some(hlc) = obj
363 + .get("__skhlc")
364 + .and_then(|v| serde_json::from_value::<Hlc>(v.clone()).ok())
365 + {
366 + let data = obj.get("data").cloned().filter(|v| !v.is_null());
367 + return Ok((hlc, data));
368 + }
369 + }
370 + // Legacy bare-row payload: the decrypted value is the row data itself.
371 + Ok((Hlc::from_legacy(timestamp_ms, node), Some(decrypted)))
372 + }
373 +
374 + /// Encrypt with a pre-loaded key. Used by `push()` to avoid per-entry lock
375 + /// acquisition. Every change (including Deletes) is encrypted, because the HLC
376 + /// envelope always needs sealing. The ciphertext is bound to its
377 + /// `(table, row_id)` address via AEAD associated data, so a server cannot
378 + /// relocate it to another row/table without the open failing closed.
379 + pub(super) fn encrypt_change_with_key(
380 + entry: ChangeEntry,
381 + master_key: &[u8; 32],
382 + ) -> Result<WireChangeEntry> {
383 + let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
384 + let envelope = Self::hlc_envelope(&entry.hlc, entry.data.as_ref());
385 + let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, master_key, &ctx)?);
386 +
387 + Ok(WireChangeEntry {
388 + table: entry.table,
389 + op: entry.op,
390 + row_id: entry.row_id,
391 + timestamp: entry.timestamp,
392 + data: encrypted_data,
393 + })
394 + }
395 +
396 + /// Decrypt the data field of a pulled change entry.
397 + #[cfg(test)]
398 + pub(super) fn decrypt_change(&self, entry: PullChangeEntry) -> Result<ChangeEntry> {
399 + if entry.data.is_some() {
400 + let master_key = self.require_master_key()?;
401 + Self::decrypt_change_with_key(entry, &master_key)
402 + } else {
403 + Self::decrypt_change_no_data(entry)
404 + }
405 + }
406 +
407 + /// Decrypt with a pre-loaded key, preserving `device_id` and `seq` in a [`PulledChange`].
408 + ///
409 + /// Used by `pull_rich()` to produce conflict-detection-ready results.
410 + pub(super) fn decrypt_change_to_pulled(
411 + entry: PullChangeEntry,
412 + master_key: &[u8; 32],
413 + ) -> Result<crate::types::PulledChange> {
414 + let device_id = entry.device_id;
415 + let seq = entry.seq;
416 + let decrypted = Self::decrypt_change_with_key(entry, master_key)?;
417 + Ok(crate::types::PulledChange {
418 + entry: decrypted,
419 + device_id,
420 + seq,
421 + })
422 + }
423 +
424 + /// Decrypt a pulled entry during a rotation window, selecting the key by the
425 + /// entry's `key_id`. Generic over the decrypt step so both the plain-pull
426 + /// (`ChangeEntry`) and rich-pull (`PulledChange`) paths share exactly this
427 + /// selection logic, including the unknown-`key_id` fallback that tries the
428 + /// primary key then the pending key. Previously the live pull path
429 + /// reimplemented a fallback-less variant while the tested one sat unused.
430 + pub(super) fn decrypt_with_rotation_keys<T, F>(
431 + entry: PullChangeEntry,
432 + primary_key: &[u8; 32],
433 + primary_key_id: i32,
434 + pending_key: &[u8; 32],
435 + pending_key_id: i32,
436 + decrypt_fn: &F,
437 + ) -> Result<T>
438 + where
439 + F: Fn(PullChangeEntry, &[u8; 32]) -> Result<T>,
440 + {
441 + let effective_key_id = entry.key_id.unwrap_or(1);
442 + if effective_key_id == pending_key_id {
443 + decrypt_fn(entry, pending_key)
444 + } else if effective_key_id == primary_key_id || effective_key_id <= 1 {
445 + decrypt_fn(entry, primary_key)
446 + } else {
447 + // Unknown key_id, try primary, then fall back to pending.
448 + match decrypt_fn(entry.clone(), primary_key) {
449 + Ok(result) => Ok(result),
450 + Err(_) => decrypt_fn(entry, pending_key),
451 + }
452 + }
453 + }
454 +
455 + /// Encrypt a change for a **group** changelog, sealed under the group's GCK
456 + /// and bound to `(group_id, table, row_id)` as AEAD associated data. Same HLC
457 + /// envelope as the personal path; the extra `group_id` binding makes a
458 + /// ciphertext non-relocatable across groups (the p1 crypto guarantee).
459 + pub(super) fn encrypt_group_change_with_key(
460 + group_id: &str,
461 + entry: ChangeEntry,
462 + gck: &[u8; 32],
463 + ) -> Result<WireChangeEntry> {
464 + let ctx = crypto::AeadContext::group_entry(group_id, &entry.table, &entry.row_id);
465 + let envelope = Self::hlc_envelope(&entry.hlc, entry.data.as_ref());
466 + let encrypted_data = Some(crypto::encrypt_json_aad(&envelope, gck, &ctx)?);
467 +
468 + Ok(WireChangeEntry {
469 + table: entry.table,
470 + op: entry.op,
471 + row_id: entry.row_id,
472 + timestamp: entry.timestamp,
473 + data: encrypted_data,
474 + })
475 + }
476 +
477 + /// Decrypt a pulled group entry under the group's GCK, preserving `device_id`
478 + /// and `seq` in a [`PulledChange`]. The counterpart to
479 + /// [`encrypt_group_change_with_key`](Self::encrypt_group_change_with_key).
480 + /// Group pull has no master-key-rotation window (a group's key rotates
481 + /// server-side, re-encrypted in place), so this is the whole decrypt path.
482 + pub(super) fn decrypt_group_change_to_pulled(
483 + group_id: &str,
484 + entry: PullChangeEntry,
485 + gck: &[u8; 32],
486 + ) -> Result<crate::types::PulledChange> {
487 + let device_id = entry.device_id;
488 + let seq = entry.seq;
489 + let ctx = crypto::AeadContext::group_entry(group_id, &entry.table, &entry.row_id);
490 + let (hlc, data) = match entry.data {
491 + Some(ref value) => {
492 + let decrypted = crypto::decrypt_json_aad(value, gck, &ctx)?;
493 + Self::split_hlc_envelope(decrypted, device_id, entry.timestamp.timestamp_millis())?
494 + }
495 + None => (
496 + Hlc::from_legacy(entry.timestamp.timestamp_millis(), device_id),
497 + None,
498 + ),
499 + };
500 + Ok(crate::types::PulledChange {
Lines truncated
@@ -0,0 +1,889 @@
1 + //! HTTP transport and high-level API with transparent end-to-end encryption.
2 + //!
3 + //! This module provides [`SyncKitClient`], the primary interface to the MNW
4 + //! SyncKit server. All encryption and decryption happens transparently inside
5 + //! the client, callers work with plaintext [`ChangeEntry`] values and never
6 + //! handle ciphertext directly.
7 + //!
8 + //! ## Method groups
9 + //!
10 + //! - **Authentication**: [`authenticate`](SyncKitClient::authenticate) (email/password),
11 + //! [`authenticate_with_code`](SyncKitClient::authenticate_with_code) (OAuth2 PKCE),
12 + //! [`restore_session`](SyncKitClient::restore_session), [`clear_session`](SyncKitClient::clear_session).
13 + //! - **Encryption setup**: [`setup_encryption_new`](SyncKitClient::setup_encryption_new) (first device),
14 + //! [`setup_encryption_existing`](SyncKitClient::setup_encryption_existing) (subsequent devices),
15 + //! [`try_load_key_from_keychain`](SyncKitClient::try_load_key_from_keychain),
16 + //! [`change_password`](SyncKitClient::change_password).
17 + //! - **Device management**: [`register_device`](SyncKitClient::register_device),
18 + //! [`list_devices`](SyncKitClient::list_devices).
19 + //! - **Push/Pull sync**: [`push`](SyncKitClient::push), [`pull`](SyncKitClient::pull),
20 + //! [`status`](SyncKitClient::status).
21 + //! - **Blob storage**: [`blob_upload_streaming`](SyncKitClient::blob_upload_streaming)
22 + //! (file-backed, bounded memory, the path for large blobs),
23 + //! [`blob_upload_url`](SyncKitClient::blob_upload_url) +
24 + //! [`blob_upload`](SyncKitClient::blob_upload) (in-memory),
25 + //! [`blob_confirm`](SyncKitClient::blob_confirm),
26 + //! [`blob_download_url`](SyncKitClient::blob_download_url),
27 + //! [`blob_download`](SyncKitClient::blob_download).
28 + //!
29 + //! ## Internal state
30 + //!
31 + //! The client holds two `RwLock`-wrapped fields: the authenticated session
32 + //! (JWT token, user ID, app ID) and the 256-bit master encryption key. Both
33 + //! start as `None` and are populated by the authentication and encryption
34 + //! setup methods respectively.
35 + //!
36 + //! ## Thread safety
37 + //!
38 + //! `SyncKitClient` is `Send + Sync` and safe to share via `Arc`. All public
39 + //! methods take `&self`, acquiring the internal locks only briefly to read
40 + //! or update state. The locks are never held across `.await` points.
41 + //!
42 + //! ## Retry strategy
43 + //!
44 + //! All HTTP operations retry transient failures (network errors, 5xx,
45 + //! 429) up to 3 times with exponential backoff (1s, 2s, 4s). Client errors
46 + //! (4xx except 429) are permanent and returned immediately.
47 + //!
48 + //! ## Token handling
49 + //!
50 + //! The client decodes the JWT `exp` claim (without signature verification)
51 + //! and applies a 30-second expiry buffer. If the token is about to expire,
52 + //! `require_token()` returns [`SyncKitError::TokenExpired`] so the caller
53 + //! can re-authenticate before the request fails on the server.
54 +
55 + mod auth;
56 + mod blob;
57 + mod groups;
58 + pub use blob::BlobUploadOutcome;
59 + mod encryption;
60 + pub(crate) mod helpers;
61 + mod ota;
62 + mod rotation;
63 + mod subscribe;
64 + pub mod subscription;
65 + mod sync;
66 +
67 + pub use ota::{OtaArtifactUpload, OtaManifest, OtaRelease};
68 + pub use subscribe::SyncNotifyStream;
69 +
70 + use parking_lot::RwLock;
71 + use reqwest::Client;
72 + use std::sync::Arc;
73 + use std::time::Duration;
74 + #[cfg(test)]
75 + use uuid::Uuid;
76 +
77 + use crate::{
78 + crypto,
79 + error::{Result, SyncKitError},
80 + ids::{AppId, GroupId, UserId},
81 + };
82 +
83 + /// Maximum number of retry attempts for transient failures.
84 + const MAX_RETRIES: u32 = 3;
85 +
86 + /// Base delay for exponential backoff (1s, 2s, 4s).
87 + const BASE_DELAY: Duration = Duration::from_secs(1);
88 +
89 + /// Seconds before actual expiry to consider the token expired.
90 + /// Avoids sending a request with a token that expires mid-flight.
91 + const TOKEN_EXPIRY_BUFFER_SECS: i64 = 30;
92 +
93 + /// Inactivity (read) timeout for the streaming HTTP client. Bounds the gap
94 + /// between successive body reads without capping the total transfer, so a stalled
95 + /// (slow-loris) blob/OTA/SSE connection is torn down while a legitimately long,
96 + /// steady transfer is not. Kept above the server's 30s SSE keepalive interval so
97 + /// an idle notification stream is never mistaken for a stall.
98 + const STREAM_READ_TIMEOUT: Duration = Duration::from_secs(90);
99 +
100 + /// Configuration for the SyncKit client.
101 + #[derive(Debug, Clone)]
102 + pub struct SyncKitConfig {
103 + /// Base URL of the MNW server (e.g. "https://makenot.work").
104 + pub server_url: String,
105 + /// App API key (obtained from MNW dashboard).
106 + pub api_key: String,
107 + }
108 +
109 + /// Pre-built endpoint URLs, computed once at client construction.
110 + struct Endpoints {
111 + auth: String,
112 + oauth_token: String,
113 + devices: String,
114 + push: String,
115 + pull: String,
116 + subscribe: String,
117 + status: String,
118 + keys: String,
119 + groups_base: String,
120 + blobs_upload: String,
121 + blobs_confirm: String,
122 + blobs_download: String,
123 + blobs_multipart_start: String,
124 + blobs_multipart_parts: String,
125 + blobs_multipart_complete: String,
126 + blobs_multipart_abort: String,
127 + subscription: String,
128 + subscription_checkout: String,
129 + subscription_quote: String,
130 + subscription_storage_cap: String,
131 + app_pricing: String,
132 + account: String,
133 + /// Base for OTA paths (`{server}/api/v1/sync/ota`). The app-scoped and public
134 + /// OTA URLs carry runtime ids (app, release, slug/target/arch/version) so they
135 + /// cannot be pre-built like the static endpoints above; the `ota_*` builder
136 + /// methods construct them from this base, keeping *all* path construction in
137 + /// this one type instead of re-deriving it with `format!` in `ota.rs`.
138 + ota_base: String,
139 + }
140 +
141 + impl Endpoints {
142 + fn new(base: &str) -> Self {
143 + let base = base.trim_end_matches('/');
144 + Self {
145 + auth: format!("{base}/api/v1/sync/auth"),
146 + oauth_token: format!("{base}/oauth/token"),
147 + devices: format!("{base}/api/v1/sync/devices"),
148 + push: format!("{base}/api/v1/sync/push"),
149 + pull: format!("{base}/api/v1/sync/pull"),
150 + subscribe: format!("{base}/api/v1/sync/subscribe"),
151 + status: format!("{base}/api/v1/sync/status"),
152 + keys: format!("{base}/api/v1/sync/keys"),
153 + blobs_upload: format!("{base}/api/v1/sync/blobs/upload"),
154 + blobs_confirm: format!("{base}/api/v1/sync/blobs/confirm"),
155 + blobs_download: format!("{base}/api/v1/sync/blobs/download"),
156 + blobs_multipart_start: format!("{base}/api/v1/sync/blobs/multipart/start"),
157 + blobs_multipart_parts: format!("{base}/api/v1/sync/blobs/multipart/parts"),
158 + blobs_multipart_complete: format!("{base}/api/v1/sync/blobs/multipart/complete"),
159 + blobs_multipart_abort: format!("{base}/api/v1/sync/blobs/multipart/abort"),
160 + subscription: format!("{base}/api/v1/sync/subscription"),
161 + subscription_checkout: format!("{base}/api/v1/sync/subscription/checkout"),
162 + subscription_quote: format!("{base}/api/v1/sync/subscription/quote"),
163 + subscription_storage_cap: format!("{base}/api/v1/sync/subscription/storage-cap"),
164 + app_pricing: format!("{base}/api/v1/sync/app/pricing"),
165 + account: format!("{base}/api/v1/sync/account"),
166 + ota_base: format!("{base}/api/v1/sync/ota"),
167 + groups_base: format!("{base}/api/v1/sync/groups"),
168 + }
169 + }
170 +
171 + /// `GET`/`POST` the group collection: list the caller's groups, or create one.
172 + fn groups(&self) -> &str {
173 + &self.groups_base
174 + }
175 +
176 + /// `GET` here for the caller's own sealed GCK grant for a group.
177 + fn group_grant(&self, group_id: GroupId) -> String {
178 + format!("{}/{group_id}/grant", self.groups_base)
179 + }
180 +
181 + /// `POST` here to add a member to a group (admin only).
182 + fn group_members(&self, group_id: GroupId) -> String {
183 + format!("{}/{group_id}/members", self.groups_base)
184 + }
185 +
186 + /// `DELETE` here to remove a member from a group (admin only).
187 + fn group_member(&self, group_id: GroupId, member: UserId) -> String {
188 + format!("{}/{group_id}/members/{member}", self.groups_base)
189 + }
190 +
191 + /// `POST` encrypted changes to a group's shared changelog.
192 + fn group_push(&self, group_id: GroupId) -> String {
193 + format!("{}/{group_id}/push", self.groups_base)
194 + }
195 +
196 + /// `POST` to pull a group's changes since a cursor.
197 + fn group_pull(&self, group_id: GroupId) -> String {
198 + format!("{}/{group_id}/pull", self.groups_base)
199 + }
200 +
201 + /// `POST` here to create a release, or list releases, for an app.
202 + fn ota_releases(&self, app_id: AppId) -> String {
203 + format!("{}/apps/{app_id}/releases", self.ota_base)
204 + }
205 +
206 + /// `POST` here to register an artifact under a release.
207 + fn ota_artifacts(&self, app_id: AppId, release_id: uuid::Uuid) -> String {
208 + format!(
209 + "{}/apps/{app_id}/releases/{release_id}/artifacts",
210 + self.ota_base
211 + )
212 + }
213 +
214 + /// `POST` here to confirm an uploaded artifact under a release.
215 + fn ota_confirm(&self, app_id: AppId, release_id: uuid::Uuid) -> String {
216 + format!(
217 + "{}/apps/{app_id}/releases/{release_id}/artifacts/confirm",
218 + self.ota_base
219 + )
220 + }
221 +
222 + /// The public, unauthenticated Tauri updater check URL.
223 + fn ota_updater(&self, slug: &str, target: &str, arch: &str, current_version: &str) -> String {
224 + format!("{}/{slug}/{target}/{arch}/{current_version}", self.ota_base)
225 + }
226 + }
227 +
228 + /// A bearer token whose bytes are scrubbed from memory when the last reference
229 + /// is dropped.
230 + ///
231 + /// The SDK holds the session's JWT for the lifetime of the session; wrapping it
232 + /// so its final drop zeroizes the heap keeps the credential from lingering after
233 + /// logout or session replacement. [`Deref`](std::ops::Deref) to `str` and
234 + /// [`Display`](std::fmt::Display) expose the raw value only where the HTTP layer
235 + /// needs it (the `Authorization: Bearer` header); [`Debug`](std::fmt::Debug) is
236 + /// redacted so the token cannot leak into a log line.
237 + pub struct SecretToken(String);
238 +
239 + impl SecretToken {
240 + pub(crate) fn new(token: String) -> Self {
241 + SecretToken(token)
242 + }
243 +
244 + /// The raw token string, for building the `Authorization` header.
245 + pub fn as_str(&self) -> &str {
246 + &self.0
247 + }
248 + }
249 +
250 + impl std::ops::Deref for SecretToken {
251 + type Target = str;
252 + fn deref(&self) -> &str {
253 + &self.0
254 + }
255 + }
256 +
257 + impl std::fmt::Display for SecretToken {
258 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 + f.write_str(&self.0)
260 + }
261 + }
262 +
263 + impl std::fmt::Debug for SecretToken {
264 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265 + f.write_str("SecretToken(<redacted>)")
266 + }
267 + }
268 +
269 + impl Drop for SecretToken {
270 + fn drop(&mut self) {
271 + use zeroize::Zeroize;
272 + self.0.zeroize();
273 + }
274 + }
275 +
276 + /// Session state obtained after authentication.
277 + struct Session {
278 + token: Arc<SecretToken>,
279 + /// Cached `exp` claim from the JWT, extracted once at session creation.
280 + token_exp: Option<i64>,
281 + user_id: UserId,
282 + app_id: AppId,
283 + }
284 +
285 + /// Public session info returned by `session_info()`.
286 + pub struct SessionInfo {
287 + /// The JWT bearer token for API requests (shared ref-counted to avoid
288 + /// cloning; zeroized when the last reference drops).
289 + pub token: Arc<SecretToken>,
290 + /// The authenticated user's UUID.
291 + pub user_id: UserId,
292 + /// The SyncKit app UUID this session belongs to.
293 + pub app_id: AppId,
294 + }
295 +
296 + /// Info about a pending key rotation, cached from `GET /keys`.
297 + pub(crate) struct PendingKeyState {
298 + pub key: crypto::ZeroizeOnDrop,
299 + pub key_id: i32,
300 + }
301 +
302 + /// The SyncKit client. Handles authentication, encryption, and HTTP transport.
303 + pub struct SyncKitClient {
304 + config: SyncKitConfig,
305 + /// HTTP client for small JSON control-plane calls. Carries a whole-request
306 + /// timeout, those calls should never run long.
307 + http: Client,
308 + /// HTTP client for large-body and long-lived transfers (blob up/download,
309 + /// OTA artifacts, the SSE notification stream). Deliberately has NO
310 + /// whole-request timeout, a multi-gigabyte blob or an open push stream must
311 + /// not be torn down by a fixed clock, only a connect timeout.
312 + http_stream: Client,
313 + endpoints: Endpoints,
314 + session: RwLock<Option<Session>>,
315 + master_key: RwLock<Option<crypto::ZeroizeOnDrop>>,
316 + /// The key_id associated with the current master_key. Default 1 (pre-rotation).
317 + master_key_id: RwLock<i32>,
318 + /// Pending rotation key, if a rotation is in progress.
319 + pending_key: RwLock<Option<PendingKeyState>>,
320 + /// Per-group decrypted Group Content Key cache: `group_id -> (gck_version,
321 + /// gck)`. Populated lazily by [`group_content_key`](Self::group_content_key)
322 + /// from the sealed grant; a version mismatch (rotation) or a decrypt failure
323 + /// forces a re-fetch. Holds one entry per group, the current generation.
324 + gck_cache: RwLock<std::collections::HashMap<GroupId, (i32, crypto::ZeroizeOnDrop)>>,
325 + }
326 +
327 + impl SyncKitClient {
328 + /// Create a new client with the given configuration.
329 + pub fn new(config: SyncKitConfig) -> Self {
330 + // reqwest is built with `rustls-no-provider`; a real consumer app installs the
331 + // process-wide crypto provider (audiofiles installs ring) before it ever builds
332 + // a client. Unit tests have no such app, so install ring here once, or the
333 + // client build below would panic. Never compiled into a consumer build.
334 + #[cfg(test)]
335 + {
336 + static PROVIDER: std::sync::Once = std::sync::Once::new();
337 + PROVIDER.call_once(|| {
338 + let _ = rustls::crypto::ring::default_provider().install_default();
339 + });
340 + }
341 +
342 + let https_only = requires_https(&config.server_url);
343 + let http = Client::builder()
344 + .timeout(Duration::from_secs(30))
345 + .connect_timeout(Duration::from_secs(10))
346 + .pool_max_idle_per_host(5)
347 + .pool_idle_timeout(Duration::from_secs(90))
348 + .https_only(https_only)
349 + .build()
350 + .expect("failed to build HTTP client");
351 +
352 + // No whole-request timeout: this client carries multi-gigabyte blobs and
353 + // the long-lived SSE stream, which a fixed 30s cap would break. It DOES
354 + // carry a read (inactivity) timeout: without one, a server that dribbles
355 + // one byte an hour holds a blob/OTA/SSE connection open forever (slow-
356 + // loris). The cap bounds the gap *between* reads, not the total transfer,
357 + // so a legitimate slow-but-steady transfer is unaffected. 90s sits well
358 + // above the server's 30s SSE keepalive, so an idle notification stream
359 + // (which receives a keepalive comment every 30s) is never torn down.
360 + let http_stream = Client::builder()
361 + .connect_timeout(Duration::from_secs(10))
362 + .read_timeout(STREAM_READ_TIMEOUT)
363 + .pool_max_idle_per_host(5)
364 + .pool_idle_timeout(Duration::from_secs(90))
365 + .https_only(https_only)
366 + .build()
367 + .expect("failed to build streaming HTTP client");
368 +
369 + let endpoints = Endpoints::new(&config.server_url);
370 + Self {
371 + config,
372 + http,
373 + http_stream,
374 + endpoints,
375 + session: RwLock::new(None),
376 + master_key: RwLock::new(None),
377 + master_key_id: RwLock::new(1),
378 + pending_key: RwLock::new(None),
379 + gck_cache: RwLock::new(std::collections::HashMap::new()),
380 + }
381 + }
382 +
383 + /// Create a new client with a custom HTTP client (for testing with custom timeouts).
384 + ///
385 + /// Test-only: gated behind the `testing` feature so consumer builds cannot
386 + /// substitute an unvalidated HTTP client.
387 + #[doc(hidden)]
388 + #[cfg(any(test, feature = "testing"))]
389 + pub fn with_http_client(config: SyncKitConfig, http: Client) -> Self {
390 + let endpoints = Endpoints::new(&config.server_url);
391 + Self {
392 + config,
393 + http: http.clone(),
394 + http_stream: http,
395 + endpoints,
396 + session: RwLock::new(None),
397 + master_key: RwLock::new(None),
398 + master_key_id: RwLock::new(1),
399 + pending_key: RwLock::new(None),
400 + gck_cache: RwLock::new(std::collections::HashMap::new()),
401 + }
402 + }
403 +
404 + /// Returns the client configuration.
405 + pub fn config(&self) -> &SyncKitConfig {
406 + &self.config
407 + }
408 +
409 + /// Returns whether the master encryption key is loaded and ready.
410 + pub fn has_master_key(&self) -> bool {
411 + self.master_key.read().is_some()
412 + }
413 +
414 + /// Returns the current session info, if authenticated.
415 + pub fn session_info(&self) -> Option<SessionInfo> {
416 + let guard = self.session.read();
417 + guard.as_ref().map(|s| SessionInfo {
418 + token: Arc::clone(&s.token),
419 + user_id: s.user_id,
420 + app_id: s.app_id,
421 + })
422 + }
423 +
424 + /// Set a raw 256-bit master key directly (for testing without Argon2 overhead).
425 + ///
426 + /// Test-only: gated behind the `testing` feature so a consumer build has no
427 + /// chosen-key injection point that bypasses key derivation.
428 + #[doc(hidden)]
429 + #[cfg(any(test, feature = "testing"))]
430 + pub fn set_master_key_raw(&self, key: [u8; 32]) {
431 + *self.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
432 + }
433 +
434 + // ── Internal helpers ──
435 +
436 + /// Extract the bearer token from the current session.
437 + ///
438 + /// Returns `NotAuthenticated` if no session exists. Also checks token
439 + /// expiry and returns `TokenExpired` if the JWT `exp` claim is within
440 + /// 30 seconds of the current time.
441 + pub(crate) fn require_token(&self) -> Result<Arc<SecretToken>> {
442 + let guard = self.session.read();
443 + let session = guard.as_ref().ok_or(SyncKitError::NotAuthenticated)?;
444 +
445 + if let Some(exp) = session.token_exp {
446 + let now = chrono::Utc::now().timestamp();
447 + if now >= exp - TOKEN_EXPIRY_BUFFER_SECS {
448 + return Err(SyncKitError::TokenExpired);
449 + }
450 + }
451 +
452 + Ok(Arc::clone(&session.token))
453 + }
454 +
455 + /// Extract `(app_id, user_id)` from the current session.
456 + ///
457 + /// Returns `NotAuthenticated` if no session exists.
458 + pub(crate) fn require_session_ids(&self) -> Result<(AppId, UserId)> {
459 + let guard = self.session.read();
460 + guard
461 + .as_ref()
462 + .map(|s| (s.app_id, s.user_id))
463 + .ok_or(SyncKitError::NotAuthenticated)
464 + }
465 +
466 + /// Return a copy of the 256-bit master encryption key, wrapped in
467 + /// `ZeroizeOnDrop` so the caller never holds a bare `[u8; 32]`.
468 + ///
469 + /// Returns `NoMasterKey` if encryption has not been set up yet.
470 + pub(crate) fn require_master_key(&self) -> Result<crypto::ZeroizeOnDrop> {
471 + let guard = self.master_key.read();
472 + guard
473 + .as_ref()
474 + .map(|k| crypto::ZeroizeOnDrop(**k))
475 + .ok_or(SyncKitError::NoMasterKey)
476 + }
477 + }
478 +
479 + /// Whether the HTTP client for `server_url` must enforce TLS (`https_only`).
480 + ///
481 + /// Production traffic carries the bearer sync token, so plaintext `http` to any
482 + /// non-loopback host is refused (defense-in-depth on top of normal cert
483 + /// validation). Loopback hosts stay exempt so local development and the
484 + /// mock-server test suite can use `http://127.0.0.1`.
485 + fn requires_https(server_url: &str) -> bool {
486 + let lower = server_url.trim().to_ascii_lowercase();
487 + let Some(rest) = lower.strip_prefix("http://") else {
488 + // https (or any non-plaintext scheme): enforcing https_only is a no-op.
489 + return true;
490 + };
491 + // Extract the host *exactly*. A prefix match (`starts_with("127.")`,
492 + // `starts_with("localhost")`) trusts `http://127.0.0.1.attacker.com` and
493 + // `http://localhost.evil.com`, hostnames that resolve to an attacker's IP,
494 + // and would send the bearer token to them in cleartext. Parse the authority,
495 + // strip the port, and require an exact `localhost` or an IP literal that is
496 + // genuinely loopback. `0.0.0.0` is the unspecified/bind-all address, not a
497 + // loopback you connect *to*, so `is_loopback()` correctly rejects it.
498 + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
499 + // Strip userinfo: everything up to the last `@` is credentials, not the host.
500 + // `http://127.0.0.1:80@evil.com` connects to evil.com, so a naive port-split
Lines truncated
@@ -0,0 +1,315 @@
1 + //! OTA (Over-The-Air) release publishing.
2 + //!
3 + //! These methods drive the app-owner side of the MNW OTA system: create a
4 + //! release, register an artifact (which returns a presigned S3 PUT URL), upload
5 + //! the bytes, and verify the public Tauri updater endpoint now serves it. They
6 + //! reuse the authenticated [`SyncKitClient`] session (JWT + app id), so a
7 + //! publisher authenticates once with [`authenticate`](SyncKitClient::authenticate)
8 + //! and then calls these in sequence.
9 + //!
10 + //! Unlike blobs, OTA artifacts are NOT end-to-end encrypted, they are public
11 + //! downloads served to every installed app, so the bytes are uploaded as-is.
12 + //!
13 + //! Server contract: `server/src/routes/ota.rs`. The Tauri updater manifest
14 + //! ([`OtaManifest`]) field names are load-bearing, Tauri's updater plugin
15 + //! deserializes exactly these five fields.
16 +
17 + use bytes::Bytes;
18 + use serde::{Deserialize, Serialize};
19 + use tracing::instrument;
20 + use uuid::Uuid;
21 +
22 + use super::SyncKitClient;
23 + use super::helpers::{Idempotency, check_response};
24 + use crate::error::Result;
25 +
26 + /// A created OTA release (subset of the server's release response).
27 + #[derive(Debug, Clone, Deserialize)]
28 + pub struct OtaRelease {
29 + /// Release id, used when registering artifacts.
30 + pub id: Uuid,
31 + pub version: String,
32 + pub notes: String,
33 + }
34 +
35 + /// A presigned upload target for an OTA artifact.
36 + #[derive(Debug, Clone, Deserialize)]
37 + pub struct OtaArtifactUpload {
38 + /// Presigned S3 PUT URL, upload the artifact bytes here.
39 + pub upload_url: String,
40 + /// The S3 object key the artifact will live at.
41 + pub s3_key: String,
42 + }
43 +
44 + /// The Tauri-compatible updater manifest returned by the public updater check.
45 + ///
46 + /// Field names mirror the server's `TauriUpdaterResponse` exactly; Tauri's
47 + /// updater plugin reads these five and nothing else.
48 + #[derive(Debug, Clone, Deserialize)]
49 + pub struct OtaManifest {
50 + pub version: String,
51 + pub url: String,
52 + pub signature: String,
53 + pub notes: String,
54 + pub pub_date: String,
55 + }
56 +
57 + #[derive(Serialize)]
58 + struct CreateReleaseBody<'a> {
59 + version: &'a str,
60 + notes: &'a str,
61 + }
62 +
63 + #[derive(Serialize)]
64 + struct RegisterArtifactBody<'a> {
65 + target: &'a str,
66 + arch: &'a str,
67 + file_size: i64,
68 + signature: &'a str,
69 + }
70 +
71 + impl SyncKitClient {
72 + /// Create a new OTA release for the authenticated app.
73 + ///
74 + /// The signature is per-artifact (Tauri signs each platform's file
75 + /// independently), so it is supplied to [`Self::ota_register_artifact`], not
76 + /// here.
77 + #[instrument(skip(self))]
78 + pub async fn ota_create_release(&self, version: &str, notes: &str) -> Result<OtaRelease> {
79 + let token = self.require_token()?;
80 + let (app_id, _user_id) = self.require_session_ids()?;
81 + let url = self.endpoints.ota_releases(app_id);
82 +
83 + let body = Bytes::from(serde_json::to_vec(&CreateReleaseBody { version, notes })?);
84 +
85 + self.retry_request_json(
86 + Idempotency::IdempotentWrite {
87 + on: "(app_id, version) unique, migration 033",
88 + },
89 + || {
90 + let req = self
91 + .http
92 + .post(&url)
93 + .bearer_auth(&token)
94 + .header("content-type", "application/json")
95 + .body(body.clone());
96 + async move { check_response(req.send().await?).await }
97 + },
98 + )
99 + .await
100 + }
101 +
102 + /// Register an artifact for a release and obtain a presigned upload URL.
103 + ///
104 + /// `target` is the OS (`linux`/`darwin`/`windows`), `arch` is the CPU
105 + /// (`x86_64`/`aarch64`), `file_size` is the artifact size in bytes, and
106 + /// `signature` is this file's minisign signature, Tauri's updater silently
107 + /// refuses an update whose signature is empty or belongs to another platform,
108 + /// so each artifact must carry its own.
109 + #[instrument(skip(self, signature))]
110 + pub async fn ota_register_artifact(
111 + &self,
112 + release_id: Uuid,
113 + target: &str,
114 + arch: &str,
115 + file_size: i64,
116 + signature: &str,
117 + ) -> Result<OtaArtifactUpload> {
118 + let token = self.require_token()?;
119 + let (app_id, _user_id) = self.require_session_ids()?;
120 + let url = self.endpoints.ota_artifacts(app_id, release_id);
121 +
122 + let body = Bytes::from(serde_json::to_vec(&RegisterArtifactBody {
123 + target,
124 + arch,
125 + file_size,
126 + signature,
127 + })?);
128 +
129 + self.retry_request_json(
130 + Idempotency::IdempotentWrite {
131 + on: "(release_id, target, arch) unique, migration 033",
132 + },
133 + || {
134 + let req = self
135 + .http
136 + .post(&url)
137 + .bearer_auth(&token)
138 + .header("content-type", "application/json")
139 + .body(body.clone());
140 + async move { check_response(req.send().await?).await }
141 + },
142 + )
143 + .await
144 + }
145 +
146 + /// Confirm an uploaded artifact: the server verifies the object landed and
147 + /// enqueues its malware scan. Until the scan clears, the artifact stays
148 + /// `pending` and is neither advertised nor downloadable. Call after
149 + /// [`Self::ota_upload_artifact`].
150 + #[instrument(skip(self))]
151 + pub async fn ota_confirm_artifact(
152 + &self,
153 + release_id: Uuid,
154 + target: &str,
155 + arch: &str,
156 + ) -> Result<()> {
157 + let token = self.require_token()?;
158 + let (app_id, _user_id) = self.require_session_ids()?;
159 + let url = self.endpoints.ota_confirm(app_id, release_id);
160 + let body = Bytes::from(serde_json::to_vec(&serde_json::json!({
161 + "target": target,
162 + "arch": arch,
163 + }))?);
164 +
165 + self.retry_request(
166 + Idempotency::IdempotentWrite {
167 + on: "(release_id, target, arch)",
168 + },
169 + || {
170 + let req = self
171 + .http
172 + .post(&url)
173 + .bearer_auth(&token)
174 + .header("content-type", "application/json")
175 + .body(body.clone());
176 + async move { check_response(req.send().await?).await }
177 + },
178 + )
179 + .await?;
180 + Ok(())
181 + }
182 +
183 + /// Upload artifact bytes to S3 via a presigned PUT URL.
184 + ///
185 + /// The bytes are sent as-is (no encryption, OTA artifacts are public).
186 + #[instrument(skip(self, presigned_url, data))]
187 + pub async fn ota_upload_artifact(&self, presigned_url: &str, data: Vec<u8>) -> Result<()> {
188 + let data = Bytes::from(data);
189 + self.retry_request(
190 + Idempotency::IdempotentWrite {
191 + on: "S3 key (content-addressed)",
192 + },
193 + || {
194 + let req = self
195 + .http_stream
196 + .put(presigned_url)
197 + .header("content-type", "application/octet-stream")
198 + .body(data.clone());
199 + async move { check_response(req.send().await?).await }
200 + },
201 + )
202 + .await?;
203 + Ok(())
204 + }
205 +
206 + /// Check the public Tauri updater endpoint.
207 + ///
208 + /// Returns `Some(manifest)` when a newer version than `current_version` is
209 + /// available for `slug`/`target`/`arch`, or `None` when the client is up to
210 + /// date (HTTP 204). Use this to verify a freshly published release is live.
211 + #[instrument(skip(self))]
212 + pub async fn ota_updater_check(
213 + &self,
214 + slug: &str,
215 + target: &str,
216 + arch: &str,
217 + current_version: &str,
218 + ) -> Result<Option<OtaManifest>> {
219 + let url = self
220 + .endpoints
221 + .ota_updater(slug, target, arch, current_version);
222 +
223 + let resp = self
224 + .retry_request(Idempotency::ReadOnly, || {
225 + let req = self.http_stream.get(&url);
226 + async move { check_response(req.send().await?).await }
227 + })
228 + .await?;
229 +
230 + if resp.status() == reqwest::StatusCode::NO_CONTENT {
231 + return Ok(None);
232 + }
233 +
234 + Ok(Some(
235 + crate::client::helpers::read_json_capped::<OtaManifest>(
236 + resp,
237 + crate::client::helpers::MAX_CONTROL_BODY_BYTES,
238 + )
239 + .await?,
240 + ))
241 + }
242 + }
243 +
244 + #[cfg(test)]
245 + mod tests {
246 + use super::*;
247 +
248 + #[test]
249 + fn create_release_body_serializes_expected_fields() {
250 + let body = CreateReleaseBody {
251 + version: "0.4.1",
252 + notes: "Bug fixes",
253 + };
254 + let v: serde_json::Value = serde_json::to_value(&body).unwrap();
255 + assert_eq!(v["version"], "0.4.1");
256 + assert_eq!(v["notes"], "Bug fixes");
257 + // Signature is per-artifact now, not on the release.
258 + assert!(v.get("signature").is_none());
259 + }
260 +
261 + #[test]
262 + fn register_artifact_body_serializes_expected_fields() {
263 + let body = RegisterArtifactBody {
264 + target: "darwin",
265 + arch: "aarch64",
266 + file_size: 12_345,
267 + signature: "RWS...==",
268 + };
269 + let v: serde_json::Value = serde_json::to_value(&body).unwrap();
270 + assert_eq!(v["target"], "darwin");
271 + assert_eq!(v["arch"], "aarch64");
272 + assert_eq!(v["file_size"], 12_345);
273 + assert_eq!(v["signature"], "RWS...==");
274 + }
275 +
276 + #[test]
277 + fn release_response_deserializes_with_uuid_id() {
278 + let json = r#"{
279 + "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
280 + "version": "0.4.1",
281 + "notes": "",
282 + "pub_date": "2026-06-07T00:00:00Z",
283 + "created_at": "2026-06-07T00:00:00Z"
284 + }"#;
285 + let r: OtaRelease = serde_json::from_str(json).unwrap();
286 + assert_eq!(r.version, "0.4.1");
287 + assert_eq!(
288 + r.id,
289 + Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap()
290 + );
291 + }
292 +
293 + #[test]
294 + fn artifact_upload_response_deserializes() {
295 + let json = r#"{"upload_url": "https://s3.example/put?sig=abc", "s3_key": "ota/app/0.4.1/darwin/aarch64/artifact"}"#;
296 + let u: OtaArtifactUpload = serde_json::from_str(json).unwrap();
297 + assert_eq!(u.upload_url, "https://s3.example/put?sig=abc");
298 + assert!(u.s3_key.ends_with("/artifact"));
299 + }
300 +
301 + #[test]
302 + fn manifest_deserializes_tauri_five_fields() {
303 + let json = r#"{
304 + "version": "0.4.1",
305 + "url": "https://makenot.work/api/sync/ota/goingson/download/abc/darwin/aarch64",
306 + "signature": "RWS=",
307 + "notes": "Bug fixes",
308 + "pub_date": "2026-06-07T00:00:00+00:00"
309 + }"#;
310 + let m: OtaManifest = serde_json::from_str(json).unwrap();
311 + assert_eq!(m.version, "0.4.1");
312 + assert!(m.url.contains("/download/"));
313 + assert_eq!(m.signature, "RWS=");
314 + }
315 + }
@@ -0,0 +1,512 @@
1 + //! Master-key rotation orchestration.
2 + //!
3 + //! Drives the multi-round server protocol that re-encrypts every device's key
4 + //! envelope under a new master key, completes the rotation, and sweeps up
5 + //! stragglers. The round count is bounded by `MAX_ROTATION_ROUNDS` so a
6 + //! misbehaving server cannot spin the loop indefinitely.
7 +
8 + use bytes::Bytes;
9 + use tracing::instrument;
10 + use uuid::Uuid;
11 +
12 + use crate::{
13 + crypto,
14 + error::Result,
15 + ids::DeviceId,
16 + types::{
17 + BeginRotationRequest, BeginRotationResponse, CompleteRotationRequest, PendingKeyInfo,
18 + RotationBatchEntry, RotationBatchRequest, RotationBatchResponse, RotationEntriesRequest,
19 + RotationEntriesResponse,
20 + },
21 + };
22 +
23 + use super::SyncKitClient;
24 + use super::helpers::{Idempotency, check_response};
25 +
26 + /// Hard ceiling on re-encrypt / straggler rounds within a single rotation. Each
27 + /// round re-encrypts one server batch and the set provably shrinks (re-encrypted
28 + /// entries drop out of the server's `key_id != new_key_id` filter), so a real
29 + /// rotation finishes in `total_entries / batch_size` rounds. The cap only fires
30 + /// when the set does NOT shrink, a stalled or hostile server that keeps handing
31 + /// back work, converting an unbounded spin into a bounded error. Generous enough
32 + /// to cover any real sync log at any batch size.
33 + const MAX_ROTATION_ROUNDS: u32 = 100_000;
34 +
35 + impl SyncKitClient {
36 + /// Rotate the master encryption key.
37 + ///
38 + /// Generates a new 256-bit key, re-encrypts all sync log entries in batches,
39 + /// and commits the new key to the server.
40 + ///
41 + /// Genuinely idempotent: if a previous call was interrupted, the server still
42 + /// holds the rotation's `pending_key`. This call detects that and **resumes
43 + /// with the already-committed key** rather than minting a second new key,
44 + /// the latter would orphan any entries already re-encrypted under the first
45 + /// key. Only when there is no pending rotation is a fresh key generated.
46 + ///
47 + /// Requires the encryption password (to wrap the new key) and a device_id
48 + /// (the device performing the rotation). Only one device can rotate at a time.
49 + ///
50 + /// After completion, all devices will receive the new key on their next
51 + /// `GET /keys` call. During rotation, both old and new keys are available
52 + /// so pulls continue to work.
53 + #[instrument(skip(self, password))]
54 + pub async fn rotate_key(&self, device_id: DeviceId, password: &str) -> Result<()> {
55 + // 1. Verify password against the still-active key, and learn whether a
56 + // rotation is already pending for this user.
57 + let key_state = self.get_server_key_full().await?;
58 + let key_version = key_state.key_version.unwrap_or(0);
59 + let old_key = crypto::verify_password_against_envelope(&key_state.encrypted_key, password)?;
60 + let old_key = crypto::ZeroizeOnDrop(old_key);
61 +
62 + // 2. Resume an interrupted rotation by adopting its committed pending key,
63 + // or start fresh. Either way `new_envelope` is the key the server will
64 + // promote on completion, so re-encryption uses the same key it commits.
65 + let (new_master_key, new_envelope) =
66 + Self::rotation_key_material(key_state.pending_key, password)?;
67 + // Wrap immediately so the new key is zeroized on every exit path, an
68 + // error in the re-encrypt or complete loops below would otherwise drop
69 + // the bare array without scrubbing it.
70 + let new_master_key = crypto::ZeroizeOnDrop(new_master_key);
71 +
72 + // 3. Begin (or resume) rotation. `begin_key_rotation` is idempotent for the
73 + // same device: it returns the existing rotation, ignoring `new_envelope`
74 + // on resume, which is why step 2 must adopt the committed key.
75 + let begin_resp = self
76 + .begin_rotation(device_id, &new_envelope, key_version)
77 + .await?;
78 + let rotation_id = begin_resp.rotation_id;
79 + let new_key_id = begin_resp.new_key_id;
80 +
81 + tracing::info!(
82 + rotation_id = %rotation_id,
83 + target_seq = begin_resp.target_seq,
84 + new_key_id = new_key_id,
85 + "Key rotation started",
86 + );
87 +
88 + // 4. Re-encrypt loop (bounded, see reencrypt_until_done).
89 + self.reencrypt_until_done(rotation_id, &old_key, &new_master_key)
90 + .await?;
91 +
92 + // 5. Complete, retry if stragglers arrived from concurrent pushes. Both
93 + // the straggler retries and each re-encrypt pass are capped so a server
94 + // that keeps returning 409 (a stall, or a hostile peer racing pushes)
95 + // cannot spin this loop forever.
96 + let mut straggler_rounds = 0u32;
97 + loop {
98 + match self.complete_rotation(rotation_id).await {
99 + Ok(()) => break,
100 + Err(crate::error::SyncKitError::Server { status: 409, .. }) => {
101 + straggler_rounds += 1;
102 + if straggler_rounds >= MAX_ROTATION_ROUNDS {
103 + return Err(crate::error::SyncKitError::Internal(
104 + "key rotation did not converge: server kept reporting stragglers past the round cap".into(),
105 + ));
106 + }
107 + tracing::debug!(
108 + round = straggler_rounds,
109 + "Stragglers detected, re-encrypting remaining entries"
110 + );
111 + self.reencrypt_until_done(rotation_id, &old_key, &new_master_key)
112 + .await?;
113 + }
114 + Err(e) => return Err(e),
115 + }
116 + }
117 +
118 + // 6. Update local state
119 + let (app_id, user_id) = self.require_session_ids()?;
120 + crate::keystore::store_key(app_id, user_id, &new_master_key)?;
121 + *self.master_key.write() = Some(new_master_key);
122 + *self.master_key_id.write() = new_key_id;
123 + *self.pending_key.write() = None;
124 +
125 + tracing::info!("Key rotation completed");
126 + Ok(())
127 + }
128 +
129 + /// Decide the rotation's new key material: adopt the server's committed
130 + /// `pending_key` when resuming an interrupted rotation, otherwise mint a
131 + /// fresh key. Returns `(new_master_key, new_envelope)` where the envelope is
132 + /// what the server promotes on completion, so the re-encryption key always
133 + /// matches the committed envelope. Pure (no IO) so it is unit-testable.
134 + fn rotation_key_material(
135 + pending: Option<PendingKeyInfo>,
136 + password: &str,
137 + ) -> Result<([u8; 32], String)> {
138 + match pending {
139 + Some(pending) => {
140 + let key = crypto::unwrap_master_key(&pending.encrypted_key, password)?;
141 + Ok((key, pending.encrypted_key))
142 + }
143 + None => {
144 + // Wrap before the fallible `wrap_master_key` so a wrap error
145 + // scrubs the fresh key instead of dropping a bare array.
146 + let key = crypto::ZeroizeOnDrop(crypto::generate_master_key());
147 + let envelope = crypto::wrap_master_key(&key.0, password)?;
148 + Ok((key.0, envelope))
149 + }
150 + }
151 + }
152 +
153 + /// Run [`reencrypt_batch`](Self::reencrypt_batch) until the server reports no
154 + /// entries left, bounded by [`MAX_ROTATION_ROUNDS`]. The batch set shrinks
155 + /// every round under an honest server; the cap is the backstop against a
156 + /// server that never reports it done, so the client fails with an error
157 + /// instead of spinning forever.
158 + async fn reencrypt_until_done(
159 + &self,
160 + rotation_id: Uuid,
161 + old_key: &[u8; 32],
162 + new_key: &[u8; 32],
163 + ) -> Result<()> {
164 + for _ in 0..MAX_ROTATION_ROUNDS {
165 + if self.reencrypt_batch(rotation_id, old_key, new_key).await? {
166 + return Ok(());
167 + }
168 + }
169 + Err(crate::error::SyncKitError::Internal(
170 + "key rotation did not converge: re-encrypt exceeded the round cap without the entry set draining".into(),
171 + ))
172 + }
173 +
174 + /// Pull a batch of entries needing re-encryption, re-encrypt them, and push back.
175 + /// Returns true if there are no more entries to process.
176 + async fn reencrypt_batch(
177 + &self,
178 + rotation_id: Uuid,
179 + old_key: &[u8; 32],
180 + new_key: &[u8; 32],
181 + ) -> Result<bool> {
182 + // Pull entries needing re-encryption (starting from seq 0 each time,
183 + // since the server filters by key_id != new_key_id)
184 + let entries_resp = self.rotation_entries(rotation_id, 0).await?;
185 +
186 + if entries_resp.entries.is_empty() {
187 + return Ok(true);
188 + }
189 +
190 + let has_more = entries_resp.has_more;
191 +
192 + // Re-encrypt each entry
193 + let reencrypted: Vec<RotationBatchEntry> = entries_resp
194 + .entries
195 + .into_iter()
196 + .map(|entry| {
197 + let new_data = match entry.data {
198 + Some(ref encrypted_value) => {
199 + // Decrypt with old key, re-encrypt with new key, binding the
200 + // same (table, row_id) AAD on both ends. A legacy untagged
201 + // entry decrypts with empty AAD and re-emits as a v2 tagged,
202 + // position-bound payload (opportunistic upgrade).
203 + let ctx = crypto::AeadContext::entry(&entry.table, &entry.row_id);
204 + let plaintext = crypto::decrypt_json_aad(encrypted_value, old_key, &ctx)?;
205 + let reencrypted = crypto::encrypt_json_aad(&plaintext, new_key, &ctx)?;
206 + Some(reencrypted)
207 + }
208 + None => None, // DELETE entries have no data
209 + };
210 + Ok(RotationBatchEntry {
211 + seq: entry.seq,
212 + data: new_data,
213 + })
214 + })
215 + .collect::<Result<Vec<_>>>()?;
216 +
217 + // Push re-encrypted batch
218 + let count = reencrypted.len();
219 + self.rotation_batch(rotation_id, reencrypted).await?;
220 +
221 + tracing::debug!(count, "Re-encrypted batch submitted");
222 +
223 + Ok(!has_more)
224 + }
225 +
226 + // ── HTTP helpers ──
227 +
228 + async fn begin_rotation(
229 + &self,
230 + device_id: DeviceId,
231 + new_encrypted_key: &str,
232 + expected_key_version: i32,
233 + ) -> Result<BeginRotationResponse> {
234 + let token = self.require_token()?;
235 + let url = format!(
236 + "{}/api/v1/sync/keys/rotate",
237 + self.config.server_url.trim_end_matches('/')
238 + );
239 +
240 + let body = Bytes::from(serde_json::to_vec(&BeginRotationRequest {
241 + device_id,
242 + new_encrypted_key: new_encrypted_key.to_string(),
243 + expected_key_version,
244 + })?);
245 +
246 + self.retry_request_json(
247 + Idempotency::IdempotentWrite {
248 + on: "one rotation per (app_id, user_id), migration 089",
249 + },
250 + || {
251 + let req = self
252 + .http
253 + .post(&url)
254 + .bearer_auth(&token)
255 + .header("content-type", "application/json")
256 + .body(body.clone());
257 + async move { check_response(req.send().await?).await }
258 + },
259 + )
260 + .await
261 + }
262 +
263 + async fn rotation_entries(
264 + &self,
265 + rotation_id: Uuid,
266 + after_seq: i64,
267 + ) -> Result<RotationEntriesResponse> {
268 + let token = self.require_token()?;
269 + let url = format!(
270 + "{}/api/v1/sync/keys/rotate/entries",
271 + self.config.server_url.trim_end_matches('/')
272 + );
273 +
274 + let body = Bytes::from(serde_json::to_vec(&RotationEntriesRequest {
275 + rotation_id,
276 + after_seq,
277 + })?);
278 +
279 + self.retry_request_json(Idempotency::ReadOnly, || {
280 + let req = self
281 + .http
282 + .post(&url)
283 + .bearer_auth(&token)
284 + .header("content-type", "application/json")
285 + .body(body.clone());
286 + async move { check_response(req.send().await?).await }
287 + })
288 + .await
289 + }
290 +
291 + async fn rotation_batch(
292 + &self,
293 + rotation_id: Uuid,
294 + entries: Vec<RotationBatchEntry>,
295 + ) -> Result<RotationBatchResponse> {
296 + let token = self.require_token()?;
297 + let url = format!(
298 + "{}/api/v1/sync/keys/rotate/batch",
299 + self.config.server_url.trim_end_matches('/')
300 + );
301 +
302 + let body = Bytes::from(serde_json::to_vec(&RotationBatchRequest {
303 + rotation_id,
304 + entries,
305 + })?);
306 +
307 + self.retry_request_json(
308 + Idempotency::IdempotentWrite {
309 + on: "seq (re-encrypt overwrites the same row)",
310 + },
311 + || {
312 + let req = self
313 + .http
314 + .post(&url)
315 + .bearer_auth(&token)
316 + .header("content-type", "application/json")
317 + .body(body.clone());
318 + async move { check_response(req.send().await?).await }
319 + },
320 + )
321 + .await
322 + }
323 +
324 + async fn complete_rotation(&self, rotation_id: Uuid) -> Result<()> {
325 + let token = self.require_token()?;
326 + let url = format!(
327 + "{}/api/v1/sync/keys/rotate/complete",
328 + self.config.server_url.trim_end_matches('/')
329 + );
330 +
331 + let body = Bytes::from(serde_json::to_vec(&CompleteRotationRequest {
332 + rotation_id,
333 + })?);
334 +
335 + self.retry_request(Idempotency::IdempotentWrite { on: "rotation_id" }, || {
336 + let req = self
337 + .http
338 + .post(&url)
339 + .bearer_auth(&token)
340 + .header("content-type", "application/json")
341 + .body(body.clone());
342 + async move { check_response(req.send().await?).await }
343 + })
344 + .await?;
345 +
346 + Ok(())
347 + }
348 + }
349 +
350 + #[cfg(test)]
351 + mod tests {
352 + use super::*;
353 + use crate::types::{GetKeyResponse, PullChangeEntry};
354 +
355 + #[test]
356 + fn rotation_resume_reuses_committed_pending_key() {
357 + // Simulate an interrupted rotation: the server holds a pending envelope
358 + // wrapping key K. Resuming must recover K (not mint a fresh key), or
359 + // entries already re-encrypted under K become undecryptable.
360 + let committed_key = crypto::generate_master_key();
361 + let pending_envelope = crypto::wrap_master_key(&committed_key, "pw").unwrap();
362 + let pending = Some(PendingKeyInfo {
363 + encrypted_key: pending_envelope.clone(),
364 + key_id: 2,
365 + });
366 +
367 + let (resumed_key, envelope) = SyncKitClient::rotation_key_material(pending, "pw").unwrap();
368 + assert_eq!(
369 + resumed_key, committed_key,
370 + "resume must adopt the committed key"
371 + );
372 + assert_eq!(
373 + envelope, pending_envelope,
374 + "resume must keep the committed envelope"
375 + );
376 + }
377 +
378 + #[test]
379 + fn rotation_fresh_start_mints_unwrappable_key() {
380 + // No pending rotation: a fresh key is generated and its envelope unwraps
381 + // back to that same key under the password.
382 + let (fresh_key, envelope) = SyncKitClient::rotation_key_material(None, "pw").unwrap();
383 + let recovered = crypto::unwrap_master_key(&envelope, "pw").unwrap();
384 + assert_eq!(recovered, fresh_key);
385 + }
386 +
387 + #[test]
388 + fn reencrypt_preserves_plaintext() {
389 + // Simulate re-encryption: encrypt with old key, decrypt, re-encrypt with new key
390 + let old_key = crypto::generate_master_key();
391 + let new_key = crypto::generate_master_key();
392 + let original = serde_json::json!({"title": "Test task", "priority": 3});
393 +
394 + // Encrypt with old key (simulates existing sync_log entry)
395 + let encrypted_old = crypto::encrypt_json(&original, &old_key).unwrap();
396 +
397 + // Re-encrypt: decrypt with old, encrypt with new
398 + let plaintext = crypto::decrypt_json(&encrypted_old, &old_key).unwrap();
399 + let encrypted_new = crypto::encrypt_json(&plaintext, &new_key).unwrap();
400 +
401 + // Verify: new key can decrypt to original data
402 + let recovered = crypto::decrypt_json(&encrypted_new, &new_key).unwrap();
403 + assert_eq!(recovered, original);
404 +
405 + // Verify: old key cannot decrypt re-encrypted data
406 + assert!(crypto::decrypt_json(&encrypted_new, &old_key).is_err());
407 + }
408 +
409 + #[test]
410 + fn reencrypt_null_data_stays_null() {
411 + // DELETE entries have no data, rotation should preserve this
412 + let old_key = crypto::generate_master_key();
413 + let new_key = crypto::generate_master_key();
414 +
415 + // No data to re-encrypt
416 + let data: Option<serde_json::Value> = None;
417 + let reencrypted = match data {
418 + Some(ref val) => {
419 + let plaintext = crypto::decrypt_json(val, &old_key).unwrap();
420 + Some(crypto::encrypt_json(&plaintext, &new_key).unwrap())
421 + }
422 + None => None,
423 + };
424 +
425 + assert!(reencrypted.is_none());
426 + }
427 +
428 + #[test]
429 + fn rotation_request_types_serialize() {
430 + let req = BeginRotationRequest {
431 + device_id: DeviceId::new(Uuid::new_v4()),
432 + new_encrypted_key: "envelope-json".to_string(),
433 + expected_key_version: 1,
434 + };
435 + let json = serde_json::to_string(&req).unwrap();
436 + assert!(json.contains("expected_key_version"));
437 +
438 + let req = RotationBatchRequest {
439 + rotation_id: Uuid::new_v4(),
440 + entries: vec![
441 + RotationBatchEntry {
442 + seq: 1,
443 + data: Some(serde_json::json!("encrypted")),
444 + },
445 + RotationBatchEntry { seq: 2, data: None },
446 + ],
447 + };
448 + let json = serde_json::to_string(&req).unwrap();
449 + assert!(json.contains("rotation_id"));
450 + assert!(json.contains("\"seq\":1"));
451 + }
452 +
453 + #[test]
454 + fn rotation_response_types_deserialize() {
455 + let json = r#"{"rotation_id": "550e8400-e29b-41d4-a716-446655440000", "target_seq": 100, "new_key_id": 2}"#;
456 + let resp: BeginRotationResponse = serde_json::from_str(json).unwrap();
457 + assert_eq!(resp.target_seq, 100);
458 + assert_eq!(resp.new_key_id, 2);
459 +
460 + let json = r#"{"entries": [{"seq": 1, "table": "tasks", "row_id": "r1", "data": "encrypted"}, {"seq": 2, "table": "tasks", "row_id": "r2", "data": null}], "has_more": true}"#;
461 + let resp: RotationEntriesResponse = serde_json::from_str(json).unwrap();
462 + assert_eq!(resp.entries.len(), 2);
463 + assert!(resp.has_more);
464 + assert!(resp.entries[0].data.is_some());
465 + assert_eq!(resp.entries[0].table, "tasks");
466 + assert_eq!(resp.entries[0].row_id, "r1");
467 + assert!(resp.entries[1].data.is_none());
468 + }
469 +
470 + #[test]
471 + fn pending_key_info_deserializes() {
472 + let json = r#"{
473 + "encrypted_key": "envelope",
474 + "key_version": 2,
475 + "key_id": 1,
476 + "pending_key": {"encrypted_key": "new-envelope", "key_id": 2}
477 + }"#;
478 + let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
479 + assert!(resp.pending_key.is_some());
480 + let pending = resp.pending_key.unwrap();
481 + assert_eq!(pending.key_id, 2);
482 + assert_eq!(pending.encrypted_key, "new-envelope");
483 + }
484 +
485 + #[test]
486 + fn get_key_response_without_pending_key() {
487 + let json = r#"{"encrypted_key": "envelope", "key_version": 1}"#;
488 + let resp: GetKeyResponse = serde_json::from_str(json).unwrap();
489 + assert!(resp.pending_key.is_none());
490 + assert_eq!(resp.key_id, None); // backward compat
491 + }
492 +
493 + #[test]
494 + fn pull_change_entry_with_key_id() {
495 + let device_id = Uuid::new_v4();
496 + let json = format!(
497 + r#"{{"seq": 1, "device_id": "{device_id}", "table": "t", "op": "INSERT", "row_id": "r", "timestamp": "2025-06-01T12:00:00Z", "data": null, "key_id": 2}}"#
498 + );
499 + let entry: PullChangeEntry = serde_json::from_str(&json).unwrap();
500 + assert_eq!(entry.key_id, Some(2));
Lines truncated
@@ -0,0 +1,316 @@
1 + //! SSE push notification stream for real-time sync notifications.
2 + //!
3 + //! The server sends zero-data `event: changed` events over SSE whenever
4 + //! another device pushes changes. The client should pull on each event.
5 + //! The stream auto-reconnects with backoff across transient drops; it only
6 + //! ends (`next_change` returns `None`) on a fatal condition, the server
7 + //! rejecting the reconnect for auth reasons, or a protocol violation.
8 +
9 + use std::sync::Arc;
10 + use std::time::Duration;
11 +
12 + use rand::RngExt;
13 +
14 + use crate::error::{Result, SyncKitError};
15 +
16 + use super::{SecretToken, SyncKitClient};
17 +
18 + /// A stream of SSE "changed" notifications from the SyncKit server.
19 + ///
20 + /// Created by [`SyncKitClient::subscribe`]. Wraps a raw HTTP response byte
21 + /// stream and parses SSE events line by line, yielding `Some(())` for each
22 + /// `event: changed`. When the underlying connection drops, it transparently
23 + /// reconnects (exponential backoff, capped, jittered) rather than ending,
24 + /// SSE `changed` events are contentless pokes, so a reconnect needs no cursor.
25 + /// `next_change` returns `None` only when reconnection is futile: the server
26 + /// rejects the request with `401`/`403` (the caller must re-authenticate), or
27 + /// the stream violates the protocol (invalid UTF-8, oversized block).
28 + pub struct SyncNotifyStream {
29 + buffer: String,
30 + /// `None` between a drop and the next successful reconnect.
31 + response: Option<reqwest::Response>,
32 + http: reqwest::Client,
33 + url: String,
34 + token: Arc<SecretToken>,
35 + reconnect_attempt: u32,
36 + }
37 +
38 + /// Maximum SSE buffer size (1 MB). If the server sends data without a
39 + /// block terminator (`\n\n`) exceeding this limit, we treat it as an error
40 + /// and close the stream to prevent unbounded memory growth.
41 + const MAX_SSE_BUFFER: usize = 1024 * 1024;
42 +
43 + /// Cap on the reconnect backoff delay.
44 + const MAX_RECONNECT_DELAY: Duration = Duration::from_mins(1);
45 +
46 + /// Maximum *consecutive* failed reconnect attempts before the stream gives up.
47 + /// A single success resets the counter, so this only fires when the server has
48 + /// been unreachable or erroring for a sustained stretch (with the 60s backoff
49 + /// cap, several minutes). Without it, a permanently-degraded non-auth server
50 + /// (persistent 500s, connection refused) is retried silently forever, the
51 + /// caller is better told the stream is dead so it can re-subscribe later.
52 + const MAX_RECONNECT_ATTEMPTS: u32 = 10;
53 +
54 + /// Reconnect backoff: 1s, 2s, 4s, ... capped at [`MAX_RECONNECT_DELAY`], with
55 + /// +/-20% jitter so a fleet of clients doesn't reconnect in lockstep.
56 + fn reconnect_delay(attempt: u32) -> Duration {
57 + let base = Duration::from_secs(1)
58 + .saturating_mul(2u32.saturating_pow(attempt.min(6)))
59 + .min(MAX_RECONNECT_DELAY);
60 + let millis = base.as_millis() as u64;
61 + let span = millis / 5;
62 + let delta = rand::rng().random_range(0..=2 * span.max(1));
63 + Duration::from_millis(millis.saturating_sub(span).saturating_add(delta))
64 + }
65 +
66 + impl SyncNotifyStream {
67 + pub(crate) fn new(
68 + http: reqwest::Client,
69 + url: String,
70 + token: Arc<SecretToken>,
71 + response: reqwest::Response,
72 + ) -> Self {
73 + Self {
74 + buffer: String::new(),
75 + response: Some(response),
76 + http,
77 + url,
78 + token,
79 + reconnect_attempt: 0,
80 + }
81 + }
82 +
83 + /// Re-establish the SSE connection after a drop. Loops with backoff on
84 + /// transient failures; returns `false` (fatal) if the server rejects the
85 + /// request for auth reasons (caller re-authenticates) or after
86 + /// [`MAX_RECONNECT_ATTEMPTS`] consecutive failures (caller re-subscribes
87 + /// later) rather than retrying a dead server forever.
88 + async fn reconnect(&mut self) -> bool {
89 + loop {
90 + self.reconnect_attempt += 1;
91 + if self.reconnect_attempt > MAX_RECONNECT_ATTEMPTS {
92 + tracing::warn!(
93 + attempts = self.reconnect_attempt - 1,
94 + "SSE reconnect gave up after too many consecutive failures; stream closing"
95 + );
96 + return false;
97 + }
98 + tokio::time::sleep(reconnect_delay(self.reconnect_attempt)).await;
99 +
100 + match self
101 + .http
102 + .get(&self.url)
103 + .bearer_auth(&*self.token)
104 + .send()
105 + .await
106 + {
107 + Ok(resp) => {
108 + let status = resp.status().as_u16();
109 + if status == 401 || status == 403 {
110 + tracing::warn!(status, "SSE reconnect rejected (auth); stream closing");
111 + return false;
112 + }
113 + if status >= 400 {
114 + tracing::debug!(
115 + status,
116 + attempt = self.reconnect_attempt,
117 + "SSE reconnect got error status; backing off"
118 + );
119 + continue;
120 + }
121 + tracing::debug!(attempt = self.reconnect_attempt, "SSE reconnected");
122 + self.reconnect_attempt = 0;
123 + self.buffer.clear();
124 + self.response = Some(resp);
125 + return true;
126 + }
127 + Err(e) => {
128 + tracing::debug!(
129 + error = %e,
130 + attempt = self.reconnect_attempt,
131 + "SSE reconnect failed; backing off"
132 + );
133 + }
134 + }
135 + }
136 + }
137 +
138 + /// Wait for the next "changed" notification.
139 + ///
140 + /// Transparently reconnects across transient stream drops. Returns `Some(())`
141 + /// when the server signals new changes. Returns `None` only on a fatal
142 + /// condition: the reconnect is rejected for auth, or the stream violates the
143 + /// SSE protocol (invalid UTF-8 / oversized block). Keepalive comments (lines
144 + /// starting with `:`) are silently ignored.
145 + pub async fn next_change(&mut self) -> Option<()> {
146 + loop {
147 + if self.response.is_none() && !self.reconnect().await {
148 + return None;
149 + }
150 +
151 + // Try to extract a complete SSE block from the buffer
152 + if let Some(pos) = self.buffer.find("\n\n") {
153 + let block = self.buffer[..pos].to_string();
154 + self.buffer = self.buffer[pos + 2..].to_string();
155 +
156 + if parse_sse_block_is_changed(&block) {
157 + return Some(());
158 + }
159 + // Not a "changed" event, skip and try next block
160 + continue;
161 + }
162 +
163 + // Need more data from the stream
164 + let chunk = self
165 + .response
166 + .as_mut()
167 + .expect("connected above")
168 + .chunk()
169 + .await;
170 + match chunk {
171 + Ok(Some(chunk)) => {
172 + match std::str::from_utf8(&chunk) {
173 + Ok(text) => self.buffer.push_str(text),
174 + Err(_) => {
175 + tracing::warn!("SSE stream contained invalid UTF-8, closing");
176 + return None;
177 + }
178 + }
179 + if self.buffer.len() > MAX_SSE_BUFFER {
180 + tracing::warn!(
181 + "SSE buffer exceeded {MAX_SSE_BUFFER} bytes, closing stream"
182 + );
183 + return None;
184 + }
185 + }
186 + // Stream ended or errored: drop it and reconnect on the next loop
187 + // instead of ending, this is the auto-reconnect.
188 + Ok(None) => {
189 + tracing::debug!("SSE stream ended; will reconnect");
190 + self.response = None;
191 + }
192 + Err(e) => {
193 + tracing::debug!(error = %e, "SSE stream error; will reconnect");
194 + self.response = None;
195 + }
196 + }
197 + }
198 + }
199 + }
200 +
201 + /// Parse an SSE block and return `true` if it contains `event: changed`.
202 + ///
203 + /// An SSE block is one or more lines separated by `\n`, terminated by `\n\n`.
204 + /// Lines starting with `:` are comments (keepalive). The `event:` field
205 + /// specifies the event type, `data:` the payload (ignored here).
206 + fn parse_sse_block_is_changed(block: &str) -> bool {
207 + for line in block.lines() {
208 + let trimmed = line.trim();
209 + // Skip comments
210 + if trimmed.starts_with(':') {
211 + continue;
212 + }
213 + if let Some(value) = trimmed.strip_prefix("event:")
214 + && value.trim() == "changed"
215 + {
216 + return true;
217 + }
218 + }
219 + false
220 + }
221 +
222 + impl SyncKitClient {
223 + /// Open an SSE connection for real-time push notifications.
224 + ///
225 + /// Returns a [`SyncNotifyStream`] that yields `Some(())` each time the
226 + /// server signals new changes are available. The caller should pull after
227 + /// each notification to get the actual changes.
228 + ///
229 + /// The returned stream auto-reconnects with backoff across transient drops;
230 + /// `next_change()` returns `None` only on a fatal condition (auth rejected on
231 + /// reconnect, or a protocol violation), at which point the caller should
232 + /// re-authenticate and subscribe again.
233 + #[tracing::instrument(skip(self))]
234 + pub async fn subscribe(&self) -> Result<SyncNotifyStream> {
235 + let token = self.require_token()?;
236 + let (app_id, _user_id) = self.require_session_ids()?;
237 + let url = format!("{}?app_id={}", self.endpoints.subscribe, app_id);
238 +
239 + let resp = self
240 + .http_stream
241 + .get(&url)
242 + .bearer_auth(&*token)
243 + .send()
244 + .await
245 + .map_err(SyncKitError::Http)?;
246 +
247 + let status = resp.status().as_u16();
248 + if status >= 400 {
249 + let message = crate::client::helpers::read_text_capped(
250 + resp,
251 + crate::client::helpers::MAX_CONTROL_BODY_BYTES,
252 + )
253 + .await;
254 + return Err(SyncKitError::Server {
255 + status,
256 + message,
257 + retry_after_secs: None,
258 + });
259 + }
260 +
261 + // Hand the stream everything it needs to reconnect itself.
262 + Ok(SyncNotifyStream::new(
263 + self.http_stream.clone(),
264 + url,
265 + token,
266 + resp,
267 + ))
268 + }
269 + }
270 +
271 + #[cfg(test)]
272 + mod tests {
273 + use super::*;
274 +
275 + #[test]
276 + fn parse_changed_event() {
277 + let block = "event: changed\ndata: {}";
278 + assert!(parse_sse_block_is_changed(block));
279 + }
280 +
281 + #[test]
282 + fn parse_ignores_keepalive_comments() {
283 + let block = ": keepalive";
284 + assert!(!parse_sse_block_is_changed(block));
285 + }
286 +
287 + #[test]
288 + fn parse_handles_malformed_lines() {
289 + let block = "garbled nonsense";
290 + assert!(!parse_sse_block_is_changed(block));
291 +
292 + let block2 = "";
293 + assert!(!parse_sse_block_is_changed(block2));
294 +
295 + let block3 = "event:other\ndata: test";
296 + assert!(!parse_sse_block_is_changed(block3));
297 + }
298 +
299 + #[test]
300 + fn parse_changed_with_extra_whitespace() {
301 + let block = "event: changed \ndata: {}";
302 + assert!(parse_sse_block_is_changed(block));
303 + }
304 +
305 + #[test]
306 + fn reconnect_delay_grows_then_caps() {
307 + // Within +/-20% jitter, the delay grows with the attempt and never
308 + // exceeds the cap plus jitter headroom.
309 + let d1 = reconnect_delay(1);
310 + let d6 = reconnect_delay(6);
311 + assert!(d1 >= Duration::from_millis(1600) && d1 <= Duration::from_millis(2400));
312 + assert!(d6 <= MAX_RECONNECT_DELAY + Duration::from_secs(12));
313 + // Deep attempts stay capped (exponent is clamped).
314 + assert!(reconnect_delay(100) <= MAX_RECONNECT_DELAY + Duration::from_secs(12));
315 + }
316 + }
@@ -0,0 +1,442 @@
1 + //! Subscription status, pricing-formula quoting, checkout, and storage-cap
2 + //! management for end-user SyncKit subscriptions.
3 + //!
4 + //! The server uses a formula-driven pricing model: there are no fixed tiers,
5 + //! the user picks any storage cap (within a min/max) and the server quotes a
6 + //! price. Apps typically show a slider, call [`SyncKitClient::quote_price`]
7 + //! to get the live number, then call [`SyncKitClient::create_subscription_checkout`]
8 + //! once the user clicks subscribe.
9 +
10 + use bytes::Bytes;
11 + use serde::{Deserialize, Deserializer, Serialize, Serializer};
12 +
13 + use super::SyncKitClient;
14 + use super::helpers::{Idempotency, check_response};
15 + use crate::error::Result;
16 +
17 + /// A monetary amount in whole cents.
18 + ///
19 + /// All pricing arithmetic flows through this newtype so the saturating
20 + /// discipline lives in one place rather than being re-derived at each call
21 + /// site: the inputs are server-supplied and a hostile or buggy value must
22 + /// never panic (debug overflow) or wrap to a negative price (release). It is
23 + /// `#[serde(transparent)]`, so it is wire- and JSON-identical to the bare `i64`
24 + /// it replaces.
25 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
26 + #[serde(transparent)]
27 + pub struct Cents(pub i64);
28 +
29 + impl Cents {
30 + /// The underlying whole-cent count.
31 + pub const fn get(self) -> i64 {
32 + self.0
33 + }
34 +
35 + /// Add two amounts, saturating at [`i64::MAX`] rather than overflowing.
36 + #[must_use]
37 + pub fn saturating_add(self, other: Cents) -> Cents {
38 + Cents(self.0.saturating_add(other.0))
39 + }
40 +
41 + /// Scale by an integer factor (e.g. a months multiplier), saturating.
42 + #[must_use]
43 + pub fn saturating_mul(self, factor: i64) -> Cents {
44 + Cents(self.0.saturating_mul(factor))
45 + }
46 +
47 + /// The larger of two amounts (e.g. applying a price floor).
48 + #[must_use]
49 + pub fn max(self, other: Cents) -> Cents {
50 + Cents(self.0.max(other.0))
51 + }
52 + }
53 +
54 + impl std::fmt::Display for Cents {
55 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 + write!(f, "{}", self.0)
57 + }
58 + }
59 +
60 + /// Subscription status as returned by the server.
61 + #[derive(Debug, Clone, Default, Serialize, Deserialize)]
62 + #[non_exhaustive]
63 + pub struct SubscriptionStatus {
64 + /// Whether the user has an active sync subscription for this app.
65 + pub active: bool,
66 + /// Billing interval. `None` if no subscription.
67 + /// Kept under the legacy field name `tier` on the wire for SDK compat.
68 + #[serde(rename = "tier")]
69 + pub interval: Option<BillingInterval>,
70 + /// Subscription status string (e.g. "active", "past_due", "canceled").
71 + pub status: Option<String>,
72 + /// Current blob storage cap, in bytes.
73 + pub storage_limit_bytes: Option<i64>,
74 + /// A queued cap change that applies at the next billing cycle. `None`
75 + /// when no change is pending.
76 + #[serde(default)]
77 + pub pending_storage_limit_bytes: Option<i64>,
78 + /// Blob storage currently in use, in bytes (when the server has the info).
79 + pub storage_used_bytes: Option<i64>,
80 + /// ISO 8601 end of current billing period.
81 + pub current_period_end: Option<String>,
82 + }
83 +
84 + /// Pricing-formula constants for an app. Clients use these to quote a price
85 + /// locally as a slider moves; the server enforces the same formula at
86 + /// checkout so client-computed prices are advisory only.
87 + #[derive(Debug, Clone, Serialize, Deserialize)]
88 + #[non_exhaustive]
89 + pub struct AppPricing {
90 + pub app_name: String,
91 + /// Minimum charge (applies to both monthly and annual).
92 + pub min_charge_cents: Cents,
93 + /// Storage rate per GiB per month, in tenths of a cent. Convert with
94 + /// `(gib * per_gb_tenths + 9) / 10` to get cents.
95 + pub per_gb_tenths_of_cent_per_month: i64,
96 + /// Annual price is monthly × this multiplier.
97 + pub annual_multiplier: i64,
98 + pub min_cap_bytes: i64,
99 + pub max_cap_bytes: i64,
100 + }
101 +
102 + impl AppPricing {
103 + /// Compute the price in cents for a given cap and interval. Mirrors the
104 + /// server-side formula so client-side previews match what the user will
105 + /// actually be charged.
106 + ///
107 + /// All arithmetic saturates: the inputs are server-supplied `i64` values, and
108 + /// a hostile or buggy server returning huge rates must not panic the client
109 + /// (debug overflow) or wrap to a negative price (release). The server is
110 + /// authoritative at checkout, so this is an advisory preview regardless.
111 + pub fn quote_cents(&self, cap_bytes: i64, interval: BillingInterval) -> Cents {
112 + let cap_bytes = cap_bytes.clamp(self.min_cap_bytes, self.max_cap_bytes);
113 + let gib = cap_bytes_to_gib_ceil(cap_bytes);
114 + // The storage rate is tenths-of-a-cent per GiB; convert to whole cents,
115 + // rounding up. Intermediate stays i64 (a rate, not money) until the
116 + // result becomes a `Cents` amount.
117 + let storage_monthly = Cents(
118 + gib.saturating_mul(self.per_gb_tenths_of_cent_per_month)
119 + .saturating_add(9)
120 + / 10,
121 + );
122 + let monthly = storage_monthly.max(self.min_charge_cents);
123 + match interval {
124 + BillingInterval::Monthly => monthly,
125 + BillingInterval::Annual => monthly.saturating_mul(self.annual_multiplier),
126 + }
127 + }
128 + }
129 +
130 + fn cap_bytes_to_gib_ceil(cap_bytes: i64) -> i64 {
131 + const GIB: i64 = 1024 * 1024 * 1024;
132 + cap_bytes.saturating_add(GIB - 1) / GIB
133 + }
134 +
135 + /// Billing interval for SyncKit subscriptions.
136 + ///
137 + /// Serializes to/from the wire string (`"monthly"`/`"annual"`). Deserialization
138 + /// is lenient via [`from_wire`](BillingInterval::from_wire): an unknown tag maps
139 + /// to `Monthly` rather than erroring, so a future server-side interval cannot
140 + /// break an older client's status parse.
141 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
142 + pub enum BillingInterval {
143 + Monthly,
144 + Annual,
145 + }
146 +
147 + impl Serialize for BillingInterval {
148 + fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
149 + serializer.serialize_str(self.as_str())
150 + }
151 + }
152 +
153 + impl<'de> Deserialize<'de> for BillingInterval {
154 + fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
155 + let s = String::deserialize(deserializer)?;
156 + Ok(BillingInterval::from_wire(&s))
157 + }
158 + }
159 +
160 + impl BillingInterval {
161 + pub fn as_str(self) -> &'static str {
162 + match self {
163 + Self::Monthly => "monthly",
164 + Self::Annual => "annual",
165 + }
166 + }
167 +
168 + /// Parse from the server wire string. Falls back to `Monthly` for unknown
169 + /// values rather than erroring, defensive against a future interval tag.
170 + /// Named `from_wire` (not `from_str`) because it is infallible and lenient,
171 + /// unlike `std::str::FromStr`.
172 + pub fn from_wire(s: &str) -> Self {
173 + match s {
174 + "annual" => Self::Annual,
175 + other => {
176 + if !other.is_empty() && other != "monthly" {
177 + tracing::debug!(tag = %other, "unknown billing interval tag; defaulting to monthly");
178 + }
179 + Self::Monthly
180 + }
181 + }
182 + }
183 + }
184 +
185 + /// Response from creating a checkout session.
186 + #[derive(Debug, Deserialize)]
187 + #[non_exhaustive]
188 + pub struct CheckoutResponse {
189 + /// URL to redirect the user to for Stripe Checkout.
190 + pub checkout_url: String,
191 + }
192 +
193 + /// Account identifiers for the authenticated MNW user.
194 + #[derive(Debug, Clone, Serialize, Deserialize)]
195 + #[non_exhaustive]
196 + pub struct AccountInfo {
197 + /// The authenticated user's email address.
198 + pub email: String,
199 + /// The authenticated user's username.
200 + pub username: String,
201 + }
202 +
203 + #[derive(Debug, Serialize, Deserialize)]
204 + #[non_exhaustive]
205 + pub struct PriceQuote {
206 + pub cap_bytes: i64,
207 + pub interval: BillingInterval,
208 + pub price_cents: Cents,
209 + }
210 +
211 + #[derive(Debug, Serialize)]
212 + struct AppPricingRequest<'a> {
213 + api_key: &'a str,
214 + }
215 +
216 + #[derive(Debug, Serialize)]
217 + struct QuoteRequest {
218 + cap_bytes: i64,
219 + interval: &'static str,
220 + }
221 +
222 + #[derive(Debug, Serialize)]
223 + struct CheckoutRequest {
224 + cap_bytes: i64,
225 + interval: &'static str,
226 + }
227 +
228 + #[derive(Debug, Serialize)]
229 + struct CapChangeRequest {
230 + cap_bytes: i64,
231 + }
232 +
233 + impl SyncKitClient {
234 + /// Fetch the pricing-formula constants for this app. No JWT needed; the
235 + /// app's API key is sent in the body. Safe to call before login so the
236 + /// UI can show pricing on the marketing/onboarding view.
237 + #[tracing::instrument(skip(self))]
238 + pub async fn get_app_pricing(&self) -> Result<AppPricing> {
239 + let body = Bytes::from(serde_json::to_vec(&AppPricingRequest {
240 + api_key: &self.config.api_key,
241 + })?);
242 + self.retry_request_json(Idempotency::ReadOnly, || {
243 + let req = self
244 + .http
245 + .post(&self.endpoints.app_pricing)
246 + .header("content-type", "application/json")
247 + .body(body.clone());
248 + async move { check_response(req.send().await?).await }
249 + })
250 + .await
251 + }
252 +
253 + /// Server-side price quote for a (cap, interval). Use this to confirm
254 + /// the number you display matches what Stripe will charge; the result is
255 + /// authoritative.
256 + #[tracing::instrument(skip(self))]
257 + pub async fn quote_price(
258 + &self,
259 + cap_bytes: i64,
260 + interval: BillingInterval,
261 + ) -> Result<PriceQuote> {
262 + let token = self.require_token()?;
263 + let body = Bytes::from(serde_json::to_vec(&QuoteRequest {
264 + cap_bytes,
265 + interval: interval.as_str(),
266 + })?);
267 + self.retry_request_json(Idempotency::ReadOnly, || {
268 + let req = self
269 + .http
270 + .post(&self.endpoints.subscription_quote)
271 + .bearer_auth(token.as_str())
272 + .header("content-type", "application/json")
273 + .body(body.clone());
274 + async move { check_response(req.send().await?).await }
275 + })
276 + .await
277 + }
278 +
279 + /// Fetch the authenticated user's email and username.
280 + ///
281 + /// Used by apps to display "logged in as ..." in their cloud-sync UI.
282 + #[tracing::instrument(skip(self))]
283 + pub async fn get_account_info(&self) -> Result<AccountInfo> {
284 + let token = self.require_token()?;
285 + self.retry_request_json(Idempotency::ReadOnly, || {
286 + let req = self
287 + .http
288 + .get(&self.endpoints.account)
289 + .bearer_auth(token.as_str());
290 + async move { check_response(req.send().await?).await }
291 + })
292 + .await
293 + }
294 +
295 + /// Check the subscription status for this authenticated user + app.
296 + ///
297 + /// Returns `SubscriptionStatus` with `active: true` if sync is allowed,
298 + /// or `active: false` if the user needs to subscribe.
299 + #[tracing::instrument(skip(self))]
300 + pub async fn get_subscription_status(&self) -> Result<SubscriptionStatus> {
301 + let token = self.require_token()?;
302 + self.retry_request_json(Idempotency::ReadOnly, || {
303 + let req = self
304 + .http
305 + .get(&self.endpoints.subscription)
306 + .bearer_auth(token.as_str());
307 + async move { check_response(req.send().await?).await }
308 + })
309 + .await
310 + }
311 +
312 + /// Create a Stripe Checkout session for subscribing to cloud sync at the
313 + /// chosen storage cap. Returns a URL that should be opened in the user's
314 + /// browser.
315 + #[tracing::instrument(skip(self))]
316 + pub async fn create_subscription_checkout(
317 + &self,
318 + cap_bytes: i64,
319 + interval: BillingInterval,
320 + ) -> Result<CheckoutResponse> {
321 + let token = self.require_token()?;
322 + let body = Bytes::from(serde_json::to_vec(&CheckoutRequest {
323 + cap_bytes,
324 + interval: interval.as_str(),
325 + })?);
326 + // Not idempotent: a blind auto-retry could mint a second Stripe Checkout
327 + // session. Attempt once; the user re-initiates on a transient failure.
328 + self.retry_request_json(Idempotency::Unsafe, || {
329 + let req = self
330 + .http
331 + .post(&self.endpoints.subscription_checkout)
332 + .bearer_auth(token.as_str())
333 + .header("content-type", "application/json")
334 + .body(body.clone());
335 + async move { check_response(req.send().await?).await }
336 + })
337 + .await
338 + }
339 +
340 + /// Queue a storage-cap change that takes effect at the next billing
341 + /// cycle. Returns the updated subscription status with the new cap in
342 + /// `pending_storage_limit_bytes`.
343 + #[tracing::instrument(skip(self))]
344 + pub async fn queue_storage_cap_change(&self, cap_bytes: i64) -> Result<SubscriptionStatus> {
345 + let token = self.require_token()?;
346 + let body = Bytes::from(serde_json::to_vec(&CapChangeRequest { cap_bytes })?);
347 + self.retry_request_json(
348 + Idempotency::IdempotentWrite {
349 + on: "pending cap change (upsert)",
350 + },
351 + || {
352 + let req = self
353 + .http
354 + .post(&self.endpoints.subscription_storage_cap)
355 + .bearer_auth(token.as_str())
356 + .header("content-type", "application/json")
357 + .body(body.clone());
358 + async move { check_response(req.send().await?).await }
359 + },
360 + )
361 + .await
362 + }
363 + }
364 +
365 + #[cfg(test)]
366 + mod tests {
367 + use super::*;
368 +
369 + fn pricing(min_charge: i64, per_gb_tenths: i64, annual_mult: i64) -> AppPricing {
370 + AppPricing {
371 + app_name: "test".into(),
372 + min_charge_cents: Cents(min_charge),
373 + per_gb_tenths_of_cent_per_month: per_gb_tenths,
374 + annual_multiplier: annual_mult,
375 + min_cap_bytes: 0,
376 + max_cap_bytes: i64::MAX,
377 + }
378 + }
379 +
380 + #[test]
381 + fn quote_cents_applies_floor_and_annual_multiplier() {
382 + // 10 GiB at 50 tenths-of-a-cent/GiB/mo = 500 tenths => 50 cents, above the
383 + // 16-cent floor; annual = 10x.
384 + let p = pricing(16, 50, 10);
385 + let ten_gib = 10 * 1024 * 1024 * 1024;
386 + assert_eq!(p.quote_cents(ten_gib, BillingInterval::Monthly), Cents(50));
387 + assert_eq!(p.quote_cents(ten_gib, BillingInterval::Annual), Cents(500));
388 + // Tiny cap falls back to the minimum charge.
389 + assert_eq!(p.quote_cents(1, BillingInterval::Monthly), Cents(16));
390 + }
391 +
392 + #[test]
393 + fn quote_cents_saturates_on_hostile_server_numbers() {
394 + // A server returning an absurd per-GiB rate and multiplier must not panic
395 + // (debug) or wrap negative (release), it saturates.
396 + let p = pricing(0, i64::MAX, i64::MAX);
397 + let big = p.quote_cents(i64::MAX, BillingInterval::Annual);
398 + assert!(big.get() >= 0, "price must never wrap negative, got {big}");
399 + assert_eq!(big, Cents(i64::MAX));
400 + }
401 +
402 + #[test]
403 + fn billing_interval_from_wire_defaults_to_monthly() {
404 + assert_eq!(
405 + BillingInterval::from_wire("annual"),
406 + BillingInterval::Annual
407 + );
408 + assert_eq!(
409 + BillingInterval::from_wire("monthly"),
410 + BillingInterval::Monthly
411 + );
412 + assert_eq!(
413 + BillingInterval::from_wire("weekly"),
414 + BillingInterval::Monthly
415 + );
416 + assert_eq!(BillingInterval::from_wire(""), BillingInterval::Monthly);
417 + }
418 +
419 + #[test]
420 + fn billing_interval_serde_roundtrips_as_wire_string() {
421 + // Serializes to the bare wire string and parses back leniently.
422 + assert_eq!(
423 + serde_json::to_string(&BillingInterval::Annual).unwrap(),
424 + "\"annual\""
425 + );
426 + assert_eq!(
427 + serde_json::from_str::<BillingInterval>("\"monthly\"").unwrap(),
428 + BillingInterval::Monthly
429 + );
430 + // Unknown future tag falls back to Monthly, not a parse error.
431 + assert_eq!(
432 + serde_json::from_str::<BillingInterval>("\"weekly\"").unwrap(),
433 + BillingInterval::Monthly
434 + );
435 + }
436 +
437 + #[test]
438 + fn cents_is_wire_transparent_to_i64() {
439 + assert_eq!(serde_json::to_string(&Cents(1234)).unwrap(), "1234");
440 + assert_eq!(serde_json::from_str::<Cents>("1234").unwrap(), Cents(1234));
441 + }
442 + }
@@ -0,0 +1,506 @@
1 + //! Device registration and the core encrypted push/pull.
2 + //!
3 + //! Registers a device, lists the account's devices, and moves change entries
4 + //! to and from the server. Rows are encrypted before push and decrypted after
5 + //! pull, so the transport only ever carries ciphertext; the `*_filtered` and
6 + //! `*_rich` variants narrow the pull or return per-row sync metadata.
7 +
8 + use bytes::Bytes;
9 + use tracing::instrument;
10 + use uuid::Uuid;
11 +
12 + use crate::{
13 + crypto,
14 + error::Result,
15 + ids::DeviceId,
16 + types::{
17 + ChangeEntry, Device, FilteredPullRequest, PullChangeEntry, PullFilter, PullRequest,
18 + PullResponse, PulledChange, PushResponse, RegisterDeviceRequest, SyncStatus,
19 + WirePushRequest,
20 + },
21 + };
22 +
23 + use super::SyncKitClient;
24 + use super::helpers::{Idempotency, check_response};
25 +
26 + impl SyncKitClient {
27 + // ── Devices ──
28 +
29 + /// Register a device for sync.
30 + ///
31 + /// If a device with the same name already exists for this user/app, the
32 + /// server upserts: it updates the existing device's platform and
33 + /// `last_seen_at` rather than creating a duplicate.
34 + #[instrument(skip(self))]
35 + pub async fn register_device(&self, device_name: &str, platform: &str) -> Result<Device> {
36 + let token = self.require_token()?;
37 +
38 + let body = Bytes::from(serde_json::to_vec(&RegisterDeviceRequest {
39 + device_name: device_name.to_string(),
40 + platform: platform.to_string(),
41 + })?);
42 +
43 + self.retry_request_json(
44 + Idempotency::IdempotentWrite {
45 + on: "device registration (server-deduped)",
46 + },
47 + || {
48 + let req = self
49 + .http
50 + .post(&self.endpoints.devices)
51 + .bearer_auth(&token)
52 + .header("content-type", "application/json")
53 + .body(body.clone());
54 + async move { check_response(req.send().await?).await }
55 + },
56 + )
57 + .await
58 + }
59 +
60 + /// List all devices for the current user.
61 + #[instrument(skip(self))]
62 + pub async fn list_devices(&self) -> Result<Vec<Device>> {
63 + let token = self.require_token()?;
64 +
65 + self.retry_request_json(Idempotency::ReadOnly, || {
66 + let req = self.http.get(&self.endpoints.devices).bearer_auth(&token);
67 + async move { check_response(req.send().await?).await }
68 + })
69 + .await
70 + }
71 +
72 + // ── Push / Pull ──
73 +
74 + /// Push changes to the server. Encrypts `data` fields automatically.
75 + /// Returns the server cursor after the push.
76 + ///
77 + /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
78 + #[instrument(skip(self, changes))]
79 + pub async fn push(&self, device_id: DeviceId, changes: Vec<ChangeEntry>) -> Result<i64> {
80 + let token = self.require_token()?;
81 +
82 + // Every change now seals an HLC envelope (Deletes included), so the master
83 + // key is needed whenever there is anything to push.
84 + let key_holder = if changes.is_empty() {
85 + crypto::ZeroizeOnDrop([0u8; 32])
86 + } else {
87 + self.require_master_key()?
88 + };
89 + let master_key: &[u8; 32] = &key_holder;
90 + let wire_changes = changes
91 + .into_iter()
92 + .map(|c| Self::encrypt_change_with_key(c, master_key))
93 + .collect::<Result<Vec<_>>>()?;
94 +
95 + let body = Bytes::from(serde_json::to_vec(&WirePushRequest {
96 + device_id,
97 + batch_id: Uuid::new_v4(),
98 + changes: wire_changes,
99 + })?);
100 +
101 + let push_resp: PushResponse = self
102 + .retry_request_json(Idempotency::Keyed, || {
103 + let req = self
104 + .http
105 + .post(&self.endpoints.push)
106 + .bearer_auth(&token)
107 + .header("content-type", "application/json")
108 + .body(body.clone());
109 + async move { check_response(req.send().await?).await }
110 + })
111 + .await?;
112 + Ok(push_resp.cursor)
113 + }
114 +
115 + /// Pull changes from the server since the given cursor.
116 + /// Decrypts `data` fields automatically.
117 + /// Returns (changes, new_cursor, has_more).
118 + ///
119 + /// Retries on transient failures (network errors, 5xx, 429) with exponential backoff.
120 + ///
121 + /// **Contract, persist the cursor only after applying.** The returned cursor
122 + /// must be written to durable storage *after* the returned changes are
123 + /// applied, in the same transaction where possible. Persisting the cursor
124 + /// first and crashing before apply silently drops a batch; the SDK does not
125 + /// dedup re-delivered changes, so apply must also be idempotent per
126 + /// `(table, row_id, hlc)`.
127 + #[instrument(skip(self))]
128 + pub async fn pull(
129 + &self,
130 + device_id: DeviceId,
131 + cursor: i64,
132 + ) -> Result<(Vec<ChangeEntry>, i64, bool)> {
133 + let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
134 + self.pull_inner(body, Self::decrypt_change_with_key).await
135 + }
136 +
137 + /// Pull changes from the server with optional table and timestamp filters.
138 + /// Decrypts `data` fields automatically.
139 + /// Returns (changes, new_cursor, has_more).
140 + ///
141 + /// Identical to [`pull`](SyncKitClient::pull) when the filter is empty/default.
142 + #[instrument(skip(self, filter))]
143 + pub async fn pull_filtered(
144 + &self,
145 + device_id: DeviceId,
146 + cursor: i64,
147 + filter: PullFilter,
148 + ) -> Result<(Vec<ChangeEntry>, i64, bool)> {
149 + let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest {
150 + device_id,
151 + cursor,
152 + tables: filter.tables,
153 + since: filter.since,
154 + })?);
155 + self.pull_inner(body, Self::decrypt_change_with_key).await
156 + }
157 +
158 + /// Pull changes from the server, preserving `device_id` and `seq` metadata.
159 + ///
160 + /// Same HTTP call and decryption as [`pull`](SyncKitClient::pull), but returns
161 + /// [`PulledChange`] wrappers that retain server metadata needed for conflict
162 + /// detection. Returns (changes, new_cursor, has_more).
163 + #[instrument(skip(self))]
164 + pub async fn pull_rich(
165 + &self,
166 + device_id: DeviceId,
167 + cursor: i64,
168 + ) -> Result<(Vec<PulledChange>, i64, bool)> {
169 + let body = Bytes::from(serde_json::to_vec(&PullRequest { device_id, cursor })?);
170 + self.pull_inner(body, Self::decrypt_change_to_pulled).await
171 + }
172 +
173 + /// Pull changes with filters, preserving `device_id` and `seq` metadata.
174 + ///
175 + /// Same as [`pull_rich`](SyncKitClient::pull_rich) but with table/timestamp
176 + /// filtering support. Returns (changes, new_cursor, has_more).
177 + #[instrument(skip(self, filter))]
178 + pub async fn pull_filtered_rich(
179 + &self,
180 + device_id: DeviceId,
181 + cursor: i64,
182 + filter: PullFilter,
183 + ) -> Result<(Vec<PulledChange>, i64, bool)> {
184 + let body = Bytes::from(serde_json::to_vec(&FilteredPullRequest {
185 + device_id,
186 + cursor,
187 + tables: filter.tables,
188 + since: filter.since,
189 + })?);
190 + self.pull_inner(body, Self::decrypt_change_to_pulled).await
191 + }
192 +
193 + /// Shared pull implementation: sends the request, extracts the master key,
194 + /// and decrypts each change using the provided function.
195 + ///
196 + /// If a pending rotation key is cached on the client, entries are decrypted
197 + /// with key selection based on each entry's `key_id` field.
198 + async fn pull_inner<T, F>(&self, body: Bytes, decrypt_fn: F) -> Result<(Vec<T>, i64, bool)>
199 + where
200 + F: Fn(PullChangeEntry, &[u8; 32]) -> Result<T>,
201 + {
202 + let token = self.require_token()?;
203 +
204 + let pull_resp: PullResponse = self
205 + .retry_request_json(Idempotency::ReadOnly, || {
206 + let req = self
207 + .http
208 + .post(&self.endpoints.pull)
209 + .bearer_auth(&token)
210 + .header("content-type", "application/json")
211 + .body(body.clone());
212 + async move { check_response(req.send().await?).await }
213 + })
214 + .await?;
215 +
216 + // Extract key once for the entire batch (only needed if any entry has data)
217 + let has_data = pull_resp.changes.iter().any(|c| c.data.is_some());
218 + let key_holder = if has_data {
219 + self.require_master_key()?
220 + } else {
221 + crypto::ZeroizeOnDrop([0u8; 32])
222 + };
223 + let master_key: &[u8; 32] = &key_holder;
224 +
225 + // Check for pending rotation key (multi-key decryption)
226 + let pending_guard = self.pending_key.read();
227 + let has_pending = pending_guard.is_some() && has_data;
228 +
229 + let changes = if has_pending {
230 + let pending = pending_guard.as_ref().unwrap();
231 + let primary_key_id = *self.master_key_id.read();
232 + pull_resp
233 + .changes
234 + .into_iter()
235 + .map(|c| {
236 + Self::decrypt_with_rotation_keys(
237 + c,
238 + master_key,
239 + primary_key_id,
240 + &pending.key,
241 + pending.key_id,
242 + &decrypt_fn,
243 + )
244 + })
245 + .collect::<Result<Vec<_>>>()?
246 + } else {
247 + drop(pending_guard);
248 + pull_resp
249 + .changes
250 + .into_iter()
251 + .map(|c| decrypt_fn(c, master_key))
252 + .collect::<Result<Vec<_>>>()?
253 + };
254 +
255 + Ok((changes, pull_resp.cursor, pull_resp.has_more))
256 + }
257 +
258 + /// Get sync status (total changes, latest cursor).
259 + #[instrument(skip(self))]
260 + pub async fn status(&self) -> Result<SyncStatus> {
261 + let token = self.require_token()?;
262 +
263 + self.retry_request_json(Idempotency::ReadOnly, || {
264 + let req = self.http.get(&self.endpoints.status).bearer_auth(&token);
265 + async move { check_response(req.send().await?).await }
266 + })
267 + .await
268 + }
269 + }
270 +
271 + #[cfg(test)]
272 + mod tests {
273 + use chrono::Utc;
274 + use uuid::Uuid;
275 +
276 + use crate::ids::{AppId, DeviceId, UserId};
277 + use crate::types::*;
278 +
279 + // ── Type serialization / deserialization ──
280 +
281 + #[test]
282 + fn change_entry_serialization_roundtrip() {
283 + let entry = ChangeEntry {
284 + table: "tasks".to_string(),
285 + op: ChangeOp::Insert,
286 + row_id: Uuid::new_v4().to_string(),
287 + timestamp: Utc::now(),
288 + hlc: Hlc::zero(DeviceId::nil()),
289 + data: Some(serde_json::json!({"title": "Test task", "done": false})),
290 + extra: serde_json::Map::default(),
291 + };
292 +
293 + let json = serde_json::to_string(&entry).unwrap();
294 + let deserialized: ChangeEntry = serde_json::from_str(&json).unwrap();
295 +
296 + assert_eq!(deserialized.table, entry.table);
297 + assert_eq!(deserialized.op, entry.op);
298 + assert_eq!(deserialized.row_id, entry.row_id);
299 + assert_eq!(deserialized.data, entry.data);
300 + }
301 +
302 + #[test]
303 + fn change_entry_with_none_data_omits_field() {
304 + let entry = ChangeEntry {
305 + table: "tasks".to_string(),
306 + op: ChangeOp::Delete,
307 + row_id: "abc-123".to_string(),
308 + timestamp: Utc::now(),
309 + hlc: Hlc::zero(DeviceId::nil()),
310 + data: None,
311 + extra: serde_json::Map::default(),
312 + };
313 +
314 + let json = serde_json::to_string(&entry).unwrap();
315 + assert!(!json.contains("\"data\""));
316 + }
317 +
318 + #[test]
319 + fn change_entry_deserialization_with_missing_data() {
320 + let json = r#"{
321 + "table": "events",
322 + "op": "DELETE",
323 + "row_id": "evt-1",
324 + "timestamp": "2025-01-15T10:00:00Z"
325 + }"#;
326 +
327 + let entry: ChangeEntry = serde_json::from_str(json).unwrap();
328 + assert_eq!(entry.table, "events");
329 + assert_eq!(entry.op, ChangeOp::Delete);
330 + assert!(entry.data.is_none());
331 + }
332 +
333 + #[test]
334 + fn device_serialization_roundtrip() {
335 + let device = Device {
336 + id: DeviceId::new(Uuid::new_v4()),
337 + app_id: AppId::new(Uuid::new_v4()),
338 + user_id: UserId::new(Uuid::new_v4()),
339 + device_name: "MacBook Pro".to_string(),
340 + platform: "macos".to_string(),
341 + last_seen_at: Utc::now(),
342 + created_at: Utc::now(),
343 + };
344 +
345 + let json = serde_json::to_string(&device).unwrap();
346 + let deserialized: Device = serde_json::from_str(&json).unwrap();
347 +
348 + assert_eq!(deserialized.id, device.id);
349 + assert_eq!(deserialized.device_name, device.device_name);
350 + assert_eq!(deserialized.platform, device.platform);
351 + }
352 +
353 + #[test]
354 + fn sync_status_deserialization() {
355 + let json = r#"{"total_changes": 42, "latest_cursor": 100}"#;
356 + let status: SyncStatus = serde_json::from_str(json).unwrap();
357 + assert_eq!(status.total_changes, 42);
358 + assert_eq!(status.latest_cursor, Some(100));
359 + }
360 +
361 + #[test]
362 + fn sync_status_with_null_cursor() {
363 + let json = r#"{"total_changes": 0, "latest_cursor": null}"#;
364 + let status: SyncStatus = serde_json::from_str(json).unwrap();
365 + assert_eq!(status.total_changes, 0);
366 + assert_eq!(status.latest_cursor, None);
367 + }
368 +
369 + #[test]
370 + fn register_device_request_serialization() {
371 + let req = RegisterDeviceRequest {
372 + device_name: "iPhone 15".to_string(),
373 + platform: "ios".to_string(),
374 + };
375 +
376 + let json = serde_json::to_string(&req).unwrap();
377 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
378 + assert_eq!(parsed["device_name"], "iPhone 15");
379 + assert_eq!(parsed["platform"], "ios");
380 + }
381 +
382 + // ── Wire types ──
383 +
384 + #[test]
385 + fn wire_push_request_serialization() {
386 + let device_id = DeviceId::new(Uuid::new_v4());
387 + let req = WirePushRequest {
388 + device_id,
389 + batch_id: Uuid::new_v4(),
390 + changes: vec![WireChangeEntry {
391 + table: "tasks".to_string(),
392 + op: ChangeOp::Insert,
393 + row_id: "r1".to_string(),
394 + timestamp: Utc::now(),
395 + data: Some(serde_json::json!("encrypted-blob")),
396 + }],
397 + };
398 +
399 + let json = serde_json::to_string(&req).unwrap();
400 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
401 + assert_eq!(parsed["device_id"], device_id.to_string());
402 + assert_eq!(parsed["changes"].as_array().unwrap().len(), 1);
403 + }
404 +
405 + #[test]
406 + fn pull_request_serialization() {
407 + let device_id = DeviceId::new(Uuid::new_v4());
408 + let req = PullRequest {
409 + device_id,
410 + cursor: 42,
411 + };
412 +
413 + let json = serde_json::to_string(&req).unwrap();
414 + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
415 + assert_eq!(parsed["device_id"], device_id.to_string());
416 + assert_eq!(parsed["cursor"], 42);
417 + }
418 +
419 + #[test]
420 + fn pull_response_deserialization() {
421 + let device_id = Uuid::new_v4();
422 + let json = format!(
423 + r#"{{
424 + "changes": [
425 + {{
426 + "seq": 1,
427 + "device_id": "{device_id}",
428 + "table": "tasks",
429 + "op": "INSERT",
430 + "row_id": "r1",
431 + "timestamp": "2025-06-01T12:00:00Z",
432 + "data": "encrypted"
433 + }}
434 + ],
435 + "cursor": 5,
436 + "has_more": true
437 + }}"#
438 + );
439 +
440 + let resp: PullResponse = serde_json::from_str(&json).unwrap();
441 + assert_eq!(resp.changes.len(), 1);
442 + assert_eq!(resp.cursor, 5);
443 + assert!(resp.has_more);
444 + assert_eq!(resp.changes[0].seq, 1);
445 + assert_eq!(resp.changes[0].table, "tasks");
446 + }
447 +
448 + #[test]
449 + fn pull_response_empty_changes() {
450 + let json = r#"{"changes": [], "cursor": 0, "has_more": false}"#;
451 + let resp: PullResponse = serde_json::from_str(json).unwrap();
452 + assert!(resp.changes.is_empty());
453 + assert_eq!(resp.cursor, 0);
454 + assert!(!resp.has_more);
455 + }
456 +
457 + #[test]
458 + fn push_response_deserialization() {
459 + let json = r#"{"cursor": 99}"#;
460 + let resp: PushResponse = serde_json::from_str(json).unwrap();
461 + assert_eq!(resp.cursor, 99);
462 + }
463 +
464 + // ── ChangeOp display and parsing ──
465 +
466 + #[test]
467 + fn change_op_display() {
468 + assert_eq!(ChangeOp::Insert.to_string(), "INSERT");
469 + assert_eq!(ChangeOp::Update.to_string(), "UPDATE");
470 + assert_eq!(ChangeOp::Delete.to_string(), "DELETE");
471 + }
472 +
473 + #[test]
474 + fn change_op_from_str_valid() {
475 + assert_eq!(ChangeOp::from_str_opt("INSERT"), Some(ChangeOp::Insert));
476 + assert_eq!(ChangeOp::from_str_opt("UPDATE"), Some(ChangeOp::Update));
477 + assert_eq!(ChangeOp::from_str_opt("DELETE"), Some(ChangeOp::Delete));
478 + }
479 +
480 + #[test]
481 + fn change_op_from_str_invalid() {
482 + assert_eq!(ChangeOp::from_str_opt("insert"), None);
483 + assert_eq!(ChangeOp::from_str_opt("UPSERT"), None);
484 + assert_eq!(ChangeOp::from_str_opt(""), None);
485 + }
486 +
487 + // ── Malformed response types ──
488 +
489 + #[test]
490 + fn pull_response_missing_changes_fails() {
491 + let json = r#"{"cursor": 0, "has_more": false}"#;
492 + assert!(serde_json::from_str::<PullResponse>(json).is_err());
493 + }
494 +
495 + #[test]
496 + fn pull_response_missing_cursor_fails() {
497 + let json = r#"{"changes": [], "has_more": false}"#;
498 + assert!(serde_json::from_str::<PullResponse>(json).is_err());
499 + }
500 +
Lines truncated