diff --git a/Cargo.lock b/Cargo.lock index cf8dbd3..a333d7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/README.md b/README.md index 62bf781..7737bd9 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 2df15c8..031593a 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -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" diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 351310b..7a7d1d2 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -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, 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 { + 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 { + 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!()) diff --git a/app/src/index.html b/app/src/index.html index 1785544..77e1856 100644 --- a/app/src/index.html +++ b/app/src/index.html @@ -112,6 +112,11 @@ + + + + + diff --git a/app/src/main.js b/app/src/main.js index c3f12ca..17e150b 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -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ý", ` +

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ố.

+ + +
+
+ `); + 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", ` + + + + + +
+ + +
+
+
+ `); + 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 + ? '✓ Hợp lệ' + : '✗ Không hợp lệ'; + 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 `${i + 1}${(c.signer || "").replace(/${badge} + ${detail.join("; ")}`; + }) + .join("") + : `Tài liệu chưa có chữ ký số nào.`; + const box = openModal("Kết quả kiểm tra chữ ký", ` + + + ${rows} +
#Người kýTrạng tháiGhi chú
+
+ `); + 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); diff --git a/app/src/styles.css b/app/src/styles.css index c2e5ae2..7e9f5b1 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -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; diff --git a/crates/ff-engine/Cargo.toml b/crates/ff-engine/Cargo.toml index 20d4442..cd98046 100644 --- a/crates/ff-engine/Cargo.toml +++ b/crates/ff-engine/Cargo.toml @@ -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"] } diff --git a/crates/ff-engine/src/lib.rs b/crates/ff-engine/src/lib.rs index e7dcb8e..b2069ce 100644 --- a/crates/ff-engine/src/lib.rs +++ b/crates/ff-engine/src/lib.rs @@ -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}; diff --git a/crates/ff-engine/src/qpdf.rs b/crates/ff-engine/src/qpdf.rs index 519eb7d..47056b1 100644 --- a/crates/ff-engine/src/qpdf.rs +++ b/crates/ff-engine/src/qpdf.rs @@ -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( diff --git a/crates/ff-engine/src/redact.rs b/crates/ff-engine/src/redact.rs index bd9e3c1..70ef84e 100644 --- a/crates/ff-engine/src/redact.rs +++ b/crates/ff-engine/src/redact.rs @@ -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 { let r = Rect { @@ -48,82 +59,123 @@ pub fn redact_areas( password: Option<&str>, ) -> Result { 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 = Vec::new(); + let mut new_imgs: Vec = Vec::new(); + let mut text_trims: Vec = 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 = Vec::new(); - struct ImgFix { - idx: usize, - bounds: Rect, - cuts: Vec, - } - let mut img_fixes: Vec = Vec::new(); for (i, obj) in page.objects().iter().enumerate() { let Some(b) = obj_rect(&obj) else { continue }; let cuts: Vec = 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 = 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 = 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 { + 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 = 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> { + 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, + tf: f32, + linear: (f32, f32, f32, f32), + color: PdfColor, + font_bytes: Vec, +} diff --git a/crates/ff-engine/src/sign.rs b/crates/ff-engine/src/sign.rs new file mode 100644 index 0000000..ddfb23d --- /dev/null +++ b/crates/ff-engine/src/sign.rs @@ -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(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::::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::::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, 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, +} + +/// 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 { + 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> { + 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 = 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, 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, 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::::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::, 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 { + 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::().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::() { + 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 { + 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::::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 { + 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::() { + return Some(s.to_string()); + } + if let Ok(s) = atv.value.decode_as::() { + 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> { + let clean: Vec = 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 { + 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 { + 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, placeholder: &str) -> Result, 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 { + 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, 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) +} diff --git a/crates/ff-engine/tests/redact_roundtrip.rs b/crates/ff-engine/tests/redact_roundtrip.rs index b19c6f6..f5edf15 100644 --- a/crates/ff-engine/tests/redact_roundtrip.rs +++ b/crates/ff-engine/tests/redact_roundtrip.rs @@ -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"); diff --git a/crates/ff-engine/tests/security_roundtrip.rs b/crates/ff-engine/tests/security_roundtrip.rs index 64dd870..6190e0e 100644 --- a/crates/ff-engine/tests/security_roundtrip.rs +++ b/crates/ff-engine/tests/security_roundtrip.rs @@ -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] diff --git a/crates/ff-engine/tests/sign_roundtrip.rs b/crates/ff-engine/tests/sign_roundtrip.rs new file mode 100644 index 0000000..be95674 --- /dev/null +++ b/crates/ff-engine/tests/sign_roundtrip.rs @@ -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"); +} diff --git a/docs/03-roadmap.md b/docs/03-roadmap.md index 4e68cbe..a49e810 100644 --- a/docs/03-roadmap.md +++ b/docs/03-roadmap.md @@ -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á. diff --git a/docs/14-foxit-gap-analysis.md b/docs/14-foxit-gap-analysis.md index 06f154f..030b7d9 100644 --- a/docs/14-foxit-gap-analysis.md +++ b/docs/14-foxit-gap-analysis.md @@ -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. diff --git a/docs/15-phase5-summary.md b/docs/15-phase5-summary.md index ffbb855..508b7f3 100644 --- a/docs/15-phase5-summary.md +++ b/docs/15-phase5-summary.md @@ -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 ` 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). diff --git a/docs/16-phase5-user-tests.md b/docs/16-phase5-user-tests.md index 6b92129..aa3a63f 100644 --- a/docs/16-phase5-user-tests.md +++ b/docs/16-phase5-user-tests.md @@ -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ở: — Hiện tượng: —