lesavka/server/src/paste.rs

147 lines
5.2 KiB
Rust

// server/src/paste.rs
#![forbid(unsafe_code)]
use anyhow::{Context, Result};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use lesavka_common::hid::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<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}"))?;
Ok(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<File>, text: &str) -> Result<()> {
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())
.unwrap_or(8);
let delay = std::time::Duration::from_millis(delay_ms);
let mut kb = kb.lock().await;
for c in text.chars().take(max) {
if let Some((usage, mods)) = char_to_usage(c) {
let report = [mods, 0, usage, 0, 0, 0, 0, 0];
kb.write_all(&report).await?;
kb.write_all(&[0u8; 8]).await?;
if delay_ms > 0 {
tokio::time::sleep(delay).await;
}
}
}
Ok(())
}
fn load_key() -> Result<[u8; 32]> {
let raw = std::env::var("LESAVKA_PASTE_KEY")
.context("LESAVKA_PASTE_KEY not set (required for PasteText RPC)")?;
decode_shared_key(&raw)
}
#[cfg(test)]
mod tests {
use super::{decrypt, type_text};
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(), 32);
});
});
}
}