diff --git a/hlkx-sign/.gitignore b/hlkx-sign/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/hlkx-sign/.gitignore @@ -0,0 +1 @@ +/target diff --git a/hlkx-sign/Cargo.lock b/hlkx-sign/Cargo.lock new file mode 100644 index 0000000..3cd421b --- /dev/null +++ b/hlkx-sign/Cargo.lock @@ -0,0 +1,1877 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[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 = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cryptoki" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff765b99fc49f3116c9a908484486a2b92fd73c48da45c3a69716471c6cc56c6" +dependencies = [ + "bitflags", + "cryptoki-sys", + "libloading", + "log", + "secrecy", +] + +[[package]] +name = "cryptoki-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fd850498411e4057f1cba79e6e2bc7cbe960544c1046ab46d4685c403a1121" +dependencies = [ + "libloading", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hlkx-sign" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "chrono", + "clap", + "cryptoki", + "der", + "hex", + "quick-xml", + "rsa", + "sha1", + "sha2", + "signature", + "tempfile", + "ureq", + "uuid", + "x509-cert", + "zip", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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]] +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", + "sha1", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "strsim" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[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 = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +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 = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/hlkx-sign/Cargo.toml b/hlkx-sign/Cargo.toml new file mode 100644 index 0000000..661c90a --- /dev/null +++ b/hlkx-sign/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "hlkx-sign" +version = "0.1.0" +edition = "2021" +description = "Tool to sign HLKX/VSIX packages using a PKCS#11 module" + +[dependencies] +anyhow = "1" +base64 = "0.22" +chrono = "0.4" +clap = { version = "4", features = ["derive"] } +cryptoki = "0.12" +der = "0.7" +hex = "0.4" +quick-xml = "0.37" +rsa = { version = "0.9", features = ["sha1", "sha2"] } +sha1 = "0.10" +sha2 = "0.10" +signature = "2" +tempfile = "3" +ureq = "3" +uuid = { version = "1", features = ["v4"] } +x509-cert = "0.2" +zip = "2.3" diff --git a/hlkx-sign/README.md b/hlkx-sign/README.md new file mode 100644 index 0000000..ebf5409 --- /dev/null +++ b/hlkx-sign/README.md @@ -0,0 +1,86 @@ +# hlkx-sign + +A Rust tool to sign HLKX (and VSIX) OPC packages using a PKCS#11 module. + +It uses the [`cryptoki`](https://crates.io/crates/cryptoki) crate to talk +directly to the PKCS#11 library — **no OpenSSL dependency**. + +## Requirements + +* A PKCS#11 shared library for your hardware token or software HSM, e.g.: + * OpenSC: `/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so` + * SoftHSM2: `/usr/lib/softhsm/libsofthsm2.so` + +## Building + +``` +cargo build --release +``` + +The resulting binary is at `target/release/hlkx-sign`. + +## Usage + +``` +hlkx-sign sign \ + --pkcs11-module /usr/lib/x86_64-linux-gnu/opensc-pkcs11.so \ + --pkcs11-cert "pkcs11:token=MyToken;type=cert;object=MyCert" \ + --pkcs11-key "pkcs11:token=MyToken;type=private;object=MyCert;pin-value=1234" \ + [--file-digest sha256] \ + [--timestamp http://timestamp.example.com/] \ + [--timestamp-algorithm sha256] \ + [--force] \ + path/to/package.hlkx +``` + +### Options + +| Flag | Description | Default | +|------|-------------|---------| +| `--pkcs11-module` | Path to the PKCS#11 shared library | *(required)* | +| `--pkcs11-cert` | PKCS#11 URI (`pkcs11:…;object=Label`) or plain CKA_LABEL | *(required)* | +| `--pkcs11-key` | PKCS#11 URI (`pkcs11:…;object=Label;pin-value=PIN`) or plain CKA_LABEL | *(required)* | +| `--file-digest` | Hash algorithm: `sha1`, `sha256`, `sha384`, `sha512` | `sha256` | +| `--timestamp` | URL of a RFC 3161 Time Stamping Authority | none | +| `--timestamp-algorithm` | Hash algorithm for the timestamp request | `sha256` | +| `--force` / `-f` | Overwrite an existing signature | off | + +### Object identification + +`--pkcs11-cert` and `--pkcs11-key` accept either a PKCS#11 URI (RFC 7512) +with an `object=` component, or a plain object label (`CKA_LABEL`). + +When a `token=` component is present in the URI, only slots whose token +label matches are searched — preventing accidental login to the wrong token. + +The PIN is supplied via the `pin-value=` field of the `--pkcs11-key` URI +(same convention as OpenSSL's `engine_pkcs11`). If omitted, no login is +attempted. + +Examples: +``` +--pkcs11-cert "pkcs11:token=MyHSM;type=cert;object=CodeSigningCert" +--pkcs11-key "pkcs11:token=MyHSM;type=private;object=CodeSigningCert;pin-value=1234" +--pkcs11-key CodeSigningCert # plain label, no PIN +``` + +## How it works + +1. The package (ZIP file) is read into memory. +2. A `package/services/digital-signature/origin.psdsor` origin part is added, + linked from the package root `_rels/.rels`. +3. All non-signature parts are digested with the chosen hash algorithm. + The `_rels/.rels` file produces **two** manifest entries: + * a C14N (Canonical XML 1.0) transform of the raw relationship document; + * a RelationshipTransform + C14N transform of the filtered relationships. +4. An XML digital signature is built following ECMA-376 Part 2 §13: + * `` containing the manifest and a `` property is + C14N-hashed and referenced from ``. + * The canonical `` bytes are sent to the PKCS#11 token which + computes the hash and produces an RSA PKCS#1 v1.5 signature in one + operation (`CKM_SHA256_RSA_PKCS` etc.). +5. The signature XML is written to + `package/services/digital-signature/xml-signature/.psdsxs`. +6. The DER-encoded certificate is written to + `package/services/digital-signature/certificate/.cer`. +7. The updated package is written back to disk atomically. diff --git a/hlkx-sign/c14n-reference/C14nReference.csproj b/hlkx-sign/c14n-reference/C14nReference.csproj new file mode 100644 index 0000000..5d97831 --- /dev/null +++ b/hlkx-sign/c14n-reference/C14nReference.csproj @@ -0,0 +1,12 @@ + + + Exe + net8.0 + latest + enable + enable + + + + + diff --git a/hlkx-sign/c14n-reference/Program.cs b/hlkx-sign/c14n-reference/Program.cs new file mode 100644 index 0000000..a6e2e1e --- /dev/null +++ b/hlkx-sign/c14n-reference/Program.cs @@ -0,0 +1,35 @@ +// Reference canonicalizer using the same API as XmlSignatureBuilder / OPC signing. +// Usage: dotnet run -- [output.bin] +// Writes UTF-8 canonical bytes to stdout if no output path is given. + +using System.Security.Cryptography.Xml; +using System.Text; +using System.Xml; + +if (args.Length < 1) +{ + Console.Error.WriteLine("Usage: C14nReference [output.bin]"); + return 1; +} + +var inputPath = args[0]; +var xml = File.ReadAllText(inputPath); + +var doc = new XmlDocument { PreserveWhitespace = true }; +doc.LoadXml(xml); + +var transform = new XmlDsigC14NTransform(false); +transform.LoadInput(doc); +var stream = (MemoryStream)transform.GetOutput(typeof(Stream))!; +var bytes = stream.ToArray(); + +if (args.Length >= 2) +{ + File.WriteAllBytes(args[1], bytes); +} +else +{ + Console.OpenStandardOutput().Write(bytes); +} + +return 0; diff --git a/hlkx-sign/src/c14n.rs b/hlkx-sign/src/c14n.rs new file mode 100644 index 0000000..a3f00e7 --- /dev/null +++ b/hlkx-sign/src/c14n.rs @@ -0,0 +1,388 @@ +//! W3C Canonical XML 1.0 (without comments), matching .NET `XmlDsigC14NTransform`. +//! +//! Algorithm ported from .NET's `CanonicalXml` / `CanonicalXmlElement` writers: +//! - [`XmlDsigC14NTransform`](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography.Xml/src/System/Security/Cryptography/Xml/XmlDsigC14NTransform.cs) +//! - [`CanonicalXmlElement`](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography.Xml/src/System/Security/Cryptography/Xml/CanonicalXmlElement.cs) +//! - [`AttributeSortOrder`](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography.Xml/src/System/Security/Cryptography/Xml/AttributeSortOrder.cs) (namespace URI, then local name) +//! - [`NamespaceSortOrder`](https://github.com/dotnet/runtime/blob/main/src/libraries/System.Security.Cryptography.Xml/src/System/Security/Cryptography/Xml/NamespaceSortOrder.cs) (default xmlns first, then local name) +//! +//! Golden outputs are checked against `c14n-reference/` (runs the real .NET transform). + +use anyhow::Result; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; +use std::collections::BTreeMap; + +pub const NS_DSIG: &str = "http://www.w3.org/2000/09/xmldsig#"; + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Canonicalize an XML document (`XmlDsigC14NTransform(false)` on `XmlDocument`). +pub fn c14n(xml: &[u8]) -> Result> { + let doc = parse_xml(xml)?; + let mut out = Vec::new(); + let ctx = BTreeMap::new(); + for node in &doc { + emit_node(node, &ctx, &mut out); + } + Ok(out) +} + +/// Canonicalize a dsig element with an explicit default namespace on the root. +/// +/// Matches .NET `CanonicalizeElement` / Windows HLK acceptance requirements. +pub fn c14n_dsig_element_committed(element_xml: &str) -> Result> { + let doc = ensure_default_dsig_xmlns(element_xml); + c14n(doc.as_bytes()) +} + +fn ensure_default_dsig_xmlns(element_xml: &str) -> String { + let close = element_xml.find('>').unwrap_or(element_xml.len()); + let open = &element_xml[..close]; + if open.contains("xmlns=") { + return element_xml.to_string(); + } + format!("{open} xmlns=\"{NS_DSIG}\"{}", &element_xml[close..]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internal DOM +// ───────────────────────────────────────────────────────────────────────────── + +enum Node { + Element(Element), + Text(String), +} + +struct Element { + qname: String, + prefix: String, + attrs: Vec<(String, String)>, + children: Vec, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Parser (PreserveWhitespace = true) +// ───────────────────────────────────────────────────────────────────────────── + +fn split_qname(qname: &str) -> (&str, &str) { + match qname.find(':') { + Some(i) => (&qname[..i], &qname[i + 1..]), + None => ("", qname), + } +} + +fn parse_xml(xml: &[u8]) -> Result> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(false); + + let mut ns_stack: Vec> = vec![BTreeMap::new()]; + let mut out_stack: Vec> = vec![Vec::new()]; + let mut elem_stack: Vec<(String, String, Vec<(String, String)>)> = Vec::new(); + + loop { + match reader.read_event()? { + Event::Decl(_) | Event::Comment(_) | Event::PI(_) => {} + Event::Eof => break, + + Event::Start(e) => { + let (info, scope) = decode_element(&e, &ns_stack, &reader); + ns_stack.push(scope); + out_stack.push(Vec::new()); + elem_stack.push(info); + } + + Event::Empty(e) => { + let (info, scope) = decode_element(&e, &ns_stack, &reader); + ns_stack.push(scope); + out_stack.push(Vec::new()); + elem_stack.push(info); + pop_element(&mut ns_stack, &mut out_stack, &mut elem_stack); + } + + Event::End(_) => { + pop_element(&mut ns_stack, &mut out_stack, &mut elem_stack); + } + + Event::Text(e) => { + let text = e.unescape().unwrap_or_default().into_owned(); + out_stack.last_mut().unwrap().push(Node::Text(text)); + } + Event::CData(e) => { + let text = std::str::from_utf8(e.as_ref()).unwrap_or("").to_string(); + out_stack.last_mut().unwrap().push(Node::Text(text)); + } + _ => {} + } + } + + Ok(out_stack.pop().unwrap_or_default()) +} + +fn decode_element( + e: &BytesStart<'_>, + ns_stack: &[BTreeMap], + reader: &Reader<&[u8]>, +) -> ((String, String, Vec<(String, String)>), BTreeMap) { + let mut raw_attrs: Vec<(String, String)> = Vec::new(); + for attr in e.attributes().flatten() { + let key = std::str::from_utf8(attr.key.as_ref()).unwrap_or("").to_string(); + let val = attr.decode_and_unescape_value(reader.decoder()).unwrap_or_default().into_owned(); + raw_attrs.push((key, val)); + } + + let mut scope = ns_stack.last().cloned().unwrap_or_default(); + for (k, v) in &raw_attrs { + if k == "xmlns" { + scope.insert(String::new(), v.clone()); + } else if let Some(pfx) = k.strip_prefix("xmlns:") { + scope.insert(pfx.to_string(), v.clone()); + } + } + + let qname = std::str::from_utf8(e.name().as_ref()).unwrap_or("").to_string(); + let prefix_owned = split_qname(&qname).0.to_string(); + + ((qname, prefix_owned, raw_attrs), scope) +} + +fn pop_element( + ns_stack: &mut Vec>, + out_stack: &mut Vec>, + elem_stack: &mut Vec<(String, String, Vec<(String, String)>)>, +) { + ns_stack.pop(); + let children = out_stack.pop().unwrap_or_default(); + if let Some((qname, prefix, attrs)) = elem_stack.pop() { + let elem = Element { qname, prefix, attrs, children }; + out_stack.last_mut().unwrap().push(Node::Element(elem)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// C14N emitter (CanonicalXmlElement.Write) +// ───────────────────────────────────────────────────────────────────────────── + +fn emit_node(node: &Node, parent_ctx: &BTreeMap, out: &mut Vec) { + match node { + Node::Text(t) => emit_text(t, out), + Node::Element(e) => emit_element(e, parent_ctx, out), + } +} + +/// `Utils.EscapeTextData` +fn emit_text(text: &str, out: &mut Vec) { + for c in text.chars() { + match c { + '&' => out.extend_from_slice(b"&"), + '<' => out.extend_from_slice(b"<"), + '>' => out.extend_from_slice(b">"), + '\r' => out.extend_from_slice(b" "), + c => { + let mut buf = [0u8; 4]; + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } + } +} + +fn emit_element(elem: &Element, parent_ctx: &BTreeMap, out: &mut Vec) { + let mut my_ctx = parent_ctx.clone(); + for (k, v) in &elem.attrs { + if k == "xmlns" { + my_ctx.insert(String::new(), v.clone()); + } else if let Some(pfx) = k.strip_prefix("xmlns:") { + my_ctx.insert(pfx.to_string(), v.clone()); + } + } + + // NamespaceSortOrder: default xmlns first, then xmlns:localname. + let mut ns_decls: BTreeMap = BTreeMap::new(); + + let elem_uri = my_ctx.get(elem.prefix.as_str()).cloned().unwrap_or_default(); + let parent_uri = parent_ctx.get(elem.prefix.as_str()).cloned().unwrap_or_default(); + if elem_uri != parent_uri { + ns_decls.insert(elem.prefix.clone(), elem_uri.clone()); + } + + for (k, _) in &elem.attrs { + if k == "xmlns" || k.starts_with("xmlns:") { + continue; + } + let (ap, _) = split_qname(k); + if !ap.is_empty() { + let au = my_ctx.get(ap).cloned().unwrap_or_default(); + let pu = parent_ctx.get(ap).cloned().unwrap_or_default(); + if au != pu { + ns_decls.insert(ap.to_string(), au); + } + } + } + + for (k, v) in &elem.attrs { + if k == "xmlns" { + let pu = parent_ctx.get("").cloned().unwrap_or_default(); + if v != &pu { + ns_decls.insert(String::new(), v.clone()); + } + } else if let Some(ppfx) = k.strip_prefix("xmlns:") { + let pu = parent_ctx.get(ppfx).cloned().unwrap_or_default(); + if v != &pu { + ns_decls.insert(ppfx.to_string(), v.clone()); + } + } + } + + out.push(b'<'); + out.extend_from_slice(elem.qname.as_bytes()); + + for (ns_pfx, ns_uri) in &ns_decls { + if ns_pfx.is_empty() { + out.extend_from_slice(b" xmlns=\""); + } else { + out.extend_from_slice(b" xmlns:"); + out.extend_from_slice(ns_pfx.as_bytes()); + out.extend_from_slice(b"=\""); + } + out.extend_from_slice(escape_attribute_value(ns_uri).as_bytes()); + out.push(b'"'); + } + + // AttributeSortOrder: namespace URI, then local name. + let mut reg_attrs: Vec<(&str, &str)> = elem + .attrs + .iter() + .filter(|(k, _)| k != "xmlns" && !k.starts_with("xmlns:")) + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + reg_attrs.sort_by(|(k1, _), (k2, _)| { + let (p1, l1) = split_qname(k1); + let (p2, l2) = split_qname(k2); + let u1 = if p1.is_empty() { + "" + } else { + my_ctx.get(p1).map(|s| s.as_str()).unwrap_or("") + }; + let u2 = if p2.is_empty() { + "" + } else { + my_ctx.get(p2).map(|s| s.as_str()).unwrap_or("") + }; + u1.cmp(u2).then(l1.cmp(l2)) + }); + + for (k, v) in ®_attrs { + out.push(b' '); + out.extend_from_slice(k.as_bytes()); + out.extend_from_slice(b"=\""); + out.extend_from_slice(escape_attribute_value(v).as_bytes()); + out.push(b'"'); + } + + out.push(b'>'); + + for child in &elem.children { + emit_node(child, &my_ctx, out); + } + + out.extend_from_slice(b"'); +} + +/// `Utils.EscapeAttributeValue` +fn escape_attribute_value(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '"' => out.push_str("""), + '\t' => out.push_str(" "), + '\n' => out.push_str(" "), + '\r' => out.push_str(" "), + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod dotnet_tests { + use super::*; + + fn assert_matches_dotnet(input: &str, expected_bin: &str) { + if !std::path::Path::new(expected_bin).exists() { + return; + } + let expected = std::fs::read(expected_bin).unwrap(); + let got = c14n(input.as_bytes()).unwrap(); + assert_eq!(got, expected, "mismatch for {expected_bin}"); + } + + #[test] + fn rels_matches_dotnet() { + let xml = std::fs::read_to_string("/tmp/rels_raw.xml").unwrap_or_default(); + if xml.is_empty() { + return; + } + assert_matches_dotnet(&xml, "/tmp/rels_dotnet.bin"); + } + + #[test] + fn signedinfo_xmlns_matches_dotnet() { + let xml = std::fs::read_to_string("/tmp/ms_signedinfo_xmlns.xml").unwrap_or_default(); + if xml.is_empty() { + return; + } + assert_matches_dotnet(&xml, "/tmp/si_xmlns_dotnet.bin"); + } + + #[test] + fn committed_signedinfo_matches_dotnet() { + let raw = std::fs::read_to_string("/tmp/ms_signedinfo.xml").unwrap_or_default(); + if raw.is_empty() { + return; + } + let committed = c14n_dsig_element_committed(&raw).unwrap(); + let expected = std::fs::read("/tmp/si_xmlns_dotnet.bin").unwrap(); + assert_eq!(committed, expected); + } + + #[test] + fn ensure_xmlns_on_root_not_descendant() { + let raw = r#""#; + let out = ensure_default_dsig_xmlns(raw); + assert!(out.starts_with("")); + } + + #[test] + fn committed_object_digest_matches_microsoft() { + use base64::{engine::general_purpose::STANDARD as B64, Engine}; + use crate::xml_sig::DigestAlgorithm; + let raw = std::fs::read_to_string("/tmp/ms_object.xml").unwrap_or_default(); + if raw.is_empty() { + return; + } + let committed = c14n_dsig_element_committed(&raw).unwrap(); + let hash = B64.encode(DigestAlgorithm::Sha256.hash(&committed)); + assert_eq!(hash, "xsliPb27EEU2yeNNURzHX5S8+1fAvMkrqauvAtyxmFs="); + } + + #[test] + fn object_xmlns_matches_dotnet() { + let raw = std::fs::read_to_string("/tmp/ms_object.xml").unwrap_or_default(); + if raw.is_empty() { + return; + } + let xmlns = raw.replace( + "", + "", + ); + assert_matches_dotnet(&xmlns, "/tmp/object_committed_dotnet.bin"); + } + +} diff --git a/hlkx-sign/src/main.rs b/hlkx-sign/src/main.rs new file mode 100644 index 0000000..9f8fe61 --- /dev/null +++ b/hlkx-sign/src/main.rs @@ -0,0 +1,188 @@ +//! `hlkx-sign` – sign an HLKX (or VSIX) package with a PKCS#11 module. +//! +//! Usage: +//! hlkx-sign verify package.hlkx +//! +//! hlkx-sign sign \ +//! --pkcs11-module /usr/lib/opensc-pkcs11.so \ +//! --pkcs11-cert "pkcs11:token=MyToken;object=MyCert;type=cert" \ +//! --pkcs11-key "pkcs11:token=MyToken;object=MyCert;type=private;pin-value=1234" \ +//! [--file-digest sha256] \ +//! [--timestamp http://timestamp.example.com/] \ +//! [--timestamp-algorithm sha256] \ +//! [--force] \ +//! package.hlkx +//! +//! The PIN is read from the `pin-value=` field of the PKCS#11 URI (same +//! convention as OpenSSL's engine_pkcs11). There is no separate `--pkcs11-pin` +//! argument. + +mod c14n; +mod opc; +mod pkcs11; +mod signing; +mod timestamp; +mod verification; +mod xml_sig; + +use clap::{Parser, Subcommand}; +use xml_sig::DigestAlgorithm; + +// ───────────────────────────────────────────────────────────────────────────── +// CLI +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Parser)] +#[command(name = "hlkx-sign", about = "Sign an HLKX/VSIX package with a PKCS#11 module")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Verify the digital signature on an HLKX/VSIX package. + Verify { + /// Path to the HLKX/VSIX file to verify. + file: String, + }, + + /// Sign an HLKX/VSIX package. + Sign { + /// Path to the PKCS#11 shared library. + #[arg(long, value_name = "PATH")] + pkcs11_module: String, + + /// PKCS#11 URI or object label for the certificate. + /// + /// Supported URI fields: token=, object=, type=, pin-value= + /// Example: "pkcs11:token=MyToken;object=MyCert;type=cert" + #[arg(long, value_name = "URI")] + pkcs11_cert: String, + + /// PKCS#11 URI or object label for the private key. + /// + /// Supported URI fields: token=, object=, type=, pin-value= + /// Example: "pkcs11:token=MyToken;object=MyKey;type=private;pin-value=1234" + /// + /// The PIN is extracted from the pin-value= field of this URI. + #[arg(long, value_name = "URI")] + pkcs11_key: String, + + /// Digest algorithm: sha1 | sha256 | sha384 | sha512 (default: sha256). + #[arg(long, value_name = "ALGORITHM", default_value = "sha256")] + file_digest: String, + + /// URL of a RFC 3161 Time Stamping Authority. When supplied, the + /// signature value is timestamped and the token embedded in the XML. + #[arg(long, value_name = "URL")] + timestamp: Option, + + /// Hash algorithm to use in the timestamp request: sha1 | sha256 | + /// sha384 | sha512 (default: sha256). Ignored when --timestamp is not set. + #[arg(long, value_name = "ALGORITHM", default_value = "sha256")] + timestamp_algorithm: String, + + /// Overwrite any existing signature. + #[arg(long, short = 'f')] + force: bool, + + /// Path to the HLKX/VSIX file to sign. + file: String, + }, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Entry-point +// ───────────────────────────────────────────────────────────────────────────── + +fn main() { + if let Err(e) = run() { + eprintln!("error: {e:#}"); + std::process::exit(1); + } +} + +fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Verify { file } => { + if !std::path::Path::new(&file).exists() { + anyhow::bail!("file not found: {}", file); + } + + eprintln!("Opening package: {}", file); + let pkg = opc::OpcPackage::open(&file)?; + + eprintln!("Verifying signature..."); + verification::verify(&pkg)?; + + eprintln!("Signature is valid."); + } + + Commands::Sign { + pkcs11_module, + pkcs11_cert, + pkcs11_key, + file_digest, + timestamp, + timestamp_algorithm, + force, + file, + } => { + let digest_alg = DigestAlgorithm::from_str(&file_digest).ok_or_else(|| { + anyhow::anyhow!( + "unsupported digest algorithm '{}'; choose sha1/sha256/sha384/sha512", + file_digest + ) + })?; + + // Resolve optional timestamp configuration. + let ts_config = if let Some(ref url) = timestamp { + let ts_alg = DigestAlgorithm::from_str(×tamp_algorithm).ok_or_else(|| { + anyhow::anyhow!( + "unsupported timestamp digest algorithm '{}'; choose sha1/sha256/sha384/sha512", + timestamp_algorithm + ) + })?; + Some(signing::TimestampConfig { + url: url.as_str(), + digest_alg: ts_alg, + }) + } else { + None + }; + + if !std::path::Path::new(&file).exists() { + anyhow::bail!("file not found: {}", file); + } + + eprintln!("Loading certificate from PKCS#11 token..."); + let cert_der = pkcs11::load_certificate_der(&pkcs11_module, &pkcs11_cert)?; + + // Build a signing closure. The PIN is embedded in the key URI's + // pin-value= field and extracted inside pkcs11_sign. + let signer = { + let module = pkcs11_module.clone(); + let key = pkcs11_key.clone(); + move |data: &[u8]| { + pkcs11::pkcs11_sign(&module, &key, digest_alg, data) + } + }; + + eprintln!("Opening package: {}", file); + let mut pkg = opc::OpcPackage::open(&file)?; + + eprintln!("Signing..."); + signing::sign(&mut pkg, &cert_der, &signer, digest_alg, ts_config.as_ref(), force)?; + + eprintln!("Writing signed package..."); + pkg.save()?; + + eprintln!("Signing complete."); + } + } + + Ok(()) +} diff --git a/hlkx-sign/src/opc.rs b/hlkx-sign/src/opc.rs new file mode 100644 index 0000000..d018cd9 --- /dev/null +++ b/hlkx-sign/src/opc.rs @@ -0,0 +1,465 @@ +//! OPC (Open Packaging Conventions) package manipulation. +//! +//! An OPC package is a ZIP file with: +//! - `[Content_Types].xml` – maps file extensions / part names to MIME types +//! - `_rels/.rels` – package-level relationships +//! - arbitrary parts (files) + +use anyhow::Result; +use quick_xml::events::Event; +use quick_xml::Reader; +use std::collections::HashMap; +use std::io::{Cursor, Read, Write}; +use zip::ZipArchive; + +// ───────────────────────────────────────────────────────────────────────────── +// Well-known strings +// ───────────────────────────────────────────────────────────────────────────── + +pub const CONTENT_TYPES_XML: &str = "[Content_Types].xml"; +pub const GLOBAL_RELS: &str = "_rels/.rels"; + +/// Well-known namespace for OPC relationships. +#[allow(dead_code)] +pub const NS_RELS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; +/// Well-known namespace for OPC content types. +#[allow(dead_code)] +pub const NS_CONTENT_TYPES: &str = + "http://schemas.openxmlformats.org/package/2006/content-types"; + +pub const REL_DS_ORIGIN: &str = + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/origin"; +pub const REL_DS_SIGNATURE: &str = + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/signature"; +pub const REL_DS_CERTIFICATE: &str = + "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/certificate"; + +pub const MIME_DS_ORIGIN: &str = + "application/vnd.openxmlformats-package.digital-signature-origin"; +pub const MIME_DS_SIGNATURE: &str = + "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml"; +pub const MIME_DS_CERTIFICATE: &str = + "application/vnd.openxmlformats-package.digital-signature-certificate"; +pub const MIME_RELS: &str = + "application/vnd.openxmlformats-package.relationships+xml"; +/// Default part content type when no `[Content_Types].xml` entry matches (matches +/// `OpcKnownMimeTypes.OctetString` in the C# tool). +pub const MIME_OCTET: &str = "application/octet"; + +/// Origin part URI used when filtering package relationships for signing (matches +/// `XTable.ID.OriginFileUri` / in-memory `OpcRelationship.Target` in the C# tool). +pub const DS_ORIGIN_PART_URI: &str = + "package:///package/services/digital-signature/origin.psdsor"; + +/// `Target` attribute form written to `_rels/.rels` (matches `Uri.ToQualifiedPath()`). +pub const DS_ORIGIN_PART_PATH: &str = "/package/services/digital-signature/origin.psdsor"; + +/// Extension token used with `[Content_Types].xml` lookup, mirroring .NET +/// `Path.GetExtension(partPath)?.TrimStart('.')` on `OpcPart` paths. +/// +/// Rust's [`std::path::Path::extension`] returns `None` for file names like +/// `.rels` (a leading dot before the extension), which would incorrectly fall +/// back to [`MIME_OCTET`] (`application/octet`) for `/_rels/.rels`. +pub fn extension_for_opc_content_type(part_path: &str) -> &str { + let file = part_path.rsplit('/').next().unwrap_or(part_path); + file.rfind('.').map(|i| &file[i + 1..]).unwrap_or("") +} + +// ───────────────────────────────────────────────────────────────────────────── +// OpcRelationship +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct OpcRelationship { + pub id: String, + pub rel_type: String, + pub target: String, +} + +impl OpcRelationship { + pub fn new(id: impl Into, rel_type: impl Into, target: impl Into) -> Self { + Self { id: id.into(), rel_type: rel_type.into(), target: target.into() } + } +} + +/// Decode a `_rels/*.rels` XML byte stream into a list of relationships. +pub fn parse_rels(xml: &[u8]) -> Result> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(true); + let mut rels = Vec::new(); + loop { + match reader.read_event()? { + Event::Eof => break, + Event::Start(e) | Event::Empty(e) => { + if e.local_name().as_ref() == b"Relationship" { + let mut id = String::new(); + let mut rel_type = String::new(); + let mut target = String::new(); + for attr in e.attributes().flatten() { + let key = std::str::from_utf8(attr.key.local_name().as_ref())?.to_string(); + let val = attr.decode_and_unescape_value(reader.decoder())?.to_string(); + match key.as_str() { + "Id" => id = val, + "Type" => rel_type = val, + "Target" => target = val, + _ => {} + } + } + rels.push(OpcRelationship::new(id, rel_type, target)); + } + } + _ => {} + } + } + Ok(rels) +} + +/// Serialize a list of relationships to a `_rels/*.rels` XML byte stream. +/// +/// The output is NOT a C14N document; it is the normal on-disk form used by +/// OPC packages (UTF-8 with XML declaration). +pub fn serialize_rels(rels: &[OpcRelationship]) -> Vec { + let mut out = b"".to_vec(); + out.extend_from_slice( + b"", + ); + // Sort by Id for determinism. + let mut sorted: Vec<&OpcRelationship> = rels.iter().collect(); + sorted.sort_by(|a, b| a.id.cmp(&b.id)); + for rel in &sorted { + // Attribute order matches `OpcRelationships.ToXml` in the C# tool. + out.extend_from_slice(b""); + } + out.extend_from_slice(b""); + out +} + +// ───────────────────────────────────────────────────────────────────────────── +// OpcContentType +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub enum OpcContentTypeEntry { + Default { extension: String, content_type: String }, + Override { part_name: String, content_type: String }, +} + +pub fn parse_content_types(xml: &[u8]) -> Result> { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(true); + let mut entries = Vec::new(); + loop { + match reader.read_event()? { + Event::Eof => break, + Event::Start(e) | Event::Empty(e) => { + let lname = e.local_name(); + if lname.as_ref() == b"Default" { + let mut ext = String::new(); + let mut ct = String::new(); + for attr in e.attributes().flatten() { + let key = std::str::from_utf8(attr.key.local_name().as_ref())?.to_string(); + let val = attr.decode_and_unescape_value(reader.decoder())?.to_string(); + match key.as_str() { + "Extension" => ext = val, + "ContentType" => ct = val, + _ => {} + } + } + entries.push(OpcContentTypeEntry::Default { + extension: ext, + content_type: ct, + }); + } else if lname.as_ref() == b"Override" { + let mut part = String::new(); + let mut ct = String::new(); + for attr in e.attributes().flatten() { + let key = std::str::from_utf8(attr.key.local_name().as_ref())?.to_string(); + let val = attr.decode_and_unescape_value(reader.decoder())?.to_string(); + match key.as_str() { + "PartName" => part = val, + "ContentType" => ct = val, + _ => {} + } + } + entries.push(OpcContentTypeEntry::Override { + part_name: part, + content_type: ct, + }); + } + } + _ => {} + } + } + Ok(entries) +} + +pub fn serialize_content_types(entries: &[OpcContentTypeEntry]) -> Vec { + let mut out = b"".to_vec(); + out.extend_from_slice(b""); + for entry in entries { + match entry { + OpcContentTypeEntry::Default { extension, content_type } => { + write!( + out, + "", + xml_escape_attr(content_type), + xml_escape_attr(extension), + ).unwrap(); + } + OpcContentTypeEntry::Override { part_name, content_type } => { + write!( + out, + "", + xml_escape_attr(content_type), + xml_escape_attr(part_name), + ).unwrap(); + } + } + } + out.extend_from_slice(b""); + out +} + +// ───────────────────────────────────────────────────────────────────────────── +// OpcPackage +// ───────────────────────────────────────────────────────────────────────────── + +/// In-memory representation of all ZIP entries, ready for modification before +/// writing the updated package back to disk. +pub struct OpcPackage { + /// path of the original file (for writing back) + pub path: std::path::PathBuf, + /// All ZIP entries keyed by their path (e.g. `"_rels/.rels"`). + pub entries: HashMap>, + /// Parsed content types. + pub content_types: Vec, + /// Package-level relationships. + pub pkg_rels: Vec, +} + +impl OpcPackage { + /// Read an HLKX/VSIX file into memory so parts can be modified and + /// then written back with [`OpcPackage::save`]. + pub fn open(path: impl Into) -> Result { + let path = path.into(); + let file = std::fs::File::open(&path)?; + let mut archive = ZipArchive::new(file)?; + + let mut entries: HashMap> = HashMap::new(); + let mut content_types = Vec::new(); + let mut pkg_rels = Vec::new(); + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + let name = entry.name().to_string(); + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + + if name.eq_ignore_ascii_case(CONTENT_TYPES_XML) { + content_types = parse_content_types(&buf)?; + // keep raw bytes in entries too so we can rebuild later + } else if name == GLOBAL_RELS { + pkg_rels = parse_rels(&buf)?; + } + entries.insert(name, buf); + } + + Ok(Self { path, entries, content_types, pkg_rels }) + } + + /// Return the raw bytes of a part (ZIP entry). + #[allow(dead_code)] + pub fn get_part(&self, name: &str) -> Option<&Vec> { + self.entries.get(name) + } + + /// Insert or replace a part's bytes. + pub fn set_part(&mut self, name: impl Into, data: Vec) { + self.entries.insert(name.into(), data); + } + + /// True if the package already contains a digital-signature origin file. + pub fn has_signatures(&self) -> bool { + self.pkg_rels + .iter() + .any(|r| r.rel_type == REL_DS_ORIGIN) + } + + /// Look up the MIME content-type for a given file extension. + /// Falls back to [`MIME_OCTET`] if not found. + pub fn content_type_for_extension(&self, ext: &str) -> &str { + for entry in &self.content_types { + if let OpcContentTypeEntry::Default { extension, content_type } = entry { + if extension.eq_ignore_ascii_case(ext) { + return content_type.as_str(); + } + } + } + MIME_OCTET + } + + /// Effective content type for a package part path (e.g. `hck/data/foo`). + /// + /// OPC `[Content_Types].xml` `Override` entries take precedence over + /// `Default` extension rules. Microsoft validators resolve the + /// `?ContentType=` query on manifest references this way. + pub fn content_type_for_part(&self, part_path: &str) -> &str { + let part_uri = entry_to_uri(part_path); + for entry in &self.content_types { + if let OpcContentTypeEntry::Override { part_name, content_type } = entry { + if part_name.eq_ignore_ascii_case(&part_uri) { + return content_type.as_str(); + } + } + } + self.content_type_for_extension(extension_for_opc_content_type(part_path)) + } + + /// Ensure that `` exists. + pub fn ensure_content_type(&mut self, extension: &str, mime: &str) { + let already_there = self.content_types.iter().any(|e| match e { + OpcContentTypeEntry::Default { extension: ext, .. } => { + ext.eq_ignore_ascii_case(extension) + } + _ => false, + }); + if !already_there { + self.content_types.push(OpcContentTypeEntry::Default { + extension: extension.to_string(), + content_type: mime.to_string(), + }); + } + } + + /// Generate a unique relationship ID not already present in `rels`. + pub fn new_rel_id(rels: &[OpcRelationship]) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + // Use a simple counter seeded with nanoseconds for uniqueness. + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.subsec_nanos()) + .unwrap_or(0); + let mut counter = seed as u64; + loop { + counter = counter.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let id = format!("R{:016x}", counter); + if !rels.iter().any(|r| r.id == id) { + return id; + } + } + } + + /// Write the modified package back to the original file. + /// + /// The procedure is: write a new ZIP to a temp file, then replace the + /// original atomically (best-effort on Windows). + pub fn save(&mut self) -> Result<()> { + // Rebuild serialised forms of the mutable pieces. + let ct_bytes = serialize_content_types(&self.content_types); + let rels_bytes = serialize_rels(&self.pkg_rels); + self.entries.insert(CONTENT_TYPES_XML.to_string(), ct_bytes); + self.entries.insert(GLOBAL_RELS.to_string(), rels_bytes); + + let dir = self.path.parent().unwrap_or(std::path::Path::new(".")); + let tmp = tempfile::NamedTempFile::new_in(dir)?; + { + let mut writer = zip::ZipWriter::new(tmp.as_file()); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + + // Write every entry (sorted for determinism, content-types last). + let mut names: Vec<&String> = self.entries.keys().collect(); + names.sort(); + for name in names { + let data = &self.entries[name]; + writer.start_file(name, options)?; + writer.write_all(data)?; + } + writer.finish()?; + } + tmp.persist(&self.path)?; + Ok(()) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Return an XML attribute-safe version of `s` (escaping `&`, `<`, `>`, `"`). +pub fn xml_escape_attr(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + c => out.push(c), + } + } + out +} + +/// Derive the relationships file path for a given part path. +/// E.g. `"package/services/foo.psdsxs"` → +/// `"package/services/_rels/foo.psdsxs.rels"`. +pub fn rels_path_for_part(part_path: &str) -> String { + match part_path.rfind('/') { + Some(slash) => { + let dir = &part_path[..slash]; + let file = &part_path[slash + 1..]; + format!("{}/_rels/{}.rels", dir, file) + } + None => format!("_rels/{}.rels", part_path), + } +} + +/// Normalize a relationship `Target` to a leading-slash package path. +pub fn normalize_relationship_target(target: &str) -> String { + let t = target.trim(); + if let Some(rest) = t.strip_prefix("package:///") { + if rest.starts_with('/') { + rest.to_string() + } else { + format!("/{rest}") + } + } else if let Some(rest) = t.strip_prefix("package:/") { + let rest = rest.trim_start_matches('/'); + format!("/{rest}") + } else if t.starts_with('/') { + t.to_string() + } else { + format!("/{t}") + } +} + +/// True when `target` refers to the digital-signature origin part (matches +/// `OpcSignatureManifest.GetRelationships` in the C# implementation). +pub fn is_digital_signature_origin_target(target: &str) -> bool { + normalize_relationship_target(target) == DS_ORIGIN_PART_PATH +} + +/// Map a ZIP entry path to a URI-style path (e.g. `"/foo/bar.xml"`). +pub fn entry_to_uri(path: &str) -> String { + if path.starts_with('/') { + path.to_string() + } else { + format!("/{}", path) + } +} + +/// Read a ZIP entry from the archive by name, returning its bytes. +#[allow(dead_code)] +pub fn read_entry(zip_bytes: &[u8], name: &str) -> Result> { + let cursor = Cursor::new(zip_bytes); + let mut archive = ZipArchive::new(cursor)?; + let mut entry = archive.by_name(name)?; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf)?; + Ok(buf) +} diff --git a/hlkx-sign/src/pkcs11.rs b/hlkx-sign/src/pkcs11.rs new file mode 100644 index 0000000..0d9bb32 --- /dev/null +++ b/hlkx-sign/src/pkcs11.rs @@ -0,0 +1,263 @@ +//! Load a certificate and sign data using a PKCS#11 token via the `cryptoki` crate. +//! +//! This replaces the previous OpenSSL ENGINE-based implementation with a pure +//! Rust, safe API that talks directly to the PKCS#11 library. + +use crate::xml_sig::DigestAlgorithm; +use anyhow::{bail, Context, Result}; +use cryptoki::{ + context::{CInitializeArgs, CInitializeFlags, Pkcs11}, + mechanism::Mechanism, + object::{Attribute, AttributeType, ObjectClass}, + session::UserType, + types::AuthPin, +}; + +// ───────────────────────────────────────────────────────────────────────────── +// PKCS#11 URI parsing helpers (RFC 7512) +// ───────────────────────────────────────────────────────────────────────────── + +/// A parsed view of the fields we care about in a PKCS#11 URI. +/// +/// PKCS#11 URIs look like: +/// `pkcs11:token=MyToken;object=MyCert;type=cert;pin-value=1234` +/// Plain labels (e.g. `"MyCert"`) are accepted and treated as `object=` only. +struct Pkcs11Uri<'a> { + /// `object=` – the `CKA_LABEL` of the object to look up. + pub object: &'a str, + /// `token=` – if present, only slots whose token label matches are used. + pub token: Option<&'a str>, + /// `pin-value=` – if present, used to authenticate to the token. + pub pin_value: Option<&'a str>, +} + +impl<'a> Pkcs11Uri<'a> { + /// Parse a PKCS#11 URI string, or treat a plain string as an object label. + fn parse(id: &'a str) -> Self { + if let Some(rest) = id.strip_prefix("pkcs11:") { + let mut object: Option<&str> = None; + let mut token: Option<&str> = None; + let mut pin_value: Option<&str> = None; + for part in rest.split(';') { + if let Some(v) = part.strip_prefix("object=") { + object = Some(v); + } else if let Some(v) = part.strip_prefix("token=") { + token = Some(v); + } else if let Some(v) = part.strip_prefix("pin-value=") { + pin_value = Some(v); + } + } + Pkcs11Uri { + object: object.unwrap_or(""), + token, + pin_value, + } + } else { + // Plain label – no token filter, no embedded PIN. + Pkcs11Uri { + object: id, + token: None, + pin_value: None, + } + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Initialize a PKCS#11 context from the shared library at `module`. +fn init_pkcs11(module: &str) -> Result<(Pkcs11, Vec)> { + let pkcs11 = Pkcs11::new(module).context("Failed to load PKCS#11 module")?; + pkcs11 + .initialize(CInitializeArgs::new(CInitializeFlags::OS_LOCKING_OK)) + .context("Failed to initialize PKCS#11")?; + let slots = pkcs11 + .get_slots_with_initialized_token() + .context("Failed to get PKCS#11 slots")?; + if slots.is_empty() { + bail!("No initialized PKCS#11 token found"); + } + Ok((pkcs11, slots)) +} + +/// Return `true` if the token in `slot` has a label that matches `wanted`. +/// +/// The PKCS#11 `CK_TOKEN_INFO.label` field is a fixed-width 32-byte space-padded +/// string. `TokenInfo::label()` trims that padding, so we compare the trimmed value. +fn token_label_matches(pkcs11: &Pkcs11, slot: cryptoki::slot::Slot, wanted: &str) -> bool { + match pkcs11.get_token_info(slot) { + Ok(info) => info.label().trim() == wanted.trim(), + Err(_) => false, + } +} + +/// Optionally log in to a session with the given PIN. +fn maybe_login(session: &cryptoki::session::Session, pin: Option<&str>) -> Result<()> { + if let Some(p) = pin { + session + .login(UserType::User, Some(&AuthPin::new(Box::from(p)))) + .context("PKCS#11 login failed")?; + } + Ok(()) +} + +fn rsa_pkcs1_mechanism(digest_alg: DigestAlgorithm) -> Mechanism<'static> { + match digest_alg { + DigestAlgorithm::Sha1 => Mechanism::Sha1RsaPkcs, + DigestAlgorithm::Sha256 => Mechanism::Sha256RsaPkcs, + DigestAlgorithm::Sha384 => Mechanism::Sha384RsaPkcs, + DigestAlgorithm::Sha512 => Mechanism::Sha512RsaPkcs, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Load the DER-encoded bytes of a certificate from the PKCS#11 token. +/// +/// * `module` – path to the PKCS#11 shared library. +/// * `cert_id` – PKCS#11 URI (RFC 7512) or plain label of the certificate object. +/// If the URI contains a `token=` component only matching tokens are +/// searched. A `pin-value=` component in the URI is used to log in. +pub fn load_certificate_der(module: &str, cert_id: &str) -> Result> { + let uri = Pkcs11Uri::parse(cert_id); + let (pkcs11, slots) = init_pkcs11(module)?; + + for slot in slots { + // Skip this slot if the token label does not match the URI's token= field. + if let Some(wanted) = uri.token { + if !token_label_matches(&pkcs11, slot, wanted) { + continue; + } + } + + let session = pkcs11 + .open_ro_session(slot) + .context("Failed to open PKCS#11 session")?; + maybe_login(&session, uri.pin_value)?; + + let search = vec![ + Attribute::Class(ObjectClass::CERTIFICATE), + Attribute::Label(uri.object.as_bytes().to_vec()), + ]; + let handles = session + .find_objects(&search) + .context("PKCS#11 find_objects failed")?; + + for handle in handles { + let attrs = session + .get_attributes(handle, &[AttributeType::Value]) + .context("PKCS#11 get_attributes failed")?; + for attr in attrs { + if let Attribute::Value(der) = attr { + if !der.is_empty() { + return Ok(der); + } + } + } + } + } + + bail!("Certificate '{}' not found on any PKCS#11 slot", cert_id) +} + +/// Sign `data` with the private key identified by `key_id` on the PKCS#11 token. +/// +/// Uses the combined hash-and-sign RSA PKCS#1 v1.5 mechanism (e.g. +/// `CKM_SHA256_RSA_PKCS`) so the hash is computed on the token. +/// +/// * `module` – path to the PKCS#11 shared library. +/// * `key_id` – PKCS#11 URI (RFC 7512) or plain label of the private key. +/// If the URI contains a `token=` component only matching tokens +/// are searched. A `pin-value=` component is used to log in. +/// * `digest_alg` – selects the hash algorithm embedded in the mechanism. +/// * `data` – the raw bytes to sign (the canonical `` XML). +pub fn pkcs11_sign( + module: &str, + key_id: &str, + digest_alg: DigestAlgorithm, + data: &[u8], +) -> Result> { + let uri = Pkcs11Uri::parse(key_id); + let mechanism = rsa_pkcs1_mechanism(digest_alg); + let (pkcs11, slots) = init_pkcs11(module)?; + + for slot in slots { + // Skip this slot if the token label does not match the URI's token= field. + if let Some(wanted) = uri.token { + if !token_label_matches(&pkcs11, slot, wanted) { + continue; + } + } + + // Signing may require a read-write session on some tokens. + let session = pkcs11 + .open_rw_session(slot) + .context("Failed to open PKCS#11 session")?; + maybe_login(&session, uri.pin_value)?; + + let search = vec![ + Attribute::Class(ObjectClass::PRIVATE_KEY), + Attribute::Label(uri.object.as_bytes().to_vec()), + ]; + let handles = session + .find_objects(&search) + .context("PKCS#11 find_objects failed")?; + + if let Some(&key_handle) = handles.first() { + let sig = session + .sign(&mechanism, key_handle, data) + .context("PKCS#11 sign failed")?; + return Ok(sig); + } + } + + bail!("Private key '{}' not found on any PKCS#11 slot", key_id) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::Pkcs11Uri; + + #[test] + fn parses_full_uri() { + let uri = Pkcs11Uri::parse( + "pkcs11:token=MyToken;object=MyCert;type=cert;pin-value=secret", + ); + assert_eq!(uri.object, "MyCert"); + assert_eq!(uri.token, Some("MyToken")); + assert_eq!(uri.pin_value, Some("secret")); + } + + #[test] + fn parses_uri_without_optional_fields() { + let uri = Pkcs11Uri::parse("pkcs11:object=SomeCert"); + assert_eq!(uri.object, "SomeCert"); + assert_eq!(uri.token, None); + assert_eq!(uri.pin_value, None); + } + + #[test] + fn parses_plain_label() { + let uri = Pkcs11Uri::parse("MyLabel"); + assert_eq!(uri.object, "MyLabel"); + assert_eq!(uri.token, None); + assert_eq!(uri.pin_value, None); + } + + #[test] + fn parses_uri_with_token_and_pin_no_object() { + // Edge case: URI with token= and pin-value= but no object=. + let uri = Pkcs11Uri::parse("pkcs11:token=Tok;pin-value=1234"); + assert_eq!(uri.object, ""); + assert_eq!(uri.token, Some("Tok")); + assert_eq!(uri.pin_value, Some("1234")); + } +} diff --git a/hlkx-sign/src/signing.rs b/hlkx-sign/src/signing.rs new file mode 100644 index 0000000..c0b2b4e --- /dev/null +++ b/hlkx-sign/src/signing.rs @@ -0,0 +1,416 @@ +//! Top-level signing orchestration. +//! +//! Implements the same logic as the C# `OpcPackageSignatureBuilder.Sign` and +//! `OpcSignatureManifest.Build` methods: +//! +//! 1. Collect all parts that need to be signed. +//! 2. Compute digests (two for `_rels/.rels`, one for everything else). +//! 3. Build the XML digital signature. +//! 4. Write the signature, certificate, and updated relationship/content-type +//! files back into the package. + +use crate::c14n::c14n; +use crate::opc::{ + entry_to_uri, is_digital_signature_origin_target, + rels_path_for_part, serialize_rels, OpcPackage, OpcRelationship, MIME_DS_CERTIFICATE, + MIME_DS_ORIGIN, MIME_DS_SIGNATURE, MIME_RELS, REL_DS_CERTIFICATE, REL_DS_ORIGIN, + REL_DS_SIGNATURE, +}; +use std::collections::{BTreeMap, HashSet}; +use crate::timestamp; +use crate::xml_sig::{ + append_timestamp_object, build_signature_xml, DigestAlgorithm, PartDigest, TransformInfo, +}; +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use chrono::Local; +use der::Decode; +use uuid::Uuid; +use x509_cert::Certificate; + +// ───────────────────────────────────────────────────────────────────────────── +// Timestamp configuration +// ───────────────────────────────────────────────────────────────────────────── + +/// Optional RFC 3161 timestamp configuration. +pub struct TimestampConfig<'a> { + /// URL of the Time Stamping Authority. + pub url: &'a str, + /// Hash algorithm to use in the timestamp request (defaults to SHA-256 if + /// no explicit selection is made by the caller). + pub digest_alg: DigestAlgorithm, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public entry-point +// ───────────────────────────────────────────────────────────────────────────── + +/// Sign the OPC package at `pkg`. +/// +/// * `certificate_der` – DER-encoded signing certificate (embedded in the package). +/// * `signer` – closure that signs the canonical `` bytes with +/// RSA PKCS#1 v1.5 and returns the raw signature bytes. +/// * `digest_alg` – hash algorithm for file digests and the signature. +/// * `timestamp` – optional RFC 3161 timestamp configuration; when present, +/// the signature value is timestamped and the token embedded. +/// * `force` – overwrite an existing signature if one is present. +pub fn sign( + pkg: &mut OpcPackage, + certificate_der: &[u8], + signer: &dyn Fn(&[u8]) -> Result>, + digest_alg: DigestAlgorithm, + timestamp: Option<&TimestampConfig<'_>>, + force: bool, +) -> Result<()> { + if pkg.has_signatures() && !force { + bail!("The package is already signed. Use --force to overwrite."); + } + + // ── Step 1: Choose file names for the new signature artefacts ───────── + let sig_filename = format!("{}.psdsxs", Uuid::new_v4().as_simple()); + let cert_filename = cert_der_filename(certificate_der)?; + + let origin_part_path = "package/services/digital-signature/origin.psdsor"; + let sig_part_path = format!( + "package/services/digital-signature/xml-signature/{}", + sig_filename + ); + let cert_part_path = format!( + "package/services/digital-signature/certificate/{}", + cert_filename + ); + + // ── Step 2: Create origin, signature-placeholder, and certificate parts ─ + // Origin part (empty content). + pkg.set_part(origin_part_path, vec![]); + + // Package-level relationship → origin. + let origin_target = format!("/{}", origin_part_path); + let rel_id = OpcPackage::new_rel_id(&pkg.pkg_rels); + pkg.pkg_rels.push(OpcRelationship::new( + rel_id, + REL_DS_ORIGIN, + &origin_target, + )); + + // Flush `_rels/.rels` to the entries map now so the digest is computed on + // the updated version (same order as the C# code: flush before digests). + let rels_bytes = serialize_rels(&pkg.pkg_rels); + pkg.entries.insert(crate::opc::GLOBAL_RELS.to_string(), rels_bytes); + + // Signature part (placeholder – written later). + pkg.set_part(&sig_part_path, vec![]); + + // Origin → signature relationship. + let origin_rels_path = rels_path_for_part(origin_part_path); + let origin_rels = pkg + .entries + .get(origin_rels_path.as_str()) + .cloned() + .unwrap_or_default(); + let mut origin_rel_list = if origin_rels.is_empty() { + vec![] + } else { + crate::opc::parse_rels(&origin_rels)? + }; + let sig_rel_id = OpcPackage::new_rel_id(&origin_rel_list); + origin_rel_list.push(OpcRelationship::new( + sig_rel_id, + REL_DS_SIGNATURE, + &format!("/{}", sig_part_path), + )); + pkg.entries.insert(origin_rels_path, serialize_rels(&origin_rel_list)); + + // Certificate part (DER bytes). + pkg.set_part(&cert_part_path, certificate_der.to_vec()); + + // Signature → certificate relationship. + let sig_rels_path = rels_path_for_part(&sig_part_path); + let cert_rel_id = OpcPackage::new_rel_id(&[]); + let cert_rels = vec![OpcRelationship::new( + cert_rel_id, + REL_DS_CERTIFICATE, + &format!("/{}", cert_part_path), + )]; + pkg.entries.insert(sig_rels_path, serialize_rels(&cert_rels)); + + // ── Step 3: Ensure required content types exist ─────────────────────── + pkg.ensure_content_type("rels", MIME_RELS); + pkg.ensure_content_type("psdsor", MIME_DS_ORIGIN); + pkg.ensure_content_type("psdsxs", MIME_DS_SIGNATURE); + pkg.ensure_content_type("cer", MIME_DS_CERTIFICATE); + + // ── Step 4: Collect the parts to sign ──────────────────────────────── + // Same logic as VSIXSignatureBuilderPreset: all parts that are not + // themselves digital-signature XML files. + let mut parts_to_sign: Vec = pkg + .entries + .keys() + .filter(|p| should_sign_part(p)) + .cloned() + .collect(); + parts_to_sign.sort(); + + // ── Step 5: Compute digests ──────────────────────────────────────────── + let mut all_digests: Vec = Vec::new(); + + for part_path in &parts_to_sign { + let data = pkg.entries.get(part_path.as_str()).cloned().unwrap_or_default(); + let mime = pkg.content_type_for_part(part_path).to_string(); + + if part_path == crate::opc::GLOBAL_RELS { + // Two digest entries for _rels/.rels (mirrors OpcSignatureManifest.Build). + let digests = digest_rels_part(part_path, &data, &mime, digest_alg, pkg)?; + all_digests.extend(digests); + } else { + // Regular part: simple hash of raw bytes. + let hash = digest_alg.hash(&data); + let uri = format!("{}?ContentType={}", entry_to_uri(part_path), mime); + all_digests.push(PartDigest { + uri, + digest_b64: B64.encode(&hash), + hash_uri: digest_alg.xml_uri().to_string(), + transforms: vec![], + }); + } + } + + // Sort by URI (case-insensitive, matching the C# sort). + all_digests.sort_by(|a, b| a.uri.to_lowercase().cmp(&b.uri.to_lowercase())); + + // ── Step 6: Build the XML signature ─────────────────────────────────── + let signing_time = Local::now().fixed_offset(); + + let (mut sig_xml, sig_bytes) = build_signature_xml( + &all_digests, + digest_alg, + signing_time, + signer, + )?; + + // ── Optional Step 6b: RFC 3161 timestamp (C# timestamps the existing + // SignatureValue; it does NOT re-sign the package). + if let Some(ts) = timestamp { + eprintln!("Requesting RFC 3161 timestamp..."); + let token = timestamp::request_timestamp(ts.url, &sig_bytes, ts.digest_alg) + .context("Timestamp request failed")?; + sig_xml = append_timestamp_object(sig_xml, &token)?; + } + + // ── Step 7: Write the signature into the package ────────────────────── + pkg.set_part(&sig_part_path, sig_xml); + + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Determine whether a ZIP entry path should be included in the signature. +/// Excludes: +/// - `[Content_Types].xml` +/// - existing digital-signature parts (origin, signatures, certificates) +/// - relationship files that belong to signature parts +pub(crate) fn should_sign_part(path: &str) -> bool { + if path.eq_ignore_ascii_case(crate::opc::CONTENT_TYPES_XML) { + return false; + } + // Exclude the digital-signature infrastructure itself. + let ds_prefix = "package/services/digital-signature/"; + if path.starts_with(ds_prefix) { + return false; + } + // Exclude relationship files that live inside the ds hierarchy. + // (e.g. "package/services/digital-signature/_rels/origin.psdsor.rels") + if path.contains("/digital-signature/") { + return false; + } + true +} + +/// Compute the two digest entries required for a `_rels/*.rels` part. +/// +/// Entry 1: C14N of the raw XML bytes. +/// Entry 2: C14N of a filtered relationships document (the OPC +/// RelationshipTransform), excluding the origin relationship. +pub(crate) fn digest_rels_part( + part_path: &str, + raw_xml: &[u8], + mime: &str, + digest_alg: DigestAlgorithm, + pkg: &OpcPackage, +) -> Result> { + let uri = format!("{}?ContentType={}", entry_to_uri(part_path), mime); + + // ── Entry 1: C14N of the raw XML ────────────────────────────────────── + let c14n_raw = c14n(raw_xml)?; + let hash1 = digest_alg.hash(&c14n_raw); + + // ── Entry 2: RelationshipTransform + C14N ───────────────────────────── + // Build a sorted filtered relationships document (mirrors + // `OpcSignatureManifest.GetRelationships`: exclude origin by Target URI, + // dedupe by Id, sort by Id). + let filtered = filtered_package_relationships(pkg); + let filtered_refs: Vec<&OpcRelationship> = filtered.iter().collect(); + + // Serialize in the form used by InternalRelationshipCollection: + // each Relationship has TargetMode="Internal". + let filtered_xml = build_filtered_rels_xml(&filtered_refs); + let c14n_filtered = c14n(filtered_xml.as_bytes())?; + let hash2 = digest_alg.hash(&c14n_filtered); + + // One RelationshipsGroupReference per distinct SourceType (first-seen order when + // relationships are sorted by Id). Duplicate selectors are redundant for the + // RelationshipTransform and are omitted in Microsoft-accepted HLKX signatures. + let source_types = unique_relationship_source_types(&filtered_refs); + + Ok(vec![ + // Entry 1 – C14N transform only. + PartDigest { + uri: uri.clone(), + digest_b64: B64.encode(&hash1), + hash_uri: digest_alg.xml_uri().to_string(), + transforms: vec![TransformInfo::C14n], + }, + // Entry 2 – RelationshipTransform + C14N. + PartDigest { + uri, + digest_b64: B64.encode(&hash2), + hash_uri: digest_alg.xml_uri().to_string(), + transforms: vec![ + TransformInfo::RelationshipTransform { source_types }, + TransformInfo::C14n, + ], + }, + ]) +} + +/// Distinct relationship `Type` values in first-seen order (relationships sorted by Id). +pub(crate) fn unique_relationship_source_types(rels: &[&OpcRelationship]) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for r in rels { + if seen.insert(r.rel_type.as_str()) { + out.push(r.rel_type.clone()); + } + } + out +} + +/// Relationships to include in the RelationshipTransform digest, matching +/// `OpcSignatureManifest.GetRelationships`. +pub(crate) fn filtered_package_relationships(pkg: &OpcPackage) -> Vec { + let mut by_id: BTreeMap = BTreeMap::new(); + for rel in &pkg.pkg_rels { + if is_digital_signature_origin_target(&rel.target) { + continue; + } + by_id.entry(rel.id.clone()).or_insert_with(|| rel.clone()); + } + by_id.into_values().collect() +} + +/// Build the XML document used as input to the RelationshipTransform. +/// Matches the output of `InternalRelationshipCollection.WriteRelationshipsAsXml` +/// with `alwaysWriteTargetModeAttribute = true` (attribute order: Type, Target, +/// TargetMode, Id). +pub(crate) fn build_filtered_rels_xml(rels: &[&OpcRelationship]) -> String { + use crate::opc::xml_escape_attr; + let mut s = String::new(); + s.push_str(""); + for r in rels { + s.push_str(""); + } + s.push_str(""); + s +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opc::{ + is_digital_signature_origin_target, normalize_relationship_target, DS_ORIGIN_PART_PATH, + DS_ORIGIN_PART_URI, MIME_OCTET, + }; + use std::collections::HashMap; + + #[test] + fn default_mime_matches_csharp() { + assert_eq!(MIME_OCTET, "application/octet"); + } + + #[test] + fn origin_target_normalization() { + assert_eq!( + normalize_relationship_target(DS_ORIGIN_PART_URI), + DS_ORIGIN_PART_PATH + ); + assert_eq!( + normalize_relationship_target(DS_ORIGIN_PART_PATH), + DS_ORIGIN_PART_PATH + ); + assert!(is_digital_signature_origin_target(DS_ORIGIN_PART_URI)); + assert!(is_digital_signature_origin_target(DS_ORIGIN_PART_PATH)); + assert!(!is_digital_signature_origin_target("/hck/data/foo")); + } + + #[test] + fn append_timestamp_preserves_signature_value() { + use crate::xml_sig::append_timestamp_object; + let sig_xml = b"QUJD".to_vec(); + let out = append_timestamp_object(sig_xml, b"fake-token").unwrap(); + let s = std::str::from_utf8(&out).unwrap(); + assert!(s.contains("QUJD").count(), 1); + } + + #[test] + fn unique_source_types_dedupes_telemetry() { + let rels = vec![ + OpcRelationship::new("r1", "http://example.com/telemetry", "/a"), + OpcRelationship::new("r2", "http://example.com/coredata", "/b"), + OpcRelationship::new("r3", "http://example.com/telemetry", "/c"), + ]; + let refs: Vec<&OpcRelationship> = rels.iter().collect(); + let types = unique_relationship_source_types(&refs); + assert_eq!(types.len(), 2); + assert_eq!(types[0], "http://example.com/telemetry"); + assert_eq!(types[1], "http://example.com/coredata"); + } + + #[test] + fn filtered_rels_exclude_origin_by_target() { + let pkg = OpcPackage { + path: std::path::PathBuf::from("test.hlkx"), + entries: HashMap::new(), + content_types: vec![], + pkg_rels: vec![ + OpcRelationship::new("r1", REL_DS_ORIGIN, DS_ORIGIN_PART_PATH), + OpcRelationship::new("r2", "http://example.com/other", "/part.bin"), + ], + }; + let filtered = filtered_package_relationships(&pkg); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].id, "r2"); + } +} + +/// Compute the certificate file name: serial number bytes, hex-encoded. +fn cert_der_filename(cert_der: &[u8]) -> Result { + // Parse the DER certificate to extract the serial number. + let cert = Certificate::from_der(cert_der) + .context("Failed to parse certificate DER")?; + let bytes = cert.tbs_certificate.serial_number.as_bytes(); + // Don't reverse the hex. C# had some broken code that appeared to reverse + // it, but actually doesn't. + let hex_str = hex::encode_upper(bytes); + Ok(format!("{}.cer", hex_str)) +} diff --git a/hlkx-sign/src/timestamp.rs b/hlkx-sign/src/timestamp.rs new file mode 100644 index 0000000..5070048 --- /dev/null +++ b/hlkx-sign/src/timestamp.rs @@ -0,0 +1,345 @@ +//! RFC 3161 timestamp request/response handling. +//! +//! Builds a minimal `TimeStampReq`, POSTs it to a Time Stamping Authority (TSA), +//! and returns the raw DER bytes of the `TimeStampToken` (a CMS ContentInfo) that +//! can be base64-encoded and embedded into the XML signature. +//! +//! Reference: RFC 3161 §2.4 + +use crate::xml_sig::DigestAlgorithm; +use anyhow::{bail, Context, Result}; + +// ───────────────────────────────────────────────────────────────────────────── +// Minimal DER encoding helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Encode a DER TLV (Tag, Length, Value) triplet. +fn der_tlv(tag: u8, value: &[u8]) -> Vec { + let len = value.len(); + let mut out = Vec::with_capacity(3 + len); + out.push(tag); + if len < 0x80 { + out.push(len as u8); + } else if len <= 0xFF { + out.push(0x81); + out.push(len as u8); + } else if len <= 0xFFFF { + out.push(0x82); + out.push((len >> 8) as u8); + out.push(len as u8); + } else { + // Values > 64 KiB are not expected in a timestamp request/response. + panic!("DER value too long: {} bytes (maximum 65535)", len); + } + out.extend_from_slice(value); + out +} + +fn der_sequence(contents: &[u8]) -> Vec { + der_tlv(0x30, contents) +} + +/// Encode a positive integer, adding a leading zero byte when the high bit is set. +fn der_integer(bytes: &[u8]) -> Vec { + if bytes.first().copied().unwrap_or(0) >= 0x80 { + let mut padded = vec![0x00]; + padded.extend_from_slice(bytes); + der_tlv(0x02, &padded) + } else { + der_tlv(0x02, bytes) + } +} + +fn der_octet_string(value: &[u8]) -> Vec { + der_tlv(0x04, value) +} + +fn der_oid(encoded_oid: &[u8]) -> Vec { + der_tlv(0x06, encoded_oid) +} + +fn der_null() -> Vec { + vec![0x05, 0x00] +} + +fn der_bool_true() -> Vec { + // DER requires 0xFF for TRUE. + vec![0x01, 0x01, 0xFF] +} + +// ───────────────────────────────────────────────────────────────────────────── +// Hash OID encoding (pre-computed per-algorithm) +// ───────────────────────────────────────────────────────────────────────────── + +/// Return the pre-encoded OID bytes (the content of a DER OID TLV, without tag/len). +fn hash_oid_bytes(digest_alg: DigestAlgorithm) -> &'static [u8] { + match digest_alg { + // id-sha1 1.3.14.3.2.26 + DigestAlgorithm::Sha1 => &[0x2B, 0x0E, 0x03, 0x02, 0x1A], + // id-sha256 2.16.840.1.101.3.4.2.1 + DigestAlgorithm::Sha256 => &[0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01], + // id-sha384 2.16.840.1.101.3.4.2.2 + DigestAlgorithm::Sha384 => &[0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02], + // id-sha512 2.16.840.1.101.3.4.2.3 + DigestAlgorithm::Sha512 => &[0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03], + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// TimeStampReq encoder +// ───────────────────────────────────────────────────────────────────────────── + +/// Build the DER-encoded `TimeStampReq` for the given `digest` of `content`. +/// +/// ```text +/// TimeStampReq ::= SEQUENCE { +/// version INTEGER { v1(1) }, +/// messageImprint MessageImprint, +/// nonce INTEGER OPTIONAL, +/// certReq BOOLEAN DEFAULT FALSE, +/// } +/// MessageImprint ::= SEQUENCE { +/// hashAlgorithm AlgorithmIdentifier, +/// hashedMessage OCTET STRING +/// } +/// ``` +fn encode_timestamp_req(digest: &[u8], digest_alg: DigestAlgorithm, nonce: u64) -> Vec { + // AlgorithmIdentifier { algorithm OID, parameters NULL } + let alg_oid = der_oid(hash_oid_bytes(digest_alg)); + let alg_id = der_sequence(&[alg_oid, der_null()].concat()); + + // MessageImprint { AlgorithmIdentifier, OCTET STRING(digest) } + let msg_imprint = der_sequence(&[alg_id, der_octet_string(digest)].concat()); + + // version INTEGER 1 + let version = der_integer(&[0x01]); + + // nonce INTEGER (8 random bytes, big-endian) + let nonce_bytes = nonce.to_be_bytes(); + let nonce_der = der_integer(&nonce_bytes); + + // certReq BOOLEAN TRUE + let cert_req = der_bool_true(); + + // TimeStampReq SEQUENCE + der_sequence( + &[version, msg_imprint, nonce_der, cert_req].concat(), + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// TimeStampResp decoder +// ───────────────────────────────────────────────────────────────────────────── + +/// Skip one complete DER TLV in `bytes` and return the remainder. +fn der_skip(bytes: &[u8]) -> Result<&[u8]> { + if bytes.is_empty() { + bail!("unexpected end of DER data"); + } + // tag (we skip it) + let (_, rest) = bytes.split_at(1); + // length + let (len, rest) = der_decode_length(rest)?; + if rest.len() < len { + bail!("DER TLV truncated: need {} bytes, have {}", len, rest.len()); + } + Ok(&rest[len..]) +} + +/// Decode a DER length field and return `(length, remaining_bytes)`. +fn der_decode_length(bytes: &[u8]) -> Result<(usize, &[u8])> { + if bytes.is_empty() { + bail!("unexpected end of DER length field"); + } + let first = bytes[0]; + if first < 0x80 { + return Ok((first as usize, &bytes[1..])); + } + let n_bytes = (first & 0x7F) as usize; + if n_bytes == 0 { + bail!("DER indefinite length encoding is not supported"); + } + if bytes.len() < 1 + n_bytes { + bail!("DER length encoding truncated"); + } + let mut len: usize = 0; + for &b in &bytes[1..1 + n_bytes] { + len = (len << 8) | b as usize; + } + Ok((len, &bytes[1 + n_bytes..])) +} + +/// Unwrap the content of a SEQUENCE (the bytes inside the outer SEQUENCE TLV). +fn der_unwrap_sequence(bytes: &[u8]) -> Result<&[u8]> { + if bytes.is_empty() || bytes[0] != 0x30 { + bail!("expected DER SEQUENCE (0x30), got {:02X}", bytes.first().copied().unwrap_or(0)); + } + let (len, rest) = der_decode_length(&bytes[1..])?; + if rest.len() < len { + bail!("DER SEQUENCE truncated"); + } + Ok(&rest[..len]) +} + +/// Decode a DER INTEGER into a small value (for PKI status check). +fn der_read_integer(bytes: &[u8]) -> Result<(i64, &[u8])> { + if bytes.is_empty() || bytes[0] != 0x02 { + bail!("expected DER INTEGER (0x02), got {:02X}", bytes.first().copied().unwrap_or(0)); + } + let (len, rest) = der_decode_length(&bytes[1..])?; + if rest.len() < len { + bail!("DER INTEGER truncated"); + } + let int_bytes = &rest[..len]; + // Convert up to 8 bytes to i64 (sign-extending from MSB). + if len > 8 { + bail!("DER INTEGER too large for i64"); + } + let mut val: i64 = if int_bytes[0] >= 0x80 { -1 } else { 0 }; + for &b in int_bytes { + val = (val << 8) | b as i64; + } + Ok((val, &rest[len..])) +} + +/// Parse a `TimeStampResp` and return the raw DER bytes of the `TimeStampToken`. +/// +/// ```text +/// TimeStampResp ::= SEQUENCE { +/// status PKIStatusInfo, +/// timeStampToken TimeStampToken OPTIONAL +/// } +/// PKIStatusInfo ::= SEQUENCE { +/// status PKIStatus, -- INTEGER +/// ... +/// } +/// PKIStatus ::= INTEGER { granted(0), grantedWithMods(1), rejection(2), ... } +/// ``` +fn decode_timestamp_response(resp: &[u8]) -> Result> { + // Unwrap outer SEQUENCE + let outer = der_unwrap_sequence(resp)?; + + // First element: PKIStatusInfo (a SEQUENCE) + let pki_status_bytes = der_unwrap_sequence(outer)?; + // First element of PKIStatusInfo: status INTEGER + let (status, _) = der_read_integer(pki_status_bytes)?; + if status != 0 && status != 1 { + bail!( + "TSA returned non-success status: {} (0=granted, 1=grantedWithMods, 2=rejection, …)", + status + ); + } + + // Skip the PKIStatusInfo TLV to reach the TimeStampToken + let after_status = der_skip(outer)?; + if after_status.is_empty() { + bail!("TSA response did not include a TimeStampToken"); + } + + // The TimeStampToken is a ContentInfo starting here; return its full DER. + // We wrap it back into a complete DER value (it already is one). + Ok(after_status.to_vec()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Request a RFC 3161 timestamp for `signature_value` from the TSA at `url`. +/// +/// * `url` – HTTP(S) URL of the Time Stamping Authority. +/// * `signature_value` – raw bytes of the `` element (decoded from +/// base64). The TSA will timestamp a hash of these bytes. +/// * `digest_alg` – hash algorithm to use in the timestamp request. +/// +/// Returns the raw DER bytes of the `TimeStampToken` (CMS ContentInfo), suitable +/// for base64-encoding and embedding in the XML signature. +pub fn request_timestamp( + url: &str, + signature_value: &[u8], + digest_alg: DigestAlgorithm, +) -> Result> { + // Hash the signature value. + let digest = digest_alg.hash(signature_value); + + // Generate a random 64-bit nonce. Clear the MSB of the first byte so + // the value encodes as a positive DER INTEGER (some TSAs reject negative + // nonces). + let nonce: u64 = { + let mut bytes: [u8; 8] = uuid::Uuid::new_v4().as_bytes()[..8].try_into().unwrap(); + bytes[0] &= 0x7F; + u64::from_be_bytes(bytes) + }; + + let ts_req = encode_timestamp_req(&digest, digest_alg, nonce); + + // POST the request to the TSA. + let response = ureq::post(url) + .content_type("application/timestamp-query") + .send(&ts_req) + .context("HTTP request to TSA failed")?; + + let status = response.status(); + if status != 200 { + bail!("TSA returned HTTP {}", status); + } + + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + if !content_type.contains("timestamp-reply") && !content_type.contains("octet-stream") { + // Some TSAs return a slightly different content type; just warn. + eprintln!( + "warning: unexpected TSA response content-type: {}", + content_type + ); + } + + let resp_bytes = response + .into_body() + .read_to_vec() + .context("Failed to read TSA response body")?; + + let token = decode_timestamp_response(&resp_bytes) + .context("Failed to parse TSA response")?; + + Ok(token) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // Regression test: ensure the DER TLV helper emits correct bytes for + // short, 1-byte-extended, and 2-byte-extended lengths. + #[test] + fn test_der_tlv_lengths() { + let short = der_tlv(0x04, &[0xAA; 10]); + assert_eq!(&short[..2], &[0x04, 0x0A]); + + let medium = der_tlv(0x04, &[0xBB; 200]); + assert_eq!(&medium[..3], &[0x04, 0x81, 0xC8]); + + let large = der_tlv(0x04, &[0xCC; 300]); + assert_eq!(&large[..4], &[0x04, 0x82, 0x01, 0x2C]); + } + + // Check integer sign-extension: high-bit bytes must get a leading zero. + #[test] + fn test_der_integer_sign_extension() { + // 0xFF has high bit set → should be prefixed with 0x00 + let enc = der_integer(&[0xFF]); + assert_eq!(enc, vec![0x02, 0x02, 0x00, 0xFF]); + + // 0x01 does not have high bit set → no prefix + let enc2 = der_integer(&[0x01]); + assert_eq!(enc2, vec![0x02, 0x01, 0x01]); + } +} diff --git a/hlkx-sign/src/verification.rs b/hlkx-sign/src/verification.rs new file mode 100644 index 0000000..a62cec5 --- /dev/null +++ b/hlkx-sign/src/verification.rs @@ -0,0 +1,509 @@ +//! Verify OPC package digital signatures. +//! +//! Checks that embedded XML signatures are cryptographically valid (RSA PKCS#1 +//! v1.5 over the canonical ``) and that every manifest digest +//! matches the current package contents. No certificate chain or trust-anchor +//! validation is performed. + +use crate::c14n::{c14n, c14n_dsig_element_committed}; +use crate::opc::{ + entry_to_uri, normalize_relationship_target, parse_rels, rels_path_for_part, OpcPackage, + OpcRelationship, GLOBAL_RELS, REL_DS_CERTIFICATE, REL_DS_ORIGIN, REL_DS_SIGNATURE, +}; +use crate::signing::{build_filtered_rels_xml, digest_rels_part, filtered_package_relationships}; +use crate::xml_sig::{DigestAlgorithm, TransformInfo, C14N_URL, REL_TRANSFORM_URL}; +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use der::{Decode, Encode}; +use rsa::pkcs1v15::{Signature, VerifyingKey}; +use rsa::pkcs8::DecodePublicKey; +use rsa::RsaPublicKey; +use signature::Verifier; +use std::collections::HashSet; +use x509_cert::Certificate; + +/// Verify all digital signatures in `pkg`. +pub fn verify(pkg: &OpcPackage) -> Result<()> { + if !pkg.has_signatures() { + bail!("The package is not signed."); + } + + let sigs = discover_signatures(pkg)?; + if sigs.is_empty() { + bail!("No signature parts found in the package."); + } + + for (sig_path, cert_path) in sigs { + eprintln!("Verifying signature: {sig_path}"); + let sig_xml = pkg + .entries + .get(&sig_path) + .with_context(|| format!("signature part missing: {sig_path}"))?; + let cert_der = pkg + .entries + .get(&cert_path) + .with_context(|| format!("certificate part missing: {cert_path}"))?; + + verify_signature_xml(pkg, sig_xml, cert_der) + .with_context(|| format!("signature verification failed for {sig_path}"))?; + } + + Ok(()) +} + +/// Locate `(signature_part, certificate_part)` pairs via OPC relationships. +fn discover_signatures(pkg: &OpcPackage) -> Result> { + let mut out = Vec::new(); + + for origin_rel in pkg.pkg_rels.iter().filter(|r| r.rel_type == REL_DS_ORIGIN) { + let origin_path = normalize_relationship_target(&origin_rel.target); + let origin_path = origin_path.trim_start_matches('/').to_string(); + + let origin_rels_path = rels_path_for_part(&origin_path); + let origin_rels_bytes = pkg + .entries + .get(&origin_rels_path) + .with_context(|| format!("missing origin relationships: {origin_rels_path}"))?; + let origin_rels = parse_rels(origin_rels_bytes)?; + + for sig_rel in origin_rels.iter().filter(|r| r.rel_type == REL_DS_SIGNATURE) { + let sig_path = normalize_relationship_target(&sig_rel.target); + let sig_path = sig_path.trim_start_matches('/').to_string(); + + let sig_rels_path = rels_path_for_part(&sig_path); + let sig_rels_bytes = pkg.entries.get(&sig_rels_path).with_context(|| { + format!("missing signature relationships: {sig_rels_path}") + })?; + let sig_rels = parse_rels(sig_rels_bytes)?; + + for cert_rel in sig_rels.iter().filter(|r| r.rel_type == REL_DS_CERTIFICATE) { + let cert_path = normalize_relationship_target(&cert_rel.target); + let cert_path = cert_path.trim_start_matches('/').to_string(); + out.push((sig_path.clone(), cert_path)); + } + } + } + + Ok(out) +} + +fn verify_signature_xml(pkg: &OpcPackage, sig_xml: &[u8], cert_der: &[u8]) -> Result<()> { + let parsed = parse_signature_document(sig_xml)?; + + // ── 1. RSA signature over canonical SignedInfo ───────────────────────── + let digest_alg = digest_alg_from_signature_method(&parsed.signature_method)?; + let public_key = rsa_public_key_from_cert(cert_der)?; + verify_signed_info_signature( + digest_alg, + &public_key, + &parsed.signed_info_xml, + &parsed.signature_value, + )?; + + // ── 2. SignedInfo digest of the package Object ──────────────────────── + let object_hash = B64.encode(digest_alg.hash(&c14n_dsig_element_committed(&parsed.object_xml)?)); + if object_hash != parsed.object_digest_b64 { + bail!( + "SignedInfo Object digest mismatch (expected {}, got {})", + parsed.object_digest_b64, + object_hash + ); + } + + // ── 3. Manifest part digests ────────────────────────────────────────── + verify_manifest(pkg, &parsed.manifest_refs)?; + + Ok(()) +} + +struct ParsedSignature { + signature_method: String, + signature_value: Vec, + signed_info_xml: String, + object_digest_b64: String, + object_xml: String, + manifest_refs: Vec, +} + +struct ManifestReference { + uri: String, + digest_b64: String, + hash_uri: String, + transforms: Vec, +} + +fn parse_signature_document(sig_xml: &[u8]) -> Result { + let xml = std::str::from_utf8(sig_xml).context("signature is not valid UTF-8")?; + + let signature_value_b64 = extract_element_text(xml, "SignatureValue")?; + let signature_value = B64 + .decode(signature_value_b64.trim()) + .context("invalid SignatureValue base64")?; + + let signature_method = extract_signature_method_algorithm(xml)?; + + let signed_info_start = xml + .find("")?; + let signed_info_end = xml[signed_info_start..] + .find("") + .context("missing ")? + + "".len() + + signed_info_start; + let signed_info_xml = xml[signed_info_start..signed_info_end].to_string(); + + let object_digest_b64 = extract_object_digest_value(xml)?; + + let object_start = xml + .find("") + .or_else(|| xml.find("")) + .context("missing idPackageObject")?; + let object_end = xml[object_start..] + .find("") + .context("missing ")? + + "".len() + + object_start; + let object_xml = xml[object_start..object_end].to_string(); + + let manifest_refs = parse_manifest_references(&object_xml)?; + + Ok(ParsedSignature { + signature_method, + signature_value, + signed_info_xml, + object_digest_b64, + object_xml, + manifest_refs, + }) +} + +fn extract_signature_method_algorithm(xml: &str) -> Result { + let si = xml + .find("")?; + let chunk = &xml[si..]; + let sm = chunk + .find("")?; + let attrs = &chunk[sm..]; + let algorithm = attrs + .split("Algorithm=\"") + .nth(1) + .or_else(|| attrs.split("Algorithm='").nth(1)) + .context("SignatureMethod missing Algorithm")? + .split(['\"', '\'']) + .next() + .context("SignatureMethod Algorithm empty")?; + Ok(algorithm.to_string()) +} + +fn extract_object_digest_value(xml: &str) -> Result { + let si = xml + .find("")?; + let chunk = &xml[si..]; + let ref_pos = chunk + .find("URI=\"#idPackageObject\"") + .context("SignedInfo missing reference to idPackageObject")?; + let after = &chunk[ref_pos..]; + extract_element_text(after, "DigestValue") +} + +fn extract_element_text(xml: &str, local_name: &str) -> Result { + let open = format!("<{local_name}>"); + let close = format!(""); + let start = xml + .find(&open) + .with_context(|| format!("missing <{local_name}>"))?; + let inner_start = start + open.len(); + let end = xml[inner_start..] + .find(&close) + .with_context(|| format!("missing "))? + + inner_start; + Ok(xml[inner_start..end].to_string()) +} + +fn parse_manifest_references(object_xml: &str) -> Result> { + let mut refs = Vec::new(); + for chunk in object_xml.split("") + .nth(1) + .and_then(|s| s.split('<').next()) + .context("manifest Reference missing DigestValue")? + .to_string(); + + let hash_uri = chunk + .split("DigestMethod") + .nth(1) + .and_then(|s| s.split("Algorithm=\"").nth(1)) + .or_else(|| chunk.split("DigestMethod").nth(1).and_then(|s| s.split("Algorithm='").nth(1))) + .and_then(|s| s.split(['\"', '\'']).next()) + .context("manifest Reference missing DigestMethod")? + .to_string(); + + let transforms = parse_transforms(chunk)?; + refs.push(ManifestReference { + uri, + digest_b64, + hash_uri, + transforms, + }); + } + Ok(refs) +} + +fn parse_transforms(ref_chunk: &str) -> Result> { + let transforms_start = match ref_chunk.find("") { + Some(i) => i, + None => return Ok(vec![]), + }; + let transforms_end = ref_chunk[transforms_start..] + .find("") + .map(|i| i + transforms_start + "".len()) + .unwrap_or(ref_chunk.len()); + let block = &ref_chunk[transforms_start..transforms_end]; + + let mut out = Vec::new(); + for part in block.split(" Result { + match uri { + crate::xml_sig::RSA_SHA1_URL => Ok(DigestAlgorithm::Sha1), + crate::xml_sig::RSA_SHA256_URL => Ok(DigestAlgorithm::Sha256), + crate::xml_sig::RSA_SHA384_URL => Ok(DigestAlgorithm::Sha384), + crate::xml_sig::RSA_SHA512_URL => Ok(DigestAlgorithm::Sha512), + other => bail!("unsupported SignatureMethod: {other}"), + } +} + +fn digest_alg_from_hash_uri(uri: &str) -> Result { + match uri { + crate::xml_sig::SHA1_URL => Ok(DigestAlgorithm::Sha1), + crate::xml_sig::SHA256_URL => Ok(DigestAlgorithm::Sha256), + crate::xml_sig::SHA384_URL => Ok(DigestAlgorithm::Sha384), + crate::xml_sig::SHA512_URL => Ok(DigestAlgorithm::Sha512), + other => bail!("unsupported DigestMethod: {other}"), + } +} + +fn rsa_public_key_from_cert(cert_der: &[u8]) -> Result { + let cert = Certificate::from_der(cert_der).context("failed to parse certificate DER")?; + let spki = cert.tbs_certificate.subject_public_key_info; + RsaPublicKey::from_public_key_der(&spki.to_der()?).context("certificate is not an RSA key") +} + +fn verify_signed_info_signature( + digest_alg: DigestAlgorithm, + public_key: &RsaPublicKey, + signed_info_xml: &str, + signature: &[u8], +) -> Result<()> { + let canonical = c14n_dsig_element_committed(signed_info_xml)?; + verify_signed_info_canonical(digest_alg, public_key, &canonical, signature) +} + +fn verify_signed_info_canonical( + digest_alg: DigestAlgorithm, + public_key: &RsaPublicKey, + signed_info_canonical: &[u8], + signature: &[u8], +) -> Result<()> { + let sig = Signature::try_from(signature).context("invalid RSA signature length")?; + + let result = match digest_alg { + DigestAlgorithm::Sha1 => { + VerifyingKey::::new(public_key.clone()).verify(signed_info_canonical, &sig) + } + DigestAlgorithm::Sha256 => VerifyingKey::::new(public_key.clone()) + .verify(signed_info_canonical, &sig), + DigestAlgorithm::Sha384 => VerifyingKey::::new(public_key.clone()) + .verify(signed_info_canonical, &sig), + DigestAlgorithm::Sha512 => VerifyingKey::::new(public_key.clone()) + .verify(signed_info_canonical, &sig), + }; + + result.map_err(|_| anyhow::anyhow!("RSA signature over SignedInfo is invalid")) +} + +fn verify_manifest(pkg: &OpcPackage, refs: &[ManifestReference]) -> Result<()> { + for reference in refs { + let digest_alg = digest_alg_from_hash_uri(&reference.hash_uri)?; + + let part_path = manifest_uri_to_part_path(&reference.uri)?; + if part_path == GLOBAL_RELS { + let raw = pkg + .entries + .get(&part_path) + .with_context(|| format!("signed part not found: {part_path}"))?; + + let computed_digests = digest_rels_part( + &part_path, + raw, + pkg.content_type_for_part(&part_path), + digest_alg, + pkg, + )?; + + let matched = computed_digests + .iter() + .any(|d| d.digest_b64 == reference.digest_b64); + + if !matched { + bail!( + "digest mismatch for {}: no computed digest matches {}", + reference.uri, + reference.digest_b64 + ); + } + continue; + } + + let computed_b64 = compute_part_digest(pkg, reference, digest_alg)?; + if computed_b64 != reference.digest_b64 { + bail!( + "digest mismatch for {}: expected {}, got {}", + reference.uri, + reference.digest_b64, + computed_b64 + ); + } + } + + verify_signed_parts_covered(pkg, refs)?; + Ok(()) +} + +/// Every signable part must appear in the manifest (by URI without query). +fn verify_signed_parts_covered(pkg: &OpcPackage, refs: &[ManifestReference]) -> Result<()> { + let manifest_uris: HashSet = refs + .iter() + .map(|r| r.uri.split('?').next().unwrap_or(&r.uri).to_lowercase()) + .collect(); + + let mut missing = Vec::new(); + for part_path in pkg.entries.keys() { + if !crate::signing::should_sign_part(part_path) { + continue; + } + let uri = entry_to_uri(part_path).to_lowercase(); + if !manifest_uris.contains(&uri) { + missing.push(part_path.clone()); + } + } + + if !missing.is_empty() { + bail!( + "package contains unsigned parts not listed in the signature manifest: {missing:?}" + ); + } + + Ok(()) +} + +fn manifest_uri_to_part_path(uri: &str) -> Result { + let path = uri.split('?').next().context("empty manifest URI")?; + Ok(path.trim_start_matches('/').to_string()) +} + +fn compute_part_digest( + pkg: &OpcPackage, + reference: &ManifestReference, + digest_alg: DigestAlgorithm, +) -> Result { + let part_path = manifest_uri_to_part_path(&reference.uri)?; + let data = pkg + .entries + .get(&part_path) + .with_context(|| format!("signed part not found: {part_path}"))?; + + let digest_bytes = match reference.transforms.as_slice() { + [] => digest_alg.hash(data), + [TransformInfo::C14n] => { + let canon = c14n(data)?; + digest_alg.hash(&canon) + } + [TransformInfo::RelationshipTransform { source_types }, TransformInfo::C14n] => { + let filtered = filtered_package_relationships(pkg); + let filtered_refs: Vec<&OpcRelationship> = filtered.iter().collect(); + let filtered_xml = build_filtered_rels_xml_for_types(&filtered_refs, source_types); + let canon = c14n(filtered_xml.as_bytes())?; + digest_alg.hash(&canon) + } + other => bail!("unsupported transform chain for {}: {:?}", reference.uri, other), + }; + + Ok(B64.encode(&digest_bytes)) +} + +/// Build filtered relationships XML using manifest `SourceType` selectors (order preserved). +fn build_filtered_rels_xml_for_types( + rels: &[&OpcRelationship], + source_types: &[String], +) -> String { + let allowed: HashSet<&str> = source_types.iter().map(String::as_str).collect(); + let filtered: Vec<&OpcRelationship> = rels + .iter() + .copied() + .filter(|r| allowed.contains(r.rel_type.as_str())) + .collect(); + build_filtered_rels_xml(&filtered) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + #[test] + fn manifest_uri_to_part_strips_query() { + assert_eq!( + manifest_uri_to_part_path("/_rels/.rels?ContentType=application/...") + .unwrap(), + "_rels/.rels" + ); + } + + #[test] + fn discover_requires_origin_relationship() { + let pkg = OpcPackage { + path: "test.hlkx".into(), + entries: HashMap::new(), + content_types: vec![], + pkg_rels: vec![], + }; + assert!(discover_signatures(&pkg).unwrap().is_empty()); + } +} diff --git a/hlkx-sign/src/xml_sig.rs b/hlkx-sign/src/xml_sig.rs new file mode 100644 index 0000000..d60c821 --- /dev/null +++ b/hlkx-sign/src/xml_sig.rs @@ -0,0 +1,406 @@ +//! Build the XML digital signature document for an OPC package. +//! +//! The output matches the structure produced by the C# OpenVsixSignTool, which +//! follows the OPC digital signature specification (ECMA-376 Part 2 §13). + +use crate::c14n::c14n_dsig_element_committed; +use crate::opc::xml_escape_attr; +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; +use chrono::{DateTime, FixedOffset}; +use sha1::Digest as Sha1Digest; +use sha2::Digest as Sha2Digest; + +// ───────────────────────────────────────────────────────────────────────────── +// Constants (URIs) +// ───────────────────────────────────────────────────────────────────────────── + +const NS_DSIG: &str = "http://www.w3.org/2000/09/xmldsig#"; +const NS_OPC_DSIG: &str = + "http://schemas.openxmlformats.org/package/2006/digital-signature"; + +pub(crate) const C14N_URL: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315"; +#[allow(dead_code)] +const C14N_WITH_COMMENTS_URL: &str = + "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"; +pub(crate) const REL_TRANSFORM_URL: &str = + "http://schemas.openxmlformats.org/package/2006/RelationshipTransform"; + +pub(crate) const RSA_SHA1_URL: &str = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"; +pub(crate) const RSA_SHA256_URL: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; +pub(crate) const RSA_SHA384_URL: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384"; +pub(crate) const RSA_SHA512_URL: &str = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"; + +pub(crate) const SHA1_URL: &str = "http://www.w3.org/2000/09/xmldsig#sha1"; +pub(crate) const SHA256_URL: &str = "http://www.w3.org/2001/04/xmlenc#sha256"; +pub(crate) const SHA384_URL: &str = "http://www.w3.org/2001/04/xmldsig-more#sha384"; +pub(crate) const SHA512_URL: &str = "http://www.w3.org/2001/04/xmlenc#sha512"; + +// ───────────────────────────────────────────────────────────────────────────── +// Digest algorithm selector +// ───────────────────────────────────────────────────────────────────────────── + +/// Supported file-digest algorithms. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DigestAlgorithm { + Sha1, + Sha256, + Sha384, + Sha512, +} + +impl DigestAlgorithm { + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "sha1" => Some(Self::Sha1), + "sha256" | "" => Some(Self::Sha256), + "sha384" => Some(Self::Sha384), + "sha512" => Some(Self::Sha512), + _ => None, + } + } + + /// The XML DSig URI for this hash algorithm. + pub fn xml_uri(&self) -> &'static str { + match self { + Self::Sha1 => SHA1_URL, + Self::Sha256 => SHA256_URL, + Self::Sha384 => SHA384_URL, + Self::Sha512 => SHA512_URL, + } + } + + /// The XML DSig URI for the RSA+hash signature algorithm. + pub fn rsa_sig_uri(&self) -> &'static str { + match self { + Self::Sha1 => RSA_SHA1_URL, + Self::Sha256 => RSA_SHA256_URL, + Self::Sha384 => RSA_SHA384_URL, + Self::Sha512 => RSA_SHA512_URL, + } + } + + /// Compute a digest over `data` and return the bytes. + pub fn hash(&self, data: &[u8]) -> Vec { + match self { + Self::Sha1 => { + let mut h = sha1::Sha1::new(); + Sha1Digest::update(&mut h, data); + Sha1Digest::finalize(h).to_vec() + } + Self::Sha256 => { + let mut h = sha2::Sha256::new(); + Sha2Digest::update(&mut h, data); + Sha2Digest::finalize(h).to_vec() + } + Self::Sha384 => { + let mut h = sha2::Sha384::new(); + Sha2Digest::update(&mut h, data); + Sha2Digest::finalize(h).to_vec() + } + Self::Sha512 => { + let mut h = sha2::Sha512::new(); + Sha2Digest::update(&mut h, data); + Sha2Digest::finalize(h).to_vec() + } + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Part digest entry +// ───────────────────────────────────────────────────────────────────────────── + +/// A single entry in the Manifest, representing a hashed part (or part view). +#[derive(Debug, Clone)] +pub struct PartDigest { + /// The Reference URI, e.g. `"/_rels/.rels?ContentType=..."`. + pub uri: String, + /// Base64-encoded digest. + pub digest_b64: String, + /// Hash algorithm URI. + pub hash_uri: String, + /// Optional transform list: each entry is the transform Algorithm URI. + pub transforms: Vec, +} + +/// Describes a single transform in a `` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TransformInfo { + /// `` + C14n, + /// The OPC relationships transform with a list of relationship types to include. + RelationshipTransform { source_types: Vec }, +} + +// ───────────────────────────────────────────────────────────────────────────── +// XML signature builder +// ───────────────────────────────────────────────────────────────────────────── + +/// Build the complete XML digital signature document. +/// +/// Returns the UTF-8 bytes that should be written to the `.psdsxs` part. +/// +/// Note: the certificate is stored in a separate `.cer` part linked via a +/// relationship; it is not embedded in the KeyInfo element (the C# reference +/// implementation leaves KeyInfo commented out). +/// +/// `signer` is a closure that accepts the canonical `` bytes and +/// returns the RSA PKCS#1 v1.5 signature bytes. +/// +/// Returns the UTF-8 signature document and the raw `SignatureValue` bytes that +/// were signed (needed for RFC 3161 timestamping). +pub fn build_signature_xml( + digests: &[PartDigest], + digest_alg: DigestAlgorithm, + signing_time: DateTime, + signer: &dyn Fn(&[u8]) -> Result>, +) -> Result<(Vec, Vec)> { + // ── 1. Build the element (canonical bytes for hashing, serialized + // bytes for the on-disk .psdsxs matching the C# XmlTextWriter output). + let (object_canonical, object_document) = + build_object_xml(digests, signing_time)?; + + // ── 2. Hash the canonical ────────────────────────────────── + let object_hash = digest_alg.hash(&object_canonical); + let object_hash_b64 = B64.encode(&object_hash); + + // ── 3. Build the canonical and sign it ────────────────── + let signed_info_xml = + build_signed_info_xml(&object_hash_b64, digest_alg)?; + + // Sign the canonical SignedInfo bytes. + let sig_bytes = signer(&signed_info_xml)?; + let sig_b64 = B64.encode(&sig_bytes); + + // ── 4. Assemble the full document ───────────────────────── + // The final file has an XML declaration. + let mut doc = Vec::new(); + doc.extend_from_slice(b""); + + // root. + doc.extend_from_slice(b""); + + // Embed the canonical SignedInfo content (everything inside …). + // We already have the full element bytes; strip the outer tags and re-wrap + // is fragile, so instead we re-build from components. + doc.extend_from_slice(&build_signed_info_inner( + &object_hash_b64, + digest_alg, + )); + + // + doc.extend_from_slice(b""); + doc.extend_from_slice(sig_b64.as_bytes()); + doc.extend_from_slice(b""); + + // – serialized like .NET XmlTextWriter (self-closing empty tags, + // no redundant xmlns on Object because it inherits from ). + doc.extend_from_slice(&object_document); + + doc.extend_from_slice(b""); + + Ok((doc, sig_bytes)) +} + +/// Append a timestamp `` to an existing signature document without +/// re-signing, matching `OpcPackageTimestampBuilder.ApplyTimestamp` in the C# +/// tool (timestamp the existing `SignatureValue`, then add the token). +pub fn append_timestamp_object(mut sig_xml: Vec, token: &[u8]) -> Result> { + let close = b""; + let pos = sig_xml + .windows(close.len()) + .rposition(|w| w == close) + .context("signature document missing ")?; + let token_b64 = B64.encode(token); + let mut insert = Vec::new(); + insert.extend_from_slice(b"Timestamp got from the time stamp server", + ); + insert.extend_from_slice(token_b64.as_bytes()); + insert.extend_from_slice(b""); + sig_xml.splice(pos..pos, insert.iter().copied()); + Ok(sig_xml) +} + +// ───────────────────────────────────────────────────────────────────────────── +// builder +// ───────────────────────────────────────────────────────────────────────────── + +/// Append an empty element using either C14N expanded form or .NET-style ` />`. +fn push_empty_element(xml: &mut String, name: &str, attrs: &str, self_closing: bool) { + if self_closing { + xml.push('<'); + xml.push_str(name); + xml.push_str(attrs); + xml.push_str(" />"); + } else { + xml.push('<'); + xml.push_str(name); + xml.push_str(attrs); + xml.push_str(">'); + } +} + +/// Build `` for hashing (C14N) and for the final +/// `.psdsxs` document (self-closing empty tags, no redundant xmlns on Object). +fn build_object_xml( + digests: &[PartDigest], + signing_time: DateTime, +) -> Result<(Vec, Vec)> { + let inner = build_object_content(digests, signing_time, false); + let canonical = c14n_dsig_element_committed(&inner)?; + let document = build_object_content(digests, signing_time, true).into_bytes(); + Ok((canonical, document)) +} + +/// Serialize `` without a redundant +/// default-namespace declaration (inherits from `` in the final file). +fn build_object_content( + digests: &[PartDigest], + signing_time: DateTime, + self_closing: bool, +) -> String { + let mut xml = String::new(); + xml.push_str(""); + + xml.push_str(""); + + for d in digests { + xml.push_str(""); + + if !d.transforms.is_empty() { + xml.push_str(""); + for t in &d.transforms { + match t { + TransformInfo::C14n => { + push_empty_element( + &mut xml, + "Transform", + &format!(" Algorithm=\"{C14N_URL}\""), + self_closing, + ); + } + TransformInfo::RelationshipTransform { source_types } => { + xml.push_str(""); + for st in source_types { + push_empty_element( + &mut xml, + "opc:RelationshipsGroupReference", + &format!(" SourceType=\"{}\"", xml_escape_attr(st)), + self_closing, + ); + } + xml.push_str(""); + push_empty_element( + &mut xml, + "Transform", + &format!(" Algorithm=\"{C14N_URL}\""), + self_closing, + ); + } + } + } + xml.push_str(""); + } + + push_empty_element( + &mut xml, + "DigestMethod", + &format!(" Algorithm=\"{}\"", xml_escape_attr(&d.hash_uri)), + self_closing, + ); + + xml.push_str(""); + xml.push_str(&d.digest_b64); + xml.push_str(""); + + xml.push_str(""); + } + + xml.push_str(""); + + xml.push_str(""); + xml.push_str(""); + xml.push_str(""); + xml.push_str("YYYY-MM-DDThh:mm:ss.sTZD"); + xml.push_str(""); + xml.push_str(&signing_time.format("%Y-%m-%dT%H:%M:%S.0%:z").to_string()); + xml.push_str(""); + xml.push_str(""); + xml.push_str(""); + xml.push_str(""); + + xml.push_str(""); + xml +} + +// ───────────────────────────────────────────────────────────────────────────── +// builder +// ───────────────────────────────────────────────────────────────────────────── + +/// Build the canonical `` bytes (used both for hashing/signing and +/// for embedding in the final document). +pub(crate) fn build_signed_info_xml( + object_hash_b64: &str, + digest_alg: DigestAlgorithm, +) -> Result> { + let inner = build_signed_info_inner_str(object_hash_b64, digest_alg, false); + let element = format!("{inner}"); + let canonical = c14n_dsig_element_committed(&element)?; + Ok(canonical) +} + +/// Build `` for embedding in the final document (self-closing empty +/// elements, matching .NET XmlTextWriter output). +fn build_signed_info_inner(object_hash_b64: &str, digest_alg: DigestAlgorithm) -> Vec { + let inner = build_signed_info_inner_str(object_hash_b64, digest_alg, true); + format!("{inner}").into_bytes() +} + +fn build_signed_info_inner_str( + object_hash_b64: &str, + digest_alg: DigestAlgorithm, + self_closing: bool, +) -> String { + let mut s = String::new(); + push_empty_element( + &mut s, + "CanonicalizationMethod", + &format!(" Algorithm=\"{C14N_URL}\""), + self_closing, + ); + push_empty_element( + &mut s, + "SignatureMethod", + &format!(" Algorithm=\"{}\"", digest_alg.rsa_sig_uri()), + self_closing, + ); + // Attribute order matches XmlSignatureBuilder: URI before Type. + s.push_str(""); + push_empty_element( + &mut s, + "DigestMethod", + &format!(" Algorithm=\"{}\"", digest_alg.xml_uri()), + self_closing, + ); + s.push_str(""); + s.push_str(object_hash_b64); + s.push_str(""); + s.push_str(""); + s +} diff --git a/hlkx-sign/tests/c14n-fixtures/generate.sh b/hlkx-sign/tests/c14n-fixtures/generate.sh new file mode 100644 index 0000000..f6cee4e --- /dev/null +++ b/hlkx-sign/tests/c14n-fixtures/generate.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Regenerate golden C14N outputs from .NET XmlDsigC14NTransform. +# Requires: dotnet SDK, an HLKX at HLKX_PATH (or uses /tmp fixtures). + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +REF="$ROOT/c14n-reference" +OUT="$(cd "$(dirname "$0")" && pwd)" +DOTNET_DLL="$REF/bin/Debug/net8.0/C14nReference.dll" + +dotnet build "$REF" -v q -o "$REF/bin/Debug/net8.0" + +HLKX="${HLKX_PATH:-/Users/roblabla/Downloads/hlelam_4.2.0.signed.hlkx}" +if [[ ! -f "$HLKX" ]]; then + echo "Set HLKX_PATH to a signed HLKX file" >&2 + exit 1 +fi + +SIG_PART="package/services/digital-signature/xml-signature" +SIG_FILE=$(unzip -Z1 "$HLKX" | grep '\.psdsxs$' | head -1) +unzip -p "$HLKX" "$SIG_FILE" > "$OUT/signature.xml" +unzip -p "$HLKX" "_rels/.rels" > "$OUT/rels.xml" + +python3 - <<'PY' "$OUT/signature.xml" +import sys +sig = open(sys.argv[1]).read() +for tag in ("Object", "SignedInfo"): + if tag == "Object": + start = sig.find('') + else: + start = sig.find("") + len(f"") + open(f"{sys.argv[1].replace('signature.xml', tag.lower() + '.xml')}", "w").write(sig[start:end]) +PY + +dotnet "$DOTNET_DLL" "$OUT/rels.xml" "$OUT/rels.c14n" + +SI="$OUT/signedinfo.xml" +OBJ="$OUT/object.xml" +python3 -c " +s=open('$SI').read() +open('$OUT/signedinfo_xmlns.xml','w').write(s.replace('', '', 1)) +o=open('$OBJ').read() +open('$OUT/object_xmlns.xml','w').write(o.replace('', '', 1)) +" +dotnet "$DOTNET_DLL" "$OUT/signedinfo_xmlns.xml" "$OUT/signedinfo_xmlns.c14n" +dotnet "$DOTNET_DLL" "$OUT/object_xmlns.xml" "$OUT/object_committed.c14n" + +echo "Wrote golden files to $OUT"