lesavka/server/src/paste.rs

206 lines
7.1 KiB
Rust
Raw Normal View History

// 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::{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<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}"))?;
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 (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<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(", "))
}
}
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, 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"));
});
});
}
}