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