From 6c9618e54c00f548eae6eb8afa9ae1234403229d Mon Sep 17 00:00:00 2001 From: "Morgan J." Date: Fri, 20 Mar 2026 02:50:17 +0900 Subject: [PATCH] Rust implementation --- .gitignore | 3 +- Cargo.lock | 1740 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 38 + askpass.py | 100 --- auth.py | 149 ---- bridge.py | 73 -- bw-agent | 8 - com.8bit.bitwarden.json | 7 - crypto.py | 104 --- desktop_proxy.py | 93 --- ipc.py | 84 -- log.py | 25 - native_messaging.py | 135 --- secmem.py | 61 -- src/askpass.rs | 131 +++ src/auth.rs | 269 ++++++ src/bridge.rs | 190 +++++ src/crypto.rs | 122 +++ src/ipc.rs | 124 +++ src/log.rs | 26 + src/main.rs | 105 +++ src/proxy.rs | 90 ++ src/storage/mod.rs | 17 + src/storage/pin.rs | 124 +++ storage/__init__.py | 40 - storage/pin.py | 65 -- storage/tpm2.py | 76 -- 27 files changed, 2977 insertions(+), 1022 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 askpass.py delete mode 100644 auth.py delete mode 100644 bridge.py delete mode 100755 bw-agent delete mode 100644 com.8bit.bitwarden.json delete mode 100644 crypto.py delete mode 100755 desktop_proxy.py delete mode 100644 ipc.py delete mode 100644 log.py delete mode 100644 native_messaging.py delete mode 100644 secmem.py create mode 100644 src/askpass.rs create mode 100644 src/auth.rs create mode 100644 src/bridge.rs create mode 100644 src/crypto.rs create mode 100644 src/ipc.rs create mode 100644 src/log.rs create mode 100644 src/main.rs create mode 100644 src/proxy.rs create mode 100644 src/storage/mod.rs create mode 100644 src/storage/pin.rs delete mode 100644 storage/__init__.py delete mode 100644 storage/pin.py delete mode 100644 storage/tpm2.py diff --git a/.gitignore b/.gitignore index ce2920b..ea8c4bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -__pycache__/ -sep_helper +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0288730 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1740 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "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", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "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.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "desktop-agent" +version = "0.1.0" +dependencies = [ + "aes", + "aes-gcm", + "argon2", + "base64", + "cbc", + "clap", + "ctrlc", + "hex", + "hmac", + "pbkdf2", + "rand", + "rsa", + "scrypt", + "serde", + "serde_json", + "sha1", + "sha2", + "ureq", + "uuid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[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 = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "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 = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha1", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5030500cb2d66bdfbb4ebc9563be6ce7005a4b5d0f26be0c523870fe372ca6" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5f86989a046a79640b9d8867c823349a139367bda96549794fcc3313ce91f4e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a243488 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "desktop-agent" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "bw-agent" +path = "src/main.rs" + +[[bin]] +name = "bw-proxy" +path = "src/proxy.rs" + +[dependencies] +aes = "0.8" +argon2 = "0.5" +base64 = "0.22" +cbc = { version = "0.1", features = ["alloc"] } +clap = { version = "4", features = ["derive"] } +ctrlc = "3" +hex = "0.4" +hmac = "0.12" +pbkdf2 = { version = "0.12", features = ["sha2"] } +rand = "0.8" +rsa = { version = "0.9", features = ["sha1"] } +scrypt = "0.11" +serde = { version = "1", features = ["derive"] } +sha1 = "0.10" +serde_json = "1" +sha2 = "0.10" +ureq = { version = "2", features = ["json"] } +uuid = { version = "1", features = ["v4"] } +zeroize = { version = "1", features = ["derive"] } +aes-gcm = "0.10" + +[profile.release] +strip = true +lto = true diff --git a/askpass.py b/askpass.py deleted file mode 100644 index 0526bad..0000000 --- a/askpass.py +++ /dev/null @@ -1,100 +0,0 @@ -import getpass -import os -import shutil -import subprocess -import sys -from typing import Callable - -Prompter = Callable[[str], str | None] - - -def _cli() -> Prompter: - def prompt(msg: str) -> str | None: - try: - return getpass.getpass(msg + " ") - except (EOFError, KeyboardInterrupt): - return None - return prompt - - -def _osascript() -> Prompter: - def prompt(msg: str) -> str | None: - script = ( - f'display dialog "{msg}" with title "Bitwarden" ' - f'default answer "" with hidden answer buttons {{"Cancel","OK"}} default button "OK"' - ) - r = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) - if r.returncode != 0: - return None - for part in r.stdout.strip().split(","): - if "text returned:" in part: - return part.split("text returned:")[1].strip() - return None - return prompt - - -def _zenity() -> Prompter: - def prompt(msg: str) -> str | None: - r = subprocess.run( - ["zenity", "--entry", "--hide-text", "--title", "", - "--text", msg, "--width", "300", "--window-icon", "dialog-password"], - capture_output=True, text=True, - ) - return r.stdout.strip() or None if r.returncode == 0 else None - return prompt - - -def _kdialog() -> Prompter: - def prompt(msg: str) -> str | None: - r = subprocess.run( - ["kdialog", "--password", msg, "--title", "Bitwarden"], - capture_output=True, text=True, - ) - return r.stdout.strip() or None if r.returncode == 0 else None - return prompt - - -def _ssh_askpass() -> Prompter: - binary = os.environ.get("SSH_ASKPASS") or shutil.which("ssh-askpass") - if not binary: - raise RuntimeError("SSH_ASKPASS not set and ssh-askpass not found") - - def prompt(msg: str) -> str | None: - r = subprocess.run([binary, msg], capture_output=True, text=True) - return r.stdout.strip() or None if r.returncode == 0 else None - return prompt - - -PROVIDERS = { - "cli": _cli, - "osascript": _osascript, - "zenity": _zenity, - "kdialog": _kdialog, - "ssh-askpass": _ssh_askpass, -} - - -def get_prompter(name: str | None = None) -> Prompter: - if name: - if name not in PROVIDERS: - raise ValueError(f"unknown provider: {name} (available: {', '.join(PROVIDERS)})") - return PROVIDERS[name]() - - if sys.platform == "darwin": - return _osascript() - for gui in ("zenity", "kdialog"): - if shutil.which(gui): - return PROVIDERS[gui]() - return _cli() - - -def available() -> list[str]: - found = ["cli"] - if sys.platform == "darwin": - found.append("osascript") - for name in ("zenity", "kdialog"): - if shutil.which(name): - found.append(name) - if shutil.which("ssh-askpass") or "SSH_ASKPASS" in os.environ: - found.append("ssh-askpass") - return found diff --git a/auth.py b/auth.py deleted file mode 100644 index 6f347d9..0000000 --- a/auth.py +++ /dev/null @@ -1,149 +0,0 @@ -import base64 -import hashlib -import hmac -import json -import urllib.parse -import urllib.request -import uuid - -import log -from askpass import Prompter -from crypto import SymmetricKey, enc_string_decrypt_bytes -from secmem import wipe - - -def login(email: str, password: str, server: str, prompt: Prompter) -> tuple[bytearray, str]: - base = server.rstrip("/") - if "bitwarden.com" in base or "bitwarden.eu" in base: - api = base.replace("vault.", "api.") - identity = base.replace("vault.", "identity.") - else: - api, identity = base + "/api", base + "/identity" - - log.info(f"prelogin {api}/accounts/prelogin") - prelogin = _json_post(f"{api}/accounts/prelogin", {"email": email}) - kdf_type = _get(prelogin, "kdf", 0) - kdf_iter = _get(prelogin, "kdfIterations", 600000) - kdf_mem = _get(prelogin, "kdfMemory", 64) - kdf_par = _get(prelogin, "kdfParallelism", 4) - log.info(f"kdf: {'pbkdf2' if kdf_type == 0 else 'argon2id'} iterations={kdf_iter}") - - log.info("deriving master key...") - master_key = bytearray(_derive_master_key(password, email, kdf_type, kdf_iter, kdf_mem, kdf_par)) - pw_hash = base64.b64encode( - hashlib.pbkdf2_hmac("sha256", bytes(master_key), password.encode(), 1, dklen=32) - ).decode() - - form = { - "grant_type": "password", "username": email, "password": pw_hash, - "scope": "api offline_access", "client_id": "connector", - "deviceType": "8", "deviceIdentifier": str(uuid.uuid4()), "deviceName": "bw-bridge", - } - - log.info(f"token {identity}/connect/token") - token_resp = _try_login(f"{identity}/connect/token", form, prompt) - - enc_user_key = _extract_encrypted_user_key(token_resp) - log.info("decrypting user key...") - stretched = _stretch(master_key) - wipe(master_key) - user_key = enc_string_decrypt_bytes(enc_user_key, stretched) - stretched.close() - user_id = _extract_user_id(token_resp.get("access_token", "")) - log.info(f"user key decrypted ({len(user_key)}B)") - - return user_key, user_id - - -def _get(d: dict, key: str, default=None): - return d.get(key, d.get(key[0].upper() + key[1:], default)) - - -def _try_login(url: str, form: dict, prompt: Prompter) -> dict: - headers = { - "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", - "Accept": "application/json", "Device-Type": "8", - } - try: - return _form_post(url, form, headers) - except _HttpError as e: - if "TwoFactor" not in e.body: - log.fatal(f"login failed: {e.body[:200]}") - - body = json.loads(e.body) - providers = _get(body, "twoFactorProviders2", {}) - - if "0" not in providers and 0 not in providers: - log.fatal("2FA required but TOTP not available") - - log.info("TOTP required") - code = prompt("TOTP code:") - if code is None: - log.fatal("no TOTP code provided") - form["twoFactorToken"] = code.strip() - form["twoFactorProvider"] = "0" - return _form_post(url, form, headers) - - -def _extract_encrypted_user_key(resp: dict) -> str: - udo = _get(resp, "userDecryptionOptions") - if udo: - mpu = _get(udo, "masterPasswordUnlock") - if mpu: - k = _get(mpu, "masterKeyEncryptedUserKey") - if k: - return k - k = _get(resp, "key") - if k: - return k - log.fatal("no encrypted user key in server response") - - -def _extract_user_id(token: str) -> str: - try: - payload = token.split(".")[1] - payload += "=" * (4 - len(payload) % 4) - return json.loads(base64.urlsafe_b64decode(payload)).get("sub", "unknown") - except Exception: - return "unknown" - - -def _derive_master_key(pw: str, email: str, kdf: int, iters: int, mem: int, par: int) -> bytes: - salt = email.lower().strip().encode() - if kdf == 0: - return hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, iters, dklen=32) - if kdf == 1: - from argon2.low_level import hash_secret_raw, Type - return hash_secret_raw( - secret=pw.encode(), salt=salt, time_cost=iters, - memory_cost=mem * 1024, parallelism=par, hash_len=32, type=Type.ID, - ) - log.fatal(f"unsupported kdf type: {kdf}") - - -def _stretch(master_key: bytearray) -> SymmetricKey: - enc = hmac.new(bytes(master_key), b"enc\x01", hashlib.sha256).digest() - mac = hmac.new(bytes(master_key), b"mac\x01", hashlib.sha256).digest() - return SymmetricKey(bytearray(enc + mac)) - - -class _HttpError(Exception): - def __init__(self, code: int, body: str): - self.code, self.body = code, body - super().__init__(f"HTTP {code}") - - -def _json_post(url: str, data: dict) -> dict: - req = urllib.request.Request(url, json.dumps(data).encode(), - {"Content-Type": "application/json"}) - with urllib.request.urlopen(req) as r: - return json.loads(r.read()) - - -def _form_post(url: str, form: dict, headers: dict) -> dict: - req = urllib.request.Request(url, urllib.parse.urlencode(form).encode(), headers) - try: - with urllib.request.urlopen(req) as r: - return json.loads(r.read()) - except urllib.error.HTTPError as e: - raise _HttpError(e.code, e.read().decode()) from e diff --git a/bridge.py b/bridge.py deleted file mode 100644 index 7b4c18f..0000000 --- a/bridge.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import hashlib - -import log -from askpass import get_prompter, available -from auth import login -from ipc import get_socket_path, serve -from native_messaging import BiometricBridge -from secmem import wipe -from storage import get_backend - - -def user_hash(email: str) -> str: - return hashlib.sha256(email.lower().strip().encode()).hexdigest()[:16] - - -def main(): - p = argparse.ArgumentParser(description="Bitwarden desktop bridge agent") - p.add_argument("--email", required=True) - p.add_argument("--password") - p.add_argument("--server", default="https://vault.bitwarden.com") - p.add_argument("--backend", choices=["tpm2", "pin"]) - p.add_argument("--askpass", choices=available()) - p.add_argument("--enroll", action="store_true") - p.add_argument("--remove", action="store_true") - args = p.parse_args() - - uid = user_hash(args.email) - store = get_backend(args.backend) - prompt = get_prompter(args.askpass) - log.info(f"backend: {store.name}") - - if args.remove: - if store.has_key(uid): - store.remove(uid) - log.info(f"key removed for {args.email}") - else: - log.info(f"no key found for {args.email}") - return - - if not store.has_key(uid) or args.enroll: - log.info("enrolling" if not store.has_key(uid) else "re-enrolling") - pw = args.password or prompt("master password:") - if pw is None: - log.fatal("no password provided") - log.info(f"logging in as {args.email}") - key_bytes, server_uid = login(args.email, pw, args.server, prompt) - pw = None - log.info(f"authenticated, uid={server_uid}") - - auth = prompt(f"choose {store.name} password:") - if auth is None: - log.fatal("no password provided") - auth2 = prompt(f"confirm {store.name} password:") - if auth != auth2: - log.fatal("passwords don't match") - store.store(uid, bytes(key_bytes), auth) - wipe(key_bytes) - auth = None - auth2 = None - log.info(f"key sealed via {store.name}") - else: - log.info(f"key ready for {args.email}") - - bridge = BiometricBridge(store, uid, prompt) - sock = get_socket_path() - log.info(f"listening on {sock}") - serve(sock, bridge) - - -if __name__ == "__main__": - main() diff --git a/bw-agent b/bw-agent deleted file mode 100755 index daaac63..0000000 --- a/bw-agent +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from bridge import main - -main() diff --git a/com.8bit.bitwarden.json b/com.8bit.bitwarden.json deleted file mode 100644 index a5d4f4c..0000000 --- a/com.8bit.bitwarden.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "com.8bit.bitwarden", - "description": "Bitwarden desktop <-> browser bridge", - "path": "/Users/morgan/Projects/bitwarden-client/desktop-agent/desktop_proxy.py", - "type": "stdio", - "allowed_extensions": ["{446900e4-71c2-419f-a6a7-df9c091e268b}"] -} diff --git a/crypto.py b/crypto.py deleted file mode 100644 index 3dba726..0000000 --- a/crypto.py +++ /dev/null @@ -1,104 +0,0 @@ -import base64 -import hashlib -import hmac -import os - -from cryptography.hazmat.primitives import padding as sym_padding -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - -from secmem import SecureBuffer, wipe - - -class SymmetricKey: - def __init__(self, raw: bytes | bytearray): - if len(raw) != 64: - raise ValueError(f"expected 64 bytes, got {len(raw)}") - self._secure = SecureBuffer(raw) - if isinstance(raw, bytearray): - wipe(raw) - - @property - def raw(self) -> bytearray: - return self._secure.raw - - @property - def enc_key(self) -> bytearray: - return self._secure.raw[:32] - - @property - def mac_key(self) -> bytearray: - return self._secure.raw[32:] - - def to_b64(self) -> str: - return base64.b64encode(bytes(self._secure)).decode() - - def close(self): - self._secure.close() - - @classmethod - def from_b64(cls, b64: str) -> "SymmetricKey": - return cls(base64.b64decode(b64)) - - @classmethod - def generate(cls) -> "SymmetricKey": - return cls(os.urandom(64)) - - -def _decrypt_raw(enc_str: str, key: SymmetricKey) -> bytearray: - t, rest = enc_str.split(".", 1) - parts = rest.split("|") - iv = base64.b64decode(parts[0]) - ct = base64.b64decode(parts[1]) - if len(parts) > 2: - mac_got = base64.b64decode(parts[2]) - expected = hmac.new(bytes(key.mac_key), iv + ct, hashlib.sha256).digest() - if not hmac.compare_digest(mac_got, expected): - raise ValueError("MAC mismatch") - dec = Cipher(algorithms.AES(bytes(key.enc_key)), modes.CBC(iv)).decryptor() - padded = dec.update(ct) + dec.finalize() - unpadder = sym_padding.PKCS7(128).unpadder() - return bytearray(unpadder.update(padded) + unpadder.finalize()) - - -def enc_string_encrypt(plaintext: str, key: SymmetricKey) -> str: - iv = os.urandom(16) - padder = sym_padding.PKCS7(128).padder() - padded = padder.update(plaintext.encode()) + padder.finalize() - ct = Cipher(algorithms.AES(bytes(key.enc_key)), modes.CBC(iv)).encryptor() - encrypted = ct.update(padded) + ct.finalize() - mac = hmac.new(bytes(key.mac_key), iv + encrypted, hashlib.sha256).digest() - iv_b64 = base64.b64encode(iv).decode() - ct_b64 = base64.b64encode(encrypted).decode() - mac_b64 = base64.b64encode(mac).decode() - return f"2.{iv_b64}|{ct_b64}|{mac_b64}" - - -def enc_string_decrypt(enc_str: str, key: SymmetricKey) -> str: - raw = _decrypt_raw(enc_str, key) - result = raw.decode() - wipe(raw) - return result - - -def enc_string_decrypt_bytes(enc_str: str, key: SymmetricKey) -> bytearray: - return _decrypt_raw(enc_str, key) - - -def enc_string_to_dict(enc_str: str) -> dict: - t, rest = enc_str.split(".", 1) - parts = rest.split("|") - d = {"encryptionType": int(t), "encryptedString": enc_str} - if len(parts) >= 1: - d["iv"] = parts[0] - if len(parts) >= 2: - d["data"] = parts[1] - if len(parts) >= 3: - d["mac"] = parts[2] - return d - - -def dict_to_enc_string(d: dict) -> str: - if s := d.get("encryptedString"): - return s - t = d.get("encryptionType", 2) - return f"{t}.{d.get('iv', '')}|{d.get('data', '')}|{d.get('mac', '')}" diff --git a/desktop_proxy.py b/desktop_proxy.py deleted file mode 100755 index a5a8bfd..0000000 --- a/desktop_proxy.py +++ /dev/null @@ -1,93 +0,0 @@ -#!/opt/homebrew/bin/python3 -import socket -import struct -import sys -import threading -from pathlib import Path - -MAX_MSG = 1024 * 1024 - - -def ipc_socket_path() -> str: - return str(Path.home() / ".cache" / "com.bitwarden.desktop" / "s.bw") - - -def recv_exact(sock: socket.socket, n: int) -> bytes | None: - buf = b"" - while len(buf) < n: - chunk = sock.recv(n - len(buf)) - if not chunk: - return None - buf += chunk - return buf - - -def read_stdin() -> bytes | None: - header = sys.stdin.buffer.read(4) - if len(header) < 4: - return None - length = struct.unpack("=I", header)[0] - if length == 0 or length > MAX_MSG: - return None - data = sys.stdin.buffer.read(length) - return data if len(data) == length else None - - -def write_stdout(data: bytes): - sys.stdout.buffer.write(struct.pack("=I", len(data)) + data) - sys.stdout.buffer.flush() - - -def read_ipc(sock: socket.socket) -> bytes | None: - header = recv_exact(sock, 4) - if header is None: - return None - length = struct.unpack("=I", header)[0] - if length == 0 or length > MAX_MSG: - return None - return recv_exact(sock, length) - - -def send_ipc(sock: socket.socket, data: bytes): - sock.sendall(struct.pack("=I", len(data)) + data) - - -def ipc_to_stdout(sock: socket.socket): - try: - while True: - msg = read_ipc(sock) - if msg is None: - break - write_stdout(msg) - except (ConnectionResetError, BrokenPipeError, OSError): - pass - - -def main(): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - try: - sock.connect(ipc_socket_path()) - except (FileNotFoundError, ConnectionRefusedError): - sys.exit(1) - - send_ipc(sock, b'{"command":"connected"}') - threading.Thread(target=ipc_to_stdout, args=(sock,), daemon=True).start() - - try: - while True: - msg = read_stdin() - if msg is None: - break - send_ipc(sock, msg) - except (BrokenPipeError, OSError): - pass - finally: - try: - send_ipc(sock, b'{"command":"disconnected"}') - except OSError: - pass - sock.close() - - -if __name__ == "__main__": - main() diff --git a/ipc.py b/ipc.py deleted file mode 100644 index efe7d45..0000000 --- a/ipc.py +++ /dev/null @@ -1,84 +0,0 @@ -import json -import os -import socket -import struct -from pathlib import Path - -import log - -MAX_MSG = 1024 * 1024 - - -def get_socket_path() -> Path: - cache = Path.home() / ".cache" / "com.bitwarden.desktop" - cache.mkdir(parents=True, exist_ok=True) - return cache / "s.bw" - - -def recv_exact(conn: socket.socket, n: int) -> bytes | None: - buf = b"" - while len(buf) < n: - chunk = conn.recv(n - len(buf)) - if not chunk: - return None - buf += chunk - return buf - - -def read_message(conn: socket.socket) -> dict | None: - header = recv_exact(conn, 4) - if not header: - return None - length = struct.unpack("=I", header)[0] - if length == 0 or length > MAX_MSG: - return None - data = recv_exact(conn, length) - if not data: - return None - return json.loads(data) - - -def send_message(conn: socket.socket, msg: dict): - data = json.dumps(msg).encode() - conn.sendall(struct.pack("=I", len(data)) + data) - - -def serve(sock_path: Path, handler): - if sock_path.exists(): - sock_path.unlink() - - srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - srv.bind(str(sock_path)) - srv.listen(5) - os.chmod(str(sock_path), 0o600) - - try: - while True: - conn, _ = srv.accept() - log.info("client connected") - _handle_conn(conn, handler) - log.info("client disconnected") - except KeyboardInterrupt: - log.info("shutting down") - finally: - srv.close() - if sock_path.exists(): - sock_path.unlink() - - -def _handle_conn(conn: socket.socket, handler): - try: - while True: - msg = read_message(conn) - if msg is None: - break - if "command" in msg and "appId" not in msg: - log.info(f"proxy: {msg.get('command')}") - continue - resp = handler(msg) - if resp is not None: - send_message(conn, resp) - except (ConnectionResetError, BrokenPipeError): - pass - finally: - conn.close() diff --git a/log.py b/log.py deleted file mode 100644 index 623317f..0000000 --- a/log.py +++ /dev/null @@ -1,25 +0,0 @@ -import sys -import time - -_start = time.monotonic() - - -def _ts() -> str: - return f"{time.monotonic() - _start:8.3f}" - - -def info(msg: str): - print(f"[{_ts()}] {msg}", file=sys.stderr, flush=True) - - -def warn(msg: str): - print(f"[{_ts()}] WARN {msg}", file=sys.stderr, flush=True) - - -def error(msg: str): - print(f"[{_ts()}] ERROR {msg}", file=sys.stderr, flush=True) - - -def fatal(msg: str): - error(msg) - sys.exit(1) diff --git a/native_messaging.py b/native_messaging.py deleted file mode 100644 index a9b917a..0000000 --- a/native_messaging.py +++ /dev/null @@ -1,135 +0,0 @@ -import base64 -import json -import time - -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import padding as asym_padding - -import log -from askpass import Prompter -from crypto import SymmetricKey, enc_string_encrypt, enc_string_decrypt, enc_string_to_dict, dict_to_enc_string -from secmem import wipe -from storage import KeyStore - - -class BiometricBridge: - def __init__(self, store: KeyStore, user_id: str, prompter: Prompter): - self._store = store - self._uid = user_id - self._prompt = prompter - self._sessions: dict[str, SymmetricKey] = {} - - def __call__(self, msg: dict) -> dict | None: - app_id = msg.get("appId", "") - message = msg.get("message") - if message is None: - return None - - if isinstance(message, dict) and message.get("command") == "setupEncryption": - return self._handshake(app_id, message) - - if isinstance(message, dict) and ("encryptedString" in message or "encryptionType" in message): - return self._encrypted(app_id, message) - - if isinstance(message, str) and message.startswith("2."): - return self._encrypted(app_id, {"encryptedString": message}) - - return None - - def _handshake(self, app_id: str, msg: dict) -> dict: - pub_bytes = base64.b64decode(msg.get("publicKey", "")) - pub_key = serialization.load_der_public_key(pub_bytes) - - shared = SymmetricKey.generate() - self._sessions[app_id] = shared - - encrypted = pub_key.encrypt( - bytes(shared.raw), - asym_padding.OAEP( - mgf=asym_padding.MGF1(algorithm=hashes.SHA1()), - algorithm=hashes.SHA1(), label=None, - ), - ) - - log.info(f"handshake complete, app={app_id[:12]}") - return { - "appId": app_id, - "command": "setupEncryption", - "messageId": -1, - "sharedSecret": base64.b64encode(encrypted).decode(), - } - - def _encrypted(self, app_id: str, enc_msg: dict) -> dict | None: - if app_id not in self._sessions: - log.warn(f"no session for app={app_id[:12]}") - return {"appId": app_id, "command": "invalidateEncryption"} - - key = self._sessions[app_id] - try: - plaintext = enc_string_decrypt(dict_to_enc_string(enc_msg), key) - except Exception: - log.error("message decryption failed") - return {"appId": app_id, "command": "invalidateEncryption"} - - data = json.loads(plaintext) - cmd = data.get("command", "") - mid = data.get("messageId", 0) - - log.info(f"<- {cmd} (msg={mid})") - resp = self._dispatch(cmd, mid) - if resp is None: - log.warn(f"unhandled command: {cmd}") - return None - - encrypted = enc_string_encrypt(json.dumps(resp), key) - return {"appId": app_id, "messageId": mid, "message": enc_string_to_dict(encrypted)} - - def _reply(self, cmd: str, mid: int, **kwargs) -> dict: - return {"command": cmd, "messageId": mid, "timestamp": int(time.time() * 1000), **kwargs} - - def _dispatch(self, cmd: str, mid: int) -> dict | None: - handlers = { - "unlockWithBiometricsForUser": self._handle_unlock, - "getBiometricsStatus": self._handle_status, - "getBiometricsStatusForUser": self._handle_status, - "authenticateWithBiometrics": self._handle_auth, - } - handler = handlers.get(cmd) - if handler is None: - return None - return handler(cmd, mid) - - def _handle_unlock(self, cmd: str, mid: int) -> dict: - key_b64 = self._unseal_key() - if key_b64 is None: - log.warn("unlock denied or failed") - return self._reply(cmd, mid, response=False) - log.info("-> unlock granted") - resp = self._reply(cmd, mid, response=True, userKeyB64=key_b64) - key_b64 = None - return resp - - def _handle_status(self, cmd: str, mid: int) -> dict: - log.info("-> biometrics available") - return self._reply(cmd, mid, response=0) - - def _handle_auth(self, cmd: str, mid: int) -> dict: - log.info("-> authenticated") - return self._reply(cmd, mid, response=True) - - def _unseal_key(self) -> str | None: - pw = self._prompt(f"Enter {self._store.name} password:") - if pw is None: - log.info("cancelled") - return None - try: - raw = self._store.load(self._uid, pw) - pw = None - b64 = base64.b64encode(bytes(raw)).decode() - if isinstance(raw, bytearray): - wipe(raw) - log.info(f"unsealed {len(raw)}B from {self._store.name}") - return b64 - except Exception as e: - log.error(f"unseal failed: {e}") - return None diff --git a/secmem.py b/secmem.py deleted file mode 100644 index e0f1c89..0000000 --- a/secmem.py +++ /dev/null @@ -1,61 +0,0 @@ -import ctypes -import ctypes.util -import sys - -if sys.platform == "darwin": - _libc = ctypes.CDLL("libSystem.B.dylib") -else: - _libc = ctypes.CDLL(ctypes.util.find_library("c")) - -_mlock = _libc.mlock -_mlock.argtypes = [ctypes.c_void_p, ctypes.c_size_t] -_mlock.restype = ctypes.c_int - -_munlock = _libc.munlock -_munlock.argtypes = [ctypes.c_void_p, ctypes.c_size_t] -_munlock.restype = ctypes.c_int - - -def _addr(buf: bytearray) -> int: - return ctypes.addressof((ctypes.c_char * len(buf)).from_buffer(buf)) - - -def mlock(buf: bytearray): - if len(buf) > 0: - _mlock(_addr(buf), len(buf)) - - -def munlock(buf: bytearray): - if len(buf) > 0: - _munlock(_addr(buf), len(buf)) - - -def wipe(buf: bytearray): - for i in range(len(buf)): - buf[i] = 0 - - -class SecureBuffer: - __slots__ = ("_buf",) - - def __init__(self, data: bytes | bytearray): - self._buf = bytearray(data) - mlock(self._buf) - - @property - def raw(self) -> bytearray: - return self._buf - - def __len__(self): - return len(self._buf) - - def __bytes__(self): - return bytes(self._buf) - - def __del__(self): - self.close() - - def close(self): - if self._buf: - wipe(self._buf) - munlock(self._buf) diff --git a/src/askpass.rs b/src/askpass.rs new file mode 100644 index 0000000..ddc787d --- /dev/null +++ b/src/askpass.rs @@ -0,0 +1,131 @@ +use std::io::{self, BufRead, Write}; +use std::process::Command; + +pub type Prompter = Box Option>; + +fn cli() -> Prompter { + Box::new(|msg: &str| { + eprint!("{msg} "); + io::stderr().flush().ok(); + let mut line = String::new(); + io::stdin().lock().read_line(&mut line).ok()?; + let trimmed = line.trim_end().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } + }) +} + +fn osascript() -> Prompter { + Box::new(|msg: &str| { + let script = format!( + "display dialog \"{}\" with title \"Bitwarden\" \ + default answer \"\" with hidden answer \ + buttons {{\"Cancel\",\"OK\"}} default button \"OK\"", + msg + ); + let out = Command::new("osascript").args(["-e", &script]).output().ok()?; + if !out.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&out.stdout); + for part in stdout.trim().split(',') { + if let Some(val) = part.trim().strip_prefix("text returned:") { + let v = val.trim().to_string(); + return if v.is_empty() { None } else { Some(v) }; + } + } + None + }) +} + +fn zenity() -> Prompter { + Box::new(|msg: &str| { + let out = Command::new("zenity") + .args(["--entry", "--hide-text", "--title", "", "--text", msg, "--width", "300"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + }) +} + +fn kdialog() -> Prompter { + Box::new(|msg: &str| { + let out = Command::new("kdialog") + .args(["--password", msg, "--title", "Bitwarden"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + }) +} + +fn ssh_askpass() -> Option { + let binary = std::env::var("SSH_ASKPASS") + .ok() + .or_else(|| which("ssh-askpass"))?; + Some(Box::new(move |msg: &str| { + let out = Command::new(&binary).arg(msg).output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } + })) +} + +fn which(name: &str) -> Option { + Command::new("which") + .arg(name) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + +pub fn get_prompter(name: Option<&str>) -> Prompter { + match name { + Some("cli") => cli(), + Some("osascript") => osascript(), + Some("zenity") => zenity(), + Some("kdialog") => kdialog(), + Some("ssh-askpass") => ssh_askpass().unwrap_or_else(|| { + crate::log::fatal("SSH_ASKPASS not set and ssh-askpass not found") + }), + Some(other) => crate::log::fatal(&format!("unknown askpass provider: {other}")), + None => { + if cfg!(target_os = "macos") { + return osascript(); + } + if which("zenity").is_some() { + return zenity(); + } + if which("kdialog").is_some() { + return kdialog(); + } + cli() + } + } +} + +pub fn available() -> Vec<&'static str> { + let mut found = vec!["cli"]; + if cfg!(target_os = "macos") { + found.push("osascript"); + } + if which("zenity").is_some() { + found.push("zenity"); + } + if which("kdialog").is_some() { + found.push("kdialog"); + } + if which("ssh-askpass").is_some() || std::env::var("SSH_ASKPASS").is_ok() { + found.push("ssh-askpass"); + } + found +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..6a90f9c --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,269 @@ +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use zeroize::Zeroizing; + +use crate::askpass::Prompter; +use crate::crypto::{enc_string_decrypt_bytes, SymmetricKey}; + +pub fn login( + email: &str, + password: &str, + server: &str, + prompt: &Prompter, +) -> (Vec, String) { + let base = server.trim_end_matches('/'); + let (api, identity) = if base.contains("bitwarden.com") || base.contains("bitwarden.eu") { + ( + base.replace("vault.", "api."), + base.replace("vault.", "identity."), + ) + } else { + (format!("{base}/api"), format!("{base}/identity")) + }; + + crate::log::info(&format!("prelogin {api}/accounts/prelogin")); + let prelogin: serde_json::Value = ureq::post(&format!("{api}/accounts/prelogin")) + .set("Content-Type", "application/json") + .send_string(&serde_json::json!({"email": email}).to_string()) + .unwrap_or_else(|e| crate::log::fatal(&format!("prelogin failed: {e}"))) + .into_json() + .unwrap(); + + let kdf_type = get_u64(&prelogin, "kdf").unwrap_or(0); + let kdf_iter = get_u64(&prelogin, "kdfIterations").unwrap_or(600000) as u32; + let kdf_mem = get_u64(&prelogin, "kdfMemory").unwrap_or(64) as u32; + let kdf_par = get_u64(&prelogin, "kdfParallelism").unwrap_or(4) as u32; + crate::log::info(&format!( + "kdf: {} iterations={kdf_iter}", + if kdf_type == 0 { "pbkdf2" } else { "argon2id" } + )); + + crate::log::info("deriving master key..."); + let master_key = Zeroizing::new(derive_master_key( + password, email, kdf_type, kdf_iter, kdf_mem, kdf_par, + )); + + let pw_hash = { + let mut buf = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(master_key.as_slice(), password.as_bytes(), 1, &mut buf); + B64.encode(buf) + }; + + let device_id = uuid::Uuid::new_v4().to_string(); + let form = [ + ("grant_type", "password"), + ("username", email), + ("password", &pw_hash), + ("scope", "api offline_access"), + ("client_id", "connector"), + ("deviceType", "8"), + ("deviceIdentifier", &device_id), + ("deviceName", "bw-bridge"), + ]; + + crate::log::info(&format!("token {identity}/connect/token")); + let token_resp = try_login(&format!("{identity}/connect/token"), &form, prompt); + + let enc_user_key = extract_encrypted_user_key(&token_resp); + crate::log::info("decrypting user key..."); + + let stretched = stretch(&master_key); + let user_key = enc_string_decrypt_bytes(&enc_user_key, &stretched) + .unwrap_or_else(|e| crate::log::fatal(&format!("decrypt user key failed: {e}"))); + + let user_id = extract_user_id( + token_resp + .get("access_token") + .and_then(|t| t.as_str()) + .unwrap_or(""), + ); + crate::log::info(&format!("user key decrypted ({}B)", user_key.len())); + + (user_key.to_vec(), user_id) +} + +fn get_u64(v: &serde_json::Value, key: &str) -> Option { + v.get(key) + .or_else(|| { + let pascal = format!("{}{}", key[..1].to_uppercase(), &key[1..]); + v.get(&pascal) + }) + .and_then(|v| v.as_u64()) +} + +fn get_str<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a str> { + v.get(key) + .or_else(|| { + let pascal = format!("{}{}", key[..1].to_uppercase(), &key[1..]); + v.get(&pascal) + }) + .and_then(|v| v.as_str()) +} + +fn get_obj<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> { + v.get(key).or_else(|| { + let pascal = format!("{}{}", key[..1].to_uppercase(), &key[1..]); + v.get(&pascal) + }) +} + +fn try_login( + url: &str, + form: &[(&str, &str)], + prompt: &Prompter, +) -> serde_json::Value { + let body: String = form + .iter() + .map(|(k, v)| format!("{}={}", k, urlencod(v))) + .collect::>() + .join("&"); + + match post_form(url, &body) { + Ok(v) => v, + Err(e) => { + if !e.contains("TwoFactor") { + crate::log::fatal(&format!("login failed: {}", &e[..e.len().min(200)])); + } + let body: serde_json::Value = + serde_json::from_str(&e).unwrap_or(serde_json::Value::Null); + let providers = get_obj(&body, "twoFactorProviders2").unwrap_or(&serde_json::Value::Null); + if providers.get("0").is_none() { + crate::log::fatal("2FA required but TOTP not available"); + } + + crate::log::info("TOTP required"); + let code = prompt("TOTP code:") + .unwrap_or_else(|| crate::log::fatal("no TOTP code provided")); + + let mut form2: Vec<(&str, &str)> = form.to_vec(); + let trimmed = code.trim().to_string(); + form2.push(("twoFactorToken", &trimmed)); + form2.push(("twoFactorProvider", "0")); + + let body2: String = form2 + .iter() + .map(|(k, v)| format!("{}={}", k, urlencod(v))) + .collect::>() + .join("&"); + + post_form(url, &body2) + .unwrap_or_else(|e| crate::log::fatal(&format!("login failed: {}", &e[..e.len().min(200)]))) + } + } +} + +fn post_form(url: &str, body: &str) -> Result { + let resp = ureq::post(url) + .set( + "Content-Type", + "application/x-www-form-urlencoded; charset=utf-8", + ) + .set("Accept", "application/json") + .set("Device-Type", "8") + .send_string(body); + + match resp { + Ok(r) => r.into_json().map_err(|e| e.to_string()), + Err(ureq::Error::Status(_code, resp)) => { + let body = resp.into_string().unwrap_or_default(); + Err(body) + } + Err(e) => Err(e.to_string()), + } +} + +fn urlencod(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{:02X}", b)), + } + } + out +} + +fn extract_encrypted_user_key(resp: &serde_json::Value) -> String { + if let Some(udo) = get_obj(resp, "userDecryptionOptions") { + if let Some(mpu) = get_obj(udo, "masterPasswordUnlock") { + if let Some(k) = get_str(mpu, "masterKeyEncryptedUserKey") { + return k.to_string(); + } + } + } + if let Some(k) = get_str(resp, "key") { + return k.to_string(); + } + crate::log::fatal("no encrypted user key in server response"); +} + +fn extract_user_id(token: &str) -> String { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return "unknown".into(); + } + let mut payload = parts[1].to_string(); + while payload.len() % 4 != 0 { + payload.push('='); + } + let decoded = B64.decode(&payload).or_else(|_| { + base64::engine::general_purpose::URL_SAFE.decode(&payload) + }); + match decoded { + Ok(bytes) => { + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default(); + v.get("sub") + .and_then(|s| s.as_str()) + .unwrap_or("unknown") + .to_string() + } + Err(_) => "unknown".into(), + } +} + +fn derive_master_key( + pw: &str, + email: &str, + kdf: u64, + iters: u32, + mem: u32, + par: u32, +) -> Vec { + let salt = email.to_lowercase().trim().as_bytes().to_vec(); + match kdf { + 0 => { + let mut key = vec![0u8; 32]; + pbkdf2::pbkdf2_hmac::(pw.as_bytes(), &salt, iters, &mut key); + key + } + 1 => { + let params = argon2::Params::new(mem * 1024, iters, par, Some(32)) + .unwrap_or_else(|e| crate::log::fatal(&format!("argon2 params: {e}"))); + let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params); + let mut key = vec![0u8; 32]; + argon + .hash_password_into(pw.as_bytes(), &salt, &mut key) + .unwrap_or_else(|e| crate::log::fatal(&format!("argon2: {e}"))); + key + } + _ => crate::log::fatal(&format!("unsupported kdf type: {kdf}")), + } +} + +fn stretch(master_key: &[u8]) -> SymmetricKey { + let mut enc_hmac = Hmac::::new_from_slice(master_key).unwrap(); + enc_hmac.update(b"enc\x01"); + let enc = enc_hmac.finalize().into_bytes(); + + let mut mac_hmac = Hmac::::new_from_slice(master_key).unwrap(); + mac_hmac.update(b"mac\x01"); + let mac = mac_hmac.finalize().into_bytes(); + + let mut combined = Vec::with_capacity(64); + combined.extend_from_slice(&enc); + combined.extend_from_slice(&mac); + SymmetricKey::new(combined) +} diff --git a/src/bridge.rs b/src/bridge.rs new file mode 100644 index 0000000..09b34bf --- /dev/null +++ b/src/bridge.rs @@ -0,0 +1,190 @@ +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use rsa::{pkcs1::DecodeRsaPublicKey, Oaep}; +use serde_json::{json, Value}; +use zeroize::Zeroize; + +use crate::askpass::Prompter; +use crate::crypto::{ + enc_string_decrypt, enc_string_encrypt, enc_string_to_json, json_to_enc_string, SymmetricKey, +}; +use crate::storage::KeyStore; + +pub struct BiometricBridge { + store: Box, + uid: String, + prompt: Prompter, + sessions: HashMap, +} + +impl BiometricBridge { + pub fn new(store: Box, uid: String, prompt: Prompter) -> Self { + Self { + store, + uid, + prompt, + sessions: HashMap::new(), + } + } + + pub fn handle(&mut self, msg: Value) -> Option { + let app_id = msg.get("appId")?.as_str()?.to_string(); + let message = msg.get("message")?; + + if let Some(obj) = message.as_object() { + if obj.get("command").and_then(|c| c.as_str()) == Some("setupEncryption") { + return Some(self.handshake(&app_id, message)); + } + if obj.contains_key("encryptedString") || obj.contains_key("encryptionType") { + return self.encrypted(&app_id, message); + } + } + + if let Some(s) = message.as_str() { + if s.starts_with("2.") { + return self.encrypted(&app_id, &json!({"encryptedString": s})); + } + } + + None + } + + fn handshake(&mut self, app_id: &str, msg: &Value) -> Value { + let pub_b64 = msg + .get("publicKey") + .and_then(|p| p.as_str()) + .unwrap_or(""); + let pub_bytes = B64.decode(pub_b64).unwrap_or_default(); + let pub_key = rsa::RsaPublicKey::from_pkcs1_der(&pub_bytes) + .or_else(|_| { + use rsa::pkcs8::DecodePublicKey; + rsa::RsaPublicKey::from_public_key_der(&pub_bytes) + }) + .unwrap_or_else(|e| crate::log::fatal(&format!("bad public key: {e}"))); + + let shared = SymmetricKey::generate(); + let encrypted = pub_key + .encrypt( + &mut rand::thread_rng(), + Oaep::new::(), + shared.raw(), + ) + .unwrap_or_else(|e| crate::log::fatal(&format!("RSA encrypt failed: {e}"))); + + self.sessions.insert(app_id.to_string(), shared); + + crate::log::info(&format!("handshake complete, app={}", &app_id[..12.min(app_id.len())])); + json!({ + "appId": app_id, + "command": "setupEncryption", + "messageId": -1, + "sharedSecret": B64.encode(encrypted), + }) + } + + fn encrypted(&mut self, app_id: &str, enc_msg: &Value) -> Option { + if !self.sessions.contains_key(app_id) { + crate::log::warn(&format!( + "no session for app={}", + &app_id[..12.min(app_id.len())] + )); + return Some(json!({"appId": app_id, "command": "invalidateEncryption"})); + } + + let key = self.sessions.get(app_id).unwrap(); + let enc_str = json_to_enc_string(enc_msg); + let plaintext = match enc_string_decrypt(&enc_str, key) { + Ok(p) => p, + Err(_) => { + crate::log::error("message decryption failed"); + return Some(json!({"appId": app_id, "command": "invalidateEncryption"})); + } + }; + + let data: Value = serde_json::from_str(&plaintext).ok()?; + let cmd = data.get("command")?.as_str()?.to_string(); + let mid = data.get("messageId").and_then(|m| m.as_i64()).unwrap_or(0); + + crate::log::info(&format!("<- {cmd} (msg={mid})")); + let resp = self.dispatch(&cmd, mid)?; + + let key = self.sessions.get(app_id).unwrap(); + let resp_json = serde_json::to_string(&resp).unwrap(); + let encrypted = enc_string_encrypt(&resp_json, key); + + Some(json!({ + "appId": app_id, + "messageId": mid, + "message": enc_string_to_json(&encrypted), + })) + } + + fn reply(&self, cmd: &str, mid: i64, extra: Value) -> Value { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let mut obj = json!({ + "command": cmd, + "messageId": mid, + "timestamp": ts, + }); + if let (Some(base), Some(ext)) = (obj.as_object_mut(), extra.as_object()) { + for (k, v) in ext { + base.insert(k.clone(), v.clone()); + } + } + obj + } + + fn dispatch(&mut self, cmd: &str, mid: i64) -> Option { + match cmd { + "unlockWithBiometricsForUser" => Some(self.handle_unlock(cmd, mid)), + "getBiometricsStatus" | "getBiometricsStatusForUser" => { + crate::log::info("-> biometrics available"); + Some(self.reply(cmd, mid, json!({"response": 0}))) + } + "authenticateWithBiometrics" => { + crate::log::info("-> authenticated"); + Some(self.reply(cmd, mid, json!({"response": true}))) + } + _ => { + crate::log::warn(&format!("unhandled command: {cmd}")); + None + } + } + } + + fn handle_unlock(&mut self, cmd: &str, mid: i64) -> Value { + match self.unseal_key() { + Some(key_b64) => { + crate::log::info("-> unlock granted"); + self.reply(cmd, mid, json!({"response": true, "userKeyB64": key_b64})) + } + None => { + crate::log::warn("unlock denied or failed"); + self.reply(cmd, mid, json!({"response": false})) + } + } + } + + fn unseal_key(&self) -> Option { + let pw = (self.prompt)(&format!("Enter {} password:", self.store.name()))?; + match self.store.load(&self.uid, &pw) { + Ok(mut raw) => { + let len = raw.len(); + let b64 = B64.encode(&raw); + raw.zeroize(); + crate::log::info(&format!("unsealed {len}B from {}", self.store.name())); + crate::log::info("wiped key from memory"); + Some(b64) + } + Err(e) => { + crate::log::error(&format!("unseal failed: {e}")); + None + } + } + } +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..80ced31 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,122 @@ +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::Sha256; +use zeroize::Zeroizing; + +type Aes256CbcEnc = cbc::Encryptor; +type Aes256CbcDec = cbc::Decryptor; + +pub struct SymmetricKey { + raw: Zeroizing>, +} + +impl SymmetricKey { + pub fn new(data: Vec) -> Self { + assert_eq!(data.len(), 64); + Self { raw: Zeroizing::new(data) } + } + + pub fn generate() -> Self { + let mut buf = vec![0u8; 64]; + rand::thread_rng().fill_bytes(&mut buf); + Self::new(buf) + } + + pub fn raw(&self) -> &[u8] { + &self.raw + } + + pub fn enc_key(&self) -> &[u8] { + &self.raw[..32] + } + + pub fn mac_key(&self) -> &[u8] { + &self.raw[32..] + } +} + +pub fn enc_string_encrypt(plaintext: &str, key: &SymmetricKey) -> String { + let mut iv = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut iv); + + let encrypted = Aes256CbcEnc::new(key.enc_key().into(), &iv.into()) + .encrypt_padded_vec_mut::(plaintext.as_bytes()); + + let mut mac_input = Vec::with_capacity(16 + encrypted.len()); + mac_input.extend_from_slice(&iv); + mac_input.extend_from_slice(&encrypted); + + let mut hmac = Hmac::::new_from_slice(key.mac_key()).unwrap(); + hmac.update(&mac_input); + let mac = hmac.finalize().into_bytes(); + + format!( + "2.{}|{}|{}", + B64.encode(iv), + B64.encode(&encrypted), + B64.encode(mac) + ) +} + +pub fn enc_string_decrypt(enc_str: &str, key: &SymmetricKey) -> Result { + let raw = enc_string_decrypt_bytes(enc_str, key)?; + String::from_utf8(raw.to_vec()).map_err(|_| "invalid utf8") +} + +pub fn enc_string_decrypt_bytes(enc_str: &str, key: &SymmetricKey) -> Result>, &'static str> { + let (_t, rest) = enc_str.split_once('.').ok_or("bad format")?; + let parts: Vec<&str> = rest.split('|').collect(); + if parts.len() < 2 { + return Err("bad format"); + } + + let iv = B64.decode(parts[0]).map_err(|_| "bad iv")?; + let ct = B64.decode(parts[1]).map_err(|_| "bad ct")?; + + if parts.len() > 2 { + let mac_got = B64.decode(parts[2]).map_err(|_| "bad mac")?; + let mut hmac = Hmac::::new_from_slice(key.mac_key()).unwrap(); + let mut mac_input = Vec::with_capacity(iv.len() + ct.len()); + mac_input.extend_from_slice(&iv); + mac_input.extend_from_slice(&ct); + hmac.update(&mac_input); + hmac.verify_slice(&mac_got).map_err(|_| "MAC mismatch")?; + } + + let decrypted = Aes256CbcDec::new(key.enc_key().into(), iv.as_slice().into()) + .decrypt_padded_vec_mut::(&ct) + .map_err(|_| "decrypt failed")?; + + Ok(Zeroizing::new(decrypted)) +} + +pub fn enc_string_to_json(enc_str: &str) -> serde_json::Value { + let (t, rest) = enc_str.split_once('.').unwrap_or(("2", enc_str)); + let parts: Vec<&str> = rest.split('|').collect(); + let mut m = serde_json::Map::new(); + m.insert("encryptionType".into(), serde_json::json!(t.parse::().unwrap_or(2))); + m.insert("encryptedString".into(), serde_json::json!(enc_str)); + if let Some(iv) = parts.first() { + m.insert("iv".into(), serde_json::json!(iv)); + } + if let Some(data) = parts.get(1) { + m.insert("data".into(), serde_json::json!(data)); + } + if let Some(mac) = parts.get(2) { + m.insert("mac".into(), serde_json::json!(mac)); + } + serde_json::Value::Object(m) +} + +pub fn json_to_enc_string(v: &serde_json::Value) -> String { + if let Some(s) = v.get("encryptedString").and_then(|s| s.as_str()) { + return s.to_string(); + } + let t = v.get("encryptionType").and_then(|t| t.as_u64()).unwrap_or(2); + let iv = v.get("iv").and_then(|s| s.as_str()).unwrap_or(""); + let data = v.get("data").and_then(|s| s.as_str()).unwrap_or(""); + let mac = v.get("mac").and_then(|s| s.as_str()).unwrap_or(""); + format!("{t}.{iv}|{data}|{mac}") +} diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..82542e9 --- /dev/null +++ b/src/ipc.rs @@ -0,0 +1,124 @@ +use std::io::{Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; + +const MAX_MSG: usize = 1024 * 1024; + +pub fn socket_path() -> PathBuf { + let cache = dirs_cache().join("com.bitwarden.desktop"); + std::fs::create_dir_all(&cache).ok(); + cache.join("s.bw") +} + +fn dirs_cache() -> PathBuf { + dirs_home().join(".cache") +} + +fn dirs_home() -> PathBuf { + std::env::var("HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("/tmp")) +} + +fn recv_exact(stream: &mut UnixStream, n: usize) -> Option> { + let mut buf = vec![0u8; n]; + let mut pos = 0; + while pos < n { + match stream.read(&mut buf[pos..]) { + Ok(0) => return None, + Ok(k) => pos += k, + Err(_) => return None, + } + } + Some(buf) +} + +pub fn read_message(stream: &mut UnixStream) -> Option { + let header = recv_exact(stream, 4)?; + let length = u32::from_ne_bytes(header[..4].try_into().ok()?) as usize; + if length == 0 || length > MAX_MSG { + return None; + } + let data = recv_exact(stream, length)?; + serde_json::from_slice(&data).ok() +} + +pub fn send_message(stream: &mut UnixStream, msg: &serde_json::Value) { + let data = serde_json::to_vec(msg).unwrap(); + let len_bytes = (data.len() as u32).to_ne_bytes(); + let _ = stream.write_all(&len_bytes); + let _ = stream.write_all(&data); +} + +pub fn serve(sock_path: &Path, mut handler: F) +where + F: FnMut(serde_json::Value) -> Option, +{ + if sock_path.exists() { + std::fs::remove_file(sock_path).ok(); + } + + let listener = UnixListener::bind(sock_path).unwrap_or_else(|e| { + crate::log::fatal(&format!("bind failed: {e}")); + }); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(sock_path, std::fs::Permissions::from_mode(0o600)).ok(); + } + + let cleanup = || { + if sock_path.exists() { + std::fs::remove_file(sock_path).ok(); + } + }; + + ctrlc_cleanup(sock_path.to_path_buf()); + + for stream in listener.incoming() { + match stream { + Ok(mut conn) => { + crate::log::info("client connected"); + handle_conn(&mut conn, &mut handler); + crate::log::info("client disconnected"); + } + Err(_) => break, + } + } + + cleanup(); +} + +fn ctrlc_cleanup(path: PathBuf) { + ctrlc::set_handler(move || { + if path.exists() { + std::fs::remove_file(&path).ok(); + } + std::process::exit(0); + }) + .ok(); +} + +fn handle_conn(conn: &mut UnixStream, handler: &mut F) +where + F: FnMut(serde_json::Value) -> Option, +{ + loop { + let msg = match read_message(conn) { + Some(m) => m, + None => break, + }; + + if msg.get("command").is_some() && msg.get("appId").is_none() { + if let Some(cmd) = msg.get("command").and_then(|c| c.as_str()) { + crate::log::info(&format!("proxy: {cmd}")); + } + continue; + } + + if let Some(resp) = handler(msg) { + send_message(conn, &resp); + } + } +} diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..0982679 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,26 @@ +use std::sync::OnceLock; +use std::time::Instant; + +static START: OnceLock = OnceLock::new(); + +fn ts() -> f64 { + let start = START.get_or_init(Instant::now); + start.elapsed().as_secs_f64() +} + +pub fn info(msg: &str) { + eprintln!("[{:8.3}] {msg}", ts()); +} + +pub fn warn(msg: &str) { + eprintln!("[{:8.3}] WARN {msg}", ts()); +} + +pub fn error(msg: &str) { + eprintln!("[{:8.3}] ERROR {msg}", ts()); +} + +pub fn fatal(msg: &str) -> ! { + error(msg); + std::process::exit(1); +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a2b1ce2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,105 @@ +mod askpass; +mod auth; +mod bridge; +mod crypto; +mod ipc; +mod log; +mod storage; + +use clap::Parser; +use sha2::{Digest, Sha256}; +use zeroize::Zeroize; + +use askpass::get_prompter; +use bridge::BiometricBridge; +use storage::get_backend; + +#[derive(Parser)] +#[command(about = "Bitwarden desktop bridge agent")] +struct Args { + #[arg(long)] + email: String, + + #[arg(long)] + password: Option, + + #[arg(long, default_value = "https://vault.bitwarden.com")] + server: String, + + #[arg(long)] + backend: Option, + + #[arg(long)] + askpass: Option, + + #[arg(long)] + enroll: bool, + + #[arg(long)] + remove: bool, +} + +fn user_hash(email: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(email.to_lowercase().trim().as_bytes()); + let hash = hasher.finalize(); + hex::encode(&hash[..8]) +} + +fn main() { + let args = Args::parse(); + let uid = user_hash(&args.email); + let store = get_backend(args.backend.as_deref()); + let prompt = get_prompter(args.askpass.as_deref()); + log::info(&format!("backend: {}", store.name())); + + if args.remove { + if store.has_key(&uid) { + store.remove(&uid); + log::info(&format!("key removed for {}", args.email)); + } else { + log::info(&format!("no key found for {}", args.email)); + } + return; + } + + if !store.has_key(&uid) || args.enroll { + log::info(if !store.has_key(&uid) { + "enrolling" + } else { + "re-enrolling" + }); + + let pw = args + .password + .clone() + .or_else(|| prompt("master password:")) + .unwrap_or_else(|| log::fatal("no password provided")); + + log::info(&format!("logging in as {}", args.email)); + let (mut key_bytes, server_uid) = auth::login(&args.email, &pw, &args.server, &prompt); + log::info(&format!("authenticated, uid={server_uid}")); + + let auth = prompt(&format!("choose {} password:", store.name())) + .unwrap_or_else(|| log::fatal("no password provided")); + let auth2 = prompt(&format!("confirm {} password:", store.name())) + .unwrap_or_else(|| log::fatal("no password provided")); + if auth != auth2 { + log::fatal("passwords don't match"); + } + + store + .store(&uid, &key_bytes, &auth) + .unwrap_or_else(|e| log::fatal(&format!("store failed: {e}"))); + key_bytes.zeroize(); + log::info(&format!("key sealed via {}", store.name())); + log::info("wiped key from memory"); + } else { + log::info(&format!("key ready for {}", args.email)); + } + + let mut bridge = BiometricBridge::new(store, uid, prompt); + let sock = ipc::socket_path(); + log::info(&format!("listening on {}", sock.display())); + ipc::serve(&sock, |msg| bridge.handle(msg)); +} diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..9c4de1b --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,90 @@ +use std::io::{self, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::PathBuf; +use std::thread; + +const MAX_MSG: usize = 1024 * 1024; + +fn socket_path() -> String { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(home) + .join(".cache") + .join("com.bitwarden.desktop") + .join("s.bw") + .to_string_lossy() + .into_owned() +} + +fn read_stdin() -> Option> { + let mut header = [0u8; 4]; + io::stdin().read_exact(&mut header).ok()?; + let length = u32::from_ne_bytes(header) as usize; + if length == 0 || length > MAX_MSG { + return None; + } + let mut data = vec![0u8; length]; + io::stdin().read_exact(&mut data).ok()?; + Some(data) +} + +fn write_stdout(data: &[u8]) { + let len_bytes = (data.len() as u32).to_ne_bytes(); + let stdout = io::stdout(); + let mut out = stdout.lock(); + let _ = out.write_all(&len_bytes); + let _ = out.write_all(data); + let _ = out.flush(); +} + +fn recv_exact(sock: &mut UnixStream, n: usize) -> Option> { + let mut buf = vec![0u8; n]; + let mut pos = 0; + while pos < n { + match sock.read(&mut buf[pos..]) { + Ok(0) => return None, + Ok(k) => pos += k, + Err(_) => return None, + } + } + Some(buf) +} + +fn read_ipc(sock: &mut UnixStream) -> Option> { + let header = recv_exact(sock, 4)?; + let length = u32::from_ne_bytes(header[..4].try_into().ok()?) as usize; + if length == 0 || length > MAX_MSG { + return None; + } + recv_exact(sock, length) +} + +fn send_ipc(sock: &mut UnixStream, data: &[u8]) { + let len_bytes = (data.len() as u32).to_ne_bytes(); + let _ = sock.write_all(&len_bytes); + let _ = sock.write_all(data); +} + +fn main() { + let mut sock = UnixStream::connect(socket_path()).unwrap_or_else(|_| std::process::exit(1)); + + send_ipc(&mut sock, b"{\"command\":\"connected\"}"); + + let mut sock2 = sock.try_clone().unwrap(); + thread::spawn(move || { + loop { + match read_ipc(&mut sock2) { + Some(msg) => write_stdout(&msg), + None => break, + } + } + }); + + loop { + match read_stdin() { + Some(msg) => send_ipc(&mut sock, &msg), + None => break, + } + } + + let _ = send_ipc(&mut sock, b"{\"command\":\"disconnected\"}"); +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..9933e24 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,17 @@ +pub mod pin; + +pub trait KeyStore { + fn name(&self) -> &str; + fn is_available(&self) -> bool; + fn has_key(&self, uid: &str) -> bool; + fn store(&self, uid: &str, data: &[u8], auth: &str) -> Result<(), String>; + fn load(&self, uid: &str, auth: &str) -> Result, String>; + fn remove(&self, uid: &str); +} + +pub fn get_backend(preferred: Option<&str>) -> Box { + match preferred { + Some("pin") => Box::new(pin::PinKeyStore::new(None)), + _ => Box::new(pin::PinKeyStore::new(None)), + } +} diff --git a/src/storage/pin.rs b/src/storage/pin.rs new file mode 100644 index 0000000..fab4909 --- /dev/null +++ b/src/storage/pin.rs @@ -0,0 +1,124 @@ +use std::fs; +use std::path::PathBuf; + +use aes_gcm::{ + aead::{Aead, KeyInit}, + Aes256Gcm, Nonce, +}; +use rand::RngCore; +use scrypt::{scrypt, Params}; +use zeroize::Zeroizing; + +use super::KeyStore; + +const VERSION: u8 = 1; +const SCRYPT_LOG_N: u8 = 17; +const SCRYPT_R: u32 = 8; +const SCRYPT_P: u32 = 1; + +fn store_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + PathBuf::from(home) + .join(".cache") + .join("com.bitwarden.desktop") + .join("keys") +} + +pub struct PinKeyStore { + dir: PathBuf, +} + +impl PinKeyStore { + pub fn new(dir: Option) -> Self { + let dir = dir.unwrap_or_else(store_dir); + fs::create_dir_all(&dir).ok(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).ok(); + } + Self { dir } + } + + fn path(&self, uid: &str) -> PathBuf { + self.dir.join(format!("{uid}.enc")) + } +} + +impl KeyStore for PinKeyStore { + fn name(&self) -> &str { + "pin" + } + + fn is_available(&self) -> bool { + true + } + + fn has_key(&self, uid: &str) -> bool { + self.path(uid).exists() + } + + fn store(&self, uid: &str, data: &[u8], auth: &str) -> Result<(), String> { + let mut salt = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut salt); + + let key = derive(auth, &salt)?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; + + let mut nonce_bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ct = cipher + .encrypt(nonce, aes_gcm::aead::Payload { msg: data, aad: &salt }) + .map_err(|e| e.to_string())?; + + let mut blob = Vec::with_capacity(1 + 32 + 12 + ct.len()); + blob.push(VERSION); + blob.extend_from_slice(&salt); + blob.extend_from_slice(&nonce_bytes); + blob.extend_from_slice(&ct); + + let path = self.path(uid); + fs::write(&path, &blob).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).ok(); + } + Ok(()) + } + + fn load(&self, uid: &str, auth: &str) -> Result, String> { + let blob = fs::read(self.path(uid)).map_err(|e| e.to_string())?; + if blob.len() <= 1 + 32 + 12 + 16 { + return Err("file too short".into()); + } + + let salt = &blob[1..33]; + let nonce_bytes = &blob[33..45]; + let ct = &blob[45..]; + + let key = derive(auth, salt)?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; + let nonce = Nonce::from_slice(nonce_bytes); + + cipher + .decrypt(nonce, aes_gcm::aead::Payload { msg: ct, aad: salt }) + .map_err(|_| "wrong password or corrupted data".into()) + } + + fn remove(&self, uid: &str) { + let p = self.path(uid); + if p.exists() { + fs::remove_file(p).ok(); + } + } +} + +fn derive(password: &str, salt: &[u8]) -> Result>, String> { + let params = Params::new(SCRYPT_LOG_N, SCRYPT_R, SCRYPT_P, 32).map_err(|e| e.to_string())?; + let mut key = Zeroizing::new(vec![0u8; 32]); + scrypt(password.as_bytes(), salt, ¶ms, &mut key).map_err(|e| e.to_string())?; + Ok(key) +} diff --git a/storage/__init__.py b/storage/__init__.py deleted file mode 100644 index c174869..0000000 --- a/storage/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -from abc import ABC, abstractmethod - - -class KeyStore(ABC): - @abstractmethod - def is_available(self) -> bool: ... - - @abstractmethod - def has_key(self, user_id: str) -> bool: ... - - @abstractmethod - def store(self, user_id: str, data: bytes, auth: str) -> None: ... - - @abstractmethod - def load(self, user_id: str, auth: str) -> bytes: ... - - @abstractmethod - def remove(self, user_id: str) -> None: ... - - @property - @abstractmethod - def name(self) -> str: ... - - -def get_backend(preferred: str | None = None) -> KeyStore: - from .tpm2 import TPM2KeyStore - from .pin import PinKeyStore - - if preferred == "pin": - return PinKeyStore() - if preferred == "tpm2": - store = TPM2KeyStore() - if not store.is_available(): - raise RuntimeError("TPM2 not available") - return store - - tpm = TPM2KeyStore() - if tpm.is_available(): - return tpm - return PinKeyStore() diff --git a/storage/pin.py b/storage/pin.py deleted file mode 100644 index 9c4f3e1..0000000 --- a/storage/pin.py +++ /dev/null @@ -1,65 +0,0 @@ -import hashlib -import os -from pathlib import Path - -from cryptography.hazmat.primitives.ciphers.aead import AESGCM - -from . import KeyStore - -VERSION = 1 -STORE_DIR = Path.home() / ".cache" / "com.bitwarden.desktop" / "keys" - -SCRYPT_N = 2**17 -SCRYPT_R = 8 -SCRYPT_P = 1 -SCRYPT_MAXMEM = 256 * 1024 * 1024 - - -class PinKeyStore(KeyStore): - def __init__(self, store_dir: Path = STORE_DIR): - self._dir = store_dir - self._dir.mkdir(parents=True, exist_ok=True) - os.chmod(str(self._dir), 0o700) - - @property - def name(self) -> str: - return "pin" - - def _path(self, uid: str) -> Path: - return self._dir / f"{uid}.enc" - - def is_available(self) -> bool: - return True - - def has_key(self, uid: str) -> bool: - return self._path(uid).exists() - - def store(self, uid: str, data: bytes, auth: str): - salt = os.urandom(32) - key = _derive(auth, salt) - nonce = os.urandom(12) - ct = AESGCM(key).encrypt(nonce, data, salt) - blob = bytes([VERSION]) + salt + nonce + ct - self._path(uid).write_bytes(blob) - os.chmod(str(self._path(uid)), 0o600) - - def load(self, uid: str, auth: str) -> bytes: - blob = self._path(uid).read_bytes() - _ver, salt, nonce, ct = blob[0], blob[1:33], blob[33:45], blob[45:] - key = _derive(auth, salt) - try: - return AESGCM(key).decrypt(nonce, ct, salt) - except Exception: - raise ValueError("wrong password or corrupted data") - - def remove(self, uid: str): - p = self._path(uid) - if p.exists(): - p.unlink() - - -def _derive(password: str, salt: bytes) -> bytes: - return hashlib.scrypt( - password.encode(), salt=salt, - n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=32, maxmem=SCRYPT_MAXMEM, - ) diff --git a/storage/tpm2.py b/storage/tpm2.py deleted file mode 100644 index e2b236b..0000000 --- a/storage/tpm2.py +++ /dev/null @@ -1,76 +0,0 @@ -import os -import subprocess -import tempfile -from pathlib import Path - -from . import KeyStore - -STORE_DIR = Path.home() / ".cache" / "com.bitwarden.desktop" / "tpm2" - - -class TPM2KeyStore(KeyStore): - def __init__(self, store_dir: Path = STORE_DIR): - self._dir = store_dir - self._dir.mkdir(parents=True, exist_ok=True) - os.chmod(str(self._dir), 0o700) - - @property - def name(self) -> str: - return "tpm2" - - def _pub(self, uid: str) -> Path: - return self._dir / f"{uid}.pub" - - def _priv(self, uid: str) -> Path: - return self._dir / f"{uid}.priv" - - def is_available(self) -> bool: - try: - return subprocess.run( - ["tpm2_getcap", "properties-fixed"], - capture_output=True, timeout=5, - ).returncode == 0 - except (FileNotFoundError, subprocess.TimeoutExpired): - return False - - def has_key(self, uid: str) -> bool: - return self._pub(uid).exists() and self._priv(uid).exists() - - def store(self, uid: str, data: bytes, auth: str): - with tempfile.TemporaryDirectory() as tmp: - t = Path(tmp) - ctx = t / "primary.ctx" - dat = t / "data.bin" - dat.write_bytes(data) - os.chmod(str(dat), 0o600) - - _run(["tpm2_createprimary", "-C", "o", "-g", "sha256", "-G", "aes256cfb", "-c", str(ctx)]) - _run(["tpm2_create", "-C", str(ctx), "-i", str(dat), - "-u", str(self._pub(uid)), "-r", str(self._priv(uid)), "-p", auth]) - dat.write_bytes(b"\x00" * len(data)) - - os.chmod(str(self._pub(uid)), 0o600) - os.chmod(str(self._priv(uid)), 0o600) - - def load(self, uid: str, auth: str) -> bytes: - with tempfile.TemporaryDirectory() as tmp: - t = Path(tmp) - ctx = t / "primary.ctx" - loaded = t / "loaded.ctx" - - _run(["tpm2_createprimary", "-C", "o", "-g", "sha256", "-G", "aes256cfb", "-c", str(ctx)]) - _run(["tpm2_load", "-C", str(ctx), - "-u", str(self._pub(uid)), "-r", str(self._priv(uid)), "-c", str(loaded)]) - return _run(["tpm2_unseal", "-c", str(loaded), "-p", auth]).stdout - - def remove(self, uid: str): - for p in (self._pub(uid), self._priv(uid)): - if p.exists(): - p.unlink() - - -def _run(cmd: list[str]) -> subprocess.CompletedProcess: - r = subprocess.run(cmd, capture_output=True, timeout=30) - if r.returncode != 0: - raise RuntimeError(r.stderr.decode(errors="replace").strip()) - return r