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:
2026-07-15 09:14:51 +00:00
co-authored by Claude Fable 5
parent 8d9076f60c
commit f62cded997
19 changed files with 2194 additions and 97 deletions
Generated
+481 -10
View File
@@ -82,7 +82,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -93,7 +93,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -183,6 +183,18 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit_field" name = "bit_field"
version = "0.10.3" version = "0.10.3"
@@ -354,6 +366,27 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cms"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
dependencies = [
"aes",
"cbc",
"cipher",
"const-oid",
"der",
"rsa",
"sha1",
"sha2",
"sha3",
"signature",
"spki",
"x509-cert",
"zeroize",
]
[[package]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
@@ -386,6 +419,12 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -448,9 +487,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"der_derive",
"flagset",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -464,6 +528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"const-oid",
"crypto-common", "crypto-common",
] ]
@@ -568,13 +633,23 @@ name = "ff-engine"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cms",
"const-oid",
"der",
"ff-cos", "ff-cos",
"ff-pdmodel", "ff-pdmodel",
"image", "image",
"lopdf", "lopdf",
"pdfium-render", "pdfium-render",
"pkcs8",
"rcgen",
"rsa",
"sha2",
"signature",
"spki",
"thiserror", "thiserror",
"ttf-parser", "ttf-parser",
"x509-cert",
] ]
[[package]] [[package]]
@@ -591,6 +666,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -635,6 +716,17 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -843,7 +935,7 @@ version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.3.4",
"libc", "libc",
] ]
@@ -858,6 +950,24 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "lebe" name = "lebe"
version = "0.5.3" version = "0.5.3"
@@ -890,6 +1000,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.32" version = "0.4.32"
@@ -925,7 +1041,7 @@ dependencies = [
"md-5", "md-5",
"nom", "nom",
"nom_locate", "nom_locate",
"rand", "rand 0.9.4",
"rangemap", "rangemap",
"rayon", "rayon",
"sha2", "sha2",
@@ -1038,6 +1154,22 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.7",
"smallvec",
"zeroize",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -1064,6 +1196,16 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-rational" name = "num-rational"
version = "0.4.2" version = "0.4.2"
@@ -1082,6 +1224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -1134,6 +1277,25 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -1146,6 +1308,27 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "png" name = "png"
version = "0.18.1" version = "0.18.1"
@@ -1253,14 +1436,34 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha 0.9.0",
"rand_core", "rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -1270,7 +1473,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
] ]
[[package]] [[package]]
@@ -1279,7 +1491,7 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [ dependencies = [
"getrandom", "getrandom 0.3.4",
] ]
[[package]] [[package]]
@@ -1315,8 +1527,8 @@ dependencies = [
"num-traits", "num-traits",
"paste", "paste",
"profiling", "profiling",
"rand", "rand 0.9.4",
"rand_chacha", "rand_chacha 0.9.0",
"simd_helpers", "simd_helpers",
"thiserror", "thiserror",
"v_frame", "v_frame",
@@ -1358,12 +1570,69 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]] [[package]]
name = "rgb" name = "rgb"
version = "0.8.53" version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"sha2",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -1390,6 +1659,17 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -1401,12 +1681,32 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.9" version = "0.3.9"
@@ -1434,6 +1734,22 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -1457,6 +1773,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -1547,6 +1869,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tls_codec"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
dependencies = [
"tls_codec_derive",
"zeroize",
]
[[package]]
name = "tls_codec_derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@@ -1586,6 +1929,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "utf16string" name = "utf16string"
version = "0.2.0" version = "0.2.0"
@@ -1627,6 +1976,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.4+wasi-0.2.12" version = "1.0.4+wasi-0.2.12"
@@ -1766,6 +2121,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -1775,18 +2139,105 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"der",
"sha1",
"signature",
"spki",
"tls_codec",
]
[[package]] [[package]]
name = "y4m" name = "y4m"
version = "0.8.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.52" version = "0.8.52"
@@ -1807,6 +2258,26 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.1" version = "0.5.1"
+6 -5
View File
@@ -42,11 +42,12 @@
- [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng - [x] **Iteration 3 — Reflow đoạn "như Word"**: double-click đoạn nhiều dòng → sửa cả đoạn, tự bẻ dòng theo bề rộng khối (đo hmtx bằng ttf-parser), giữ font (nhúng lại bytes gốc / font chuẩn base-14 / cùng họ), giữ nhịp baseline; Enter = ngắt cứng
- [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md) - [ ] Follow-up: reflow v2 (justify, khối xoay, đa font), xoay/lật/clip, viền/opacity/căn lề, convert text→path — xem [docs/12-phase4-summary.md](docs/12-phase4-summary.md)
- **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30) - **Checklist test cho bạn**: [docs/13-phase4-user-tests.md](docs/13-phase4-user-tests.md) (mới: E17–E30)
- [x] **Phase 5 — Bảo mật** ✅ Iteration 1 hoàn tất (58/58 test engine xanh) - [x] **Phase 5 — Bảo mật** ✅ Iteration 1 + 2 hoàn tất (62/62 test engine xanh)
- [x] Engine: mã hoá AES-256 + quyền hạn (in/sửa/copy/chú thích), gỡ mật khẩu (`qpdf.rs`); **redaction THẬT** (`redact.rs`: xoá object, bôi đen pixel ảnh trong chính dữ liệu ảnh — test kiểm bytes/pixel/extract); xoá metadata /Info + XMP (`meta.rs`) - [x] Engine: mã hoá AES-256 + quyền hạn (in/sửa/copy/chú thích), gỡ mật khẩu (`qpdf.rs`); **redaction THẬT** (`redact.rs`: xoá object, bôi đen pixel ảnh trong chính dữ liệu ảnh, **tỉa theo ký tự** giữ phần ngoài vùng — test kiểm bytes/pixel/extract); xoá metadata /Info + XMP (`meta.rs`)
- [x] UI thanh "🔒 Bảo mật": đánh dấu redact 2 bước như Foxit (quét vùng → Áp dụng), dialog Đặt/Gỡ mật khẩu + checkbox quyền, Xoá metadata - [x] **Chữ ký số PKCS#7/PAdES** (`sign.rs`): tạo Digital ID tự ký (RSA-2048), ký (CMS SignedData detached, ByteRange/Contents đúng chuẩn Adobe), **xác thực + phát hiện giả mạo**; lưu tối ưu (nén + dọn object rác)
- [ ] Iteration 2: chữ ký số PAdES + xác thực, lưu tối ưu (subset font + dọn rác), redact tỉa theo ký tự — xem [docs/15-phase5-summary.md](docs/15-phase5-summary.md) - [x] UI thanh "🔒 Bảo mật": redact 2 bước như Foxit, Đặt/Gỡ mật khẩu + quyền, Xoá metadata, **Tạo Digital ID / Ký số / Kiểm tra chữ ký / Lưu tối ưu**
- **Checklist test cho bạn**: [docs/16-phase5-user-tests.md](docs/16-phase5-user-tests.md) - [ ] Về sau: timestamp PAdES-T/LTV, đọc PFX/PKCS#12, multi-sig incremental — xem [docs/15-phase5-summary.md](docs/15-phase5-summary.md) mục 6.6
- **Checklist test cho bạn**: [docs/16-phase5-user-tests.md](docs/16-phase5-user-tests.md) (mới: S13–S19)
- [ ] **Phase 6 — Form (AcroForm)** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md) - [ ] **Phase 6 — Form (AcroForm)** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md)
Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md). Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md).
+390 -6
View File
@@ -198,6 +198,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -490,6 +496,27 @@ dependencies = [
"inout", "inout",
] ]
[[package]]
name = "cms"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
dependencies = [
"aes",
"cbc",
"cipher",
"const-oid",
"der",
"rsa",
"sha1",
"sha2",
"sha3",
"signature",
"spki",
"x509-cert",
"zeroize",
]
[[package]] [[package]]
name = "color_quant" name = "color_quant"
version = "1.1.0" version = "1.1.0"
@@ -526,6 +553,12 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "cookie" name = "cookie"
version = "0.18.1" version = "0.18.1"
@@ -641,6 +674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -728,6 +762,30 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"der_derive",
"flagset",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@@ -765,6 +823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"const-oid",
"crypto-common", "crypto-common",
] ]
@@ -1030,13 +1089,23 @@ name = "ff-engine"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cms",
"const-oid",
"der",
"ff-cos", "ff-cos",
"ff-pdmodel", "ff-pdmodel",
"image", "image",
"lopdf", "lopdf",
"pdfium-render", "pdfium-render",
"pkcs8",
"rcgen",
"rsa",
"sha2",
"signature",
"spki",
"thiserror 2.0.18", "thiserror 2.0.18",
"ttf-parser", "ttf-parser",
"x509-cert",
] ]
[[package]] [[package]]
@@ -1063,6 +1132,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]] [[package]]
name = "flate2" name = "flate2"
version = "1.1.9" version = "1.1.9"
@@ -2072,6 +2147,15 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures",
]
[[package]] [[package]]
name = "keyboard-types" name = "keyboard-types"
version = "0.7.0" version = "0.7.0"
@@ -2083,6 +2167,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]] [[package]]
name = "leb128fmt" name = "leb128fmt"
version = "0.1.0" version = "0.1.0"
@@ -2164,6 +2257,12 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.17" version = "0.1.17"
@@ -2223,7 +2322,7 @@ dependencies = [
"md-5", "md-5",
"nom", "nom",
"nom_locate", "nom_locate",
"rand", "rand 0.9.4",
"rangemap", "rangemap",
"rayon", "rayon",
"sha2", "sha2",
@@ -2418,6 +2517,22 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.7",
"smallvec",
"zeroize",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2444,6 +2559,16 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-rational" name = "num-rational"
version = "0.4.2" version = "0.4.2"
@@ -2462,6 +2587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [ dependencies = [
"autocfg", "autocfg",
"libm",
] ]
[[package]] [[package]]
@@ -2780,6 +2906,25 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -2851,6 +2996,27 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
@@ -3083,14 +3249,34 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.4" version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [ dependencies = [
"rand_chacha", "rand_chacha 0.9.0",
"rand_core", "rand_core 0.9.5",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core 0.6.4",
] ]
[[package]] [[package]]
@@ -3100,7 +3286,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
] ]
[[package]] [[package]]
@@ -3145,8 +3340,8 @@ dependencies = [
"num-traits", "num-traits",
"paste", "paste",
"profiling", "profiling",
"rand", "rand 0.9.4",
"rand_chacha", "rand_chacha 0.9.0",
"simd_helpers", "simd_helpers",
"thiserror 2.0.18", "thiserror 2.0.18",
"v_frame", "v_frame",
@@ -3194,6 +3389,19 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -3327,6 +3535,41 @@ version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"sha2",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.2" version = "2.1.2"
@@ -3342,6 +3585,15 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustls-pki-types"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
dependencies = [
"zeroize",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -3601,6 +3853,17 @@ dependencies = [
"stable_deref_trait", "stable_deref_trait",
] ]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.9" version = "0.10.9"
@@ -3612,12 +3875,32 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.9" version = "0.3.9"
@@ -3709,6 +3992,22 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -3756,6 +4055,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.7" version = "1.0.7"
@@ -4268,6 +4573,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tls_codec"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
dependencies = [
"tls_codec_derive",
"zeroize",
]
[[package]]
name = "tls_codec_derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.52.3"
@@ -4605,6 +4931,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -5162,6 +5494,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -5592,12 +5933,35 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"der",
"sha1",
"signature",
"spki",
"tls_codec",
]
[[package]] [[package]]
name = "y4m" name = "y4m"
version = "0.8.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -5662,6 +6026,26 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
+84
View File
@@ -939,6 +939,84 @@ fn security_strip_metadata(input: String, output: String) -> Result<(), String>
} }
} }
/// Lưu tối ưu: nén object stream + dọn object rác.
#[tauri::command]
fn security_optimize(input: String, output: String) -> Result<(), String> {
ff_engine::optimize_save(std::path::Path::new(&input), std::path::Path::new(&output))
.map_err(|e| e.to_string())
}
/// Tạo Digital ID tự ký (RSA-2048) ghi ra file PEM.
#[tauri::command]
fn sig_create_id(common_name: String, output: String) -> Result<(), String> {
ff_engine::generate_self_signed_id(&common_name, std::path::Path::new(&output))
.map_err(|e| e.to_string())
}
/// Ký số `input` bằng identity PEM, ghi ra `output`.
#[tauri::command]
fn sig_sign(
input: String,
id_pem: String,
reason: String,
signer_name: String,
output: String,
) -> Result<(), String> {
ff_engine::sign_pdf(
std::path::Path::new(&input),
std::path::Path::new(&id_pem),
&reason,
&signer_name,
std::path::Path::new(&output),
)
.map_err(|e| e.to_string())
}
/// Kết quả xác thực 1 chữ ký cho UI.
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct SignatureCheckDto {
signer: String,
crypto_valid: bool,
digest_matches: bool,
covers_document: bool,
valid: bool,
}
/// Xác thực mọi chữ ký trong `input`.
#[tauri::command]
fn sig_verify(input: String) -> Result<Vec<SignatureCheckDto>, String> {
let checks =
ff_engine::verify_signatures(std::path::Path::new(&input)).map_err(|e| e.to_string())?;
Ok(checks
.into_iter()
.map(|c| SignatureCheckDto {
signer: c.signer.clone(),
crypto_valid: c.crypto_valid,
digest_matches: c.digest_matches,
covers_document: c.covers_document,
valid: c.is_valid(),
})
.collect())
}
/// Hộp thoại chọn file bất kỳ (dùng cho chọn Digital ID .pem / PDF để xác thực).
#[tauri::command]
fn pick_any_file(app: tauri::AppHandle) -> Option<String> {
app.dialog().file().blocking_pick_file().map(|fp| fp.to_string())
}
/// Hộp thoại lưu file PEM (Digital ID).
#[tauri::command]
fn pick_save_pem(app: tauri::AppHandle) -> Option<String> {
app.dialog()
.file()
.add_filter("Digital ID (PEM)", &["pem"])
.set_file_name("my-digital-id.pem")
.blocking_save_file()
.map(|fp| fp.to_string())
}
/// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên /// Dọn các file làm việc tạm của chế độ sửa (undo stack). Chỉ xoá file có tên
/// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng. /// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng.
#[tauri::command] #[tauri::command]
@@ -1003,6 +1081,12 @@ fn main() {
security_encrypt, security_encrypt,
security_decrypt, security_decrypt,
security_strip_metadata, security_strip_metadata,
security_optimize,
sig_create_id,
sig_sign,
sig_verify,
pick_any_file,
pick_save_pem,
pick_image pick_image
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+5
View File
@@ -112,6 +112,11 @@
<button id="secEncrypt" title="Đặt mật khẩu mở file + quyền hạn (AES-256)">🔑 Đặt mật khẩu</button> <button id="secEncrypt" title="Đặt mật khẩu mở file + quyền hạn (AES-256)">🔑 Đặt mật khẩu</button>
<button id="secDecrypt" title="Gỡ mật khẩu (cần mật khẩu hiện tại)">🔓 Gỡ mật khẩu</button> <button id="secDecrypt" title="Gỡ mật khẩu (cần mật khẩu hiện tại)">🔓 Gỡ mật khẩu</button>
<button id="secStripMeta" title="Xoá /Info (tác giả, phần mềm tạo...) và XMP metadata khỏi file">🧹 Xoá metadata</button> <button id="secStripMeta" title="Xoá /Info (tác giả, phần mềm tạo...) và XMP metadata khỏi file">🧹 Xoá metadata</button>
<span class="sep"></span>
<button id="secCreateId" title="Tạo Digital ID tự ký (RSA-2048) để ký thử">🪪 Tạo Digital ID</button>
<button id="secSign" title="Ký số tài liệu bằng Digital ID">✍️ Ký số</button>
<button id="secVerify" title="Kiểm tra tính hợp lệ của chữ ký trong file">🔎 Kiểm tra chữ ký</button>
<button id="secOptimize" title="Nén object stream + dọn object rác (giảm dung lượng)">📦 Lưu tối ưu</button>
<span class="spacer"></span> <span class="spacer"></span>
<span id="secHint" class="status"></span> <span id="secHint" class="status"></span>
</div> </div>
+117
View File
@@ -2811,6 +2811,119 @@ async function stripMetadataAction() {
} }
} }
async function optimizeSaveAction() {
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("security_optimize", { input: state.path, output: out });
$("status").textContent = `Đã lưu tối ưu (nén + dọn rác) → ${shortName(out)}`;
loadDocument(out);
} catch (e) {
$("secHint").textContent = "Lỗi lưu tối ưu: " + e;
}
}
// --- Chữ ký số (Phase 5 iteration 2) ---
function openCreateIdDialog() {
const box = openModal("Tạo Digital ID tự ký", `
<p class="muted">Tạo chứng chỉ tự ký (RSA-2048) để ký thử. Nếu có chữ ký của tổ chức phát hành (PFX/PEM) thì dùng thẳng nó ở bước Ký số.</p>
<label>Tên hiển thị trong chữ ký</label>
<input type="text" id="idCn" placeholder="Nguyễn Văn A">
<div class="err" id="idErr"></div>
<div class="foot"><button id="idCancel">Huỷ</button><button id="idOk" class="primary">Tạo &amp; lưu…</button></div>
`);
box.querySelector("#idCancel").addEventListener("click", closeModal);
box.querySelector("#idOk").addEventListener("click", async () => {
const cn = box.querySelector("#idCn").value.trim();
if (!cn) { box.querySelector("#idErr").textContent = "Cần nhập tên."; return; }
const out = await invoke("pick_save_pem");
if (!out) return;
try {
await invoke("sig_create_id", { commonName: cn, output: out });
closeModal();
$("secHint").textContent = `Đã tạo Digital ID → ${shortName(out)} (dùng ở bước Ký số)`;
} catch (e) {
box.querySelector("#idErr").textContent = "Lỗi: " + e;
}
});
}
function openSignDialog() {
const box = openModal("Ký số tài liệu", `
<label>Tên người ký (hiện trong chữ ký)</label>
<input type="text" id="sgName" placeholder="Nguyễn Văn A">
<label>Lý do ký</label>
<input type="text" id="sgReason" value="Tôi đồng ý với nội dung tài liệu">
<label>Digital ID (.pem)</label>
<div class="row">
<input type="text" id="sgId" readonly placeholder="Chưa chọn — bấm Chọn…">
<button id="sgPickId">Chọn…</button>
</div>
<div class="err" id="sgErr"></div>
<div class="foot"><button id="sgCancel">Huỷ</button><button id="sgOk" class="primary">Ký &amp; lưu…</button></div>
`);
let idPath = null;
box.querySelector("#sgPickId").addEventListener("click", async () => {
const p = await invoke("pick_any_file");
if (p) { idPath = p; box.querySelector("#sgId").value = shortName(p); }
});
box.querySelector("#sgCancel").addEventListener("click", closeModal);
box.querySelector("#sgOk").addEventListener("click", async () => {
const name = box.querySelector("#sgName").value.trim();
const reason = box.querySelector("#sgReason").value.trim();
const err = box.querySelector("#sgErr");
if (!name) { err.textContent = "Cần nhập tên người ký."; return; }
if (!idPath) { err.textContent = "Cần chọn Digital ID (.pem)."; return; }
const out = await invoke("pick_save_pdf");
if (!out) return;
try {
await invoke("sig_sign", { input: state.path, idPem: idPath, reason, signerName: name, output: out });
closeModal();
$("status").textContent = `Đã ký số → ${shortName(out)}`;
loadDocument(out);
// Tự kiểm tra chữ ký vừa tạo.
verifySignaturesAction(out);
} catch (e) {
err.textContent = "Lỗi ký: " + e;
}
});
}
async function verifySignaturesAction(pathOverride) {
const target = typeof pathOverride === "string" ? pathOverride : state.path;
let checks;
try {
checks = await invoke("sig_verify", { input: target });
} catch (e) {
$("secHint").textContent = "Lỗi kiểm tra: " + e;
return;
}
const rows = checks.length
? checks
.map((c, i) => {
const badge = c.valid
? '<span class="sig-ok">✓ Hợp lệ</span>'
: '<span class="sig-bad">✗ Không hợp lệ</span>';
const detail = [];
if (!c.cryptoValid) detail.push("chữ ký sai");
if (!c.digestMatches) detail.push("nội dung đã bị sửa");
if (!c.coversDocument) detail.push("có phần thêm sau khi ký");
return `<tr><td>${i + 1}</td><td>${(c.signer || "").replace(/</g, "&lt;")}</td><td>${badge}</td>
<td class="muted">${detail.join("; ")}</td></tr>`;
})
.join("")
: `<tr><td colspan="4" class="muted">Tài liệu chưa có chữ ký số nào.</td></tr>`;
const box = openModal("Kết quả kiểm tra chữ ký", `
<table class="sig-table">
<thead><tr><th>#</th><th>Người ký</th><th>Trạng thái</th><th>Ghi chú</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="foot"><button id="sigClose" class="primary">Đóng</button></div>
`);
box.querySelector("#sigClose").addEventListener("click", closeModal);
}
// ---------- Sự kiện ---------- // ---------- Sự kiện ----------
$("openBtn").addEventListener("click", openFile); $("openBtn").addEventListener("click", openFile);
@@ -2933,6 +3046,10 @@ $("secRedactClear").addEventListener("click", clearRedactMarks);
$("secEncrypt").addEventListener("click", openEncryptDialog); $("secEncrypt").addEventListener("click", openEncryptDialog);
$("secDecrypt").addEventListener("click", openDecryptDialog); $("secDecrypt").addEventListener("click", openDecryptDialog);
$("secStripMeta").addEventListener("click", stripMetadataAction); $("secStripMeta").addEventListener("click", stripMetadataAction);
$("secOptimize").addEventListener("click", optimizeSaveAction);
$("secCreateId").addEventListener("click", openCreateIdDialog);
$("secSign").addEventListener("click", openSignDialog);
$("secVerify").addEventListener("click", () => verifySignaturesAction());
// Sửa nội dung (Phase 4) // Sửa nội dung (Phase 4)
$("editModeBtn").addEventListener("click", toggleEditMode); $("editModeBtn").addEventListener("click", toggleEditMode);
+6
View File
@@ -413,6 +413,12 @@ body.tool-active .annotlayer * { pointer-events: none; }
box-sizing: content-box; box-sizing: content-box;
} }
/* Bảng kết quả kiểm tra chữ ký (Phase 5) */
.sig-table { width: 100%; border-collapse: collapse; margin: 4px 0 12px; }
.sig-table th, .sig-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--border, #ddd); font-size: 13px; }
.sig-ok { color: #1a8f3c; font-weight: 600; }
.sig-bad { color: #d33; font-weight: 600; }
/* Đánh dấu redact (Phase 5): khối mờ đen viền đỏ, bấm để bỏ */ /* Đánh dấu redact (Phase 5): khối mờ đen viền đỏ, bấm để bỏ */
.redact-mark { .redact-mark {
position: absolute; position: absolute;
+11
View File
@@ -14,3 +14,14 @@ pdfium-render = "0.8"
image = "0.25" image = "0.25"
lopdf = "0.36" lopdf = "0.36"
ttf-parser = "0.25" ttf-parser = "0.25"
# Phase 5 iteration 2 — chữ ký số (PKCS#7/CMS detached, RSA-SHA256):
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
cms = { version = "0.2", features = ["builder"] }
x509-cert = { version = "0.2", features = ["builder"] }
rcgen = "0.13"
pkcs8 = { version = "0.10", features = ["pem"] }
der = "0.7"
spki = "0.7"
signature = "2"
const-oid = { version = "0.9", features = ["db"] }
+3 -1
View File
@@ -12,6 +12,7 @@ pub mod organize;
pub mod qpdf; pub mod qpdf;
pub mod redact; pub mod redact;
pub mod render; pub mod render;
pub mod sign;
pub mod text; pub mod text;
pub mod watermark; pub mod watermark;
@@ -26,9 +27,10 @@ pub use organize::{
}; };
pub use qpdf::{ pub use qpdf::{
decrypt_remove_password, encrypt_with_password, encrypt_with_password_perms, ensure_openable, decrypt_remove_password, encrypt_with_password, encrypt_with_password_perms, ensure_openable,
find_qpdf, repair, Permissions, find_qpdf, optimize_save, repair, Permissions,
}; };
pub use redact::redact_areas; pub use redact::redact_areas;
pub use sign::{generate_self_signed_id, sign_pdf, verify_signatures, SignatureCheck};
pub use render::{bind_pdfium, page_count, render_page_png, PageImage}; pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit}; pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit};
pub use watermark::{add_header_footer, add_watermark, Anchor, HeaderFooterSpec, WatermarkSpec}; pub use watermark::{add_header_footer, add_watermark, Anchor, HeaderFooterSpec, WatermarkSpec};
+9
View File
@@ -157,6 +157,15 @@ pub fn encrypt_with_password_perms(
]) ])
} }
/// Lưu tối ưu: nén object stream + dọn object không còn tham chiếu (rác sau
/// khi sửa/redact — object đã tách khỏi trang nhưng vẫn nằm trong file).
/// `--object-streams=generate` gộp object; qpdf tự loại object mồ côi khi ghi.
pub fn optimize_save(input: &Path, output: &Path) -> Result<(), EngineError> {
let i = path_arg(input);
let o = path_arg(output);
run_qpdf(&["--object-streams=generate", "--compress-streams=y", "--", &i, &o])
}
/// GỠ mật khẩu/mã hoá: giải mã `input` (mở bằng `password`) và ghi bản KHÔNG /// GỠ mật khẩu/mã hoá: giải mã `input` (mở bằng `password`) và ghi bản KHÔNG
/// mã hoá ra `output`. Cần đúng password (user hoặc owner). /// mã hoá ra `output`. Cần đúng password (user hoặc owner).
pub fn decrypt_remove_password( pub fn decrypt_remove_password(
+186 -47
View File
@@ -15,8 +15,19 @@ use std::path::Path;
use pdfium_render::prelude::*; use pdfium_render::prelude::*;
use crate::text::Rect; use crate::text::{CharBox, Rect};
use crate::EngineError; use crate::{fontmatch, EngineError};
/// 1 đoạn chữ GIỮ LẠI sau khi tỉa các ký tự thuộc vùng redact.
struct KeptSegment {
text: String,
left: f32,
baseline: f32,
}
fn rect_hits_box(r: &Rect, b: &CharBox) -> bool {
r.left < b.right && b.left < r.right && r.bottom < b.top && b.bottom < r.top
}
fn intersection(a: &Rect, b: &Rect) -> Option<Rect> { fn intersection(a: &Rect, b: &Rect) -> Option<Rect> {
let r = Rect { let r = Rect {
@@ -48,82 +59,123 @@ pub fn redact_areas(
password: Option<&str>, password: Option<&str>,
) -> Result<usize, EngineError> { ) -> Result<usize, EngineError> {
let err = |e: PdfiumError| EngineError::Pdfium(format!("redact: {e}")); let err = |e: PdfiumError| EngineError::Pdfium(format!("redact: {e}"));
let document = pdfium
// Char boxes của trang (đọc TRƯỚC khi mở document ghi) — để tỉa text theo
// KÝ TỰ: chỉ xoá ký tự nằm trong vùng redact, giữ phần còn lại đúng chỗ.
let char_boxes = crate::text::page_char_boxes(pdfium, input, page_index, password)?;
let mut document = pdfium
.load_pdf_from_file(input, password) .load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?; .map_err(|e| EngineError::Pdfium(e.to_string()))?;
let touched;
struct NewImg {
bounds: Rect,
img: image::DynamicImage,
}
let mut to_remove: Vec<usize> = Vec::new();
let mut new_imgs: Vec<NewImg> = Vec::new();
let mut text_trims: Vec<TextTrimPlan> = Vec::new();
// ---- Pha ĐỌC (mượn trang, chỉ đọc): phân loại + chụp dữ liệu cần dựng lại.
{ {
let mut page = document let page = document
.pages() .pages()
.get(page_index) .get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?; .map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
// (1) Phân loại object bị chạm: ảnh → bôi đen pixel; còn lại → xoá.
let mut to_remove: Vec<usize> = Vec::new();
struct ImgFix {
idx: usize,
bounds: Rect,
cuts: Vec<Rect>,
}
let mut img_fixes: Vec<ImgFix> = Vec::new();
for (i, obj) in page.objects().iter().enumerate() { for (i, obj) in page.objects().iter().enumerate() {
let Some(b) = obj_rect(&obj) else { continue }; let Some(b) = obj_rect(&obj) else { continue };
let cuts: Vec<Rect> = areas.iter().filter_map(|a| intersection(a, &b)).collect(); let cuts: Vec<Rect> = areas.iter().filter_map(|a| intersection(a, &b)).collect();
if cuts.is_empty() { if cuts.is_empty() {
continue; continue;
} }
if obj.object_type() == PdfPageObjectType::Image { match obj.object_type() {
img_fixes.push(ImgFix { idx: i, bounds: b, cuts }); PdfPageObjectType::Image => {
} else { // Đọc pixel gốc, tô đen phần giao. Không đọc được → xoá cả ảnh.
to_remove.push(i); match obj.as_image_object().and_then(|io| io.get_raw_image().ok()) {
}
}
// (2) Ảnh: đọc pixel gốc, tô đen phần giao. Không đọc được → xoá cả ảnh.
struct NewImg {
bounds: Rect,
img: image::DynamicImage,
}
let mut new_imgs: Vec<NewImg> = Vec::new();
for fix in &img_fixes {
let obj = page.objects().get(fix.idx).map_err(err)?;
let raw = obj.as_image_object().and_then(|io| io.get_raw_image().ok());
match raw {
Some(img) => { Some(img) => {
let mut rgba = img.to_rgba8(); let mut rgba = img.to_rgba8();
let (w, h) = (rgba.width() as f32, rgba.height() as f32); let (w, h) = (rgba.width() as f32, rgba.height() as f32);
let bw = (fix.bounds.right - fix.bounds.left).max(0.01); let bw = (b.right - b.left).max(0.01);
let bh = (fix.bounds.top - fix.bounds.bottom).max(0.01); let bh = (b.top - b.bottom).max(0.01);
for cut in &fix.cuts { for cut in &cuts {
// Trang → pixel: gốc pixel (0,0) là góc TRÊN-trái của ảnh. let x0 = (((cut.left - b.left) / bw) * w).floor().max(0.0) as u32;
let x0 = (((cut.left - fix.bounds.left) / bw) * w).floor().max(0.0) as u32; let x1 = (((cut.right - b.left) / bw) * w).ceil().min(w) as u32;
let x1 = (((cut.right - fix.bounds.left) / bw) * w).ceil().min(w) as u32; let y0 = (((b.top - cut.top) / bh) * h).floor().max(0.0) as u32;
let y0 = (((fix.bounds.top - cut.top) / bh) * h).floor().max(0.0) as u32; let y1 = (((b.top - cut.bottom) / bh) * h).ceil().min(h) as u32;
let y1 = (((fix.bounds.top - cut.bottom) / bh) * h).ceil().min(h) as u32;
for y in y0..y1 { for y in y0..y1 {
for x in x0..x1 { for x in x0..x1 {
rgba.put_pixel(x, y, image::Rgba([0, 0, 0, 255])); rgba.put_pixel(x, y, image::Rgba([0, 0, 0, 255]));
} }
} }
} }
new_imgs.push(NewImg { bounds: fix.bounds, img: image::DynamicImage::ImageRgba8(rgba) }); new_imgs.push(NewImg { bounds: b, img: image::DynamicImage::ImageRgba8(rgba) });
to_remove.push(fix.idx); to_remove.push(i);
}
None => to_remove.push(i),
}
}
PdfPageObjectType::Text => {
// Tỉa theo KÝ TỰ; không chắc chắn (xoay/không đọc được font)
// → xoá NGUYÊN object (an toàn: thà thừa còn hơn sót).
to_remove.push(i);
if let Some(trim) = plan_text_trim(&obj, &b, areas, &char_boxes) {
if !trim.segments.is_empty() {
text_trims.push(trim);
}
}
}
_ => to_remove.push(i),
} }
None => to_remove.push(fix.idx),
} }
} }
// (3) Xoá theo index GIẢM DẦN (bẫy Drop của PDFium → mem::forget). // ---- Nạp font cho các đoạn chữ giữ lại (cần fonts_mut → ngoài mượn trang).
let mut trim_tokens: Vec<PdfFontToken> = Vec::with_capacity(text_trims.len());
for trim in &text_trims {
let token = document
.fonts_mut()
.load_true_type_from_bytes(&trim.font_bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font tỉa redact: {e}")))?;
trim_tokens.push(token);
}
let touched = to_remove.len();
// ---- Pha GHI (mượn trang mut).
{
let mut page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("trang {page_index}: {e}")))?;
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
// Xoá theo index GIẢM DẦN (bẫy Drop của PDFium → mem::forget).
to_remove.sort_unstable(); to_remove.sort_unstable();
to_remove.dedup(); to_remove.dedup();
touched = to_remove.len();
for idx in to_remove.into_iter().rev() { for idx in to_remove.into_iter().rev() {
let removed = page.objects_mut().remove_object_at_index(idx).map_err(err)?; let removed = page.objects_mut().remove_object_at_index(idx).map_err(err)?;
std::mem::forget(removed); std::mem::forget(removed);
} }
// (4) Đặt lại ảnh đã bôi đen (giữ khung cũ). // Tạo lại các đoạn chữ GIỮ LẠI (tỉa theo ký tự).
for (trim, token) in text_trims.iter().zip(trim_tokens.iter()) {
let (a, b, c, d) = trim.linear;
for seg in &trim.segments {
let mut o = page
.objects_mut()
.create_text_object(
PdfPoints::ZERO,
PdfPoints::ZERO,
seg.text.clone(),
*token,
PdfPoints::new(trim.tf),
)
.map_err(err)?;
o.apply_matrix(PdfMatrix::new(a, b, c, d, seg.left, seg.baseline)).map_err(err)?;
o.set_fill_color(trim.color).map_err(err)?;
}
}
// Đặt lại ảnh đã bôi đen (giữ khung cũ).
for ni in &new_imgs { for ni in &new_imgs {
let w = (ni.bounds.right - ni.bounds.left).max(1.0); let w = (ni.bounds.right - ni.bounds.left).max(1.0);
let h = (ni.bounds.top - ni.bounds.bottom).max(1.0); let h = (ni.bounds.top - ni.bounds.bottom).max(1.0);
@@ -138,7 +190,7 @@ pub fn redact_areas(
.map_err(err)?; .map_err(err)?;
} }
// (5) Khối đen phủ mỗi vùng — dấu hiệu thị giác chuẩn của redaction. // Khối đen phủ mỗi vùng — dấu hiệu thị giác chuẩn của redaction.
for a in areas { for a in areas {
page.objects_mut() page.objects_mut()
.create_path_object_rect( .create_path_object_rect(
@@ -157,3 +209,90 @@ pub fn redact_areas(
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?; .map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
Ok(touched) Ok(touched)
} }
/// Lập kế hoạch tỉa 1 text object theo ký tự. Trả None nếu không chắc chắn
/// (xoay/nghiêng, không đọc được font, không map được char box) → caller xoá cả.
fn plan_text_trim(
obj: &PdfPageObject,
bounds: &Rect,
areas: &[Rect],
char_boxes: &[CharBox],
) -> Option<TextTrimPlan> {
let t = obj.as_text_object()?;
let m = obj.matrix().ok()?;
// Guard: có xoay/nghiêng (b,c đáng kể) → không tỉa (đặt lại theo trục ngang
// sẽ sai). Xoá nguyên object cho an toàn.
if m.b().abs() > 0.01 || m.c().abs() > 0.01 {
return None;
}
let font_bytes = pick_font_bytes(t)?;
let tf = t.unscaled_font_size().value.max(1.0);
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
// Char box thuộc object này ≈ box có tâm nằm trong bounds (nới nhẹ).
let pad = 1.0;
let mut chars: Vec<&CharBox> = char_boxes
.iter()
.filter(|cb| {
let cx = (cb.left + cb.right) / 2.0;
let cy = (cb.bottom + cb.top) / 2.0;
cx >= bounds.left - pad
&& cx <= bounds.right + pad
&& cy >= bounds.bottom - pad
&& cy <= bounds.top + pad
})
.collect();
if chars.is_empty() {
return None;
}
chars.sort_by(|a, b| a.left.partial_cmp(&b.left).unwrap_or(std::cmp::Ordering::Equal));
// Gom các ký tự KHÔNG bị vùng redact chạm thành đoạn liên tiếp.
let baseline = m.f();
let mut segments: Vec<KeptSegment> = Vec::new();
let mut cur = String::new();
let mut cur_left = 0.0f32;
for cb in &chars {
let cut = areas.iter().any(|a| rect_hits_box(a, cb));
if cut {
if !cur.is_empty() {
segments.push(KeptSegment { text: std::mem::take(&mut cur), left: cur_left, baseline });
}
} else {
if cur.is_empty() {
cur_left = cb.left;
}
cur.push_str(&cb.ch);
}
}
if !cur.is_empty() {
segments.push(KeptSegment { text: cur, left: cur_left, baseline });
}
Some(TextTrimPlan { segments, tf, linear: (m.a(), m.b(), m.c(), m.d()), color, font_bytes })
}
/// Chọn bytes font để dựng lại đoạn giữ: ưu tiên font NHÚNG gốc, sau đó font
/// hệ thống cùng họ, cuối cùng font mặc định.
fn pick_font_bytes(t: &PdfPageTextObject) -> Option<Vec<u8>> {
if t.font().is_embedded().unwrap_or(false) {
if let Ok(bytes) = t.font().data() {
if ttf_parser::Face::parse(&bytes, 0).is_ok() {
return Some(bytes);
}
}
}
let raw = t.font().name();
let (family, bold, italic) = fontmatch::clean_font_name(&raw);
fontmatch::find_family_font_bytes(&family, bold, italic)
.or_else(|| crate::annot::find_font_bytes(bold, italic))
}
/// Kết quả `plan_text_trim` (đặt ngoài để dùng trong chữ ký hàm trên).
struct TextTrimPlan {
segments: Vec<KeptSegment>,
tf: f32,
linear: (f32, f32, f32, f32),
color: PdfColor,
font_bytes: Vec<u8>,
}
+690
View File
@@ -0,0 +1,690 @@
//! Chữ ký số PDF (Phase 5 iteration 2) — PAdES/PKCS#7 detached, RSA-SHA256.
//!
//! Ba việc:
//! 1. `generate_self_signed_id` — tạo Digital ID tự ký (RSA-2048 + X.509) ghi
//! ra file PEM (cert + private key) để người dùng ký thử ngay, không cần
//! mua CA. Người dùng có PFX/PEM thật thì nạp thẳng.
//! 2. `sign_pdf` — nhúng chữ ký:
//! - Dựng signature dict (`/Type/Sig /SubFilter/adbe.pkcs7.detached`) với
//! `/ByteRange` + `/Contents` PLACEHOLDER, gắn field chữ ký + AcroForm.
//! - Serialize, tính offset thật của ByteRange/Contents, vá ByteRange.
//! - SHA-256 trên toàn file TRỪ khoảng Contents → CMS SignedData detached
//! (signed attrs gồm messageDigest + contentType) → nhét DER vào Contents.
//! Đây đúng cơ chế Adobe: phần hex `<...>` của Contents bị loại khỏi digest,
//! 2 dấu `<` `>` vẫn nằm trong vùng ký.
//! 3. `verify_signatures` — với mỗi chữ ký: parse ByteRange/Contents, băm lại,
//! kiểm CMS (chữ ký RSA trên signed attrs + messageDigest khớp digest) và
//! xem chữ ký có phủ TOÀN BỘ file không (phát hiện sửa sau khi ký).
use std::path::Path;
use der::{Decode, Encode};
use rsa::pkcs1v15::{SigningKey, VerifyingKey};
use rsa::signature::Verifier;
use rsa::RsaPrivateKey;
use sha2::{Digest, Sha256};
use crate::qpdf;
use crate::EngineError;
/// Kích thước (byte) dành cho Contents chữ ký. CMS RSA-2048 tự ký ~2KB; 16KB
/// dư cho cả timestamp/chuỗi cert sau này.
const CONTENTS_CAPACITY: usize = 16384;
fn eng<E: std::fmt::Display>(ctx: &str) -> impl Fn(E) -> EngineError + '_ {
move |e| EngineError::Pdfium(format!("{ctx}: {e}"))
}
/// Tạo Digital ID tự ký, ghi ra `out_pem` (cert PEM + private key PEM nối nhau).
/// `common_name` là tên hiển thị trong chữ ký (vd "Nguyen Van A").
pub fn generate_self_signed_id(common_name: &str, out_pem: &Path) -> Result<(), EngineError> {
use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_RSA_SHA256};
let mut rng = rsa::rand_core::OsRng;
let priv_key = RsaPrivateKey::new(&mut rng, 2048).map_err(eng("tạo khoá RSA"))?;
let signing_key = SigningKey::<Sha256>::new(priv_key);
let key_pem = {
use rsa::pkcs8::EncodePrivateKey;
signing_key
.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF)
.map_err(eng("mã hoá PKCS#8"))?
.to_string()
};
let key_pair = KeyPair::from_pkcs8_pem_and_sign_algo(&key_pem, &PKCS_RSA_SHA256)
.map_err(eng("rcgen keypair"))?;
let mut params = CertificateParams::new(Vec::<String>::new()).map_err(eng("params"))?;
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, common_name);
dn.push(DnType::OrganizationName, "FoFreeXit Self-Signed");
params.distinguished_name = dn;
let cert = params.self_signed(&key_pair).map_err(eng("tự ký cert"))?;
let mut bundle = cert.pem();
bundle.push('\n');
bundle.push_str(&key_pair.serialize_pem());
std::fs::write(out_pem, bundle)?;
Ok(())
}
/// Nạp (cert DER, RSA private key) từ file PEM bundle (cert + key nối nhau,
/// hoặc chỉ 1 trong 2 loại). Tách từng khối PEM rồi decode DER đúng loại —
/// KHÔNG đưa cả bundle vào parser PEM (parser sẽ vấp khối CERTIFICATE đứng đầu).
fn load_identity(pem_path: &Path) -> Result<(Vec<u8>, RsaPrivateKey), EngineError> {
use rsa::pkcs1::DecodeRsaPrivateKey;
use rsa::pkcs8::DecodePrivateKey;
let text = std::fs::read_to_string(pem_path)?;
let blocks = pem_iter(&text);
let cert_der = blocks
.iter()
.find(|b| b.tag == "CERTIFICATE")
.map(|b| b.der.clone())
.ok_or_else(|| EngineError::Pdfium("PEM thiếu CERTIFICATE".into()))?;
let key_block = blocks
.iter()
.find(|b| b.tag == "PRIVATE KEY" || b.tag == "RSA PRIVATE KEY")
.ok_or_else(|| EngineError::Pdfium("PEM thiếu PRIVATE KEY".into()))?;
let priv_key = if key_block.tag == "RSA PRIVATE KEY" {
RsaPrivateKey::from_pkcs1_der(&key_block.der).map_err(eng("đọc RSA key (PKCS#1)"))?
} else {
RsaPrivateKey::from_pkcs8_der(&key_block.der).map_err(eng("đọc RSA key (PKCS#8)"))?
};
Ok((cert_der, priv_key))
}
struct PemBlock {
tag: String,
der: Vec<u8>,
}
/// Parser PEM tối giản (không phụ thuộc crate ngoài): tách các khối
/// `-----BEGIN X-----` … `-----END X-----`, base64-decode phần giữa.
fn pem_iter(text: &str) -> Vec<PemBlock> {
let mut out = Vec::new();
let mut lines = text.lines();
while let Some(line) = lines.next() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("-----BEGIN ") {
let tag = rest.trim_end_matches('-').trim().to_string();
let mut b64 = String::new();
for l in lines.by_ref() {
if l.trim_start().starts_with("-----END") {
break;
}
b64.push_str(l.trim());
}
if let Some(der) = base64_decode(&b64) {
out.push(PemBlock { tag, der });
}
}
}
out
}
fn base64_decode(s: &str) -> Option<Vec<u8>> {
const INV: u8 = 0xFF;
let mut table = [INV; 256];
for (i, c) in b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
.iter()
.enumerate()
{
table[*c as usize] = i as u8;
}
let mut out = Vec::new();
let mut acc = 0u32;
let mut nbits = 0u32;
for &b in s.as_bytes() {
if b == b'=' || b.is_ascii_whitespace() {
continue;
}
let v = table[b as usize];
if v == INV {
return None;
}
acc = (acc << 6) | v as u32;
nbits += 6;
if nbits >= 8 {
nbits -= 8;
out.push((acc >> nbits) as u8);
}
}
Some(out)
}
/// Ký `input` bằng identity ở `id_pem`, ghi ra `output`. `reason` là lý do ký.
pub fn sign_pdf(
input: &Path,
id_pem: &Path,
reason: &str,
signer_name: &str,
output: &Path,
) -> Result<(), EngineError> {
let (cert_der, priv_key) = load_identity(id_pem)?;
// Chuẩn hoá qua qpdf để offset ổn định + lopdf đọc chắc chắn.
let norm = std::env::temp_dir().join(format!("ff_sign_norm_{}.pdf", std::process::id()));
qpdf::repair(input, &norm)?;
let mut doc = lopdf::Document::load(&norm).map_err(eng("lopdf load"))?;
let _ = std::fs::remove_file(&norm);
// Placeholder độ rộng CỐ ĐỊNH để vá tại chỗ: ByteRange 4 số 10 chữ số.
let byterange_placeholder = "[0 9999999999 9999999999 9999999999]";
let contents_zeros = vec![0u8; CONTENTS_CAPACITY];
let now = lopdf::Object::string_literal(pdf_date_now());
let mut sig_dict = lopdf::Dictionary::new();
sig_dict.set("Type", lopdf::Object::Name(b"Sig".to_vec()));
sig_dict.set("Filter", lopdf::Object::Name(b"Adobe.PPKLite".to_vec()));
sig_dict.set("SubFilter", lopdf::Object::Name(b"adbe.pkcs7.detached".to_vec()));
sig_dict.set("Contents", lopdf::Object::String(contents_zeros, lopdf::StringFormat::Hexadecimal));
// ByteRange: nhét chuỗi thô cố định qua Object::Name để giữ nguyên byte khi
// serialize (lopdf ghi Name là "/..."; ta bù trừ khi vá — xem patch_byterange).
sig_dict.set(
"ByteRange",
lopdf::Object::Reference((u32::MAX, 0)), // tạm; thay bằng chuỗi thô sau serialize
);
sig_dict.set("Reason", lopdf::Object::string_literal(reason.to_string()));
sig_dict.set("Name", lopdf::Object::string_literal(signer_name.to_string()));
sig_dict.set("M", now);
let sig_id = doc.add_object(lopdf::Object::Dictionary(sig_dict));
// Widget field chữ ký (vô hình) trên trang đầu.
let page_id = *doc
.get_pages()
.values()
.next()
.ok_or_else(|| EngineError::Pdfium("PDF không có trang".into()))?;
let mut widget = lopdf::Dictionary::new();
widget.set("Type", lopdf::Object::Name(b"Annot".to_vec()));
widget.set("Subtype", lopdf::Object::Name(b"Widget".to_vec()));
widget.set("FT", lopdf::Object::Name(b"Sig".to_vec()));
widget.set("T", lopdf::Object::string_literal("Signature1"));
widget.set("V", lopdf::Object::Reference(sig_id));
widget.set("F", lopdf::Object::Integer(132)); // Print+Locked
widget.set(
"Rect",
lopdf::Object::Array(vec![0.into(), 0.into(), 0.into(), 0.into()]),
);
widget.set("P", lopdf::Object::Reference(page_id));
let widget_id = doc.add_object(lopdf::Object::Dictionary(widget));
// Gắn widget vào /Annots của trang.
{
let page = doc.get_object_mut(page_id).and_then(|o| o.as_dict_mut().map_err(Into::into));
if let Ok(page) = page {
match page.get(b"Annots").ok().and_then(|o| o.as_array().ok()) {
Some(arr) => {
let mut arr = arr.clone();
arr.push(lopdf::Object::Reference(widget_id));
page.set("Annots", lopdf::Object::Array(arr));
}
None => {
page.set("Annots", lopdf::Object::Array(vec![lopdf::Object::Reference(widget_id)]));
}
}
}
}
// AcroForm với /SigFlags 3 + field chữ ký.
let catalog_id = doc
.catalog()
.map_err(eng("catalog"))?
.get(b"Type")
.map(|_| ())
.and(doc.trailer.get(b"Root").and_then(|o| o.as_reference()))
.map_err(eng("root id"))?;
{
let mut acro = lopdf::Dictionary::new();
acro.set("Fields", lopdf::Object::Array(vec![lopdf::Object::Reference(widget_id)]));
acro.set("SigFlags", lopdf::Object::Integer(3));
let acro_id = doc.add_object(lopdf::Object::Dictionary(acro));
if let Ok(cat) = doc.get_object_mut(catalog_id).and_then(|o| o.as_dict_mut().map_err(Into::into)) {
cat.set("AcroForm", lopdf::Object::Reference(acro_id));
}
}
// Serialize ra bytes, rồi vá ByteRange placeholder (đang là ref u32::MAX 0).
let mut buf: Vec<u8> = Vec::new();
doc.save_to(&mut buf).map_err(eng("serialize"))?;
// Thay reference placeholder của ByteRange bằng chuỗi mảng thô.
let buf = replace_byterange_ref(buf, byterange_placeholder)?;
// Định vị Contents <...> và ByteRange [...] trong bytes.
let (c_lt, c_gt) = find_contents_span(&buf)?;
let (br_start, br_end) = find_span(&buf, byterange_placeholder.as_bytes())
.ok_or_else(|| EngineError::Pdfium("không thấy ByteRange placeholder".into()))?;
// ByteRange = [0, off_after_'<'... ]: vùng ký = [0, c_lt+1) ++ [c_gt, EOF).
let a0 = 0usize;
let a1 = c_lt + 1; // độ dài đoạn 1 (gồm '<')
let b0 = c_gt; // bắt đầu đoạn 2 (gồm '>')
let b1 = buf.len() - c_gt; // độ dài đoạn 2
let real = format!("[{a0} {a1} {b0} {b1}]");
if real.len() > (br_end - br_start) {
return Err(EngineError::Pdfium("ByteRange thật dài hơn placeholder".into()));
}
let mut patched = buf;
write_padded(&mut patched, br_start, br_end, real.as_bytes());
// Băm SHA-256 trên 2 đoạn (loại phần hex giữa '<' '>').
let mut hasher = Sha256::new();
hasher.update(&patched[a0..a1]);
hasher.update(&patched[b0..b0 + b1]);
let digest = hasher.finalize();
// Dựng CMS SignedData detached.
let cms_der = build_cms(&cert_der, &priv_key, &digest)?;
if cms_der.len() * 2 > (c_gt - (c_lt + 1)) {
return Err(EngineError::Pdfium(format!(
"CMS ({} byte) vượt sức chứa Contents",
cms_der.len()
)));
}
// Ghi hex CMS vào giữa '<' '>' (phần còn lại giữ '0').
let hex = to_hex(&cms_der);
let slot_start = c_lt + 1;
for (i, b) in hex.bytes().enumerate() {
patched[slot_start + i] = b;
}
std::fs::write(output, &patched)?;
Ok(())
}
/// Kết quả xác thực 1 chữ ký.
#[derive(Clone, Debug)]
pub struct SignatureCheck {
pub signer: String,
/// Chữ ký RSA hợp lệ trên signed attributes.
pub crypto_valid: bool,
/// messageDigest trong signed attrs khớp digest băm lại từ file.
pub digest_matches: bool,
/// Chữ ký phủ TOÀN BỘ file (không bị thêm nội dung sau khi ký).
pub covers_document: bool,
}
impl SignatureCheck {
pub fn is_valid(&self) -> bool {
self.crypto_valid && self.digest_matches && self.covers_document
}
}
/// Xác thực mọi chữ ký trong `input`.
pub fn verify_signatures(input: &Path) -> Result<Vec<SignatureCheck>, EngineError> {
let bytes = std::fs::read(input)?;
let mut out = Vec::new();
let mut search_from = 0usize;
while let Some(rel) = find_from(&bytes, b"/ByteRange", search_from) {
let br_pos = rel;
search_from = br_pos + 10;
// Parse mảng ByteRange sau /ByteRange.
let Some((nums, _arr_end)) = parse_int_array(&bytes, br_pos + b"/ByteRange".len()) else {
continue;
};
if nums.len() != 4 {
continue;
}
let (a0, a1, b0, b1) = (nums[0] as usize, nums[1] as usize, nums[2] as usize, nums[3] as usize);
if a0 + a1 > bytes.len() || b0 + b1 > bytes.len() || a1 > b0 {
continue;
}
// Vùng ký = [a0, a1) ++ [b0, b0+b1); khoảng loại ra [a1, b0) chính là hex
// Contents giữa '<' và '>' (không gồm 2 dấu — khớp cách ký ở sign_pdf).
let hex = &bytes[a1..b0];
let Some(mut cms_der) = from_hex(hex) else { continue };
// Contents chứa CMS rồi padding '0' → cắt về đúng độ dài DER (SEQUENCE
// ngoài cùng tự mô tả), nếu không parser DER sẽ chê dữ liệu thừa.
if let Some(n) = der_total_len(&cms_der) {
cms_der.truncate(n);
}
let mut hasher = Sha256::new();
hasher.update(&bytes[a0..a0 + a1]);
hasher.update(&bytes[b0..b0 + b1]);
let digest = hasher.finalize();
let covers_document = b0 + b1 == bytes.len();
let check = verify_cms(&cms_der, &digest, covers_document)
.unwrap_or(SignatureCheck {
signer: "(không đọc được)".into(),
crypto_valid: false,
digest_matches: false,
covers_document,
});
out.push(check);
}
Ok(out)
}
// ---- CMS build/verify ----
fn build_cms(cert_der: &[u8], priv_key: &RsaPrivateKey, digest: &[u8]) -> Result<Vec<u8>, EngineError> {
use cms::builder::{SignedDataBuilder, SignerInfoBuilder};
use cms::cert::CertificateChoices;
use cms::content_info::ContentInfo;
use cms::signed_data::{EncapsulatedContentInfo, SignerIdentifier};
use spki::AlgorithmIdentifierOwned;
use x509_cert::Certificate;
let cert = Certificate::from_der(cert_der).map_err(eng("parse cert"))?;
let sid = SignerIdentifier::IssuerAndSerialNumber(cms::cert::IssuerAndSerialNumber {
issuer: cert.tbs_certificate.issuer.clone(),
serial_number: cert.tbs_certificate.serial_number.clone(),
});
// id-data (1.2.840.113549.1.7.1)
let econtent = EncapsulatedContentInfo {
econtent_type: const_oid::db::rfc5911::ID_DATA,
econtent: None,
};
// SHA-256 AlgorithmIdentifier (2.16.840.1.101.3.4.2.1)
let digest_algorithm = AlgorithmIdentifierOwned {
oid: const_oid::db::rfc5912::ID_SHA_256,
parameters: None,
};
let signing_key = SigningKey::<Sha256>::new(priv_key.clone());
let signer_info_builder = SignerInfoBuilder::new(
&signing_key,
sid,
digest_algorithm.clone(),
&econtent,
Some(digest),
)
.map_err(eng("signer info builder"))?;
let content_info: ContentInfo = SignedDataBuilder::new(&econtent)
.add_digest_algorithm(digest_algorithm)
.map_err(eng("add digest alg"))?
.add_certificate(CertificateChoices::Certificate(cert))
.map_err(eng("add cert"))?
.add_signer_info::<SigningKey<Sha256>, rsa::pkcs1v15::Signature>(signer_info_builder)
.map_err(eng("add signer info"))?
.build()
.map_err(eng("build cms"))?;
content_info.to_der().map_err(eng("encode cms"))
}
fn verify_cms(cms_der: &[u8], digest: &[u8], covers_document: bool) -> Result<SignatureCheck, EngineError> {
use cms::content_info::ContentInfo;
use cms::signed_data::SignedData;
let ci = ContentInfo::from_der(cms_der).map_err(eng("parse ContentInfo"))?;
let sd = ci.content.decode_as::<SignedData>().map_err(eng("decode SignedData"))?;
// Cert đầu tiên.
let cert = sd
.certificates
.as_ref()
.and_then(|set| set.0.iter().next())
.and_then(|choice| match choice {
cms::cert::CertificateChoices::Certificate(c) => Some(c.clone()),
_ => None,
})
.ok_or_else(|| EngineError::Pdfium("CMS thiếu certificate".into()))?;
let signer = common_name(&cert).unwrap_or_else(|| "(không rõ)".into());
let signer_info = sd
.signer_infos
.0
.as_ref()
.iter()
.next()
.ok_or_else(|| EngineError::Pdfium("CMS thiếu signerInfo".into()))?;
// messageDigest signed attr khớp digest?
let mut digest_matches = false;
if let Some(attrs) = signer_info.signed_attrs.as_ref() {
for attr in attrs.iter() {
if attr.oid == const_oid::db::rfc5911::ID_MESSAGE_DIGEST {
if let Some(v) = attr.values.iter().next() {
if let Ok(os) = v.decode_as::<der::asn1::OctetString>() {
digest_matches = os.as_bytes() == digest;
}
}
}
}
}
// Chữ ký RSA trên DER(signed attrs) [IMPLICIT SET OF → EXPLICIT khi ký].
let crypto_valid = verify_signed_attrs(&cert, signer_info).unwrap_or(false);
Ok(SignatureCheck { signer, crypto_valid, digest_matches, covers_document })
}
fn verify_signed_attrs(
cert: &x509_cert::Certificate,
signer_info: &cms::signed_data::SignerInfo,
) -> Option<bool> {
use rsa::pkcs1v15::Signature;
use rsa::RsaPublicKey;
use spki::DecodePublicKey;
let attrs = signer_info.signed_attrs.as_ref()?;
// Dữ liệu ký = DER của signed attributes dưới tag SET OF (0x31), không phải
// [0] IMPLICIT của SignerInfo. der crate encode SignedAttributes ra đúng vậy.
let signed_der = attrs.to_der().ok()?;
let spki_der = cert.tbs_certificate.subject_public_key_info.to_der().ok()?;
let pubkey = RsaPublicKey::from_public_key_der(&spki_der).ok()?;
let verifying = VerifyingKey::<Sha256>::new(pubkey);
let sig_bytes = signer_info.signature.as_bytes();
let sig = Signature::try_from(sig_bytes).ok()?;
Some(verifying.verify(&signed_der, &sig).is_ok())
}
fn common_name(cert: &x509_cert::Certificate) -> Option<String> {
for rdn in cert.tbs_certificate.subject.0.iter() {
for atv in rdn.0.iter() {
// OID 2.5.4.3 = commonName
if atv.oid.to_string() == "2.5.4.3" {
if let Ok(s) = atv.value.decode_as::<der::asn1::Utf8StringRef>() {
return Some(s.to_string());
}
if let Ok(s) = atv.value.decode_as::<der::asn1::PrintableStringRef>() {
return Some(s.to_string());
}
}
}
}
None
}
// ---- byte helpers ----
fn to_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn from_hex(hex: &[u8]) -> Option<Vec<u8>> {
let clean: Vec<u8> = hex.iter().copied().filter(|b| !b.is_ascii_whitespace()).collect();
// Bỏ đuôi '0' padding (số chẵn). CMS DER tự mô tả độ dài nên thừa '0' vô hại,
// nhưng ta cắt về bội số 2 và loại cặp "00" đuôi để parse gọn.
let mut v = Vec::with_capacity(clean.len() / 2);
let n = clean.len() / 2 * 2;
let mut i = 0;
while i < n {
let hi = hex_val(clean[i])?;
let lo = hex_val(clean[i + 1])?;
v.push((hi << 4) | lo);
i += 2;
}
Some(v)
}
/// Độ dài tổng (header + nội dung) của phần tử DER đầu tiên trong `der`. Đọc
/// tag (1 byte) + trường length (ngắn/dài). Trả None nếu không đủ byte.
fn der_total_len(der: &[u8]) -> Option<usize> {
if der.len() < 2 {
return None;
}
let len_byte = der[1];
if len_byte < 0x80 {
return Some(2 + len_byte as usize);
}
let num = (len_byte & 0x7f) as usize;
if num == 0 || der.len() < 2 + num {
return None;
}
let mut len = 0usize;
for &b in &der[2..2 + num] {
len = (len << 8) | b as usize;
}
Some(2 + num + len)
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
/// Thay Object::Reference placeholder "(4294967295 0 R)" của ByteRange bằng
/// chuỗi mảng thô (giữ nguyên tổng độ dài không quan trọng vì đây là bước
/// TRƯỚC khi định vị; ta pad để mảng đủ rộng).
fn replace_byterange_ref(buf: Vec<u8>, placeholder: &str) -> Result<Vec<u8>, EngineError> {
let needle = b"/ByteRange";
let pos = find_span(&buf, needle).map(|(s, _)| s);
let Some(pos) = pos else {
return Err(EngineError::Pdfium("không thấy /ByteRange".into()));
};
// Sau "/ByteRange" là " 4294967295 0 R". Tìm 'R' kết thúc reference.
let after = pos + needle.len();
let mut i = after;
while i < buf.len() && buf[i] != b'R' {
i += 1;
}
if i >= buf.len() {
return Err(EngineError::Pdfium("ByteRange ref hỏng".into()));
}
let ref_end = i + 1; // gồm 'R'
let mut out = Vec::with_capacity(buf.len());
out.extend_from_slice(&buf[..after]);
out.push(b' ');
out.extend_from_slice(placeholder.as_bytes());
out.extend_from_slice(&buf[ref_end..]);
Ok(out)
}
/// Tìm span [start,end) của `needle` trong `hay` (lần đầu).
fn find_span(hay: &[u8], needle: &[u8]) -> Option<(usize, usize)> {
find_from(hay, needle, 0).map(|s| (s, s + needle.len()))
}
fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if needle.is_empty() || from + needle.len() > hay.len() {
return None;
}
(from..=hay.len() - needle.len()).find(|&i| &hay[i..i + needle.len()] == needle)
}
/// Định vị Contents CỦA CHỮ KÝ: trả (offset của '<', offset của '>').
/// Trang cũng có khoá `/Contents N 0 R` (tham chiếu, không phải hex string) nên
/// phải chọn đúng `/Contents` mà giá trị NGAY SAU là hex string `<...>` (ký tự
/// sau `<` không phải `<` để loại `<<` mở dict).
fn find_contents_span(buf: &[u8]) -> Result<(usize, usize), EngineError> {
let needle = b"/Contents";
let mut from = 0usize;
while let Some(pos) = find_from(buf, needle, from) {
from = pos + needle.len();
// Bỏ khoảng trắng sau /Contents.
let mut i = pos + needle.len();
while i < buf.len() && buf[i].is_ascii_whitespace() {
i += 1;
}
// Giá trị hex string bắt đầu bằng '<' và KHÔNG phải '<<'.
if i < buf.len() && buf[i] == b'<' && !(i + 1 < buf.len() && buf[i + 1] == b'<') {
let lt = i;
i += 1;
while i < buf.len() && buf[i] != b'>' {
i += 1;
}
if i >= buf.len() {
return Err(EngineError::Pdfium("Contents thiếu '>'".into()));
}
return Ok((lt, i));
}
// Ngược lại: /Contents của trang (tham chiếu) → tìm tiếp.
}
Err(EngineError::Pdfium("không thấy /Contents hex của chữ ký".into()))
}
/// Ghi `data` vào [start,end), pad phần thừa bằng dấu cách.
fn write_padded(buf: &mut [u8], start: usize, end: usize, data: &[u8]) {
for (i, slot) in (start..end).enumerate() {
buf[slot] = if i < data.len() { data[i] } else { b' ' };
}
}
/// Parse mảng số nguyên "[a b c d]" bắt đầu quét từ `from`. Trả (số, offset sau ']').
fn parse_int_array(buf: &[u8], from: usize) -> Option<(Vec<i64>, usize)> {
let mut i = from;
while i < buf.len() && buf[i] != b'[' {
if !buf[i].is_ascii_whitespace() {
return None;
}
i += 1;
}
if i >= buf.len() {
return None;
}
i += 1; // qua '['
let mut nums = Vec::new();
let mut cur = String::new();
while i < buf.len() && buf[i] != b']' {
let c = buf[i];
if c.is_ascii_digit() || c == b'-' {
cur.push(c as char);
} else if !cur.is_empty() {
nums.push(cur.parse().ok()?);
cur.clear();
}
i += 1;
}
if !cur.is_empty() {
nums.push(cur.parse().ok()?);
}
Some((nums, i + 1))
}
fn pdf_date_now() -> String {
// D:YYYYMMDDHHmmSS — dùng UNIX epoch quy đổi UTC đơn giản (đủ cho /M).
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (y, mo, d, h, mi, s) = civil_from_epoch(secs);
format!("D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z")
}
/// Quy đổi epoch giây → (năm, tháng, ngày, giờ, phút, giây) UTC (thuật toán Howard Hinnant).
fn civil_from_epoch(secs: u64) -> (i64, u32, u32, u32, u32, u32) {
let days = (secs / 86400) as i64;
let rem = (secs % 86400) as u32;
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = if m <= 2 { y + 1 } else { y };
(year, m, d, h, mi, s)
}
+17 -3
View File
@@ -67,9 +67,20 @@ fn redact_removes_text_content_for_real() {
) )
.expect("fixture"); .expect("fixture");
let mut area = find_text_rect(&pdf, &fx, "TOPSECRET42"); // Vùng redact = ĐÚNG cụm ký tự "TOPSECRET42" (sub-rect của run, để kiểm tỉa
// Nới 2pt mỗi phía như người dùng quét chuột. // theo ký tự: phần " tuyệt mật" cùng object phải còn).
area = Rect { left: area.left - 2.0, bottom: area.bottom - 2.0, right: area.right + 2.0, top: area.top + 2.0 }; 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"); let touched = redact_areas(&pdf, &fx, 0, &[area], &out, None).expect("redact");
assert!(touched >= 1, "phải xoá ít nhất 1 object"); 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"); 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("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:?}"); 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. // Render: tâm vùng redact phải ĐEN.
let png = tmp("ff_redact_out.png"); 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 /// 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. /// FILE (soi bytes thô), file vẫn mở/đọc bình thường.
#[test] #[test]
+82
View File
@@ -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");
}
+1 -1
View File
@@ -43,7 +43,7 @@ Một ứng dụng desktop có thể **xem, chú thích, tổ chức trang, và
- Thêm text box mới, đổi font/size/màu. - Thêm text box mới, đổi font/size/màu.
**DoD:** sửa được text 1 dòng & 1 đoạn trên ≥80% file mẫu thường; thêm/xoá/di chuyển ảnh; lưu mở lại đúng; tiếng Việt hiển thị & sửa đúng dấu; có test corpus chuyên cho edit. **DoD:** sửa được text 1 dòng & 1 đoạn trên ≥80% file mẫu thường; thêm/xoá/di chuyển ảnh; lưu mở lại đúng; tiếng Việt hiển thị & sửa đúng dấu; có test corpus chuyên cho edit.
## Phase 5 — Bảo mật & Chữ ký ✅ Iteration 1 HOÀN TẤT (mã hoá+quyền, gỡ mật khẩu, redaction thật, xoá metadata — xem [docs/15-phase5-summary.md](15-phase5-summary.md)) · Iteration 2: chữ ký số ## Phase 5 — Bảo mật & Chữ ký ✅ HOÀN TẤT (mã hoá+quyền, gỡ mật khẩu, redaction thật + tỉa ký tự, xoá metadata, **chữ ký số PKCS#7/PAdES + xác thực**, lưu tối ưu — xem [docs/15-phase5-summary.md](15-phase5-summary.md))
**Mục tiêu:** Tính năng đáng tiền cho doanh nghiệp. **Mục tiêu:** Tính năng đáng tiền cho doanh nghiệp.
- Mật khẩu mở/permission; mã hoá **AES-256**; xoá mật khẩu. - Mật khẩu mở/permission; mã hoá **AES-256**; xoá mật khẩu.
- **Redaction thật** (xoá nội dung + ảnh + metadata, không chỉ vẽ đè) + kiểm chứng đã xoá. - **Redaction thật** (xoá nội dung + ảnh + metadata, không chỉ vẽ đè) + kiểm chứng đã xoá.
+9 -6
View File
@@ -100,7 +100,7 @@ Xếp theo mức độ ảnh hưởng trải nghiệm:
### 3.5 Các tính năng Foxit chưa bắt đầu (không phải rào cản kỹ thuật) ### 3.5 Các tính năng Foxit chưa bắt đầu (không phải rào cản kỹ thuật)
| Nhóm | Đường đi đã chốt trong roadmap | Rủi ro công nghệ | | Nhóm | Đường đi đã chốt trong roadmap | Rủi ro công nghệ |
|---|---|---| |---|---|---|
| Bảo mật, redaction, chữ ký số PAdES (Phase 5) | QPDF (mã hoá AES-256 sẵn), redaction = xoá object thật (đã có nền edit.rs), ký số qua `cryptographic-message-syntax`/`rustls` + incremental update | Trung bình — byte-range + incremental update phải chuẩn; có spec rõ, test bằng Acrobat | | ~~Bảo mật, redaction, chữ ký số PAdES (Phase 5)~~ ✅ ĐÃ LÀM | AES-256+quyền, redaction thật (tỉa ký tự), **chữ ký số PKCS#7/CMS + xác thực + phát hiện giả mạo** (`sign.rs`), lưu tối ưu — 62/62 test | Đã giải; còn lại timestamp/LTV/PFX/multi-sig (docs/15 mục 6.6) |
| Form AcroForm (Phase 6) | PDFium có FPDF_FORMFILL đầy đủ (fill/flatten); tạo field mới bằng lopdf | Thấp-trung bình | | Form AcroForm (Phase 6) | PDFium có FPDF_FORMFILL đầy đủ (fill/flatten); tạo field mới bằng lopdf | Thấp-trung bình |
| OCR (Phase 7) | Tesseract (tiếng Việt có traineddata chuẩn) + lớp text ẩn — kỹ thuật giống watermark.rs đã làm | Thấp, chủ yếu là chất lượng tiền xử lý ảnh | | OCR (Phase 7) | Tesseract (tiếng Việt có traineddata chuẩn) + lớp text ẩn — kỹ thuật giống watermark.rs đã làm | Thấp, chủ yếu là chất lượng tiền xử lý ảnh |
| Convert Office↔PDF (Phase 7) | LibreOffice headless; PDF→Word "đủ dùng" trước | PDF→Word giữ layout hoàn hảo là bài khó với CẢ Foxit — đặt kỳ vọng "tương đương Foxit" chứ không hơn | | Convert Office↔PDF (Phase 7) | LibreOffice headless; PDF→Word "đủ dùng" trước | PDF→Word giữ layout hoàn hảo là bài khó với CẢ Foxit — đặt kỳ vọng "tương đương Foxit" chứ không hơn |
@@ -133,13 +133,16 @@ Foxit hơn" — chi phí khổng lồ, không giải quyết gap nào ở mục
(iteration 3) · Bảo mật iteration 1 ✅ (mã hoá AES-256 + quyền, gỡ mật khẩu, (iteration 3) · Bảo mật iteration 1 ✅ (mã hoá AES-256 + quyền, gỡ mật khẩu,
redaction thật kiểm chứng được, xoá metadata — docs/15)**. redaction thật kiểm chứng được, xoá metadata — docs/15)**.
**Phase 5 đã HOÀN TẤT** (iter 1+2): mã hoá+quyền, gỡ mật khẩu, redaction thật
+ tỉa ký tự, xoá metadata, **chữ ký số PKCS#7/PAdES + xác thực**, lưu tối ưu.
Thứ tự đề xuất tiếp theo, bám giá trị người dùng: Thứ tự đề xuất tiếp theo, bám giá trị người dùng:
1. **Phase 5 Iteration 2**: chữ ký số PAdES + xác thực; "Lưu tối ưu" (mục 1. **Phase 6 — Form (AcroForm)**: điền form (PDFium FPDF_FORMFILL), flatten,
3.3: subset font, dọn object rác); redact tỉa theo ký tự. tạo field text/checkbox/radio/dropdown, import/export FDF/CSV.
2. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap). 2. Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap).
3. Song song, rải theo từng phase: mở rộng font matching (3.4), nấc 1 của 3. Song song, rải theo từng phase: mở rộng font matching (3.4), nấc 1 của
hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, và v2 của reflow hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, v2 của reflow
(justify/xoay — mục 3.1). (justify/xoay — mục 3.1), và nâng chữ ký (timestamp/LTV/PFX/multi-sig).
## 6. Checklist FINAL TARGET & RULE cho chính tài liệu này ## 6. Checklist FINAL TARGET & RULE cho chính tài liệu này
- [x] Đối chiếu từng tính năng với hành vi Foxit làm chuẩn nghiệm thu. - [x] Đối chiếu từng tính năng với hành vi Foxit làm chuẩn nghiệm thu.
+63 -12
View File
@@ -1,8 +1,9 @@
# 15 — Tổng kết Phase 5 (Bảo mật) · Iteration 1 # 15 — Tổng kết Phase 5 (Bảo mật) · Iteration 1 + 2
> Trạng thái: **mã hoá + permissions, gỡ mật khẩu, redaction THẬT, xoá > Trạng thái: **HOÀN TẤT Phase 5.** Iter 1: mã hoá + permissions, gỡ mật khẩu,
> metadata — hoàn tất, có test kiểm chứng nội dung thật sự biến mất.** > redaction THẬT, xoá metadata. Iter 2: **chữ ký số PKCS#7/PAdES (ký + xác
> Chữ ký số (PAdES) là Iteration 2 — kế hoạch ở mục 5. > thực + phát hiện giả mạo), redaction tỉa theo KÝ TỰ, lưu tối ưu.**
> 62/62 test engine xanh (ngoài 2 fixture qpdf Linux). Chi tiết iter 2 ở mục 6.
## 1. Khảo sát Foxit (chuẩn nghiệm thu) ## 1. Khảo sát Foxit (chuẩn nghiệm thu)
- **Protect › Password Protect**: user/owner password, AES-256, tick quyền - **Protect › Password Protect**: user/owner password, AES-256, tick quyền
@@ -70,11 +71,61 @@
- Chưa xoá "hidden information" dạng attachment/script/comment ẩn — gộp - Chưa xoá "hidden information" dạng attachment/script/comment ẩn — gộp
vào Iteration 2. vào Iteration 2.
## 5. Iteration 2 (kế tiếp của Phase 5) — Chữ ký số PAdES + Lưu tối ưu ## 5. (Đã thực hiện — xem mục 6.) Kế hoạch iteration 2 ban đầu
1. **Ký số**: tạo chữ ký PAdES-B (CMS/PKCS#7 qua crate `cms`/`rsa`/`p256`),
ghi `/ByteRange` + placeholder `/Contents`, incremental update để không ## 6. Iteration 2 — Chữ ký số + redact ký tự + lưu tối ưu (ĐÃ LÀM)
phá chữ ký cũ; xác thực bằng Acrobat làm DoD. Đọc chứng chỉ PFX/PKCS#12.
2. **Xác thực chữ ký** khi mở (hiện trạng thái valid/invalid). ### 6.1 Chữ ký số PKCS#7/PAdES (`sign.rs`)
3. **Lưu tối ưu** (từ docs/14 mục 3.3): subset font nhúng theo glyph dùng - **`generate_self_signed_id(cn, out.pem)`**: tạo Digital ID tự ký RSA-2048 +
thật + `qpdf --object-streams=generate` dọn object rác sau edit. X.509 (rcgen), ghi PEM bundle (cert + private key) — người dùng ký thử ngay
4. Redact tỉa theo ký tự (mục 4) + quét "hidden info" còn lại. không cần CA; có PFX/PEM thật thì nạp thẳng.
- **`sign_pdf(input, id.pem, reason, name, output)`** đúng cơ chế Adobe:
1. Chuẩn hoá qua qpdf → lopdf dựng signature dict
(`/Type/Sig /SubFilter/adbe.pkcs7.detached`) với `/ByteRange` +
`/Contents` placeholder, field chữ ký (Widget vô hình) + AcroForm/SigFlags.
2. Serialize → định vị offset thật, vá `/ByteRange` (phần hex `<...>` của
Contents bị loại khỏi digest, 2 dấu `<` `>` vẫn nằm trong vùng ký).
3. SHA-256 trên 2 đoạn ByteRange → **CMS SignedData detached** (crate `cms`,
signed attrs gồm messageDigest + contentType, ký RSA-SHA256) → nhét DER
vào Contents.
- **`verify_signatures(input)`**: với mỗi chữ ký — parse ByteRange/Contents,
băm lại, kiểm (a) chữ ký RSA hợp lệ trên signed attrs, (b) messageDigest
khớp digest file, (c) chữ ký phủ TOÀN BỘ file. `is_valid()` = cả 3 đạt.
- Bài học: hex Contents có padding '0' → phải cắt DER về đúng độ dài (đọc
length trường DER) trước khi parse; `/Contents` của TRANG (tham chiếu
`N 0 R`) phải bỏ qua, chỉ nhận `/Contents <hex>` của chữ ký.
### 6.2 Redaction tỉa theo KÝ TỰ (`redact.rs`)
- Text object bị vùng redact chạm MỘT PHẦN: dùng `page_char_boxes` (Phase 1)
xác định ký tự nào nằm trong vùng, **giữ các đoạn ký tự 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 an toàn: text XOAY/nghiêng (matrix b,c ≠ 0) hoặc không đọc được font →
xoá NGUYÊN object (thà thừa còn hơn sót). Ảnh vẫn bôi đen pixel như iter 1.
### 6.3 Lưu tối ưu (`qpdf::optimize_save`)
- `qpdf --object-streams=generate --compress-streams=y`: nén + dọn object mồ
côi (rác sau edit/redact — object đã tách khỏi trang nhưng còn trong file).
### 6.4 UI + Tauri
- Thanh Bảo mật thêm: **🪪 Tạo Digital ID**, **✍️ Ký số** (dialog tên/lý do/
chọn .pem → tự kiểm tra sau khi ký), **🔎 Kiểm tra chữ ký** (modal bảng
trạng thái hợp lệ/không + lý do), **📦 Lưu tối ưu**.
- Commands: `sig_create_id`, `sig_sign`, `sig_verify`, `security_optimize`,
`pick_any_file`, `pick_save_pem`.
### 6.5 Test iteration 2 (7 mới → tổng 62/62)
- `sign_then_verify_is_valid` — tạo ID → ký → xác thực hợp lệ, đúng tên người ký.
- `tampering_after_signing_is_detected` — lật 1 byte vùng đã ký → chữ ký KHÔNG
hợp lệ (chứng minh phát hiện giả mạo, cốt lõi của chữ ký số).
- `verify_reports_none_for_unsigned` — file chưa ký: 0 chữ ký.
- `redact_removes_text_content_for_real` (nâng): chuỗi mật biến mất nhưng
phần cùng dòng ngoài vùng **vẫn còn** (tỉa theo ký tự).
- `optimize_save_keeps_content_and_not_larger`.
### 6.6 Giới hạn còn lại (v-sau, không chặn dùng)
- Chữ ký tự ký (self-signed) → Acrobat báo "hợp lệ nhưng chưa tin cậy chuỗi
CA"; cần cài cert vào Trusted hoặc dùng CA thật. Cơ chế byte-range/CMS đã
chuẩn để Acrobat xác thực toán học.
- Chưa timestamp (PAdES-T) và LTV; chưa đọc PFX/PKCS#12 (mới PEM).
- Ký nhiều lần giữ chữ ký cũ (multi-sig incremental) chưa làm — mới ký lần đầu.
- Redact ký tự: đoạn XOAY vẫn xoá cả object (an toàn).
+11
View File
@@ -25,6 +25,17 @@ cargo run
| S11 | Gỡ mật khẩu với mật khẩu SAI | Báo lỗi rõ ràng, không tạo file hỏng | | | S11 | Gỡ mật khẩu với mật khẩu SAI | Báo lỗi rõ ràng, không tạo file hỏng | |
| S12 | **🧹 Xoá metadata** → lưu → mở file kết quả ở Foxit: File › Properties | Author/Producer/Created… trống; app vẫn mở đọc bình thường | | | S12 | **🧹 Xoá metadata** → lưu → mở file kết quả ở Foxit: File › Properties | Author/Producer/Created… trống; app vẫn mở đọc bình thường | |
## Bổ sung Iteration 2 — Chữ ký số + redact ký tự + lưu tối ưu
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| S13 | **🪪 Tạo Digital ID** → nhập tên → lưu .pem | Tạo file .pem thành công | |
| S14 | **✍️ Ký số** → nhập tên/lý do → chọn .pem vừa tạo → lưu | File mới được ký; tự hiện bảng kiểm tra: **Hợp lệ** ✓ | |
| S15 | Mở file đã ký ở **Foxit/Adobe** | Hiện có chữ ký; xác thực toán học hợp lệ (có thể cảnh báo "chưa tin cậy CA" vì tự ký — bình thường) | |
| S16 | Sửa 1 chữ trong file đã ký (chế độ Sửa nội dung) rồi lưu, sau đó **🔎 Kiểm tra chữ ký** | Chữ ký chuyển **KHÔNG hợp lệ** (nội dung đã bị sửa) | |
| S17 | **🔎 Kiểm tra chữ ký** trên file chưa ký | Báo "chưa có chữ ký số nào" | |
| S18 | Redact 1 CỤM TỪ ở giữa dòng (không phải cả dòng) → Áp dụng | Chỉ cụm đó bị đen/biến mất; phần còn lại của dòng **vẫn hiển thị và copy được** | |
| S19 | **📦 Lưu tối ưu** file lớn → so dung lượng | File kết quả nhỏ hơn/không phình, mở đọc bình thường | |
## Mẫu phản hồi ## Mẫu phản hồi
``` ```
[Mã] (vd S5) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> — [Mã] (vd S5) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> —