328 lines
10 KiB
Rust
328 lines
10 KiB
Rust
//! Secret-safe clipboard lifecycle and platform-neutral QR presentation.
|
|
|
|
use std::{error::Error, fmt, time::Duration};
|
|
|
|
use qrcode::{EcLevel, QrCode, types::Color};
|
|
use zeroize::Zeroize as _;
|
|
|
|
use crate::repository::SecretBytes;
|
|
|
|
mod platform;
|
|
|
|
pub const DEFAULT_CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(45);
|
|
pub const MAX_CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
|
const QR_QUIET_ZONE: usize = 4;
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct ClipboardTimeout(Duration);
|
|
|
|
impl ClipboardTimeout {
|
|
pub fn new(duration: Duration) -> Result<Self, ClipboardError> {
|
|
if duration.is_zero() || duration > MAX_CLIPBOARD_TIMEOUT {
|
|
return Err(ClipboardError::InvalidTimeout);
|
|
}
|
|
Ok(Self(duration))
|
|
}
|
|
|
|
pub const fn pass_default() -> Self {
|
|
Self(DEFAULT_CLIPBOARD_TIMEOUT)
|
|
}
|
|
|
|
pub const fn duration(self) -> Duration {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl Default for ClipboardTimeout {
|
|
fn default() -> Self {
|
|
Self::pass_default()
|
|
}
|
|
}
|
|
|
|
pub enum ClipboardContent {
|
|
Text(SecretBytes),
|
|
EmptyOrNonText,
|
|
}
|
|
|
|
impl ClipboardContent {
|
|
pub fn text(value: Vec<u8>) -> Self {
|
|
Self::Text(SecretBytes::new(value))
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for ClipboardContent {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::Text(_) => formatter.write_str("ClipboardContent::Text([REDACTED])"),
|
|
Self::EmptyOrNonText => formatter.write_str("ClipboardContent::EmptyOrNonText"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Byte-oriented adapter contract for native clipboards and deterministic tests.
|
|
pub trait ClipboardBackend {
|
|
fn read(&mut self) -> Result<ClipboardContent, ClipboardError>;
|
|
fn write(&mut self, value: &SecretBytes) -> Result<(), ClipboardError>;
|
|
fn clear(&mut self) -> Result<(), ClipboardError>;
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ClipboardWait {
|
|
Elapsed,
|
|
Cancelled,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ClipboardDisposition {
|
|
RestoredPrevious,
|
|
Cleared,
|
|
PreservedNewer,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ClipboardError {
|
|
InvalidTimeout,
|
|
EmptySecret,
|
|
NonUtf8,
|
|
Unavailable,
|
|
ReadFailed,
|
|
WriteFailed,
|
|
CleanupFailed,
|
|
Cancelled,
|
|
}
|
|
|
|
impl fmt::Display for ClipboardError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let message = match self {
|
|
Self::InvalidTimeout => "the clipboard timeout is invalid",
|
|
Self::EmptySecret => "empty data cannot be presented on the clipboard",
|
|
Self::NonUtf8 => "the native clipboard accepts only UTF-8 text",
|
|
Self::Unavailable => "the native clipboard is unavailable",
|
|
Self::ReadFailed => "the native clipboard could not be read",
|
|
Self::WriteFailed => "the native clipboard could not be written",
|
|
Self::CleanupFailed => "the clipboard secret could not be cleaned up safely",
|
|
Self::Cancelled => "clipboard presentation was cancelled",
|
|
};
|
|
formatter.write_str(message)
|
|
}
|
|
}
|
|
|
|
impl Error for ClipboardError {}
|
|
|
|
pub struct ClipboardManager<B> {
|
|
backend: B,
|
|
timeout: ClipboardTimeout,
|
|
}
|
|
|
|
impl<B> fmt::Debug for ClipboardManager<B> {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter
|
|
.debug_struct("ClipboardManager")
|
|
.field("timeout", &self.timeout)
|
|
.field("contents", &"[REDACTED]")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl<B: ClipboardBackend> ClipboardManager<B> {
|
|
pub fn new(backend: B, timeout: ClipboardTimeout) -> Self {
|
|
Self { backend, timeout }
|
|
}
|
|
|
|
pub fn timeout(&self) -> ClipboardTimeout {
|
|
self.timeout
|
|
}
|
|
|
|
/// Copy a secret, wait under caller-controlled scheduling, and then clean up.
|
|
///
|
|
/// Cleanup restores the previous UTF-8 value (or clears a previous non-text
|
|
/// value) only while the copied secret is still current. A newer clipboard
|
|
/// value is never overwritten.
|
|
pub fn copy_with(
|
|
&mut self,
|
|
value: &SecretBytes,
|
|
wait: impl FnOnce(Duration) -> ClipboardWait,
|
|
) -> Result<ClipboardDisposition, ClipboardError> {
|
|
if value.expose().is_empty() {
|
|
return Err(ClipboardError::EmptySecret);
|
|
}
|
|
let previous = self.backend.read()?;
|
|
self.backend.write(value)?;
|
|
let wait_result = wait(self.timeout.duration());
|
|
let disposition = self.cleanup(value, &previous)?;
|
|
if wait_result == ClipboardWait::Cancelled {
|
|
return Err(ClipboardError::Cancelled);
|
|
}
|
|
Ok(disposition)
|
|
}
|
|
|
|
fn cleanup(
|
|
&mut self,
|
|
copied: &SecretBytes,
|
|
previous: &ClipboardContent,
|
|
) -> Result<ClipboardDisposition, ClipboardError> {
|
|
let current = self
|
|
.backend
|
|
.read()
|
|
.map_err(|_| ClipboardError::CleanupFailed)?;
|
|
let ClipboardContent::Text(current) = current else {
|
|
return Ok(ClipboardDisposition::PreservedNewer);
|
|
};
|
|
if current.expose() != copied.expose() {
|
|
return Ok(ClipboardDisposition::PreservedNewer);
|
|
}
|
|
match previous {
|
|
ClipboardContent::Text(previous) => self
|
|
.backend
|
|
.write(previous)
|
|
.map(|()| ClipboardDisposition::RestoredPrevious)
|
|
.map_err(|_| ClipboardError::CleanupFailed),
|
|
ClipboardContent::EmptyOrNonText => self
|
|
.backend
|
|
.clear()
|
|
.map(|()| ClipboardDisposition::Cleared)
|
|
.map_err(|_| ClipboardError::CleanupFailed),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type NativeClipboardManager = ClipboardManager<platform::NativeClipboardBackend>;
|
|
|
|
impl NativeClipboardManager {
|
|
pub fn system(timeout: ClipboardTimeout) -> Result<Self, ClipboardError> {
|
|
Ok(Self::new(platform::NativeClipboardBackend::new()?, timeout))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum QrError {
|
|
EmptyPayload,
|
|
PayloadTooLarge,
|
|
InvalidImage,
|
|
NotFound,
|
|
InvalidPayload,
|
|
}
|
|
|
|
impl fmt::Display for QrError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::EmptyPayload => formatter.write_str("empty data cannot be encoded as a QR code"),
|
|
Self::PayloadTooLarge => formatter.write_str("the QR payload is too large"),
|
|
Self::InvalidImage => formatter.write_str("the selected file is not a supported image"),
|
|
Self::NotFound => formatter.write_str("the image does not contain a QR code"),
|
|
Self::InvalidPayload => formatter.write_str("the QR code contains invalid text"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Error for QrError {}
|
|
|
|
/// A secret-derived, zeroizing QR symbol without presentation-platform choices.
|
|
pub struct QrMatrix {
|
|
width: usize,
|
|
modules: Vec<u8>,
|
|
}
|
|
|
|
impl QrMatrix {
|
|
pub fn encode(payload: &SecretBytes) -> Result<Self, QrError> {
|
|
if payload.expose().is_empty() {
|
|
return Err(QrError::EmptyPayload);
|
|
}
|
|
let code = QrCode::with_error_correction_level(payload.expose(), EcLevel::L)
|
|
.map_err(|_| QrError::PayloadTooLarge)?;
|
|
let width = code.width();
|
|
let modules = code
|
|
.into_colors()
|
|
.into_iter()
|
|
.map(|color| u8::from(color == Color::Dark))
|
|
.collect();
|
|
Ok(Self { width, modules })
|
|
}
|
|
|
|
/// Decode the first QR symbol from an encoded image without exposing the
|
|
/// secret-derived payload to a presentation adapter.
|
|
pub fn decode_image(image: &SecretBytes) -> Result<SecretBytes, QrError> {
|
|
let grayscale = image::load_from_memory(image.expose())
|
|
.map_err(|_| QrError::InvalidImage)?
|
|
.into_luma8();
|
|
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(
|
|
grayscale.width() as usize,
|
|
grayscale.height() as usize,
|
|
|x, y| grayscale.get_pixel(x as u32, y as u32).0[0],
|
|
);
|
|
let grid = prepared
|
|
.detect_grids()
|
|
.into_iter()
|
|
.next()
|
|
.ok_or(QrError::NotFound)?;
|
|
let (_, payload) = grid.decode().map_err(|_| QrError::InvalidPayload)?;
|
|
Ok(SecretBytes::new(payload.into_bytes()))
|
|
}
|
|
|
|
pub fn width(&self) -> usize {
|
|
self.width
|
|
}
|
|
|
|
pub fn is_dark(&self, x: usize, y: usize) -> Option<bool> {
|
|
if x >= self.width || y >= self.width {
|
|
return None;
|
|
}
|
|
Some(self.modules[y * self.width + x] != 0)
|
|
}
|
|
|
|
/// Render a QR symbol with the standard four-module quiet zone and square
|
|
/// terminal cells. The returned bytes are secret-derived and zeroize on drop.
|
|
pub fn render_terminal(&self) -> SecretBytes {
|
|
let padded_width = self.width + 2 * QR_QUIET_ZONE;
|
|
let padded_height = padded_width.next_multiple_of(2);
|
|
let mut rendered = String::with_capacity(padded_width * padded_height * 2);
|
|
for y in (0..padded_height).step_by(2) {
|
|
for x in 0..padded_width {
|
|
let top = self.padded_module(x, y);
|
|
let bottom = self.padded_module(x, y + 1);
|
|
let cell = match (top, bottom) {
|
|
(true, true) => '█',
|
|
(true, false) => '▀',
|
|
(false, true) => '▄',
|
|
(false, false) => ' ',
|
|
};
|
|
rendered.push(cell);
|
|
rendered.push(cell);
|
|
}
|
|
rendered.push('\n');
|
|
}
|
|
SecretBytes::new(rendered.into_bytes())
|
|
}
|
|
|
|
fn padded_module(&self, x: usize, y: usize) -> bool {
|
|
let Some(x) = x.checked_sub(QR_QUIET_ZONE) else {
|
|
return false;
|
|
};
|
|
let Some(y) = y.checked_sub(QR_QUIET_ZONE) else {
|
|
return false;
|
|
};
|
|
self.is_dark(x, y).unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
impl Clone for QrMatrix {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
width: self.width,
|
|
modules: self.modules.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl fmt::Debug for QrMatrix {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str("QrMatrix([REDACTED])")
|
|
}
|
|
}
|
|
|
|
impl Drop for QrMatrix {
|
|
fn drop(&mut self) {
|
|
self.modules.zeroize();
|
|
}
|
|
}
|