Phase 5 Iteration 2: chữ ký số PKCS#7/PAdES + redact tỉa ký tự + lưu tối ưu
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
This commit is contained in:
@@ -67,9 +67,20 @@ fn redact_removes_text_content_for_real() {
|
||||
)
|
||||
.expect("fixture");
|
||||
|
||||
let mut area = find_text_rect(&pdf, &fx, "TOPSECRET42");
|
||||
// Nới 2pt mỗi phía như người dùng quét chuột.
|
||||
area = Rect { left: area.left - 2.0, bottom: area.bottom - 2.0, right: area.right + 2.0, top: area.top + 2.0 };
|
||||
// Vùng redact = ĐÚNG cụm ký tự "TOPSECRET42" (sub-rect của run, để kiểm tỉa
|
||||
// theo ký tự: phần " tuyệt mật" cùng object phải còn).
|
||||
let boxes = ff_engine::page_char_boxes(&pdf, &fx, 0, None).expect("char boxes");
|
||||
let secret_boxes: Vec<_> = boxes.iter().filter(|b| b.top < 620.0 && b.bottom > 580.0).collect();
|
||||
// Lấy 11 ký tự đầu của dòng (TOPSECRET42) theo thứ tự trái→phải.
|
||||
let mut sorted = secret_boxes.clone();
|
||||
sorted.sort_by(|a, b| a.left.partial_cmp(&b.left).unwrap());
|
||||
let head = &sorted[..sorted.len().min(11)];
|
||||
let area = Rect {
|
||||
left: head.iter().map(|b| b.left).fold(f32::INFINITY, f32::min) - 1.0,
|
||||
right: head.iter().map(|b| b.right).fold(f32::NEG_INFINITY, f32::max) + 1.0,
|
||||
bottom: head.iter().map(|b| b.bottom).fold(f32::INFINITY, f32::min) - 2.0,
|
||||
top: head.iter().map(|b| b.top).fold(f32::NEG_INFINITY, f32::max) + 2.0,
|
||||
};
|
||||
|
||||
let touched = redact_areas(&pdf, &fx, 0, &[area], &out, None).expect("redact");
|
||||
assert!(touched >= 1, "phải xoá ít nhất 1 object");
|
||||
@@ -77,6 +88,9 @@ fn redact_removes_text_content_for_real() {
|
||||
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
|
||||
assert!(!text.contains("TOPSECRET42"), "nội dung mật phải BIẾN MẤT: {text:?}");
|
||||
assert!(text.contains("PUBLICINFO"), "nội dung ngoài vùng phải còn: {text:?}");
|
||||
// Cùng dòng với chuỗi mật vẫn còn phần "tuyệt mật" (tỉa theo ký tự, không
|
||||
// xoá cả dòng) — chỉ TOPSECRET42 bị chạm.
|
||||
assert!(text.contains("mật"), "phần ngoài vùng của cùng dòng phải giữ: {text:?}");
|
||||
|
||||
// Render: tâm vùng redact phải ĐEN.
|
||||
let png = tmp("ff_redact_out.png");
|
||||
|
||||
@@ -81,6 +81,23 @@ fn decrypt_with_wrong_password_fails() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Test chữ ký số (Phase 5 iteration 2): tạo Digital ID → ký → xác thực hợp
|
||||
//! lệ; và phát hiện GIẢ MẠO (sửa 1 byte nội dung sau khi ký → chữ ký hỏng).
|
||||
//! Cần binary `qpdf`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.canonicalize()
|
||||
.expect("workspace root")
|
||||
}
|
||||
|
||||
fn ensure_qpdf() {
|
||||
if std::env::var("FOFREEXIT_QPDF_PATH").is_err() {
|
||||
std::env::set_var("FOFREEXIT_QPDF_PATH", "/usr/bin");
|
||||
}
|
||||
}
|
||||
|
||||
fn sample() -> PathBuf {
|
||||
workspace_root().join("corpus").join("sample-multipage.pdf")
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(name);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sign_then_verify_is_valid() {
|
||||
ensure_qpdf();
|
||||
let id = tmp("ff_sign_id.pem");
|
||||
let out = tmp("ff_signed.pdf");
|
||||
ff_engine::generate_self_signed_id("Nguyen Van Ky", &id).expect("tạo ID");
|
||||
ff_engine::sign_pdf(&sample(), &id, "Tôi đồng ý nội dung", "Nguyen Van Ky", &out).expect("ký");
|
||||
|
||||
let checks = ff_engine::verify_signatures(&out).expect("verify");
|
||||
assert_eq!(checks.len(), 1, "phải có đúng 1 chữ ký");
|
||||
let c = &checks[0];
|
||||
assert!(c.crypto_valid, "chữ ký RSA phải hợp lệ");
|
||||
assert!(c.digest_matches, "messageDigest phải khớp digest file");
|
||||
assert!(c.covers_document, "chữ ký phải phủ toàn bộ file");
|
||||
assert!(c.is_valid(), "tổng thể phải VALID");
|
||||
assert!(c.signer.contains("Nguyen Van Ky"), "tên người ký: {:?}", c.signer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampering_after_signing_is_detected() {
|
||||
ensure_qpdf();
|
||||
let id = tmp("ff_sign_id2.pem");
|
||||
let signed = tmp("ff_signed2.pdf");
|
||||
let tampered = tmp("ff_tampered2.pdf");
|
||||
ff_engine::generate_self_signed_id("Kiem Thu", &id).expect("tạo ID");
|
||||
ff_engine::sign_pdf(&sample(), &id, "ký", "Kiem Thu", &signed).expect("ký");
|
||||
|
||||
// Sửa 1 byte trong VÙNG ĐƯỢC KÝ (đầu file — header/nội dung), không đụng
|
||||
// vùng Contents. Chữ ký phải phát hiện.
|
||||
let mut bytes = std::fs::read(&signed).expect("đọc signed");
|
||||
// Tìm 1 byte chữ cái gần đầu (sau "%PDF-") để lật.
|
||||
let pos = 8;
|
||||
bytes[pos] ^= 0x01;
|
||||
std::fs::write(&tampered, &bytes).expect("ghi tampered");
|
||||
|
||||
let checks = ff_engine::verify_signatures(&tampered).expect("verify tampered");
|
||||
assert_eq!(checks.len(), 1);
|
||||
assert!(
|
||||
!checks[0].is_valid(),
|
||||
"sửa nội dung sau khi ký phải làm chữ ký KHÔNG hợp lệ (digest_matches={}, crypto={}, covers={})",
|
||||
checks[0].digest_matches,
|
||||
checks[0].crypto_valid,
|
||||
checks[0].covers_document
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_reports_none_for_unsigned() {
|
||||
ensure_qpdf();
|
||||
let checks = ff_engine::verify_signatures(&sample()).expect("verify unsigned");
|
||||
assert!(checks.is_empty(), "file chưa ký không có chữ ký nào");
|
||||
}
|
||||
Reference in New Issue
Block a user