2026-04-08 20:00:14 -03:00
|
|
|
// server/src/paste.rs
|
|
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
|
|
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
|
|
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
2026-04-21 13:08:20 -03:00
|
|
|
use std::{path::PathBuf, sync::Arc};
|
2026-04-08 20:00:14 -03:00
|
|
|
use tokio::sync::Mutex;
|
|
|
|
|
|
2026-04-21 13:08:20 -03:00
|
|
|
use crate::runtime_support;
|
2026-04-14 18:44:40 -03:00
|
|
|
use lesavka_common::hid::{append_char_reports, char_to_usage};
|
2026-04-08 20:00:14 -03:00
|
|
|
use lesavka_common::lesavka::PasteRequest;
|
2026-04-10 15:56:18 -03:00
|
|
|
use lesavka_common::paste::decode_shared_key;
|
2026-04-08 20:00:14 -03:00
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
/// 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.
|
2026-04-08 20:00:14 -03:00
|
|
|
pub fn decrypt(req: &PasteRequest) -> Result<String> {
|
|
|
|
|
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}"))?;
|
2026-04-14 05:50:20 -03:00
|
|
|
String::from_utf8(plaintext).context("paste plaintext not UTF-8")
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
/// 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.
|
2026-04-21 13:08:20 -03:00
|
|
|
pub async fn type_text(
|
|
|
|
|
kb: &Arc<Mutex<Option<tokio::fs::File>>>,
|
|
|
|
|
hid_path: &str,
|
|
|
|
|
text: &str,
|
|
|
|
|
) -> Result<()> {
|
2026-04-08 20:00:14 -03:00
|
|
|
let max = std::env::var("LESAVKA_PASTE_MAX")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|v| v.parse::<usize>().ok())
|
|
|
|
|
.unwrap_or(4096);
|
|
|
|
|
let delay_ms = std::env::var("LESAVKA_PASTE_DELAY_MS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|v| v.parse::<u64>().ok())
|
2026-04-10 15:56:18 -03:00
|
|
|
.unwrap_or(8);
|
2026-04-08 20:00:14 -03:00
|
|
|
let delay = std::time::Duration::from_millis(delay_ms);
|
|
|
|
|
|
2026-04-14 05:50:20 -03:00
|
|
|
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}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 20:00:14 -03:00
|
|
|
for c in text.chars().take(max) {
|
2026-04-14 18:44:40 -03:00
|
|
|
let mut reports = Vec::with_capacity(4);
|
|
|
|
|
if append_char_reports(&mut reports, c) {
|
|
|
|
|
for report in reports {
|
2026-04-21 13:08:20 -03:00
|
|
|
runtime_support::write_hid_report(kb, hid_path, &report).await?;
|
2026-04-14 18:44:40 -03:00
|
|
|
if delay_ms > 0 {
|
|
|
|
|
tokio::time::sleep(delay).await;
|
|
|
|
|
}
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 05:50:20 -03:00
|
|
|
/// Summarize unsupported clipboard characters for explicit operator feedback.
|
|
|
|
|
fn unsupported_chars(chars: impl Iterator<Item = char>) -> (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(", "))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 20:00:14 -03:00
|
|
|
fn load_key() -> Result<[u8; 32]> {
|
2026-04-16 12:58:05 -03:00
|
|
|
let raw = load_key_material()?;
|
2026-04-10 15:56:18 -03:00
|
|
|
decode_shared_key(&raw)
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn load_key_material() -> Result<String> {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-04-14 05:50:20 -03:00
|
|
|
use super::{decrypt, type_text, unsupported_chars};
|
2026-04-10 15:56:18 -03:00
|
|
|
use chacha20poly1305::aead::{Aead, KeyInit};
|
|
|
|
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
|
|
|
|
use lesavka_common::lesavka::PasteRequest;
|
|
|
|
|
use serial_test::serial;
|
2026-04-21 13:08:20 -03:00
|
|
|
use std::sync::Arc;
|
2026-04-10 15:56:18 -03:00
|
|
|
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"));
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
#[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");
|
|
|
|
|
});
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:56:18 -03:00
|
|
|
#[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");
|
2026-04-21 13:08:20 -03:00
|
|
|
let kb = Arc::new(Mutex::new(Some(file)));
|
|
|
|
|
type_text(&kb, path.to_str().unwrap(), "A!?")
|
|
|
|
|
.await
|
|
|
|
|
.expect("type text");
|
2026-04-10 15:56:18 -03:00
|
|
|
|
|
|
|
|
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");
|
2026-04-14 18:44:40 -03:00
|
|
|
assert_eq!(bytes.len(), 96);
|
2026-04-14 05:50:20 -03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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");
|
2026-04-21 13:08:20 -03:00
|
|
|
let kb = Arc::new(Mutex::new(Some(file)));
|
|
|
|
|
let err = type_text(&kb, path.to_str().unwrap(), "pw🙂")
|
2026-04-14 23:03:18 -03:00
|
|
|
.await
|
|
|
|
|
.expect_err("unsupported char should fail");
|
2026-04-14 05:50:20 -03:00
|
|
|
assert!(err.to_string().contains("unsupported character"));
|
2026-04-10 15:56:18 -03:00
|
|
|
});
|
|
|
|
|
});
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
|
|
|
|
|
#[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");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-08 20:00:14 -03:00
|
|
|
}
|