diff --git a/CHANGELOG.md b/CHANGELOG.md index 545d0b9..cfc0839 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.11.9] - 2026-04-07 + +### Fixed + +- Collab join now fails closed when GitHub API collaborator check fails +- Recipient filtering enforced against authorized list during `collab add` and `collab refresh` +- Secret scan during `team add` is now recursive (catches secrets in subdirectories) +- Secure file permissions on Windows for key cache, identity cache, and decrypted secrets via `icacls` +- Centralized `write_owner_only` helper fixes pre-existing file permissions on Unix + +### Changed + +- Dashboard TUI palette brightened for better readability on dark and light terminals + ## [1.11.8] - 2026-03-09 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 5e94a2c..3536e19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tether" -version = "1.11.8" +version = "1.11.9" edition = "2021" authors = ["Paddo Tech"] description = "Sync your development environment across machines automatically" diff --git a/src/cli/commands/collab.rs b/src/cli/commands/collab.rs index e68d8a9..ddd7cfc 100644 --- a/src/cli/commands/collab.rs +++ b/src/cli/commands/collab.rs @@ -267,11 +267,14 @@ pub async fn join(url: &str) -> Result<()> { } } Err(_) => { - // Can't verify - might not have access to check collaborators Output::warning(&format!( "Could not verify access to {}/{}", project_owner, project_repo )); + not_collaborator_on.push(format!( + "{}/{} (verification failed)", + project_owner, project_repo + )); } } } @@ -356,7 +359,7 @@ pub async fn add(file: &str, project_path: Option<&str>) -> Result<()> { let normalized_url = normalize_remote_url(&remote_url); // Find collab for this project - let (collab_name, _collab_config) = + let (collab_name, collab_config) = config.collab_for_project(&normalized_url).ok_or_else(|| { anyhow::anyhow!( "No collab configured for this project. Run 'tether collab init' first." @@ -374,9 +377,13 @@ pub async fn add(file: &str, project_path: Option<&str>) -> Result<()> { let git = GitBackend::open(&collab_dir)?; git.pull()?; - // Load recipients + // Load recipients filtered by authorized members let recipients_dir = collab_dir.join("recipients"); - let recipients = crate::security::load_recipients(&recipients_dir)?; + let (recipients, skipped) = + crate::security::load_recipients_authorized(&recipients_dir, &collab_config.members_cache)?; + for name in &skipped { + Output::warning(&format!("Skipping unauthorized recipient: {}", name)); + } if recipients.is_empty() { return Err(anyhow::anyhow!( "No recipients found in collab. Add recipients first." @@ -571,8 +578,12 @@ pub async fn refresh(project_path: Option<&str>) -> Result<()> { } } - // Re-encrypt all secrets with current recipients - let recipients = crate::security::load_recipients(&recipients_dir)?; + // Re-encrypt all secrets with only authorized recipients + let (recipients, skipped) = + crate::security::load_recipients_authorized(&recipients_dir, &collaborators)?; + for name in &skipped { + Output::warning(&format!("Skipping unauthorized recipient: {}", name)); + } if recipients.is_empty() { Output::warning("No recipients found - secrets won't be re-encrypted"); diff --git a/src/cli/commands/sync.rs b/src/cli/commands/sync.rs index 1db390c..d4fd10f 100644 --- a/src/cli/commands/sync.rs +++ b/src/cli/commands/sync.rs @@ -651,13 +651,7 @@ fn preserve_executable_bit(source: &Path, dest: &Path) { /// Write decrypted content with secure permissions (0o600 on Unix) fn write_decrypted(path: &Path, contents: &[u8]) -> Result<()> { - std::fs::write(path, contents)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; - } - Ok(()) + crate::security::write_owner_only(path, contents) } pub fn decrypt_from_repo( diff --git a/src/cli/commands/team.rs b/src/cli/commands/team.rs index cf66911..b29f608 100644 --- a/src/cli/commands/team.rs +++ b/src/cli/commands/team.rs @@ -359,29 +359,42 @@ pub async fn add(url: &str, name: Option<&str>, _no_auto_inject: bool) -> Result let mut secrets_found = false; if dotfiles_dir.exists() { - for entry in std::fs::read_dir(&dotfiles_dir)? { - let entry = entry?; - if entry.file_type()?.is_file() { - if let Some(filename) = entry.file_name().to_str() { - team_files.push(filename.to_string()); - - // Scan for secrets - let file_path = entry.path(); - if let Ok(findings) = crate::security::scan_for_secrets(&file_path) { - if !findings.is_empty() { - secrets_found = true; + for entry in walkdir::WalkDir::new(&dotfiles_dir) + .min_depth(1) + .follow_links(false) + { + let entry = match entry { + Ok(e) => e, + Err(e) => { + Output::warning(&format!("Could not read during scan: {}", e)); + continue; + } + }; + if !entry.file_type().is_file() { + continue; + } + let rel_path = entry + .path() + .strip_prefix(&dotfiles_dir) + .unwrap_or(entry.path()); + if let Some(rel_str) = rel_path.to_str() { + team_files.push(rel_str.to_string()); + + // Scan for secrets + if let Ok(findings) = crate::security::scan_for_secrets(entry.path()) { + if !findings.is_empty() { + secrets_found = true; + Output::warning(&format!( + " {} - Found {} potential secret(s)", + rel_str, + findings.len() + )); + for finding in findings.iter().take(2) { Output::warning(&format!( - " {} - Found {} potential secret(s)", - filename, - findings.len() + " Line {}: {}", + finding.line_number, + finding.secret_type.description() )); - for finding in findings.iter().take(2) { - Output::warning(&format!( - " Line {}: {}", - finding.line_number, - finding.secret_type.description() - )); - } } } } diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index b199e7e..f3c1ed8 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -1558,9 +1558,9 @@ fn render_confirm_popup(f: &mut Frame, title: &str, msg: &str, border_color: Col Line::from(""), Line::from(vec![ Span::styled(" y", Style::default().fg(Color::Yellow).bold()), - Span::styled(" confirm ", Style::default().fg(Color::DarkGray)), + Span::styled(" confirm ", Style::default().fg(Color::Gray)), Span::styled("n/Esc", Style::default().fg(Color::Yellow).bold()), - Span::styled(" cancel", Style::default().fg(Color::DarkGray)), + Span::styled(" cancel", Style::default().fg(Color::Gray)), ]), ]; @@ -1609,7 +1609,10 @@ fn render_file_import_popup(f: &mut Frame, picker: &ImportPickerState) { { let marker = if i == picker.cursor { "> " } else { " " }; let style = if i == picker.cursor { - Style::default().fg(Color::White).bg(Color::DarkGray).bold() + Style::default() + .fg(Color::White) + .bg(Color::Indexed(240)) + .bold() } else { Style::default().fg(Color::White) }; @@ -1618,9 +1621,11 @@ fn render_file_import_popup(f: &mut Frame, picker: &ImportPickerState) { Span::styled( format!(" [{}]", item.source_profile), if i == picker.cursor { - Style::default().fg(Color::DarkGray).bg(Color::DarkGray) + Style::default() + .fg(Color::Indexed(240)) + .bg(Color::Indexed(240)) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) }, ), ])); @@ -1628,11 +1633,11 @@ fn render_file_import_popup(f: &mut Frame, picker: &ImportPickerState) { text.push(Line::from("")); text.push(Line::from(vec![ Span::styled(" j/k", Style::default().fg(Color::Yellow).bold()), - Span::styled(" navigate ", Style::default().fg(Color::DarkGray)), + Span::styled(" navigate ", Style::default().fg(Color::Gray)), Span::styled("Enter", Style::default().fg(Color::Yellow).bold()), - Span::styled(" import ", Style::default().fg(Color::DarkGray)), + Span::styled(" import ", Style::default().fg(Color::Gray)), Span::styled("Esc", Style::default().fg(Color::Yellow).bold()), - Span::styled(" close", Style::default().fg(Color::DarkGray)), + Span::styled(" close", Style::default().fg(Color::Gray)), ])); let paragraph = ratatui::widgets::Paragraph::new(text).block( @@ -1685,14 +1690,19 @@ fn render_pkg_import_popup(f: &mut Frame, picker: &PkgImportPickerState) { let label = widgets::manager_label(&item.manager_key); let sources = item.sources.join(", "); let style = if i == picker.cursor { - Style::default().fg(Color::White).bg(Color::DarkGray).bold() + Style::default() + .fg(Color::White) + .bg(Color::Indexed(240)) + .bold() } else { Style::default().fg(Color::White) }; let dim = if i == picker.cursor { - Style::default().fg(Color::DarkGray).bg(Color::DarkGray) + Style::default() + .fg(Color::Indexed(240)) + .bg(Color::Indexed(240)) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) }; text.push(Line::from(vec![ Span::styled(format!(" {}{}", marker, item.name), style), @@ -1703,11 +1713,11 @@ fn render_pkg_import_popup(f: &mut Frame, picker: &PkgImportPickerState) { text.push(Line::from("")); text.push(Line::from(vec![ Span::styled(" j/k", Style::default().fg(Color::Yellow).bold()), - Span::styled(" navigate ", Style::default().fg(Color::DarkGray)), + Span::styled(" navigate ", Style::default().fg(Color::Gray)), Span::styled("Enter", Style::default().fg(Color::Yellow).bold()), - Span::styled(" install ", Style::default().fg(Color::DarkGray)), + Span::styled(" install ", Style::default().fg(Color::Gray)), Span::styled("Esc", Style::default().fg(Color::Yellow).bold()), - Span::styled(" close", Style::default().fg(Color::DarkGray)), + Span::styled(" close", Style::default().fg(Color::Gray)), ])); let paragraph = ratatui::widgets::Paragraph::new(text).block( @@ -1761,16 +1771,16 @@ fn draw(f: &mut Frame, app: &App) { ]) } else { Line::from(vec![ - Span::styled(num, Style::default().fg(Color::DarkGray)), + Span::styled(num, Style::default().fg(Color::Gray)), Span::raw(":"), - Span::styled(t.title(), Style::default().fg(Color::DarkGray)), + Span::styled(t.title(), Style::default().fg(Color::Gray)), ]) } }) .collect(); let tabs = ratatui::widgets::Tabs::new(tab_titles) - .divider(Span::styled(" | ", Style::default().fg(Color::DarkGray))) + .divider(Span::styled(" | ", Style::default().fg(Color::Gray))) .select( Tab::all() .iter() @@ -1898,7 +1908,10 @@ fn render_profile_popup(f: &mut Frame, options: &[String], cursor: usize) { for (i, option) in options.iter().enumerate() { let marker = if i == cursor { "> " } else { " " }; let style = if i == cursor { - Style::default().fg(Color::White).bg(Color::DarkGray).bold() + Style::default() + .fg(Color::White) + .bg(Color::Indexed(240)) + .bold() } else { Style::default().fg(Color::White) }; @@ -1910,15 +1923,15 @@ fn render_profile_popup(f: &mut Frame, options: &[String], cursor: usize) { text.push(Line::from("")); text.push(Line::from(vec![ Span::styled(" j/k", Style::default().fg(Color::Yellow).bold()), - Span::styled(" navigate ", Style::default().fg(Color::DarkGray)), + Span::styled(" navigate ", Style::default().fg(Color::Gray)), Span::styled("Enter", Style::default().fg(Color::Yellow).bold()), - Span::styled(" select ", Style::default().fg(Color::DarkGray)), + Span::styled(" select ", Style::default().fg(Color::Gray)), Span::styled("Esc", Style::default().fg(Color::Yellow).bold()), - Span::styled(" cancel", Style::default().fg(Color::DarkGray)), + Span::styled(" cancel", Style::default().fg(Color::Gray)), ])); text.push(Line::from(Span::styled( " New: tether machines profile create ", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), ))); let paragraph = ratatui::widgets::Paragraph::new(text).block( diff --git a/src/dashboard/widgets/activity.rs b/src/dashboard/widgets/activity.rs index e29b3a8..edabc79 100644 --- a/src/dashboard/widgets/activity.rs +++ b/src/dashboard/widgets/activity.rs @@ -4,18 +4,13 @@ pub fn render(f: &mut Frame, area: Rect, lines: &[String]) { let text = if lines.is_empty() { Text::from(Span::styled( " No activity", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), )) } else { Text::from( lines .iter() - .map(|l| { - Line::from(Span::styled( - l.as_str(), - Style::default().fg(Color::DarkGray), - )) - }) + .map(|l| Line::from(Span::styled(l.as_str(), Style::default().fg(Color::Gray)))) .collect::>(), ) }; @@ -24,7 +19,7 @@ pub fn render(f: &mut Frame, area: Rect, lines: &[String]) { Block::default() .title(" Activity ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(Color::Gray)), ); f.render_widget(paragraph, area); } diff --git a/src/dashboard/widgets/config.rs b/src/dashboard/widgets/config.rs index 102fa3f..5b8e912 100644 --- a/src/dashboard/widgets/config.rs +++ b/src/dashboard/widgets/config.rs @@ -15,13 +15,13 @@ pub fn render( let Some(config) = config else { let msg = Paragraph::new(Span::styled( " No config loaded", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), )) .block( Block::default() .title(" Config ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(Color::Gray)), ); f.render_widget(msg, area); return; @@ -38,7 +38,7 @@ pub fn render( let inner = Block::default() .title(" Config ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); + .border_style(Style::default().fg(Color::Gray)); let inner_area = inner.inner(area); f.render_widget(inner, area); @@ -116,7 +116,7 @@ pub fn render( }; let style = if is_selected { - Style::default().fg(Color::White).bg(Color::DarkGray) + Style::default().fg(Color::White).bg(Color::Indexed(240)) } else { Style::default().fg(Color::White) }; @@ -146,7 +146,7 @@ fn render_list_edit(f: &mut Frame, area: Rect, le: &ListEditState) { let block = Block::default() .title(title) .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); + .border_style(Style::default().fg(Color::Gray)); let inner_area = block.inner(area); f.render_widget(block, area); @@ -157,18 +157,18 @@ fn render_list_edit(f: &mut Frame, area: Rect, le: &ListEditState) { // Header line with keybindings let header = Line::from(vec![ Span::styled(" Esc", Style::default().fg(Color::Yellow).bold()), - Span::styled(" back ", Style::default().fg(Color::DarkGray)), + Span::styled(" back ", Style::default().fg(Color::Gray)), Span::styled("a", Style::default().fg(Color::Yellow).bold()), - Span::styled(" add ", Style::default().fg(Color::DarkGray)), + Span::styled(" add ", Style::default().fg(Color::Gray)), Span::styled("d", Style::default().fg(Color::Yellow).bold()), - Span::styled(" delete", Style::default().fg(Color::DarkGray)), + Span::styled(" delete", Style::default().fg(Color::Gray)), if le.is_dotfile { Span::styled(" t", Style::default().fg(Color::Yellow).bold()) } else { Span::raw("") }, if le.is_dotfile { - Span::styled(" toggle create", Style::default().fg(Color::DarkGray)) + Span::styled(" toggle create", Style::default().fg(Color::Gray)) } else { Span::raw("") }, @@ -184,7 +184,7 @@ fn render_list_edit(f: &mut Frame, area: Rect, le: &ListEditState) { } let sep = "─".repeat(inner_area.width as usize); f.render_widget( - Paragraph::new(Span::styled(sep, Style::default().fg(Color::DarkGray))), + Paragraph::new(Span::styled(sep, Style::default().fg(Color::Gray))), Rect::new(inner_area.x, inner_area.y + 1, inner_area.width, 1), ); @@ -216,7 +216,7 @@ fn render_list_edit(f: &mut Frame, area: Rect, le: &ListEditState) { let is_selected = i == le.cursor; let style = if is_selected { - Style::default().fg(Color::White).bg(Color::DarkGray) + Style::default().fg(Color::White).bg(Color::Indexed(240)) } else { Style::default().fg(Color::White) }; diff --git a/src/dashboard/widgets/files.rs b/src/dashboard/widgets/files.rs index 48d1f27..5dfaf1f 100644 --- a/src/dashboard/widgets/files.rs +++ b/src/dashboard/widgets/files.rs @@ -345,14 +345,14 @@ pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, ft: &FilesTabSt let block = Block::default() .title(" Files ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); + .border_style(Style::default().fg(Color::Gray)); let inner_area = block.inner(area); f.render_widget(block, area); if rows.is_empty() { let msg = Paragraph::new(Span::styled( " No sync state", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), )); f.render_widget(msg, inner_area); return; @@ -375,7 +375,7 @@ pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, ft: &FilesTabSt let row_area = Rect::new(inner_area.x, y, inner_area.width, 1); let bg = if is_selected { - Color::DarkGray + Color::Indexed(240) } else { Color::Reset }; @@ -396,13 +396,13 @@ pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, ft: &FilesTabSt ), Span::styled( format!("({})", count), - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), ), ]; if !url.is_empty() { spans.push(Span::styled( format!(" {}", url), - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), )); } spans.push(Span::styled( @@ -436,7 +436,7 @@ pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, ft: &FilesTabSt let mut spans = vec![ Span::styled( format!(" {}", arrow), - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), ), badge, Span::styled(" ", Style::default().bg(bg)), @@ -445,15 +445,12 @@ pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, ft: &FilesTabSt if *shared { spans.push(Span::styled( " [shared]", - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), )); } if !time.is_empty() { spans.push(Span::styled(" ", Style::default().bg(bg))); - spans.push(Span::styled( - time, - Style::default().fg(Color::DarkGray).bg(bg), - )); + spans.push(Span::styled(time, Style::default().fg(Color::Gray).bg(bg))); } spans.push(Span::styled( " ".repeat(inner_area.width as usize), @@ -473,16 +470,16 @@ pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, ft: &FilesTabSt let line = Line::from(vec![ Span::styled( format!(" {} ", arrow), - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), ), Span::styled(short_hash, Style::default().fg(Color::Yellow).bg(bg).bold()), Span::styled( format!(" {:>12}", date), - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), ), Span::styled( format!(" {:15}", machine_id), - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), ), Span::styled( format!(" {}", message), @@ -501,11 +498,11 @@ pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, ft: &FilesTabSt let line = Line::from(vec![ Span::styled( format!(" {} ", arrow), - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), ), Span::styled( format!("Deleted ({})", count), - Style::default().fg(Color::DarkGray).bg(bg), + Style::default().fg(Color::Gray).bg(bg), ), Span::styled( " ".repeat(inner_area.width as usize), @@ -529,13 +526,13 @@ pub fn render(f: &mut Frame, area: Rect, state: &DashboardState, ft: &FilesTabSt let fg = if diff_line.starts_with("@@") { Color::Cyan } else if diff_line.starts_with("+++") || diff_line.starts_with("---") { - Color::DarkGray + Color::Gray } else if diff_line.starts_with('+') { Color::Green } else if diff_line.starts_with('-') { Color::Red } else { - Color::DarkGray + Color::Gray }; let line = Line::from(vec![ Span::styled(" ", Style::default().bg(bg)), @@ -560,7 +557,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState, scroll let items: Vec = if rows.is_empty() { vec![ListItem::new(Span::styled( " No sync state", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), ))] } else { rows.into_iter() @@ -572,12 +569,12 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState, scroll format!(" {} ", label), Style::default().fg(Color::Cyan).bold(), ), - Span::styled(format!("({})", count), Style::default().fg(Color::DarkGray)), + Span::styled(format!("({})", count), Style::default().fg(Color::Gray)), ]; if !url.is_empty() { spans.push(Span::styled( format!(" {}", url), - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), )); } ListItem::new(Line::from(spans)) @@ -598,7 +595,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState, scroll ]; if !time.is_empty() { spans.push(Span::raw(" ")); - spans.push(Span::styled(time, Style::default().fg(Color::DarkGray))); + spans.push(Span::styled(time, Style::default().fg(Color::Gray))); } ListItem::new(Line::from(spans)) } @@ -611,7 +608,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState, scroll Block::default() .title(" Dotfiles ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(Color::Gray)), ); f.render_widget(list, area); } diff --git a/src/dashboard/widgets/help.rs b/src/dashboard/widgets/help.rs index 6cd2db6..d1beaa0 100644 --- a/src/dashboard/widgets/help.rs +++ b/src/dashboard/widgets/help.rs @@ -4,44 +4,44 @@ use ratatui::{prelude::*, widgets::*}; pub fn render_bar(f: &mut Frame, area: Rect, active_tab: Tab) { let mut spans = vec![ Span::styled(" q", Style::default().fg(Color::Yellow).bold()), - Span::styled("uit ", Style::default().fg(Color::DarkGray)), + Span::styled("uit ", Style::default().fg(Color::Gray)), Span::styled("s", Style::default().fg(Color::Yellow).bold()), - Span::styled("ync ", Style::default().fg(Color::DarkGray)), + Span::styled("ync ", Style::default().fg(Color::Gray)), Span::styled("d", Style::default().fg(Color::Yellow).bold()), - Span::styled("aemon ", Style::default().fg(Color::DarkGray)), + Span::styled("aemon ", Style::default().fg(Color::Gray)), Span::styled("r", Style::default().fg(Color::Yellow).bold()), - Span::styled("efresh ", Style::default().fg(Color::DarkGray)), + Span::styled("efresh ", Style::default().fg(Color::Gray)), ]; match active_tab { Tab::Config => { spans.extend([ Span::styled("Enter", Style::default().fg(Color::Yellow).bold()), - Span::styled(" edit ", Style::default().fg(Color::DarkGray)), + Span::styled(" edit ", Style::default().fg(Color::Gray)), ]); } Tab::Packages => { spans.extend([ Span::styled("Enter", Style::default().fg(Color::Yellow).bold()), - Span::styled(" expand/uninstall ", Style::default().fg(Color::DarkGray)), + Span::styled(" expand/uninstall ", Style::default().fg(Color::Gray)), ]); } Tab::Machines => { spans.extend([ Span::styled("Enter", Style::default().fg(Color::Yellow).bold()), - Span::styled(" expand ", Style::default().fg(Color::DarkGray)), + Span::styled(" expand ", Style::default().fg(Color::Gray)), Span::styled("p", Style::default().fg(Color::Yellow).bold()), - Span::styled(" profile ", Style::default().fg(Color::DarkGray)), + Span::styled(" profile ", Style::default().fg(Color::Gray)), ]); } Tab::Files => { spans.extend([ Span::styled("Enter", Style::default().fg(Color::Yellow).bold()), - Span::styled(" expand/diff ", Style::default().fg(Color::DarkGray)), + Span::styled(" expand/diff ", Style::default().fg(Color::Gray)), Span::styled("t", Style::default().fg(Color::Yellow).bold()), - Span::styled(" shared ", Style::default().fg(Color::DarkGray)), + Span::styled(" shared ", Style::default().fg(Color::Gray)), Span::styled("R", Style::default().fg(Color::Yellow).bold()), - Span::styled("estore ", Style::default().fg(Color::DarkGray)), + Span::styled("estore ", Style::default().fg(Color::Gray)), ]); } _ => {} @@ -49,7 +49,7 @@ pub fn render_bar(f: &mut Frame, area: Rect, active_tab: Tab) { spans.extend([ Span::styled("?", Style::default().fg(Color::Yellow).bold()), - Span::styled(" help", Style::default().fg(Color::DarkGray)), + Span::styled(" help", Style::default().fg(Color::Gray)), ]); let paragraph = Paragraph::new(Line::from(spans)); diff --git a/src/dashboard/widgets/machines.rs b/src/dashboard/widgets/machines.rs index 96350d6..7dd2bf1 100644 --- a/src/dashboard/widgets/machines.rs +++ b/src/dashboard/widgets/machines.rs @@ -108,14 +108,14 @@ pub fn render( let block = Block::default() .title(" Machines ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); + .border_style(Style::default().fg(Color::Gray)); let inner_area = block.inner(area); f.render_widget(block, area); if rows.is_empty() { let msg = Paragraph::new(Span::styled( " No machines found", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), )); f.render_widget(msg, inner_area); return; @@ -153,9 +153,12 @@ pub fn render( let name_style = if is_selected { if *is_current { - Style::default().fg(Color::White).bg(Color::DarkGray).bold() + Style::default() + .fg(Color::White) + .bg(Color::Indexed(240)) + .bold() } else { - Style::default().fg(Color::White).bg(Color::DarkGray) + Style::default().fg(Color::White).bg(Color::Indexed(240)) } } else if *is_current { Style::default().fg(Color::White).bold() @@ -164,14 +167,17 @@ pub fn render( }; let bg_style = if is_selected { - Style::default().bg(Color::DarkGray) + Style::default().bg(Color::Indexed(240)) } else { Style::default() }; let marker_style = if *is_current { if is_selected { - Style::default().fg(Color::Green).bg(Color::DarkGray).bold() + Style::default() + .fg(Color::Green) + .bg(Color::Indexed(240)) + .bold() } else { Style::default().fg(Color::Green).bold() } @@ -180,9 +186,11 @@ pub fn render( }; let dim_style = if is_selected { - Style::default().fg(Color::DarkGray).bg(Color::DarkGray) + Style::default() + .fg(Color::Indexed(240)) + .bg(Color::Indexed(240)) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) }; let profile_span = if let Some(p) = profile { @@ -204,14 +212,16 @@ pub fn render( } MachineRow::Detail { label, value } => { let style = if is_selected { - Style::default().fg(Color::White).bg(Color::DarkGray) + Style::default().fg(Color::White).bg(Color::Indexed(240)) } else { Style::default().fg(Color::White) }; let label_style = if is_selected { - Style::default().fg(Color::DarkGray).bg(Color::DarkGray) + Style::default() + .fg(Color::Indexed(240)) + .bg(Color::Indexed(240)) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) }; let line = Line::from(vec![ Span::styled(format!(" {}: ", label), label_style), @@ -219,7 +229,7 @@ pub fn render( Span::styled( " ".repeat(inner_area.width as usize), if is_selected { - Style::default().bg(Color::DarkGray) + Style::default().bg(Color::Indexed(240)) } else { Style::default() }, @@ -244,7 +254,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState) { let items: Vec = if state.machines.is_empty() { vec![ListItem::new(Span::styled( " No machines found", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), ))] } else { state @@ -278,10 +288,10 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState) { Span::raw(" "), Span::styled( format!("{}f {}p", file_count, pkg_count), - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), ), Span::raw(" "), - Span::styled(time, Style::default().fg(Color::DarkGray)), + Span::styled(time, Style::default().fg(Color::Gray)), ])) }) .collect() @@ -291,7 +301,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState) { Block::default() .title(" Machines ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(Color::Gray)), ); f.render_widget(list, area); } diff --git a/src/dashboard/widgets/packages.rs b/src/dashboard/widgets/packages.rs index c59ffa0..503642c 100644 --- a/src/dashboard/widgets/packages.rs +++ b/src/dashboard/widgets/packages.rs @@ -68,14 +68,14 @@ pub fn render( let block = Block::default() .title(" Packages ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); + .border_style(Style::default().fg(Color::Gray)); let inner_area = block.inner(area); f.render_widget(block, area); if rows.is_empty() { let msg = Paragraph::new(Span::styled( " No package data for this machine", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), )); f.render_widget(msg, inner_area); return; @@ -110,12 +110,15 @@ pub fn render( ">" }; let style = if is_selected { - Style::default().fg(Color::Cyan).bg(Color::DarkGray).bold() + Style::default() + .fg(Color::Cyan) + .bg(Color::Indexed(240)) + .bold() } else { Style::default().fg(Color::Cyan).bold() }; let bg_style = if is_selected { - Style::default().bg(Color::DarkGray) + Style::default().bg(Color::Indexed(240)) } else { Style::default() }; @@ -124,9 +127,11 @@ pub fn render( Span::styled( format!("({})", count), if is_selected { - Style::default().fg(Color::DarkGray).bg(Color::DarkGray) + Style::default() + .fg(Color::Indexed(240)) + .bg(Color::Indexed(240)) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Gray) }, ), Span::styled(" ".repeat(inner_area.width as usize), bg_style), @@ -135,7 +140,7 @@ pub fn render( } PkgRow::Package { name, .. } => { let style = if is_selected { - Style::default().fg(Color::White).bg(Color::DarkGray) + Style::default().fg(Color::White).bg(Color::Indexed(240)) } else { Style::default().fg(Color::White) }; @@ -172,7 +177,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState) { if managers.is_empty() { vec![ListItem::new(Span::styled( " No packages tracked", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), ))] } else { managers @@ -184,7 +189,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState) { Span::raw(" "), Span::styled( format!("{} packages", packages.len()), - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), ), ])) }) @@ -193,7 +198,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState) { } None => vec![ListItem::new(Span::styled( " No package data", - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), ))], }; @@ -201,7 +206,7 @@ pub fn render_overview(f: &mut Frame, area: Rect, state: &DashboardState) { Block::default() .title(" Packages ") .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(Color::Gray)), ); f.render_widget(list, area); } diff --git a/src/dashboard/widgets/status.rs b/src/dashboard/widgets/status.rs index 595c6b8..8a77b23 100644 --- a/src/dashboard/widgets/status.rs +++ b/src/dashboard/widgets/status.rs @@ -76,7 +76,7 @@ pub fn render( } else if let Some(ref sync_state) = state.sync_state { spans.push(Span::styled( format!("last sync: {}", relative_time(sync_state.last_sync)), - Style::default().fg(Color::DarkGray), + Style::default().fg(Color::Gray), )); } @@ -126,7 +126,7 @@ pub fn render( let paragraph = Paragraph::new(Line::from(spans)).block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(Color::Gray)), ); f.render_widget(paragraph, area); } diff --git a/src/security/keychain.rs b/src/security/keychain.rs index 2a91893..ff33ff9 100644 --- a/src/security/keychain.rs +++ b/src/security/keychain.rs @@ -4,9 +4,6 @@ use std::fs; use std::io::{Read, Write}; use std::path::PathBuf; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; - const ENCRYPTED_KEY_FILENAME: &str = "encryption.key.age"; /// Get the path to the encrypted key in the sync repo @@ -57,20 +54,7 @@ fn cache_key(key: &[u8]) -> Result<()> { fs::create_dir_all(parent)?; } - // Write key with secure permissions (0o600 on Unix) - #[cfg(unix)] - { - use std::fs::OpenOptions; - let mut file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path)?; - file.write_all(key)?; - } - #[cfg(not(unix))] - fs::write(&path, key)?; + super::write_owner_only(&path, key)?; Ok(()) } diff --git a/src/security/mod.rs b/src/security/mod.rs index fe080ff..acdf0f0 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -3,6 +3,61 @@ pub mod keychain; pub mod recipients; pub mod secrets; +use anyhow::Result; +use std::path::Path; + +/// Write data to a file readable only by the current user. +/// Unix: mode 0o600. Windows: inherits parent ACL then restricts to current user via icacls. +pub fn write_owner_only(path: &Path, data: &[u8]) -> Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::fs::PermissionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(data)?; + // mode() only applies on creation; fix permissions for pre-existing files + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + #[cfg(windows)] + { + // TOCTOU: file is briefly world-readable before icacls runs. + // Fixing requires CreateFile with SECURITY_ATTRIBUTES via Win32 API. + std::fs::write(path, data)?; + let username = std::env::var("USERNAME") + .map_err(|_| anyhow::anyhow!("USERNAME not set; cannot secure file permissions"))?; + let path_str = path.to_string_lossy(); + // Strip inherited ACLs, then grant full control to current user only + let status = std::process::Command::new("icacls") + .args([ + &*path_str, + "/inheritance:r", + "/grant", + &format!("{}:(F)", username), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + match status { + Ok(s) if s.success() => {} + _ => { + std::fs::remove_file(path).ok(); + anyhow::bail!("Failed to restrict file permissions on {}", path.display()); + } + } + } + #[cfg(not(any(unix, windows)))] + { + std::fs::write(path, data)?; + } + Ok(()) +} + pub use encryption::{decrypt, encrypt, generate_key}; pub use keychain::{ clear_cached_key, get_encryption_key, has_encryption_key, is_unlocked, @@ -11,6 +66,6 @@ pub use keychain::{ pub use recipients::{ clear_cached_identity, decrypt_with_identity, encrypt_to_recipients, generate_identity, get_public_key, get_public_key_from_identity, has_identity, is_identity_unlocked, - load_identity, load_recipients, store_identity, validate_pubkey, + load_identity, load_recipients, load_recipients_authorized, store_identity, validate_pubkey, }; pub use secrets::{scan_for_secrets, SecretFinding, SecretType}; diff --git a/src/security/recipients.rs b/src/security/recipients.rs index 9cbd526..d096fe2 100644 --- a/src/security/recipients.rs +++ b/src/security/recipients.rs @@ -4,9 +4,6 @@ use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; - const IDENTITY_FILENAME: &str = "identity.age"; const PUBKEY_FILENAME: &str = "identity.pub"; @@ -57,18 +54,7 @@ pub fn store_identity(identity: &age::x25519::Identity, passphrase: &str) -> Res fs::create_dir_all(parent)?; } - #[cfg(unix)] - { - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path)?; - file.write_all(&encrypted)?; - } - #[cfg(not(unix))] - fs::write(&path, &encrypted)?; + super::write_owner_only(&path, &encrypted)?; // Also store public key for easy sharing let pubkey = identity.to_public().to_string(); @@ -131,18 +117,7 @@ fn cache_identity(identity: &age::x25519::Identity) -> Result<()> { let identity_str = identity.to_string(); - #[cfg(unix)] - { - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path)?; - file.write_all(identity_str.expose_secret().as_bytes())?; - } - #[cfg(not(unix))] - fs::write(&path, identity_str.expose_secret())?; + super::write_owner_only(&path, identity_str.expose_secret().as_bytes())?; Ok(()) } @@ -185,16 +160,45 @@ pub fn get_public_key() -> Result { /// Load recipients from a team's recipients directory pub fn load_recipients(recipients_dir: &Path) -> Result> { + let (recipients, _) = load_recipients_filtered(recipients_dir, &[])?; + Ok(recipients) +} + +/// Load recipients filtered by an authorized list. +/// Returns (included recipients, skipped usernames). +/// If authorized list is empty, loads all recipients. +pub fn load_recipients_authorized( + recipients_dir: &Path, + authorized: &[String], +) -> Result<(Vec, Vec)> { + load_recipients_filtered(recipients_dir, authorized) +} + +fn load_recipients_filtered( + recipients_dir: &Path, + authorized: &[String], +) -> Result<(Vec, Vec)> { let mut recipients = Vec::new(); + let mut skipped = Vec::new(); if !recipients_dir.exists() { - return Ok(recipients); + return Ok((recipients, skipped)); } for entry in fs::read_dir(recipients_dir)? { let entry = entry?; let path = entry.path(); if path.extension().is_some_and(|e| e == "pub") { + if !authorized.is_empty() { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + if !authorized.iter().any(|a| a.eq_ignore_ascii_case(stem)) { + skipped.push(stem.to_string()); + continue; + } + } let pubkey = fs::read_to_string(&path)?; let recipient: age::x25519::Recipient = pubkey .trim() @@ -204,7 +208,7 @@ pub fn load_recipients(recipients_dir: &Path) -> Result