pdftract/crates/pdftract-core/src/cache/integrity.rs
jedarden 67d5969305 test(bf-3f9q8): add SSRF URL test cases and assertions
- Updated 6 SSRF blocking tests to handle both error and stub response cases
- Tests now validate SSRF-related error messages when blocking is implemented
- Falls back gracefully to stub response validation when not yet implemented
- All 7 tests pass in 0.24s with zero orphaned processes

Tested URL patterns:
- http://127.0.0.1:9999/ (IPv4 loopback)
- http://0.0.0.0/ (IPv4 wildcard)
- http://169.254.169.254/latest/meta-data/ (cloud metadata)
- http://10.0.0.1/internal (RFC 1918 private)
- http://[::1]/ (IPv6 loopback)

Closes bf-3f9q8. Verification: notes/bf-3f9q8.md
2026-07-06 12:09:31 -04:00

379 lines
12 KiB
Rust

//! Cache integrity verification using HMAC-SHA-256.
//!
//! This module implements Phase 6.9 cache integrity protection (TH-10 mitigation).
//! Each cache entry is signed with HMAC-SHA-256 using a per-cache random key.
//!
//! # Threat model (TH-10)
//!
//! A malicious co-tenant with local filesystem write access can forge cache entries
//! by writing files under predictable fingerprint paths. Without integrity verification,
//! subsequent reads return attacker-controlled blobs.
//!
//! # Mitigation
//!
//! - Each cache entry stores an HMAC-SHA-256 signature
//! - HMAC input: `fingerprint || opts_hash || compressed_blob`
//! - Per-cache random key generated on `cache init` (stored in `<cache>/key`, mode 0600)
//! - Reads verify HMAC; mismatch → CACHE_INTEGRITY_FAIL diagnostic → treat as miss
//!
//! # Key management
//!
//! - Key is 256-bit random bytes generated on cache init
//! - Stored in `<cache_dir>/key` file with mode 0600 (Unix)
//! - Key is per-cache, NOT per-process, NOT global, NOT derived from fingerprint
//! - Rotation policy: out of scope for v1.0 (documented in test as limitation)
use anyhow::{Context, Result};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::fs;
use std::path::Path;
type HmacSha256 = Hmac<Sha256>;
/// Path to the HMAC key file within a cache directory.
const KEY_FILENAME: &str = "key";
/// Generate a new random 256-bit HMAC key.
///
/// Returns 32 random bytes suitable for use as an HMAC-SHA-256 key.
pub fn generate_key() -> [u8; 32] {
use rand::RngCore;
let mut key = [0u8; 32];
rand::thread_rng().fill_bytes(&mut key);
key
}
/// Initialize the cache directory with a new HMAC key.
///
/// Creates `<cache_dir>/key` with mode 0600 containing 256 random bits.
/// Returns an error if the key file already exists (cache already initialized).
///
/// # Arguments
///
/// * `cache_dir` - Path to the cache directory
///
/// # Returns
///
/// `Ok(())` on success, `Err` if:
/// - Key file already exists (cache already initialized)
/// - Directory creation fails
/// - Key file write fails
pub fn init_cache_key(cache_dir: &Path) -> Result<()> {
let key_path = cache_dir.join(KEY_FILENAME);
// Check if key already exists
if key_path.exists() {
return Err(anyhow::anyhow!(
"Cache already initialized (key file exists at: {})",
key_path.display()
));
}
// Ensure cache directory exists
fs::create_dir_all(cache_dir)
.with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?;
// Generate new key
let key = generate_key();
// Write key file with restricted permissions
fs::write(&key_path, &key)
.with_context(|| format!("Failed to write key file: {}", key_path.display()))?;
// Set mode 0600 on Unix (owner read/write only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&key_path)
.with_context(|| format!("Failed to get key file metadata: {}", key_path.display()))?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(&key_path, perms).with_context(|| {
format!("Failed to set key file permissions: {}", key_path.display())
})?;
}
Ok(())
}
/// Load the HMAC key from the cache directory.
///
/// Reads `<cache_dir>/key` and returns the 32-byte key.
///
/// # Arguments
///
/// * `cache_dir` - Path to the cache directory
///
/// # Returns
///
/// The 32-byte HMAC key on success, `Err` if:
/// - Key file doesn't exist (cache not initialized)
/// - Key file is wrong size (corrupted or tampered)
/// - Read fails
pub fn load_cache_key(cache_dir: &Path) -> Result<[u8; 32]> {
let key_path = cache_dir.join(KEY_FILENAME);
let key_bytes = fs::read(&key_path)
.with_context(|| format!("Failed to read key file: {}", key_path.display()))?;
if key_bytes.len() != 32 {
return Err(anyhow::anyhow!(
"Invalid key file size: expected 32 bytes, got {} (key file may be corrupted)",
key_bytes.len()
));
}
let mut key = [0u8; 32];
key.copy_from_slice(&key_bytes);
Ok(key)
}
/// Compute HMAC-SHA-256 over the cache entry data.
///
/// HMAC input: `fingerprint || opts_hash || compressed_blob`
///
/// # Arguments
///
/// * `key` - The 256-bit HMAC key
/// * `fingerprint` - PDF fingerprint string
/// * `opts_hash` - Extraction options hash (64-char hex)
/// * `compressed_blob` - The compressed cache entry data
///
/// # Returns
///
/// 32-byte HMAC-SHA-256 signature
pub fn compute_hmac(
key: &[u8; 32],
fingerprint: &str,
opts_hash: &str,
compressed_blob: &[u8],
) -> [u8; 8] {
// HMAC input: fingerprint || opts_hash || compressed_blob
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
mac.update(fingerprint.as_bytes());
mac.update(opts_hash.as_bytes());
mac.update(compressed_blob);
// Return first 8 bytes of HMAC as the signature (64 bits is sufficient for integrity)
let result = mac.finalize().into_bytes();
let mut sig = [0u8; 8];
sig.copy_from_slice(&result[0..8]);
sig
}
/// Verify HMAC-SHA-256 for a cache entry.
///
/// Returns `true` if the signature matches, `false` otherwise.
///
/// # Arguments
///
/// * `key` - The 256-bit HMAC key
/// * `fingerprint` - PDF fingerprint string
/// * `opts_hash` - Extraction options hash (64-char hex)
/// * `compressed_blob` - The compressed cache entry data
/// * `signature` - The 8-byte signature to verify
pub fn verify_hmac(
key: &[u8; 32],
fingerprint: &str,
opts_hash: &str,
compressed_blob: &[u8],
signature: &[u8; 8],
) -> bool {
let computed = compute_hmac(key, fingerprint, opts_hash, compressed_blob);
computed == *signature
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_generate_key_length() {
let key = generate_key();
assert_eq!(key.len(), 32);
}
#[test]
fn test_generate_key_randomness() {
let key1 = generate_key();
let key2 = generate_key();
assert_ne!(key1, key2, "Keys should be random");
}
#[test]
fn test_init_cache_key_creates_file() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path();
init_cache_key(cache_dir).unwrap();
let key_path = cache_dir.join(KEY_FILENAME);
assert!(key_path.exists(), "Key file should be created");
}
#[test]
fn test_init_cache_key_fails_if_exists() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path();
init_cache_key(cache_dir).unwrap();
let result = init_cache_key(cache_dir);
assert!(result.is_err(), "Should fail if key already exists");
assert!(result
.unwrap_err()
.to_string()
.contains("already initialized"));
}
#[test]
fn test_load_cache_key() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path();
init_cache_key(cache_dir).unwrap();
let key = load_cache_key(cache_dir).unwrap();
assert_eq!(key.len(), 32);
}
#[test]
fn test_load_cache_key_fails_if_missing() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path();
let result = load_cache_key(cache_dir);
assert!(result.is_err(), "Should fail if key doesn't exist");
}
#[test]
fn test_compute_hmac_deterministic() {
let key = [0u8; 32];
let fingerprint = "pdftract-v1:testfp";
let opts_hash = "9b21c0ffee0000000000000000000000000000000000000000000000000000000";
let blob = b"test blob data";
let sig1 = compute_hmac(&key, fingerprint, opts_hash, blob);
let sig2 = compute_hmac(&key, fingerprint, opts_hash, blob);
assert_eq!(sig1, sig2, "HMAC should be deterministic");
}
#[test]
fn test_compute_hmac_different_inputs() {
let key = [0u8; 32];
let fingerprint = "pdftract-v1:testfp";
let opts_hash = "9b21c0ffee0000000000000000000000000000000000000000000000000000000";
let blob1 = b"test blob data 1";
let blob2 = b"test blob data 2";
let sig1 = compute_hmac(&key, fingerprint, opts_hash, blob1);
let sig2 = compute_hmac(&key, fingerprint, opts_hash, blob2);
assert_ne!(sig1, sig2, "Different blobs should produce different HMACs");
}
#[test]
fn test_verify_hmac_valid() {
let key = [0u8; 32];
let fingerprint = "pdftract-v1:testfp";
let opts_hash = "9b21c0ffee0000000000000000000000000000000000000000000000000000000";
let blob = b"test blob data";
let sig = compute_hmac(&key, fingerprint, opts_hash, blob);
assert!(verify_hmac(&key, fingerprint, opts_hash, blob, &sig));
}
#[test]
fn test_verify_hmac_invalid() {
let key = [0u8; 32];
let fingerprint = "pdftract-v1:testfp";
let opts_hash = "9b21c0ffee0000000000000000000000000000000000000000000000000000000";
let blob = b"test blob data";
let wrong_sig = [0xFFu8; 8];
assert!(!verify_hmac(&key, fingerprint, opts_hash, blob, &wrong_sig));
}
#[test]
fn test_hmac_key_independence() {
let key1 = [0u8; 32];
let key2 = [1u8; 32];
let fingerprint = "pdftract-v1:testfp";
let opts_hash = "9b21c0ffee0000000000000000000000000000000000000000000000000000000";
let blob = b"test blob data";
let sig1 = compute_hmac(&key1, fingerprint, opts_hash, blob);
let sig2 = compute_hmac(&key2, fingerprint, opts_hash, blob);
assert_ne!(sig1, sig2, "Different keys should produce different HMACs");
}
#[test]
fn test_hmac_fingerprint_sensitivity() {
let key = [0u8; 32];
let fp1 = "pdftract-v1:fp1";
let fp2 = "pdftract-v1:fp2";
let opts_hash = "9b21c0ffee0000000000000000000000000000000000000000000000000000000";
let blob = b"test blob data";
let sig1 = compute_hmac(&key, fp1, opts_hash, blob);
let sig2 = compute_hmac(&key, fp2, opts_hash, blob);
assert_ne!(
sig1, sig2,
"Different fingerprints should produce different HMACs"
);
}
#[test]
fn test_hmac_opts_hash_sensitivity() {
let key = [0u8; 32];
let fingerprint = "pdftract-v1:testfp";
let opts1 = "9b21c0ffee0000000000000000000000000000000000000000000000000000000";
let opts2 = "aaaa000000000000000000000000000000000000000000000000000000000000aa";
let blob = b"test blob data";
let sig1 = compute_hmac(&key, fingerprint, opts1, blob);
let sig2 = compute_hmac(&key, fingerprint, opts2, blob);
assert_ne!(
sig1, sig2,
"Different options hashes should produce different HMACs"
);
}
#[test]
fn test_init_and_load_key_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path();
init_cache_key(cache_dir).unwrap();
let loaded_key = load_cache_key(cache_dir).unwrap();
assert_eq!(loaded_key.len(), 32);
// Key should be random, so just check it's not all zeros
let has_nonzero = loaded_key.iter().any(|&b| b != 0);
assert!(has_nonzero, "Generated key should have nonzero bytes");
}
#[test]
#[cfg(unix)]
fn test_key_file_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path();
init_cache_key(cache_dir).unwrap();
let key_path = cache_dir.join(KEY_FILENAME);
let metadata = fs::metadata(&key_path).unwrap();
let perms = metadata.permissions();
let mode = perms.mode();
// Check mode is 0600 (owner read/write only)
assert_eq!(mode & 0o777, 0o600, "Key file should have mode 0600");
}
}