Harden lock UI inputs and decoding and reduce idle clock updates
This commit is contained in:
1 parent
8ad5293178
commit
b45917c41d
4 files changed
+150
-23
No files matched your search
+18
-2
@@ -53,8 +53,24 @@ instead of powering off the host display.
|
|||||||
## Recovery and limits
|
## Recovery and limits
|
||||||
|
|
||||||
The lock covers all displays, including newly connected ones. If the lock UI
|
The lock covers all displays, including newly connected ones. If the lock UI
|
||||||
crashes, the compositor keeps the session protected. Recovery may require ending
|
crashes, the compositor keeps the session protected and permits a replacement
|
||||||
that session from another TTY; see [Recovery](installation.md#logout-and-recovery).
|
locker to take over. A running locker cannot be replaced. Launch `ferese-lock`
|
||||||
|
with the affected session's Wayland environment to recover; ending the session
|
||||||
|
from another TTY remains an option (see [Recovery](installation.md#logout-and-recovery)).
|
||||||
|
|
||||||
|
Readiness is reported only after protocol confirmation: every connected output
|
||||||
|
has presented protected content, or there are no outputs. The pinned Iced runtime
|
||||||
|
also emits a synthetic `Locked` event when making the request. The locker retains
|
||||||
|
its two-event guard until upstream distinguishes request initiation from protocol
|
||||||
|
confirmation; dependency upgrades must re-audit this workaround.
|
||||||
|
|
||||||
|
Password messages use zeroizing storage. Avatar files are limited to 8 MiB,
|
||||||
|
4096 pixels per dimension, and a 32 MiB decoding budget. The clock updates at
|
||||||
|
minute boundaries; authentication retry deadlines are independent.
|
||||||
|
|
||||||
|
When restricted clients are introduced, their registry must omit
|
||||||
|
`ext_session_lock_manager_v1`. This restriction is deferred until that client
|
||||||
|
classification exists.
|
||||||
|
|
||||||
The password interface does not support password changes or interactive
|
The password interface does not support password changes or interactive
|
||||||
multi-factor prompts. Update expired credentials outside the locker.
|
multi-factor prompts. Update expired credentials outside the locker.
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ fn account_picture(user: &str) -> Option<image::Handle> {
|
|||||||
candidates.push(std::path::PathBuf::from(home).join(".face"));
|
candidates.push(std::path::PathBuf::from(home).join(".face"));
|
||||||
}
|
}
|
||||||
candidates.into_iter().find_map(|path| {
|
candidates.into_iter().find_map(|path| {
|
||||||
let pixels = cosmic::iced::advanced::graphics::image::load(&image::Handle::from_path(path)).ok()?;
|
let pixels = decode_avatar(&path).ok()?;
|
||||||
let side = pixels.width().min(pixels.height());
|
let side = pixels.width().min(pixels.height());
|
||||||
if side == 0 {
|
if side == 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -169,6 +169,50 @@ fn account_picture(user: &str) -> Option<image::Handle> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_avatar(path: &std::path::Path) -> Result<::image::RgbaImage, String> {
|
||||||
|
use std::io::Read;
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
|
use ::image::ImageDecoder;
|
||||||
|
|
||||||
|
const ENCODED_LIMIT: u64 = 8 * 1024 * 1024;
|
||||||
|
const DECODED_LIMIT: u64 = 32 * 1024 * 1024;
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NONBLOCK)
|
||||||
|
.open(path)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if !file.metadata().map_err(|error| error.to_string())?.is_file() {
|
||||||
|
return Err("Avatar is not a regular file".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
file.take(ENCODED_LIMIT + 1)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
if bytes.len() as u64 > ENCODED_LIMIT {
|
||||||
|
return Err("Avatar exceeds the file limit".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reader = ::image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let mut limits = ::image::Limits::default();
|
||||||
|
limits.max_image_width = Some(4096);
|
||||||
|
limits.max_image_height = Some(4096);
|
||||||
|
limits.max_alloc = Some(DECODED_LIMIT);
|
||||||
|
reader.limits(limits);
|
||||||
|
let decoder = reader.into_decoder().map_err(|error| error.to_string())?;
|
||||||
|
let (width, height) = decoder.dimensions();
|
||||||
|
if width == 0 || height == 0 || u64::from(width) * u64::from(height) * 4 > DECODED_LIMIT {
|
||||||
|
return Err("Avatar exceeds the decode limit".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
::image::DynamicImage::from_decoder(decoder)
|
||||||
|
.map(|image| image.into_rgba8())
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle, String> {
|
pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle, String> {
|
||||||
use ::image::ImageDecoder;
|
use ::image::ImageDecoder;
|
||||||
let mut reader = ::image::ImageReader::open(path)
|
let mut reader = ::image::ImageReader::open(path)
|
||||||
@@ -201,6 +245,23 @@ pub fn load_wallpaper(path: &std::path::Path, blur: f32) -> Result<image::Handle
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn avatar_decoding_bounds_dimensions_and_encoded_size() {
|
||||||
|
let file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
::image::RgbaImage::new(3, 2)
|
||||||
|
.save_with_format(file.path(), ::image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decode_avatar(file.path()).unwrap().dimensions(), (3, 2));
|
||||||
|
|
||||||
|
::image::RgbaImage::new(4097, 1)
|
||||||
|
.save_with_format(file.path(), ::image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
assert!(decode_avatar(file.path()).is_err());
|
||||||
|
|
||||||
|
file.as_file().set_len(8 * 1024 * 1024 + 1).unwrap();
|
||||||
|
assert!(decode_avatar(file.path()).unwrap_err().contains("file limit"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn portal_wallpaper_loads_without_a_filename_extension() {
|
fn portal_wallpaper_loads_without_a_filename_extension() {
|
||||||
let file = tempfile::NamedTempFile::new().unwrap();
|
let file = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
|||||||
@@ -85,10 +85,11 @@ enum Message {
|
|||||||
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
ThemeChanged(Box<ferese_config::theme::Snapshot>),
|
||||||
WallpaperLoaded(u64, Result<widget::image::Handle, String>),
|
WallpaperLoaded(u64, Result<widget::image::Handle, String>),
|
||||||
Event(Box<Event>),
|
Event(Box<Event>),
|
||||||
Input(String),
|
Input(Zeroizing<String>),
|
||||||
Submit,
|
Submit,
|
||||||
Authenticated(bool),
|
Authenticated(bool),
|
||||||
Tick,
|
Tick,
|
||||||
|
RetryReady,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never derive Debug for password-bearing messages.
|
// Never derive Debug for password-bearing messages.
|
||||||
@@ -203,7 +204,17 @@ impl cosmic::Application for Locker {
|
|||||||
| Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => Some(Message::Event(Box::new(event))),
|
| Event::Keyboard(iced::keyboard::Event::ModifiersChanged(_)) => Some(Message::Event(Box::new(event))),
|
||||||
_ => None,
|
_ => None,
|
||||||
}),
|
}),
|
||||||
iced::time::every(Duration::from_secs(1)).map(|_| Message::Tick),
|
if self.appearance.show_clock || self.appearance.show_date {
|
||||||
|
Subscription::run(runtime::minute_ticks).map(|_| Message::Tick)
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
},
|
||||||
|
match self.state {
|
||||||
|
AuthState::Rejected(at) => {
|
||||||
|
Subscription::run_with(at, runtime::retry_deadline).map(|_| Message::RetryReady)
|
||||||
|
}
|
||||||
|
_ => Subscription::none(),
|
||||||
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,18 +271,18 @@ impl cosmic::Application for Locker {
|
|||||||
let now = jiff::Zoned::now();
|
let now = jiff::Zoned::now();
|
||||||
self.clock = now.strftime(self.appearance.clock_format()).to_string();
|
self.clock = now.strftime(self.appearance.clock_format()).to_string();
|
||||||
self.date = now.strftime("%A, %B %-d").to_string();
|
self.date = now.strftime("%A, %B %-d").to_string();
|
||||||
|
}
|
||||||
|
Message::RetryReady => {
|
||||||
if matches!(self.state, AuthState::Rejected(at) if at.elapsed() >= Duration::from_secs(2)) {
|
if matches!(self.state, AuthState::Rejected(at) if at.elapsed() >= Duration::from_secs(2)) {
|
||||||
self.state = AuthState::Ready;
|
self.state = AuthState::Ready;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::Input(mut value) => {
|
Message::Input(value) => {
|
||||||
if self.state.may_submit() && value.len() <= 4096 {
|
if self.state.may_submit() && value.len() <= 4096 {
|
||||||
self.password.zeroize();
|
self.password.zeroize();
|
||||||
*self.password = std::mem::take(&mut value);
|
self.password = value;
|
||||||
self.status = "Press Enter to unlock";
|
self.status = "Press Enter to unlock";
|
||||||
}
|
}
|
||||||
value.zeroize();
|
|
||||||
}
|
}
|
||||||
Message::Submit => {
|
Message::Submit => {
|
||||||
if self.preview {
|
if self.preview {
|
||||||
@@ -390,31 +401,34 @@ impl Locker {
|
|||||||
|
|
||||||
fn screen_content(&self, size: iced::Size) -> Element<'_, Message> {
|
fn screen_content(&self, size: iced::Size) -> Element<'_, Message> {
|
||||||
let avatar_size = (size.height * 0.08).clamp(56., 88.);
|
let avatar_size = (size.height * 0.08).clamp(56., 88.);
|
||||||
let clock_size = (size.height * 0.165)
|
let clock_size = (size.height * 0.06)
|
||||||
.min(size.width * if self.appearance.twelve_hour { 0.075 } else { 0.12 })
|
.min(size.width * if self.appearance.twelve_hour { 0.055 } else { 0.085 })
|
||||||
.clamp(40., 180.) as u16;
|
.clamp(32., 72.) as u16;
|
||||||
let margin = if size.width < 640. { 24. } else { 36. };
|
let margin = if size.width < 640. { 24. } else { 36. };
|
||||||
let top = (size.height * 0.09).max(24.);
|
let top = (size.height * 0.09).max(24.);
|
||||||
let a = &self.appearance;
|
let a = &self.appearance;
|
||||||
let mut input = widget::text_input("Password", self.password.as_str())
|
let mut input = widget::text_input("Password", self.password.as_str())
|
||||||
.password()
|
.password()
|
||||||
.padding([6, 12])
|
.padding([4, 10])
|
||||||
|
.size(14)
|
||||||
.style(ferese_theme::controls::lock_input(a.radius, a.accent, a.panel))
|
.style(ferese_theme::controls::lock_input(a.radius, a.accent, a.panel))
|
||||||
.font(a.font)
|
.font(a.font)
|
||||||
.id(widget::Id::new("password"));
|
.id(widget::Id::new("password"));
|
||||||
|
|
||||||
if self.state.may_submit() {
|
if self.state.may_submit() {
|
||||||
input = input.on_input(Message::Input).on_submit(|mut value| {
|
input = input
|
||||||
value.zeroize();
|
.on_input(|value| Message::Input(Zeroizing::new(value)))
|
||||||
Message::Submit
|
.on_submit(|value| {
|
||||||
});
|
drop(Zeroizing::new(value));
|
||||||
|
Message::Submit
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let submit = widget::button::custom(widget::icon::from_name("go-next-symbolic").size(20))
|
let submit = widget::button::custom(widget::icon::from_name("go-next-symbolic").size(16))
|
||||||
.class(theme::Button::Icon)
|
.class(theme::Button::Icon)
|
||||||
.padding(6)
|
.padding(4)
|
||||||
.on_press_maybe(self.state.may_submit().then_some(Message::Submit));
|
.on_press_maybe(self.state.may_submit().then_some(Message::Submit));
|
||||||
input = input.trailing_icon(container(submit).padding([0, 6]).into());
|
input = input.trailing_icon(container(submit).padding([0, 4]).into());
|
||||||
let avatar: Element<'_, Message> = if let Some(photo) = &a.avatar {
|
let avatar: Element<'_, Message> = if let Some(photo) = &a.avatar {
|
||||||
image(photo.clone())
|
image(photo.clone())
|
||||||
.width(avatar_size)
|
.width(avatar_size)
|
||||||
@@ -457,8 +471,8 @@ impl Locker {
|
|||||||
.spacing(12)
|
.spacing(12)
|
||||||
.align_x(iced::Alignment::Center)
|
.align_x(iced::Alignment::Center)
|
||||||
.width(
|
.width(
|
||||||
(size.height * 0.31)
|
(size.height * 0.23)
|
||||||
.clamp(240., 320.)
|
.clamp(200., 240.)
|
||||||
.min((size.width - margin * 2.).max(160.)),
|
.min((size.width - margin * 2.).max(160.)),
|
||||||
);
|
);
|
||||||
let mut clock = column([]).spacing(0).align_x(iced::Alignment::Center);
|
let mut clock = column([]).spacing(0).align_x(iced::Alignment::Center);
|
||||||
@@ -559,6 +573,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn password_messages_are_redacted() {
|
fn password_messages_are_redacted() {
|
||||||
assert!(!format!("{:?}", Message::Input("secret".into())).contains("secret"));
|
assert!(!format!("{:?}", Message::Input(Zeroizing::new("secret".into()))).contains("secret"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,3 +93,39 @@ mod tests {
|
|||||||
assert!(!c.observe());
|
assert!(!c.observe());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn until_next_minute(since_epoch: Duration) -> Duration {
|
||||||
|
Duration::from_secs(60) - Duration::new(since_epoch.as_secs() % 60, since_epoch.subsec_nanos())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn minute_ticks() -> impl cosmic::iced::futures::Stream<Item = ()> {
|
||||||
|
cosmic::iced::futures::stream::unfold((), |()| async {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
tokio::time::sleep(until_next_minute(now)).await;
|
||||||
|
Some(((), ()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retry_deadline(at: &std::time::Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
|
||||||
|
let at = *at;
|
||||||
|
cosmic::iced::futures::stream::once(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(2).saturating_sub(at.elapsed())).await;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod clock_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn minute_deadline_uses_the_wall_clock_boundary() {
|
||||||
|
assert_eq!(until_next_minute(Duration::ZERO), Duration::from_secs(60));
|
||||||
|
assert_eq!(
|
||||||
|
until_next_minute(Duration::from_millis(119_999)),
|
||||||
|
Duration::from_millis(1)
|
||||||
|
);
|
||||||
|
assert_eq!(until_next_minute(Duration::from_secs(125)), Duration::from_secs(55));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user