147 lines
5.2 KiB
Rust
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);
|
|
});
|
|
});
|
|
}
|
|
}
|