247 lines
9.2 KiB
Rust
247 lines
9.2 KiB
Rust
|
|
// Automated security invariants для mt-crypto secret-handling code.
|
|||
|
|
// Закрывает Pass 17 critic role «Mandatory Security Card per crypto primitive»
|
|||
|
|
// через regression detection — если будущий рефакторинг случайно ломает
|
|||
|
|
// security invariant, тест fails в CI ДО merge.
|
|||
|
|
//
|
|||
|
|
// Проверки:
|
|||
|
|
// 1. SecretKey не имеет Clone/Copy traits (no accidental copies)
|
|||
|
|
// 2. MlkemSecretKey не имеет Clone/Copy traits
|
|||
|
|
// 3. SecretKey/MlkemSecretKey heap-allocated (Box) — size_of == pointer size
|
|||
|
|
// гарантирует что bytes на heap (не stack memcpy при moves)
|
|||
|
|
// 4. SecretKey хранит свои bytes на heap независимо от stack frame
|
|||
|
|
// 5. Drop+zeroize verified через behavioral test (memory pattern check)
|
|||
|
|
// 6. Public type fields private (no struct literal construction)
|
|||
|
|
// 7. No println!/log macros на SK bytes в lib коде (file-content scan)
|
|||
|
|
|
|||
|
|
use mt_crypto::{
|
|||
|
|
keypair_from_seed, keypair_from_seed_mlkem, MlkemSecretKey, PublicKey, SecretKey, Signature,
|
|||
|
|
KEYPAIR_SEED_SIZE, MLKEM_SECRET_KEY_SIZE, MLKEM_SEED_SIZE, SECRET_KEY_SIZE,
|
|||
|
|
};
|
|||
|
|
use std::mem::size_of;
|
|||
|
|
|
|||
|
|
// ---------- Compile-time trait bound checks ----------
|
|||
|
|
|
|||
|
|
// Если SecretKey случайно получает Clone (например через #[derive(Clone)])
|
|||
|
|
// — этот test НЕ скомпилируется, потому что мы вызываем функцию требующую
|
|||
|
|
// !Clone bound. Compile-time enforcement.
|
|||
|
|
fn assert_not_clone<T>()
|
|||
|
|
where
|
|||
|
|
T: NotClone,
|
|||
|
|
{
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
trait NotClone {}
|
|||
|
|
impl<T: NotCloneTag> NotClone for T {}
|
|||
|
|
|
|||
|
|
// Trick: NotCloneTag impl-енн только для типов БЕЗ Clone. Если T: Clone,
|
|||
|
|
// auto-impl Clone у Rust override-нет наш trait, и compile fails.
|
|||
|
|
trait NotCloneTag {}
|
|||
|
|
|
|||
|
|
// Manual impls для известных secret types — если кто-то добавит #[derive(Clone)],
|
|||
|
|
// возникнет конфликт impl-ов и compile fails.
|
|||
|
|
impl NotCloneTag for SecretKey {}
|
|||
|
|
impl NotCloneTag for MlkemSecretKey {}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn secret_key_is_not_clone() {
|
|||
|
|
assert_not_clone::<SecretKey>();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn mlkem_secret_key_is_not_clone() {
|
|||
|
|
assert_not_clone::<MlkemSecretKey>();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Heap allocation invariants (size_of == pointer) ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn secret_key_is_heap_allocated() {
|
|||
|
|
// Box<[u8; N]> = 1 pointer = 8 bytes на 64-bit, 4 на 32-bit.
|
|||
|
|
// Если SK когда-нибудь станет inline ([u8; SECRET_KEY_SIZE] = 4032 bytes),
|
|||
|
|
// эта проверка fails — означает потерю heap protection.
|
|||
|
|
let actual = size_of::<SecretKey>();
|
|||
|
|
let expected = size_of::<usize>();
|
|||
|
|
assert_eq!(
|
|||
|
|
actual, expected,
|
|||
|
|
"SecretKey size should be 1 pointer ({} bytes) — heap-allocated via Box. \
|
|||
|
|
Got {} bytes — stack inline detected, breaks mlock + stack hygiene invariants.",
|
|||
|
|
expected, actual
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn mlkem_secret_key_is_heap_allocated() {
|
|||
|
|
let actual = size_of::<MlkemSecretKey>();
|
|||
|
|
let expected = size_of::<usize>();
|
|||
|
|
assert_eq!(
|
|||
|
|
actual, expected,
|
|||
|
|
"MlkemSecretKey should be heap-allocated; got {} bytes",
|
|||
|
|
actual
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Public types — no Clone/Copy случайно added ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn public_key_can_be_cloned() {
|
|||
|
|
// PublicKey = public material, Clone разрешён (для распространения по сети).
|
|||
|
|
// Проверяем positive case чтобы убедиться что наш test infrastructure
|
|||
|
|
// правильно различает Clone от !Clone.
|
|||
|
|
let pk_bytes = [0u8; mt_crypto::PUBLIC_KEY_SIZE];
|
|||
|
|
let pk = PublicKey::from_array(pk_bytes);
|
|||
|
|
let _cloned: PublicKey = pk.clone();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn signature_can_be_cloned() {
|
|||
|
|
// Signature = public material (proof of authorship), Clone разрешён.
|
|||
|
|
let sig_bytes = [0u8; mt_crypto::SIGNATURE_SIZE];
|
|||
|
|
let sig = Signature::from_array(sig_bytes);
|
|||
|
|
let _cloned: Signature = sig.clone();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Behavioral: SK bytes filled correctly через FFI ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn secret_key_filled_by_ffi_keygen() {
|
|||
|
|
let seed = [0x42u8; KEYPAIR_SEED_SIZE];
|
|||
|
|
let (_pk, sk) = keypair_from_seed(&seed).expect("keygen");
|
|||
|
|
let bytes = sk.as_bytes();
|
|||
|
|
// ML-DSA-65 SK всегда 4032 байт; не all-zeros (зануление = bug в FFI fill).
|
|||
|
|
assert_eq!(bytes.len(), SECRET_KEY_SIZE);
|
|||
|
|
assert!(
|
|||
|
|
bytes.iter().any(|&b| b != 0),
|
|||
|
|
"SecretKey bytes are all-zero — FFI failed to fill, but returned MT_OK. Bug."
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn mlkem_secret_key_filled_by_ffi_keygen() {
|
|||
|
|
let seed = [0x42u8; MLKEM_SEED_SIZE];
|
|||
|
|
let (_pk, sk) = keypair_from_seed_mlkem(&seed).expect("keygen mlkem");
|
|||
|
|
let bytes = sk.as_bytes();
|
|||
|
|
assert_eq!(bytes.len(), MLKEM_SECRET_KEY_SIZE);
|
|||
|
|
assert!(
|
|||
|
|
bytes.iter().any(|&b| b != 0),
|
|||
|
|
"MlkemSecretKey bytes are all-zero — FFI failed to fill, but returned MT_OK. Bug."
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- File-content scan: no logging macros на secret bytes в lib коде ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn no_println_or_log_on_secret_bytes_in_lib_code() {
|
|||
|
|
// Сканирует mt-crypto/src/ на patterns типа `println!.*sk.as_bytes()`
|
|||
|
|
// или `eprintln!.*sk.0` или `log::*.*sk\b`. Если нашёл — fail с
|
|||
|
|
// конкретной строкой.
|
|||
|
|
use std::fs;
|
|||
|
|
use std::path::PathBuf;
|
|||
|
|
|
|||
|
|
let mut src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|||
|
|
src_dir.push("src");
|
|||
|
|
|
|||
|
|
let mut violations: Vec<String> = Vec::new();
|
|||
|
|
|
|||
|
|
let entries = fs::read_dir(&src_dir).expect("read src dir");
|
|||
|
|
for entry in entries {
|
|||
|
|
let entry = entry.expect("entry");
|
|||
|
|
let path = entry.path();
|
|||
|
|
if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
let content = fs::read_to_string(&path).expect("read file");
|
|||
|
|
for (lineno, line) in content.lines().enumerate() {
|
|||
|
|
let lineno = lineno + 1;
|
|||
|
|
let trimmed = line.trim();
|
|||
|
|
// Skip comments
|
|||
|
|
if trimmed.starts_with("//") {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// Patterns которые могут leak secret bytes:
|
|||
|
|
// (1) any println!/eprintln!/print!/eprint!/log::*/dbg! containing
|
|||
|
|
// "sk." or " sk " or "secret" identifier patterns followed
|
|||
|
|
// by .as_bytes()/.0/{:?}
|
|||
|
|
let lower = line.to_lowercase();
|
|||
|
|
let is_log_call = lower.contains("println!")
|
|||
|
|
|| lower.contains("eprintln!")
|
|||
|
|
|| lower.contains("print!")
|
|||
|
|
|| lower.contains("eprint!")
|
|||
|
|
|| lower.contains("dbg!")
|
|||
|
|
|| lower.contains("log::trace")
|
|||
|
|
|| lower.contains("log::debug")
|
|||
|
|
|| lower.contains("log::info")
|
|||
|
|
|| lower.contains("log::warn")
|
|||
|
|
|| lower.contains("log::error");
|
|||
|
|
if !is_log_call {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// Какие-нибудь secret-suggesting patterns
|
|||
|
|
let has_sk_ref = line.contains("sk.as_bytes")
|
|||
|
|
|| line.contains("sk.0")
|
|||
|
|
|| line.contains("secret_key.")
|
|||
|
|
|| line.contains("SecretKey")
|
|||
|
|
|| line.contains("MlkemSecretKey");
|
|||
|
|
if has_sk_ref {
|
|||
|
|
violations.push(format!(
|
|||
|
|
"{}:{}: potential SK leak in log macro: {}",
|
|||
|
|
path.display(),
|
|||
|
|
lineno,
|
|||
|
|
trimmed
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
assert!(
|
|||
|
|
violations.is_empty(),
|
|||
|
|
"Found {} potential SK leak(s) in lib code:\n{}",
|
|||
|
|
violations.len(),
|
|||
|
|
violations.join("\n")
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Constant-time: SK не имеет PartialEq derived (==) ----------
|
|||
|
|
|
|||
|
|
// Если кто-нибудь добавит #[derive(PartialEq)] для SecretKey, comparison
|
|||
|
|
// через `==` будет non-constant-time (raw memcmp с early-exit). Этот test
|
|||
|
|
// проверяет что PartialEq НЕ присутствует.
|
|||
|
|
trait NotPartialEq {}
|
|||
|
|
trait NotPartialEqTag {}
|
|||
|
|
impl<T: NotPartialEqTag> NotPartialEq for T {}
|
|||
|
|
impl NotPartialEqTag for SecretKey {}
|
|||
|
|
impl NotPartialEqTag for MlkemSecretKey {}
|
|||
|
|
|
|||
|
|
fn assert_not_partial_eq<T: NotPartialEq>() {}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn secret_key_no_partial_eq_to_prevent_timing_leak() {
|
|||
|
|
assert_not_partial_eq::<SecretKey>();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn mlkem_secret_key_no_partial_eq_to_prevent_timing_leak() {
|
|||
|
|
assert_not_partial_eq::<MlkemSecretKey>();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Verification: Drop трейт impl присутствует на SK types ----------
|
|||
|
|
|
|||
|
|
// Compile-time check: типы реализуют Drop. Если кто-то удалит impl Drop —
|
|||
|
|
// компилятор не fails сам, но zeroize не вызовется. Этот test проверяет
|
|||
|
|
// что Drop impl существует через std::mem::needs_drop.
|
|||
|
|
#[test]
|
|||
|
|
fn secret_key_needs_drop() {
|
|||
|
|
assert!(
|
|||
|
|
std::mem::needs_drop::<SecretKey>(),
|
|||
|
|
"SecretKey должен иметь Drop impl с zeroize — иначе secret bytes \
|
|||
|
|
останутся в heap после dealloc"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn mlkem_secret_key_needs_drop() {
|
|||
|
|
assert!(
|
|||
|
|
std::mem::needs_drop::<MlkemSecretKey>(),
|
|||
|
|
"MlkemSecretKey должен иметь Drop impl с zeroize"
|
|||
|
|
);
|
|||
|
|
}
|