From 70274347affd22db4356fec4ac8895c46264ede6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 14:14:18 -0400 Subject: [PATCH] One read of a device's link: state::credentials, with the client's seal Five places read the server address and token straight from sync_state: sharing, autosync, sync_unlink, update's download token, and the ffi. Reading it raw is how Android came to send its sealed token to the share routes (#5381). state::credentials(conn, seal) now holds that read. With a seal it opens the token (open_token), and without one (the desktop keeps it plain) it returns it as stored. Every site calls it. DRY pass #2, batch 1, F1 (#5372). Co-Authored-By: Claude Opus 5.5 --- android/ffi/src/lib.rs | 10 ++-------- core/src/sync/sharing.rs | 21 ++++++++------------- core/src/sync/state.rs | 21 +++++++++++++++++++++ desktop/src-tauri/src/autosync.rs | 3 +-- desktop/src-tauri/src/commands/sync.rs | 3 +-- desktop/src-tauri/src/update.rs | 11 ++++------- 6 files changed, 37 insertions(+), 32 deletions(-) diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 775f144..9576f6f 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -753,14 +753,8 @@ impl Inkwell { /// it (`state::open_token`), so the app asks to sign in again. fn credentials(&self) -> Result<(String, String), CoreError> { let conn = self.db.conn().map_err(CoreError::store)?; - let current = state::read(&conn).map_err(CoreError::store)?; - let (Some(url), Some(stored)) = (current.server_url, current.device_token) else { - return Err(CoreError::NotLinked); - }; - match state::open_token(&conn, &stored, &self.seal).map_err(CoreError::store)? { - Some(token) => Ok((url, token)), - None => Err(CoreError::NotLinked), - } + let link = state::credentials(&conn, Some(&self.seal)).map_err(CoreError::store)?; + link.ok_or(CoreError::NotLinked) } /// Persist a fresh link, adopting the server's retention window at the same time diff --git a/core/src/sync/sharing.rs b/core/src/sync/sharing.rs index 38df0f9..3ab1acd 100644 --- a/core/src/sync/sharing.rs +++ b/core/src/sync/sharing.rs @@ -6,10 +6,10 @@ //! the server's answer so the card's chip changes at once rather than at the next //! sync (which brings the same value). //! -//! The server address and token come from the CALLER, never from the store here. -//! A stored token may be sealed (Android keeps it under a Keystore key, see -//! `state::TokenSeal`), and only the caller holds the seal that opens it. Reading it -//! here sent `sealed:…` as the bearer token, and every share call was refused (#5381). +//! The server address and token come from the CALLER, read with +//! `state::credentials` and the caller's seal. A stored token may be sealed +//! (Android keeps it under a Keystore key), and reading it raw here sent `sealed:…` +//! as the bearer token, so every share call was refused (#5381). use rusqlite::params; @@ -22,17 +22,12 @@ use crate::local::Db; pub const NEEDS_SERVER: &str = "Sharing is between people on a server. Link this device to one in Sync to share notes."; -/// The server address and token AS STORED, or [`NEEDS_SERVER`]. -/// -/// Only for a client that stores its token plain, as the desktop does. A client -/// with a seal opens the token itself (`state::open_token`) and passes that. +/// The link to share over, or [`NEEDS_SERVER`]. For a client that keeps its token +/// plain, as the desktop does; one with a seal reads `state::credentials` itself. pub fn stored_link(db: &Db) -> Result<(String, String), String> { let conn = db.conn()?; - let link = state::read(&conn).map_err(|e| e.to_string())?; - match (link.server_url, link.device_token) { - (Some(url), Some(token)) => Ok((url, token)), - _ => Err(NEEDS_SERVER.to_string()), - } + let link = state::credentials(&conn, None).map_err(|e| e.to_string())?; + link.ok_or_else(|| NEEDS_SERVER.to_string()) } /// Set the local note's `shared` flag from the server's list of its shares. Not a diff --git a/core/src/sync/state.rs b/core/src/sync/state.rs index 05e7bc0..5db0a71 100644 --- a/core/src/sync/state.rs +++ b/core/src/sync/state.rs @@ -175,6 +175,27 @@ pub fn open_token( Ok(Some(stored.to_string())) } +/// The server address and the token to send it, or None when this device isn't +/// linked. The ONE read of a link for a call to the server: every client goes +/// through here, so none can send a token as stored when it is sealed (#5381). +/// +/// With a `seal`, the stored token is opened ([`open_token`]); a client without one +/// (the desktop keeps its token plain) passes None and gets it as stored. +pub fn credentials( + conn: &Connection, + seal: Option<&dyn TokenSeal>, +) -> rusqlite::Result> { + let link = read(conn)?; + let (Some(url), Some(stored)) = (link.server_url, link.device_token) else { + return Ok(None); + }; + let token = match seal { + Some(seal) => open_token(conn, &stored, seal)?, + None => Some(stored), + }; + Ok(token.map(|token| (url, token))) +} + /// Replace the stored token alone, leaving the server and cursor as they are. fn store_token(conn: &Connection, token: Option<&str>) -> rusqlite::Result<()> { conn.execute( diff --git a/desktop/src-tauri/src/autosync.rs b/desktop/src-tauri/src/autosync.rs index 64a6c11..1ce3d63 100644 --- a/desktop/src-tauri/src/autosync.rs +++ b/desktop/src-tauri/src/autosync.rs @@ -176,8 +176,7 @@ fn backoff(failures: u32) -> Duration { /// The server URL and token, or None when this device isn't linked. fn credentials(db: &Db) -> Result, String> { let conn = db.conn()?; - let current = state::read(&conn).map_err(|e| e.to_string())?; - Ok(current.server_url.zip(current.device_token)) + state::credentials(&conn, None).map_err(|e| e.to_string()) } /// What is waiting to be sent, as [`push::pending_fingerprint`] sees it. A store diff --git a/desktop/src-tauri/src/commands/sync.rs b/desktop/src-tauri/src/commands/sync.rs index 7663934..5968756 100644 --- a/desktop/src-tauri/src/commands/sync.rs +++ b/desktop/src-tauri/src/commands/sync.rs @@ -136,8 +136,7 @@ pub async fn sync_unlink(db: State<'_, Db>) -> Result { // the UI. let link = { let conn = db.conn()?; - let current = state::read(&conn).map_err(|e| e.to_string())?; - current.server_url.zip(current.device_token) + state::credentials(&conn, None).map_err(|e| e.to_string())? }; let revoked = match &link { Some((base_url, token)) => client::revoke_self(base_url, token).await, diff --git a/desktop/src-tauri/src/update.rs b/desktop/src-tauri/src/update.rs index 4b6af8a..9274f08 100644 --- a/desktop/src-tauri/src/update.rs +++ b/desktop/src-tauri/src/update.rs @@ -343,13 +343,10 @@ fn read_source(db: &Db) -> Result { /// the link is the one credential this app already holds for it. fn token_for(db: &Db, base: &str) -> Result, String> { let conn = db.conn()?; - let link = state::read(&conn).map_err(|e| e.to_string())?; - let linked = link.server_url.as_deref().and_then(normalize_server); - if linked.as_deref() == Some(base) { - Ok(link.device_token) - } else { - Ok(None) - } + let Some((url, token)) = state::credentials(&conn, None).map_err(|e| e.to_string())? else { + return Ok(None); + }; + Ok((normalize_server(&url).as_deref() == Some(base)).then_some(token)) } /// An updater pointed at `source`, carrying `token` when there is one. The plugin