8000 fix(privacy): keep mail files owner-only and purge Flatpak staging by hanthor · Pull Request #600 · hyprlab/hylki · GitHub
[go: up one dir, main page]

Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/app/diagnostics.rs
Original file line number Diff line number Diff line cha 8000 nge
Expand Up @@ -254,7 +254,7 @@ impl AppModel {

AppMsg::ExportLogTo(path) => {
let text = crate::desktop::console_log::export_text(&self.memory_report());
match std::fs::write(&path, text) {
match crate::config::write_private(&path, &text) {
Ok(()) => tracing::info!("log exported to {}", path.display()),
Err(e) => tracing::warn!("log export to {} failed: {e}", path.display()),
}
Expand Down
17 changes: 13 additions & 4 deletions src/app/prefs_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,12 @@ impl AppModel {
&i18n("Export Settings"),
"hylki-settings.toml",
|path| {
crate::config::export_bundle()
.and_then(|t| std::fs::write(path, t).map_err(|e| e.to_string()))
// It carries every account's server and address, and
// OAuth client secrets: no more readable than the files
// it was made from.
crate::config::export_bundle().and_then(|t| {
crate::config::write_private(path, &t).map_err(|e| e.to_string())
})
},
|path| {
i18n_f(
Expand All @@ -166,8 +170,13 @@ impl AppModel {
&title,
&name,
move |path| {
std::fs::write(path, crate::desktop::console_log::export_text(&memory))
.map_err(|e| e.to_string())
// Redaction shortens addresses only: names, subjects
// and hosts are still in it.
crate::config::write_private(
path,
&crate::desktop::console_log::export_text(&memory),
)
.map_err(|e| e.to_string())
},
|path| {
i18n_f(
Expand Down
33 changes: 29 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ mod tests;
/// hostnames, usernames, OAuth client secrets and correspondent lists. Creating
/// with the mode already set closes it.
pub(crate) fn write_private(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
write_private_bytes(path, contents.as_bytes())
}

/// [`write_private`] for bytes: the remote-content cache, the exported log
/// and the settings bundle.
pub(crate) fn write_private_bytes(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
Expand All @@ -76,15 +82,34 @@ pub(crate) fn write_private(path: &std::path::Path, contents: &str) -> std::io::
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(contents.as_bytes())?;
// An existing file keeps its old mode through `OpenOptions::mode`, which
// only applies at creation — so tighten regardless.
// only applies at creation, so it is tightened before anything goes in.
// Best effort: a document portal path may not take a mode.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
let _ = f.set_permissions(std::fs::Permissions::from_mode(0o600));
}
f.write_all(contents)
}

/// Create `dir`, and any parent missing, for its owner alone, and tighten
/// it if it already exists with more.
pub(crate) fn private_dir(dir: &std::path::Path) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
if std::fs::metadata(dir)?.permissions().mode() & 0o077 != 0 {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
Ok(())
#[cfg(not(unix))]
std::fs::create_dir_all(dir)
}

/// XDG base directories, with one twist: the beta channel running inside its
Expand Down
26 changes: 26 additions & 0 deletions src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,3 +483,29 @@ fn a_view_keeps_its_sort_through_the_file() {
assert!(old.list_sorts.is_empty());
assert_eq!(old.list_sort, "unread");
}

/// An exported log or settings bundle replaces whatever file the user picked,
/// and is left readable by its owner only, even where the file was not.
#[cfg(unix)]
#[test]
fn a_private_file_is_tightened_before_it_is_filled() {
use std::os::unix::fs::PermissionsExt;
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
let dir = std::env::temp_dir().join(format!("hylki-private-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
super::private_dir(&dir.join("a/b")).unwrap();
assert_eq!(mode(&dir.join("a/b")), 0o700);
std::fs::set_permissions(dir.join("a"), std::fs::Permissions::from_mode(0o755)).unwrap();
super::private_dir(&dir.join("a")).unwrap();
assert_eq!(mode(&dir.join("a")), 0o700);

let file = dir.join("hylki-log.txt");
std::fs::write(&file, b"an older, longer log").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
super::write_private(&file, "log").unwrap();
assert_eq!(mode(&file), 0o600);
assert_eq!(std::fs::read(&file).unwrap(), b"log");
super::write_private_bytes(&dir.join("new.bin"), b"\x00\x01").unwrap();
assert_eq!(mode(&dir.join("new.bin")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
47 changes: 45 additions & 2 deletions src/services/remote_content/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ pub(super) fn write(url: &str, entry: &Entry) {
}

fn write_in(dir: &Path, url: &str, entry: &Entry) -> std::io::Result<u64> {
std::fs::create_dir_all(dir)?;
// What was fetched for a message says which mail was opened: the
// directory and its files are the owner's alone.
crate::config::private_dir(dir)?;
let name = file_name(url);
// Written aside and renamed into place, so a reader never sees half a file.
let tmp = dir.join(format!("{name}.part"));
Expand All @@ -103,7 +105,7 @@ fn write_in(dir: &Path, url: &str, entry: &Entry) -> std::io::Result<u64> {
data.extend_from_slice(content_type.as_bytes());
data.push(b'\n');
data.extend_from_slice(&entry.bytes);
std::fs::write(&tmp, &data)?;
crate::config::write_private_bytes(&tmp, &data)?;
std::fs::rename(&tmp, dir.join(name))?;
Ok(data.len() as u64)
}
Expand Down Expand Up @@ -138,6 +140,7 @@ fn trim_files(dir: &Path, limit: u64, now: SystemTime) -> u64 {
let _ = std::fs::remove_file(e.path());
continue;
}
tighten(&e.path(), &meta);
files.push((modified, meta.len(), e.path()));
}
let mut total: u64 = files.iter().map(|f| f.1).sum();
Expand All @@ -156,6 +159,19 @@ fn trim_files(dir: &Path, limit: u64, now: SystemTime) -> u64 {
total
}

/// An entry written before the cache was kept private is made so at the
/// first trim of a session.
#[cfg(unix)]
fn tighten(path: &Path, meta: &std::fs::Metadata) {
use std::os::unix::fs::PermissionsExt;
if meta.permissions().mode() & 0o077 != 0 {
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}

#[cfg(not(unix))]
fn tighten(_path: &Path, _meta: &std::fs::Metadata) {}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -183,6 +199,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}

#[cfg(unix)]
#[test]
fn the_cache_is_its_owners_alone() {
use std::os::unix::fs::PermissionsExt;
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
let dir = temp_dir("private");
// As an earlier version left it: open to every user.
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let old = dir.join(file_name("https://a.example/old.png"));
std::fs::write(&old, b"image/png\nold").unwrap();
std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o644)).unwrap();
let entry = Entry {
content_type: "image/png".into(),
bytes: b"new".to_vec(),
};
write_in(&dir, "https://a.example/new.png", &entry).unwrap();
assert_eq!(mode(&dir), 0o700);
assert_eq!(
mode(&dir.join(file_name("https://a.example/new.png"))),
0o600
);
trim_files(&dir, LIMIT, SystemTime::now());
assert_eq!(mode(&old), 0o600);
8000 let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn a_trim_drops_the_oldest_and_the_stale() {
let dir = temp_dir("trim");
Expand Down
77 changes: 58 additions & 19 deletions src/ui/attachment_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,23 +206,7 @@ fn portal_open_file(path: std::path::PathBuf, ask: bool, parent: Option<gtk::Win
/// else's access — an attacker who pre-creates it on a shared host would
/// otherwise get every attachment the user opens.
fn attachment_dir() -> Option<std::path::PathBuf> {
// Inside Flatpak /tmp is PRIVATE to the sandbox: the document portal
// validates an exported fd by re-opening its path in the HOST namespace,
// so a /tmp-staged file fails validation and every portal open dies
// silently before any UI. $XDG_RUNTIME_DIR/app/$FLATPAK_ID is
// bind-mounted from the host at the SAME path — the one temp location
// both sides of the sandbox agree on (and it's session-scoped tmpfs,
// like /tmp). Native builds keep /tmp.
let dir = match std::env::var("FLATPAK_ID") {
Ok(app_id) => {
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
std::path::Path::new(&runtime)
.join("app")
.join(app_id)
.join("vireo-attachments")
}
Err(_) => std::env::temp_dir().join("vireo-attachments"),
};
let dir = staging_dir()?;
#[cfg(unix)]
{
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
Expand Down Expand Up @@ -301,14 +285,48 @@ fn our_uid() -> u32 {
unsafe { getuid() }
}

/// Where opened attachments are staged.
///
/// Inside Flatpak /tmp is PRIVATE to the sandbox: the document portal
/// validates an exported fd by re-opening its path in the HOST namespace,
/// so a /tmp-staged file fails validation and every portal open dies
/// silently before any UI. $XDG_RUNTIME_DIR/app/$FLATPAK_ID is
/// bind-mounted from the host at the SAME path — the one temp location
/// both sides of the sandbox agree on (and it's session-scoped tmpfs,
/// like /tmp). Native builds keep /tmp.
fn staging_dir() -> Option<std::path::PathBuf> {
staging_dir_for(
std::env::var("FLATPAK_ID").ok().as_deref(),
std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
&std::env::temp_dir(),
)
}

fn staging_dir_for(
flatpak_id: Option<&str>,
runtime_dir: Option<&str>,
temp_dir: &std::path::Path,
) -> Option<std::path::PathBuf> {
match flatpak_id {
Some(app_id) => Some(
std::path::Path::new(runtime_dir?)
.join("app")
.join(app_id)
.join("vireo-attachments"),
),
None => Some(temp_dir.join("vireo-attachments")),
}
}

/// Delete anything left in the attachment staging directory.
///
/// Opened attachments used to accumulate in `/tmp` for the life of the machine —
/// decrypted, readable, and long past the point the user considers them gone.
/// Called once at startup, because the helper application the user opened a file
/// with may still have it open while Hylki is running.
/// with may still have it open while Hylki is running. It clears the directory
/// [`attachment_dir`] stages in, which under Flatpak is not /tmp.
pub(crate) fn purge_attachment_dir() {
let dir = std::env::temp_dir().join("vireo-attachments");
let Some(dir) = staging_dir() else { return };
if !dir.exists() {
return;
}
Expand Down Expand Up @@ -353,4 +371,25 @@ mod tests {
assert_eq!(std::fs::read(&target).unwrap(), b"keep");
let _ = std::fs::remove_dir_all(&dir);
}

/// The startup purge and the staging must agree on the directory:
/// under Flatpak the purge once cleared /tmp while the files sat in the
/// runtime directory.
#[test]
fn the_purge_clears_where_attachments_are_staged() {
let tmp = std::path::Path::new("/tmp");
assert_eq!(
staging_dir_for(Some("co.hyprlab.Hylki"), Some("/run/user/1000"), tmp),
Some(std::path::PathBuf::from(
"/run/user/1000/app/co.hyprlab.Hylki/vireo-attachments"
))
);
assert_eq!(
staging_dir_for(None, Some("/run/user/1000"), tmp),
Some(std::path::PathBuf::from("/tmp/vireo-attachments"))
);
// Without a runtime directory a Flatpak has nowhere to stage, and
// nothing to purge.
assert_eq!(staging_dir_for(Some("co.hyprlab.Hylki"), None, tmp), None);
}
}
14 changes: 9 additions & 5 deletions src/worker/local/copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,12 +222,16 @@ fn ledger_line(key: &CopyKey) -> String {
fn append_ledger(ledger: &std::path::Path, key: &CopyKey) {
let written = ledger
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.map_or(Ok(()), crate::config::private_dir)
.and_then(|()| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(ledger)
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(ledger)
})
.and_then(|mut f| f.write_all(ledger_line(key).as_bytes()));
if let Err(e) = written {
Expand Down
22 changes: 18 additions & 4 deletions src/worker/local/maildir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ pub(super) struct Maildir {
}

impl Maildir {
/// The tree at `root`, with its Inbox and Trash made if missing.
/// The tree at `root`, with its Inbox and Trash made if missing. The
/// tree is the owner's alone, as the cache is: it is the only copy.
pub(super) fn open(root: PathBuf) -> std::io::Result<Maildir> {
crate::config::private_dir(&root)?;
let md = Maildir {
root,
folders: HashMap::new(),
Expand Down Expand Up @@ -504,19 +506,31 @@ fn folder_dir(root: &Path, path: &str) -> PathBuf {
fn make_folder_dirs(root: &Path, path: &str) -> std::io::Result<()> {
let dir = folder_dir(root, path);
for sub in ["cur", "new", "tmp"] {
std::fs::create_dir_all(dir.join(sub))?;
crate::config::private_dir(&dir.join(sub))?;
}
Ok(())
}

/// A file in the tree, readable by its owner alone however the umask is set.
pub(super) fn create_private(path: &Path) -> std::io::Result<std::fs::File> {
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts.open(path)
}

/// Write a message into the folder at `dir`: in full under `tmp`, synced,
/// then renamed into `cur` with `flags` (letters in any order). Returns
/// the file's name.
fn write_message(dir: &Path, raw: &[u8], flags: &str) -> std::io::Result<String> {
let unique = unique_name();
let tmp = dir.join("tmp").join(&unique);
{
let mut f = std::fs::File::create(&tmp)?;
let mut f = create_private(&tmp)?;
f.write_all(raw)?;
f.sync_all()?;
}
Expand Down Expand Up @@ -726,7 +740,7 @@ fn keyword_letters(dir: &Path, keywords: &[String], add: bool) -> String {
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("tmp");
{
let mut f = std::fs::File::create(&tmp)?;
let mut f = create_private(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
Expand Down
Loading
0