diff --git a/Cargo.lock b/Cargo.lock index 6e69086f3..285897052 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,17 +29,62 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "aes" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.1", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes 0.8.4", +] + [[package]] name = "ahash" version = "0.8.12" @@ -135,6 +180,19 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -332,6 +390,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -390,6 +454,27 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb2907cc03d08cdb1c6724ad0670ce67c4a71e1c45a6d7635224011714cfc4b1" +[[package]] +name = "bitfields" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.21", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -402,6 +487,27 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.7" @@ -433,6 +539,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -464,6 +579,16 @@ dependencies = [ "piper", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + [[package]] name = "borsh" version = "1.8.1" @@ -530,6 +655,15 @@ dependencies = [ "alloc-stdlib 0.3.0", ] +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -556,6 +690,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -582,6 +722,16 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + [[package]] name = "cap-primitives" version = "4.0.3" @@ -606,6 +756,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.6.0" @@ -629,6 +788,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -686,6 +854,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + [[package]] name = "cipher" version = "0.5.2" @@ -693,7 +871,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "crypto-common 0.2.2", - "inout", + "inout 0.2.6", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", ] [[package]] @@ -817,6 +1006,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1545,7 +1743,7 @@ dependencies = [ name = "craft-odf" version = "0.1.0" dependencies = [ - "aes", + "aes 0.9.4", "base64 0.23.1", "chrono", "craft-flow", @@ -1556,7 +1754,7 @@ dependencies = [ "craft-zip", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.13.0", "image", "js-sys", "log", @@ -1581,12 +1779,12 @@ dependencies = [ name = "craft-offcrypto" version = "0.1.0" dependencies = [ - "aes", + "aes 0.9.4", "base64 0.23.1", "craft-cfb", "craft-xml", "getrandom 0.3.4", - "hmac", + "hmac 0.13.0", "sha1 0.11.0", "sha2 0.11.1", "thiserror 2.0.21", @@ -1604,6 +1802,21 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-openpgp" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "pgp", + "proptest", + "rand 0.8.8", + "rsa", + "sha1 0.10.7", + "sha2 0.10.9", + "sha3", + "thiserror 2.0.21", +] + [[package]] name = "craft-ora" version = "0.1.0" @@ -1627,7 +1840,7 @@ dependencies = [ name = "craft-pdf" version = "0.1.0" dependencies = [ - "aes", + "aes 0.9.4", "bytemuck", "chrono", "craft-stream-codecs", @@ -2168,6 +2381,12 @@ dependencies = [ "zip", ] +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + [[package]] name = "crc32fast" version = "1.5.2" @@ -2229,6 +2448,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -2236,6 +2467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2248,6 +2480,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.8" @@ -2263,12 +2504,100 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + [[package]] name = "data-url" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + [[package]] name = "der" version = "0.7.10" @@ -2299,6 +2628,69 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "dify" version = "0.8.0" @@ -2319,7 +2711,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -2414,12 +2808,55 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2 0.10.9", + "signature", + "zeroize", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead", + "cipher 0.4.4", + "cmac", + "ctr", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ecolor" version = "0.37.0" @@ -2430,6 +2867,31 @@ dependencies = [ "serde", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "eframe" version = "0.37.0" @@ -2591,6 +3053,31 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect 0.2.0", + "subtle", + "tap", + "zeroize", +] + [[package]] name = "emath" version = "0.37.0" @@ -2809,6 +3296,23 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -3011,6 +3515,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -3081,6 +3591,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -3143,6 +3654,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.2" @@ -3289,6 +3810,17 @@ dependencies = [ "windows", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "guillotiere" version = "0.7.0" @@ -3479,6 +4011,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "hmac" version = "0.13.0" @@ -3849,6 +4399,21 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -3927,6 +4492,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inout" version = "0.2.6" @@ -4265,6 +4839,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + [[package]] name = "keyboard-types" version = "0.8.3" @@ -4358,6 +4955,9 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "lebe" @@ -4703,6 +5303,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4712,6 +5321,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "serde", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -4727,6 +5353,25 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -5078,6 +5723,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead", + "cipher 0.4.4", + "ctr", + "subtle", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5090,6 +5747,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.5" @@ -5141,6 +5804,44 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -5176,6 +5877,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pdf-writer" version = "0.15.0" @@ -5226,6 +5938,77 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pgp" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a" +dependencies = [ + "aead", + "aes 0.8.4", + "aes-gcm", + "aes-kw", + "argon2", + "base64 0.22.1", + "bitfields", + "block-padding", + "blowfish", + "buffer-redux", + "byteorder", + "bytes", + "camellia", + "cast5", + "cfb-mode", + "cipher 0.4.4", + "const-oid 0.9.6", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more", + "des", + "digest 0.10.7", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array", + "getrandom 0.2.17", + "hex", + "hkdf", + "idea", + "k256", + "log", + "md-5 0.10.6", + "memchr", + "nom", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.8", + "replace_with", + "ripemd", + "rsa", + "sha1 0.10.7", + "sha1-checked", + "sha2 0.10.9", + "sha3", + "signature", + "smallvec", + "snafu", + "subtle", + "twofish", + "web-time", + "x25519-dalek", + "zeroize", +] + [[package]] name = "phf" version = "0.13.1" @@ -5321,6 +6104,27 @@ dependencies = [ "futures-io", ] +[[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.34" @@ -5417,6 +6221,18 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -5461,12 +6277,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "presser" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5527,7 +6361,7 @@ dependencies = [ "chacha20", "core_detect", "num-traits", - "rand", + "rand 0.10.3", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -5601,7 +6435,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -5649,6 +6483,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.3" @@ -5660,11 +6511,24 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] 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 = "rand_core" @@ -5834,6 +6698,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + [[package]] name = "reqwest" version = "0.13.5" @@ -5891,6 +6761,16 @@ dependencies = [ "usvg 0.45.1", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfd" version = "0.17.2" @@ -5938,6 +6818,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5953,6 +6842,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6165,6 +7074,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect 0.2.0", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6296,6 +7220,26 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.7" @@ -6318,6 +7262,17 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1 0.10.7", + "zeroize", +] + [[package]] name = "sha2" version = "0.10.9" @@ -6340,6 +7295,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "shlex" version = "2.0.1" @@ -6362,6 +7327,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -6468,7 +7434,7 @@ dependencies = [ "const-oid 0.9.6", "der", "hex", - "rand", + "rand 0.10.3", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -6647,6 +7613,27 @@ dependencies = [ "serde", ] +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "socket2" version = "0.6.5" @@ -6657,6 +7644,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -6708,6 +7701,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.28.0" @@ -7049,6 +8048,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.46" @@ -7430,6 +8435,15 @@ dependencies = [ "core_maths", ] +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "type-map" version = "0.5.1" @@ -7543,6 +8557,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -8554,6 +9584,15 @@ dependencies = [ "either", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11-dl" version = "2.21.0" @@ -8586,6 +9625,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-cert" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 303922646..b026ad3fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ craft-menu = { path = "crates/menu" } craft-metafile = { path = "crates/metafile" } craft-offcrypto = { path = "crates/offcrypto" } craft-opc = { path = "crates/opc" } +craft-openpgp = { path = "crates/openpgp" } craft-ora = { path = "crates/ora" } craft-pptx = { path = "crates/pptx" } craft-penpot = { path = "crates/penpot" } diff --git a/README.md b/README.md index fba3a4cff..bc693fc7f 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " &'static [u8] { + match self { + Kind::Signature => b"SIGNATURE", + Kind::PublicKey => b"PUBLIC KEY BLOCK", + Kind::PrivateKey => b"PRIVATE KEY BLOCK", + } + } +} + +/// One line of `text`: its bytes without the line break, and without the white space it ends +/// in as well. +struct Line<'a> { + raw: &'a [u8], + text: &'a [u8], + /// Where it starts in the text, and where the next line starts. + start: usize, + next: usize, +} + +fn lines(text: &[u8]) -> impl Iterator> { + let mut at = 0usize; + std::iter::from_fn(move || { + if at >= text.len() { + return None; + } + let rest = text.get(at..).unwrap_or_default(); + let len = rest.iter().position(|b| *b == b'\n'); + let raw = rest.get(..len.unwrap_or(rest.len())).unwrap_or_default(); + let start = at; + at = len.map_or(text.len(), |n| at.saturating_add(n).saturating_add(1)); + let raw = raw.strip_suffix(b"\r").unwrap_or(raw); + Some(Line { raw, text: trim_end(raw), start, next: at }) + }) +} + +/// `line` without the spaces, tabs and CR it ends in. +pub(crate) fn trim_end(line: &[u8]) -> &[u8] { + let end = line.iter().rposition(|b| !matches!(b, b' ' | b'\t' | b'\r')).map_or(0, |i| i.saturating_add(1)); + line.get(..end).unwrap_or_default() +} + +fn marker(edge: &[u8], label: &[u8]) -> Vec { + let mut m = Vec::with_capacity(label.len().saturating_add(20)); + m.extend_from_slice(b"-----"); + m.extend_from_slice(edge); + m.extend_from_slice(b" PGP "); + m.extend_from_slice(label); + m.extend_from_slice(b"-----"); + m +} + +/// The decoded contents of every armour block of `kind` in `text`, at most `max_blocks`, each +/// at most `limit` bytes (a larger one is skipped). A byte-order mark before a line is allowed. +pub(crate) fn blocks(text: &[u8], kind: Kind, max_blocks: usize, limit: usize) -> Vec> { + let begin = marker(b"BEGIN", kind.label()); + let end = marker(b"END", kind.label()); + let mut out = Vec::new(); + if max_blocks == 0 { + return out; + } + let mut inside: Option = None; + for line in lines(text) { + let line_text = line.text.strip_prefix(b"\xef\xbb\xbf").unwrap_or(line.text); + match inside.as_mut() { + None if line_text == begin.as_slice() => inside = Some(Body::new(limit)), + None => {} + Some(body) if line_text == end.as_slice() => { + if let Some(bytes) = body.finish() { + out.push(bytes); + if out.len() >= max_blocks { + return out; + } + } + inside = None; + } + Some(body) if line_text == begin.as_slice() => *body = Body::new(limit), + Some(body) => body.line(line_text), + } + } + out +} + +/// The base64 of one block as its lines come: armour headers until the first empty line, then +/// data until the checksum line (`=` and four characters), which RFC 9580 lets a reader ignore. +struct Body { + in_headers: bool, + data: Vec, + limit: usize, + too_large: bool, + done: bool, +} + +impl Body { + fn new(limit: usize) -> Body { + Body { in_headers: true, data: Vec::new(), limit, too_large: false, done: false } + } + + fn line(&mut self, line: &[u8]) { + if self.done || self.too_large { + return; + } + if self.in_headers { + if line.is_empty() { + self.in_headers = false; + return; + } + // Armour without headers or the empty line after them: data from the first line. + if line.contains(&b':') { + return; + } + self.in_headers = false; + } + if line.first() == Some(&b'=') { + self.done = true; + return; + } + // Four characters of base64 are three bytes: stop collecting well before the limit. + if self.data.len().saturating_add(line.len()) / 4 * 3 > self.limit { + self.too_large = true; + return; + } + self.data.extend_from_slice(line); + } + + fn finish(&mut self) -> Option> { + if self.too_large { + return None; + } + let bytes = mime::base64(&self.data); + (!bytes.is_empty() && bytes.len() <= self.limit).then_some(bytes) + } +} + +/// The hash names a `Hash:` armour header may give (RFC 9580 §7.1, the names of §9.5). +const HASH_NAMES: [&[u8]; 9] = [b"MD5", b"SHA1", b"RIPEMD160", b"SHA224", b"SHA256", b"SHA384", b"SHA512", b"SHA3-256", b"SHA3-512"]; + +/// Whether `line` is a `Hash:` header whose value is nothing but hash names, separated by +/// commas: any other words there would be shown and aren't signed. +fn hash_header(line: &[u8]) -> bool { + let Some(value) = line.strip_prefix(b"Hash:") else { return false }; + value.split(|b| *b == b',').all(|name| { + let name = name.trim_ascii(); + HASH_NAMES.iter().any(|known| name.eq_ignore_ascii_case(known)) + }) +} + +/// A cleartext-signed text (RFC 9580 §7): the bytes that were signed and the decoded +/// signature block after them. +#[derive(Debug)] +pub(crate) struct Cleartext { + /// The text as it was signed: dash-escaping undone, white space at each line's end removed, + /// lines joined by CR LF and no line break after the last one. + pub(crate) signed: Vec, + /// The signature packets, decoded from their armour. + pub(crate) signature: Vec, + /// Where the block is in the text: from its first line to the end of its last. + pub(crate) span: (usize, usize), + /// Nothing but white space around the block, and so no other block: the block is the text. + pub(crate) alone: bool, + /// Its armour headers are `Hash:` lines only, and no line of the signed text is unescaped + /// armour. One that isn't would show words that aren't signed, so it can't be checked. + pub(crate) well_formed: bool, +} + +/// The first cleartext-signed text in `text`, if there is a whole one: `-----BEGIN PGP SIGNED +/// MESSAGE-----`, its `Hash:` headers and an empty line, the text, and a signature block, which +/// may be at most `limit` bytes. +pub(crate) fn cleartext(text: &[u8], limit: usize) -> Option { + let begin = marker(b"BEGIN", b"SIGNED MESSAGE"); + let sig_begin = marker(b"BEGIN", Kind::Signature.label()); + let sig_end = marker(b"END", Kind::Signature.label()); + #[derive(PartialEq)] + enum At { + Before, + Headers, + Text, + Signature, + } + let mut at = At::Before; + let mut signed: Vec = Vec::new(); + let mut first_line = true; + let mut body = Body::new(limit); + let (mut start, mut alone, mut well_formed) = (0usize, true, true); + for line in lines(text) { + let line_text = line.text.strip_prefix(b"\xef\xbb\xbf").unwrap_or(line.text); + match at { + At::Before if line_text == begin.as_slice() => { + at = At::Headers; + start = line.start; + } + At::Before => alone &= line_text.iter().all(u8::is_ascii_whitespace), + // RFC 9580 §7.1: `Hash:` lines (or none, in newer writers), then an empty line. + // Anything else there would be shown and isn't signed. + At::Headers if line_text.is_empty() => at = At::Text, + At::Headers => well_formed &= hash_header(line_text), + At::Text if line_text == sig_begin.as_slice() => at = At::Signature, + At::Text => { + if !first_line { + signed.extend_from_slice(b"\r\n"); + } + first_line = false; + // Dash-escaping undone (§7.1), then the white space the line ends in removed + // (§7.2). Every line that starts with a dash is escaped: one that isn't is + // armour where text belongs. + let raw = line.raw.strip_prefix(b"\xef\xbb\xbf").unwrap_or(line.raw); + if raw.starts_with(b"-") && !raw.starts_with(b"- ") { + well_formed = false; + } + signed.extend_from_slice(trim_end(raw.strip_prefix(b"- ").unwrap_or(raw))); + } + At::Signature if line_text == sig_end.as_slice() => { + let signature = body.finish()?; + alone &= text.get(line.next..).unwrap_or_default().iter().all(u8::is_ascii_whitespace); + return Some(Cleartext { signed, signature, span: (start, line.next), alone, well_formed }); + } + At::Signature => body.line(line_text), + } + } + None +} diff --git a/crates/openpgp/src/armour_tests.rs b/crates/openpgp/src/armour_tests.rs new file mode 100644 index 000000000..d5df00292 --- /dev/null +++ b/crates/openpgp/src/armour_tests.rs @@ -0,0 +1,38 @@ +use crate::armour::{Kind, blocks, cleartext}; + +#[test] +fn a_block_is_decoded_without_its_headers_or_checksum() { + let text = + b"before\r\n-----BEGIN PGP SIGNATURE-----\r\nComment: made by hand\r\n\r\nSGVs\r\nbG8=\r\n=AAAA\r\n-----END PGP SIGNATURE-----\r\nafter"; + assert_eq!(blocks(text, Kind::Signature, 4, 100), vec![b"Hello".to_vec()]); + // Armour with no headers and no empty line after the first line. + let text = b"-----BEGIN PGP SIGNATURE-----\nSGVsbG8=\n-----END PGP SIGNATURE-----\n"; + assert_eq!(blocks(text, Kind::Signature, 4, 100), vec![b"Hello".to_vec()]); +} + +#[test] +fn blocks_are_capped_in_size_and_number() { + let block = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nSGVsbG8=\n-----END PGP PUBLIC KEY BLOCK-----\n"; + let many = block.repeat(10); + assert_eq!(blocks(many.as_bytes(), Kind::PublicKey, 3, 100).len(), 3); + assert!(blocks(block.as_bytes(), Kind::PublicKey, 3, 4).is_empty()); + // Another kind of block, an unclosed one, and an empty one are not blocks. + assert!(blocks(block.as_bytes(), Kind::Signature, 3, 100).is_empty()); + assert!(blocks(b"-----BEGIN PGP SIGNATURE-----\n\nSGVsbG8=\n", Kind::Signature, 3, 100).is_empty()); + assert!(blocks(b"-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n", Kind::Signature, 3, 100).is_empty()); +} + +#[test] +fn cleartext_undoes_dash_escaping_and_trailing_white_space() { + let text = b"Intro, not signed\r\n-----BEGIN PGP SIGNED MESSAGE-----\r\nHash: SHA256\r\n\r\nline one \r\n- -dashes\r\n\r\nlast\t\r\n-----BEGIN PGP SIGNATURE-----\r\n\r\nSGVsbG8=\r\n-----END PGP SIGNATURE-----\r\ntrailer"; + let clear = cleartext(text, 100).unwrap(); + assert_eq!(clear.signed, b"line one\r\n-dashes\r\n\r\nlast"); + assert_eq!(clear.signature, b"Hello"); +} + +#[test] +fn an_incomplete_cleartext_is_none() { + assert!(cleartext(b"-----BEGIN PGP SIGNED MESSAGE-----\n\ntext\n", 100).is_none()); + assert!(cleartext(b"-----BEGIN PGP SIGNED MESSAGE-----\n\ntext\n-----BEGIN PGP SIGNATURE-----\n\nSGVsbG8=\n", 100).is_none()); + assert!(cleartext(b"plain text", 100).is_none()); +} diff --git a/crates/openpgp/src/autocrypt.rs b/crates/openpgp/src/autocrypt.rs new file mode 100644 index 000000000..99f4c99c8 --- /dev/null +++ b/crates/openpgp/src/autocrypt.rs @@ -0,0 +1,73 @@ +//! The `Autocrypt:` header (Autocrypt Level 1, §2.1): a sender's Public key carried in every +//! message, for the address it came from. Read, never acted on here: the caller keeps the key +//! for that address, and nothing is sent. + +use crate::keys::{Budget, MAX_CHECKS_PER_CALL, MAX_KEY_BYTES, PublicKey}; +use crate::{Error, mime}; + +/// How long a header value may be: a key for Autocrypt is meant to be small (Level 1 §2.1 +/// recommends one signing and one encryption key, a few KB), and the cap leaves room. +pub(crate) const MAX_HEADER: usize = 64 * 1024; + +/// An `Autocrypt:` header that reads: whose it is, what they prefer, and their key. +#[derive(Clone, Debug)] +pub struct Autocrypt { + /// The address it is for (`addr`), lower case. + pub address: String, + /// `prefer-encrypt=mutual`: the sender wants encrypted replies when both sides can. + pub prefer_encrypt_mutual: bool, + /// The key (`keydata`), its self-signatures checked. + pub key: PublicKey, +} + +/// Reads one `Autocrypt:` header value. [`Error::Unreadable`] when it lacks `addr` or `keydata`, +/// has an attribute it doesn't know that doesn't start with `_` (which Level 1 says makes the +/// whole header one to ignore), or carries no key that checks; [`Error::TooLarge`] when it is +/// longer than a key for Autocrypt needs. +pub fn parse_autocrypt(value: &str) -> Result { + attributes(value)?.read(&mut Budget::new(MAX_CHECKS_PER_CALL)) +} + +/// A header's attributes, read before its key is: the key is the costly part. +pub(crate) struct Attributes<'a> { + pub(crate) address: String, + mutual: bool, + keydata: &'a str, +} + +/// The attributes of an `Autocrypt:` header value, as [`parse_autocrypt`] reads them. +pub(crate) fn attributes(value: &str) -> Result, Error> { + if value.len() > MAX_HEADER { + return Err(Error::TooLarge); + } + let (mut address, mut keydata, mut mutual) = (None, None, false); + for attribute in value.split(';') { + let attribute = attribute.trim(); + if attribute.is_empty() { + continue; + } + let (name, data) = attribute.split_once('=').ok_or(Error::Unreadable)?; + match name.trim() { + "addr" if address.is_none() => address = Some(mime::address(data.trim())), + "keydata" if keydata.is_none() => keydata = Some(data), + "prefer-encrypt" => mutual = data.trim() == "mutual", + name if name.starts_with('_') => {} + _ => return Err(Error::Unreadable), + } + } + let address = address.filter(|a| !a.is_empty()).ok_or(Error::Unreadable)?; + Ok(Attributes { address, mutual, keydata: keydata.ok_or(Error::Unreadable)? }) +} + +impl Attributes<'_> { + /// The header with its key read. + pub(crate) fn read(self, total: &mut Budget) -> Result { + let bytes = mime::base64(self.keydata.as_bytes()); + if bytes.len() > MAX_KEY_BYTES { + return Err(Error::TooLarge); + } + // Level 1 §2.1: the keydata is one Transferable Public Key. + let key = PublicKey::binary(&bytes, 1, total).into_iter().next().ok_or(Error::Unreadable)?; + Ok(Autocrypt { address: self.address, prefer_encrypt_mutual: self.mutual, key }) + } +} diff --git a/crates/openpgp/src/autocrypt_tests.rs b/crates/openpgp/src/autocrypt_tests.rs new file mode 100644 index 000000000..813a0a019 --- /dev/null +++ b/crates/openpgp/src/autocrypt_tests.rs @@ -0,0 +1,23 @@ +use crate::autocrypt::MAX_HEADER; +use crate::{Error, parse_autocrypt}; + +#[test] +fn a_header_without_addr_or_keydata_is_unreadable() { + assert_eq!(parse_autocrypt("keydata=AAAA").unwrap_err(), Error::Unreadable); + assert_eq!(parse_autocrypt("addr=a@example.org").unwrap_err(), Error::Unreadable); + assert_eq!(parse_autocrypt("").unwrap_err(), Error::Unreadable); +} + +#[test] +fn an_unknown_critical_attribute_makes_the_header_unreadable() { + assert_eq!(parse_autocrypt("addr=a@example.org; colour=blue; keydata=AAAA").unwrap_err(), Error::Unreadable); + assert_eq!(parse_autocrypt("addr=a@example.org; novalue; keydata=AAAA").unwrap_err(), Error::Unreadable); + // One starting with `_` is skipped; the keydata here is no key, so it still doesn't read. + assert_eq!(parse_autocrypt("addr=a@example.org; _colour=blue; keydata=AAAA").unwrap_err(), Error::Unreadable); +} + +#[test] +fn a_header_too_long_is_refused_before_it_is_read() { + let long = format!("addr=a@example.org; keydata={}", "A".repeat(MAX_HEADER)); + assert_eq!(parse_autocrypt(&long).unwrap_err(), Error::TooLarge); +} diff --git a/crates/openpgp/src/check.rs b/crates/openpgp/src/check.rs new file mode 100644 index 000000000..3b54eed21 --- /dev/null +++ b/crates/openpgp/src/check.rs @@ -0,0 +1,462 @@ +//! Checking one Digital signature over bytes, and what came of it. +//! +//! Only what a signature's hashed area says is believed: its issuer there may be blamed for a +//! signature that doesn't match, and its unhashed area (which anybody can change) is a hint to +//! find a key that then has to fit, never a reason for a result. What was signed is hashed once +//! for each hash, kind and salt among the signatures, whatever the number of keys tried. + +use std::cell::Cell; + +use pgp::composed::{Deserializable, DetachedSignature}; +use pgp::crypto::hash::HashAlgorithm; +use pgp::crypto::public_key::PublicKeyAlgorithm; +use pgp::line_writer::LineBreak; +use pgp::normalize_lines::NormalizedReader; +use pgp::packet::{Signature, SignatureConfig, SignatureType, SignatureVersion, SignatureVersionSpecific, Subpacket, SubpacketData}; +use pgp::types::{KeyVersion, SignatureBytes, VerifyingKey}; +use sha2::digest::{Digest, DynDigest}; + +use crate::keys::{self, Fingerprint, KeyId, KeyRing, PublicKey, Signing}; + +/// How many signatures over one text are looked at. +pub(crate) const MAX_SIGNATURES: usize = 8; +/// How many bytes a block of signatures may hold, decoded. +pub(crate) const MAX_SIGNATURE_BYTES: usize = 64 * 1024; +/// How many keys one signature is tried with. +pub(crate) const MAX_CANDIDATES: usize = 8; +/// How many signature checks (public-key operations) one message may cost. +pub(crate) const MAX_VERIFICATIONS: usize = 24; + +/// Who made a Digital signature and when, for the details. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Signer { + /// The primary key's fingerprint: what the owner compares and marks Verified. + pub fingerprint: Fingerprint, + /// The fingerprint of the key that signed, when it is a subkey. + pub subkey: Option, + /// The key id of the key that signed. + pub key_id: KeyId, + /// The key's addresses (from its own User IDs), lower case. + pub addresses: Vec, + /// When the signature says it was made, in seconds since 1970. + pub signed: u64, + /// When the primary key was made, in seconds since 1970. + pub key_created: u64, + /// The signing key's algorithm ("RSA", "Ed25519", …). + pub algorithm: &'static str, + /// The signature's hash ("SHA-256", …). + pub hash: &'static str, +} + +/// Who a signature that couldn't be checked names as its signer in its hashed area, if it does. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Issuer { + pub fingerprint: Option, + pub key_id: Option, +} + +/// Why a valid Digital signature isn't Verified: one or more of these. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct NotVerified { + /// The owner hasn't Verified this key for the From address (marked it after comparing the + /// fingerprint, or their GnuPG keyring trusts it fully). + pub key_unverified: bool, + /// The From address isn't one of the key's own (when the message was signed). + pub address_differs: bool, + /// The signature covers only part of the message ([`Coverage`]). + pub partly_signed: bool, +} + +/// Why a Digital signature can't be checked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CantCheck { + /// No Public key here for the key it names, nor any for the From address. + NoPublicKey, + /// None of the Public keys here for the From address made it, and the key it names (if + /// any) isn't here. + NoMatchingKey, + /// Made with an algorithm that is no longer safe: a hash (MD5, SHA-1, RIPEMD-160) or a key + /// (DSA, RSA under 2048 bits). Its name. + WeakAlgorithm(&'static str), + /// An algorithm or signature version this program doesn't know (or one before 4). + UnknownAlgorithm, + /// The signing key (or its primary key) was revoked by its owner, for this signature. + KeyRevoked, + /// The signing key (or its primary key) had expired when the signature was made. + KeyExpired, + /// The key that matches isn't one that may sign (or its subkey didn't sign back that it + /// belongs to the key). + NotASigningKey, + /// The signature itself has expired. + SignatureExpired, + /// The message is larger than any looked at: it is signed, but isn't checked. + TooLarge, + /// The signature, or what it is over, can't be read, or is malformed. + Unreadable, +} + +/// What came of checking a message's OpenPGP Digital signature: the four results. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + /// Valid, the key Verified for the From address, which is the key's, over the whole message. + Verified(Signer), + /// Valid, but not Verified: why. + Valid { signer: Signer, why: NotVerified }, + /// The message was changed after it was signed: the key its hashed area names is here and + /// doesn't match it. + Changed(Signer), + /// It can't be checked, and why; who its hashed area names as its signer. + CantCheck { why: CantCheck, issuer: Issuer }, +} + +impl Outcome { + /// Which outcome to show of several signatures over one text: the best. + fn rank(&self) -> u8 { + match self { + Outcome::Verified(_) => 3, + Outcome::Valid { .. } => 2, + Outcome::Changed(_) => 1, + Outcome::CantCheck { .. } => 0, + } + } + + pub(crate) fn cant(why: CantCheck) -> Outcome { + Outcome::CantCheck { why, issuer: Issuer::default() } + } +} + +/// What of a message a Digital signature covers. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Coverage { + /// Whether it is the whole message: a PGP/MIME message at the top, or a plain text body that + /// is one cleartext-signed block with nothing but white space around it. Only then can a + /// message be Verified. + pub whole: bool, + /// The MIME part signed, as the place of each part on the way down from the message (empty: + /// the message itself; `[0]`: its first part). + pub path: Vec, + /// For a cleartext-signed text: where the block is in that part's text (decoded from its + /// transfer encoding), from its first line to the end of its last. + pub span: Option<(usize, usize)>, +} + +/// A message's Digital signature checked: what came of it, and what it covers. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Checked { + pub outcome: Outcome, + pub coverage: Coverage, +} + +/// What the caller hands in to check against, and what checking has cost so far. +pub(crate) struct Context<'a> { + /// The From address, lower case; empty when the From field doesn't hold exactly one. + pub(crate) from: String, + pub(crate) keys: &'a KeyRing, + pub(crate) now: u64, + pub(crate) verified: &'a dyn Fn(&Fingerprint, &str) -> bool, + /// Public-key operations made. + pub(crate) verifications: Cell, + /// Times what was signed was hashed. + pub(crate) data_passes: Cell, +} + +/// A hash over what was signed, before a signature's own fields: cloned for each signature. +#[derive(Clone)] +enum Base { + Sha224(sha2::Sha224), + Sha256(sha2::Sha256), + Sha384(sha2::Sha384), + Sha512(sha2::Sha512), + Sha3_256(sha3::Sha3_256), + Sha3_512(sha3::Sha3_512), +} + +impl Base { + fn new(hash: HashAlgorithm) -> Option { + Some(match hash { + HashAlgorithm::Sha224 => Base::Sha224(Default::default()), + HashAlgorithm::Sha256 => Base::Sha256(Default::default()), + HashAlgorithm::Sha384 => Base::Sha384(Default::default()), + HashAlgorithm::Sha512 => Base::Sha512(Default::default()), + HashAlgorithm::Sha3_256 => Base::Sha3_256(Default::default()), + HashAlgorithm::Sha3_512 => Base::Sha3_512(Default::default()), + _ => return None, + }) + } + + fn update(&mut self, bytes: &[u8]) { + match self { + Base::Sha224(h) => Digest::update(h, bytes), + Base::Sha256(h) => Digest::update(h, bytes), + Base::Sha384(h) => Digest::update(h, bytes), + Base::Sha512(h) => Digest::update(h, bytes), + Base::Sha3_256(h) => Digest::update(h, bytes), + Base::Sha3_512(h) => Digest::update(h, bytes), + } + } + + fn boxed(&self) -> Box { + match self.clone() { + Base::Sha224(h) => Box::new(h), + Base::Sha256(h) => Box::new(h), + Base::Sha384(h) => Box::new(h), + Base::Sha512(h) => Box::new(h), + Base::Sha3_256(h) => Box::new(h), + Base::Sha3_512(h) => Box::new(h), + } + } +} + +/// The hashes over what was signed, one for each hash, kind (binary or text) and salt. +type Bases = Vec<((HashAlgorithm, bool, Vec), Base)>; + +/// Checks the signature packets `packets` (binary) over `data`; the best outcome of them. +pub(crate) fn check(packets: &[u8], data: &[u8], cx: &Context<'_>) -> Outcome { + if packets.len() > MAX_SIGNATURE_BYTES { + return Outcome::cant(CantCheck::Unreadable); + } + let parsed = crate::guard(|| DetachedSignature::from_bytes_many(packets).map(|it| it.take(MAX_SIGNATURES).collect::>())); + let Some(Ok(signatures)) = parsed else { return Outcome::cant(CantCheck::Unreadable) }; + let mut bases: Bases = Vec::new(); + let mut best: Option = None; + for signature in signatures.into_iter().filter_map(Result::ok) { + let outcome = crate::guard(|| one(&signature.signature, data, cx, &mut bases)).unwrap_or_else(|| Outcome::cant(CantCheck::Unreadable)); + if best.as_ref().is_none_or(|b| outcome.rank() > b.rank()) { + best = Some(outcome); + } + } + best.unwrap_or_else(|| Outcome::cant(CantCheck::Unreadable)) +} + +/// The issuers a list of subpackets names. +fn issuers(subpackets: &[Subpacket]) -> (Vec, Vec) { + let mut fps = Vec::new(); + let mut ids = Vec::new(); + for p in subpackets.iter().take(64) { + match &p.data { + SubpacketData::IssuerFingerprint(fp) => fps.push(Fingerprint::of(fp)), + SubpacketData::IssuerKeyId(id) => ids.push(KeyId::of(id)), + _ => {} + } + } + (fps, ids) +} + +/// The digest a signature signs: what was signed (hashed once per hash, kind and salt), then +/// its own fields and trailer (RFC 9580 §5.2.4). `None` when it can't be made. +fn digest(config: &SignatureConfig, data: &[u8], cx: &Context<'_>, bases: &mut Bases) -> Option> { + let text = config.typ == SignatureType::Text; + let salt = match &config.version_specific { + SignatureVersionSpecific::V6 { salt } => { + if config.hash_alg.salt_len() != Some(salt.len()) { + return None; + } + salt.clone() + } + _ => Vec::new(), + }; + let key = (config.hash_alg, text, salt); + let base = match bases.iter().find(|(k, _)| *k == key) { + Some((_, base)) => base.clone(), + None => { + let mut base = Base::new(config.hash_alg)?; + base.update(&key.2); + if text { + // RFC 9580 §5.2.1.2: a text signature is over the text with CR LF line ends. + let mut reader = NormalizedReader::new(data, LineBreak::Crlf); + let mut chunk = [0u8; 8192]; + loop { + let n = std::io::Read::read(&mut reader, &mut chunk).ok()?; + if n == 0 { + break; + } + base.update(chunk.get(..n)?); + } + } else { + base.update(data); + } + cx.data_passes.set(cx.data_passes.get().saturating_add(1)); + bases.push((key, base.clone())); + base + } + }; + let mut hasher = base.boxed(); + let len = config.hash_signature_data(&mut hasher).ok()?; + hasher.update(&config.trailer(len).ok()?); + Some(hasher.finalize().to_vec()) +} + +fn one(sig: &Signature, data: &[u8], cx: &Context<'_>, bases: &mut Bases) -> Outcome { + let Some(config) = sig.config() else { return Outcome::cant(CantCheck::UnknownAlgorithm) }; + let (fps, ids) = issuers(&config.hashed_subpackets); + let (hint_fps, hint_ids) = issuers(&config.unhashed_subpackets); + let issuer = Issuer { fingerprint: fps.first().cloned(), key_id: ids.first().copied() }; + let cant = |why: CantCheck| Outcome::CantCheck { why, issuer: issuer.clone() }; + // Version 3 signatures (and earlier) hash too little of themselves to be trusted. + if !matches!(config.version(), SignatureVersion::V4 | SignatureVersion::V6) { + return cant(CantCheck::UnknownAlgorithm); + } + if !matches!(config.typ, SignatureType::Binary | SignatureType::Text) { + return cant(CantCheck::Unreadable); + } + if keys::is_weak(config.hash_alg) { + return cant(CantCheck::WeakAlgorithm(keys::hash_name(config.hash_alg))); + } + if config.pub_alg == PublicKeyAlgorithm::DSA { + return cant(CantCheck::WeakAlgorithm("DSA")); + } + if !known_signature_algorithm(config.pub_alg) { + return cant(CantCheck::UnknownAlgorithm); + } + // RFC 9580 §5.2.3.11: a signature says when it was made, in its hashed area. + let Some(signed) = config.created().map(|t| u64::from(t.as_secs())) else { return cant(CantCheck::Unreadable) }; + // A signature whose value isn't of the algorithm's shape is malformed, never "changed". + let Some(sig_bytes) = sig.signature().filter(|b| well_shaped(config.pub_alg, b)) else { return cant(CantCheck::Unreadable) }; + let Some(digest) = digest(config, data, cx, bases) else { return cant(CantCheck::UnknownAlgorithm) }; + // The first two bytes of the digest are in the signature: a quick test that it is over this. + let fits = sig.signed_hash_value().is_some_and(|v| digest.get(..2) == Some(&v[..])); + + // The keys to try, at most MAX_CANDIDATES: those the hashed area names; then those the + // unhashed area names (a hint: only a key that fits counts), before any other key of the + // From address, so that keys claiming the address can't push the signer's out; then the + // From address's keys. + let mut candidates: Vec<(&PublicKey, &Signing, bool)> = Vec::new(); + for (k, s) in cx.keys.signers(&fps, &ids) { + push(&mut candidates, k, s, true); + } + for (k, s) in cx.keys.signers(&hint_fps, &hint_ids) { + push(&mut candidates, k, s, false); + } + let from_keys: Vec<&PublicKey> = cx.keys.for_address(&cx.from).collect(); + for k in &from_keys { + for s in &k.signing { + push(&mut candidates, k, s, false); + } + } + + let mut blamed: Option = None; + let mut weak: Option<&'static str> = None; + let mut starved = false; + for (key, signing, named) in candidates { + // A version 6 signature is made by a version 6 key and only by one (RFC 9580 §5.2.3). + if (signing.version == KeyVersion::V6) != (config.version() == SignatureVersion::V6) || signing.algorithm != config.pub_alg { + continue; + } + let signer = Signer { + fingerprint: key.fingerprint().clone(), + subkey: signing.subkey.map(|_| signing.fingerprint.clone()), + key_id: signing.key_id, + addresses: key.addresses().to_vec(), + signed, + key_created: key.created(), + algorithm: keys::algorithm_name(signing.algorithm), + hash: keys::hash_name(config.hash_alg), + }; + if let Some(why) = signing.weak { + if named { + weak = Some(why); + } + continue; + } + if !fits { + if named { + blamed.get_or_insert(signer); + } + continue; + } + if cx.verifications.get() >= MAX_VERIFICATIONS { + // Keys left untried: what came of it isn't known. + starved = true; + break; + } + cx.verifications.set(cx.verifications.get().saturating_add(1)); + if verify_with(config.hash_alg, &digest, sig_bytes, key, signing) { + return judged(sig, key, signing, signer, cx).unwrap_or_else(&cant); + } + if named { + blamed.get_or_insert(signer); + } + } + if let Some(why) = weak { + return cant(CantCheck::WeakAlgorithm(why)); + } + match blamed { + Some(signer) => Outcome::Changed(signer), + None if starved => cant(CantCheck::Unreadable), + None if !from_keys.is_empty() => cant(CantCheck::NoMatchingKey), + None => cant(CantCheck::NoPublicKey), + } +} + +/// Adds a key to try, once, while there is room. +fn push<'a>(list: &mut Vec<(&'a PublicKey, &'a Signing, bool)>, key: &'a PublicKey, signing: &'a Signing, named: bool) { + if list.len() < MAX_CANDIDATES && !list.iter().any(|(_, s, _)| s.fingerprint == signing.fingerprint) { + list.push((key, signing, named)); + } +} + +/// A signature that checks: whether its key (and the primary key over it) could make it then, +/// and whether it is Verified. +fn judged(sig: &Signature, key: &PublicKey, signing: &Signing, signer: Signer, cx: &Context<'_>) -> Result { + let when = signer.signed; + let primary = key.primary(); + if signing.revoked.is_some_and(|r| r.covers(Some(when))) || primary.and_then(|p| p.revoked).is_some_and(|r| r.covers(Some(when))) { + return Err(CantCheck::KeyRevoked); + } + if !signing.may_sign { + return Err(CantCheck::NotASigningKey); + } + // A signature that says it was made before its key was is not one the key made. + if when < u64::from(signing.created) { + return Err(CantCheck::Unreadable); + } + if signing.expired_at(when) || primary.is_some_and(|p| p.expired_at(when)) { + return Err(CantCheck::KeyExpired); + } + if let Some(after) = sig.signature_expiration_time().map(|d| u64::from(d.as_secs())) + && after > 0 + && cx.now >= when.saturating_add(after) + { + return Err(CantCheck::SignatureExpired); + } + let address_on_key = !cx.from.is_empty() && key.has_address_at(&cx.from, when); + let why = NotVerified { + key_unverified: cx.from.is_empty() || !(cx.verified)(&signer.fingerprint, &cx.from), + address_differs: !address_on_key, + partly_signed: false, + }; + Ok(if why.key_unverified || why.address_differs { Outcome::Valid { signer, why } } else { Outcome::Verified(signer) }) +} + +fn verify_with(hash: HashAlgorithm, digest: &[u8], sig: &SignatureBytes, key: &PublicKey, signing: &Signing) -> bool { + let inner = key.inner(); + match signing.subkey { + None => inner.primary_key.verify(hash, digest, sig).is_ok(), + Some(i) => inner.public_subkeys.get(i).is_some_and(|sub| sub.key.verify(hash, digest, sig).is_ok()), + } +} + +/// Whether a signature value has the shape its algorithm gives it (RFC 9580 §5.2.3): one MPI +/// for RSA, two for ECDSA and legacy EdDSA, 64 or 114 bytes for Ed25519 and Ed448. +fn well_shaped(algorithm: PublicKeyAlgorithm, bytes: &SignatureBytes) -> bool { + match (algorithm, bytes) { + (PublicKeyAlgorithm::RSA | PublicKeyAlgorithm::RSASign, SignatureBytes::Mpis(m)) => m.len() == 1, + (PublicKeyAlgorithm::ECDSA | PublicKeyAlgorithm::EdDSALegacy, SignatureBytes::Mpis(m)) => m.len() == 2, + (PublicKeyAlgorithm::Ed25519, SignatureBytes::Native(b)) => b.len() == 64, + (PublicKeyAlgorithm::Ed448, SignatureBytes::Native(b)) => b.len() == 114, + _ => false, + } +} + +/// Algorithms a Digital signature can be made with that are checked (DSA is refused before). +fn known_signature_algorithm(algorithm: PublicKeyAlgorithm) -> bool { + matches!( + algorithm, + PublicKeyAlgorithm::RSA + | PublicKeyAlgorithm::RSASign + | PublicKeyAlgorithm::ECDSA + | PublicKeyAlgorithm::EdDSALegacy + | PublicKeyAlgorithm::Ed25519 + | PublicKeyAlgorithm::Ed448 + ) +} diff --git a/crates/openpgp/src/coverage_tests.rs b/crates/openpgp/src/coverage_tests.rs new file mode 100644 index 000000000..ffad12ce2 --- /dev/null +++ b/crates/openpgp/src/coverage_tests.rs @@ -0,0 +1,136 @@ +//! What a signature covers: only a whole message can be Verified, and words around a signed +//! part never borrow its signature. + +use pgp::crypto::hash::HashAlgorithm; + +use crate::fixtures::*; +use crate::{CantCheck, Checked, Coverage, Fingerprint, KeyRing, MAX_MESSAGE, NotVerified, Outcome, verify}; + +const ALICE: &str = "Alice "; + +fn yes(_: &Fingerprint, _: &str) -> bool { + true +} + +fn alice() -> (pgp::composed::SignedSecretKey, KeyRing) { + let alice = secret(ALICE, Make::default()); + let ring = [public(&alice)].into_iter().collect(); + (alice, ring) +} + +/// A PGP/MIME message by `alice` as a part: its Content-Type field and its body. +fn signed_part(alice: &pgp::composed::SignedSecretKey) -> (String, String) { + let inner = String::from_utf8(signed_message(alice, HashAlgorithm::Sha256)).unwrap(); + let (head, body) = inner.split_once("\r\n\r\n").unwrap(); + let content_type = head.split("\r\n").skip_while(|l| !l.starts_with("Content-Type")).collect::>().join("\r\n"); + (content_type, body.to_string()) +} + +fn partly(checked: &Checked) -> bool { + matches!(&checked.outcome, Outcome::Valid { why: NotVerified { partly_signed: true, .. }, .. }) +} + +#[test] +fn a_pgp_mime_message_at_the_top_covers_the_whole_message() { + let (alice, keys) = alice(); + let checked = verify(&signed_message(&alice, HashAlgorithm::Sha256), ALICE, &keys, LATER, yes).unwrap(); + assert_eq!(checked.coverage, Coverage { whole: true, path: vec![], span: None }); + assert!(matches!(checked.outcome, Outcome::Verified(_))); +} + +#[test] +fn a_signed_alternative_beside_an_unsigned_one_is_not_a_signed_message() { + // The HTML alternative is what most readers show, and it isn't signed. + let (alice, keys) = alice(); + let (content_type, body) = signed_part(&alice); + let message = format!( + "From: {ALICE}\r\nContent-Type: multipart/alternative; boundary=\"alt\"\r\n\r\n--alt\r\n{content_type}\r\n\r\n{body}\r\n--alt\r\nContent-Type: text/html\r\n\r\n

Wire 10000 EUR to Mallory

\r\n--alt--\r\n" + ); + assert_eq!(verify(message.as_bytes(), ALICE, &keys, LATER, yes), None); +} + +#[test] +fn a_signed_part_beside_an_unsigned_one_is_only_partly_signed() { + let (alice, keys) = alice(); + let (content_type, body) = signed_part(&alice); + let message = format!( + "From: {ALICE}\r\nContent-Type: multipart/mixed; boundary=\"mix\"\r\n\r\n--mix\r\n{content_type}\r\n\r\n{body}\r\n--mix\r\nContent-Type: text/plain\r\n\r\nWire 10000 EUR to Mallory\r\n--mix--\r\n" + ); + let checked = verify(message.as_bytes(), ALICE, &keys, LATER, yes).unwrap(); + assert!(!checked.coverage.whole); + assert_eq!(checked.coverage.path, vec![0]); + assert!(partly(&checked), "{checked:?}"); +} + +#[test] +fn an_inline_signed_body_alone_covers_the_whole_message_and_says_where() { + let (alice, keys) = alice(); + let block = cleartext(&alice, "Pay 10 EUR to Bob.\n"); + let body = format!("\r\n{block}\r\n\r\n"); + let checked = verify(&plain(ALICE, "7bit", &body), ALICE, &keys, LATER, yes).unwrap(); + assert!(matches!(checked.outcome, Outcome::Verified(_)), "{checked:?}"); + assert!(checked.coverage.whole); + let (start, end) = checked.coverage.span.unwrap(); + assert_eq!(&body[start..end].trim_end(), &block.trim_end()); +} + +#[test] +fn words_before_or_after_an_inline_signed_block_are_not_covered() { + let (alice, keys) = alice(); + let block = cleartext(&alice, "Pay 10 EUR to Bob.\n"); + for body in [format!("Wire 10000 EUR to Mallory\r\n{block}"), format!("{block}\r\nPS: wire 10000 EUR to Mallory\r\n")] { + let checked = verify(&plain(ALICE, "7bit", &body), ALICE, &keys, LATER, yes).unwrap(); + assert!(!checked.coverage.whole); + assert!(partly(&checked), "{checked:?}"); + } +} + +#[test] +fn two_inline_signed_blocks_are_each_only_part() { + let (alice, keys) = alice(); + let body = format!("{}\r\n{}", cleartext(&alice, "First.\n"), cleartext(&alice, "Second.\n")); + let checked = verify(&plain(ALICE, "7bit", &body), ALICE, &keys, LATER, yes).unwrap(); + assert!(partly(&checked), "{checked:?}"); +} + +#[test] +fn an_armour_header_other_than_hash_or_an_unescaped_dash_line_is_unreadable() { + // Both would show words in the block that aren't signed. + let (alice, keys) = alice(); + let block = cleartext(&alice, "Pay 10 EUR to Bob.\n"); + let header = block.replacen("-----BEGIN PGP SIGNED MESSAGE-----\n", "-----BEGIN PGP SIGNED MESSAGE-----\nNote: and 10000 EUR to Mallory\n", 1); + assert_ne!(header, block); + let dash = block.replacen("Pay 10 EUR to Bob.", "-----BEGIN PGP SIGNATURE-----", 1); + // A `Hash:` header with more than hash names after it. + let with_hash = block.replacen( + "-----BEGIN PGP SIGNED MESSAGE-----\n", + "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256, and pay Mallory 10000 EUR today\n", + 1, + ); + assert_ne!(with_hash, block); + // Hash names alone, as many writers give them, are fine. + let names = block.replacen("-----BEGIN PGP SIGNED MESSAGE-----\n", "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256, sha512 ,SHA3-256\n", 1); + let checked = verify(&plain(ALICE, "7bit", &names), ALICE, &keys, LATER, yes).unwrap(); + assert!(matches!(checked.outcome, Outcome::Verified(_)), "{checked:?}"); + for body in [header, dash, with_hash] { + let checked = verify(&plain(ALICE, "7bit", &body), ALICE, &keys, LATER, yes).unwrap(); + assert!(matches!(checked.outcome, Outcome::CantCheck { why: CantCheck::Unreadable, .. }), "{checked:?}"); + } +} + +#[test] +fn a_signed_message_too_large_to_check_says_so() { + let mut big = signed_message(&secret(ALICE, Make::default()), HashAlgorithm::Sha256); + big.resize(MAX_MESSAGE + 1, b'a'); + let checked = verify(&big, ALICE, &KeyRing::new(), LATER, yes).unwrap(); + assert!(matches!(checked.outcome, Outcome::CantCheck { why: CantCheck::TooLarge, .. })); + // Inline-signed, the block found anywhere in it. + let mut big = plain(ALICE, "7bit", &"a".repeat(1 << 20)); + big.extend_from_slice(b"\r\n-----BEGIN PGP SIGNED MESSAGE-----\r\n"); + big.resize(MAX_MESSAGE + 1, b'a'); + assert!(verify(&big, ALICE, &KeyRing::new(), LATER, yes).is_some()); + // Not signed at all: nothing to say. + let mut big = plain(ALICE, "7bit", "Just text.\r\n"); + big.resize(MAX_MESSAGE + 1, b'a'); + assert_eq!(verify(&big, ALICE, &KeyRing::new(), LATER, yes), None); +} diff --git a/crates/openpgp/src/fixtures_tests.rs b/crates/openpgp/src/fixtures_tests.rs new file mode 100644 index 000000000..04ebcdb94 --- /dev/null +++ b/crates/openpgp/src/fixtures_tests.rs @@ -0,0 +1,222 @@ +//! Keys, signatures and messages made in the tests: synthetic `example.org` identities only. + +use pgp::composed::{ + ArmorOptions, CleartextSignedMessage, DetachedSignature, EncryptionCaps, KeyType, SecretKeyParamsBuilder, SignedPublicKey, SignedSecretKey, + SubkeyParamsBuilder, +}; +use pgp::crypto::hash::HashAlgorithm; +use pgp::packet::{SignatureConfig, SignatureType, Subpacket, SubpacketData}; +use pgp::ser::Serialize; +use pgp::types::{KeyDetails, KeyVersion, Password, SigningKey, Timestamp}; +use rand::SeedableRng; +use rand::rngs::StdRng; + +use crate::PublicKey; + +/// RFC 9580 Appendix A.3: the sample version 6 certificate (no User ID). +pub(crate) const RFC9580_A3_CERT: &str = "-----BEGIN PGP PUBLIC KEY BLOCK----- + +xioGY4d/4xsAAAAg+U2nu0jWCmHlZ3BqZYfQMxmZu52JGggkLq2EVD34laPCsQYf +GwoAAABCBYJjh3/jAwsJBwUVCg4IDAIWAAKbAwIeCSIhBssYbE8GCaaX5NUt+mxy +KwwfHifBilZwj2Ul7Ce62azJBScJAgcCAAAAAK0oIBA+LX0ifsDm185Ecds2v8lw +gyU2kCcUmKfvBXbAf6rhRYWzuQOwEn7E/aLwIwRaLsdry0+VcallHhSu4RN6HWaE +QsiPlR4zxP/TP7mhfVEe7XWPxtnMUMtf15OyA51YBM4qBmOHf+MZAAAAIIaTJINn ++eUBXbki+PSAld2nhJh/LVmFsS+60WyvXkQ1wpsGGBsKAAAALAWCY4d/4wKbDCIh +BssYbE8GCaaX5NUt+mxyKwwfHifBilZwj2Ul7Ce62azJAAAAAAQBIKbpGG2dWTX8 +j+VjFM21J0hqWlEg+bdiojWnKfA5AQpWUWtnNwDEM0g12vYxoWM8Y81W+bHBw805 +I8kWVkXU6vFOi+HWvv/ira7ofJu16NnoUkhclkUrk0mXubZvyl4GBg== +-----END PGP PUBLIC KEY BLOCK----- +"; + +/// RFC 9580 Appendix A.6: the sample cleartext-signed message, signed by [`RFC9580_A3_CERT`]. +pub(crate) const RFC9580_A6_CLEARTEXT: &str = "-----BEGIN PGP SIGNED MESSAGE----- + +What we need from the grocery store: + +- - tofu +- - vegetables +- - noodles + +-----BEGIN PGP SIGNATURE----- + +wpgGARsKAAAAKQWCY5ijYyIhBssYbE8GCaaX5NUt+mxyKwwfHifBilZwj2Ul7Ce6 +2azJAAAAAGk2IHZJX1AhiJD39eLuPBgiUU9wUA9VHYblySHkBONKU/usJ9BvuAqo +/FvLFuGWMbKAdA+epq7V4HOtAPlBWmU8QOd6aud+aSunHQaaEJ+iTFjP2OMW0KBr +NK2ay45cX1IVAQ== +-----END PGP SIGNATURE----- +"; + +/// The primary fingerprint of [`RFC9580_A3_CERT`]. +pub(crate) const RFC9580_A3_FINGERPRINT: &str = "CB186C4F0609A697E4D52DFA6C722B0C1F1E27C18A56708F6525EC27BAD9ACC9"; + +/// A key for the property tests to look signatures up in. +pub(crate) static ALICE_FOR_PROPS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| vec![public(&secret("A ", Make::default()))]); + +/// A time after every signature made in the tests (2100-01-01). +pub(crate) const LATER: u64 = 4_102_444_800; + +/// How a test key is made. +#[derive(Clone, Copy)] +pub(crate) struct Make { + pub(crate) seed: u64, + /// A subkey signs, not the primary key. + pub(crate) subkey: bool, + pub(crate) v6: bool, + /// RSA of this many bits rather than EdDSA: rPGP makes MD5 and SHA-1 signatures only with RSA. + pub(crate) rsa: Option, + /// A second User ID, `Alias `. + pub(crate) alias: bool, + /// An RSA encryption subkey of 2048 bits besides. + pub(crate) rsa_encryption: bool, +} + +impl Default for Make { + fn default() -> Make { + Make { seed: 1, subkey: false, v6: false, rsa: None, alias: false, rsa_encryption: false } + } +} + +/// A secret key for `user_id` (an `example.org` identity). +pub(crate) fn secret(user_id: &str, make: Make) -> SignedSecretKey { + let rng = StdRng::seed_from_u64(make.seed); + let version = if make.v6 { KeyVersion::V6 } else { KeyVersion::V4 }; + let key_type = match (make.rsa, make.v6) { + (Some(bits), _) => KeyType::Rsa(bits as u32), + (None, true) => KeyType::Ed25519, + (None, false) => KeyType::Ed25519Legacy, + }; + let mut params = SecretKeyParamsBuilder::default(); + params.version(version).key_type(key_type.clone()).can_certify(true).can_sign(!make.subkey).primary_user_id(user_id.into()); + if make.alias { + params.user_ids(vec!["Alias ".into()]); + } + let mut subkeys = Vec::new(); + if make.subkey { + let mut sub = SubkeyParamsBuilder::default(); + sub.version(version).key_type(key_type).can_sign(true).can_encrypt(EncryptionCaps::None); + subkeys.push(sub.build().unwrap()); + } + if make.rsa_encryption { + let mut sub = SubkeyParamsBuilder::default(); + sub.version(version).key_type(KeyType::Rsa(2048)).can_sign(false).can_encrypt(EncryptionCaps::All); + subkeys.push(sub.build().unwrap()); + } + if !subkeys.is_empty() { + params.subkeys(subkeys); + } + params.build().unwrap().generate(rng).unwrap() +} + +/// The public half of `secret`, read back as the crate reads keys. +pub(crate) fn public(secret: &SignedSecretKey) -> PublicKey { + let bytes = SignedPublicKey::from(secret.clone()).to_bytes().unwrap(); + PublicKey::parse(&bytes).unwrap().remove(0) +} + +/// The armoured public half of `secret`. +pub(crate) fn armoured_public(secret: &SignedSecretKey) -> String { + SignedPublicKey::from(secret.clone()).to_armored_string(ArmorOptions::default()).unwrap() +} + +/// The key that signs: rPGP takes either kind through this box alone. +#[allow(clippy::redundant_allocation)] +pub(crate) fn signer(secret: &SignedSecretKey) -> Box<&dyn SigningKey> { + match secret.secret_subkeys.first() { + Some(sub) => Box::new(&sub.key), + None => Box::new(&secret.primary_key), + } +} + +/// An armoured detached signature over `data` by `secret` (its signing subkey when it has one). +pub(crate) fn detached(secret: &SignedSecretKey, data: &[u8], hash: HashAlgorithm, text: bool) -> String { + let rng = StdRng::seed_from_u64(7); + let key = signer(secret); + let sig = if text { + DetachedSignature::sign_text_data(rng, &key, &Password::empty(), hash, data).unwrap() + } else { + DetachedSignature::sign_binary_data(rng, &key, &Password::empty(), hash, data).unwrap() + }; + sig.to_armored_string(ArmorOptions::default()).unwrap() +} + +/// When `secret`'s primary key was made, in seconds since 1970. +pub(crate) fn made(secret: &SignedSecretKey) -> u32 { + secret.primary_key.created_at().as_secs() +} + +/// A signature of `typ` by `key` made at `at`, its issuer's fingerprint and `extra` in its +/// hashed area: to be signed. +pub(crate) fn config(key: &impl SigningKey, typ: SignatureType, at: u32, extra: Vec) -> SignatureConfig { + let mut config = SignatureConfig::from_key(StdRng::seed_from_u64(11), key, typ).unwrap(); + config.hashed_subpackets = [SubpacketData::SignatureCreationTime(Timestamp::from_secs(at)), SubpacketData::IssuerFingerprint(key.fingerprint())] + .into_iter() + .chain(extra) + .map(|d| Subpacket::regular(d).unwrap()) + .collect(); + config +} + +/// A PGP/MIME message from alice@example.org over [`SIGNED_PART`], signed by `secret`'s +/// signing key at `at`. +pub(crate) fn signed_at(secret: &SignedSecretKey, at: u32) -> Vec { + let key = signer(secret); + let sig = config(&key, SignatureType::Binary, at, vec![]).sign(&key, &Password::empty(), SIGNED_PART.as_bytes()).unwrap(); + pgp_mime("alice@example.org", SIGNED_PART, &DetachedSignature::new(sig).to_armored_string(ArmorOptions::default()).unwrap()) +} + +/// The keys in `key`, read as the crate reads them, as a ring. +pub(crate) fn ring_of(key: &SignedPublicKey) -> crate::KeyRing { + PublicKey::parse(&key.to_bytes().unwrap()).unwrap().into_iter().collect() +} + +/// A cleartext-signed `text` by `secret`. +pub(crate) fn cleartext(secret: &SignedSecretKey, text: &str) -> String { + let rng = StdRng::seed_from_u64(9); + let key = signer(secret); + CleartextSignedMessage::sign(rng, text, &key, &Password::empty()).unwrap().to_armored_string(ArmorOptions::default()).unwrap() +} + +/// The part a PGP/MIME message signs, CR LF throughout. +pub(crate) const SIGNED_PART: &str = "Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 7bit\r\n\r\nHello Bob,\r\n\r\nthe minutes are attached.\r\n-- \r\nAlice\r\n"; + +/// A PGP/MIME message from `from` whose first part is `part` and whose signature is `armour`. +pub(crate) fn pgp_mime(from: &str, part: &str, armour: &str) -> Vec { + format!( + "From: Alice <{from}>\r\nTo: bob@example.org\r\nSubject: Minutes\r\nMIME-Version: 1.0\r\nContent-Type: multipart/signed; micalg=pgp-sha256;\r\n protocol=\"application/pgp-signature\"; boundary=\"=-b1\"\r\n\r\nThis is an OpenPGP/MIME signed message.\r\n--=-b1\r\n{part}\r\n--=-b1\r\nContent-Type: application/pgp-signature; name=\"signature.asc\"\r\nContent-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n{armour}\r\n--=-b1--\r\n" + ) + .into_bytes() +} + +/// A PGP/MIME message from alice@example.org signed by `secret` over [`SIGNED_PART`]. +pub(crate) fn signed_message(secret: &SignedSecretKey, hash: HashAlgorithm) -> Vec { + let armour = detached(secret, SIGNED_PART.as_bytes(), hash, false); + pgp_mime("alice@example.org", SIGNED_PART, &armour) +} + +/// A one-part message from `from` with `body` as its text, `encoding` its transfer encoding. +pub(crate) fn plain(from: &str, encoding: &str, body: &str) -> Vec { + format!("From: {from}\r\nSubject: Note\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: {encoding}\r\n\r\n{body}") + .into_bytes() +} + +/// `bytes` in base64, a line break after every 76 characters. +pub(crate) fn base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for (n, chunk) in bytes.chunks(3).enumerate() { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let v = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for i in 0..4 { + if i <= chunk.len() { + out.push(ALPHABET[((v >> (18 - 6 * i)) & 63) as usize] as char); + } else { + out.push('='); + } + } + if n % 19 == 18 { + out.push_str("\r\n"); + } + } + out +} diff --git a/crates/openpgp/src/keys.rs b/crates/openpgp/src/keys.rs new file mode 100644 index 000000000..c3b083162 --- /dev/null +++ b/crates/openpgp/src/keys.rs @@ -0,0 +1,751 @@ +//! Public keys: read from their bytes (armoured or not), their self-signatures checked once +//! when they are read, and kept in a [`KeyRing`] that finds them by fingerprint, key id or +//! address. A key's addresses are those of the User IDs it signed itself (RFC 9580 §5.2.1, +//! §10.1); nothing here is a web of trust: whether a key is Verified is the caller's to say. + +use pgp::composed::{Deserializable, SignedKeyDetails, SignedPublicKey, SignedPublicSubKey}; +use pgp::crypto::hash::HashAlgorithm; +use pgp::crypto::public_key::PublicKeyAlgorithm; +use pgp::packet::{KeyFlags, RevocationCode, Signature, SignatureType, SignatureVersion, SubpacketData}; +use pgp::ser::Serialize; +use pgp::types::{KeyDetails, KeyVersion, PublicParams, SignedUser, Tag}; + +use crate::armour::{self, Kind}; +use crate::{Error, mime}; + +/// How many bytes a block of Public keys may hold, decoded. +pub(crate) const MAX_KEY_BYTES: usize = 1 << 20; +/// How many keys one block (a file, an attachment) is read for. +pub(crate) const MAX_KEYS_PER_BLOCK: usize = 16; +/// How many User IDs and subkeys one key may have: more is not a key anybody sends. +const MAX_USERS: usize = 64; +const MAX_SUBKEYS: usize = 32; +/// How long a User ID may be: a name and an address are far shorter. (User attributes, such as +/// pictures, are never checked or kept, so cost nothing.) +const MAX_USER_ID: usize = 4 * 1024; +/// How many signature checks reading one key may cost (third-party certifications are never +/// checked, so a real key needs a handful). Room for two copies of the largest key kept, merged: +/// two self-signatures for each User ID, and two with a back-signature each for each subkey. +const MAX_CHECKS: usize = 512; +/// How many packets one block is read for before it is given up on. +const MAX_PACKETS: usize = 4_096; + +/// A key's fingerprint (RFC 9580 §5.5.4): 20 bytes for a version 4 key, 32 for version 6. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Fingerprint(Vec); + +impl Fingerprint { + /// The fingerprint written as hexadecimal digits (spaces and a `0x` in front allowed). + pub fn from_hex(text: &str) -> Option { + let text = text.trim(); + let text = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")).unwrap_or(text); + let digits: Vec = text.chars().filter(|c| !c.is_whitespace()).map(|c| c.to_digit(16)).collect::>()?; + if !matches!(digits.len(), 40 | 64) { + return None; + } + Some(Fingerprint(digits.chunks(2).map(|p| p.iter().fold(0u8, |acc, d| (acc << 4) | (*d as u8))).collect())) + } + + /// The fingerprint's bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Upper-case hexadecimal, no spaces: what [`Fingerprint::from_hex`] reads back. + pub fn to_hex(&self) -> String { + self.0.iter().map(|b| format!("{b:02X}")).collect() + } + + /// Upper-case hexadecimal in groups of four, for an owner to compare by eye. + pub fn grouped(&self) -> String { + let hex = self.to_hex(); + let groups: Vec<&str> = (0..hex.len()).step_by(4).filter_map(|i| hex.get(i..i.saturating_add(4).min(hex.len()))).collect(); + groups.join(" ") + } + + pub(crate) fn of(fp: &pgp::types::Fingerprint) -> Fingerprint { + Fingerprint(fp.as_bytes().to_vec()) + } +} + +impl std::fmt::Display for Fingerprint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_hex()) + } +} + +/// A key id (RFC 9580 §5.5.4): eight bytes that a signature names its signer by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct KeyId(pub [u8; 8]); + +impl KeyId { + /// Upper-case hexadecimal, sixteen digits. + pub fn to_hex(&self) -> String { + self.0.iter().map(|b| format!("{b:02X}")).collect() + } + + pub(crate) fn of(id: &pgp::types::KeyId) -> KeyId { + let mut bytes = [0u8; 8]; + for (to, from) in bytes.iter_mut().zip(id.as_ref()) { + *to = *from; + } + KeyId(bytes) + } +} + +impl std::fmt::Display for KeyId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_hex()) + } +} + +/// A public-key algorithm's name, as an owner may see it in the details. +pub(crate) fn algorithm_name(algorithm: PublicKeyAlgorithm) -> &'static str { + match algorithm { + PublicKeyAlgorithm::RSA | PublicKeyAlgorithm::RSASign | PublicKeyAlgorithm::RSAEncrypt => "RSA", + PublicKeyAlgorithm::DSA => "DSA", + PublicKeyAlgorithm::ECDSA => "ECDSA", + PublicKeyAlgorithm::EdDSALegacy => "EdDSA", + PublicKeyAlgorithm::Ed25519 => "Ed25519", + PublicKeyAlgorithm::Ed448 => "Ed448", + PublicKeyAlgorithm::ECDH => "ECDH", + PublicKeyAlgorithm::X25519 => "X25519", + PublicKeyAlgorithm::X448 => "X448", + PublicKeyAlgorithm::Elgamal | PublicKeyAlgorithm::ElgamalEncrypt => "Elgamal", + _ => "unknown", + } +} + +/// A hash algorithm's name, as an owner may see it in the details. +pub(crate) fn hash_name(hash: HashAlgorithm) -> &'static str { + match hash { + HashAlgorithm::Md5 => "MD5", + HashAlgorithm::Sha1 => "SHA-1", + HashAlgorithm::Ripemd160 => "RIPEMD-160", + HashAlgorithm::Sha224 => "SHA-224", + HashAlgorithm::Sha256 => "SHA-256", + HashAlgorithm::Sha384 => "SHA-384", + HashAlgorithm::Sha512 => "SHA-512", + HashAlgorithm::Sha3_256 => "SHA3-256", + HashAlgorithm::Sha3_512 => "SHA3-512", + _ => "unknown", + } +} + +/// Hashes no longer safe for a signature: MD5, SHA-1 and RIPEMD-160 (RFC 9580 §9.5). +pub(crate) fn is_weak(hash: HashAlgorithm) -> bool { + matches!(hash, HashAlgorithm::Md5 | HashAlgorithm::Sha1 | HashAlgorithm::Ripemd160) +} + +/// Why a key may not be trusted to make a Digital signature at all, whatever its signature +/// says: DSA, and RSA under 2048 bits (RFC 9580 §12.4, §12.5). +pub(crate) fn weak_key(key: &impl KeyDetails) -> Option<&'static str> { + match key.public_params() { + PublicParams::DSA(_) => Some("DSA"), + PublicParams::RSA(params) => { + // An MPI starts with its length in bits (RFC 9580 §3.2): n comes first. + let bytes = params.to_bytes().unwrap_or_default(); + let bits = u16::from_be_bytes([bytes.first().copied().unwrap_or(0), bytes.get(1).copied().unwrap_or(0)]); + (bits < 2048).then_some("RSA under 2048 bits") + } + _ => None, + } +} + +/// A revocation of a key or subkey (RFC 9580 §5.2.3.31): a hard one (compromised, or no reason +/// a reader knows) undoes every signature the key made; a soft one (superseded, retired) only +/// those made at or after it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Revoked { + pub(crate) hard: bool, + pub(crate) at: u64, +} + +impl Revoked { + fn of(sig: &Signature) -> Revoked { + let soft = matches!(sig.revocation_reason_code(), Some(RevocationCode::KeySuperseded | RevocationCode::KeyRetired)); + Revoked { hard: !soft, at: u64::from(created(sig)) } + } + + /// Whether it undoes a signature made at `when` (`None`: unknown, so yes). + pub(crate) fn covers(&self, when: Option) -> bool { + self.hard || when.is_none_or(|t| t >= self.at) + } +} + +/// One key of a [`PublicKey`] that may make Digital signatures: the primary key or a subkey. +#[derive(Clone, Debug)] +pub(crate) struct Signing { + /// `None` for the primary key, else the subkey's place in the key. + pub(crate) subkey: Option, + pub(crate) fingerprint: Fingerprint, + pub(crate) key_id: KeyId, + pub(crate) version: KeyVersion, + pub(crate) algorithm: PublicKeyAlgorithm, + pub(crate) created: u32, + /// When it stops being valid, in seconds since 1970 (its own expiry, or that of the + /// self-signature that binds it); `None` when never. + pub(crate) expires: Option, + /// Its own revocation; a subkey is also undone by the primary key's. + pub(crate) revoked: Option, + /// Whether its self-signature lets it sign (subkeys say so; a primary key that says nothing + /// may, as RFC 9580 §5.2.3.29 leaves it). + pub(crate) may_sign: bool, + /// Why it isn't strong enough to be trusted, if it isn't. + pub(crate) weak: Option<&'static str>, +} + +impl Signing { + /// Whether it was valid at `when`: not before it was made, not after it expired. + pub(crate) fn expired_at(&self, when: u64) -> bool { + self.expires.is_some_and(|end| when >= end) + } +} + +/// A Public key (an OpenPGP certificate, RFC 9580 §10.1) whose self-signatures check. +#[derive(Clone, Debug)] +pub struct PublicKey { + key: SignedPublicKey, + fingerprint: Fingerprint, + addresses: Vec, + /// For each address, when its User ID's self-signature stops being valid. + address_until: Vec>, + /// The primary key first, then each subkey that may sign. + pub(crate) signing: Vec, +} + +/// How many signature checks one call that reads keys may cost in all ([`PublicKey::parse`], +/// [`crate::keys_in_message`]). +pub(crate) const MAX_CHECKS_PER_CALL: usize = 1_024; +/// How many revocations of one key are checked: never cut short; a key with more is refused. +/// Room for two of each part of two merged copies of the largest key kept. +const MAX_REVOCATION_CHECKS: usize = 256; + +/// How many signature checks some reading may cost. +pub(crate) struct Budget { + left: usize, + /// A check was wanted and refused. + pub(crate) starved: bool, +} + +impl Budget { + pub(crate) fn new(left: usize) -> Budget { + Budget { left, starved: false } + } + + /// Whether one more check may be made, counting it. + fn spend(&mut self) -> bool { + if self.left == 0 { + self.starved = true; + return false; + } + self.left -= 1; + true + } +} + +impl PublicKey { + /// Every Public key in `bytes`: armoured (`-----BEGIN PGP PUBLIC KEY BLOCK-----`, as many + /// blocks as there are) or binary, at most 16 in all. A key whose self-signatures don't + /// check, of a version before 4, or larger than a key anybody sends, is left out. + /// [`Error::Unreadable`] when no key at all could be read. + pub fn parse(bytes: &[u8]) -> Result, Error> { + Self::parse_with(bytes, &mut Budget::new(MAX_CHECKS_PER_CALL)) + } + + pub(crate) fn parse_with(bytes: &[u8], total: &mut Budget) -> Result, Error> { + let mut out = Vec::new(); + let blocks = armour::blocks(bytes, Kind::PublicKey, MAX_KEYS_PER_BLOCK, MAX_KEY_BYTES); + if blocks.is_empty() { + if bytes.len() > MAX_KEY_BYTES { + return Err(Error::TooLarge); + } + out.extend(Self::binary(bytes, MAX_KEYS_PER_BLOCK, total)); + } else { + for block in blocks { + let room = MAX_KEYS_PER_BLOCK.saturating_sub(out.len()); + if room == 0 { + break; + } + out.extend(Self::binary(&block, room, total)); + } + } + if out.is_empty() { Err(Error::Unreadable) } else { Ok(out) } + } + + /// At most `room` keys in binary packets. + pub(crate) fn binary(bytes: &[u8], room: usize, total: &mut Budget) -> Vec { + if room == 0 || count_packets(bytes) > MAX_PACKETS { + return Vec::new(); + } + let Some(Ok(keys)) = crate::guard(|| SignedPublicKey::from_bytes_many(bytes).map(|it| it.take(room).collect::>())) else { + return Vec::new(); + }; + keys.into_iter().filter_map(Result::ok).filter_map(|key| crate::guard(|| PublicKey::checked(key, total)).flatten()).collect() + } + + /// The key, once its self-signatures check: its addresses from the User IDs it certified, + /// its revocations, and the keys of it that may sign. `None` when it is no key to trust: + /// nothing certifies it, it is too large, its version is before 4, or a budget ran out + /// before all of it was checked. What is kept of it is what checked and is used: for the + /// key, each User ID and each subkey, its newest self-signature and its strongest + /// revocation; nothing that anybody but the key's owner could have added. + pub(crate) fn checked(key: SignedPublicKey, total: &mut Budget) -> Option { + if !matches!(key.primary_key.version(), KeyVersion::V4 | KeyVersion::V6) + || key.details.users.len() > MAX_USERS + || key.public_subkeys.len() > MAX_SUBKEYS + { + return None; + } + let primary = &key.primary_key; + let mut local = Budget::new(MAX_CHECKS); + let mut revocations = Budget::new(MAX_REVOCATION_CHECKS); + let spend = |budget: &mut Budget, total: &mut Budget| budget.spend() && total.spend(); + + // Revocations first, on a budget of their own, so that none is ever left unchecked. + let mut key_revocation: Option<&Signature> = None; + for sig in key.details.revocation_signatures.iter().chain(&key.details.direct_signatures) { + if sig.typ() == Some(SignatureType::KeyRevocation) + && usable(sig) + && is_self(sig, primary) + && spend(&mut revocations, total) + && sig.verify_key(primary).is_ok() + { + key_revocation = stronger(key_revocation, sig); + } + } + + // The newest valid self-signature on the key or one of its User IDs: its key flags and + // expiry are the primary key's (RFC 9580 §5.2.3.10, §10.1.1). + let mut newest: Option<&Signature> = None; + let mut direct: Option<&Signature> = None; + for sig in &key.details.direct_signatures { + if sig.typ() == Some(SignatureType::Key) + && usable(sig) + && is_self(sig, primary) + && spend(&mut local, total) + && sig.verify_key(primary).is_ok() + { + consider(sig, &mut direct); + } + } + if let Some(sig) = direct { + consider(sig, &mut newest); + } + let (mut addresses, mut address_until, mut users) = (Vec::new(), Vec::new(), Vec::new()); + for user in &key.details.users { + // Every check of a User ID hashes all of it: a longer one than anybody's is left + // out with its signatures, unhashed. + if user.id.id().len() > MAX_USER_ID { + continue; + } + let mut certified: Option<&Signature> = None; + let mut user_revocation: Option<&Signature> = None; + for sig in &user.signatures { + let Some(typ) = sig.typ() else { continue }; + if !usable(sig) || !is_self(sig, primary) { + continue; + } + if typ == SignatureType::CertRevocation { + if spend(&mut revocations, total) && sig.verify_certification(primary, Tag::UserId, &user.id).is_ok() { + consider(sig, &mut user_revocation); + } + continue; + } + let certification = + matches!(typ, SignatureType::CertGeneric | SignatureType::CertPersona | SignatureType::CertCasual | SignatureType::CertPositive); + if certification && spend(&mut local, total) && sig.verify_certification(primary, Tag::UserId, &user.id).is_ok() { + consider(sig, &mut certified); + } + } + if certified.is_some() || user_revocation.is_some() { + users.push(SignedUser::new(user.id.clone(), certified.into_iter().chain(user_revocation).cloned().collect())); + } + let Some(cert) = certified else { continue }; + consider(cert, &mut newest); + // RFC 9580 §5.2.3.31: the newest self-signature on a User ID is what holds, so a + // certification after its revocation takes it back (one at the same second doesn't). + let revoked = user_revocation.is_some_and(|r| created(r) >= created(cert)); + let address = mime::address(&String::from_utf8_lossy(user.id.id())); + if !revoked && !address.is_empty() && !addresses.contains(&address) { + addresses.push(address); + address_until.push(signature_until(cert)); + } + } + let newest = newest?; + let primary_created = primary.created_at().as_secs(); + let mut signing = vec![Signing { + subkey: None, + fingerprint: Fingerprint::of(&primary.fingerprint()), + key_id: KeyId::of(&primary.legacy_key_id()), + version: primary.version(), + algorithm: primary.algorithm(), + created: primary_created, + expires: earlier(expiry(primary_created, newest), signature_until(newest)), + revoked: key_revocation.map(Revoked::of), + may_sign: flags(newest).is_none_or(|f| f.sign()), + weak: weak_key(primary), + }]; + let mut subkeys = Vec::new(); + for sub in &key.public_subkeys { + if !matches!(sub.key.version(), KeyVersion::V4 | KeyVersion::V6) { + continue; + } + let mut binding: Option<&Signature> = None; + let mut sub_revocation: Option<&Signature> = None; + for sig in &sub.signatures { + if !usable(sig) || !is_self(sig, primary) { + continue; + } + match sig.typ() { + Some(SignatureType::SubkeyRevocation) => { + if spend(&mut revocations, total) && sig.verify_subkey_binding(primary, &sub.key).is_ok() { + sub_revocation = stronger(sub_revocation, sig); + } + } + Some(SignatureType::SubkeyBinding) if spend(&mut local, total) && sig.verify_subkey_binding(primary, &sub.key).is_ok() => { + consider(sig, &mut binding); + } + _ => {} + } + } + let Some(binding) = binding else { continue }; + let place = subkeys.len(); + subkeys.push(SignedPublicSubKey::new(sub.key.clone(), std::iter::once(binding).chain(sub_revocation).cloned().collect())); + if !flags(binding).is_some_and(|f| f.sign()) { + continue; + } + // A subkey that signs must sign back that it belongs to this key (§5.2.1): else + // anybody's key could claim somebody else's subkey. + let backed = binding + .embedded_signature() + .is_some_and(|back| spend(&mut local, total) && back.verify_primary_key_binding(&sub.key, primary).is_ok()); + let sub_created = sub.key.created_at().as_secs(); + signing.push(Signing { + subkey: Some(place), + fingerprint: Fingerprint::of(&sub.key.fingerprint()), + key_id: KeyId::of(&sub.key.legacy_key_id()), + version: sub.key.version(), + algorithm: sub.key.algorithm(), + created: sub_created, + expires: earlier(expiry(sub_created, binding), signature_until(binding)), + revoked: sub_revocation.map(Revoked::of), + may_sign: backed, + weak: weak_key(&sub.key), + }); + } + // A revocation that couldn't be checked, or a key not checked whole, is no key at all: + // never a key with a part of it left out. + if revocations.starved || local.starved || total.starved { + return None; + } + let details = SignedKeyDetails::new(key_revocation.into_iter().cloned().collect(), direct.into_iter().cloned().collect(), users, Vec::new()); + let kept = SignedPublicKey::new(key.primary_key.clone(), details, subkeys); + Some(PublicKey { fingerprint: Fingerprint::of(&key.fingerprint()), key: kept, addresses, address_until, signing }) + } + + /// The primary key's fingerprint: what the owner compares, and marks Verified. + pub fn fingerprint(&self) -> &Fingerprint { + &self.fingerprint + } + + /// The primary key's key id. + pub fn key_id(&self) -> KeyId { + KeyId::of(&self.key.primary_key.legacy_key_id()) + } + + /// The addresses of the User IDs the key certified itself (lower case), in its order. + pub fn addresses(&self) -> &[String] { + &self.addresses + } + + /// Whether `address` (an address, or a From field's value) is one of the key's own. + pub fn has_address(&self, address: &str) -> bool { + let address = mime::address(address); + !address.is_empty() && self.addresses.contains(&address) + } + + /// Whether `address` was one of the key's own at `when`: its self-signature hadn't expired. + pub(crate) fn has_address_at(&self, address: &str, when: u64) -> bool { + let address = mime::address(address); + !address.is_empty() && self.addresses.iter().zip(&self.address_until).any(|(a, until)| *a == address && until.is_none_or(|end| when < end)) + } + + /// When the primary key was made, in seconds since 1970. + pub fn created(&self) -> u64 { + u64::from(self.key.primary_key.created_at().as_secs()) + } + + /// The primary key's algorithm, by name ("RSA", "Ed25519", …). + pub fn algorithm(&self) -> &'static str { + algorithm_name(self.key.primary_key.algorithm()) + } + + /// Whether the key's owner revoked it. + pub fn revoked(&self) -> bool { + self.primary().is_some_and(|s| s.revoked.is_some()) + } + + /// When the primary key stops being valid, in seconds since 1970; `None` when never. + pub fn expires(&self) -> Option { + self.primary().and_then(|s| s.expires) + } + + pub(crate) fn primary(&self) -> Option<&Signing> { + self.signing.first() + } + + /// The key as binary OpenPGP packets, to be kept and read back with [`PublicKey::parse`]. + pub fn to_bytes(&self) -> Vec { + self.key.to_bytes().unwrap_or_default() + } + + pub(crate) fn inner(&self) -> &SignedPublicKey { + &self.key + } +} + +/// The earlier of two optional times. +fn earlier(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + } +} + +/// When a signature stops being valid by its own expiry (RFC 9580 §5.2.3.18). +fn signature_until(sig: &Signature) -> Option { + let after = sig.signature_expiration_time()?.as_secs(); + (after > 0).then(|| u64::from(created(sig)).saturating_add(u64::from(after))) +} + +/// Makes `sig` the `newest` when it is at least as new. +fn consider<'a>(sig: &'a Signature, newest: &mut Option<&'a Signature>) { + if newest.is_none_or(|n| created(sig) >= created(n)) { + *newest = Some(sig); + } +} + +/// A signature's creation time, 0 when it has none. +fn created(sig: &Signature) -> u32 { + sig.created().map_or(0, |t| t.as_secs()) +} + +/// A self-signature with a hash that may still be trusted for one: everything but MD5 (SHA-1 +/// self-signatures are still on many keys, and rPGP checks SHA-1 for collision attacks), and +/// of version 4 or 6. +fn usable(sig: &Signature) -> bool { + sig.hash_alg().is_some_and(|h| h != HashAlgorithm::Md5) && matches!(sig.version(), SignatureVersion::V4 | SignatureVersion::V6) +} + +/// Whether `sig` names `key` as its issuer, or names nobody (a hint only: the check decides). +fn is_self(sig: &Signature, key: &impl KeyDetails) -> bool { + let (ids, fps) = (sig.issuer_key_id(), sig.issuer_fingerprint()); + (ids.is_empty() && fps.is_empty()) || ids.iter().any(|id| **id == key.legacy_key_id()) || fps.iter().any(|fp| **fp == key.fingerprint()) +} + +/// The key flags a self-signature states, if it states any. +fn flags(sig: &Signature) -> Option { + sig.config()?.hashed_subpackets.iter().find_map(|p| match &p.data { + SubpacketData::KeyFlags(flags) => Some(flags.clone()), + _ => None, + }) +} + +/// When a key made at `created` stops being valid by self-signature `sig`. +fn expiry(created: u32, sig: &Signature) -> Option { + let after = sig.key_expiration_time()?.as_secs(); + (after > 0).then(|| u64::from(created).saturating_add(u64::from(after))) +} + +/// How many packet headers `bytes` holds, counted without reading them (RFC 9580 §4.2): a +/// cap on work for a block of many tiny packets. +fn count_packets(bytes: &[u8]) -> usize { + packet_tags(bytes, MAX_PACKETS.saturating_add(1)).len() +} + +/// The tags of the first `max` packets of `bytes` (RFC 9580 §4.2), read from their headers +/// alone: as far as a header reads, and a packet of partial length as the last. +pub(crate) fn packet_tags(bytes: &[u8], max: usize) -> Vec { + packets(bytes, max).0 +} + +/// [`packet_tags`], with where the packets read end: the length of `bytes` when they are all +/// there is, short of it when something follows that isn't a packet (or a packet of partial or +/// indeterminate length, or more than `max`), past it when the last says more than is there. +pub(crate) fn packets(bytes: &[u8], max: usize) -> (Vec, usize) { + let mut at = 0usize; + let mut tags = Vec::new(); + while let Some(&tag) = bytes.get(at) { + if tag & 0x80 == 0 || tags.len() >= max { + break; + } + tags.push(if tag & 0x40 != 0 { tag & 0x3f } else { (tag >> 2) & 0x0f }); + let len = |o: usize| bytes.get(at.saturating_add(o)).copied().map_or(0usize, usize::from); + let (header, body) = if tag & 0x40 != 0 { + match len(1) { + 0..=191 => (2, len(1)), + 192..=223 => (3, ((len(1) - 192) << 8) + len(2) + 192), + 255 => (6, (len(2) << 24) | (len(3) << 16) | (len(4) << 8) | len(5)), + // A partial length: the rest is one stream, counted as one. + _ => break, + } + } else { + match tag & 0x03 { + 0 => (2, len(1)), + 1 => (3, (len(1) << 8) | len(2)), + 2 => (5, (len(1) << 24) | (len(2) << 16) | (len(3) << 8) | len(4)), + _ => break, + } + }; + at = at.saturating_add(header).saturating_add(body); + } + (tags, at) +} + +/// Of two revocations of one thing, the one that counts for more: a hard one (it undoes +/// everything), else the earlier. +fn stronger<'a>(a: Option<&'a Signature>, b: &'a Signature) -> Option<&'a Signature> { + Some(match a { + Some(a) if Revoked::of(a).hard => a, + _ if Revoked::of(b).hard => b, + Some(a) if created(a) <= created(b) => a, + _ => b, + }) +} + +/// `a`, then what of `b` isn't in it: each signature once, `a`'s first. Both are what a check +/// kept of a key (signatures its owner made, at most two of a kind for each part), so nothing +/// is cut, and no date anybody wrote decides what stays. +fn union(a: &[Signature], b: &[Signature]) -> Vec { + let mut all = a.to_vec(); + for sig in b { + if !all.contains(sig) { + all.push(sig.clone()); + } + } + all +} + +/// Two checked copies of one certificate as one (RFC 9580 §10.1): every User ID, subkey and +/// self-signature of either, the old copy's first, so that a new copy can only add to it. +fn merge(old: &SignedPublicKey, new: &SignedPublicKey) -> SignedPublicKey { + let mut merged = old.clone(); + merged.details.revocation_signatures = union(&old.details.revocation_signatures, &new.details.revocation_signatures); + merged.details.direct_signatures = union(&old.details.direct_signatures, &new.details.direct_signatures); + for user in &new.details.users { + let users = &mut merged.details.users; + match users.iter().position(|u| u.id == user.id) { + Some(i) => { + if let Some(mine) = users.get_mut(i) { + mine.signatures = union(&mine.signatures, &user.signatures); + } + } + None if users.len() < MAX_USERS => users.push(user.clone()), + None => {} + } + } + for sub in &new.public_subkeys { + let fingerprint = sub.key.fingerprint(); + let subs = &mut merged.public_subkeys; + match subs.iter().position(|s| s.key.fingerprint() == fingerprint) { + Some(i) => { + if let Some(mine) = subs.get_mut(i) { + mine.signatures = union(&mine.signatures, &sub.signatures); + } + } + None if subs.len() < MAX_SUBKEYS => subs.push(sub.clone()), + None => {} + } + } + merged +} + +/// `key` with nothing but its revocations: what a merge must keep when all of it can't be. +fn revocations_only(key: &SignedPublicKey) -> SignedPublicKey { + let mut only = key.clone(); + only.details.direct_signatures.clear(); + only.details.users.clear(); + only.details.user_attributes.clear(); + for sub in &mut only.public_subkeys { + sub.signatures.retain(|s| s.typ() == Some(SignatureType::SubkeyRevocation)); + } + only +} + +/// The Public keys the caller holds: by address, fingerprint and key id. Linear look-ups; the +/// caller decides how many keys it keeps. +#[derive(Clone, Debug, Default)] +pub struct KeyRing { + keys: Vec, +} + +impl KeyRing { + /// An empty ring. + pub fn new() -> KeyRing { + KeyRing::default() + } + + /// Adds `key`. A key already here with the same fingerprint is merged with it: every User + /// ID, subkey and self-signature of both is kept and checked again, so that a copy that + /// lacks a revocation never undoes it and a copy that brings one always counts. Whether + /// the ring changed. + pub fn insert(&mut self, key: PublicKey) -> bool { + let Some(old) = self.keys.iter_mut().find(|k| k.fingerprint == key.fingerprint) else { + self.keys.push(key); + return true; + }; + let recheck = |merged: SignedPublicKey| crate::guard(|| PublicKey::checked(merged, &mut Budget::new(MAX_CHECKS_PER_CALL))).flatten(); + let merged = recheck(merge(&old.key, &key.key)).or_else(|| recheck(merge(&old.key, &revocations_only(&key.key)))); + match merged { + Some(merged) if merged.to_bytes() != old.to_bytes() => { + *old = merged; + true + } + _ => false, + } + } + + /// Every key in the ring. + pub fn keys(&self) -> &[PublicKey] { + &self.keys + } + + /// The keys that have `address` (an address, or a From field's value) as one of their own. + pub fn for_address<'a>(&'a self, address: &str) -> impl Iterator + 'a { + let address = mime::address(address); + self.keys.iter().filter(move |k| !address.is_empty() && k.addresses.contains(&address)) + } + + /// The key with primary fingerprint `fingerprint`. + pub fn get(&self, fingerprint: &Fingerprint) -> Option<&PublicKey> { + self.keys.iter().find(|k| &k.fingerprint == fingerprint) + } + + /// How many keys the ring holds. + pub fn len(&self) -> usize { + self.keys.len() + } + + /// Whether the ring holds no key. + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + /// The signing keys (primary or subkey) named by fingerprint or key id. + pub(crate) fn signers<'a>(&'a self, fps: &'a [Fingerprint], ids: &'a [KeyId]) -> impl Iterator + 'a { + self.keys + .iter() + .flat_map(move |k| k.signing.iter().filter(move |s| fps.contains(&s.fingerprint) || ids.contains(&s.key_id)).map(move |s| (k, s))) + } +} + +impl FromIterator for KeyRing { + fn from_iter>(iter: I) -> KeyRing { + let mut ring = KeyRing::new(); + for key in iter { + ring.insert(key); + } + ring + } +} diff --git a/crates/openpgp/src/keys_tests.rs b/crates/openpgp/src/keys_tests.rs new file mode 100644 index 000000000..3cc53b0bc --- /dev/null +++ b/crates/openpgp/src/keys_tests.rs @@ -0,0 +1,332 @@ +//! Keys over time: revocations that are never lost, expiry of the primary key and of +//! self-signatures, and the cost of reading keys. + +use pgp::composed::{SignedPublicKey, SignedSecretKey}; +use pgp::packet::{RevocationCode, SignatureType, SubpacketData}; +use pgp::ser::Serialize; +use pgp::types::{Duration, Password, Tag}; + +use crate::fixtures::*; +use crate::keys::Budget; +use crate::{CantCheck, Fingerprint, KeyRing, NotVerified, Outcome, PublicKey, verify}; + +const ALICE: &str = "Alice "; + +fn yes(_: &Fingerprint, _: &str) -> bool { + true +} + +fn outcome(message: &[u8], from: &str, keys: &KeyRing) -> Outcome { + verify(message, from, keys, LATER, yes).unwrap().outcome +} + +fn revoked(outcome: &Outcome) -> bool { + matches!(outcome, Outcome::CantCheck { why: CantCheck::KeyRevoked, .. }) +} + +/// `secret`'s public key revoked at `at`, for `reason` (none: no reason given). +fn key_revoked(secret: &SignedSecretKey, at: u32, reason: Option) -> SignedPublicKey { + let mut key = SignedPublicKey::from(secret.clone()); + let extra = reason.map(|r| SubpacketData::RevocationReason(r, Vec::new().into())).into_iter().collect(); + let sig = config(&secret.primary_key, SignatureType::KeyRevocation, at, extra) + .sign_key(&secret.primary_key, &Password::empty(), &key.primary_key) + .unwrap(); + key.details.revocation_signatures.push(sig); + key +} + +/// `secret`'s public key with its first subkey revoked at `at`. +fn subkey_revoked(secret: &SignedSecretKey, at: u32) -> SignedPublicKey { + let mut key = SignedPublicKey::from(secret.clone()); + let sig = config(&secret.primary_key, SignatureType::SubkeyRevocation, at, vec![]) + .sign_subkey_binding(&secret.primary_key, &key.primary_key, &Password::empty(), &key.public_subkeys[0].key) + .unwrap(); + key.public_subkeys[0].signatures.push(sig); + key +} + +/// `secret`'s public key with a new self-signature on its User ID number `user`, made at `at`. +fn recertified(secret: &SignedSecretKey, key: &mut SignedPublicKey, user: usize, at: u32, extra: Vec) { + let id = key.details.users[user].id.clone(); + let sig = config(&secret.primary_key, SignatureType::CertPositive, at, extra) + .sign_certification(&secret.primary_key, &key.primary_key, &Password::empty(), Tag::UserId, &id) + .unwrap(); + key.details.users[user].signatures.push(sig); +} + +#[test] +fn a_copy_without_the_revocation_never_undoes_it() { + let alice = secret(ALICE, Make::default()); + let mut keys = ring_of(&key_revoked(&alice, made(&alice), None)); + assert!(!keys.insert(public(&alice)), "nothing new in the copy"); + assert!(keys.keys()[0].revoked()); + assert!(revoked(&outcome(&signed_message(&alice, pgp::crypto::hash::HashAlgorithm::Sha256), ALICE, &keys))); +} + +#[test] +fn a_copy_that_brings_a_revocation_always_counts() { + let alice = secret(ALICE, Make::default()); + let mut keys = ring_of(&SignedPublicKey::from(alice.clone())); + let copy = PublicKey::parse(&key_revoked(&alice, made(&alice), None).to_bytes().unwrap()).unwrap().remove(0); + assert!(keys.insert(copy)); + assert!(keys.keys()[0].revoked()); +} + +#[test] +fn a_copy_with_a_subkey_revocation_stripped_never_undoes_it() { + let alice = secret(ALICE, Make { subkey: true, ..Make::default() }); + let mut keys = ring_of(&subkey_revoked(&alice, made(&alice))); + let message = signed_at(&alice, made(&alice) + 100); + assert!(revoked(&outcome(&message, ALICE, &keys))); + keys.insert(public(&alice)); + assert!(revoked(&outcome(&message, ALICE, &keys))); +} + +#[test] +fn the_primary_keys_expiry_and_revocation_hold_for_its_subkeys() { + let alice = secret(ALICE, Make { subkey: true, ..Make::default() }); + let t = made(&alice); + // The newest self-signature says the key expires 50 seconds after it was made. + let mut key = SignedPublicKey::from(alice.clone()); + recertified(&alice, &mut key, 0, t + 10, vec![SubpacketData::KeyExpirationTime(Duration::from_secs(50))]); + let keys = ring_of(&key); + assert!(matches!(outcome(&signed_at(&alice, t + 20), ALICE, &keys), Outcome::Verified(_))); + assert!(matches!(outcome(&signed_at(&alice, t + 100), ALICE, &keys), Outcome::CantCheck { why: CantCheck::KeyExpired, .. })); + // A revoked primary key undoes its subkey's signatures. + assert!(revoked(&outcome(&signed_at(&alice, t + 20), ALICE, &ring_of(&key_revoked(&alice, t + 50, None))))); +} + +#[test] +fn a_soft_revocation_undoes_only_what_was_signed_after_it() { + let alice = secret(ALICE, Make::default()); + let t = made(&alice); + for reason in [RevocationCode::KeySuperseded, RevocationCode::KeyRetired] { + let keys = ring_of(&key_revoked(&alice, t + 50, Some(reason))); + assert!(matches!(outcome(&signed_at(&alice, t + 20), ALICE, &keys), Outcome::Verified(_))); + assert!(revoked(&outcome(&signed_at(&alice, t + 100), ALICE, &keys))); + } + // Compromised, or no reason: everything it ever signed. + for reason in [Some(RevocationCode::KeyCompromised), None] { + let keys = ring_of(&key_revoked(&alice, t + 50, reason)); + assert!(revoked(&outcome(&signed_at(&alice, t + 20), ALICE, &keys))); + } +} + +#[test] +fn an_expired_user_id_self_signature_takes_its_address_away() { + let alice = secret(ALICE, Make { alias: true, ..Make::default() }); + let t = made(&alice); + let mut key = SignedPublicKey::from(alice.clone()); + let alias = key.details.users.iter().position(|u| u.id.id().starts_with(b"Alias")).unwrap(); + // The alias's newest self-signature expires 50 seconds after it was made; then a newer one + // on the primary User ID that doesn't, so that the key itself stays valid. + recertified(&alice, &mut key, alias, t + 10, vec![SubpacketData::SignatureExpirationTime(Duration::from_secs(50))]); + recertified(&alice, &mut key, 1 - alias, t + 20, vec![]); + let keys = ring_of(&key); + let from_alias = |at| outcome(&signed_at(&alice, at), "alias@example.org", &keys); + assert!(matches!(from_alias(t + 30), Outcome::Verified(_))); + let Outcome::Valid { why, .. } = from_alias(t + 100) else { panic!() }; + assert_eq!(why, NotVerified { address_differs: true, ..NotVerified::default() }); +} + +#[test] +fn an_expired_subkey_binding_stops_the_subkey() { + let alice = secret(ALICE, Make { subkey: true, ..Make::default() }); + let t = made(&alice); + let mut key = SignedPublicKey::from(alice.clone()); + let back = key.public_subkeys[0].signatures[0].embedded_signature().unwrap().clone(); + let mut flags = pgp::packet::KeyFlags::default(); + flags.set_sign(true); + let extra = vec![ + SubpacketData::KeyFlags(flags), + SubpacketData::SignatureExpirationTime(Duration::from_secs(50)), + SubpacketData::EmbeddedSignature(Box::new(back)), + ]; + let binding = config(&alice.primary_key, SignatureType::SubkeyBinding, t + 10, extra) + .sign_subkey_binding(&alice.primary_key, &key.primary_key, &Password::empty(), &key.public_subkeys[0].key) + .unwrap(); + key.public_subkeys[0].signatures.push(binding); + let keys = ring_of(&key); + assert!(matches!(outcome(&signed_at(&alice, t + 30), ALICE, &keys), Outcome::Verified(_))); + assert!(matches!(outcome(&signed_at(&alice, t + 100), ALICE, &keys), Outcome::CantCheck { why: CantCheck::KeyExpired, .. })); +} + +#[test] +fn rsa_under_2048_bits_is_too_weak_to_trust() { + use pgp::composed::SignedKeyDetails; + use pgp::crypto::public_key::PublicKeyAlgorithm; + use pgp::packet::{PubKeyInner, PublicKey as Packet, SecretKey, UserId}; + use pgp::types::{KeyVersion, PlainSecretParams, PublicParams, RsaPublicParams, SecretParams, SignedUser, Timestamp}; + use rand::SeedableRng; + // rPGP won't make a key this small, so it is put together from one the rsa crate makes. + let private = rsa::RsaPrivateKey::new(&mut rand::rngs::StdRng::seed_from_u64(3), 1024).unwrap(); + let t = 1_704_067_200; + let public_params = PublicParams::RSA(RsaPublicParams { key: private.to_public_key() }); + let inner = PubKeyInner::new(KeyVersion::V4, PublicKeyAlgorithm::RSA, Timestamp::from_secs(t), None, public_params).unwrap(); + let packet = Packet::from_inner(inner).unwrap(); + let secret = SecretKey::new(packet.clone(), SecretParams::Plain(PlainSecretParams::RSA(private.into()))).unwrap(); + let id = UserId::from_str(Default::default(), ALICE).unwrap(); + let mut flags = pgp::packet::KeyFlags::default(); + flags.set_sign(true); + flags.set_certify(true); + let cert = config(&secret, SignatureType::CertPositive, t, vec![SubpacketData::KeyFlags(flags)]) + .sign_certification(&secret, &packet, &Password::empty(), Tag::UserId, &id) + .unwrap(); + let key = SignedPublicKey::new(packet, SignedKeyDetails::new(vec![], vec![], vec![SignedUser::new(id, vec![cert])], vec![]), vec![]); + let keys = ring_of(&key); + let sig = config(&secret, SignatureType::Binary, t + 1, vec![]).sign(&secret, &Password::empty(), SIGNED_PART.as_bytes()).unwrap(); + let armour = pgp::composed::DetachedSignature::new(sig).to_armored_string(Default::default()).unwrap(); + let out = outcome(&pgp_mime("alice@example.org", SIGNED_PART, &armour), ALICE, &keys); + assert!(matches!(out, Outcome::CantCheck { why: CantCheck::WeakAlgorithm("RSA under 2048 bits"), .. }), "{out:?}"); +} + +#[test] +fn reading_keys_stops_when_its_budget_or_room_runs_out() { + let block = armoured_public(&secret(ALICE, Make::default())); + let many = block.repeat(20); + // At most 16 keys from one call, however many blocks. + assert_eq!(PublicKey::parse(many.as_bytes()).unwrap().len(), 16); + // A budget of checks shared by everything one call reads: the key that would overspend it is + // left out, whole, and so is every key after it. + let mut budget = Budget::new(2); + let read = PublicKey::parse_with(block.repeat(4).as_bytes(), &mut budget).unwrap(); + assert_eq!(read.len(), 2); + assert!(budget.starved); +} + +/// A signature of `typ` that names Alice's key as its issuer but is made by Mallory's: junk +/// anybody can add to a copy of Alice's key. +fn forged( + alice: &SignedSecretKey, + mallory: &SignedSecretKey, + typ: SignatureType, + at: u32, + extra: Vec, +) -> pgp::packet::SignatureConfig { + use pgp::types::KeyDetails; + let mut config = config(&mallory.primary_key, typ, at, extra); + for p in &mut config.hashed_subpackets { + if matches!(p.data, SubpacketData::IssuerFingerprint(_)) { + *p = pgp::packet::Subpacket::regular(SubpacketData::IssuerFingerprint(alice.primary_key.fingerprint())).unwrap(); + } + } + config +} + +/// Inserts `bytes` as a copy of a key, when they read as one. +fn offer(keys: &mut KeyRing, bytes: &[u8]) { + for key in PublicKey::parse(bytes).unwrap_or_default() { + keys.insert(key); + } +} + +#[test] +fn a_copy_with_junk_subkey_bindings_from_the_future_never_pushes_out_the_real_one() { + let alice = secret(ALICE, Make { subkey: true, ..Make::default() }); + let mallory = secret("Mallory ", Make { seed: 6, ..Make::default() }); + let mut copy = SignedPublicKey::from(alice.clone()); + let mut flags = pgp::packet::KeyFlags::default(); + flags.set_sign(true); + for i in 0..64 { + // Dated 2096. + let junk = forged(&alice, &mallory, SignatureType::SubkeyBinding, 4_000_000_000 + i, vec![SubpacketData::KeyFlags(flags.clone())]) + .sign_subkey_binding(&mallory.primary_key, ©.primary_key, &Password::empty(), ©.public_subkeys[0].key) + .unwrap(); + copy.public_subkeys[0].signatures.push(junk); + } + let junk = copy.to_bytes().unwrap(); + let message = signed_at(&alice, made(&alice) + 10); + // Either way round, the real key stays whole, and nothing of the junk is kept. + for first_real in [true, false] { + let mut keys = KeyRing::new(); + if first_real { + keys.insert(public(&alice)); + offer(&mut keys, &junk); + } else { + offer(&mut keys, &junk); + keys.insert(public(&alice)); + } + assert!(matches!(outcome(&message, ALICE, &keys), Outcome::Verified(_)), "real first: {first_real}"); + assert_eq!(keys.keys()[0].to_bytes(), public(&alice).to_bytes()); + } +} + +#[test] +fn a_copy_that_costs_more_checks_than_a_key_may_is_refused_not_cut_short() { + let alice = secret(ALICE, Make { subkey: true, ..Make::default() }); + let mallory = secret("Mallory ", Make { seed: 6, ..Make::default() }); + let mut copy = SignedPublicKey::from(alice.clone()); + // Junk User IDs with junk certifications before the subkey: checking them all would leave + // no checks for the subkey's binding. + for n in 0..5 { + let id = pgp::packet::UserId::from_str(Default::default(), format!("Alice {n} ")).unwrap(); + let sigs = (0..110) + .map(|i| { + forged(&alice, &mallory, SignatureType::CertPositive, made(&alice) + i, vec![]) + .sign_certification(&mallory.primary_key, ©.primary_key, &Password::empty(), Tag::UserId, &id) + .unwrap() + }) + .collect(); + copy.details.users.push(pgp::types::SignedUser::new(id, sigs)); + } + let junk = copy.to_bytes().unwrap(); + assert!(PublicKey::parse(&junk).is_err(), "a key not checked whole is no key"); + let mut keys = KeyRing::new(); + keys.insert(public(&alice)); + offer(&mut keys, &junk); + assert!(matches!(outcome(&signed_at(&alice, made(&alice) + 10), ALICE, &keys), Outcome::Verified(_))); +} + +#[test] +fn a_key_whose_revocations_or_the_calls_budget_run_out_is_refused() { + let alice = secret(ALICE, Make { subkey: true, ..Make::default() }); + let mallory = secret("Mallory ", Make { seed: 6, ..Make::default() }); + // More revocations than are checked: one left unchecked could be the real one. + let mut copy = SignedPublicKey::from(alice.clone()); + for i in 0..257 { + let junk = forged(&alice, &mallory, SignatureType::KeyRevocation, made(&alice) + i, vec![]) + .sign_key(&mallory.primary_key, &Password::empty(), ©.primary_key) + .unwrap(); + copy.details.revocation_signatures.push(junk); + } + assert!(PublicKey::parse(©.to_bytes().unwrap()).is_err()); + // The call's budget runs out after the User ID, before the subkey. + let bytes = SignedPublicKey::from(alice).to_bytes().unwrap(); + assert!(PublicKey::parse_with(&bytes, &mut Budget::new(1)).is_err()); + assert!(PublicKey::parse_with(&bytes, &mut Budget::new(3)).is_ok()); +} + +#[test] +fn a_user_id_certified_again_after_its_revocation_is_the_keys_again() { + let alice = secret(ALICE, Make::default()); + let t = made(&alice); + let mut key = SignedPublicKey::from(alice.clone()); + let id = key.details.users[0].id.clone(); + let revocation = config(&alice.primary_key, SignatureType::CertRevocation, t + 10, vec![]) + .sign_certification(&alice.primary_key, &key.primary_key, &Password::empty(), Tag::UserId, &id) + .unwrap(); + key.details.users[0].signatures.push(revocation); + let revoked = ring_of(&key); + assert!(revoked.keys()[0].addresses().is_empty(), "revoked"); + // Certified again in the same second as the revocation: which came last isn't known, so it + // stays revoked. + let mut tie = key.clone(); + recertified(&alice, &mut tie, 0, t + 10, vec![]); + assert!(ring_of(&tie).keys()[0].addresses().is_empty(), "same second"); + recertified(&alice, &mut key, 0, t + 20, vec![]); + assert_eq!(ring_of(&key).keys()[0].addresses(), ["alice@example.org".to_string()]); +} + +#[test] +fn a_user_id_longer_than_any_real_one_is_left_out_unhashed() { + let alice = secret(ALICE, Make::default()); + let mut key = SignedPublicKey::from(alice.clone()); + let long = format!("{} ", "x".repeat(5_000)); + let id = pgp::packet::UserId::from_str(Default::default(), &long).unwrap(); + let cert = config(&alice.primary_key, SignatureType::CertPositive, made(&alice), vec![]) + .sign_certification(&alice.primary_key, &key.primary_key, &Password::empty(), Tag::UserId, &id) + .unwrap(); + key.details.users.push(pgp::types::SignedUser::new(id, vec![cert])); + let keys = ring_of(&key); + assert_eq!(keys.keys()[0].addresses(), ["alice@example.org".to_string()]); +} diff --git a/crates/openpgp/src/lib.rs b/crates/openpgp/src/lib.rs new file mode 100644 index 000000000..01ec0af43 --- /dev/null +++ b/crates/openpgp/src/lib.rs @@ -0,0 +1,257 @@ +//! OpenPGP for mail: checking a message's Digital signature and reading the Public keys it +//! carries. No I/O, no clock and no keys of its own: the caller hands in the message's +//! original bytes (what `Message::original` keeps), the Public keys it keeps +//! ([`KeyRing`]), the time, and whether the owner has Verified a key; so it builds for the +//! browser too, which checks but never signs. +//! +//! - [`verify`] checks a PGP/MIME message (`multipart/signed; +//! protocol="application/pgp-signature"`, RFC 3156 §5) over the signed part's canonical +//! bytes, or a cleartext-signed ("inline") text (RFC 9580 §7), and says which of the four +//! results it is ([`Outcome`]). OpenPGP has no certificate authorities and this crate no web of +//! trust: a key is Verified only when the owner marked it after comparing its fingerprint +//! or their GnuPG keyring trusts it fully, both of which the caller knows and this doesn't. +//! - [`keys_in_message`] reads the keys a message carries: attached `application/pgp-keys` +//! parts and the `Autocrypt:` header (Autocrypt Level 1), for the caller to keep by +//! address. Nothing is fetched or sent. +//! +//! The cryptography is rPGP's (`pgp`, MIT OR Apache-2.0); the MIME around it, the armour and the +//! Cleartext Signature Framework are read here, from RFC 2045/2046, RFC 3156 and RFC 9580, so +//! that every size is capped by this crate. Everything from outside is hostile: nothing here +//! panics on any bytes (a panic inside rPGP is caught and is an unreadable signature), work is +//! linear in the input, and sizes, counts and nesting are capped. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod armour; +mod autocrypt; +mod check; +mod keys; +mod mime; +pub mod secret; + +#[cfg(test)] +mod armour_tests; +#[cfg(test)] +mod autocrypt_tests; +#[cfg(test)] +mod coverage_tests; +#[cfg(test)] +#[path = "fixtures_tests.rs"] +mod fixtures; +#[cfg(test)] +mod keys_tests; +#[cfg(test)] +mod mime_tests; +#[cfg(test)] +mod prop_tests; +#[cfg(test)] +mod secret_tests; +#[cfg(test)] +mod tests; +#[cfg(test)] +mod vector_tests; + +pub use autocrypt::{Autocrypt, parse_autocrypt}; +pub use check::{CantCheck, Checked, Coverage, Issuer, NotVerified, Outcome, Signer}; +pub use keys::{Fingerprint, KeyId, KeyRing, PublicKey}; + +/// The largest message looked at, as large as the largest any server is fetched for. +pub const MAX_MESSAGE: usize = 150 << 20; +/// How deep into wrapping `multipart/mixed` parts the signed part is looked for, and how deep +/// into multiparts an attached key. +const MAX_DEPTH: usize = 8; +/// How many parts of a message are looked at for attached keys. +const MAX_PARTS: usize = 256; + +/// Why key material couldn't be read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// Not OpenPGP key material, or none of it checks. + #[error("no Public key that can be read")] + Unreadable, + /// Larger than any real key or header. + #[error("larger than a Public key can be")] + TooLarge, + /// More than one key where only one is taken (a secret key to import). + #[error("more than one key")] + SeveralKeys, +} + +/// The address of a From field's value, lower case: as [`verify`] takes it. Empty when the +/// field names no one address (none, or several). +pub fn from_address(value: &str) -> String { + mime::address(value).trim().to_lowercase() +} + +/// Runs `f`, giving `None` instead of letting a panic out of it: rPGP is a large library +/// reading hostile bytes, and a panic in it must not take the program down. Where panics abort (the +/// browser build) this is only a function call. +pub(crate) fn guard(f: impl FnOnce() -> T) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok() +} + +/// Checks the OpenPGP Digital signature of the message `original` (its bytes as they came), +/// sent from `from` (the From field's value or its address), against `keys`, at `now` (seconds +/// since 1970). `verified(fingerprint, address)` says whether the owner has Verified that key +/// for that address: marked it after comparing its fingerprint, or their GnuPG keyring +/// trusts it fully with that User ID. +/// +/// `None` when the message has no OpenPGP Digital signature (an S/MIME one isn't this crate's, +/// and one inside `multipart/alternative` is none: the other alternative would be shown +/// unsigned). Never fails: what can't be read is [`CantCheck::Unreadable`]. A message is +/// [`Outcome::Verified`] only when the signature covers all of it ([`Coverage::whole`]); one +/// that covers part of it is at best [`Outcome::Valid`] with [`NotVerified::partly_signed`]. +pub fn verify(original: &[u8], from: &str, keys: &KeyRing, now: u64, verified: impl Fn(&Fingerprint, &str) -> bool) -> Option { + if original.len() > MAX_MESSAGE { + return looks_signed(original).then(|| Checked { outcome: Outcome::cant(CantCheck::TooLarge), coverage: Coverage::default() }); + } + let cx = check::Context { + from: mime::address(from), + keys, + now, + verified: &verified, + verifications: std::cell::Cell::new(0), + data_passes: std::cell::Cell::new(0), + }; + let (headers, body) = mime::split(original); + let mut checked = find(&headers, body, &mut Vec::new(), &cx)?; + if !checked.coverage.whole { + checked.outcome = match checked.outcome { + Outcome::Verified(signer) => Outcome::Valid { signer, why: NotVerified { partly_signed: true, ..NotVerified::default() } }, + Outcome::Valid { signer, why } => Outcome::Valid { signer, why: NotVerified { partly_signed: true, ..why } }, + other => other, + }; + } + Some(checked) +} + +/// Whether a message too large to check is a signed one, told cheaply: a PGP/MIME type at the +/// top, or a cleartext-signed block anywhere in it. +fn looks_signed(original: &[u8]) -> bool { + let (headers, _) = mime::split(original.get(..1 << 20).unwrap_or(original)); + let content_type = headers.content_type(); + let pgp_mime = content_type.value == "multipart/signed" + && content_type.get("protocol").is_some_and(|p| p.trim().eq_ignore_ascii_case("application/pgp-signature")); + pgp_mime || original.windows(34).any(|w| w == b"-----BEGIN PGP SIGNED MESSAGE-----") +} + +/// The signed part of a message at `path`, checked. +fn find(headers: &mime::Headers, body: &[u8], path: &mut Vec, cx: &check::Context<'_>) -> Option { + let content_type = headers.content_type(); + match content_type.value.as_str() { + "multipart/signed" => { + let protocol = content_type.get("protocol").unwrap_or_default().trim().to_ascii_lowercase(); + if protocol != "application/pgp-signature" { + return None; + } + // Only a PGP/MIME message at the top is all of the message. + let coverage = Coverage { whole: path.is_empty(), path: path.clone(), span: None }; + let Some(boundary) = content_type.get("boundary").filter(|b| !b.is_empty()) else { + return Some(Checked { outcome: Outcome::cant(CantCheck::Unreadable), coverage }); + }; + let parts = mime::parts(body, boundary, 3); + // RFC 1847 §2.1: exactly two parts, what was signed and the signature. + let [signed, signature] = parts.as_slice() else { return Some(Checked { outcome: Outcome::cant(CantCheck::Unreadable), coverage }) }; + let (sig_headers, sig_body) = mime::split(signature); + let sig_bytes = mime::decode(&sig_headers.encoding(), sig_body); + let packets = signature_packets(&sig_bytes); + // RFC 3156 §5: the signed part with its header, in canonical form (CR LF). + Some(Checked { outcome: check::check(&packets, &mime::crlf(signed), cx), coverage }) + } + "text/plain" => { + let text = mime::decode(&headers.encoding(), body); + let clear = armour::cleartext(&text, check::MAX_SIGNATURE_BYTES)?; + let coverage = Coverage { whole: path.is_empty() && clear.alone, path: path.clone(), span: Some(clear.span) }; + // Words shown in the block that aren't signed: nothing in it can be vouched for. + let outcome = if clear.well_formed { check::check(&clear.signature, &clear.signed, cx) } else { Outcome::cant(CantCheck::Unreadable) }; + Some(Checked { outcome, coverage }) + } + // A mailing list's wrapper: its first part, which is then only part of the message. + // Never into `multipart/alternative`, whose other alternatives would be shown unsigned. + "multipart/mixed" if path.len() < MAX_DEPTH => { + let boundary = content_type.get("boundary").filter(|b| !b.is_empty())?; + let first = *mime::parts(body, boundary, 1).first()?; + let (part_headers, part_body) = mime::split(first); + path.push(0); + find(&part_headers, part_body, path, cx) + } + _ => None, + } +} + +/// The signature packets of a PGP/MIME signature part: armoured, as RFC 3156 §5 says, or +/// binary from a writer that didn't armour them. Empty when it holds neither, which checks as +/// an unreadable signature: the message is still a signed one. +fn signature_packets(bytes: &[u8]) -> Vec { + let blocks = armour::blocks(bytes, armour::Kind::Signature, 1, check::MAX_SIGNATURE_BYTES); + match blocks.into_iter().next() { + Some(block) => block, + None if bytes.first().is_some_and(|b| b & 0x80 != 0) && bytes.len() <= check::MAX_SIGNATURE_BYTES => bytes.to_vec(), + None => Vec::new(), + } +} + +/// The Public keys a message carries, for the caller to keep by address. +#[derive(Clone, Debug, Default)] +pub struct Found { + /// Keys in attached `application/pgp-keys` parts, their self-signatures checked. + pub attached: Vec, + /// The sender's `Autocrypt:` header, when there is exactly one that reads and it is for + /// the From address (Autocrypt Level 1 §2.3). + pub autocrypt: Option, +} + +/// How many keys are taken from one message's attachments. +const MAX_ATTACHED: usize = 16; +/// How many `Autocrypt:` headers of one message are looked at. +const MAX_AUTOCRYPT: usize = 8; + +/// Reads the Public keys in the message `original`: attached key parts and the `Autocrypt:` +/// header. Nothing is fetched or sent; what can't be read is left out. All of it together costs +/// at most a fixed number of signature checks. +pub fn keys_in_message(original: &[u8]) -> Found { + let mut found = Found::default(); + if original.len() > MAX_MESSAGE { + return found; + } + let mut total = keys::Budget::new(keys::MAX_CHECKS_PER_CALL); + let (headers, body) = mime::split(original); + // One From mailbox, or none counts. + let from = headers.get("from").map(mime::address).unwrap_or_default(); + // Level 1 §2.3: not in a report (a bounce quotes the message it is about), and more than + // one header for the address is as good as none. The attributes are read first, and the + // key, which costs, only of the one header that counts. + if headers.content_type().value != "multipart/report" && !from.is_empty() { + let mut headers_for_from = + headers.all("autocrypt").take(MAX_AUTOCRYPT).filter_map(|v| autocrypt::attributes(v).ok()).filter(|a| a.address == from); + if let (Some(one), None) = (headers_for_from.next(), headers_for_from.next()) { + found.autocrypt = one.read(&mut total).ok(); + } + } + let mut parts = MAX_PARTS; + attached(&headers, body, 0, &mut parts, &mut total, &mut found.attached); + found +} + +fn attached(headers: &mime::Headers, body: &[u8], depth: usize, parts: &mut usize, total: &mut keys::Budget, out: &mut Vec) { + if *parts == 0 || out.len() >= MAX_ATTACHED { + return; + } + *parts = parts.saturating_sub(1); + let content_type = headers.content_type(); + let media = content_type.value.as_str(); + if media == "application/pgp-keys" { + let bytes = mime::decode(&headers.encoding(), body); + if let Ok(keys) = PublicKey::parse_with(&bytes, total) { + let room = MAX_ATTACHED.saturating_sub(out.len()); + out.extend(keys.into_iter().take(room)); + } + return; + } + if media.starts_with("multipart/") && depth < MAX_DEPTH { + let Some(boundary) = content_type.get("boundary").filter(|b| !b.is_empty()) else { return }; + for part in mime::parts(body, boundary, *parts) { + let (part_headers, part_body) = mime::split(part); + attached(&part_headers, part_body, depth.saturating_add(1), parts, total, out); + } + } +} diff --git a/crates/openpgp/src/mime.rs b/crates/openpgp/src/mime.rs new file mode 100644 index 000000000..743ffffe4 --- /dev/null +++ b/crates/openpgp/src/mime.rs @@ -0,0 +1,400 @@ +//! Just enough MIME to find what OpenPGP signed, written from RFC 2045, RFC 2046 and RFC 3156: +//! a part's header fields and parameters, the parts of a multipart with their bytes exactly as +//! they came, and the two transfer encodings that carry text. Everything works on borrowed bytes +//! of the original message and is linear in them. + +/// How many header fields of one part are read; the rest are ignored. +const MAX_FIELDS: usize = 1_000; + +/// A part's header fields, names in lower case, folded lines joined. +#[derive(Debug, Default)] +pub(crate) struct Headers { + fields: Vec<(String, String)>, +} + +impl Headers { + /// The first field called `name` (lower case). + pub(crate) fn get(&self, name: &str) -> Option<&str> { + self.fields.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) + } + + /// Every field called `name` (lower case), in order. + pub(crate) fn all<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.fields.iter().filter(move |(n, _)| n == name).map(|(_, v)| v.as_str()) + } + + /// The part's media type and its parameters (`text/plain` when it says none, RFC 2045 §5.2). + pub(crate) fn content_type(&self) -> Params { + let params = self.get("content-type").map(Params::parse).unwrap_or_default(); + if params.value.is_empty() { Params { value: "text/plain".into(), ..params } } else { params } + } + + /// The part's transfer encoding, in lower case. + pub(crate) fn encoding(&self) -> String { + self.get("content-transfer-encoding").map(|e| e.trim().to_ascii_lowercase()).unwrap_or_default() + } +} + +/// Splits a part into its header fields and its body: the body starts after the first empty +/// line (or is empty when there is none). +pub(crate) fn split(part: &[u8]) -> (Headers, &[u8]) { + let (head, body) = match blank_line(part) { + Some((head_end, body_start)) => (part.get(..head_end).unwrap_or_default(), part.get(body_start..).unwrap_or_default()), + None => (part, &[][..]), + }; + (parse_fields(head), body) +} + +/// Where the header of `part` ends and where its body starts. +fn blank_line(part: &[u8]) -> Option<(usize, usize)> { + // A part that starts with an empty line has no header fields at all. + if part.starts_with(b"\r\n") { + return Some((0, 2)); + } + if part.starts_with(b"\n") { + return Some((0, 1)); + } + let mut i = 0usize; + while let Some(off) = part.get(i..).and_then(|rest| rest.iter().position(|b| *b == b'\n')) { + let nl = i.saturating_add(off); + let next = nl.saturating_add(1); + match part.get(next..) { + Some(rest) if rest.starts_with(b"\r\n") => return Some((next, next.saturating_add(2))), + Some(rest) if rest.starts_with(b"\n") => return Some((next, next.saturating_add(1))), + Some(_) => i = next, + None => break, + } + } + None +} + +fn parse_fields(head: &[u8]) -> Headers { + let mut fields: Vec<(String, String)> = Vec::new(); + for raw in head.split(|b| *b == b'\n') { + let line = raw.strip_suffix(b"\r").unwrap_or(raw); + if line.is_empty() { + continue; + } + if matches!(line.first(), Some(b' ' | b'\t')) { + if let Some((_, value)) = fields.last_mut() { + value.push(' '); + value.push_str(String::from_utf8_lossy(line).trim()); + } + continue; + } + if fields.len() >= MAX_FIELDS { + continue; + } + let Some(colon) = line.iter().position(|b| *b == b':') else { continue }; + let name = String::from_utf8_lossy(line.get(..colon).unwrap_or_default()).trim().to_ascii_lowercase(); + let value = String::from_utf8_lossy(line.get(colon.saturating_add(1)..).unwrap_or_default()).trim().to_string(); + if !name.is_empty() { + fields.push((name, value)); + } + } + Headers { fields } +} + +/// A header value with `;` parameters: `multipart/signed; protocol="application/pgp-signature"`. +#[derive(Debug, Default, Clone)] +pub(crate) struct Params { + /// The value before the first `;`, in lower case. + pub(crate) value: String, + params: Vec<(String, String)>, +} + +impl Params { + pub(crate) fn parse(text: &str) -> Params { + let mut pieces = Pieces { text, at: 0 }; + let value = pieces.next().unwrap_or_default().trim().to_ascii_lowercase(); + let mut params = Vec::new(); + for piece in pieces { + let Some((name, raw)) = piece.split_once('=') else { continue }; + let name = name.trim().to_ascii_lowercase(); + if !name.is_empty() && params.len() < MAX_FIELDS { + params.push((name, unquote(raw.trim()))); + } + } + Params { value, params } + } + + /// The parameter called `name` (lower case). + pub(crate) fn get(&self, name: &str) -> Option<&str> { + self.params.iter().find(|(n, _)| n == name).map(|(_, v)| v.as_str()) + } +} + +/// The `;`-separated pieces of a header value, a `;` inside quotes not counting. +struct Pieces<'a> { + text: &'a str, + at: usize, +} + +impl<'a> Iterator for Pieces<'a> { + type Item = &'a str; + + fn next(&mut self) -> Option<&'a str> { + let rest = self.text.get(self.at..)?; + if rest.is_empty() && self.at > 0 { + return None; + } + let (mut quoted, mut escaped) = (false, false); + for (i, c) in rest.char_indices() { + match c { + _ if escaped => escaped = false, + '\\' if quoted => escaped = true, + '"' => quoted = !quoted, + ';' if !quoted => { + self.at = self.at.saturating_add(i).saturating_add(1); + return rest.get(..i); + } + _ => {} + } + } + self.at = self.text.len().saturating_add(1); + Some(rest) + } +} + +fn unquote(raw: &str) -> String { + let Some(inner) = raw.strip_prefix('"') else { return raw.to_string() }; + let mut out = String::new(); + let mut escaped = false; + for c in inner.chars() { + match c { + _ if escaped => { + out.push(c); + escaped = false; + } + '\\' => escaped = true, + '"' => break, + _ => out.push(c), + } + } + out +} + +/// The parts of the multipart `body` with `boundary` (RFC 2046 §5.1.1), each exactly as it came: +/// from after its delimiter line to before the line break that comes ahead of the next one, +/// which belongs to the delimiter. At most `max` parts; a part after the last delimiter that +/// isn't closed still counts, as mail programs read it. +pub(crate) fn parts<'a>(body: &'a [u8], boundary: &str, max: usize) -> Vec<&'a [u8]> { + let mut out = Vec::new(); + if boundary.is_empty() { + return out; + } + let mut dash = Vec::with_capacity(boundary.len().saturating_add(2)); + dash.extend_from_slice(b"--"); + dash.extend_from_slice(boundary.as_bytes()); + // The start of the current part, once its opening delimiter has been seen. + let mut open: Option = None; + let mut line_start = 0usize; + while line_start <= body.len() { + let rest = body.get(line_start..).unwrap_or_default(); + let line_len = rest.iter().position(|b| *b == b'\n'); + let next = line_len.map_or(body.len().saturating_add(1), |n| line_start.saturating_add(n).saturating_add(1)); + if let Some(after) = rest.strip_prefix(dash.as_slice()) { + let line = after.get(..line_len.map_or(after.len(), |n| n.saturating_sub(dash.len()))).unwrap_or_default(); + let close = line.starts_with(b"--"); + let tail = if close { line.get(2..).unwrap_or_default() } else { line }; + if tail.iter().all(|b| matches!(b, b' ' | b'\t' | b'\r')) { + if let Some(start) = open.take() { + // The line break before the delimiter is the delimiter's. + let mut end = line_start.saturating_sub(1); + if end > start && body.get(end.saturating_sub(1)) == Some(&b'\r') { + end = end.saturating_sub(1); + } + let end = end.max(start); + out.push(body.get(start..end).unwrap_or_default()); + if out.len() >= max { + return out; + } + } + if close { + return out; + } + open = Some(next.min(body.len())); + } + } + line_start = next; + } + if let Some(start) = open { + out.push(body.get(start..).unwrap_or_default()); + } + out.truncate(max); + out +} + +/// `bytes` with every line break as CR LF (RFC 3156 §5: what was signed is the part's +/// canonical form). A lone LF gets its CR; nothing else changes. +pub(crate) fn crlf(bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(bytes.len().saturating_add(bytes.len() / 32)); + let mut previous = 0u8; + for &b in bytes { + if b == b'\n' && previous != b'\r' { + out.push(b'\r'); + } + out.push(b); + previous = b; + } + out +} + +/// A leaf's body without its transfer encoding: base64 and quoted-printable are undone, every +/// other encoding is the bytes themselves. +pub(crate) fn decode(encoding: &str, body: &[u8]) -> Vec { + match encoding { + "base64" => base64(body), + "quoted-printable" => quoted_printable(body), + _ => body.to_vec(), + } +} + +/// Base64 (RFC 2045 §6.8, RFC 4648), tolerant: characters outside the alphabet are skipped and +/// decoding stops at the first `=`. +pub(crate) fn base64(text: &[u8]) -> Vec { + let mut out = Vec::with_capacity(text.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0u32); + for &c in text { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + _ => continue, + }; + acc = (acc << 6) | u32::from(v); + bits = bits.saturating_add(6); + if bits >= 8 { + bits -= 8; + out.push(((acc >> bits) & 0xff) as u8); + } + acc &= (1 << bits) - 1; + } + out +} + +/// Quoted-printable (RFC 2045 §6.7): `=XX` is a byte, `=` at a line's end joins it to the next. +fn quoted_printable(text: &[u8]) -> Vec { + let mut out = Vec::with_capacity(text.len()); + let mut i = 0usize; + while let Some(&b) = text.get(i) { + if b != b'=' { + out.push(b); + i = i.saturating_add(1); + continue; + } + let hex = |o: usize| text.get(i.saturating_add(o)).and_then(|c| (*c as char).to_digit(16)); + if let (Some(h), Some(l)) = (hex(1), hex(2)) { + out.push(((h << 4) | l) as u8); + i = i.saturating_add(3); + continue; + } + // A soft line break, with any white space the line ends in. + let mut j = i.saturating_add(1); + while matches!(text.get(j), Some(b' ' | b'\t')) { + j = j.saturating_add(1); + } + match (text.get(j), text.get(j.saturating_add(1))) { + (Some(b'\r'), Some(b'\n')) => i = j.saturating_add(2), + (Some(b'\n'), _) => i = j.saturating_add(1), + (None, _) => i = j, + _ => { + out.push(b'='); + i = i.saturating_add(1); + } + } + } + out +} + +/// The address of the one mailbox in a From field (or an address given as it is): what is in +/// its `<…>` outside quoted strings and comments, else the value without its comments, in +/// lower case. Empty when there is no one address in it. +pub(crate) fn address(value: &str) -> String { + // RFC 5322 §3.4: a mailbox is `name ` or a bare addr-spec. Quoted strings and comments + // are read past, so that nothing in them is taken for the address; a field with more than + // one mailbox (§3.6.2: a comma between them, or more than one `<…>`) has no one address. + let (mut quoted, mut escaped, mut comment) = (false, false, 0usize); + let (mut angle, mut angles, mut closed) = (false, 0usize, false); + let (mut inside, mut outside) = (String::new(), String::new()); + for c in value.chars() { + let text = comment == 0; + match c { + _ if escaped => escaped = false, + '\\' if quoted || comment > 0 => { + escaped = true; + continue; + } + '"' if comment == 0 => quoted = !quoted, + _ if quoted => {} + '(' => comment = comment.saturating_add(1), + ')' => comment = comment.saturating_sub(1), + _ if comment > 0 => {} + '<' if angle => return String::new(), + '<' => { + angle = true; + angles = angles.saturating_add(1); + continue; + } + '>' if angle => { + angle = false; + closed = true; + continue; + } + ',' if !angle => return String::new(), + _ => {} + } + // What is in a comment, and the parentheses around it, is neither. + if text && comment == 0 && c != ')' { + if angle { inside.push(c) } else { outside.push(c) } + } + } + let spec = match angles { + 0 => outside, + 1 if closed && !angle => inside, + _ => return String::new(), + }; + let spec = spec.trim(); + // A control or format character (a zero-width space, a direction mark) would make an + // address that looks like another one: none is taken. + let clean = spec.matches('@').count() == 1 + && !spec.chars().any(|c| c.is_whitespace() || c.is_control() || is_format(c) || matches!(c, '<' | '>' | '"' | '(' | ')')); + if clean { spec.to_lowercase() } else { String::new() } +} + +/// Whether `c` is one of Unicode's format characters (general category Cf, Unicode 17.0): the +/// soft hyphen, the Arabic number signs and end-of-ayah marks, the Syriac abbreviation mark, the +/// Arabic pound and piastre marks above, the Mongolian vowel separator, zero-width marks and +/// joiners, bidirectional marks, embeddings, overrides and isolates, the word joiner and +/// invisible operators, the deprecated shaping controls, the byte-order mark, interlinear +/// annotations, the Kaithi number signs, the Egyptian hieroglyph, shorthand and musical format +/// controls, and tags. +pub(crate) fn is_format(c: char) -> bool { + matches!( + c, + '\u{00ad}' + | '\u{0600}'..='\u{0605}' + | '\u{061c}' + | '\u{06dd}' + | '\u{070f}' + | '\u{0890}'..='\u{0891}' + | '\u{08e2}' + | '\u{180e}' + | '\u{200b}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2060}'..='\u{2064}' + | '\u{2066}'..='\u{206f}' + | '\u{feff}' + | '\u{fff9}'..='\u{fffb}' + | '\u{110bd}' + | '\u{110cd}' + | '\u{13430}'..='\u{1343f}' + | '\u{1bca0}'..='\u{1bca3}' + | '\u{1d173}'..='\u{1d17a}' + | '\u{e0001}' + | '\u{e0020}'..='\u{e007f}' + ) +} diff --git a/crates/openpgp/src/mime_tests.rs b/crates/openpgp/src/mime_tests.rs new file mode 100644 index 000000000..d6fd32b69 --- /dev/null +++ b/crates/openpgp/src/mime_tests.rs @@ -0,0 +1,101 @@ +use crate::mime::{Params, address, base64, crlf, decode, parts, split}; + +#[test] +fn headers_unfold_and_parameters_unquote() { + let (headers, body) = + split(b"Content-Type: multipart/signed;\r\n\tprotocol=\"application/pgp-signature\";\r\n micalg=pgp-sha256; boundary=\"a;b\"\r\n\r\nbody"); + assert_eq!(body, b"body"); + let ct = headers.content_type(); + assert_eq!(ct.value, "multipart/signed"); + assert_eq!(ct.get("protocol"), Some("application/pgp-signature")); + assert_eq!(ct.get("boundary"), Some("a;b")); + assert_eq!(ct.get("micalg"), Some("pgp-sha256")); +} + +#[test] +fn a_part_without_fields_or_type_is_plain_text() { + let (headers, body) = split(b"\r\nonly a body"); + assert_eq!(body, b"only a body"); + assert_eq!(headers.content_type().value, "text/plain"); + let (_, body) = split(b"Subject: no body at all"); + assert!(body.is_empty()); +} + +#[test] +fn parts_are_cut_exactly_with_the_line_break_before_a_delimiter_belonging_to_it() { + let body = b"preamble\r\n--b\r\nA: 1\r\n\r\nfirst\r\n\r\n--b \r\nsecond\r\n--b--\r\nepilogue"; + assert_eq!(parts(body, "b", 10), vec![&b"A: 1\r\n\r\nfirst\r\n"[..], &b"second"[..]]); + // Bare line feeds, and a delimiter that only starts like the boundary isn't one. + let body = b"--b\nx\n--bc\ny\n--b--\n"; + assert_eq!(parts(body, "b", 10), vec![&b"x\n--bc\ny"[..]]); + // A part that is never closed still counts; an empty one too. + assert_eq!(parts(b"--b\r\n--b\r\nrest", "b", 10), vec![&b""[..], &b"rest"[..]]); + assert_eq!(parts(b"--b\r\na\r\n--b\r\nb\r\n--b--", "b", 1), vec![&b"a"[..]]); + assert!(parts(b"no delimiters", "b", 10).is_empty()); + assert!(parts(b"--\r\n", "", 10).is_empty()); +} + +#[test] +fn canonical_form_adds_carriage_returns_only_where_missing() { + assert_eq!(crlf(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n"); + assert_eq!(crlf(b""), b""); +} + +#[test] +fn transfer_encodings_are_undone() { + assert_eq!(base64(b"SGVs\r\nbG8=\r\n"), b"Hello"); + assert_eq!(decode("base64", b"!!QUJD"), b"ABC"); + assert_eq!(decode("quoted-printable", b"a=3Db=\r\nc= \r\nd=ZZ="), b"a=bcd=ZZ"); + assert_eq!(decode("7bit", b"same"), b"same"); +} + +#[test] +fn addresses_are_taken_from_angle_brackets_and_lower_cased() { + assert_eq!(address("Alice "), "alice@example.org"); + assert_eq!(address(" bob@example.org "), "bob@example.org"); + assert_eq!(address("\"a \" "), "c@example.org"); + assert_eq!(address("no address"), ""); + assert_eq!(address("two words@example.org"), ""); + // An address as the display name, as many writers send it. + assert_eq!(address("bob@example.org "), "bob@example.org"); + assert_eq!(address("\"Bob, the builder\" "), "bob@example.org"); + // Two mailboxes: no one address. + assert_eq!(address("alice@example.org, Mallory "), ""); + assert_eq!(address("Alice "), ""); + assert_eq!(address("alice@example.org mallory@example.org"), ""); + // What is in a comment or a quoted string is never the address, wherever it is. + assert_eq!(address("Mallory ()"), "mallory@evil.example"); + assert_eq!(address("Mallory \"\""), "mallory@evil.example"); + assert_eq!(address("mallory@evil.example (alice@example.org)"), "mallory@evil.example"); + assert_eq!(address("Alice