Compare commits

...
Author SHA1 Message Date
Lotus CI 151199459f chore: bump cinny submodule to d1993f20 (nightly catch-up)
Build Lotus Chat Desktop / build-arch (push) Blocked by required conditions
Build Lotus Chat Desktop / update-manifest (push) Blocked by required conditions
Build Lotus Chat Desktop / prepare (push) Successful in 2s
Build Lotus Chat Desktop / build-linux (push) In progress
Build Lotus Chat Desktop / build-windows (push) In progress
2026-10-01 04:01:02 +00:00
jared ec643c677a Merge pull request 'Login tokens in the OS keychain: secure_session commands (cinny #105, step 1)' (#28) from desktop-keychain into main
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m18s
Build Lotus Chat Desktop / build-arch (push) Successful in 17s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m3s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
Merge pull request #28: login tokens in the OS keychain (cinny #105, step 1)
2026-09-30 20:19:01 -04:00
Lotus CI 28dee7bea0 Merge remote-tracking branch 'origin/main' into desktop-keychain
# Conflicts:
#	src-tauri/src/lib.rs
2026-09-30 20:18:53 -04:00
jared 03e3937e21 Merge pull request 'Call page on its own origin, http://127.0.0.1:44548 (cinny #43)' (#27) from desktop-call-origin into main
Build Lotus Chat Desktop / prepare (push) Canceled after 0s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
Merge pull request #27: call page on its own origin, http://127.0.0.1:44548 (cinny #43)
2026-09-30 20:17:24 -04:00
jared 97ccc97a70 Merge pull request 'WebView permissions only for the app's own origin (#22)' (#26) from webview-permission-origin into main
Build Lotus Chat Desktop / prepare (push) Canceled after 0s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
Merge pull request #26: WebView permissions only for the app's own origin (#22)
2026-09-30 20:17:19 -04:00
Lotus CI 86947dba50 chore: bump cinny submodule to 8e30c73e
Build Lotus Chat Desktop / prepare (push) Canceled after 0s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
2026-10-01 00:11:54 +00:00
jared 4e98503c49 Merge pull request 'Updater: Linux package installs update through their package manager' (#32) from fix-linux-pkg-update into main
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m33s
Build Lotus Chat Desktop / build-arch (push) Successful in 10s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m18s
Build Lotus Chat Desktop / update-manifest (push) Successful in 2s
Merge pull request #32: Linux package installs update via their package manager
2026-09-30 19:00:55 -04:00
Lotus CIandClaude Opus 5.5 261d7852cb fix(updater): package installs update through their package manager
On CachyOS the in-app update failed with "Permission denied (os error 13)
at path /usr/bin/tauri_current_app…": the app was installed from the
Arch package, and Tauri's Linux updater can only replace an AppImage — for
anything else it tries to write next to the binary in /usr/bin.

- update_install_kind command: "in-app" on Windows and for an AppImage
  ($APPIMAGE set); on Linux package installs "pacman" (ID/ID_LIKE arch:
  Arch, CachyOS, Manjaro, EndeavourOS…), "deb" (debian/ubuntu and
  derivatives) or "manual". The web UI shows the matching update command.
- install_update refuses up front on a package install
  ("install: package-managed (…)") instead of downloading the whole
  update and failing at the last step.

Tests: CachyOS/Arch → pacman; Ubuntu/Debian/Mint → deb; Fedora/unknown →
manual; AppImage and Windows → in-app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 18:46:11 -04:00
jared 06fe43c3cd Merge pull request 'Windows smoke: foreign-page mic check on a local origin (#19)' (#31) from smoke-foreign-origin into main
Build Lotus Chat Desktop / prepare (push) Successful in 2s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m28s
Build Lotus Chat Desktop / build-arch (push) Successful in 14s
Build Lotus Chat Desktop / build-windows (push) Successful in 26m6s
Build Lotus Chat Desktop / update-manifest (push) Successful in 6s
Merge pull request #31: smoke foreign-page check (#19)
2026-09-30 12:46:31 -04:00
Lotus CIandClaude Opus 5.5 bcbc5ecdfb windows smoke: foreign-page mic check on a local origin, navigation verified
The example.com check could pass vacuously if the runner can't reach the
internet (goto failed silently, the mic request then came from the app's
own page). Serve a page on http://localhost:9333 instead, confirm the
navigation happened, and fail on builds that should refuse it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 12:46:30 -04:00
Lotus CI bd32048461 Merge remote-tracking branch 'origin/main' into desktop-keychain 2026-09-30 11:00:19 -04:00
Lotus CI 63a8ebe103 Merge remote-tracking branch 'origin/webview-permission-origin' into desktop-call-origin
# Conflicts:
#	config.json
2026-09-30 11:00:17 -04:00
Lotus CIandClaude Opus 5.5 032b6e04e7 feat: secure_session commands, login tokens in the OS keychain (cinny #105, step 1)
Commands for the web client to keep a copy of the login tokens in the OS
keychain: secure_session_supported / _set / _get / _clear.

- Windows: Credential Manager via the keyring crate (3.6, windows-native),
  entry "session" in service "Lotus Chat". Only the secrets are stored
  (userId, deviceId, accessToken, refreshToken); the serialized value is
  capped at 1200 chars (Windows' limit is 2560 bytes).
- Other platforms: supported = false and the other commands answer "not
  supported on this platform" (Linux Secret Service can prompt to unlock a
  wallet at startup; that needs its own testing). No new Linux dependency:
  without a platform feature the crate only has its mock store.
- Keychain calls run on the blocking pool, off the main thread.

Step 1 is a mirror only (the web client still reads its session from
localStorage); see the cinny PR.

Tests: round trip + clear, clearing an empty keychain, incomplete and
oversized sessions rejected with nothing written, the JSON shape the web
client sends, a realistic OIDC session fits (keyring's mock store). Linux
release build: commands answer as designed and login is unaffected.
Windows: type-checked only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:25:01 -04:00
Lotus CIandClaude Opus 5.5 5c3ac68328 feat: call page on its own origin, http://127.0.0.1:44548 (cinny #43)
The bundled Element Call page ran on the app's own origin
(http://localhost:44548), so the call frame could read the app's storage
(login token) and DOM. Serve it from http://127.0.0.1:44548 instead: the
same local server and bundle, a different origin.

- The local server binds 127.0.0.1 explicitly. The app is still loaded as
  http://localhost:44548 (its storage stays where it is; the engines try
  127.0.0.1 for `localhost`). Binding the name `localhost` could pick ::1
  only (Windows lists it first), and then 127.0.0.1 wouldn't answer.
- config.json: desktopCallOrigin = http://127.0.0.1:44548. cinny loads the
  call page from there only when this is set (cinny #43 PR).
- CSP frame-src allows http://127.0.0.1:44548.
- Permissions (on top of #22): the call page's origin gets microphone/
  camera/screen only; nothing else.
- The call page gets no IPC: the capability only matches
  http://localhost:44548.

Tested (Linux release build): the server listens on 127.0.0.1:44548 and
the app loads as http://localhost:44548; the call page loads from
127.0.0.1 inside the app under its CSP; from that frame parent.localStorage
and parent.document are SecurityError, while a same-origin frame (the old
setup) reads the app's storage. The call itself was tested in a simulated
desktop (Chromium, the WebView2 engine) against a local Synapse + LiveKit;
see the cinny PR. Rust tests 17 passed; Windows code type-checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-29 00:09:41 -04:00
9 changed files with 389 additions and 23 deletions
+1 -1
Submodule cinny updated: 747400ea25...d1993f2084
+1
View File
@@ -25,6 +25,7 @@
}, },
"gifApiKey": "", "gifApiKey": "",
"webAppUrl": "https://chat.lotusguild.org", "webAppUrl": "https://chat.lotusguild.org",
"desktopCallOrigin": "http://127.0.0.1:44548",
"statusPages": { "statusPages": {
"matrix.lotusguild.org": { "matrix.lotusguild.org": {
"url": "https://isitup.lotusguild.org", "url": "https://isitup.lotusguild.org",
+32 -14
View File
@@ -11,6 +11,7 @@
// instead of failing, so the same script runs on main and on PR branches. // instead of failing, so the same script runs on main and on PR branches.
import { writeFileSync, mkdirSync } from 'node:fs'; import { writeFileSync, mkdirSync } from 'node:fs';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { createServer } from 'node:http';
import { chromium } from 'playwright-core'; import { chromium } from 'playwright-core';
const OUT = process.argv[2] || 'smoke-out'; const OUT = process.argv[2] || 'smoke-out';
@@ -150,21 +151,38 @@ else if (!kc.supported) record('keychain round trip', 'fail', 'secure_session_su
else record('keychain round trip', kc.roundTrip && kc.restored ? 'pass' : 'fail', JSON.stringify(kc)); else record('keychain round trip', kc.roundTrip && kc.restored ? 'pass' : 'fail', JSON.stringify(kc));
// 7. A foreign page loaded in the window must not get the microphone (#22). // 7. A foreign page loaded in the window must not get the microphone (#22).
await page.goto('https://example.com/').catch(() => undefined); // Served locally on another port (a different origin, still a secure context),
const foreignMic = await page.evaluate(async () => { // so the check doesn't depend on the runner reaching the internet.
try { const foreign = createServer((_, res) => {
const s = await navigator.mediaDevices.getUserMedia({ audio: true }); res.writeHead(200, { 'Content-Type': 'text/html' });
s.getTracks().forEach((t) => t.stop()); res.end('<!doctype html><title>foreign</title>foreign page');
return 'ok';
} catch (e) {
return e.name;
}
}); });
record( await new Promise((r) => foreign.listen(9333, '127.0.0.1', r));
'microphone refused to a foreign page', const FOREIGN = 'http://localhost:9333/';
foreignMic === 'NotAllowedError' ? 'pass' : 'info', await page.goto(FOREIGN).catch(() => undefined);
`${foreignMic}${foreignMic === 'ok' ? ' (build without the #22 origin check)' : ''}`, if (!page.url().startsWith(FOREIGN)) {
); record('microphone refused to a foreign page', 'fail', `navigation did not happen (at ${page.url()})`);
} else {
const foreignMic = await page.evaluate(async () => {
try {
const s = await navigator.mediaDevices.getUserMedia({ audio: true });
s.getTracks().forEach((t) => t.stop());
return 'ok';
} catch (e) {
return e.name;
}
});
const guarded = cfg.desktopCallOrigin !== undefined; // builds with #22 also carry #43
let status = 'info';
if (foreignMic === 'NotAllowedError') status = 'pass';
else if (guarded) status = 'fail';
record(
'microphone refused to a foreign page',
status,
`${foreignMic} at ${page.url()}${status === 'info' ? ' (build without the #22 origin check)' : ''}`,
);
}
foreign.close();
await page.goto(APP).catch(() => undefined); await page.goto(APP).catch(() => undefined);
await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined); await page.screenshot({ path: `${OUT}/02-end.png` }).catch(() => undefined);
+13
View File
@@ -498,6 +498,7 @@ checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
name = "cinny" name = "cinny"
version = "4.12.2" version = "4.12.2"
dependencies = [ dependencies = [
"keyring",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
@@ -2090,6 +2091,18 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"log",
"windows-sys 0.60.2",
"zeroize",
]
[[package]] [[package]]
name = "kuchikiki" name = "kuchikiki"
version = "0.8.8-speedreader" version = "0.8.8-speedreader"
+5
View File
@@ -38,6 +38,10 @@ tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2" # P6-1 launch-on-login tauri-plugin-autostart = "2" # P6-1 launch-on-login
# Update retry backoff (already in the tree via tauri; adds only the timer). # Update retry backoff (already in the tree via tauri; adds only the timer).
tokio = { version = "1", features = ["time"] } tokio = { version = "1", features = ["time"] }
# cinny #105: login tokens in the OS keychain. Without a platform feature
# the crate only has its in-memory mock store (used by the tests); Windows
# turns on Credential Manager below.
keyring = "3.6"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
# P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher # P6-1 desktop parity: screensaver inhibit (no-sleep in calls) + Unity launcher
@@ -50,6 +54,7 @@ zbus = "5"
webkit2gtk = "2.0" webkit2gtk = "2.0"
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6", features = ["windows-native"] }
webview2-com = "0.38" webview2-com = "0.38"
window-vibrancy = "0.6" window-vibrancy = "0.6"
windows = { version = "0.61", features = [ windows = { version = "0.61", features = [
+97 -1
View File
@@ -13,6 +13,7 @@ use tauri_plugin_opener::OpenerExt;
pub mod gpu_workarounds; pub mod gpu_workarounds;
mod native; mod native;
mod secure_session;
#[cfg(any(target_os = "linux", target_os = "windows", test))] #[cfg(any(target_os = "linux", target_os = "windows", test))]
mod webview_permissions; mod webview_permissions;
@@ -191,6 +192,47 @@ mod update_retry {
} }
} }
/// How this copy of the app gets updated.
///
/// Tauri's updater can replace the Windows install and a Linux AppImage, but
/// not a copy installed by a package manager: it tries to write next to the
/// binary in /usr/bin and fails with "Permission denied (os error 13)"
/// (reported on CachyOS). Those installs update through their package
/// manager instead; the web UI shows the right command.
pub(crate) fn install_kind(linux: bool, appimage: bool, os_release: &str) -> &'static str {
if !linux || appimage {
return "in-app";
}
let field = |key: &str| {
os_release
.lines()
.find_map(|l| l.strip_prefix(key).and_then(|v| v.strip_prefix('=')))
.map(|v| v.trim().trim_matches('"').to_ascii_lowercase())
.unwrap_or_default()
};
let ids = format!("{} {}", field("ID"), field("ID_LIKE"));
let has = |name: &str| ids.split_whitespace().any(|w| w == name);
if has("arch") {
"pacman"
} else if has("debian") || has("ubuntu") {
"deb"
} else {
"manual"
}
}
#[tauri::command]
fn update_install_kind() -> &'static str {
let linux = cfg!(target_os = "linux");
let appimage = std::env::var_os("APPIMAGE").is_some();
let os_release = if linux {
std::fs::read_to_string("/etc/os-release").unwrap_or_default()
} else {
String::new()
};
install_kind(linux, appimage, &os_release)
}
#[tauri::command] #[tauri::command]
async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> { async fn check_for_update(app: tauri::AppHandle) -> Result<UpdateInfo, String> {
#[cfg(not(any(target_os = "android", target_os = "ios")))] #[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -213,6 +255,15 @@ async fn install_update(app: tauri::AppHandle) -> Result<(), String> {
{ {
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
// A package-manager install can't be replaced in place (see
// install_kind); refuse before downloading anything.
let kind = update_install_kind();
if kind != "in-app" {
return Err(format!(
"install: package-managed ({kind}): update Lotus Chat with your package manager"
));
}
let emit = |detail: serde_json::Value| { let emit = |detail: serde_json::Value| {
native::emit_to_web(&app, "lotus-update-progress", &detail.to_string()); native::emit_to_web(&app, "lotus-update-progress", &detail.to_string());
}; };
@@ -941,6 +992,7 @@ pub fn run() {
send_notification, send_notification,
check_for_update, check_for_update,
install_update, install_update,
update_install_kind,
native::power::set_call_active, native::power::set_call_active,
native::jumplist::set_jump_list, native::jumplist::set_jump_list,
native::thumbbar::set_thumbbar, native::thumbbar::set_thumbbar,
@@ -954,8 +1006,22 @@ pub fn run() {
native::focus_assist::get_focus_assist, native::focus_assist::get_focus_assist,
native::hotkeys::global_hotkeys_supported, native::hotkeys::global_hotkeys_supported,
native::hotkeys::set_global_hotkeys, native::hotkeys::set_global_hotkeys,
secure_session::secure_session_supported,
secure_session::secure_session_set,
secure_session::secure_session_get,
secure_session::secure_session_clear,
]) ])
.plugin(tauri_plugin_localhost::Builder::new(port).build()) // Bound to 127.0.0.1 explicitly (cinny #43). The app is still loaded as
// http://localhost:{port} (its storage lives under that origin, and the
// engines try 127.0.0.1 for `localhost`); the bundled call page is
// loaded as http://127.0.0.1:{port}, a separate origin on the same
// server. Binding the name `localhost` could pick ::1 only (Windows
// lists it first), and then the call page wouldn't load.
.plugin(
tauri_plugin_localhost::Builder::new(port)
.host("127.0.0.1")
.build(),
)
.plugin( .plugin(
// DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome) // DECORATIONS is excluded: the custom-chrome toggle (set_custom_chrome)
// owns the decorated flag. Letting window-state restore a saved // owns the decorated flag. Letting window-state restore a saved
@@ -1399,6 +1465,36 @@ mod webview2_args_tests {
} }
} }
#[cfg(test)]
mod install_kind_tests {
use super::install_kind;
#[test]
fn package_installs_update_through_their_package_manager() {
let cachy = "NAME=\"CachyOS Linux\"\nID=cachyos\nID_LIKE=arch\n";
let arch = "NAME=\"Arch Linux\"\nID=arch\n";
let ubuntu = "NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n";
let debian = "ID=debian\n";
let mint = "ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n";
let fedora = "ID=fedora\n";
assert_eq!(install_kind(true, false, cachy), "pacman");
assert_eq!(install_kind(true, false, arch), "pacman");
assert_eq!(install_kind(true, false, ubuntu), "deb");
assert_eq!(install_kind(true, false, debian), "deb");
assert_eq!(install_kind(true, false, mint), "deb");
assert_eq!(install_kind(true, false, fedora), "manual");
assert_eq!(install_kind(true, false, ""), "manual");
// ID_LIKE mentioning arch only as part of a longer word doesn't count.
assert_eq!(install_kind(true, false, "ID=x\nID_LIKE=archlike\n"), "manual");
}
#[test]
fn appimage_and_windows_update_in_app() {
assert_eq!(install_kind(true, true, "ID=cachyos\nID_LIKE=arch\n"), "in-app");
assert_eq!(install_kind(false, false, ""), "in-app");
}
}
#[cfg(test)] #[cfg(test)]
mod tray_tests { mod tray_tests {
use super::*; use super::*;
+181
View File
@@ -0,0 +1,181 @@
//! Login tokens in the OS keychain (cinny #105, step 1).
//!
//! Step 1 only MIRRORS the session tokens into the keychain (Windows
//! Credential Manager): the web client keeps reading its session from
//! localStorage exactly as before, so nothing about login changes and a
//! keychain problem can't log anyone out. Once the mirror has proven itself on
//! real installs, step 2 switches reads to the keychain and drops the tokens
//! from localStorage.
//!
//! Only the secrets are stored (user id + device id to match them to the
//! session, the access token and the refresh token); the rest of the session
//! stays in localStorage. Windows caps a credential at 2560 bytes, so the
//! serialized value is limited well below that.
//!
//! Other platforms: not supported yet (Linux Secret Service can prompt to
//! unlock a wallet at startup; that needs its own testing), and the commands
//! say so instead of failing.
use serde::{Deserialize, Serialize};
#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))]
pub(crate) const SERVICE: &str = "Lotus Chat";
#[cfg_attr(not(any(target_os = "windows", test)), allow(dead_code))]
pub(crate) const ACCOUNT: &str = "session";
/// Serialized-length cap (chars). Windows' limit is 2560 bytes; staying under
/// 1200 chars keeps us safe whether the value is stored as UTF-8 or UTF-16.
pub(crate) const MAX_LEN: usize = 1200;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SessionTokens {
pub user_id: String,
pub device_id: String,
pub access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
}
pub(crate) fn store(entry: &keyring::Entry, tokens: &SessionTokens) -> Result<(), String> {
if tokens.user_id.is_empty() || tokens.device_id.is_empty() || tokens.access_token.is_empty() {
return Err("incomplete session".into());
}
let value = serde_json::to_string(tokens).map_err(|e| e.to_string())?;
if value.chars().count() > MAX_LEN {
return Err(format!(
"session too large for the keychain ({} chars)",
value.chars().count()
));
}
entry.set_password(&value).map_err(|e| e.to_string())
}
pub(crate) fn load(entry: &keyring::Entry) -> Result<Option<SessionTokens>, String> {
match entry.get_password() {
Ok(value) => serde_json::from_str(&value)
.map(Some)
.map_err(|e| format!("unreadable keychain entry: {e}")),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
pub(crate) fn clear(entry: &keyring::Entry) -> Result<(), String> {
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[cfg(target_os = "windows")]
fn entry() -> Result<keyring::Entry, String> {
keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| e.to_string())
}
#[cfg(not(target_os = "windows"))]
fn entry() -> Result<keyring::Entry, String> {
Err("not supported on this platform".into())
}
/// Keychain calls can block (credential store, AV scanners): keep them off
/// the main thread.
async fn blocking<T: Send + 'static>(
f: impl FnOnce() -> Result<T, String> + Send + 'static,
) -> Result<T, String> {
tauri::async_runtime::spawn_blocking(f)
.await
.map_err(|e| e.to_string())?
}
/// Whether this platform stores the session in the keychain.
#[tauri::command]
pub fn secure_session_supported() -> bool {
cfg!(target_os = "windows")
}
#[tauri::command]
pub async fn secure_session_set(tokens: SessionTokens) -> Result<(), String> {
blocking(move || store(&entry()?, &tokens)).await
}
#[tauri::command]
pub async fn secure_session_get() -> Result<Option<SessionTokens>, String> {
blocking(|| load(&entry()?)).await
}
#[tauri::command]
pub async fn secure_session_clear() -> Result<(), String> {
blocking(|| clear(&entry()?)).await
}
#[cfg(test)]
mod tests {
use super::*;
fn mock_entry() -> keyring::Entry {
keyring::set_default_credential_builder(keyring::mock::default_credential_builder());
keyring::Entry::new(SERVICE, ACCOUNT).unwrap()
}
fn tokens() -> SessionTokens {
SessionTokens {
user_id: "@alice:lotusguild.org".into(),
device_id: "ABCDEFGHIJ".into(),
access_token: "syt_YWxpY2U_abcdefghijklmnopqrst_0AbCdE".into(),
refresh_token: None,
}
}
#[test]
fn round_trip_and_clear() {
let e = mock_entry();
assert_eq!(load(&e).unwrap(), None);
store(&e, &tokens()).unwrap();
assert_eq!(load(&e).unwrap(), Some(tokens()));
let mut oidc = tokens();
oidc.refresh_token = Some("mar_refresh_token_value_0123456789".into());
store(&e, &oidc).unwrap();
assert_eq!(load(&e).unwrap(), Some(oidc));
clear(&e).unwrap();
assert_eq!(load(&e).unwrap(), None);
// Clearing an empty keychain is fine (logout twice, or never mirrored).
clear(&e).unwrap();
}
#[test]
fn rejects_incomplete_or_oversized_sessions() {
let e = mock_entry();
let mut t = tokens();
t.access_token = String::new();
assert!(store(&e, &t).is_err());
let mut big = tokens();
big.access_token = "x".repeat(MAX_LEN);
assert!(store(&e, &big).unwrap_err().contains("too large"));
assert_eq!(load(&e).unwrap(), None, "nothing written on error");
}
#[test]
fn serialized_shape_matches_the_web_client() {
let json = serde_json::to_value(tokens()).unwrap();
assert_eq!(json["userId"], "@alice:lotusguild.org");
assert_eq!(json["deviceId"], "ABCDEFGHIJ");
assert!(json.get("refreshToken").is_none());
let back: SessionTokens = serde_json::from_str(
r#"{"userId":"@a:b","deviceId":"D","accessToken":"t","refreshToken":"r"}"#,
)
.unwrap();
assert_eq!(back.refresh_token.as_deref(), Some("r"));
}
#[test]
fn a_realistic_oidc_session_fits() {
let e = mock_entry();
let t = SessionTokens {
user_id: format!("@{}:matrix.lotusguild.org", "a".repeat(60)),
device_id: "X".repeat(40),
access_token: "mat_".to_string() + &"A".repeat(200),
refresh_token: Some("mar_".to_string() + &"B".repeat(200)),
};
store(&e, &t).unwrap();
}
}
+58 -6
View File
@@ -12,8 +12,13 @@
//! - WebKitGTK doesn't say which frame asked; the check is on the page loaded //! - WebKitGTK doesn't say which frame asked; the check is on the page loaded
//! in the window. Frames are gated before the request gets this far by the //! in the window. Frames are gated before the request gets this far by the
//! Permissions Policy: cinny only puts `microphone; camera` in the `allow=` //! Permissions Policy: cinny only puts `microphone; camera` in the `allow=`
//! of the call frame (same origin on desktop), and cross-origin frames get //! of the call frame, and cross-origin frames get neither location nor
//! neither location nor notifications. //! notifications.
//!
//! The call frame (cinny #43): the bundled Element Call page is loaded from
//! `http://127.0.0.1:{port}`, the same local server on a second origin, so it
//! can't reach the app's storage. WebView2 reports that origin for the call's
//! microphone/camera requests; it gets media and nothing else.
use tauri::Url; use tauri::Url;
@@ -57,6 +62,14 @@ pub(crate) fn decide(kind: Kind, uri: &str, app: &AppOrigins, grants: &[Kind]) -
if kind == Kind::Other { if kind == Kind::Other {
return Decision::Default; return Decision::Default;
} }
if app.is_call_frame(uri) {
// The call page: microphone/camera/screen only.
return if kind == Kind::Media && grants.contains(&kind) {
Decision::Allow
} else {
Decision::Deny
};
}
if !app.contains(uri) { if !app.contains(uri) {
return Decision::Deny; return Decision::Deny;
} }
@@ -76,9 +89,12 @@ fn origin_of(uri: &str) -> Option<Origin> {
Some((url.scheme().to_owned(), host, url.port_or_known_default())) Some((url.scheme().to_owned(), host, url.port_or_known_default()))
} }
/// The origins the app's own page is served from. /// The origins the app's own page is served from, and the call page's.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct AppOrigins(Vec<Origin>); pub(crate) struct AppOrigins {
app: Vec<Origin>,
call: Option<Origin>,
}
impl AppOrigins { impl AppOrigins {
/// Release builds load `http://localhost:{port}` (tauri-plugin-localhost). /// Release builds load `http://localhost:{port}` (tauri-plugin-localhost).
@@ -93,11 +109,20 @@ impl AppOrigins {
uris.push(dev.to_string()); uris.push(dev.to_string());
} }
} }
Self(uris.iter().filter_map(|u| origin_of(u)).collect()) Self {
app: uris.iter().filter_map(|u| origin_of(u)).collect(),
call: origin_of(&format!("http://127.0.0.1:{port}/")),
}
} }
/// The app's own page.
pub(crate) fn contains(&self, uri: &str) -> bool { pub(crate) fn contains(&self, uri: &str) -> bool {
origin_of(uri).is_some_and(|o| self.0.contains(&o)) origin_of(uri).is_some_and(|o| self.app.contains(&o))
}
/// The call page on its own origin (`http://127.0.0.1:{port}`).
pub(crate) fn is_call_frame(&self, uri: &str) -> bool {
origin_of(uri).is_some_and(|o| self.call.as_ref() == Some(&o))
} }
} }
@@ -204,6 +229,33 @@ mod tests {
} }
} }
#[test]
fn call_frame_gets_media_only() {
let app = app();
let call = "http://127.0.0.1:44548/public/element-call/index.html?widgetId=x";
assert!(app.is_call_frame(call));
assert!(!app.contains(call));
for grants in [LINUX_GRANTS, WINDOWS_GRANTS] {
assert_eq!(decide(Kind::Media, call, &app, grants), Decision::Allow);
for kind in [Kind::DeviceInfo, Kind::Notifications, Kind::Geolocation] {
assert_eq!(decide(kind, call, &app, grants), Decision::Deny, "{kind:?}");
}
assert_eq!(decide(Kind::Other, call, &app, grants), Decision::Default);
}
for not_call in [
"http://127.0.0.1:44549/",
"https://127.0.0.1:44548/",
"http://127.0.0.2:44548/",
"http://[::1]:44548/",
] {
assert!(!app.is_call_frame(not_call), "{not_call}");
assert_eq!(
decide(Kind::Media, not_call, &app, WINDOWS_GRANTS),
Decision::Deny
);
}
}
#[test] #[test]
fn other_kinds_are_left_to_the_engine() { fn other_kinds_are_left_to_the_engine() {
let app = app(); let app = app();
+1 -1
View File
@@ -71,7 +71,7 @@
}, },
"app": { "app": {
"security": { "security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'" "csp": "default-src 'self'; script-src 'self' 'unsafe-eval' 'sha256-dT6noyex1I8o5CS9Sx/y8UOqwpZYIridpGz92gcObIM='; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: http: https:; media-src 'self' blob: data: mediastream: http: https:; worker-src 'self' blob:; frame-src 'self' blob: http://127.0.0.1:44548 https://www.openstreetmap.org https://www.youtube-nocookie.com https://www.youtube.com https://player.vimeo.com https://www.tiktok.com https://www.dailymotion.com https://geo.dailymotion.com https://streamable.com https://player.twitch.tv https://clips.twitch.tv https://open.spotify.com https://w.soundcloud.com https://embed.music.apple.com https://platform.twitter.com https://www.instagram.com https://embed.tidal.com https://www.redditmedia.com https://embed.reddit.com https://embed.bsky.app https://www.loom.com https://player.kick.com https://www.mixcloud.com https://widget.deezer.com https://store.steampowered.com; connect-src 'self' blob: data: ipc: ws: wss: http: https: http://ipc.localhost; object-src 'none'; base-uri 'self'"
} }
} }
} }