194 lines
6.1 KiB
Rust
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"));
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|