Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ members = [
]

[workspace.package]
version = "0.2.11"
version = "0.2.12"
edition = "2021"
license = "MIT"
publish = false
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- | --- | --- |
Expand Down
5 changes: 4 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<account>.state`, each value sealed with AES-256-GCM-SIV under a key held only in the
Expand Down
8 changes: 8 additions & 0 deletions crates/chatd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }

Expand Down
35 changes: 34 additions & 1 deletion crates/chatd/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Item = Link>) -> 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);
}
}
161 changes: 151 additions & 10 deletions crates/chatd/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()),
}
}
}

Expand All @@ -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)
}
Expand All @@ -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).
Expand Down Expand Up @@ -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")]
Expand Down
Loading
Loading