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
|
||||
// `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.
|
||||
pub async fn share_directory(&self) -> Result<Directory, CoreError> {
|
||||
self.credentials()?;
|
||||
let directory = sharing::directory(&self.db)
|
||||
let (url, token) = self.credentials()?;
|
||||
let directory = sharing::directory(&url, &token)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(directory.into())
|
||||
@@ -639,8 +642,8 @@ impl Inkwell {
|
||||
|
||||
/// Who this note is shared with.
|
||||
pub async fn note_shares(&self, note_id: String) -> Result<Vec<NoteShare>, CoreError> {
|
||||
self.credentials()?;
|
||||
let shares = sharing::list(&self.db, ¬e_id)
|
||||
let (url, token) = self.credentials()?;
|
||||
let shares = sharing::list(&self.db, &url, &token, ¬e_id)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
@@ -653,10 +656,17 @@ impl Inkwell {
|
||||
target: ShareTarget,
|
||||
permission: String,
|
||||
) -> Result<Vec<NoteShare>, CoreError> {
|
||||
self.credentials()?;
|
||||
let shares = sharing::share(&self.db, ¬e_id, &target.into(), &permission)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
let (url, token) = self.credentials()?;
|
||||
let shares = sharing::share(
|
||||
&self.db,
|
||||
&url,
|
||||
&token,
|
||||
¬e_id,
|
||||
&target.into(),
|
||||
&permission,
|
||||
)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
}
|
||||
|
||||
@@ -665,8 +675,8 @@ impl Inkwell {
|
||||
note_id: String,
|
||||
share_id: String,
|
||||
) -> Result<Vec<NoteShare>, CoreError> {
|
||||
self.credentials()?;
|
||||
let shares = sharing::unshare(&self.db, ¬e_id, &share_id)
|
||||
let (url, token) = self.credentials()?;
|
||||
let shares = sharing::unshare(&self.db, &url, &token, ¬e_id, &share_id)
|
||||
.await
|
||||
.map_err(CoreError::network)?;
|
||||
Ok(shares.into_iter().map(NoteShare::from).collect())
|
||||
@@ -1188,6 +1198,75 @@ mod tests {
|
||||
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
|
||||
/// crate's dev-dependencies to assert one field is well-formed.
|
||||
fn chrono_free_parse(raw: &str) -> usize {
|
||||
|
||||
Reference in New Issue
Block a user