Android sharing sends the opened device token, not the sealed one
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Web typecheck and unit tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 34s
CI & Build / integration (push) Successful in 1m39s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m58s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m11s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m17s
Android / Build the server image (push) Successful in 1s

Android has stored its device token sealed ("sealed:…") since 8592b83, and
core's sharing calls read the token from the store themselves. The ffi opened
it in credentials() and then threw the result away, so every Share-sheet
request went out as `Bearer sealed:…` and the server refused it.

The sharing functions now take the server address and token from the caller.
The ffi passes what credentials() opened; the desktop, which stores its token
plain, reads it through sharing::stored_link. A new ffi test serves one request
on a loopback port and checks the bearer token that arrives (#5381).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:05:10 -04:00
co-authored by Claude Opus 5.5
parent 39b1ebae96
commit be4897276c
3 changed files with 128 additions and 27 deletions
+87 -8
View File
@@ -627,11 +627,14 @@ impl Inkwell {
// //
// The Share dialog asks the server directly (#5175). Unlinked, each answers // The Share dialog asks the server directly (#5175). Unlinked, each answers
// `NotLinked`, which the dialog turns into "sharing needs a server". // `NotLinked`, which the dialog turns into "sharing needs a server".
//
// Each passes the token `credentials()` OPENED. The stored one is sealed, and the
// server refuses `sealed:…` (#5381).
/// Everyone on the instance a note can be shared with, and every group. /// Everyone on the instance a note can be shared with, and every group.
pub async fn share_directory(&self) -> Result<Directory, CoreError> { pub async fn share_directory(&self) -> Result<Directory, CoreError> {
self.credentials()?; let (url, token) = self.credentials()?;
let directory = sharing::directory(&self.db) let directory = sharing::directory(&url, &token)
.await .await
.map_err(CoreError::network)?; .map_err(CoreError::network)?;
Ok(directory.into()) Ok(directory.into())
@@ -639,8 +642,8 @@ impl Inkwell {
/// Who this note is shared with. /// Who this note is shared with.
pub async fn note_shares(&self, note_id: String) -> Result<Vec<NoteShare>, CoreError> { pub async fn note_shares(&self, note_id: String) -> Result<Vec<NoteShare>, CoreError> {
self.credentials()?; let (url, token) = self.credentials()?;
let shares = sharing::list(&self.db, &note_id) let shares = sharing::list(&self.db, &url, &token, &note_id)
.await .await
.map_err(CoreError::network)?; .map_err(CoreError::network)?;
Ok(shares.into_iter().map(NoteShare::from).collect()) Ok(shares.into_iter().map(NoteShare::from).collect())
@@ -653,8 +656,15 @@ impl Inkwell {
target: ShareTarget, target: ShareTarget,
permission: String, permission: String,
) -> Result<Vec<NoteShare>, CoreError> { ) -> Result<Vec<NoteShare>, CoreError> {
self.credentials()?; let (url, token) = self.credentials()?;
let shares = sharing::share(&self.db, &note_id, &target.into(), &permission) let shares = sharing::share(
&self.db,
&url,
&token,
&note_id,
&target.into(),
&permission,
)
.await .await
.map_err(CoreError::network)?; .map_err(CoreError::network)?;
Ok(shares.into_iter().map(NoteShare::from).collect()) Ok(shares.into_iter().map(NoteShare::from).collect())
@@ -665,8 +675,8 @@ impl Inkwell {
note_id: String, note_id: String,
share_id: String, share_id: String,
) -> Result<Vec<NoteShare>, CoreError> { ) -> Result<Vec<NoteShare>, CoreError> {
self.credentials()?; let (url, token) = self.credentials()?;
let shares = sharing::unshare(&self.db, &note_id, &share_id) let shares = sharing::unshare(&self.db, &url, &token, &note_id, &share_id)
.await .await
.map_err(CoreError::network)?; .map_err(CoreError::network)?;
Ok(shares.into_iter().map(NoteShare::from).collect()) Ok(shares.into_iter().map(NoteShare::from).collect())
@@ -1188,6 +1198,75 @@ mod tests {
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
/// A seal that works, as Android's Keystore one does: reversal is enough to tell
/// a sealed token from an opened one.
struct Reverse;
impl TokenSeal for Reverse {
fn seal_token(&self, token: String) -> Option<String> {
Some(token.chars().rev().collect())
}
fn open_token(&self, sealed: String) -> Option<String> {
Some(sealed.chars().rev().collect())
}
}
/// Serve one request on a loopback port with `body` as JSON, and hand back the
/// request as it arrived, headers included.
fn one_reply(body: &'static str) -> (String, std::thread::JoinHandle<String>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let url = format!("http://{}", listener.local_addr().expect("addr"));
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let mut request = Vec::new();
let mut buf = [0u8; 4096];
while !request.windows(4).any(|w| w == b"\r\n\r\n") {
let n = stream.read(&mut buf).expect("read");
if n == 0 {
break;
}
request.extend_from_slice(&buf[..n]);
}
let len = body.len();
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {len}\r\n"
);
write!(stream, "{head}Connection: close\r\n\r\n{body}").expect("reply");
String::from_utf8_lossy(&request).into_owned()
});
(url, handle)
}
/// #5381: the token is stored sealed, so a share call has to send the OPENED one.
/// It used to read the store itself and send `sealed:…`, which the server refused.
#[test]
fn sharing_sends_the_opened_token_not_the_stored_one() {
let (url, server) = one_reply(r#"{"members":[],"groups":[]}"#);
let dir = scratch_dir();
let app = Inkwell::new(dir.clone(), Arc::new(Reverse)).expect("open");
app.store_link(&url, "tok-1", None).expect("link");
let stored = {
let conn = app.db.conn().expect("conn");
state::read(&conn).expect("read").device_token
};
assert_eq!(stored.as_deref(), Some("sealed:1-kot"), "stored sealed");
tokio::runtime::Runtime::new()
.expect("runtime")
.block_on(app.share_directory())
.expect("directory");
let request = server.join().expect("server").to_ascii_lowercase();
assert!(
request.contains("authorization: bearer tok-1\r\n"),
"sent the wrong token:\n{request}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A crude RFC3339 sanity check that doesn't pull a date crate into this /// A crude RFC3339 sanity check that doesn't pull a date crate into this
/// crate's dev-dependencies to assert one field is well-formed. /// crate's dev-dependencies to assert one field is well-formed.
fn chrono_free_parse(raw: &str) -> usize { fn chrono_free_parse(raw: &str) -> usize {
+31 -13
View File
@@ -5,6 +5,11 @@
//! device token. The one thing kept locally is the note's `shared` flag, set from //! device token. The one thing kept locally is the note's `shared` flag, set from
//! the server's answer so the card's chip changes at once rather than at the next //! the server's answer so the card's chip changes at once rather than at the next
//! sync (which brings the same value). //! 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).
use rusqlite::params; use rusqlite::params;
@@ -17,7 +22,11 @@ use crate::local::Db;
pub const NEEDS_SERVER: &str = pub const NEEDS_SERVER: &str =
"Sharing is between people on a server. Link this device to one in Sync to share notes."; "Sharing is between people on a server. Link this device to one in Sync to share notes.";
fn link(db: &Db) -> Result<(String, String), String> { /// 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.
pub fn stored_link(db: &Db) -> Result<(String, String), String> {
let conn = db.conn()?; let conn = db.conn()?;
let link = state::read(&conn).map_err(|e| e.to_string())?; let link = state::read(&conn).map_err(|e| e.to_string())?;
match (link.server_url, link.device_token) { match (link.server_url, link.device_token) {
@@ -38,33 +47,42 @@ fn mark_shared(db: &Db, note_id: &str, shares: &[NoteShare]) -> Result<(), Strin
Ok(()) Ok(())
} }
pub async fn directory(db: &Db) -> Result<Directory, String> { pub async fn directory(url: &str, token: &str) -> Result<Directory, String> {
let (url, token) = link(db)?; client::directory(url, token).await
client::directory(&url, &token).await
} }
pub async fn list(db: &Db, note_id: &str) -> Result<Vec<NoteShare>, String> { pub async fn list(
let (url, token) = link(db)?; db: &Db,
let shares = client::list_shares(&url, &token, note_id).await?; url: &str,
token: &str,
note_id: &str,
) -> Result<Vec<NoteShare>, String> {
let shares = client::list_shares(url, token, note_id).await?;
mark_shared(db, note_id, &shares)?; mark_shared(db, note_id, &shares)?;
Ok(shares) Ok(shares)
} }
pub async fn share( pub async fn share(
db: &Db, db: &Db,
url: &str,
token: &str,
note_id: &str, note_id: &str,
target: &ShareTarget, target: &ShareTarget,
permission: &str, permission: &str,
) -> Result<Vec<NoteShare>, String> { ) -> Result<Vec<NoteShare>, String> {
let (url, token) = link(db)?; let shares = client::share_note(url, token, note_id, target, permission).await?;
let shares = client::share_note(&url, &token, note_id, target, permission).await?;
mark_shared(db, note_id, &shares)?; mark_shared(db, note_id, &shares)?;
Ok(shares) Ok(shares)
} }
pub async fn unshare(db: &Db, note_id: &str, share_id: &str) -> Result<Vec<NoteShare>, String> { pub async fn unshare(
let (url, token) = link(db)?; db: &Db,
let shares = client::unshare_note(&url, &token, note_id, share_id).await?; url: &str,
token: &str,
note_id: &str,
share_id: &str,
) -> Result<Vec<NoteShare>, String> {
let shares = client::unshare_note(url, token, note_id, share_id).await?;
mark_shared(db, note_id, &shares)?; mark_shared(db, note_id, &shares)?;
Ok(shares) Ok(shares)
} }
@@ -85,7 +103,7 @@ mod tests {
#[test] #[test]
fn an_unlinked_device_explains_that_sharing_needs_a_server() { fn an_unlinked_device_explains_that_sharing_needs_a_server() {
assert_eq!(link(&db()).unwrap_err(), NEEDS_SERVER); assert_eq!(stored_link(&db()).unwrap_err(), NEEDS_SERVER);
} }
#[test] #[test]
+8 -4
View File
@@ -194,12 +194,14 @@ pub fn sync_has_pending(db: State<'_, Db>) -> Result<bool, String> {
#[tauri::command] #[tauri::command]
pub async fn shares_directory(db: State<'_, Db>) -> Result<Directory, String> { pub async fn shares_directory(db: State<'_, Db>) -> Result<Directory, String> {
sharing::directory(&db).await let (url, token) = sharing::stored_link(&db)?;
sharing::directory(&url, &token).await
} }
#[tauri::command] #[tauri::command]
pub async fn shares_list(note_id: String, db: State<'_, Db>) -> Result<Vec<NoteShare>, String> { pub async fn shares_list(note_id: String, db: State<'_, Db>) -> Result<Vec<NoteShare>, String> {
sharing::list(&db, &note_id).await let (url, token) = sharing::stored_link(&db)?;
sharing::list(&db, &url, &token, &note_id).await
} }
/// Share with a person (`user_id`) or a group (`group_id`, #5177): exactly one. /// Share with a person (`user_id`) or a group (`group_id`, #5177): exactly one.
@@ -216,7 +218,8 @@ pub async fn shares_share(
(None, Some(id)) => ShareTarget::Group(id), (None, Some(id)) => ShareTarget::Group(id),
_ => return Err("Choose someone or a group to share with.".to_string()), _ => return Err("Choose someone or a group to share with.".to_string()),
}; };
sharing::share(&db, &note_id, &target, &permission).await let (url, token) = sharing::stored_link(&db)?;
sharing::share(&db, &url, &token, &note_id, &target, &permission).await
} }
#[tauri::command] #[tauri::command]
@@ -225,5 +228,6 @@ pub async fn shares_unshare(
share_id: String, share_id: String,
db: State<'_, Db>, db: State<'_, Db>,
) -> Result<Vec<NoteShare>, String> { ) -> Result<Vec<NoteShare>, String> {
sharing::unshare(&db, &note_id, &share_id).await let (url, token) = sharing::stored_link(&db)?;
sharing::unshare(&db, &url, &token, &note_id, &share_id).await
} }