From 8d38f1abdfc733e0e814c55f2c2e7c5720f2e7e9 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Sun, 26 Apr 2026 13:36:33 +0200 Subject: [PATCH 1/2] refactor: remove `sync-storage-config` crate and update workspace configuration --- Cargo.lock | 144 -------------------- Cargo.toml | 2 - TODO.md | 27 +--- sync-storage-config/Cargo.toml | 17 --- sync-storage-config/src/lib.rs | 237 --------------------------------- 5 files changed, 6 insertions(+), 421 deletions(-) delete mode 100644 sync-storage-config/Cargo.toml delete mode 100644 sync-storage-config/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 8edb7f4..0e513be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,41 +17,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures", -] - -[[package]] -name = "aes-gcm" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] - [[package]] name = "ahash" version = "0.8.12" @@ -532,19 +497,6 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" -[[package]] -name = "bcrypt" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" -dependencies = [ - "base64", - "blowfish", - "getrandom 0.2.16", - "subtle", - "zeroize", -] - [[package]] name = "bincode" version = "2.0.1" @@ -613,16 +565,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blowfish" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" -dependencies = [ - "byteorder", - "cipher", -] - [[package]] name = "bumpalo" version = "3.19.0" @@ -1036,16 +978,6 @@ dependencies = [ "half", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", -] - [[package]] name = "clap" version = "4.5.46" @@ -1287,7 +1219,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1335,15 +1266,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - [[package]] name = "cubecl" version = "0.5.0" @@ -2492,16 +2414,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - [[package]] name = "gimli" version = "0.31.1" @@ -3035,15 +2947,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - [[package]] name = "intl-memoizer" version = "0.5.3" @@ -3763,12 +3666,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openssl" version = "0.10.73" @@ -4034,18 +3931,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures", - "opaque-debug", - "universal-hash", -] - [[package]] name = "portable-atomic" version = "1.11.1" @@ -4363,9 +4248,6 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] [[package]] name = "rand_core" @@ -5248,22 +5130,6 @@ dependencies = [ "wiremock", ] -[[package]] -name = "sync-storage-config" -version = "25.9.0" -dependencies = [ - "aes-gcm", - "anyhow", - "bcrypt", - "data-encoding", - "hex", - "reqwest", - "rusqlite", - "serde", - "serde_json", - "tokio", -] - [[package]] name = "sync-storage-server" version = "25.9.0" @@ -5898,16 +5764,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index c0bf352..e60fb30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ rust-version = "1.80" members = [ "sync-platform-api", "sync-storage-backends", - "sync-storage-config", "sync-storage-server", "rslib", "rslib/i18n", @@ -55,7 +54,6 @@ anki_proto = { path = "rslib/proto" } anki_proto_gen = { path = "rslib/proto_gen" } sync-platform-api = { path = "sync-platform-api" } sync-storage-backends = { path = "sync-storage-backends" } -sync-storage-config = { path = "sync-storage-config" } sync-storage-server = { path = "sync-storage-server" } # pinned diff --git a/TODO.md b/TODO.md index eedff28..a380300 100644 --- a/TODO.md +++ b/TODO.md @@ -33,27 +33,12 @@ After this refactor: - Updated workspace `Cargo.toml`: replaced `sync-storage-api` entry with `sync-platform-api` - Updated all import paths across the workspace (9 files) -**2. Delete `sync-storage-config` crate** - -Blocked on `anki-cloud` team landing `sync-platform-cloud` first (it takes ownership of all DB -queries, token decryption, OAuth exchange, and bcrypt auth currently in `sync-storage-config`). - -Sequencing: - -``` -anki-cloud-sync (rename + strip) → TAG -├── anki-cloud (sync-platform-cloud) ┐ parallel -└── anki-cloud-android (sync-platform-android) ┘ -↓ -anki-cloud-sync (delete sync-storage-config) → TAG -↓ -anki-cloud (bump pinned tag) -``` - -Once `anki-cloud` signals ready: -- Remove `sync-storage-config/` directory -- Remove from workspace `Cargo.toml` -- Remove from `sync-storage-server/Cargo.toml` dependencies +**2. ✅ Delete `sync-storage-config` crate** + +`anki-cloud` landed `sync-platform-cloud` (owns DB queries, token decryption, OAuth exchange, +bcrypt auth). Unblocked and completed: +- Removed `sync-storage-config/` directory +- Removed from workspace `Cargo.toml` (member + dep) **3. ✅ Strip Cloud impls from `sync-storage-server`** diff --git a/sync-storage-config/Cargo.toml b/sync-storage-config/Cargo.toml deleted file mode 100644 index b86e6a7..0000000 --- a/sync-storage-config/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "sync-storage-config" -version = "25.9.0" -edition = "2021" -license = "AGPL-3.0-or-later" - -[dependencies] -aes-gcm = { workspace = true } -bcrypt = { workspace = true } -anyhow = { workspace = true } -data-encoding = { workspace = true } -hex = { workspace = true } -reqwest = { workspace = true } -rusqlite = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } diff --git a/sync-storage-config/src/lib.rs b/sync-storage-config/src/lib.rs deleted file mode 100644 index 0f5345a..0000000 --- a/sync-storage-config/src/lib.rs +++ /dev/null @@ -1,237 +0,0 @@ -use aes_gcm::aead::Aead; -use aes_gcm::Aes256Gcm; -use aes_gcm::KeyInit; -use anyhow::{anyhow, Context, Result}; -use data_encoding::BASE64URL_NOPAD; -use rusqlite::OptionalExtension; -use serde::Deserialize; - -const IV_LENGTH: usize = 12; - -/// Decrypt a token encrypted by packages/db/src/encrypt.ts. -/// Format: base64url(IV[12] || ciphertext+tag) -fn decrypt_token(encrypted: &str, key_bytes: &[u8]) -> Result { - let combined = BASE64URL_NOPAD - .decode(encrypted.as_bytes()) - .context("base64url decode")?; - - if combined.len() < IV_LENGTH { - return Err(anyhow!("encrypted token too short")); - } - - let (iv_bytes, ciphertext) = combined.split_at(IV_LENGTH); - let key = aes_gcm::Key::::from_slice(key_bytes); - let cipher = Aes256Gcm::new(key); - let nonce = aes_gcm::Nonce::from_slice(iv_bytes); - - let plaintext = cipher - .decrypt(nonce, ciphertext) - .map_err(|_| anyhow!("AES-GCM decryption failed"))?; - - String::from_utf8(plaintext).context("UTF-8 decode") -} - -fn load_enc_key() -> Result> { - let raw = std::env::var("TOKEN_ENCRYPTION_KEY") - .context("TOKEN_ENCRYPTION_KEY env var is required")?; - let key_bytes = if raw.len() == 64 { - hex::decode(&raw).context("hex decode TOKEN_ENCRYPTION_KEY")? - } else { - data_encoding::BASE64 - .decode(raw.as_bytes()) - .context("base64 decode TOKEN_ENCRYPTION_KEY")? - }; - if key_bytes.len() != 32 { - return Err(anyhow!("TOKEN_ENCRYPTION_KEY must be 32 bytes")); - } - Ok(key_bytes) -} - -fn db_path() -> Result { - let url = std::env::var("DATABASE_URL").context("DATABASE_URL env var is required")?; - // rusqlite takes a file path; strip the "file:" prefix if present - let path = if url.starts_with("file:") { - url[5..].to_string() - } else { - url - }; - Ok(path) -} - -/// Look up user_storage_connection for the given user (matched by email). -/// Returns (provider, plaintext_refresh_token, folder_path). -pub fn fetch_storage_connection(username: &str) -> Result<(String, String, String)> { - let path = db_path()?; - - let conn = rusqlite::Connection::open_with_flags( - &path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .with_context(|| format!("open SQLite at {path}"))?; - - let (provider, encrypted_refresh, folder_path): (String, Option, String) = conn - .query_row( - "SELECT sc.provider, sc.oauth_refresh_token, sc.folder_path \ - FROM user_storage_connection sc \ - JOIN user u ON u.id = sc.user_id \ - WHERE u.email = ?1 \ - LIMIT 1", - rusqlite::params![username], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .with_context(|| format!("no storage connection found for user '{username}'"))?; - - if provider == "local" { - return Ok((provider, String::new(), folder_path)); - } - - let enc_key = load_enc_key()?; - let refresh_token = decrypt_token( - &encrypted_refresh.ok_or_else(|| anyhow!("missing oauth_refresh_token"))?, - &enc_key, - )?; - Ok((provider, refresh_token, folder_path)) -} - -// ── Sync credential auth ────────────────────────────────────────────────────── - -const DUMMY_HASH: &str = "$2b$10$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - -/// Verify email + plaintext sync password against user_sync_config.sync_password_hash (bcrypt). -/// Timing-safe: always runs a bcrypt compare, even for unknown users or null hashes. -pub fn verify_sync_credentials(email: &str, password: &str) -> Result<()> { - let path = db_path()?; - let conn = rusqlite::Connection::open_with_flags( - &path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .with_context(|| format!("open SQLite at {path}"))?; - - let hash: Option = conn - .query_row( - "SELECT usc.sync_password_hash \ - FROM user_sync_config usc \ - JOIN user u ON u.id = usc.user_id \ - WHERE u.email = ?1 LIMIT 1", - rusqlite::params![email], - |row| row.get(0), - ) - .optional() - .context("query user_sync_config")? - .flatten(); - - let hash_to_verify = hash.as_deref().unwrap_or(DUMMY_HASH); - let ok = bcrypt::verify(password, hash_to_verify).unwrap_or(false); - - if hash.is_none() || !ok { - return Err(anyhow!("invalid credentials")); - } - Ok(()) -} - -/// Upsert hkey into user_sync_state.sync_key for the user with this email. -pub fn store_sync_key(email: &str, hkey: &str) -> Result<()> { - let path = db_path()?; - let conn = rusqlite::Connection::open_with_flags( - &path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .with_context(|| format!("open SQLite at {path}"))?; - - conn.execute( - "INSERT INTO user_sync_state (id, user_id, sync_key) - SELECT lower(hex(randomblob(16))), u.id, ?2 - FROM user u WHERE u.email = ?1 - ON CONFLICT (user_id) DO UPDATE SET sync_key = excluded.sync_key", - rusqlite::params![email, hkey], - ) - .context("upsert sync key")?; - - Ok(()) -} - -/// Reverse-lookup: find the user's email from hkey stored in user_sync_state.sync_key. -pub fn lookup_user_by_sync_key(hkey: &str) -> Result { - let path = db_path()?; - let conn = rusqlite::Connection::open_with_flags( - &path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .with_context(|| format!("open SQLite at {path}"))?; - - let email: String = conn - .query_row( - "SELECT u.email FROM user u - JOIN user_sync_state s ON s.user_id = u.id - WHERE s.sync_key = ?1 LIMIT 1", - rusqlite::params![hkey], - |row| row.get(0), - ) - .context("no user found for sync key")?; - - Ok(email) -} - -#[derive(Deserialize)] -struct TokenResponse { - access_token: String, -} - -/// Exchange a Google OAuth2 refresh token for a fresh access token. -/// Uses GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars. -pub async fn exchange_refresh_token(refresh_token: &str) -> Result { - let client_id = - std::env::var("GOOGLE_CLIENT_ID").context("GOOGLE_CLIENT_ID env var is required")?; - let client_secret = std::env::var("GOOGLE_CLIENT_SECRET") - .context("GOOGLE_CLIENT_SECRET env var is required")?; - - let client = reqwest::Client::new(); - let resp = client - .post("https://oauth2.googleapis.com/token") - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token), - ("client_id", &client_id), - ("client_secret", &client_secret), - ]) - .send() - .await - .context("send token refresh request")?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow!("token refresh failed ({status}): {body}")); - } - - let token_resp: TokenResponse = resp.json().await.context("parse token response")?; - Ok(token_resp.access_token) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn decrypt_round_trip() { - use aes_gcm::aead::Aead; - use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; - - let key_bytes = [0u8; 32]; - let iv = [0u8; IV_LENGTH]; - let plaintext = b"hello"; - - let key = aes_gcm::Key::::from_slice(&key_bytes); - let cipher = Aes256Gcm::new(key); - let nonce = Nonce::from_slice(&iv); - let ciphertext = cipher.encrypt(nonce, plaintext.as_ref()).unwrap(); - - let mut combined = Vec::with_capacity(IV_LENGTH + ciphertext.len()); - combined.extend_from_slice(&iv); - combined.extend_from_slice(&ciphertext); - let encoded = BASE64URL_NOPAD.encode(&combined); - - let decrypted = decrypt_token(&encoded, &key_bytes).unwrap(); - assert_eq!(decrypted, "hello"); - } -} From 781a3abd9d58f23ca68b1d808c5f65710d2efd51 Mon Sep 17 00:00:00 2001 From: Daniel Klimuntowski Date: Sun, 26 Apr 2026 13:43:18 +0200 Subject: [PATCH 2/2] ci: update test step to include `sync-storage-server` --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 244a401..f60b9e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: cargo build --bin anki-sync-server - name: Test - run: cargo test -p sync-storage-config -p sync-storage-backends -p sync-platform-api + run: cargo test -p sync-storage-backends -p sync-platform-api -p sync-storage-server docker-smoke-test: runs-on: ubuntu-latest