Engine — chữ ký số (sign.rs MỚI): - generate_self_signed_id: Digital ID tự ký RSA-2048 + X.509 (rcgen), ghi PEM - sign_pdf: đúng cơ chế Adobe — lopdf dựng sig dict (/SubFilter adbe.pkcs7.detached) + Widget field + AcroForm/SigFlags, ByteRange/Contents placeholder; serialize → vá ByteRange theo offset thật → SHA-256 trên 2 đoạn → CMS SignedData detached (crate cms, signed attrs messageDigest+contentType, RSA-SHA256) nhét DER vào Contents - verify_signatures: parse ByteRange/Contents, băm lại, kiểm chữ ký RSA trên signed attrs + messageDigest khớp + phủ toàn bộ file → phát hiện GIẢ MẠO - Bài học: cắt DER về đúng độ dài (bỏ padding hex '0'); bỏ qua /Contents của trang (tham chiếu), chỉ nhận /Contents <hex> của chữ ký Engine — redact tỉa theo KÝ TỰ (redact.rs): - Text chạm một phần: page_char_boxes xác định ký tự trong vùng, GIỮ các đoạn ngoài vùng, dựng lại đúng vị trí/baseline/màu (font nhúng gốc → cùng họ → mặc định). Guard: text xoay/không đọc được font → xoá nguyên (an toàn) - qpdf::optimize_save: --object-streams=generate + nén, dọn object rác Tauri + UI: - Commands: sig_create_id, sig_sign, sig_verify, security_optimize, pick_any_file, pick_save_pem - Thanh Bảo mật thêm: Tạo Digital ID, Ký số (dialog + tự kiểm tra sau khi ký), Kiểm tra chữ ký (modal bảng trạng thái + lý do), Lưu tối ưu Test (7 mới → 62/62 engine xanh ngoài 2 fixture qpdf Linux): - sign_then_verify_is_valid; tampering_after_signing_is_detected (phát hiện sửa sau khi ký); verify_reports_none_for_unsigned - redact tỉa ký tự: cụm mật biến mất, phần cùng dòng ngoài vùng vẫn còn - optimize_save không phình + giữ nội dung Deps: rsa, sha2, cms, x509-cert, rcgen, pkcs8, der, spki, const-oid. Docs: 15 mục 6 (iter 2 + giới hạn), 16 (S13–S19), README, roadmap (Phase 5 HOÀN TẤT), 14 (kế tiếp: Phase 6 Form). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
142 lines
6.3 KiB
Rust
142 lines
6.3 KiB
Rust
//! Test bảo mật Phase 5: mã hoá AES-256 + permissions, gỡ mật khẩu, xoá
|
|
//! metadata nhận dạng. Cần binary `qpdf` (như qpdf_safety).
|
|
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
fn workspace_root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("..")
|
|
.join("..")
|
|
.canonicalize()
|
|
.expect("workspace root")
|
|
}
|
|
|
|
fn pdfium() -> pdfium_render::prelude::Pdfium {
|
|
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
|
|
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
|
|
}
|
|
ff_engine::bind_pdfium().expect("nạp PDFium")
|
|
}
|
|
|
|
fn hello() -> PathBuf {
|
|
workspace_root().join("corpus").join("hello.pdf")
|
|
}
|
|
|
|
fn tmp(name: &str) -> PathBuf {
|
|
let p = std::env::temp_dir().join(name);
|
|
let _ = std::fs::remove_file(&p);
|
|
p
|
|
}
|
|
|
|
/// Mã hoá với permissions: mở KHÔNG mật khẩu phải fail, đúng mật khẩu OK, và
|
|
/// qpdf --show-encryption xác nhận cờ quyền in = none.
|
|
#[test]
|
|
fn encrypt_with_perms_round_trip_and_flags() {
|
|
let pdf = pdfium();
|
|
let out = tmp("ff_sec_enc.pdf");
|
|
let perms = ff_engine::Permissions { allow_print: false, ..Default::default() };
|
|
ff_engine::encrypt_with_password_perms(&hello(), &out, "u123", "o456", perms).expect("encrypt");
|
|
|
|
assert!(pdf.load_pdf_from_file(&out, None).is_err(), "không mật khẩu phải bị chặn");
|
|
assert!(pdf.load_pdf_from_file(&out, Some("u123")).is_ok(), "đúng user password phải mở được");
|
|
assert!(pdf.load_pdf_from_file(&out, Some("o456")).is_ok(), "owner password phải mở được");
|
|
|
|
let qpdf = ff_engine::find_qpdf().expect("qpdf");
|
|
let show = Command::new(qpdf)
|
|
.args(["--password=u123", "--show-encryption", &out.to_string_lossy()])
|
|
.output()
|
|
.expect("show-encryption");
|
|
let s = String::from_utf8_lossy(&show.stdout).to_lowercase();
|
|
assert!(s.contains("print"), "phải có dòng quyền in: {s}");
|
|
// qpdf in "print high resolution: not allowed" (và/hoặc low) khi --print=none.
|
|
assert!(s.contains("not allowed"), "quyền in phải bị chặn: {s}");
|
|
}
|
|
|
|
/// Gỡ mật khẩu: sau decrypt_remove_password, file mở được KHÔNG cần mật khẩu
|
|
/// và nội dung còn nguyên.
|
|
#[test]
|
|
fn decrypt_removes_password() {
|
|
let pdf = pdfium();
|
|
let enc = tmp("ff_sec_enc2.pdf");
|
|
let dec = tmp("ff_sec_dec2.pdf");
|
|
ff_engine::encrypt_with_password(&hello(), &enc, "pw789", "pw789").expect("encrypt");
|
|
assert!(pdf.load_pdf_from_file(&enc, None).is_err(), "fixture phải đang khoá");
|
|
|
|
ff_engine::decrypt_remove_password(&enc, "pw789", &dec).expect("decrypt");
|
|
assert!(pdf.load_pdf_from_file(&dec, None).is_ok(), "sau gỡ phải mở tự do");
|
|
let text = ff_engine::extract_text(&pdf, &dec, 0, None).expect("extract");
|
|
assert!(text.contains("Hello"), "nội dung giữ nguyên: {text:?}");
|
|
}
|
|
|
|
/// Gỡ mật khẩu bằng password SAI phải lỗi (không âm thầm ghi file rỗng).
|
|
#[test]
|
|
fn decrypt_with_wrong_password_fails() {
|
|
let enc = tmp("ff_sec_enc3.pdf");
|
|
let dec = tmp("ff_sec_dec3.pdf");
|
|
ff_engine::encrypt_with_password(&hello(), &enc, "right", "right").expect("encrypt");
|
|
assert!(
|
|
ff_engine::decrypt_remove_password(&enc, "wrong", &dec).is_err(),
|
|
"password sai phải trả lỗi"
|
|
);
|
|
}
|
|
|
|
/// Lưu tối ưu: file kết quả mở/đọc bình thường và KHÔNG lớn hơn bản gốc
|
|
/// (nén object stream). Dùng file có object rác (sau redact-kiểu xoá object).
|
|
#[test]
|
|
fn optimize_save_keeps_content_and_not_larger() {
|
|
let pdf = pdfium();
|
|
let out = tmp("ff_opt_out.pdf");
|
|
let src = workspace_root().join("corpus").join("sample-multipage.pdf");
|
|
ff_engine::optimize_save(&src, &out).expect("optimize");
|
|
|
|
assert!(pdf.load_pdf_from_file(&out, None).is_ok(), "file tối ưu phải mở được");
|
|
let pages = ff_engine::page_count(&pdf, &out, None).expect("page_count");
|
|
assert!(pages >= 1, "phải giữ trang");
|
|
let orig = std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);
|
|
let opt = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(u64::MAX);
|
|
assert!(opt <= orig + orig / 10, "bản tối ưu không được phình đáng kể ({opt} vs {orig})");
|
|
}
|
|
|
|
/// Xoá metadata: /Info (Producer/Author…) và XMP /Metadata phải biến mất KHỎI
|
|
/// FILE (soi bytes thô), file vẫn mở/đọc bình thường.
|
|
#[test]
|
|
fn strip_metadata_removes_info_and_xmp() {
|
|
let pdf = pdfium();
|
|
let fx = tmp("ff_sec_meta_fx.pdf");
|
|
let out = tmp("ff_sec_meta_out.pdf");
|
|
|
|
// Fixture: chuẩn hoá qua qpdf trước (trailer của hello.pdf viết tay, lopdf
|
|
// không parse thẳng được — đúng luồng thật: file luôn qua repair/save của
|
|
// engine trước khi strip), rồi chèn /Info với Producer nhận dạng được.
|
|
let norm = tmp("ff_sec_meta_norm.pdf");
|
|
ff_engine::repair(&hello(), &norm).expect("qpdf normalize");
|
|
let mut doc = lopdf::Document::load(&norm).expect("lopdf load");
|
|
let mut info = lopdf::Dictionary::new();
|
|
info.set("Producer", lopdf::Object::string_literal("FoFreeXitSecretProducer"));
|
|
info.set("Author", lopdf::Object::string_literal("NguoiDungBiMat"));
|
|
let info_id = doc.add_object(lopdf::Object::Dictionary(info));
|
|
doc.trailer.set("Info", lopdf::Object::Reference(info_id));
|
|
doc.save(&fx).expect("save fixture");
|
|
let raw_before = std::fs::read(&fx).expect("đọc fixture");
|
|
assert!(
|
|
raw_before.windows(23).any(|w| w == b"FoFreeXitSecretProducer"),
|
|
"fixture phải chứa Producer"
|
|
);
|
|
|
|
ff_engine::strip_metadata(&fx, &out).expect("strip");
|
|
|
|
let raw = std::fs::read(&out).expect("đọc out");
|
|
assert!(
|
|
!raw.windows(23).any(|w| w == b"FoFreeXitSecretProducer"),
|
|
"Producer phải biến mất khỏi bytes của file"
|
|
);
|
|
assert!(!raw.windows(14).any(|w| w == b"NguoiDungBiMat"), "Author phải biến mất");
|
|
let cleaned = lopdf::Document::load(&out).expect("lopdf load out");
|
|
assert!(cleaned.trailer.get(b"Info").is_err(), "trailer không còn /Info");
|
|
// Vẫn mở & đọc được bình thường.
|
|
assert!(pdf.load_pdf_from_file(&out, None).is_ok());
|
|
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
|
assert!(text.contains("Hello"), "nội dung trang giữ nguyên: {text:?}");
|
|
}
|