// Security coverage for encrypted paste payloads. // // Scope: validate that clipboard RPC payloads are encrypted, authenticated, // and fail closed under key/nonce/ciphertext tampering. // Targets: `client/src/paste.rs`, `server/src/paste.rs`, and shared paste key // decoding. // Why: paste is effectively remote keyboard injection, so corrupted or // unauthenticated payloads should never fall through to HID writes. use lesavka_client::paste::build_paste_request; use lesavka_server::paste::decrypt; use serial_test::serial; use temp_env::with_vars; use tempfile::tempdir; const KEY_A: &str = "hex:00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; const KEY_B: &str = "hex:ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"; #[test] #[serial] fn encrypted_paste_round_trips_only_when_keys_match() { with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_A)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || { let req = build_paste_request("secret paste").expect("build request"); assert!(req.encrypted); assert_eq!(decrypt(&req).expect("decrypt request"), "secret paste"); }, ); } #[test] #[serial] fn tampered_ciphertext_is_rejected() { with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_A)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || { let mut req = build_paste_request("keep this private").expect("build request"); req.data[0] ^= 0x55; let err = decrypt(&req).expect_err("tampered ciphertext must fail auth"); assert!(err.to_string().contains("paste decrypt failed")); }, ); } #[test] #[serial] fn tampered_nonce_is_rejected() { with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_A)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || { let mut req = build_paste_request("nonce protected").expect("build request"); req.nonce[0] ^= 0x33; let err = decrypt(&req).expect_err("tampered nonce must fail auth"); assert!(err.to_string().contains("paste decrypt failed")); }, ); } #[test] #[serial] fn every_nonce_byte_is_authenticated() { with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_A)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || { let original = build_paste_request("nonce sweep").expect("build request"); for index in 0..original.nonce.len() { let mut req = original.clone(); req.nonce[index] ^= 0x5a; let err = decrypt(&req).expect_err("mutated nonce byte must fail auth"); assert!( err.to_string().contains("paste decrypt failed"), "nonce byte {index} should fail closed, got {err:#}" ); } }, ); } #[test] #[serial] fn truncated_ciphertexts_fail_without_leaking_plaintext() { with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_A)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || { let original = build_paste_request("truncation protected").expect("build request"); for len in [0, 1, 2, 8, 15, original.data.len().saturating_sub(1)] { let mut req = original.clone(); req.data.truncate(len); let err = decrypt(&req).expect_err("truncated ciphertext must fail auth"); let rendered = format!("{err:#}"); assert!(rendered.contains("paste decrypt failed")); assert!(!rendered.contains("truncation protected")); } }, ); } #[test] #[serial] fn malformed_nonce_is_rejected_without_panicking() { with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_A)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || { let mut req = build_paste_request("bad nonce").expect("build request"); req.nonce.truncate(3); let err = decrypt(&req).expect_err("malformed nonce must fail validation"); assert!(err.to_string().contains("12 bytes")); }, ); } #[test] #[serial] fn missing_key_blocks_client_request_creation() { let dir = tempdir().expect("empty home"); with_vars( [ ("LESAVKA_PASTE_KEY", None::<&str>), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ("HOME", Some(dir.path().to_string_lossy().as_ref())), ], || { let err = build_paste_request("no key").expect_err("missing key must fail"); let rendered = format!("{err:#}"); assert!( rendered.contains("LESAVKA_PASTE_KEY") || rendered.contains("paste key file"), "missing key error should point at the env var or default key file, got: {rendered}" ); }, ); } #[test] #[serial] fn bad_server_key_rejects_client_payload() { let req = with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_A)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || build_paste_request("wrong key").expect("build request"), ); with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_B)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || { let err = decrypt(&req).expect_err("wrong key must fail auth"); let rendered = format!("{err:#}"); assert!(rendered.contains("paste decrypt failed")); assert!(!rendered.contains(KEY_A)); assert!(!rendered.contains("wrong key")); }, ); } #[test] #[serial] fn plaintext_downgrade_is_rejected() { with_vars( [ ("LESAVKA_PASTE_KEY", Some(KEY_A)), ("LESAVKA_PASTE_KEY_FILE", None::<&str>), ], || { let mut req = build_paste_request("plaintext downgrade").expect("build request"); req.encrypted = false; let err = decrypt(&req).expect_err("plaintext must not be accepted"); assert!(err.to_string().contains("encrypted")); }, ); }