lesavka/tests/security/client/paste/client_paste_security_contract.rs

194 lines
6.1 KiB
Rust

// 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"));
},
);
}