// server/src/paste.rs #![forbid(unsafe_code)] use anyhow::{Context, Result}; use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use std::path::PathBuf; use tokio::fs::File; use tokio::io::AsyncWriteExt; use tokio::sync::Mutex; use lesavka_common::hid::{append_char_reports, char_to_usage}; use lesavka_common::lesavka::PasteRequest; use lesavka_common::paste::decode_shared_key; /// Decrypt a `PasteRequest` sent by the desktop client. /// /// Inputs: the protobuf request carrying the encrypted payload and nonce. /// Outputs: the decoded UTF-8 clipboard text, or an error when validation or /// decryption fails. /// Why: the server must reject plaintext payloads so clipboard injection is /// never silently downgraded to an unencrypted transport. pub fn decrypt(req: &PasteRequest) -> Result { if !req.encrypted { anyhow::bail!("paste request must be encrypted"); } let key = load_key()?; let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); let nonce = Nonce::from_slice(&req.nonce); let plaintext = cipher .decrypt(nonce, req.data.as_ref()) .map_err(|e| anyhow::anyhow!("paste decrypt failed: {e}"))?; String::from_utf8(plaintext).context("paste plaintext not UTF-8") } /// Type clipboard text into the HID keyboard gadget. /// /// Inputs: the HID keyboard file handle plus the plaintext that should be /// injected into the remote machine. /// Outputs: `Ok(())` after emitting key press/release reports for every /// supported character up to the configured maximum. /// Why: paste injection must rate-limit itself so slower hosts do not drop /// HID reports under bursty clipboard loads. pub async fn type_text(kb: &Mutex, text: &str) -> Result<()> { let max = std::env::var("LESAVKA_PASTE_MAX") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(4096); let delay_ms = std::env::var("LESAVKA_PASTE_DELAY_MS") .ok() .and_then(|v| v.parse::().ok()) .unwrap_or(8); let delay = std::time::Duration::from_millis(delay_ms); let (unsupported_count, unsupported_preview) = unsupported_chars(text.chars().take(max)); if unsupported_count > 0 { anyhow::bail!( "paste contains {unsupported_count} unsupported character(s): {unsupported_preview}" ); } let mut kb = kb.lock().await; for c in text.chars().take(max) { let mut reports = Vec::with_capacity(4); if append_char_reports(&mut reports, c) { for report in reports { kb.write_all(&report).await?; if delay_ms > 0 { tokio::time::sleep(delay).await; } } } } Ok(()) } /// Summarize unsupported clipboard characters for explicit operator feedback. fn unsupported_chars(chars: impl Iterator) -> (usize, String) { let mut count = 0usize; let mut preview = Vec::new(); for ch in chars { if char_to_usage(ch).is_some() { continue; } count += 1; if preview.len() < 6 { preview.push(format!("{:?}", ch)); } } if count == 0 { (0, String::new()) } else { (count, preview.join(", ")) } } fn load_key() -> Result<[u8; 32]> { let raw = load_key_material()?; decode_shared_key(&raw) } fn load_key_material() -> Result { if let Some(raw) = std::env::var("LESAVKA_PASTE_KEY") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) { return Ok(raw); } let path = std::env::var("LESAVKA_PASTE_KEY_FILE") .ok() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("/etc/lesavka/paste-key")); let raw = std::fs::read_to_string(&path) .with_context(|| format!("reading paste key file {}", path.display()))?; let trimmed = raw.trim().to_string(); if trimmed.is_empty() { anyhow::bail!("paste key file {} is empty", path.display()); } Ok(trimmed) } #[cfg(test)] mod tests { use super::{decrypt, type_text, unsupported_chars}; use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use lesavka_common::lesavka::PasteRequest; use serial_test::serial; use temp_env::with_var; use tempfile::tempdir; use tokio::fs::{File, OpenOptions}; use tokio::io::AsyncReadExt; use tokio::runtime::Runtime; use tokio::sync::Mutex; #[test] #[serial] fn decrypt_rejects_plaintext_requests() { let req = PasteRequest { nonce: vec![], data: vec![], encrypted: false, }; let err = decrypt(&req).expect_err("plaintext must fail"); assert!(err.to_string().contains("encrypted")); } #[test] #[serial] fn decrypt_round_trips_encrypted_payload() { let key = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; with_var("LESAVKA_PASTE_KEY", Some(key), || { let raw_key = lesavka_common::paste::decode_shared_key(key).expect("decode key"); let cipher = ChaCha20Poly1305::new(Key::from_slice(&raw_key)); let nonce_bytes = [0x11u8; 12]; let nonce = Nonce::from_slice(&nonce_bytes); let data = cipher .encrypt(nonce, b"secret paste".as_ref()) .expect("encrypt"); let req = PasteRequest { nonce: nonce_bytes.to_vec(), data, encrypted: true, }; assert_eq!(decrypt(&req).expect("decrypt"), "secret paste"); }); } #[test] #[serial] fn type_text_writes_reports_for_supported_chars() { let rt = Runtime::new().expect("runtime"); with_var("LESAVKA_PASTE_DELAY_MS", Some("0"), || { rt.block_on(async { let dir = tempdir().expect("tempdir"); let path = dir.path().join("hidg0.bin"); let file = OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&path) .await .expect("open temp file"); let kb = Mutex::new(file); type_text(&kb, "A!?").await.expect("type text"); let mut bytes = Vec::new(); let mut file = File::open(&path).await.expect("reopen temp file"); file.read_to_end(&mut bytes).await.expect("read reports"); assert_eq!(bytes.len(), 96); }); }); } #[test] fn unsupported_chars_reports_count_and_preview() { let (count, preview) = unsupported_chars("ok🙂éx".chars()); assert_eq!(count, 2); assert!(preview.contains("'🙂'")); assert!(preview.contains("'é'")); } #[test] #[serial] /// Reject unsupported clipboard characters instead of silently dropping them. fn type_text_rejects_unsupported_chars() { let rt = Runtime::new().expect("runtime"); with_var("LESAVKA_PASTE_DELAY_MS", Some("0"), || { rt.block_on(async { let dir = tempdir().expect("tempdir"); let path = dir.path().join("hidg0.bin"); let file = OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&path) .await .expect("open temp file"); let kb = Mutex::new(file); let err = type_text(&kb, "pw🙂") .await .expect_err("unsupported char should fail"); assert!(err.to_string().contains("unsupported character")); }); }); } #[test] #[serial] fn decrypt_loads_key_from_file() { let dir = tempdir().expect("tempdir"); let path = dir.path().join("paste-key"); let key = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; std::fs::write(&path, key).expect("write key file"); with_var("LESAVKA_PASTE_KEY", None::<&str>, || { with_var("LESAVKA_PASTE_KEY_FILE", Some(path.as_os_str()), || { let raw_key = lesavka_common::paste::decode_shared_key(key).expect("decode key"); let cipher = ChaCha20Poly1305::new(Key::from_slice(&raw_key)); let nonce_bytes = [0x22u8; 12]; let nonce = Nonce::from_slice(&nonce_bytes); let data = cipher .encrypt(nonce, b"file backed secret".as_ref()) .expect("encrypt"); let req = PasteRequest { nonce: nonce_bytes.to_vec(), data, encrypted: true, }; assert_eq!(decrypt(&req).expect("decrypt"), "file backed secret"); }); }); } }