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
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:
+89
-10
@@ -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, ¬e_id)
|
let shares = sharing::list(&self.db, &url, &token, ¬e_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,10 +656,17 @@ 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, ¬e_id, &target.into(), &permission)
|
let shares = sharing::share(
|
||||||
.await
|
&self.db,
|
||||||
.map_err(CoreError::network)?;
|
&url,
|
||||||
|
&token,
|
||||||
|
¬e_id,
|
||||||
|
&target.into(),
|
||||||
|
&permission,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.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, ¬e_id, &share_id)
|
let shares = sharing::unshare(&self.db, &url, &token, ¬e_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
@@ -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]
|
||||||
|
|||||||
@@ -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, ¬e_id).await
|
let (url, token) = sharing::stored_link(&db)?;
|
||||||
|
sharing::list(&db, &url, &token, ¬e_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, ¬e_id, &target, &permission).await
|
let (url, token) = sharing::stored_link(&db)?;
|
||||||
|
sharing::share(&db, &url, &token, ¬e_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, ¬e_id, &share_id).await
|
let (url, token) = sharing::stored_link(&db)?;
|
||||||
|
sharing::unshare(&db, &url, &token, ¬e_id, &share_id).await
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user