From e1323a20e75550198a22e1d013f72f31c89e4536 Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Sat, 24 Oct 2026 18:52:36 -0400 Subject: [PATCH] Version 0.2.12: keep Tiny server on the rail, and stop asking for the keychain password after an update. The Tiny server mark declared its own size, and the rail added a second one. The SVG was invalid, so the button drew nothing. A network whose session is still in the keychain stays on the rail while that read is waiting or refused. An ad-hoc release's designated requirement is only its cdhash, so each update asked for the login password once per network. After chatd reads or writes an item, any app on this Mac can read it without asking. The keychain still locks when the person is logged out. --- Cargo.lock | 14 ++- Cargo.toml | 2 +- README.md | 2 +- SECURITY.md | 5 +- crates/chatd/Cargo.toml | 8 ++ crates/chatd/src/server.rs | 35 +++++- crates/chatd/src/session.rs | 161 ++++++++++++++++++++++++++-- crates/tinyclient/src/gui/assets.rs | 60 ++++++++++- docs/product/08-sign-in-accounts.md | 10 +- 9 files changed, 271 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad1f183..f8fddd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,7 +1015,7 @@ dependencies = [ [[package]] name = "chatd" -version = "0.2.11" +version = "0.2.12" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -1024,6 +1024,8 @@ dependencies = [ "base64 0.22.1", "chrono", "cipher 0.4.4", + "core-foundation 0.10.1", + "core-foundation-sys", "ctr 0.9.2", "dbus", "futures", @@ -1045,6 +1047,8 @@ dependencies = [ "protocol", "rand 0.9.5", "rusqlite", + "security-framework 3.7.0", + "security-framework-sys", "serde", "serde_json", "sha2 0.10.9", @@ -4934,7 +4938,7 @@ dependencies = [ [[package]] name = "login" -version = "0.2.11" +version = "0.2.12" dependencies = [ "block2 0.6.2", "embed-resource", @@ -5094,7 +5098,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "mediad" -version = "0.2.11" +version = "0.2.12" dependencies = [ "aes-gcm", "chacha20poly1305", @@ -7013,7 +7017,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.11" +version = "0.2.12" dependencies = [ "interprocess", "serde", @@ -9077,7 +9081,7 @@ dependencies = [ [[package]] name = "tinyclient" -version = "0.2.11" +version = "0.2.12" dependencies = [ "embed-resource", "gpui", diff --git a/Cargo.toml b/Cargo.toml index bc3f67f..3369cef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.2.11" +version = "0.2.12" edition = "2021" license = "MIT" publish = false diff --git a/README.md b/README.md index 3e7e8eb..e3cd482 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Sizes of a macOS arm64 release build, 2026-10-14: ## What works today -Current release: [v0.2.11](https://github.com/itz4blitz/tinyclient/releases/tag/v0.2.11). This is what `main` does. The goal is full parity with each official app. Every gap is a place to help. +Current release: [v0.2.12](https://github.com/itz4blitz/tinyclient/releases/tag/v0.2.12). This is what `main` does. The goal is full parity with each official app. Every gap is a place to help. | Network | Sign-in | Reading and media | Writing | Calls | | --- | --- | --- | --- | --- | diff --git a/SECURITY.md b/SECURITY.md index c7873fe..a399430 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,7 +73,10 @@ What tinyclient protects, and how. Every point is checkable in the code named. **Sessions** are kept in the operating system's credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service) under the service `tinyclient`, one item per network (`crates/chatd/src/session.rs`). A build whose credential store would not persist refuses to keep -sessions at all rather than fall back to memory or disk. +sessions at all rather than fall back to memory or disk. On macOS the release is ad-hoc signed, so +each item's access list allows any app running as the same user to read it without a prompt. +That is what stops a login-password dialog after every update. The keychain stays locked when +the user is logged out. A Developer ID signature can restrict the list to chatd. **Signal's state** (identity keys, prekeys, sessions, group keys, contacts) is in `signal/.state`, each value sealed with AES-256-GCM-SIV under a key held only in the diff --git a/crates/chatd/Cargo.toml b/crates/chatd/Cargo.toml index c96daa9..2ba7c66 100644 --- a/crates/chatd/Cargo.toml +++ b/crates/chatd/Cargo.toml @@ -71,6 +71,14 @@ rand = { version = "0.9", optional = true } default = ["signal"] signal = ["dep:presage", "dep:aes-gcm-siv", "dep:futures", "dep:async-trait", "dep:chrono", "dep:rand", "tokio/rt"] +[target.'cfg(target_os = "macos")'.dependencies] +# macOS reads the login keychain here so an ad-hoc update does not ask for the password again. +# See `session::apple`. +security-framework = { version = "3", features = ["OSX_10_15"] } +security-framework-sys = "2" +core-foundation = "0.10" +core-foundation-sys = "0.8" + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock"] } diff --git a/crates/chatd/src/server.rs b/crates/chatd/src/server.rs index 716caf3..9da16c8 100644 --- a/crates/chatd/src/server.rs +++ b/crates/chatd/src/server.rs @@ -463,7 +463,12 @@ impl Chat { .into_iter() .map(|network| NetworkStatus { network, - link: self.hub.network_link(network), + // The rail trusts this link. A session still in the keychain, or waiting on + // it, has no live link yet and would otherwise read as signed out. + link: published_link( + self.hub.network_link(network), + accounts.iter().filter(|account| account.key.network == network).map(|account| account.link), + ), }) .collect(), accounts, @@ -1986,3 +1991,31 @@ fn typed_profile(person: Person, mut about: About) -> Profile { profile.warning = about.warning; profile } + +/// The link the shell uses to decide a network is on the rail. A live connection wins. When the +/// adapter has not reported one, an account kept on screen (its session is unread, refused, or +/// still in the keychain) stays as offline instead of disappearing. +fn published_link(live: Link, kept: impl IntoIterator) -> Link { + if live != Link::SignedOut { + return live; + } + if kept.into_iter().any(|link| link != Link::SignedOut) { + Link::Offline + } else { + Link::SignedOut + } +} + +#[cfg(test)] +mod tests { + use super::published_link; + use protocol::Link; + + #[test] + fn a_stored_account_stays_on_the_rail_while_its_session_is_unread() { + assert_eq!(published_link(Link::SignedOut, [Link::Offline]), Link::Offline); + assert_eq!(published_link(Link::SignedOut, std::iter::empty()), Link::SignedOut); + assert_eq!(published_link(Link::Connected, [Link::Offline]), Link::Connected); + assert_eq!(published_link(Link::Expired, [Link::Offline]), Link::Expired); + } +} diff --git a/crates/chatd/src/session.rs b/crates/chatd/src/session.rs index 0ea9a15..a8c8a4a 100644 --- a/crates/chatd/src/session.rs +++ b/crates/chatd/src/session.rs @@ -156,6 +156,9 @@ pub fn read_waiting(network: Network) -> Stored { } fn read_session(network: Network, retry_timeouts: bool) -> Stored { + // The unlock retry is the Linux Secret Service. macOS reads through `apple` instead. + #[cfg(target_os = "macos")] + let _ = retry_timeouts; if !enabled() { let name = format!("TINYCLIENT_SESSION_{}", network.label().to_uppercase()); // Scratch-only simulation of a provider whose GetSecret times out until unlocked. @@ -184,14 +187,19 @@ fn read_session(network: Network, retry_timeouts: bool) -> Stored { if !persistent() { return Stored::Unreadable(NO_STORE.to_owned()); } - let entry = match Entry::new(SERVICE, account(network)) { - Ok(entry) => entry, - Err(err) => return Stored::Unreadable(err.to_string()), - }; - match read_password(&entry, retry_timeouts) { - Ok(session) => Stored::Found(session), - Err(keyring::Error::NoEntry) => moved(network, &entry, retry_timeouts), - Err(err) => Stored::Unreadable(err.to_string()), + #[cfg(target_os = "macos")] + return apple::read(network); + #[cfg(not(target_os = "macos"))] + { + let entry = match Entry::new(SERVICE, account(network)) { + Ok(entry) => entry, + Err(err) => return Stored::Unreadable(err.to_string()), + }; + match read_password(&entry, retry_timeouts) { + Ok(session) => Stored::Found(session), + Err(keyring::Error::NoEntry) => moved(network, &entry, retry_timeouts), + Err(err) => Stored::Unreadable(err.to_string()), + } } } @@ -205,6 +213,8 @@ fn moved(network: Network, entry: &Entry, retry_timeouts: bool) -> Stored { Ok(session) => { if entry.set_password(&session).is_ok() { let _ = old.delete_credential(); + #[cfg(target_os = "macos")] + apple::trust_account(account(network)); } Stored::Found(session) } @@ -220,10 +230,16 @@ pub fn save(network: Network, session: &str) -> Result<(), String> { if !persistent() { return Err(NO_STORE.to_owned()); } - Entry::new(SERVICE, account(network)) + let saved = Entry::new(SERVICE, account(network)) .map_err(|err| format!("keychain: {err}"))? .set_password(session) - .map_err(|err| format!("keychain: {err}")) + .map_err(|err| format!("keychain: {err}")); + // The item a sign-in just wrote trusts only this binary. Open it to later releases too. + #[cfg(target_os = "macos")] + if saved.is_ok() { + apple::trust_account(account(network)); + } + saved } /// A network reissued part of a session while it was in use (Slack rotates its `d` cookie). @@ -268,6 +284,131 @@ pub fn forget(network: Network) { } } +/// macOS keeps one generic password per network in the login keychain. An ad-hoc release's +/// designated requirement is only its cdhash, so the next release is a different app and macOS +/// asks for the login password once per network. After this process has read or written an item, +/// its access list allows any app on this Mac, which is what makes the next release silent. +/// The keychain is still locked when the person is logged out. A Developer ID signature can +/// limit that list to chatd; this release is not signed with one. +#[cfg(target_os = "macos")] +mod apple { + use super::{account, moved, Stored, SERVICE}; + use core_foundation::array::CFArray; + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + use core_foundation_sys::base::{CFRelease, CFTypeRef}; + use protocol::Network; + use security_framework::os::macos::access::SecAccess; + use security_framework::os::macos::keychain::SecKeychain; + use security_framework::os::macos::keychain_item::SecKeychainItem; + use security_framework::os::macos::passwords::find_generic_password; + use security_framework_sys::base::{errSecItemNotFound, SecAccessRef}; + use std::os::raw::c_void; + use std::ptr; + + #[link(name = "Security", kind = "framework")] + extern "C" { + fn SecAccessCreate(descriptor: CFTypeRef, trusted: CFTypeRef, access: *mut SecAccessRef) -> i32; + fn SecAccessCopyACLList(access: SecAccessRef, list: *mut CFTypeRef) -> i32; + fn SecACLSetContents(acl: CFTypeRef, apps: CFTypeRef, description: CFTypeRef, prompt: u16) -> i32; + fn SecACLCreateWithSimpleContents( + access: SecAccessRef, + apps: CFTypeRef, + description: CFTypeRef, + prompt: u16, + new_acl: *mut CFTypeRef, + ) -> i32; + fn SecKeychainItemSetAccess(item: CFTypeRef, access: SecAccessRef) -> i32; + } + + pub(super) fn read(network: Network) -> Stored { + let name = account(network); + let keychain = match SecKeychain::default() { + Ok(keychain) => keychain, + Err(err) => return Stored::Unreadable(err.to_string()), + }; + match find_generic_password(Some(&[keychain]), SERVICE, name) { + Ok((password, item)) => { + let session = match String::from_utf8(password.as_ref().to_vec()) { + Ok(session) => session, + Err(_) => return Stored::Unreadable("the keychain item was not text".to_owned()), + }; + // `password` frees its bytes on drop. The item stays, and this process may change it. + drop(password); + trust_item(name, &item); + Stored::Found(session) + } + Err(err) if err.code() == errSecItemNotFound => match keyring::Entry::new(SERVICE, name) { + Ok(entry) => moved(network, &entry, false), + Err(err) => Stored::Unreadable(err.to_string()), + }, + Err(err) => Stored::Unreadable(err.to_string()), + } + } + + pub(super) fn trust_account(name: &str) { + let Ok(keychain) = SecKeychain::default() else { + return; + }; + match find_generic_password(Some(&[keychain]), SERVICE, name) { + Ok((password, item)) => { + drop(password); + trust_item(name, &item); + } + Err(err) => eprintln!("chatd: {name}: could not open the saved sign-in to keep it ({err})"), + } + } + + fn trust_item(name: &str, item: &SecKeychainItem) { + if let Err(err) = allow_anyone(item) { + eprintln!("chatd: {name}: the sign-in will ask again after the next update ({err})"); + } + } + + /// `nil` applications means any app may read the item without a prompt. An empty list would + /// trust nobody. Apple documents the difference on `SecACLSetContents`. + fn allow_anyone(item: &SecKeychainItem) -> Result<(), String> { + let label = CFString::from("tinyclient"); + let label_ref = label.as_concrete_TypeRef(); + unsafe { + let mut created: SecAccessRef = ptr::null_mut(); + let status = SecAccessCreate(label_ref.cast(), ptr::null(), &mut created); + if status != 0 { + return Err(format!("keychain access {status}")); + } + let access = SecAccess::wrap_under_create_rule(created); + let mut list: CFTypeRef = ptr::null(); + let status = SecAccessCopyACLList(access.as_concrete_TypeRef(), &mut list); + if status != 0 || list.is_null() { + return Err(format!("keychain access list {status}")); + } + let acls = CFArray::<*const c_void>::wrap_under_create_rule(list.cast()); + if acls.is_empty() { + let mut acl: CFTypeRef = ptr::null(); + let status = SecACLCreateWithSimpleContents(access.as_concrete_TypeRef(), ptr::null(), label_ref.cast(), 0, &mut acl); + if status != 0 { + return Err(format!("keychain access entry {status}")); + } + if !acl.is_null() { + CFRelease(acl); + } + } else { + for acl in acls.iter() { + let status = SecACLSetContents(*acl, ptr::null(), label_ref.cast(), 0); + if status != 0 { + return Err(format!("keychain access entry {status}")); + } + } + } + let status = SecKeychainItemSetAccess(item.as_concrete_TypeRef().cast(), access.as_concrete_TypeRef()); + if status != 0 { + return Err(format!("keychain access set {status}")); + } + } + Ok(()) + } +} + #[cfg(test)] mod tests { #[cfg(target_os = "linux")] diff --git a/crates/tinyclient/src/gui/assets.rs b/crates/tinyclient/src/gui/assets.rs index ce844cd..395ae55 100644 --- a/crates/tinyclient/src/gui/assets.rs +++ b/crates/tinyclient/src/gui/assets.rs @@ -149,6 +149,10 @@ pub fn network_asset(network: Network, points: f32) -> SharedString { } /// The same SVG, declaring a size that fits `points` square and keeps its shape. +/// +/// A source that already has `width` and `height` cannot be given a second pair. The parser +/// rejects the duplicate and the rail draws an empty button, which is how the Tiny server mark +/// disappeared. fn sized(bytes: &'static [u8], points: f32) -> Cow<'static, [u8]> { let Ok(text) = std::str::from_utf8(bytes) else { return Cow::Borrowed(bytes); @@ -156,21 +160,48 @@ fn sized(bytes: &'static [u8], points: f32) -> Cow<'static, [u8]> { let Some(open) = text.find("') else { + return Cow::Borrowed(bytes); + }; + let tag_end = open + end; let (wide, tall) = view_box(text).unwrap_or((1., 1.)); let scale = points / wide.max(tall); - let at = open + "{}", + &text[..open], + tag, wide * scale, tall * scale, - &text[at..] + &text[tag_end + 1..] ) .into_bytes(), ) } +/// Removes root `width` and `height` so [`sized`] can set the drawn size once. +fn without_size(tag: &str) -> String { + let mut out = String::with_capacity(tag.len()); + let mut rest = tag; + loop { + let width_at = rest.find(" width=\""); + let height_at = rest.find(" height=\""); + let Some(at) = width_at.into_iter().chain(height_at).min() else { + out.push_str(rest); + return out; + }; + let key = if width_at == Some(at) { " width=\"" } else { " height=\"" }; + out.push_str(&rest[..at]); + let value = at + key.len(); + let Some(end) = rest[value..].find('"') else { + out.push_str(&rest[at..]); + return out; + }; + rest = &rest[value + end + 1..]; + } +} + fn view_box(text: &str) -> Option<(f32, f32)> { let rest = &text[text.find("viewBox=\"")? + "viewBox=\"".len()..]; let mut numbers = rest[..rest.find('"')?] @@ -195,3 +226,24 @@ fn stroked(bytes: &'static [u8], points: f32) -> Cow<'static, [u8]> { let width = format!("stroke-width=\"{}\"", 1.5 * 24. / points.max(1.)); Cow::Owned(text.replacen("stroke-width=\"2\"", &width, 1).into_bytes()) } + +#[cfg(test)] +mod tests { + use super::sized; + + fn root(bytes: &[u8]) -> String { + let text = std::str::from_utf8(bytes).unwrap(); + text[..text.find('>').unwrap()].to_owned() + } + + #[test] + fn a_sized_icon_declares_its_drawn_size_once() { + let tiny = root(&sized(include_bytes!("../../assets/networks/tiny.svg"), 24.)); + assert_eq!(tiny.matches("width=").count(), 1, "{tiny}"); + assert_eq!(tiny.matches("height=").count(), 1, "{tiny}"); + assert!(tiny.contains("width=\"24\""), "{tiny}"); + let discord = root(&sized(include_bytes!("../../assets/networks/discord.svg"), 24.)); + assert_eq!(discord.matches("width=").count(), 1, "{discord}"); + assert_eq!(discord.matches("height=").count(), 1, "{discord}"); + } +} diff --git a/docs/product/08-sign-in-accounts.md b/docs/product/08-sign-in-accounts.md index 67d253f..45e1437 100644 --- a/docs/product/08-sign-in-accounts.md +++ b/docs/product/08-sign-in-accounts.md @@ -144,9 +144,13 @@ anything that hides presence on Telegram. notarization, a downloaded copy is blocked until the person allows it in System Settings. - **Why the bundle matters for behavior, not looks.** Notifications need a bundle identifier; a bare binary cannot post one with our name and icon. Keychain items are tied to the code - signature of the process that wrote them: with a stable Developer ID signature chatd reads its - sessions silently forever; with an unsigned or ad-hoc build, every new build is a new identity - and macOS asks for the login password per item. Login Items shows the bundle's name and icon. + signature of the process that wrote them. This release is ad-hoc, and an ad-hoc signature's + designated requirement is only its cdhash, so the next release would otherwise be a different + app and macOS would ask for the login password once per network. After chatd has read or written + an item, its access list allows any app on this Mac to read it without asking. That is what + keeps an update from prompting. The login keychain is still locked when the person is logged + out. A Developer ID signature can limit the list to chatd. Login Items shows the bundle's name + and icon. - **Open at login** registers chatd as a login agent from inside the bundle (`SMAppService`, macOS 13+), so it can be switched off in System Settings and goes when the app is deleted. - **Updates:** while the window is open, tinyclient asks GitHub's public latest release about