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"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -93,7 +93,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -183,6 +183,18 @@ dependencies = [
"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]]
name = "bit_field"
version = "0.10.3"
@@ -354,6 +366,27 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "color_quant"
version = "1.1.0"
@@ -386,6 +419,12 @@ dependencies = [
"web-sys",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -448,9 +487,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"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]]
name = "deranged"
version = "0.5.8"
@@ -464,6 +528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
@@ -568,13 +633,23 @@ name = "ff-engine"
version = "0.1.0"
dependencies = [
"anyhow",
"cms",
"const-oid",
"der",
"ff-cos",
"ff-pdmodel",
"image",
"lopdf",
"pdfium-render",
"pkcs8",
"rcgen",
"rsa",
"sha2",
"signature",
"spki",
"thiserror",
"ttf-parser",
"x509-cert",
]
[[package]]
@@ -591,6 +666,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -635,6 +716,17 @@ dependencies = [
"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]]
name = "getrandom"
version = "0.3.4"
@@ -843,7 +935,7 @@ version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom",
"getrandom 0.3.4",
"libc",
]
@@ -858,6 +950,24 @@ dependencies = [
"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]]
name = "lebe"
version = "0.5.3"
@@ -890,6 +1000,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "log"
version = "0.4.32"
@@ -925,7 +1041,7 @@ dependencies = [
"md-5",
"nom",
"nom_locate",
"rand",
"rand 0.9.4",
"rangemap",
"rayon",
"sha2",
@@ -1038,6 +1154,22 @@ dependencies = [
"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]]
name = "num-conv"
version = "0.2.2"
@@ -1064,6 +1196,16 @@ dependencies = [
"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]]
name = "num-rational"
version = "0.4.2"
@@ -1082,6 +1224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@@ -1134,6 +1277,25 @@ dependencies = [
"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]]
name = "pin-project-lite"
version = "0.2.17"
@@ -1146,6 +1308,27 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "png"
version = "0.18.1"
@@ -1253,14 +1436,34 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
"rand_chacha 0.9.0",
"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]]
@@ -1270,7 +1473,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"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]]
@@ -1279,7 +1491,7 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
"getrandom 0.3.4",
]
[[package]]
@@ -1315,8 +1527,8 @@ dependencies = [
"num-traits",
"paste",
"profiling",
"rand",
"rand_chacha",
"rand 0.9.4",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror",
"v_frame",
@@ -1358,12 +1570,69 @@ dependencies = [
"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]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rustversion"
version = "1.0.22"
@@ -1390,6 +1659,17 @@ dependencies = [
"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]]
name = "sha2"
version = "0.10.9"
@@ -1401,12 +1681,32 @@ dependencies = [
"digest",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "simd-adler32"
version = "0.3.9"
@@ -1434,6 +1734,22 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -1457,6 +1773,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -1547,6 +1869,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "ttf-parser"
version = "0.25.1"
@@ -1586,6 +1929,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "utf16string"
version = "0.2.0"
@@ -1627,6 +1976,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
@@ -1766,6 +2121,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.61.2"
@@ -1775,18 +2139,105 @@ dependencies = [
"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]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "zerocopy"
version = "0.8.52"
@@ -1807,6 +2258,26 @@ dependencies = [
"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]]
name = "zune-core"
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
- [ ] 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)
- [x] **Phase 5 — Bảo mật** ✅ Iteration 1 hoàn tất (58/58 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] 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
- [ ] 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)
- **Checklist test cho bạn**: [docs/16-phase5-user-tests.md](docs/16-phase5-user-tests.md)
- [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, **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] **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)
- [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**
- [ ] 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)
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"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -490,6 +496,27 @@ dependencies = [
"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]]
name = "color_quant"
version = "1.1.0"
@@ -526,6 +553,12 @@ dependencies = [
"web-sys",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cookie"
version = "0.18.1"
@@ -641,6 +674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"typenum",
]
@@ -728,6 +762,30 @@ dependencies = [
"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]]
name = "deranged"
version = "0.5.8"
@@ -765,6 +823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
@@ -1030,13 +1089,23 @@ name = "ff-engine"
version = "0.1.0"
dependencies = [
"anyhow",
"cms",
"const-oid",
"der",
"ff-cos",
"ff-pdmodel",
"image",
"lopdf",
"pdfium-render",
"pkcs8",
"rcgen",
"rsa",
"sha2",
"signature",
"spki",
"thiserror 2.0.18",
"ttf-parser",
"x509-cert",
]
[[package]]
@@ -1063,6 +1132,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -2072,6 +2147,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
@@ -2083,6 +2167,15 @@ dependencies = [
"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]]
name = "leb128fmt"
version = "0.1.0"
@@ -2164,6 +2257,12 @@ dependencies = [
"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]]
name = "libredox"
version = "0.1.17"
@@ -2223,7 +2322,7 @@ dependencies = [
"md-5",
"nom",
"nom_locate",
"rand",
"rand 0.9.4",
"rangemap",
"rayon",
"sha2",
@@ -2418,6 +2517,22 @@ dependencies = [
"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]]
name = "num-conv"
version = "0.2.2"
@@ -2444,6 +2559,16 @@ dependencies = [
"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]]
name = "num-rational"
version = "0.4.2"
@@ -2462,6 +2587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@@ -2780,6 +2906,25 @@ dependencies = [
"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]]
name = "percent-encoding"
version = "2.3.2"
@@ -2851,6 +2996,27 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "pkg-config"
version = "0.3.33"
@@ -3083,14 +3249,34 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
"rand_chacha 0.9.0",
"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]]
@@ -3100,7 +3286,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"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]]
@@ -3145,8 +3340,8 @@ dependencies = [
"num-traits",
"paste",
"profiling",
"rand",
"rand_chacha",
"rand 0.9.4",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror 2.0.18",
"v_frame",
@@ -3194,6 +3389,19 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.5.18"
@@ -3327,6 +3535,41 @@ version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
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 = "rustc-hash"
version = "2.1.2"
@@ -3342,6 +3585,15 @@ dependencies = [
"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]]
name = "rustversion"
version = "1.0.22"
@@ -3601,6 +3853,17 @@ dependencies = [
"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]]
name = "sha2"
version = "0.10.9"
@@ -3612,12 +3875,32 @@ dependencies = [
"digest",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "simd-adler32"
version = "0.3.9"
@@ -3709,6 +3992,22 @@ dependencies = [
"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]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3756,6 +4055,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4268,6 +4573,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "tokio"
version = "1.52.3"
@@ -4605,6 +4931,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -5162,6 +5494,15 @@ dependencies = [
"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]]
name = "windows-sys"
version = "0.59.0"
@@ -5592,12 +5933,35 @@ dependencies = [
"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]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5662,6 +6026,26 @@ dependencies = [
"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]]
name = "zerotrie"
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
/// `ff_edit_*` nằm đúng trong thư mục temp — không bao giờ đụng file người dùng.
#[tauri::command]
@@ -1003,6 +1081,12 @@ fn main() {
security_encrypt,
security_decrypt,
security_strip_metadata,
security_optimize,
sig_create_id,
sig_sign,
sig_verify,
pick_any_file,
pick_save_pem,
pick_image
])
.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="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>
<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 id="secHint" class="status"></span>
</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 ----------
$("openBtn").addEventListener("click", openFile);
@@ -2933,6 +3046,10 @@ $("secRedactClear").addEventListener("click", clearRedactMarks);
$("secEncrypt").addEventListener("click", openEncryptDialog);
$("secDecrypt").addEventListener("click", openDecryptDialog);
$("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)
$("editModeBtn").addEventListener("click", toggleEditMode);
+6
View File
@@ -413,6 +413,12 @@ body.tool-active .annotlayer * { pointer-events: none; }
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ỏ */
.redact-mark {
position: absolute;
+11
View File
@@ -14,3 +14,14 @@ pdfium-render = "0.8"
image = "0.25"
lopdf = "0.36"
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 redact;
pub mod render;
pub mod sign;
pub mod text;
pub mod watermark;
@@ -26,9 +27,10 @@ pub use organize::{
};
pub use qpdf::{
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 sign::{generate_self_signed_id, sign_pdf, verify_signatures, SignatureCheck};
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 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
/// mã hoá ra `output`. Cần đúng password (user hoặc owner).
pub fn decrypt_remove_password(
+192 -53
View File
@@ -15,8 +15,19 @@ use std::path::Path;
use pdfium_render::prelude::*;
use crate::text::Rect;
use crate::EngineError;
use crate::text::{CharBox, Rect};
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> {
let r = Rect {
@@ -48,82 +59,123 @@ pub fn redact_areas(
password: Option<&str>,
) -> Result<usize, EngineError> {
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)
.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()
.get(page_index)
.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() {
let Some(b) = obj_rect(&obj) else { continue };
let cuts: Vec<Rect> = areas.iter().filter_map(|a| intersection(a, &b)).collect();
if cuts.is_empty() {
continue;
}
if obj.object_type() == PdfPageObjectType::Image {
img_fixes.push(ImgFix { idx: i, bounds: b, cuts });
} else {
to_remove.push(i);
}
}
// (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) => {
let mut rgba = img.to_rgba8();
let (w, h) = (rgba.width() as f32, rgba.height() as f32);
let bw = (fix.bounds.right - fix.bounds.left).max(0.01);
let bh = (fix.bounds.top - fix.bounds.bottom).max(0.01);
for cut in &fix.cuts {
// Trang → pixel: gốc pixel (0,0) là góc TRÊN-trái của ảnh.
let x0 = (((cut.left - fix.bounds.left) / bw) * w).floor().max(0.0) as u32;
let x1 = (((cut.right - fix.bounds.left) / bw) * w).ceil().min(w) as u32;
let y0 = (((fix.bounds.top - cut.top) / bh) * h).floor().max(0.0) as u32;
let y1 = (((fix.bounds.top - cut.bottom) / bh) * h).ceil().min(h) as u32;
for y in y0..y1 {
for x in x0..x1 {
rgba.put_pixel(x, y, image::Rgba([0, 0, 0, 255]));
match obj.object_type() {
PdfPageObjectType::Image => {
// Đọc pixel gốc, tô đen phần giao. Không đọc được → xoá cả ảnh.
match obj.as_image_object().and_then(|io| io.get_raw_image().ok()) {
Some(img) => {
let mut rgba = img.to_rgba8();
let (w, h) = (rgba.width() as f32, rgba.height() as f32);
let bw = (b.right - b.left).max(0.01);
let bh = (b.top - b.bottom).max(0.01);
for cut in &cuts {
let x0 = (((cut.left - b.left) / bw) * w).floor().max(0.0) as u32;
let x1 = (((cut.right - b.left) / bw) * w).ceil().min(w) as u32;
let y0 = (((b.top - cut.top) / bh) * h).floor().max(0.0) as u32;
let y1 = (((b.top - cut.bottom) / bh) * h).ceil().min(h) as u32;
for y in y0..y1 {
for x in x0..x1 {
rgba.put_pixel(x, y, image::Rgba([0, 0, 0, 255]));
}
}
}
new_imgs.push(NewImg { bounds: b, img: image::DynamicImage::ImageRgba8(rgba) });
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);
}
}
new_imgs.push(NewImg { bounds: fix.bounds, img: image::DynamicImage::ImageRgba8(rgba) });
to_remove.push(fix.idx);
}
None => to_remove.push(fix.idx),
_ => to_remove.push(i),
}
}
}
// (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.dedup();
touched = to_remove.len();
for idx in to_remove.into_iter().rev() {
let removed = page.objects_mut().remove_object_at_index(idx).map_err(err)?;
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 {
let w = (ni.bounds.right - ni.bounds.left).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)?;
}
// (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 {
page.objects_mut()
.create_path_object_rect(
@@ -157,3 +209,90 @@ pub fn redact_areas(
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
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");
let mut area = find_text_rect(&pdf, &fx, "TOPSECRET42");
// Nới 2pt mỗi phía như người dùng quét chuột.
area = Rect { left: area.left - 2.0, bottom: area.bottom - 2.0, right: area.right + 2.0, top: area.top + 2.0 };
// Vùng redact = ĐÚNG cụm ký tự "TOPSECRET42" (sub-rect của run, để kiểm tỉa
// theo ký tự: phần " tuyệt mật" cùng object phải còn).
let boxes = ff_engine::page_char_boxes(&pdf, &fx, 0, None).expect("char boxes");
let secret_boxes: Vec<_> = boxes.iter().filter(|b| b.top < 620.0 && b.bottom > 580.0).collect();
// Lấy 11 ký tự đầu của dòng (TOPSECRET42) theo thứ tự trái→phải.
let mut sorted = secret_boxes.clone();
sorted.sort_by(|a, b| a.left.partial_cmp(&b.left).unwrap());
let head = &sorted[..sorted.len().min(11)];
let area = Rect {
left: head.iter().map(|b| b.left).fold(f32::INFINITY, f32::min) - 1.0,
right: head.iter().map(|b| b.right).fold(f32::NEG_INFINITY, f32::max) + 1.0,
bottom: head.iter().map(|b| b.bottom).fold(f32::INFINITY, f32::min) - 2.0,
top: head.iter().map(|b| b.top).fold(f32::NEG_INFINITY, f32::max) + 2.0,
};
let touched = redact_areas(&pdf, &fx, 0, &[area], &out, None).expect("redact");
assert!(touched >= 1, "phải xoá ít nhất 1 object");
@@ -77,6 +88,9 @@ fn redact_removes_text_content_for_real() {
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(!text.contains("TOPSECRET42"), "nội dung mật phải BIẾN MẤT: {text:?}");
assert!(text.contains("PUBLICINFO"), "nội dung ngoài vùng phải còn: {text:?}");
// Cùng dòng với chuỗi mật vẫn còn phần "tuyệt mật" (tỉa theo ký tự, không
// xoá cả dòng) — chỉ TOPSECRET42 bị chạm.
assert!(text.contains("mật"), "phần ngoài vùng của cùng dòng phải giữ: {text:?}");
// Render: tâm vùng redact phải ĐEN.
let png = tmp("ff_redact_out.png");
@@ -81,6 +81,23 @@ fn decrypt_with_wrong_password_fails() {
);
}
/// Lưu tối ưu: file kết quả mở/đọc bình thường và KHÔNG lớn hơn bản gốc
/// (nén object stream). Dùng file có object rác (sau redact-kiểu xoá object).
#[test]
fn optimize_save_keeps_content_and_not_larger() {
let pdf = pdfium();
let out = tmp("ff_opt_out.pdf");
let src = workspace_root().join("corpus").join("sample-multipage.pdf");
ff_engine::optimize_save(&src, &out).expect("optimize");
assert!(pdf.load_pdf_from_file(&out, None).is_ok(), "file tối ưu phải mở được");
let pages = ff_engine::page_count(&pdf, &out, None).expect("page_count");
assert!(pages >= 1, "phải giữ trang");
let orig = std::fs::metadata(&src).map(|m| m.len()).unwrap_or(0);
let opt = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(u64::MAX);
assert!(opt <= orig + orig / 10, "bản tối ưu không được phình đáng kể ({opt} vs {orig})");
}
/// Xoá metadata: /Info (Producer/Author…) và XMP /Metadata phải biến mất KHỎI
/// FILE (soi bytes thô), file vẫn mở/đọc bình thường.
#[test]
+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.
**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ậ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á.
+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)
| 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 |
| 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 |
@@ -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,
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:
1. **Phase 5 Iteration 2**: chữ ký số PAdES + xác thực; "Lưu tối ưu" (mục
3.3: subset font, dọn object rác); redact tỉa theo ký tự.
2. Phase 6 Form → Phase 7 OCR/Convert → Phase 8 phát hành (theo roadmap).
1. **Phase 6 — Form (AcroForm)**: điền form (PDFium FPDF_FORMFILL), flatten,
tạo field text/checkbox/radio/dropdown, import/export FDF/CSV.
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
hiệu năng phiên sửa (3.2) khi chạm file lớn thực tế, và v2 của reflow
(justify/xoay — mục 3.1).
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), và nâng chữ ký (timestamp/LTV/PFX/multi-sig).
## 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.
+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á
> metadata — hoàn tất, có test kiểm chứng nội dung thật sự biến mất.**
> Chữ ký số (PAdES) là Iteration 2 — kế hoạch ở mục 5.
> Trạng thái: **HOÀN TẤT Phase 5.** Iter 1: mã hoá + permissions, gỡ mật khẩu,
> redaction THẬT, xoá metadata. Iter 2: **chữ ký số PKCS#7/PAdES (ký + xác
> 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)
- **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
vào Iteration 2.
## 5. Iteration 2 (kế tiếp của Phase 5) — Chữ ký số PAdES + Lưu tối ư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
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).
3. **Lưu tối ưu** (từ docs/14 mục 3.3): subset font nhúng theo glyph dùng
thật + `qpdf --object-streams=generate` dọn object rác sau edit.
4. Redact tỉa theo ký tự (mục 4) + quét "hidden info" còn lại.
## 5. (Đã thực hiện — xem mục 6.) Kế hoạch iteration 2 ban đầu
## 6. Iteration 2 — Chữ ký số + redact ký tự + lưu tối ưu (ĐÃ LÀM)
### 6.1 Chữ ký số PKCS#7/PAdES (`sign.rs`)
- **`generate_self_signed_id(cn, out.pem)`**: tạo Digital ID tự ký RSA-2048 +
X.509 (rcgen), ghi PEM bundle (cert + private key) — người dùng ký thử ngay
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 | |
| 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ã] (vd S5) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> —