From 880eb286e8076f84f5d5afab6641cfc58f59c8e1 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Fri, 15 Aug 2025 15:57:56 +0200 Subject: [PATCH] Add new `uv-keyring` crate that vendors the `keyring-rs` crate (#14725) This PR is a first step toward support for storing credentials in the system keyring. The `keyring-rs` crate is the best option for system keyring integration, but the latest version (v4) requires either that Linux users have `libdbus` installed or that it is built with `libdbus` vendored in. This is because v4 depends on [dbus-secret-service](https://github.com/open-source-cooperative/dbus-secret-service), which was created as an alternative to [secret-service](https://github.com/open-source-cooperative/secret-service-rs) so that users are not required to use an async runtime. Since uv does use an async runtime, this is not a good tradeoff for uv. This PR: * Vendors `keyring-rs` crate into a new `uv-keyring` workspace crate * Moves to the async `secret-service` crate that does not require clients on Linux to have `libdbus` on their machines. This includes updating `CredentialsAPI` trait (and implementations) to use async methods. * Adds `uv-keyring` tests to `cargo test` jobs. For `cargo test | ubuntu`, this meant setting up secret service and priming gnome-keyring as an earlier step. * Removes iOS code paths * Patches in @oconnor663 's changes from his [`keyring-rs` PR](https://github.com/open-source-cooperative/keyring-rs/pull/261) * Applies many clippy-driven updates --- .github/workflows/ci.yml | 17 +- Cargo.lock | 426 ++++++++++- crates/uv-keyring/Cargo.toml | 45 ++ crates/uv-keyring/README.md | 64 ++ crates/uv-keyring/src/blocking.rs | 11 + crates/uv-keyring/src/credential.rs | 187 +++++ crates/uv-keyring/src/error.rs | 128 ++++ crates/uv-keyring/src/lib.rs | 610 ++++++++++++++++ crates/uv-keyring/src/macos.rs | 449 ++++++++++++ crates/uv-keyring/src/mock.rs | 341 +++++++++ crates/uv-keyring/src/secret_service.rs | 932 ++++++++++++++++++++++++ crates/uv-keyring/src/windows.rs | 787 ++++++++++++++++++++ crates/uv-keyring/tests/basic.rs | 175 +++++ crates/uv-keyring/tests/common/mod.rs | 33 + crates/uv-keyring/tests/threading.rs | 280 +++++++ 15 files changed, 4474 insertions(+), 11 deletions(-) create mode 100644 crates/uv-keyring/Cargo.toml create mode 100644 crates/uv-keyring/README.md create mode 100644 crates/uv-keyring/src/blocking.rs create mode 100644 crates/uv-keyring/src/credential.rs create mode 100644 crates/uv-keyring/src/error.rs create mode 100644 crates/uv-keyring/src/lib.rs create mode 100644 crates/uv-keyring/src/macos.rs create mode 100644 crates/uv-keyring/src/mock.rs create mode 100644 crates/uv-keyring/src/secret_service.rs create mode 100644 crates/uv-keyring/src/windows.rs create mode 100644 crates/uv-keyring/tests/basic.rs create mode 100644 crates/uv-keyring/tests/common/mod.rs create mode 100644 crates/uv-keyring/tests/threading.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 772a1c4b4..a3af53041 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,6 +232,17 @@ jobs: - name: "Install required Python versions" run: uv python install + - name: "Install secret service" + run: | + sudo apt update -y + sudo apt install -y gnome-keyring + + - name: "Start gnome-keyring" + # run gnome-keyring with 'foobar' as password for the login keyring + # this will create a new login keyring and unlock it + # the login password doesn't matter, but the keyring must be unlocked for the tests to work + run: gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar' + - name: "Install cargo nextest" uses: taiki-e/install-action@a416ddeedbd372e614cc1386e8b642692f66865e # v2.57.1 with: @@ -243,7 +254,7 @@ jobs: UV_HTTP_RETRIES: 5 run: | cargo nextest run \ - --features python-patch \ + --features python-patch,keyring-tests,secret-service \ --workspace \ --status-level skip --failure-output immediate-final --no-fail-fast -j 20 --final-status-level slow @@ -282,7 +293,7 @@ jobs: run: | cargo nextest run \ --no-default-features \ - --features python,python-managed,pypi,git,performance,crates-io \ + --features python,python-managed,pypi,git,performance,crates-io,keyring-tests,apple-native \ --workspace \ --status-level skip --failure-output immediate-final --no-fail-fast -j 12 --final-status-level slow @@ -334,7 +345,7 @@ jobs: run: | cargo nextest run \ --no-default-features \ - --features python,pypi,python-managed \ + --features python,pypi,python-managed,keyring-tests,windows-native \ --workspace \ --status-level skip --failure-output immediate-final --no-fail-fast -j 20 --final-status-level slow diff --git a/Cargo.lock b/Cargo.lock index 87f13eaf9..13d693a65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -187,6 +198,18 @@ dependencies = [ "xattr", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -217,6 +240,17 @@ dependencies = [ "zstd-safe", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.88" @@ -418,6 +452,15 @@ 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 = "boxcar" version = "0.2.13" @@ -545,6 +588,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[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.30" @@ -605,6 +657,16 @@ dependencies = [ "half", ] +[[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.5.43" @@ -1129,12 +1191,62 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_home" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1586,6 +1698,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.11" @@ -1903,6 +2033,16 @@ version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +[[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 = "insta" version = "1.43.1" @@ -2262,6 +2402,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miette" version = "7.6.0" @@ -2396,6 +2545,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] @@ -2423,6 +2573,70 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[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-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2481,6 +2695,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_str_bytes" version = "6.6.1" @@ -2767,6 +2991,15 @@ dependencies = [ "indexmap", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit 0.22.27", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -3518,6 +3751,25 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "secret-service" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dccff79e916a339eec808de579764e3459658c903960d5aa4f7959ee9f6d5f2b" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.16", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.2.0" @@ -3612,6 +3864,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "1.0.0" @@ -3784,6 +4047,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "statrs" version = "0.18.0" @@ -4211,6 +4480,7 @@ dependencies = [ "slab", "socket2 0.6.0", "tokio-macros", + "tracing", "windows-sys 0.59.0", ] @@ -4271,12 +4541,18 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.0", "toml_parser", "toml_writer", "winnow", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "0.7.0" @@ -4286,6 +4562,17 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "winnow", +] + [[package]] name = "toml_edit" version = "0.23.2" @@ -4295,7 +4582,7 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.7.0", "toml_parser", "toml_writer", "winnow", @@ -4516,6 +4803,17 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicase" version = "2.8.1" @@ -4736,7 +5034,7 @@ dependencies = [ "tokio", "tokio-util", "toml", - "toml_edit", + "toml_edit 0.23.2", "tracing", "tracing-durations-export", "tracing-subscriber", @@ -4920,7 +5218,7 @@ dependencies = [ "tempfile", "thiserror 2.0.12", "tokio", - "toml_edit", + "toml_edit 0.23.2", "tracing", "uv-cache-key", "uv-configuration", @@ -5494,6 +5792,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "uv-keyring" +version = "0.0.1" +dependencies = [ + "async-trait", + "byteorder", + "doc-comment", + "env_logger", + "fastrand", + "secret-service", + "security-framework", + "tokio", + "windows-sys 0.59.0", + "zeroize", +] + [[package]] name = "uv-macros" version = "0.0.1" @@ -5684,7 +5998,7 @@ dependencies = [ "serde", "serde-untagged", "thiserror 2.0.12", - "toml_edit", + "toml_edit 0.23.2", "tracing", "url", "uv-cache-key", @@ -5866,7 +6180,7 @@ dependencies = [ "tokio", "tokio-stream", "toml", - "toml_edit", + "toml_edit 0.23.2", "tracing", "url", "uv-cache-key", @@ -6002,7 +6316,7 @@ dependencies = [ "serde", "thiserror 2.0.12", "toml", - "toml_edit", + "toml_edit 0.23.2", "tracing", "uv-cache", "uv-configuration", @@ -6132,7 +6446,7 @@ dependencies = [ "thiserror 2.0.12", "tokio", "toml", - "toml_edit", + "toml_edit 0.23.2", "tracing", "uv-build-backend", "uv-cache-key", @@ -6908,6 +7222,61 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597f45e98bc7e6f0988276012797855613cd8269e23b5be62cc4e5d28b7e515d" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix 0.30.1", + "ordered-stream", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5c8e4e14dcdd9d97a98b189cd1220f30e8394ad271e8c987da84f73693862c2" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.26" @@ -7054,3 +7423,44 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn", + "winnow", +] diff --git a/crates/uv-keyring/Cargo.toml b/crates/uv-keyring/Cargo.toml new file mode 100644 index 000000000..909b06d59 --- /dev/null +++ b/crates/uv-keyring/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "uv-keyring" +version = "0.0.1" +edition = { workspace = true } + +[lib] +doctest = false + +[lints] +workspace = true + +[features] +default = ["apple-native", "secret-service", "windows-native"] +keyring-tests = [] + +## Use the built-in Keychain Services on macOS +apple-native = ["dep:security-framework"] +## Use the secret-service on *nix. +secret-service = ["dep:secret-service"] +## Use the built-in credential store on Windows +windows-native = ["dep:windows-sys", "dep:byteorder"] + +[dependencies] +async-trait = { workspace = true } +tokio = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = { version = "3", optional = true } + +[target.'cfg(any(target_os = "linux",target_os = "freebsd", target_os = "openbsd"))'.dependencies] +secret-service = { version = "5.0.0", features = ["rt-tokio-crypto-rust"], optional = true } + +[target.'cfg(target_os = "windows")'.dependencies] +byteorder = { version = "1", optional = true } +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Credentials"], optional = true } +zeroize = "1.8.1" + +[dev-dependencies] +doc-comment = "0.3" +env_logger = "0.11.5" +fastrand = "2" + +[package.metadata.docs.rs] +default-target = "x86_64-unknown-linux-gnu" +targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-pc-windows-msvc"] diff --git a/crates/uv-keyring/README.md b/crates/uv-keyring/README.md new file mode 100644 index 000000000..613bff88a --- /dev/null +++ b/crates/uv-keyring/README.md @@ -0,0 +1,64 @@ +# uv-keyring + +This is vendored from [keyring-rs crate](https://github.com/open-source-cooperative/keyring-rs) +commit 9635a2f53a19eb7f188cdc4e38982dcb19caee00. + +A cross-platform library to manage storage and retrieval of passwords (and other secrets) in the +underlying platform secure store, with a fully-developed example that provides a command-line +interface. + +## Usage + +You can use the `Entry::new` function to create a new keyring entry. The `new` function takes a +service name and a user's name which together identify the entry. + +Passwords (strings) or secrets (binary data) can be added to an entry using its `set_password` or +`set_secret` methods, respectively. (These methods create or update an entry in the underlying +platform's persistent credential store.) The password or secret can then be read back using the +`get_password` or `get_secret` methods. The underlying credential (with its password/secret data) +can then be removed using the `delete_credential` method. + +```rust +use keyring::{Entry, Result}; + +fn main() -> Result<()> { + let entry = Entry::new("my-service", "my-name")?; + entry.set_password("topS3cr3tP4$$w0rd").await?; + let password = entry.get_password().await?; + println!("My password is '{}'", password); + entry.delete_credential().await?; + Ok(()) +} +``` + +## Errors + +Creating and operating on entries can yield a `keyring::Error` which provides both a +platform-independent code that classifies the error and, where relevant, underlying platform errors +or more information about what went wrong. + +## Platforms + +This crate provides built-in implementations of the following platform-specific credential stores: + +- _Linux_, _FreeBSD_, _OpenBSD_: The DBus-based Secret Service. +- _macOS_: Keychain Services. +- _Windows_: The Windows Credential Manager. + +It can be built and used on other platforms, but will not provide a built-in credential store +implementation; you will have to bring your own. + +### Platform-specific issues + +If you use the _Secret Service_ as your credential store, be aware that every call to the Secret +Service is done via an inter-process call, which takes time (typically tens if not hundreds of +milliseconds). + +If you use the _Windows-native credential store_, be careful about multi-threaded access, because +the Windows credential store does not guarantee your calls will be serialized in the order they are +made. Always access any single credential from just one thread at a time, and if you are doing +operations on multiple credentials that require a particular serialization order, perform all those +operations from the same thread. + +The _macOS credential store_ does not allow service names or usernames to be empty, because empty +fields are treated as wildcards on lookup. Use some default, non-empty value instead. diff --git a/crates/uv-keyring/src/blocking.rs b/crates/uv-keyring/src/blocking.rs new file mode 100644 index 000000000..eae0a7c9c --- /dev/null +++ b/crates/uv-keyring/src/blocking.rs @@ -0,0 +1,11 @@ +use crate::error::{Error as ErrorCode, Result}; + +pub(crate) async fn spawn_blocking(f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(f) + .await + .map_err(|e| ErrorCode::PlatformFailure(Box::new(e)))? +} diff --git a/crates/uv-keyring/src/credential.rs b/crates/uv-keyring/src/credential.rs new file mode 100644 index 000000000..3622cff60 --- /dev/null +++ b/crates/uv-keyring/src/credential.rs @@ -0,0 +1,187 @@ +/*! + +# Platform-independent secure storage model + +This module defines a plug and play model for platform-specific credential stores. +The model comprises two traits: [`CredentialBuilderApi`] for the underlying store +and [`CredentialApi`] for the entries in the store. These traits must be implemented +in a thread-safe way, a requirement captured in the [`CredentialBuilder`] and +[`Credential`] types that wrap them. + */ +use std::any::Any; +use std::collections::HashMap; + +use crate::Result; + +/// The API that [credentials](Credential) implement. +#[async_trait::async_trait] +pub trait CredentialApi { + /// Set the credential's password (a string). + /// + /// This will persist the password in the underlying store. + async fn set_password(&self, password: &str) -> Result<()> { + self.set_secret(password.as_bytes()).await + } + + /// Set the credential's secret (a byte array). + /// + /// This will persist the secret in the underlying store. + async fn set_secret(&self, password: &[u8]) -> Result<()>; + + /// Retrieve the password (a string) from the underlying credential. + /// + /// This has no effect on the underlying store. If there is no credential + /// for this entry, a [`NoEntry`](crate::Error::NoEntry) error is returned. + async fn get_password(&self) -> Result { + let secret = self.get_secret().await?; + crate::error::decode_password(secret) + } + + /// Retrieve a secret (a byte array) from the credential. + /// + /// This has no effect on the underlying store. If there is no credential + /// for this entry, a [NoEntry](crate::Error::NoEntry) error is returned. + async fn get_secret(&self) -> Result>; + + /// Get the secure store attributes on this entry's credential. + /// + /// Each credential store may support reading and updating different + /// named attributes; see the documentation on each of the stores + /// for details. Note that the keyring itself uses some of these + /// attributes to map entries to their underlying credential; these + /// _controlled_ attributes are not available for reading or updating. + /// + /// We provide a default (no-op) implementation of this method + /// for backward compatibility with stores that don't implement it. + async fn get_attributes(&self) -> Result> { + // this should err in the same cases as get_secret, so first call that for effect + self.get_secret().await?; + // if we got this far, return success with no attributes + Ok(HashMap::new()) + } + + /// Update the secure store attributes on this entry's credential. + /// + /// Each credential store may support reading and updating different + /// named attributes; see the documentation on each of the stores + /// for details. The implementation will ignore any attribute names + /// that you supply that are not available for update. Because the + /// names used by the different stores tend to be distinct, you can + /// write cross-platform code that will work correctly on each platform. + /// + /// We provide a default no-op implementation of this method + /// for backward compatibility with stores that don't implement it. + async fn update_attributes(&self, _: &HashMap<&str, &str>) -> Result<()> { + // this should err in the same cases as get_secret, so first call that for effect + self.get_secret().await?; + // if we got this far, return success after setting no attributes + Ok(()) + } + + /// Delete the underlying credential, if there is one. + /// + /// This is not idempotent if the credential existed! + /// A second call to `delete_credential` will return + /// a [`NoEntry`](crate::Error::NoEntry) error. + async fn delete_credential(&self) -> Result<()>; + + /// Return the underlying concrete object cast to [Any]. + /// + /// This allows clients + /// to downcast the credential to its concrete type so they + /// can do platform-specific things with it (e.g., + /// query its attributes in the underlying store). + fn as_any(&self) -> &dyn Any; + + /// The `Debug` trait call for the object. + /// + /// This is used to implement the `Debug` trait on this type; it + /// allows generic code to provide debug printing as provided by + /// the underlying concrete object. + /// + /// We provide a (useless) default implementation for backward + /// compatibility with existing implementors who may have not + /// implemented the `Debug` trait for their credential objects + fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self.as_any(), f) + } +} + +/// A thread-safe implementation of the [Credential API](CredentialApi). +pub type Credential = dyn CredentialApi + Send + Sync; + +impl std::fmt::Debug for Credential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.debug_fmt(f) + } +} + +/// A descriptor for the lifetime of stored credentials, returned from +/// a credential store's [`persistence`](CredentialBuilderApi::persistence) call. +#[non_exhaustive] +pub enum CredentialPersistence { + /// Credentials vanish when the entry vanishes (stored in the entry) + EntryOnly, + /// Credentials vanish when the process terminates (stored in process memory) + ProcessOnly, + /// Credentials persist until the machine reboots (stored in kernel memory) + UntilReboot, + /// Credentials persist until they are explicitly deleted (stored on disk) + UntilDelete, +} + +/// The API that [credential builders](CredentialBuilder) implement. +pub trait CredentialBuilderApi { + /// Create a credential identified by the given target, service, and user. + /// + /// This typically has no effect on the content of the underlying store. + /// A credential need not be persisted until its password is set. + fn build(&self, target: Option<&str>, service: &str, user: &str) -> Result>; + + /// Return the underlying concrete object cast to [Any]. + /// + /// Because credential builders need not have any internal structure, + /// this call is not so much for clients + /// as it is to allow automatic derivation of a Debug trait for builders. + fn as_any(&self) -> &dyn Any; + + /// The lifetime of credentials produced by this builder. + /// + /// A default implementation is provided for backward compatibility, + /// since this API was added in a minor release. The default assumes + /// that keystores use disk-based credential storage. + fn persistence(&self) -> CredentialPersistence { + CredentialPersistence::UntilDelete + } +} + +impl std::fmt::Debug for CredentialBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_any().fmt(f) + } +} + +/// A thread-safe implementation of the [`CredentialBuilder` API](CredentialBuilderApi). +pub type CredentialBuilder = dyn CredentialBuilderApi + Send + Sync; + +struct NopCredentialBuilder; + +impl CredentialBuilderApi for NopCredentialBuilder { + fn build(&self, _: Option<&str>, _: &str, _: &str) -> Result> { + Err(super::Error::NoDefaultCredentialBuilder) + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn persistence(&self) -> CredentialPersistence { + CredentialPersistence::EntryOnly + } +} + +// Return a credential builder that always fails. This is the builder +// used if none of the crate-supplied keystores were included in the build. +pub fn nop_credential_builder() -> Box { + Box::new(NopCredentialBuilder) +} diff --git a/crates/uv-keyring/src/error.rs b/crates/uv-keyring/src/error.rs new file mode 100644 index 000000000..9f7e86c76 --- /dev/null +++ b/crates/uv-keyring/src/error.rs @@ -0,0 +1,128 @@ +/*! + +Platform-independent error model. + +There is an escape hatch here for surfacing platform-specific +error information returned by the platform-specific storage provider, +but the concrete objects returned must be `Send` so they can be +moved from one thread to another. (Since most platform errors +are integer error codes, this requirement +is not much of a burden on the platform-specific store providers.) + */ + +use crate::Credential; + +#[derive(Debug)] +/// Each variant of the `Error` enum provides a summary of the error. +/// More details, if relevant, are contained in the associated value, +/// which may be platform-specific. +/// +/// This enum is non-exhaustive so that more values can be added to it +/// without a `SemVer` break. Clients should always have default handling +/// for variants they don't understand. +#[non_exhaustive] +pub enum Error { + /// This indicates runtime failure in the underlying + /// platform storage system. The details of the failure can + /// be retrieved from the attached platform error. + PlatformFailure(Box), + /// This indicates that the underlying secure storage + /// holding saved items could not be accessed. Typically, this + /// is because of access rules in the platform; for example, it + /// might be that the credential store is locked. The underlying + /// platform error will typically give the reason. + NoStorageAccess(Box), + /// This indicates that there is no underlying credential + /// entry in the platform for this entry. Either one was + /// never set, or it was deleted. + NoEntry, + /// This indicates that the retrieved password blob was not + /// a UTF-8 string. The underlying bytes are available + /// for examination in the attached value. + BadEncoding(Vec), + /// This indicates that one of the entry's credential + /// attributes exceeded a + /// length limit in the underlying platform. The + /// attached values give the name of the attribute and + /// the platform length limit that was exceeded. + TooLong(String, u32), + /// This indicates that one of the entry's required credential + /// attributes was invalid. The + /// attached value gives the name of the attribute + /// and the reason it's invalid. + Invalid(String, String), + /// This indicates that there is more than one credential found in the store + /// that matches the entry. Its value is a vector of the matching credentials. + Ambiguous(Vec>), + /// This indicates that there was no default credential builder to use; + /// the client must set one before creating entries. + NoDefaultCredentialBuilder, +} + +pub type Result = std::result::Result; + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::PlatformFailure(err) => write!(f, "Platform secure storage failure: {err}"), + Self::NoStorageAccess(err) => { + write!(f, "Couldn't access platform secure storage: {err}") + } + Self::NoEntry => write!(f, "No matching entry found in secure storage"), + Self::BadEncoding(_) => write!(f, "Data is not UTF-8 encoded"), + Self::TooLong(name, len) => write!( + f, + "Attribute '{name}' is longer than platform limit of {len} chars" + ), + Self::Invalid(attr, reason) => { + write!(f, "Attribute {attr} is invalid: {reason}") + } + Self::Ambiguous(items) => { + write!( + f, + "Entry is matched by {} credentials: {items:?}", + items.len(), + ) + } + Self::NoDefaultCredentialBuilder => { + write!( + f, + "No default credential builder is available; set one before creating entries" + ) + } + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::PlatformFailure(err) => Some(err.as_ref()), + Self::NoStorageAccess(err) => Some(err.as_ref()), + _ => None, + } + } +} + +/// Try to interpret a byte vector as a password string +pub fn decode_password(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|err| Error::BadEncoding(err.into_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bad_password() { + // malformed sequences here taken from: + // https://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-test.txt + for bytes in [b"\x80".to_vec(), b"\xbf".to_vec(), b"\xed\xa0\xa0".to_vec()] { + match decode_password(bytes.clone()) { + Err(Error::BadEncoding(str)) => assert_eq!(str, bytes), + Err(other) => panic!("Bad password ({bytes:?}) decode gave wrong error: {other}"), + Ok(s) => panic!("Bad password ({bytes:?}) decode gave results: {s:?}"), + } + } + } +} diff --git a/crates/uv-keyring/src/lib.rs b/crates/uv-keyring/src/lib.rs new file mode 100644 index 000000000..2ddec8a79 --- /dev/null +++ b/crates/uv-keyring/src/lib.rs @@ -0,0 +1,610 @@ +#![cfg_attr(docsrs, feature(doc_cfg))] +/*! + +# Keyring + +This is a cross-platform library that does storage and retrieval of passwords +(or other secrets) in an underlying platform-specific secure credential store. +A top-level introduction to the library's usage, as well as a small code sample, +may be found in [the library's entry on crates.io](https://crates.io/crates/keyring). +Currently supported platforms are +Linux, +FreeBSD, +OpenBSD, +Windows, +and macOS. + +## Design + +This crate implements a very simple, platform-independent concrete object called an _entry_. +Each entry is identified by a <_service name_, _user name_> pair of UTF-8 strings. +Entries support setting, getting, and forgetting (aka deleting) passwords (UTF-8 strings) +and binary secrets (byte arrays). Each created entry provides security and persistence +of its secret by wrapping a credential held in a platform-specific, secure credential store. + +The cross-platform API for creating an _entry_ supports specifying an (optional) +UTF-8 _target_ attribute on entries, but the meaning of this +attribute is credential-store (and thus platform) specific, +and should not be thought of as part of the credential's identification. See the +documentation of each credential store to understand the +effect of specifying the _target_ attribute on entries in that store, +as well as which values are allowed for _target_ by that store. + +The abstract behavior of entries and credential stores are captured +by two types (with associated traits): + +- a _credential builder_, represented by the [`CredentialBuilder`] type + (and [`CredentialBuilderApi`](credential::CredentialBuilderApi) trait). Credential + builders are given the identifying information (and target, if any) + provided for an entry and map + it to the identifying information for a platform-specific credential. +- a _credential_, represented by the [`Credential`] type + (and [`CredentialApi`](credential::CredentialApi) trait). The platform-specific credential + identified by the builder for an entry is what provides the secure storage + for that entry's password/secret. + +## Crate-provided Credential Stores + +This crate runs on several different platforms, and on each one +it provides (by default) an implementation of a default credential store used +on that platform (see [`default_credential_builder`]). +These implementations work by mapping the data used to identify an entry +to data used to identify platform-specific storage objects. +For example, on macOS, the service and user provided for an entry +are mapped to the service and user attributes that identify a +generic credential in the macOS keychain. + +Typically, platform-specific credential stores (called _keystores_ in this crate) +have a richer model of a credential than +the one used by this crate to identify entries. +These keystores expose their specific model in the +concrete credential objects they use to implement the Credential trait. +In order to allow clients to access this richer model, the Credential trait +has an [`as_any`](credential::CredentialApi::as_any) method that returns a +reference to the underlying +concrete object typed as [`Any`](std::any::Any), so that it can be downgraded to +its concrete type. + +### Credential store features + +Each of the platform-specific credential stores is associated a feature. +This feature controls whether that store is included when the crate is built +for its specific platform. For example, the macOS Keychain credential store +implementation is only included if the `"apple-native"` feature is specified and the crate +is built with a macOS target. + +The available credential store features, listed here, are all included in the +default feature set: + +- `apple-native`: Provides access to the Keychain credential store on macOS. + +- `windows-native`: Provides access to the Windows Credential Store on Windows. + +- `secret-service`: Provides access to Secret Service. + +If you suppress the default feature set when building this crate, and you +don't separately specify one of the included keystore features for your platform, +then no keystore will be built in, and calls to [`Entry::new`] and [`Entry::new_with_target`] +will fail unless the client brings their own keystore (see next section). + +## Client-provided Credential Stores + +In addition to the keystores implemented by this crate, clients +are free to provide their own keystores and use those. There are +two mechanisms provided for this: + +- Clients can give their desired credential builder to the crate + for use by the [`Entry::new`] and [`Entry::new_with_target`] calls. + This is done by making a call to [`set_default_credential_builder`]. + The major advantage of this approach is that client code remains + independent of the credential builder being used. + +- Clients can construct their concrete credentials directly and + then turn them into entries by using the [`Entry::new_with_credential`] + call. The major advantage of this approach is that credentials + can be identified however clients want, rather than being restricted + to the simple model used by this crate. + +## Mock Credential Store + +In addition to the platform-specific credential stores, this crate +always provides a mock credential store that clients can use to +test their code in a platform independent way. The mock credential +store allows for pre-setting errors as well as password values to +be returned from [`Entry`] method calls. If you want to use the mock +credential store as your default in tests, make this call: +``` +uv_keyring::set_default_credential_builder(uv_keyring::mock::default_credential_builder()) +``` + +## Interoperability with Third Parties + +Each of the platform-specific credential stores provided by this crate uses +an underlying store that may also be used by modules written +in other languages. If you want to interoperate with these third party +credential writers, then you will need to understand the details of how the +target, service, and user of this crate's generic model +are used to identify credentials in the platform-specific store. +These details are in the implementation of this crate's keystores, +and are documented in the headers of those modules. + +(_N.B._ Since the included credential store implementations are platform-specific, +you may need to use the Platform drop-down on [docs.rs](https://docs.rs/keyring) to +view the storage module documentation for your desired platform.) + +## Caveats + +This module expects passwords to be UTF-8 encoded strings, +so if a third party has stored an arbitrary byte string +then retrieving that as a password will return a +[`BadEncoding`](Error::BadEncoding) error. +The returned error will have the raw bytes attached, +so you can access them, but you can also just fetch +them directly using [`get_secret`](Entry::get_secret) rather than +[`get_password`](Entry::get_password). + +While this crate's code is thread-safe, the underlying credential +stores may not handle access from different threads reliably. +In particular, accessing the same credential +from multiple threads at the same time can fail, especially on +Windows and Linux, because the accesses may not be serialized in the same order +they are made. And for RPC-based credential stores such as the dbus-based Secret +Service, accesses from multiple threads (and even the same thread very quickly) +are not recommended, as they may cause the RPC mechanism to fail. + */ + +use std::collections::HashMap; + +pub use credential::{Credential, CredentialBuilder}; +pub use error::{Error, Result}; + +#[cfg(any(target_os = "macos", target_os = "windows"))] +mod blocking; +pub mod mock; + +// +// pick the *nix keystore +// +#[cfg(all( + any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"), + feature = "secret-service" +))] +#[cfg_attr( + docsrs, + doc(cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))) +)] +pub mod secret_service; + +// +// pick the Apple keystore +// +#[cfg(all(target_os = "macos", feature = "apple-native"))] +#[cfg_attr(docsrs, doc(cfg(target_os = "macos")))] +pub mod macos; + +// +// pick the Windows keystore +// +#[cfg(all(target_os = "windows", feature = "windows-native"))] +#[cfg_attr(docsrs, doc(cfg(target_os = "windows")))] +pub mod windows; + +pub mod credential; +pub mod error; + +#[derive(Default, Debug)] +struct EntryBuilder { + inner: Option>, +} + +static DEFAULT_BUILDER: std::sync::RwLock = + std::sync::RwLock::new(EntryBuilder { inner: None }); + +/// Set the credential builder used by default to create entries. +/// +/// This is really meant for use by clients who bring their own credential +/// store and want to use it everywhere. If you are using multiple credential +/// stores and want precise control over which credential is in which store, +/// then use [`new_with_credential`](Entry::new_with_credential). +/// +/// This will block waiting for all other threads currently creating entries +/// to complete what they are doing. It's really meant to be called +/// at app startup before you start creating entries. +pub fn set_default_credential_builder(new: Box) { + let mut guard = DEFAULT_BUILDER + .write() + .expect("Poisoned RwLock in keyring-rs: please report a bug!"); + guard.inner = Some(new); +} + +pub fn default_credential_builder() -> Box { + #[cfg(any( + all(target_os = "linux", feature = "secret-service"), + all(target_os = "freebsd", feature = "secret-service"), + all(target_os = "openbsd", feature = "secret-service") + ))] + return secret_service::default_credential_builder(); + #[cfg(all(target_os = "macos", feature = "apple-native"))] + return macos::default_credential_builder(); + #[cfg(all(target_os = "windows", feature = "windows-native"))] + return windows::default_credential_builder(); + #[cfg(not(any( + all(target_os = "linux", feature = "secret-service"), + all(target_os = "freebsd", feature = "secret-service"), + all(target_os = "openbsd", feature = "secret-service"), + all(target_os = "macos", feature = "apple-native"), + all(target_os = "windows", feature = "windows-native"), + )))] + credential::nop_credential_builder() +} + +fn build_default_credential(target: Option<&str>, service: &str, user: &str) -> Result { + static DEFAULT: std::sync::LazyLock> = + std::sync::LazyLock::new(default_credential_builder); + let guard = DEFAULT_BUILDER + .read() + .expect("Poisoned RwLock in keyring-rs: please report a bug!"); + let builder = guard.inner.as_ref().unwrap_or_else(|| &DEFAULT); + let credential = builder.build(target, service, user)?; + Ok(Entry { inner: credential }) +} + +#[derive(Debug)] +pub struct Entry { + inner: Box, +} + +impl Entry { + /// Create an entry for the given service and user. + /// + /// The default credential builder is used. + /// + /// # Errors + /// + /// This function will return an [`Error`] if the `service` or `user` values are invalid. + /// The specific reasons for invalidity are platform-dependent, but include length constraints. + /// + /// # Panics + /// + /// In the very unlikely event that the internal credential builder's `RwLock` is poisoned, this function + /// will panic. If you encounter this, and especially if you can reproduce it, please report a bug with the + /// details (and preferably a backtrace) so the developers can investigate. + pub fn new(service: &str, user: &str) -> Result { + let entry = build_default_credential(None, service, user)?; + Ok(entry) + } + + /// Create an entry for the given target, service, and user. + /// + /// The default credential builder is used. + pub fn new_with_target(target: &str, service: &str, user: &str) -> Result { + let entry = build_default_credential(Some(target), service, user)?; + Ok(entry) + } + + /// Create an entry from a credential that may be in any credential store. + pub fn new_with_credential(credential: Box) -> Self { + Self { inner: credential } + } + + /// Set the password for this entry. + /// + /// Can return an [`Ambiguous`](Error::Ambiguous) error + /// if there is more than one platform credential + /// that matches this entry. This can only happen + /// on some platforms, and then only if a third-party + /// application wrote the ambiguous credential. + pub async fn set_password(&self, password: &str) -> Result<()> { + self.inner.set_password(password).await + } + + /// Set the secret for this entry. + /// + /// Can return an [`Ambiguous`](Error::Ambiguous) error + /// if there is more than one platform credential + /// that matches this entry. This can only happen + /// on some platforms, and then only if a third-party + /// application wrote the ambiguous credential. + pub async fn set_secret(&self, secret: &[u8]) -> Result<()> { + self.inner.set_secret(secret).await + } + + /// Retrieve the password saved for this entry. + /// + /// Returns a [`NoEntry`](Error::NoEntry) error if there isn't one. + /// + /// Can return an [`Ambiguous`](Error::Ambiguous) error + /// if there is more than one platform credential + /// that matches this entry. This can only happen + /// on some platforms, and then only if a third-party + /// application wrote the ambiguous credential. + pub async fn get_password(&self) -> Result { + self.inner.get_password().await + } + + /// Retrieve the secret saved for this entry. + /// + /// Returns a [`NoEntry`](Error::NoEntry) error if there isn't one. + /// + /// Can return an [`Ambiguous`](Error::Ambiguous) error + /// if there is more than one platform credential + /// that matches this entry. This can only happen + /// on some platforms, and then only if a third-party + /// application wrote the ambiguous credential. + pub async fn get_secret(&self) -> Result> { + self.inner.get_secret().await + } + + /// Get the attributes on the underlying credential for this entry. + /// + /// Some of the underlying credential stores allow credentials to have named attributes + /// that can be set to string values. See the documentation for each credential store + /// for a list of which attribute names are supported by that store. + /// + /// Returns a [`NoEntry`](Error::NoEntry) error if there isn't a credential for this entry. + /// + /// Can return an [`Ambiguous`](Error::Ambiguous) error + /// if there is more than one platform credential + /// that matches this entry. This can only happen + /// on some platforms, and then only if a third-party + /// application wrote the ambiguous credential. + pub async fn get_attributes(&self) -> Result> { + self.inner.get_attributes().await + } + + /// Update the attributes on the underlying credential for this entry. + /// + /// Some of the underlying credential stores allow credentials to have named attributes + /// that can be set to string values. See the documentation for each credential store + /// for a list of which attribute names can be given values by this call. To support + /// cross-platform use, each credential store ignores (without error) any specified attributes + /// that aren't supported by that store. + /// + /// Returns a [`NoEntry`](Error::NoEntry) error if there isn't a credential for this entry. + /// + /// Can return an [`Ambiguous`](Error::Ambiguous) error + /// if there is more than one platform credential + /// that matches this entry. This can only happen + /// on some platforms, and then only if a third-party + /// application wrote the ambiguous credential. + pub async fn update_attributes(&self, attributes: &HashMap<&str, &str>) -> Result<()> { + self.inner.update_attributes(attributes).await + } + + /// Delete the underlying credential for this entry. + /// + /// Returns a [`NoEntry`](Error::NoEntry) error if there isn't one. + /// + /// Can return an [`Ambiguous`](Error::Ambiguous) error + /// if there is more than one platform credential + /// that matches this entry. This can only happen + /// on some platforms, and then only if a third-party + /// application wrote the ambiguous credential. + /// + /// Note: This does _not_ affect the lifetime of the [Entry] + /// structure, which is controlled by Rust. It only + /// affects the underlying credential store. + pub async fn delete_credential(&self) -> Result<()> { + self.inner.delete_credential().await + } + + /// Return a reference to this entry's wrapped credential. + /// + /// The reference is of the [Any](std::any::Any) type, so it can be + /// downgraded to a concrete credential object. The client must know + /// what type of concrete object to cast to. + pub fn get_credential(&self) -> &dyn std::any::Any { + self.inner.as_any() + } +} + +#[cfg(doctest)] +doc_comment::doctest!("../README.md", readme); + +#[cfg(test)] +/// There are no actual tests in this module. +/// Instead, it contains generics that each keystore invokes in their tests, +/// passing their store-specific parameters for the generic ones. +mod tests { + use super::{Entry, Error, Result, credential::CredentialApi}; + use std::collections::HashMap; + + /// Create a platform-specific credential given the constructor, service, and user + pub(crate) fn entry_from_constructor(f: F, service: &str, user: &str) -> Entry + where + F: FnOnce(Option<&str>, &str, &str) -> Result, + T: 'static + CredentialApi + Send + Sync, + { + match f(None, service, user) { + Ok(credential) => Entry::new_with_credential(Box::new(credential)), + Err(err) => { + panic!("Couldn't create entry (service: {service}, user: {user}): {err:?}") + } + } + } + + async fn test_round_trip_no_delete(case: &str, entry: &Entry, in_pass: &str) { + entry + .set_password(in_pass) + .await + .unwrap_or_else(|err| panic!("Can't set password for {case}: {err:?}")); + let out_pass = entry + .get_password() + .await + .unwrap_or_else(|err| panic!("Can't get password for {case}: {err:?}")); + assert_eq!( + in_pass, out_pass, + "Passwords don't match for {case}: set='{in_pass}', get='{out_pass}'", + ); + } + + /// A basic round-trip unit test given an entry and a password. + pub(crate) async fn test_round_trip(case: &str, entry: &Entry, in_pass: &str) { + test_round_trip_no_delete(case, entry, in_pass).await; + entry + .delete_credential() + .await + .unwrap_or_else(|err| panic!("Can't delete password for {case}: {err:?}")); + let password = entry.get_password().await; + assert!( + matches!(password, Err(Error::NoEntry)), + "Read deleted password for {case}", + ); + } + + /// A basic round-trip unit test given an entry and a password. + pub(crate) async fn test_round_trip_secret(case: &str, entry: &Entry, in_secret: &[u8]) { + entry + .set_secret(in_secret) + .await + .unwrap_or_else(|err| panic!("Can't set secret for {case}: {err:?}")); + let out_secret = entry + .get_secret() + .await + .unwrap_or_else(|err| panic!("Can't get secret for {case}: {err:?}")); + assert_eq!( + in_secret, &out_secret, + "Passwords don't match for {case}: set='{in_secret:?}', get='{out_secret:?}'", + ); + entry + .delete_credential() + .await + .unwrap_or_else(|err| panic!("Can't delete password for {case}: {err:?}")); + let password = entry.get_secret().await; + assert!( + matches!(password, Err(Error::NoEntry)), + "Read deleted password for {case}", + ); + } + + /// When tests fail, they leave keys behind, and those keys + /// have to be cleaned up before the tests can be run again + /// in order to avoid bad results. So it's a lot easier just + /// to have tests use a random string for key names to avoid + /// the conflicts, and then do any needed cleanup once everything + /// is working correctly. So we export this function for tests to use. + pub(crate) fn generate_random_string_of_len(len: usize) -> String { + use fastrand; + use std::iter::repeat_with; + repeat_with(fastrand::alphanumeric).take(len).collect() + } + + pub(crate) fn generate_random_string() -> String { + generate_random_string_of_len(30) + } + + fn generate_random_bytes_of_len(len: usize) -> Vec { + use fastrand; + use std::iter::repeat_with; + repeat_with(|| fastrand::u8(..)).take(len).collect() + } + + pub(crate) async fn test_missing_entry(f: F) + where + F: FnOnce(&str, &str) -> Entry, + { + let name = generate_random_string(); + let entry = f(&name, &name); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Missing entry has password" + ); + } + + pub(crate) async fn test_empty_password(f: F) + where + F: FnOnce(&str, &str) -> Entry, + { + let name = generate_random_string(); + let entry = f(&name, &name); + test_round_trip("empty password", &entry, "").await; + } + + pub(crate) async fn test_round_trip_ascii_password(f: F) + where + F: FnOnce(&str, &str) -> Entry, + { + let name = generate_random_string(); + let entry = f(&name, &name); + test_round_trip("ascii password", &entry, "test ascii password").await; + } + + pub(crate) async fn test_round_trip_non_ascii_password(f: F) + where + F: FnOnce(&str, &str) -> Entry, + { + let name = generate_random_string(); + let entry = f(&name, &name); + test_round_trip("non-ascii password", &entry, "このきれいな花は桜です").await; + } + + pub(crate) async fn test_round_trip_random_secret(f: F) + where + F: FnOnce(&str, &str) -> Entry, + { + let name = generate_random_string(); + let entry = f(&name, &name); + let secret = generate_random_bytes_of_len(24); + test_round_trip_secret("non-ascii password", &entry, secret.as_slice()).await; + } + + pub(crate) async fn test_update(f: F) + where + F: FnOnce(&str, &str) -> Entry, + { + let name = generate_random_string(); + let entry = f(&name, &name); + test_round_trip_no_delete("initial ascii password", &entry, "test ascii password").await; + test_round_trip( + "updated non-ascii password", + &entry, + "このきれいな花は桜です", + ) + .await; + } + + pub(crate) async fn test_noop_get_update_attributes(f: F) + where + F: FnOnce(&str, &str) -> Entry, + { + let name = generate_random_string(); + let entry = f(&name, &name); + assert!( + matches!(entry.get_attributes().await, Err(Error::NoEntry)), + "Read missing credential in attribute test", + ); + let mut map: HashMap<&str, &str> = HashMap::new(); + map.insert("test attribute name", "test attribute value"); + assert!( + matches!(entry.update_attributes(&map).await, Err(Error::NoEntry)), + "Updated missing credential in attribute test", + ); + // create the credential and test again + entry + .set_password("test password for attributes") + .await + .unwrap_or_else(|err| panic!("Can't set password for attribute test: {err:?}")); + match entry.get_attributes().await { + Err(err) => panic!("Couldn't get attributes: {err:?}"), + Ok(attrs) if attrs.is_empty() => {} + Ok(attrs) => panic!("Unexpected attributes: {attrs:?}"), + } + assert!( + matches!(entry.update_attributes(&map).await, Ok(())), + "Couldn't update attributes in attribute test", + ); + match entry.get_attributes().await { + Err(err) => panic!("Couldn't get attributes after update: {err:?}"), + Ok(attrs) if attrs.is_empty() => {} + Ok(attrs) => panic!("Unexpected attributes after update: {attrs:?}"), + } + entry + .delete_credential() + .await + .unwrap_or_else(|err| panic!("Can't delete credential for attribute test: {err:?}")); + assert!( + matches!(entry.get_attributes().await, Err(Error::NoEntry)), + "Read deleted credential in attribute test", + ); + } +} diff --git a/crates/uv-keyring/src/macos.rs b/crates/uv-keyring/src/macos.rs new file mode 100644 index 000000000..9c7dfbb40 --- /dev/null +++ b/crates/uv-keyring/src/macos.rs @@ -0,0 +1,449 @@ +/*! + +# macOS Keychain credential store + +All credentials on macOS are stored in secure stores called _keychains_. +The OS automatically creates three of them that live on filesystem, +called _User_ (aka login), _Common_, and _System_. In addition, removable +media can contain a keychain which can be registered under the name _Dynamic_. + +The target attribute of an [`Entry`](crate::Entry) determines (case-insensitive) +which keychain that entry's credential is created in or searched for. +If the entry has no target, or the specified target doesn't name (case-insensitive) +one of the keychains listed above, the 'User' keychain is used. + +For a given service/user pair, this module creates/searches for a credential +in the target keychain whose _account_ attribute holds the user +and whose _name_ attribute holds the service. +Because of a quirk in the Mac keychain services API, neither the _account_ +nor the _name_ may be the empty string. (Empty strings are treated as +wildcards when looking up credentials by attribute value.) + +In the _Keychain Access_ UI on Mac, credentials created by this module +show up in the passwords area (with their _where_ field equal to their _name_). +What the Keychain Access lists under _Note_ entries on the Mac are +also generic credentials, so existing _notes_ created by third-party +applications can be accessed by this module if you know the value +of their _account_ attribute (which is not displayed by _Keychain Access_). + +Credentials on macOS can have a large number of _key/value_ attributes, +but this module controls the _account_ and _name_ attributes and +ignores all the others. so clients can't use it to access or update any attributes. + */ +use crate::credential::{Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi}; +use crate::error::{Error as ErrorCode, Result, decode_password}; +use security_framework::base::Error; +use security_framework::os::macos::keychain::{SecKeychain, SecPreferencesDomain}; +use security_framework::os::macos::passwords::find_generic_password; + +/// The representation of a generic Keychain credential. +/// +/// The actual credentials can have lots of attributes +/// not represented here. There's no way to use this +/// module to get at those attributes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MacCredential { + pub domain: MacKeychainDomain, + pub service: String, + pub account: String, +} + +#[async_trait::async_trait] +impl CredentialApi for MacCredential { + /// Create and write a credential with password for this entry. + /// + /// The new credential replaces any existing one in the store. + /// Since there is only one credential with a given _account_ and _user_ + /// in any given keychain, there is no chance of ambiguity. + async fn set_password(&self, password: &str) -> Result<()> { + let service = self.service.clone(); + let account = self.account.clone(); + let domain = self.domain; + let password = password.to_string(); + crate::blocking::spawn_blocking(move || { + get_keychain(domain)? + .set_generic_password(&service, &account, password.as_bytes()) + .map_err(decode_error) + }) + .await?; + Ok(()) + } + + /// Create and write a credential with secret for this entry. + /// + /// The new credential replaces any existing one in the store. + /// Since there is only one credential with a given _account_ and _user_ + /// in any given keychain, there is no chance of ambiguity. + async fn set_secret(&self, secret: &[u8]) -> Result<()> { + let service = self.service.clone(); + let account = self.account.clone(); + let domain = self.domain; + let secret = secret.to_vec(); + crate::blocking::spawn_blocking(move || { + get_keychain(domain)? + .set_generic_password(&service, &account, &secret) + .map_err(decode_error) + }) + .await?; + Ok(()) + } + + /// Look up the password for this entry, if any. + /// + /// Returns a [`NoEntry`](ErrorCode::NoEntry) error if there is no + /// credential in the store. + async fn get_password(&self) -> Result { + let service = self.service.clone(); + let account = self.account.clone(); + let domain = self.domain; + + let password_bytes = crate::blocking::spawn_blocking(move || -> Result> { + let keychain = get_keychain(domain)?; + let (password_bytes, _) = find_generic_password(Some(&[keychain]), &service, &account) + .map_err(decode_error)?; + Ok(password_bytes.to_owned()) + }) + .await?; + + decode_password(password_bytes) + } + + /// Look up the secret for this entry, if any. + /// + /// Returns a [`NoEntry`](ErrorCode::NoEntry) error if there is no + /// credential in the store. + async fn get_secret(&self) -> Result> { + let service = self.service.clone(); + let account = self.account.clone(); + let domain = self.domain; + + let password_bytes = crate::blocking::spawn_blocking(move || -> Result> { + let keychain = get_keychain(domain)?; + let (password_bytes, _) = find_generic_password(Some(&[keychain]), &service, &account) + .map_err(decode_error)?; + Ok(password_bytes.to_owned()) + }) + .await?; + + Ok(password_bytes) + } + + /// Delete the underlying generic credential for this entry, if any. + /// + /// Returns a [`NoEntry`](ErrorCode::NoEntry) error if there is no + /// credential in the store. + async fn delete_credential(&self) -> Result<()> { + let service = self.service.clone(); + let account = self.account.clone(); + let domain = self.domain; + + crate::blocking::spawn_blocking(move || { + let keychain = get_keychain(domain)?; + let (_, item) = find_generic_password(Some(&[keychain]), &service, &account) + .map_err(decode_error)?; + item.delete(); + Ok(()) + }) + .await?; + Ok(()) + } + + /// Return the underlying concrete object with an `Any` type so that it can + /// be downgraded to a [`MacCredential`] for platform-specific processing. + fn as_any(&self) -> &dyn std::any::Any { + self + } + + /// Expose the concrete debug formatter for use via the [Credential] trait + fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +impl MacCredential { + /// Construct a credential from the underlying generic credential. + /// + /// On Mac, this is basically a no-op, because we represent any attributes + /// other than the ones we use to find the generic credential. + /// But at least this checks whether the underlying credential exists. + pub async fn get_credential(&self) -> Result { + let service = self.service.clone(); + let account = self.account.clone(); + let domain = self.domain; + let keychain = get_keychain(domain)?; + crate::blocking::spawn_blocking(move || -> Result<()> { + let (_, _) = find_generic_password(Some(&[keychain]), &service, &account) + .map_err(decode_error)?; + Ok(()) + }) + .await?; + Ok(self.clone()) + } + + /// Create a credential representing a Mac keychain entry. + /// + /// Creating a credential does not put anything into the keychain. + /// The keychain entry will be created + /// when [`set_password`](MacCredential::set_password) is + /// called. + /// + /// This will fail if the service or user strings are empty, + /// because empty attribute values act as wildcards in the + /// Keychain Services API. + pub fn new_with_target( + target: Option, + service: &str, + user: &str, + ) -> Result { + if service.is_empty() { + return Err(ErrorCode::Invalid( + "service".to_string(), + "cannot be empty".to_string(), + )); + } + if user.is_empty() { + return Err(ErrorCode::Invalid( + "user".to_string(), + "cannot be empty".to_string(), + )); + } + let domain = if let Some(target) = target { + target + } else { + MacKeychainDomain::User + }; + Ok(Self { + domain, + service: service.to_string(), + account: user.to_string(), + }) + } +} + +/// The builder for Mac keychain credentials +pub struct MacCredentialBuilder; + +/// Returns an instance of the Mac credential builder. +/// +/// On Mac, with default features enabled, +/// this is called once when an entry is first created. +pub fn default_credential_builder() -> Box { + Box::new(MacCredentialBuilder {}) +} + +impl CredentialBuilderApi for MacCredentialBuilder { + /// Build a [`MacCredential`] for the given target, service, and user. + /// + /// If a target is specified but not recognized as a keychain name, + /// the User keychain is selected. + fn build(&self, target: Option<&str>, service: &str, user: &str) -> Result> { + let domain: MacKeychainDomain = if let Some(target) = target { + target.parse().unwrap_or(MacKeychainDomain::User) + } else { + MacKeychainDomain::User + }; + Ok(Box::new(MacCredential::new_with_target( + Some(domain), + service, + user, + )?)) + } + + /// Return the underlying builder object with an `Any` type so that it can + /// be downgraded to a [`MacCredentialBuilder`] for platform-specific processing. + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +/// The four pre-defined Mac keychains. +pub enum MacKeychainDomain { + User, + System, + Common, + Dynamic, + Protected, +} + +impl std::fmt::Display for MacKeychainDomain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::User => "User".fmt(f), + Self::System => "System".fmt(f), + Self::Common => "Common".fmt(f), + Self::Dynamic => "Dynamic".fmt(f), + Self::Protected => "Protected".fmt(f), + } + } +} + +impl std::str::FromStr for MacKeychainDomain { + type Err = ErrorCode; + + /// Convert a target specification string to a keychain domain. + /// + /// We accept any case in the string, + /// but the value has to match a known keychain domain name + /// or else we assume the login keychain is meant. + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "user" => Ok(Self::User), + "system" => Ok(Self::System), + "common" => Ok(Self::Common), + "dynamic" => Ok(Self::Dynamic), + "protected" => Ok(Self::Protected), + "data protection" => Ok(Self::Protected), + _ => Err(ErrorCode::Invalid( + "target".to_string(), + format!("'{s}' is not User, System, Common, Dynamic, or Protected"), + )), + } + } +} + +fn get_keychain(domain: MacKeychainDomain) -> Result { + let domain = match domain { + MacKeychainDomain::User => SecPreferencesDomain::User, + MacKeychainDomain::System => SecPreferencesDomain::System, + MacKeychainDomain::Common => SecPreferencesDomain::Common, + MacKeychainDomain::Dynamic => SecPreferencesDomain::Dynamic, + MacKeychainDomain::Protected => panic!("Protected is not a keychain domain on macOS"), + }; + match SecKeychain::default_for_domain(domain) { + Ok(keychain) => Ok(keychain), + Err(err) => Err(decode_error(err)), + } +} + +/// Map a Mac API error to a crate error with appropriate annotation +/// +/// The macOS error code values used here are from +/// [this reference](https://opensource.apple.com/source/libsecurity_keychain/libsecurity_keychain-78/lib/SecBase.h.auto.html) +pub fn decode_error(err: Error) -> ErrorCode { + match err.code() { + -25291 => ErrorCode::NoStorageAccess(Box::new(err)), // errSecNotAvailable + -25292 => ErrorCode::NoStorageAccess(Box::new(err)), // errSecReadOnly + -25294 => ErrorCode::NoStorageAccess(Box::new(err)), // errSecNoSuchKeychain + -25295 => ErrorCode::NoStorageAccess(Box::new(err)), // errSecInvalidKeychain + -25300 => ErrorCode::NoEntry, // errSecItemNotFound + _ => ErrorCode::PlatformFailure(Box::new(err)), + } +} + +#[cfg(not(miri))] +#[cfg(test)] +mod tests { + use crate::credential::CredentialPersistence; + use crate::{Entry, Error, tests::generate_random_string}; + + use super::{MacCredential, default_credential_builder}; + + #[test] + fn test_persistence() { + assert!(matches!( + default_credential_builder().persistence(), + CredentialPersistence::UntilDelete + )); + } + + fn entry_new(service: &str, user: &str) -> Entry { + crate::tests::entry_from_constructor( + |_, s, u| MacCredential::new_with_target(None, s, u), + service, + user, + ) + } + + #[test] + fn test_invalid_parameter() { + let credential = MacCredential::new_with_target(None, "", "user"); + assert!( + matches!(credential, Err(Error::Invalid(_, _))), + "Created credential with empty service" + ); + let credential = MacCredential::new_with_target(None, "service", ""); + assert!( + matches!(credential, Err(Error::Invalid(_, _))), + "Created entry with empty user" + ); + } + + #[tokio::test] + async fn test_missing_entry() { + crate::tests::test_missing_entry(entry_new).await; + } + + #[tokio::test] + async fn test_empty_password() { + crate::tests::test_empty_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_ascii_password() { + crate::tests::test_round_trip_ascii_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_non_ascii_password() { + crate::tests::test_round_trip_non_ascii_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_random_secret() { + crate::tests::test_round_trip_random_secret(entry_new).await; + } + + #[tokio::test] + async fn test_update() { + crate::tests::test_update(entry_new).await; + } + + #[tokio::test] + async fn test_get_credential() { + let name = generate_random_string(); + let entry = entry_new(&name, &name); + let credential: &MacCredential = entry + .get_credential() + .downcast_ref() + .expect("Not a mac credential"); + assert!( + credential.get_credential().await.is_err(), + "Platform credential shouldn't exist yet!" + ); + entry + .set_password("test get_credential") + .await + .expect("Can't set password for get_credential"); + assert!(credential.get_credential().await.is_ok()); + entry + .delete_credential() + .await + .expect("Couldn't delete after get_credential"); + assert!(matches!(entry.get_password().await, Err(Error::NoEntry))); + } + + #[tokio::test] + async fn test_get_update_attributes() { + crate::tests::test_noop_get_update_attributes(entry_new).await; + } + + #[test] + fn test_select_keychain() { + for name in ["unknown", "user", "common", "system", "dynamic"] { + let cred = Entry::new_with_target(name, name, name) + .expect("couldn't create credential") + .inner; + let mac_cred: &MacCredential = cred + .as_any() + .downcast_ref() + .expect("credential not a MacCredential"); + if name == "unknown" { + assert!( + matches!(mac_cred.domain, super::MacKeychainDomain::User), + "wrong domain for unknown specifier" + ); + } + } + } +} diff --git a/crates/uv-keyring/src/mock.rs b/crates/uv-keyring/src/mock.rs new file mode 100644 index 000000000..891cb0291 --- /dev/null +++ b/crates/uv-keyring/src/mock.rs @@ -0,0 +1,341 @@ +/*! + +# Mock credential store + +To facilitate testing of clients, this crate provides a Mock credential store +that is platform-independent, provides no persistence, and allows the client +to specify the return values (including errors) for each call. The credentials +in this store have no attributes at all. + +To use this credential store instead of the default, make this call during +application startup _before_ creating any entries: +```rust +keyring::set_default_credential_builder(keyring::mock::default_credential_builder()); +``` + +You can then create entries as you usually do, and call their usual methods +to set, get, and delete passwords. There is no persistence other than +in the entry itself, so getting a password before setting it will always result +in a [`NoEntry`](Error::NoEntry) error. + +If you want a method call on an entry to fail in a specific way, you can +downcast the entry to a [`MockCredential`] and then call [`set_error`](MockCredential::set_error) +with the appropriate error. The next entry method called on the credential +will fail with the error you set. The error will then be cleared, so the next +call on the mock will operate as usual. Here's a complete example: +```rust +# use keyring::{Entry, Error, mock, mock::MockCredential}; +# keyring::set_default_credential_builder(mock::default_credential_builder()); +let entry = Entry::new("service", "user").unwrap(); +let mock: &MockCredential = entry.get_credential().downcast_ref().unwrap(); +mock.set_error(Error::Invalid("mock error".to_string(), "takes precedence".to_string())); +entry.set_password("test").expect_err("error will override"); +entry.set_password("test").expect("error has been cleared"); +``` + */ +use std::cell::RefCell; +use std::sync::Mutex; + +use crate::credential::{ + Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi, CredentialPersistence, +}; +use crate::error::{Error, Result, decode_password}; + +/// The concrete mock credential +/// +/// Mocks use an internal mutability pattern since entries are read-only. +/// The mutex is used to make sure these are Sync. +#[derive(Debug)] +pub struct MockCredential { + pub inner: Mutex>, +} + +impl Default for MockCredential { + fn default() -> Self { + Self { + inner: Mutex::new(RefCell::new(MockData::default())), + } + } +} + +/// The (in-memory) persisted data for a mock credential. +/// +/// We keep a password, but unlike most keystores +/// we also keep an intended error to return on the next call. +/// +/// (Everything about this structure is public for transparency. +/// Most keystore implementation hide their internals.) +#[derive(Debug, Default)] +pub struct MockData { + pub secret: Option>, + pub error: Option, +} + +#[async_trait::async_trait] +impl CredentialApi for MockCredential { + /// Set a password on a mock credential. + /// + /// If there is an error in the mock, it will be returned + /// and the password will _not_ be set. The error will + /// be cleared, so calling again will set the password. + async fn set_password(&self, password: &str) -> Result<()> { + let mut inner = self.inner.lock().expect("Can't access mock data for set"); + let data = inner.get_mut(); + let err = data.error.take(); + match err { + None => { + data.secret = Some(password.as_bytes().to_vec()); + Ok(()) + } + Some(err) => Err(err), + } + } + + /// Set a password on a mock credential. + /// + /// If there is an error in the mock, it will be returned + /// and the password will _not_ be set. The error will + /// be cleared, so calling again will set the password. + async fn set_secret(&self, secret: &[u8]) -> Result<()> { + let mut inner = self.inner.lock().expect("Can't access mock data for set"); + let data = inner.get_mut(); + let err = data.error.take(); + match err { + None => { + data.secret = Some(secret.to_vec()); + Ok(()) + } + Some(err) => Err(err), + } + } + + /// Get the password from a mock credential, if any. + /// + /// If there is an error set in the mock, it will + /// be returned instead of a password. + async fn get_password(&self) -> Result { + let mut inner = self.inner.lock().expect("Can't access mock data for get"); + let data = inner.get_mut(); + let err = data.error.take(); + match err { + None => match &data.secret { + None => Err(Error::NoEntry), + Some(val) => decode_password(val.clone()), + }, + Some(err) => Err(err), + } + } + + /// Get the password from a mock credential, if any. + /// + /// If there is an error set in the mock, it will + /// be returned instead of a password. + async fn get_secret(&self) -> Result> { + let mut inner = self.inner.lock().expect("Can't access mock data for get"); + let data = inner.get_mut(); + let err = data.error.take(); + match err { + None => match &data.secret { + None => Err(Error::NoEntry), + Some(val) => Ok(val.clone()), + }, + Some(err) => Err(err), + } + } + + /// Delete the password in a mock credential + /// + /// If there is an error, it will be returned and + /// the deletion will not happen. + /// + /// If there is no password, a [NoEntry](Error::NoEntry) error + /// will be returned. + async fn delete_credential(&self) -> Result<()> { + let mut inner = self + .inner + .lock() + .expect("Can't access mock data for delete"); + let data = inner.get_mut(); + let err = data.error.take(); + match err { + None => match data.secret { + Some(_) => { + data.secret = None; + Ok(()) + } + None => Err(Error::NoEntry), + }, + Some(err) => Err(err), + } + } + + /// Return this mock credential concrete object + /// wrapped in the [Any](std::any::Any) trait, + /// so it can be downcast. + fn as_any(&self) -> &dyn std::any::Any { + self + } + + /// Expose the concrete debug formatter for use via the [Credential] trait + fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +impl MockCredential { + /// Make a new mock credential. + /// + /// Since mocks have no persistence between sessions, + /// new mocks always have no password. + fn new_with_target(_target: Option<&str>, _service: &str, _user: &str) -> Self { + Self::default() + } + + /// Set an error to be returned from this mock credential. + /// + /// Error returns always take precedence over the normal + /// behavior of the mock. But once an error has been + /// returned it is removed, so the mock works thereafter. + pub fn set_error(&self, err: Error) { + let mut inner = self + .inner + .lock() + .expect("Can't access mock data for set_error"); + let data = inner.get_mut(); + data.error = Some(err); + } +} + +/// The builder for mock credentials. +pub struct MockCredentialBuilder; + +impl CredentialBuilderApi for MockCredentialBuilder { + /// Build a mock credential for the given target, service, and user. + /// + /// Since mocks don't persist between sessions, all mocks + /// start off without passwords. + fn build(&self, target: Option<&str>, service: &str, user: &str) -> Result> { + let credential = MockCredential::new_with_target(target, service, user); + Ok(Box::new(credential)) + } + + /// Get an [Any][std::any::Any] reference to the mock credential builder. + fn as_any(&self) -> &dyn std::any::Any { + self + } + + /// This keystore keeps the password in the entry! + fn persistence(&self) -> CredentialPersistence { + CredentialPersistence::EntryOnly + } +} + +/// Return a mock credential builder for use by clients. +pub fn default_credential_builder() -> Box { + Box::new(MockCredentialBuilder {}) +} + +#[cfg(test)] +mod tests { + use super::{MockCredential, default_credential_builder}; + use crate::credential::CredentialPersistence; + use crate::{Entry, Error, tests::generate_random_string}; + + #[test] + fn test_persistence() { + assert!(matches!( + default_credential_builder().persistence(), + CredentialPersistence::EntryOnly + )); + } + + fn entry_new(service: &str, user: &str) -> Entry { + let credential = MockCredential::new_with_target(None, service, user); + Entry::new_with_credential(Box::new(credential)) + } + + #[tokio::test] + async fn test_missing_entry() { + crate::tests::test_missing_entry(entry_new).await; + } + + #[tokio::test] + async fn test_empty_password() { + crate::tests::test_empty_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_ascii_password() { + crate::tests::test_round_trip_ascii_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_non_ascii_password() { + crate::tests::test_round_trip_non_ascii_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_random_secret() { + crate::tests::test_round_trip_random_secret(entry_new).await; + } + + #[tokio::test] + async fn test_update() { + crate::tests::test_update(entry_new).await; + } + + #[tokio::test] + async fn test_get_update_attributes() { + crate::tests::test_noop_get_update_attributes(entry_new).await; + } + + #[tokio::test] + async fn test_set_error() { + let name = generate_random_string(); + let entry = entry_new(&name, &name); + let password = "test ascii password"; + let mock: &MockCredential = entry + .inner + .as_any() + .downcast_ref() + .expect("Downcast failed"); + mock.set_error(Error::Invalid( + "mock error".to_string(), + "is an error".to_string(), + )); + assert!( + matches!( + entry.set_password(password).await, + Err(Error::Invalid(_, _)) + ), + "set: No error" + ); + entry + .set_password(password) + .await + .expect("set: Error not cleared"); + mock.set_error(Error::NoEntry); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "get: No error" + ); + let stored_password = entry.get_password().await.expect("get: Error not cleared"); + assert_eq!( + stored_password, password, + "Retrieved and set ascii passwords don't match" + ); + mock.set_error(Error::TooLong("mock".to_string(), 3)); + assert!( + matches!(entry.delete_credential().await, Err(Error::TooLong(_, 3))), + "delete: No error" + ); + entry + .delete_credential() + .await + .expect("delete: Error not cleared"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted ascii password" + ); + } +} diff --git a/crates/uv-keyring/src/secret_service.rs b/crates/uv-keyring/src/secret_service.rs new file mode 100644 index 000000000..376e466aa --- /dev/null +++ b/crates/uv-keyring/src/secret_service.rs @@ -0,0 +1,932 @@ +/*! + +# secret-service credential store + +Items in the secret-service are identified by an arbitrary collection +of attributes. This implementation controls the following attributes: + +- `target` (optional & taken from entry creation call, defaults to `default`) +- `service` (required & taken from entry creation call) +- `username` (required & taken from entry creation call's `user` parameter) + +In addition, when creating a new credential, this implementation assigns +two additional attributes: + +- `application` (set to `uv`) +- `label` (set to a string with the user, service, target, and keyring version at time of creation) + +Client code is allowed to retrieve and to set all attributes _except_ the +three that are controlled by this implementation. (N.B. The `label` string +is not actually an attribute; it's a required element in every item and is used +by GUI tools as the name for the item. But this implementation treats the +label as if it were any other non-controlled attribute, with the caveat that +it will reject any attempt to set the label to an empty string.) + +Existing items are always searched for at the service level, which +means all collections are searched. The search attributes used are +`target` (set from the entry target), `service` (set from the entry +service), and `username` (set from the entry user). Because earlier +versions of this crate did not set the `target` attribute on credentials +that were stored in the default collection, a fallback search is done +for items in the default collection with no `target` attribute *if +the original search for all three attributes returns no matches*. + +New items are created in the default collection, +unless a target other than `default` is +specified for the entry, in which case the item +will be created in a collection (created if necessary) +that is labeled with the specified target. + +Setting the password on an entry will always update the password on an +existing item in preference to creating a new item. +This provides better compatibility with 3rd party clients, as well as earlier +versions of this crate, that may already +have created items that match the entry, and thus reduces the chance +of ambiguity in later searches. + +## Headless usage + +If you must use the secret-service on a headless linux box, +be aware that there are known issues with getting +dbus and secret-service and the gnome keyring +to work properly in headless environments. +For a quick workaround, look at how this project's +[CI workflow](https://github.com/hwchen/keyring-rs/blob/master/.github/workflows/ci.yaml) +starts the Gnome keyring unlocked with a known password; +a similar solution is also documented in the +[Python Keyring docs](https://pypi.org/project/keyring/) +(search for "Using Keyring on headless Linux systems"). +The following `bash` function may be helpful: + +```shell +function unlock-keyring () +{ + read -rsp "Password: " pass + echo -n "$pass" | gnome-keyring-daemon --unlock + unset pass +} +``` + +For an excellent treatment of all the headless dbus issues, see +[this answer on ServerFault](https://serverfault.com/a/906224/79617). + +## Usage - not! - on Windows Subsystem for Linux + +As noted in +[this issue on GitHub](https://github.com/hwchen/keyring-rs/issues/133), +there is no "default" collection defined under WSL. So +this keystore doesn't work "out of the box" on WSL. See the +issue for more details and possible workarounds. + */ + +use std::collections::HashMap; + +use secret_service::{Collection, EncryptionType, Error, Item, SecretService}; + +use crate::credential::{Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi}; +use crate::error::{Error as ErrorCode, Result, decode_password}; + +/// The representation of an item in the secret-service. +/// +/// This structure has two roles. On the one hand, it captures all the +/// information a user specifies for an [`Entry`](crate::Entry) +/// and so is the basis for our search +/// (or creation) of an item for that entry. On the other hand, when +/// a search is ambiguous, each item found is represented by a credential that +/// has the same attributes and label as the item. +#[derive(Debug, Clone)] +pub struct SsCredential { + pub attributes: HashMap, + pub label: String, + target: Option, +} + +#[async_trait::async_trait] +impl CredentialApi for SsCredential { + /// Sets the password on a unique matching item, if it exists, or creates one if necessary. + /// + /// If there are multiple matches, + /// returns an [`Ambiguous`](ErrorCode::Ambiguous) error with a credential for each + /// matching item. + /// + /// When creating, the item is put into a collection named by the credential's `target` + /// attribute. + async fn set_password(&self, password: &str) -> Result<()> { + self.set_secret(password.as_bytes()).await + } + + /// Sets the secret on a unique matching item, if it exists, or creates one if necessary. + /// + /// If there are multiple matches, + /// returns an [`Ambiguous`](ErrorCode::Ambiguous) error with a credential for each + /// matching item. + /// + /// When creating, the item is put into a collection named by the credential's `target` + /// attribute. + async fn set_secret(&self, secret: &[u8]) -> Result<()> { + // first try to find a unique, existing, matching item and set its password + let secret_vec = secret.to_vec(); + match self + .map_matching_items(async move |i| set_item_secret(i, &secret_vec).await, true) + .await + { + Ok(_) => return Ok(()), + Err(ErrorCode::NoEntry) => {} + Err(err) => return Err(err), + } + // if there is no existing item, create one for this credential. In order to create + // an item, the credential must have an explicit target. All entries created with + // the [`new`] or [`new_with_target`] commands will have explicit targets. But entries + // created to wrap 3rd-party items that don't have `target` attributes may not. + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(platform_failure)?; + let name = self.target.as_ref().ok_or_else(empty_target)?; + let collection = match get_collection(&ss, name).await { + Ok(collection) => collection, + Err(_) => create_collection(&ss, name).await?, + }; + collection + .create_item( + self.label.as_str(), + self.all_attributes(), + secret, + true, // replace + "text/plain", + ) + .await + .map_err(platform_failure)?; + Ok(()) + } + + /// Gets the password on a unique matching item, if it exists. + /// + /// If there are no + /// matching items, returns a [`NoEntry`](ErrorCode::NoEntry) error. + /// If there are multiple matches, + /// returns an [`Ambiguous`](ErrorCode::Ambiguous) + /// error with a credential for each matching item. + async fn get_password(&self) -> Result { + Ok(self + .map_matching_items(get_item_password, true) + .await? + .remove(0)) + } + + /// Gets the secret on a unique matching item, if it exists. + /// + /// If there are no + /// matching items, returns a [`NoEntry`](ErrorCode::NoEntry) error. + /// If there are multiple matches, + /// returns an [`Ambiguous`](ErrorCode::Ambiguous) + /// error with a credential for each matching item. + async fn get_secret(&self) -> Result> { + Ok(self + .map_matching_items(get_item_secret, true) + .await? + .remove(0)) + } + + /// Get attributes on a unique matching item, if it exists + async fn get_attributes(&self) -> Result> { + let attributes: Vec> = + self.map_matching_items(get_item_attributes, true).await?; + Ok(attributes.into_iter().next().unwrap()) + } + + /// Update attributes on a unique matching item, if it exists + async fn update_attributes(&self, attributes: &HashMap<&str, &str>) -> Result<()> { + // Convert to owned data to avoid lifetime issues + let attributes_owned: HashMap = attributes + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + + self.map_matching_items( + async move |item| { + let attrs_ref: HashMap<&str, &str> = attributes_owned + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + update_item_attributes(item, &attrs_ref).await + }, + true, + ) + .await?; + Ok(()) + } + + /// Deletes the unique matching item, if it exists. + /// + /// If there are no + /// matching items, returns a [`NoEntry`](ErrorCode::NoEntry) error. + /// If there are multiple matches, + /// returns an [`Ambiguous`](ErrorCode::Ambiguous) + /// error with a credential for each matching item. + async fn delete_credential(&self) -> Result<()> { + self.map_matching_items(delete_item, true).await?; + Ok(()) + } + + /// Return the underlying credential object with an `Any` type so that it can + /// be downgraded to an [`SsCredential`] for platform-specific processing. + fn as_any(&self) -> &dyn std::any::Any { + self + } + + /// Expose the concrete debug formatter for use via the [`Credential`] trait + fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +impl SsCredential { + /// Create a credential for the given target, service, and user. + /// + /// The target defaults to `default` (the default secret-service collection). + /// + /// Creating this credential does not create a matching item. + /// If there isn't already one there, it will be created only + /// when [`set_password`](SsCredential::set_password) is + /// called. + pub fn new_with_target(target: Option<&str>, service: &str, user: &str) -> Result { + if let Some("") = target { + return Err(empty_target()); + } + let target = target.unwrap_or("default"); + + let attributes = HashMap::from([ + ("service".to_string(), service.to_string()), + ("username".to_string(), user.to_string()), + ("target".to_string(), target.to_string()), + ("application".to_string(), "uv".to_string()), + ]); + Ok(Self { + attributes, + label: format!( + "{user}@{service}:{target} (uv v{})", + env!("CARGO_PKG_VERSION"), + ), + target: Some(target.to_string()), + }) + } + + /// Create a credential that has *no* target and the given service and user. + /// + /// This emulates what keyring v1 did, and can be very handy when you need to + /// access an old v1 credential that's in your secret service default collection. + pub fn new_with_no_target(service: &str, user: &str) -> Result { + let attributes = HashMap::from([ + ("service".to_string(), service.to_string()), + ("username".to_string(), user.to_string()), + ("application".to_string(), "uv".to_string()), + ]); + Ok(Self { + attributes, + label: format!( + "uv v{} for no target, service '{service}', user '{user}'", + env!("CARGO_PKG_VERSION"), + ), + target: None, + }) + } + + /// Create a credential from an underlying item. + /// + /// The created credential will have all the attributes and label + /// of the underlying item, so you can examine them. + pub async fn new_from_item(item: &Item<'_>) -> Result { + let attributes = item.get_attributes().await.map_err(decode_error)?; + let target = attributes.get("target").cloned(); + Ok(Self { + attributes, + label: item.get_label().await.map_err(decode_error)?, + target, + }) + } + + /// Construct a credential for this credential's underlying matching item, + /// if there is exactly one. + pub async fn new_from_matching_item(&self) -> Result { + Ok(self + .map_matching_items(Self::new_from_item, true) + .await? + .remove(0)) + } + + /// If there are multiple matching items for this credential, get all of their passwords. + /// + /// (This is useful if [`get_password`](SsCredential::get_password) + /// returns an [`Ambiguous`](ErrorCode::Ambiguous) error.) + pub async fn get_all_passwords(&self) -> Result> { + self.map_matching_items(get_item_password, false).await + } + + /// If there are multiple matching items for this credential, delete all of them. + /// + /// (This is useful if [`delete_credential`](SsCredential::delete_credential) + /// returns an [`Ambiguous`](ErrorCode::Ambiguous) error.) + pub async fn delete_all_passwords(&self) -> Result<()> { + self.map_matching_items(delete_item, false).await?; + Ok(()) + } + + /// Map an async function over the items matching this credential. + /// + /// Items are unlocked before the function is applied. + /// + /// If `require_unique` is true, and there are no matching items, then + /// a [`NoEntry`](ErrorCode::NoEntry) error is returned. + /// If `require_unique` is true, and there are multiple matches, + /// then an [`Ambiguous`](ErrorCode::Ambiguous) error is returned + /// with a vector containing one + /// credential for each of the matching items. + async fn map_matching_items(&self, f: F, require_unique: bool) -> Result> + where + F: AsyncFn(&Item<'_>) -> Result, + T: Sized, + { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .map_err(platform_failure)?; + let attributes: HashMap<&str, &str> = self.search_attributes(false).into_iter().collect(); + let search = ss.search_items(attributes).await.map_err(decode_error)?; + let count = search.locked.len() + search.unlocked.len(); + if count == 0 { + if let Some("default") = self.target.as_deref() { + return self.map_matching_legacy_items(&ss, f, require_unique).await; + } + } + if require_unique { + if count == 0 { + return Err(ErrorCode::NoEntry); + } else if count > 1 { + let mut creds: Vec> = vec![]; + for item in search.locked.iter().chain(search.unlocked.iter()) { + let cred = Self::new_from_item(item).await?; + creds.push(Box::new(cred)); + } + return Err(ErrorCode::Ambiguous(creds)); + } + } + let mut results: Vec = vec![]; + for item in &search.unlocked { + results.push(f(item).await?); + } + for item in &search.locked { + item.unlock().await.map_err(decode_error)?; + results.push(f(item).await?); + } + Ok(results) + } + + /// Map an async function over items that older versions of keyring + /// would have matched against this credential. + /// + /// Keyring v1 created secret service items that had no target attribute, and it was + /// only able to create items in the default collection. Keyring v2, and Keyring v3.1, + /// in order to be able to find items set by keyring v1, would first look for items + /// everywhere independent of target attribute, and then filter those found by the value + /// of the target attribute. But this matching behavior overgeneralized when the keyring + /// was locked at the time of the search (see + /// [issue #204](https://github.com/hwchen/keyring-rs/issues/204) for details). + /// + /// As of keyring v3.2, the service-wide search behavior was changed to require a + /// matching target on items. But, as pointed out in + /// [issue #207](https://github.com/hwchen/keyring-rs/issues/207), + /// this meant that items set by keyring v1 (or by 3rd party tools that didn't set + /// the target attribute) would not be found, even if they were in the default + /// collection. + /// + /// So with keyring v3.2.1, if the service-wide search fails to find any matching + /// credential, and the credential being searched for has the default target, we fall back and search the default collection for a v1-style credential. + /// That preserves the legacy behavior at the cost of a second round-trip through + /// the secret service for the collection search. + pub async fn map_matching_legacy_items( + &self, + ss: &SecretService<'_>, + f: F, + require_unique: bool, + ) -> Result> + where + F: AsyncFn(&Item<'_>) -> Result, + T: Sized, + { + let collection = ss.get_default_collection().await.map_err(decode_error)?; + let attributes = self.search_attributes(true); + let search = collection + .search_items(attributes) + .await + .map_err(decode_error)?; + if require_unique { + if search.is_empty() && require_unique { + return Err(ErrorCode::NoEntry); + } else if search.len() > 1 { + let mut creds: Vec> = vec![]; + for item in &search { + let cred = Self::new_from_item(item).await?; + creds.push(Box::new(cred)); + } + return Err(ErrorCode::Ambiguous(creds)); + } + } + let mut results: Vec = vec![]; + for item in &search { + results.push(f(item).await?); + } + Ok(results) + } + + /// Using strings in the credential map makes managing the lifetime + /// of the credential much easier. But since the secret service expects + /// a map from &str to &str, we have this utility to transform the + /// credential's map into one of the right form. + fn all_attributes(&self) -> HashMap<&str, &str> { + self.attributes + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect() + } + + /// Similar to [`all_attributes`](SsCredential::all_attributes), + /// but this just selects the ones we search on + fn search_attributes(&self, omit_target: bool) -> HashMap<&str, &str> { + let mut result: HashMap<&str, &str> = HashMap::new(); + if self.target.is_some() && !omit_target { + result.insert("target", self.attributes["target"].as_str()); + } + result.insert("service", self.attributes["service"].as_str()); + result.insert("username", self.attributes["username"].as_str()); + result + } +} + +/// The builder for secret-service credentials +#[derive(Debug, Default)] +pub struct SsCredentialBuilder; + +/// Returns an instance of the secret-service credential builder. +/// +/// If secret-service is the default credential store, +/// this is called once when an entry is first created. +pub fn default_credential_builder() -> Box { + Box::new(SsCredentialBuilder {}) +} + +impl CredentialBuilderApi for SsCredentialBuilder { + /// Build an [`SsCredential`] for the given target, service, and user. + fn build(&self, target: Option<&str>, service: &str, user: &str) -> Result> { + Ok(Box::new(SsCredential::new_with_target( + target, service, user, + )?)) + } + + /// Return the underlying builder object with an `Any` type so that it can + /// be downgraded to an [`SsCredentialBuilder`] for platform-specific processing. + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +// +// Secret Service utilities +// + +/// Find the secret service collection whose label is the given name. +/// +/// The name `default` is treated specially and is interpreted as naming +/// the default collection regardless of its label (which might be different). +pub async fn get_collection<'a>(ss: &'a SecretService<'_>, name: &str) -> Result> { + let collection = if name.eq("default") { + ss.get_default_collection().await.map_err(decode_error)? + } else { + let all = ss.get_all_collections().await.map_err(decode_error)?; + let mut found = None; + for c in all { + if c.get_label().await.map_err(decode_error)?.eq(name) { + found = Some(c); + break; + } + } + found.ok_or(ErrorCode::NoEntry)? + }; + if collection.is_locked().await.map_err(decode_error)? { + collection.unlock().await.map_err(decode_error)?; + } + Ok(collection) +} + +/// Create a secret service collection labeled with the given name. +/// +/// If a collection with that name already exists, it is returned. +/// +/// The name `default` is specially interpreted to mean the default collection. +pub async fn create_collection<'a>( + ss: &'a SecretService<'_>, + name: &str, +) -> Result> { + let collection = if name.eq("default") { + ss.get_default_collection().await.map_err(decode_error)? + } else { + ss.create_collection(name, "").await.map_err(decode_error)? + }; + Ok(collection) +} + +/// Given an existing item, set its secret. +pub async fn set_item_secret(item: &Item<'_>, secret: &[u8]) -> Result<()> { + item.set_secret(secret, "text/plain") + .await + .map_err(decode_error) +} + +/// Given an existing item, retrieve and decode its password. +pub async fn get_item_password(item: &Item<'_>) -> Result { + let bytes = item.get_secret().await.map_err(decode_error)?; + decode_password(bytes) +} + +/// Given an existing item, retrieve its secret. +pub async fn get_item_secret(item: &Item<'_>) -> Result> { + let secret = item.get_secret().await.map_err(decode_error)?; + Ok(secret) +} + +/// Given an existing item, retrieve its non-controlled attributes. +pub async fn get_item_attributes(item: &Item<'_>) -> Result> { + let mut attributes = item.get_attributes().await.map_err(decode_error)?; + attributes.remove("target"); + attributes.remove("service"); + attributes.remove("username"); + attributes.insert( + "label".to_string(), + item.get_label().await.map_err(decode_error)?, + ); + Ok(attributes) +} + +/// Given an existing item, retrieve its non-controlled attributes. +pub async fn update_item_attributes( + item: &Item<'_>, + attributes: &HashMap<&str, &str>, +) -> Result<()> { + let existing = item.get_attributes().await.map_err(decode_error)?; + let mut updated: HashMap<&str, &str> = HashMap::new(); + for (k, v) in &existing { + updated.insert(k, v); + } + for (k, v) in attributes { + if k.eq(&"target") || k.eq(&"service") || k.eq(&"username") { + continue; + } + if k.eq(&"label") { + if v.is_empty() { + return Err(ErrorCode::Invalid( + "label".to_string(), + "cannot be empty".to_string(), + )); + } + item.set_label(v).await.map_err(decode_error)?; + if updated.contains_key("label") { + updated.insert("label", v); + } + } else { + updated.insert(k, v); + } + } + item.set_attributes(updated).await.map_err(decode_error)?; + Ok(()) +} + +// Given an existing item, delete it. +pub async fn delete_item(item: &Item<'_>) -> Result<()> { + item.delete().await.map_err(decode_error) +} + +// +// Error utilities +// + +/// Map underlying secret-service errors to crate errors with +/// appropriate annotation. +pub fn decode_error(err: Error) -> ErrorCode { + match err { + Error::Locked => no_access(err), + Error::NoResult => no_access(err), + Error::Prompt => no_access(err), + _ => platform_failure(err), + } +} + +fn empty_target() -> ErrorCode { + ErrorCode::Invalid("target".to_string(), "cannot be empty".to_string()) +} + +fn platform_failure(err: Error) -> ErrorCode { + ErrorCode::PlatformFailure(wrap(err)) +} + +fn no_access(err: Error) -> ErrorCode { + ErrorCode::NoStorageAccess(wrap(err)) +} + +fn wrap(err: Error) -> Box { + Box::new(err) +} + +#[cfg(feature = "keyring-tests")] +#[cfg(test)] +mod tests { + use crate::credential::CredentialPersistence; + use crate::secret_service::{EncryptionType, SecretService, SsCredential}; + use crate::{Entry, Error, default_credential_builder, tests::generate_random_string}; + use std::collections::HashMap; + + #[test] + fn test_persistence() { + assert!(matches!( + default_credential_builder().persistence(), + CredentialPersistence::UntilDelete + )); + } + + fn entry_new(service: &str, user: &str) -> Entry { + crate::tests::entry_from_constructor(SsCredential::new_with_target, service, user) + } + + #[test] + fn test_invalid_parameter() { + let credential = SsCredential::new_with_target(Some(""), "service", "user"); + assert!( + matches!(credential, Err(Error::Invalid(_, _))), + "Created entry with empty target" + ); + } + + #[tokio::test] + async fn test_missing_entry() { + crate::tests::test_missing_entry(entry_new).await; + } + + #[tokio::test] + async fn test_empty_password() { + crate::tests::test_empty_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_ascii_password() { + crate::tests::test_round_trip_ascii_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_non_ascii_password() { + crate::tests::test_round_trip_non_ascii_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_random_secret() { + crate::tests::test_round_trip_random_secret(entry_new).await; + } + + #[tokio::test] + async fn test_update() { + crate::tests::test_update(entry_new).await; + } + + #[tokio::test] + async fn test_get_credential() { + let name = generate_random_string(); + let entry = entry_new(&name, &name); + entry + .set_password("test get credential") + .await + .expect("Can't set password for get_credential"); + let credential: &SsCredential = entry + .get_credential() + .downcast_ref() + .expect("Not a secret service credential"); + let actual = credential + .new_from_matching_item() + .await + .expect("Can't read credential"); + assert_eq!(actual.label, credential.label, "Labels don't match"); + for (key, value) in &credential.attributes { + assert_eq!( + actual.attributes.get(key).expect("Missing attribute"), + value, + "Attribute mismatch" + ); + } + entry + .delete_credential() + .await + .expect("Couldn't delete get-credential"); + assert!(matches!(entry.get_password().await, Err(Error::NoEntry))); + } + + #[tokio::test] + async fn test_get_update_attributes() { + let name = generate_random_string(); + let credential = SsCredential::new_with_target(None, &name, &name) + .expect("Can't create credential for attribute test"); + let create_label = credential.label.clone(); + let entry = Entry::new_with_credential(Box::new(credential)); + assert!( + matches!(entry.get_attributes().await, Err(Error::NoEntry)), + "Read missing credential in attribute test", + ); + let mut in_map: HashMap<&str, &str> = HashMap::new(); + in_map.insert("label", "test label value"); + in_map.insert("test attribute name", "test attribute value"); + in_map.insert("target", "ignored target value"); + in_map.insert("service", "ignored service value"); + in_map.insert("username", "ignored username value"); + assert!( + matches!(entry.update_attributes(&in_map).await, Err(Error::NoEntry)), + "Updated missing credential in attribute test", + ); + // create the credential and test again + entry + .set_password("test password for attributes") + .await + .unwrap_or_else(|err| panic!("Can't set password for attribute test: {err:?}")); + let out_map = entry + .get_attributes() + .await + .expect("Can't get attributes after create"); + assert_eq!(out_map["label"], create_label); + assert_eq!(out_map["application"], "uv"); + assert!(!out_map.contains_key("target")); + assert!(!out_map.contains_key("service")); + assert!(!out_map.contains_key("username")); + assert!( + matches!(entry.update_attributes(&in_map).await, Ok(())), + "Couldn't update attributes in attribute test", + ); + let after_map = entry + .get_attributes() + .await + .expect("Can't get attributes after update"); + assert_eq!(after_map["label"], in_map["label"]); + assert_eq!( + after_map["test attribute name"], + in_map["test attribute name"] + ); + assert_eq!(out_map["application"], "uv"); + in_map.insert("label", ""); + assert!( + matches!( + entry.update_attributes(&in_map).await, + Err(Error::Invalid(_, _)) + ), + "Was able to set empty label in attribute test", + ); + entry + .delete_credential() + .await + .unwrap_or_else(|err| panic!("Can't delete credential for attribute test: {err:?}")); + assert!( + matches!(entry.get_attributes().await, Err(Error::NoEntry)), + "Read deleted credential in attribute test", + ); + } + + #[tokio::test] + #[ignore = "can't be run headless, because it needs to prompt"] + async fn test_create_new_target_collection() { + let name = generate_random_string(); + let credential = SsCredential::new_with_target(Some(&name), &name, &name) + .expect("Can't create credential for new collection"); + let entry = Entry::new_with_credential(Box::new(credential)); + let password = "password in new collection"; + entry + .set_password(password) + .await + .expect("Can't set password for new collection entry"); + let actual = entry + .get_password() + .await + .expect("Can't get password for new collection entry"); + assert_eq!(actual, password); + entry + .delete_credential() + .await + .expect("Couldn't delete password for new collection entry"); + assert!(matches!(entry.get_password().await, Err(Error::NoEntry))); + delete_collection(&name).await; + } + + #[tokio::test] + #[ignore = "can't be run headless, because it needs to prompt"] + async fn test_separate_targets_dont_interfere() { + let name1 = generate_random_string(); + let name2 = generate_random_string(); + let credential1 = SsCredential::new_with_target(Some(&name1), &name1, &name1) + .expect("Can't create credential1 with new collection"); + let entry1 = Entry::new_with_credential(Box::new(credential1)); + let credential2 = SsCredential::new_with_target(Some(&name2), &name1, &name1) + .expect("Can't create credential2 with new collection"); + let entry2 = Entry::new_with_credential(Box::new(credential2)); + let entry3 = Entry::new(&name1, &name1).expect("Can't create entry in default collection"); + let password1 = "password for collection 1"; + let password2 = "password for collection 2"; + let password3 = "password for default collection"; + entry1 + .set_password(password1) + .await + .expect("Can't set password for collection 1"); + entry2 + .set_password(password2) + .await + .expect("Can't set password for collection 2"); + entry3 + .set_password(password3) + .await + .expect("Can't set password for default collection"); + let actual1 = entry1 + .get_password() + .await + .expect("Can't get password for collection 1"); + assert_eq!(actual1, password1); + let actual2 = entry2 + .get_password() + .await + .expect("Can't get password for collection 2"); + assert_eq!(actual2, password2); + let actual3 = entry3 + .get_password() + .await + .expect("Can't get password for default collection"); + assert_eq!(actual3, password3); + entry1 + .delete_credential() + .await + .expect("Couldn't delete password for collection 1"); + assert!(matches!(entry1.get_password().await, Err(Error::NoEntry))); + entry2 + .delete_credential() + .await + .expect("Couldn't delete password for collection 2"); + assert!(matches!(entry2.get_password().await, Err(Error::NoEntry))); + entry3 + .delete_credential() + .await + .expect("Couldn't delete password for default collection"); + assert!(matches!(entry3.get_password().await, Err(Error::NoEntry))); + delete_collection(&name1).await; + delete_collection(&name2).await; + } + + #[tokio::test] + async fn test_legacy_entry() { + let name = generate_random_string(); + let pw = "test password"; + let v3_entry = Entry::new(&name, &name).expect("Can't create v3 entry"); + let _ = v3_entry.get_password().await.expect_err("Found v3 entry"); + create_v1_entry(&name, pw).await; + let password = v3_entry.get_password().await.expect("Can't find v1 entry"); + assert_eq!(password, pw); + v3_entry + .delete_credential() + .await + .expect("Can't delete v1 entry"); + let _ = v3_entry + .get_password() + .await + .expect_err("Got password for v1 entry after delete"); + } + + async fn delete_collection(name: &str) { + let ss = SecretService::connect(EncryptionType::Dh) + .await + .expect("Can't connect to secret service"); + let collection = super::get_collection(&ss, name) + .await + .expect("Can't find collection to delete"); + collection.delete().await.expect("Can't delete collection"); + } + + async fn create_v1_entry(name: &str, password: &str) { + use secret_service::{EncryptionType, SecretService}; + + let cred = SsCredential::new_with_no_target(name, name) + .expect("Can't create credential with no target"); + let ss = SecretService::connect(EncryptionType::Dh) + .await + .expect("Can't connect to secret service"); + let collection = ss + .get_default_collection() + .await + .expect("Can't get default collection"); + collection + .create_item( + cred.label.as_str(), + cred.all_attributes(), + password.as_bytes(), + true, // replace + "text/plain", + ) + .await + .expect("Can't create item with no target in default collection"); + } +} diff --git a/crates/uv-keyring/src/windows.rs b/crates/uv-keyring/src/windows.rs new file mode 100644 index 000000000..99d468d57 --- /dev/null +++ b/crates/uv-keyring/src/windows.rs @@ -0,0 +1,787 @@ +/*! + +# Windows Credential Manager credential store + +This module uses Windows Generic credentials to store entries. +These are identified by a single string (called their _target name_). +They also have a number of non-identifying but manipulable attributes: +a _username_, a _comment_, and a _target alias_. + +For a given <_service_, _username_> pair, +this module uses the concatenated string `username.service` +as the mapped credential's _target name_, and +fills the _username_ and _comment_ fields with appropriate strings. +(This convention allows multiple users to store passwords for the same service.) + +Because the Windows credential manager doesn't support multiple collections of credentials, +and because many Windows programs use _only_ the service name as the credential _target name_, +the `Entry::new_with_target` call uses the `target` parameter as the credential's _target name_ +rather than concatenating the username and service. +So if you have a custom algorithm you want to use for computing the Windows target name, +you can specify the target name directly. (You still need to provide a service and username, +because they are used in the credential's metadata.) + +The [`get_attributes`](crate::Entry::get_attributes) +call will return the values in the `username`, `comment`, and `target_alias` fields +(using those strings as the attribute names), +and the [`update_attributes`](crate::Entry::update_attributes) +call allows setting those fields. + +## Caveat + +Reads and writes of the same entry from multiple threads +are not guaranteed to be serialized by the Windows Credential Manager in +the order in which they were made. Careful testing has +shown that modifying the same entry in the same (almost simultaneous) order from +different threads produces different results on different runs. +*/ + +#![allow(unsafe_code)] + +use crate::credential::{Credential, CredentialApi, CredentialBuilder, CredentialBuilderApi}; +use crate::error::{Error as ErrorCode, Result}; +use byteorder::{ByteOrder, LittleEndian}; +use std::collections::HashMap; +use std::iter::once; +use std::str; +use windows_sys::Win32::Foundation::{ + ERROR_BAD_USERNAME, ERROR_INVALID_FLAGS, ERROR_INVALID_PARAMETER, ERROR_NO_SUCH_LOGON_SESSION, + ERROR_NOT_FOUND, FILETIME, GetLastError, +}; +use windows_sys::Win32::Security::Credentials::{ + CRED_FLAGS, CRED_MAX_CREDENTIAL_BLOB_SIZE, CRED_MAX_GENERIC_TARGET_NAME_LENGTH, + CRED_MAX_STRING_LENGTH, CRED_MAX_USERNAME_LENGTH, CRED_PERSIST_ENTERPRISE, CRED_TYPE_GENERIC, + CREDENTIAL_ATTRIBUTEW, CREDENTIALW, CredDeleteW, CredFree, CredReadW, CredWriteW, +}; +use zeroize::Zeroize; + +/// The representation of a Windows Generic credential. +/// +/// See the module header for the meanings of these fields. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WinCredential { + pub username: String, + pub target_name: String, + pub target_alias: String, + pub comment: String, +} + +// Windows API type mappings: +// DWORD is u32 +// LPCWSTR is *const u16 +// BOOL is i32 (false = 0, true = 1) +// PCREDENTIALW = *mut CREDENTIALW + +#[async_trait::async_trait] +impl CredentialApi for WinCredential { + /// Create and write a credential with password for this entry. + /// + /// The new credential replaces any existing one in the store. + /// Since there is only one credential with a given _target name_, + /// there is no chance of ambiguity. + async fn set_password(&self, password: &str) -> Result<()> { + self.validate_attributes(None, Some(password))?; + // Password strings are converted to UTF-16, because that's the native + // charset for Windows strings. This allows interoperability with native + // Windows credential APIs. But the storage for the credential is actually + // a little-endian blob, because Windows credentials can contain anything. + let mut blob_u16 = to_wstr_no_null(password); + let mut blob = vec![0; blob_u16.len() * 2]; + LittleEndian::write_u16_into(&blob_u16, &mut blob); + let result = self.set_secret(&blob).await; + // make sure that the copies of the secret are erased + blob_u16.zeroize(); + blob.zeroize(); + result + } + + /// Create and write a credential with secret for this entry. + /// + /// The new credential replaces any existing one in the store. + /// Since there is only one credential with a given _target name_, + /// there is no chance of ambiguity. + async fn set_secret(&self, secret: &[u8]) -> Result<()> { + self.validate_attributes(Some(secret), None)?; + self.save_credential(secret).await + } + + /// Look up the password for this entry, if any. + /// + /// Returns a [`NoEntry`](ErrorCode::NoEntry) error if there is no + /// credential in the store. + async fn get_password(&self) -> Result { + self.extract_from_platform(extract_password).await + } + + /// Look up the secret for this entry, if any. + /// + /// Returns a [`NoEntry`](ErrorCode::NoEntry) error if there is no + /// credential in the store. + async fn get_secret(&self) -> Result> { + self.extract_from_platform(extract_secret).await + } + + /// Get the attributes from the credential for this entry, if it exists. + /// + /// Returns a [`NoEntry`](ErrorCode::NoEntry) error if there is no + /// credential in the store. + async fn get_attributes(&self) -> Result> { + let cred = self.extract_from_platform(Self::extract_credential).await?; + let mut attributes: HashMap = HashMap::new(); + attributes.insert("comment".to_string(), cred.comment.clone()); + attributes.insert("target_alias".to_string(), cred.target_alias.clone()); + attributes.insert("username".to_string(), cred.username.clone()); + Ok(attributes) + } + + /// Update the attributes on the credential for this entry, if it exists. + /// + /// Returns a [`NoEntry`](ErrorCode::NoEntry) error if there is no + /// credential in the store. + async fn update_attributes(&self, attributes: &HashMap<&str, &str>) -> Result<()> { + let secret = self.extract_from_platform(extract_secret).await?; + let mut cred = self.extract_from_platform(Self::extract_credential).await?; + if let Some(comment) = attributes.get(&"comment") { + cred.comment = (*comment).to_string(); + } + if let Some(target_alias) = attributes.get(&"target_alias") { + cred.target_alias = (*target_alias).to_string(); + } + if let Some(username) = attributes.get(&"username") { + cred.username = (*username).to_string(); + } + cred.validate_attributes(Some(&secret), None)?; + cred.save_credential(&secret).await + } + + /// Delete the underlying generic credential for this entry, if any. + /// + /// Returns a [`NoEntry`](ErrorCode::NoEntry) error if there is no + /// credential in the store. + async fn delete_credential(&self) -> Result<()> { + self.validate_attributes(None, None)?; + let target_name = to_wstr(&self.target_name); + let cred_type = CRED_TYPE_GENERIC; + // SAFETY: Calling Windows API + match crate::blocking::spawn_blocking(move || unsafe { + Ok(CredDeleteW(target_name.as_ptr(), cred_type, 0)) + }) + .await? + { + 0 => Err(decode_error()), + _ => Ok(()), + } + } + + /// Return the underlying concrete object with an `Any` type so that it can + /// be downgraded to a [`WinCredential`] for platform-specific processing. + fn as_any(&self) -> &dyn std::any::Any { + self + } + + /// Expose the concrete debug formatter for use via the [`Credential`] trait + fn debug_fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Debug::fmt(self, f) + } +} + +impl WinCredential { + fn validate_attributes(&self, secret: Option<&[u8]>, password: Option<&str>) -> Result<()> { + if self.username.len() > CRED_MAX_USERNAME_LENGTH as usize { + return Err(ErrorCode::TooLong( + String::from("user"), + CRED_MAX_USERNAME_LENGTH, + )); + } + if self.target_name.is_empty() { + return Err(ErrorCode::Invalid( + "target".to_string(), + "cannot be empty".to_string(), + )); + } + if self.target_name.len() > CRED_MAX_GENERIC_TARGET_NAME_LENGTH as usize { + return Err(ErrorCode::TooLong( + String::from("target"), + CRED_MAX_GENERIC_TARGET_NAME_LENGTH, + )); + } + if self.target_alias.len() > CRED_MAX_STRING_LENGTH as usize { + return Err(ErrorCode::TooLong( + String::from("target alias"), + CRED_MAX_STRING_LENGTH, + )); + } + if self.comment.len() > CRED_MAX_STRING_LENGTH as usize { + return Err(ErrorCode::TooLong( + String::from("comment"), + CRED_MAX_STRING_LENGTH, + )); + } + if let Some(secret) = secret { + if secret.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE as usize { + return Err(ErrorCode::TooLong( + String::from("secret"), + CRED_MAX_CREDENTIAL_BLOB_SIZE, + )); + } + } + if let Some(password) = password { + // We're going to store the password as UTF-16, so first transform it to UTF-16, + // count its runes, and then multiply by 2 to get the number of bytes needed. + if password.encode_utf16().count() * 2 > CRED_MAX_CREDENTIAL_BLOB_SIZE as usize { + return Err(ErrorCode::TooLong( + String::from("password encoded as UTF-16"), + CRED_MAX_CREDENTIAL_BLOB_SIZE, + )); + } + } + Ok(()) + } + + /// Write this credential into the underlying store as a Generic credential + /// + /// You must always have validated attributes before you call this! + #[allow(clippy::cast_possible_truncation)] + async fn save_credential(&self, secret: &[u8]) -> Result<()> { + let mut username = to_wstr(&self.username); + let mut target_name = to_wstr(&self.target_name); + let mut target_alias = to_wstr(&self.target_alias); + let mut comment = to_wstr(&self.comment); + let mut blob = secret.to_vec(); + let blob_len = blob.len() as u32; + crate::blocking::spawn_blocking(move || { + let flags = CRED_FLAGS::default(); + let cred_type = CRED_TYPE_GENERIC; + let persist = CRED_PERSIST_ENTERPRISE; + // Ignored by CredWriteW + let last_written = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let attribute_count = 0; + let attributes: *mut CREDENTIAL_ATTRIBUTEW = std::ptr::null_mut(); + let credential = CREDENTIALW { + Flags: flags, + Type: cred_type, + TargetName: target_name.as_mut_ptr(), + Comment: comment.as_mut_ptr(), + LastWritten: last_written, + CredentialBlobSize: blob_len, + CredentialBlob: blob.as_mut_ptr(), + Persist: persist, + AttributeCount: attribute_count, + Attributes: attributes, + TargetAlias: target_alias.as_mut_ptr(), + UserName: username.as_mut_ptr(), + }; + // SAFETY: Calling Windows API + let result = match unsafe { CredWriteW(&raw const credential, 0) } { + 0 => Err(decode_error()), + _ => Ok(()), + }; + // erase the copy of the secret + blob.zeroize(); + result + }) + .await + } + + /// Construct a credential from this credential's underlying Generic credential. + /// + /// This can be useful for seeing modifications made by a third party. + pub async fn get_credential(&self) -> Result { + self.extract_from_platform(Self::extract_credential).await + } + + async fn extract_from_platform(&self, f: F) -> Result + where + F: FnOnce(&CREDENTIALW) -> Result + Send + 'static, + T: Send + 'static, + { + self.validate_attributes(None, None)?; + let target_name = to_wstr(&self.target_name); + crate::blocking::spawn_blocking(move || { + let mut p_credential = std::ptr::null_mut(); + // at this point, p_credential is just a pointer to nowhere. + // The allocation happens in the `CredReadW` call below. + let cred_type = CRED_TYPE_GENERIC; + // SAFETY: Calling windows API + let result = + unsafe { CredReadW(target_name.as_ptr(), cred_type, 0, &raw mut p_credential) }; + if result == 0 { + // `CredReadW` failed, so no allocation has been done, so no free needs to be done + Err(decode_error()) + } else { + // SAFETY: `CredReadW` succeeded, so p_credential points at an allocated credential. Apply + // the passed extractor function to it. + let ref_cred: &mut CREDENTIALW = unsafe { &mut *p_credential }; + let result = f(ref_cred); + // Finally, we erase the secret and free the allocated credential. + erase_secret(ref_cred); + let p_credential = p_credential; + // SAFETY: `CredReadW` succeeded, so p_credential points at an allocated credential. + // Free the allocation. + unsafe { CredFree(p_credential.cast()) } + result + } + }) + .await + } + + #[allow(clippy::unnecessary_wraps)] + fn extract_credential(w_credential: &CREDENTIALW) -> Result { + Ok(Self { + username: unsafe { from_wstr(w_credential.UserName) }, + target_name: unsafe { from_wstr(w_credential.TargetName) }, + target_alias: unsafe { from_wstr(w_credential.TargetAlias) }, + comment: unsafe { from_wstr(w_credential.Comment) }, + }) + } + + /// Create a credential for the given target, service, and user. + /// + /// Creating a credential does not create a matching Generic credential + /// in the Windows Credential Manager. + /// If there isn't already one there, it will be created only + /// when [`set_password`](WinCredential::set_password) is + /// called. + pub fn new_with_target(target: Option<&str>, service: &str, user: &str) -> Result { + const VERSION: &str = env!("CARGO_PKG_VERSION"); + let credential = if let Some(target) = target { + Self { + // On Windows, the target name is all that's used to + // search for the credential, so we allow clients to + // specify it if they want a different convention. + username: user.to_string(), + target_name: target.to_string(), + target_alias: String::new(), + comment: format!("{user}@{service}:{target} (keyring v{VERSION})"), + } + } else { + Self { + // Note: default concatenation of user and service name is + // used because windows uses target_name as sole identifier. + // See the module docs for more rationale. Also see this issue + // for Python: https://github.com/jaraco/keyring/issues/47 + // + // Note that it's OK to have an empty user or service name, + // because the format for the target name will not be empty. + // But it's certainly not recommended. + username: user.to_string(), + target_name: format!("{user}.{service}"), + target_alias: String::new(), + comment: format!("{user}@{service}:{user}.{service} (keyring v{VERSION})"), + } + }; + credential.validate_attributes(None, None)?; + Ok(credential) + } +} + +/// The builder for Windows Generic credentials. +pub struct WinCredentialBuilder; + +/// Returns an instance of the Windows credential builder. +/// +/// On Windows, with the default feature set, +/// this is called once when an entry is first created. +pub fn default_credential_builder() -> Box { + Box::new(WinCredentialBuilder {}) +} + +impl CredentialBuilderApi for WinCredentialBuilder { + /// Build a [`WinCredential`] for the given target, service, and user. + fn build(&self, target: Option<&str>, service: &str, user: &str) -> Result> { + Ok(Box::new(WinCredential::new_with_target( + target, service, user, + )?)) + } + + /// Return the underlying builder object with an `Any` type so that it can + /// be downgraded to a [`WinCredentialBuilder`] for platform-specific processing. + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +fn extract_password(credential: &CREDENTIALW) -> Result { + let mut blob = extract_secret(credential)?; + // 3rd parties may write credential data with an odd number of bytes, + // so we make sure that we don't try to decode those as utf16 + if blob.len() % 2 != 0 { + return Err(ErrorCode::BadEncoding(blob)); + } + // This should be a UTF-16 string, so convert it to + // a UTF-16 vector and then try to decode it. + let mut blob_u16 = vec![0; blob.len() / 2]; + LittleEndian::read_u16_into(&blob, &mut blob_u16); + let result = match String::from_utf16(&blob_u16) { + Err(_) => Err(ErrorCode::BadEncoding(blob)), + Ok(s) => { + // we aren't returning the blob, so clear it + blob.zeroize(); + Ok(s) + } + }; + // we aren't returning the utf16 blob, so clear it + blob_u16.zeroize(); + result +} + +#[allow(clippy::unnecessary_wraps)] +fn extract_secret(credential: &CREDENTIALW) -> Result> { + let blob_pointer: *const u8 = credential.CredentialBlob; + let blob_len: usize = credential.CredentialBlobSize as usize; + if blob_len == 0 { + return Ok(Vec::new()); + } + let blob = unsafe { std::slice::from_raw_parts(blob_pointer, blob_len) }; + Ok(blob.to_vec()) +} + +fn erase_secret(credential: &mut CREDENTIALW) { + let blob_pointer: *mut u8 = credential.CredentialBlob; + let blob_len: usize = credential.CredentialBlobSize as usize; + if blob_len == 0 { + return; + } + let blob = unsafe { std::slice::from_raw_parts_mut(blob_pointer, blob_len) }; + blob.zeroize(); +} + +fn to_wstr(s: &str) -> Vec { + s.encode_utf16().chain(once(0)).collect() +} + +fn to_wstr_no_null(s: &str) -> Vec { + s.encode_utf16().collect() +} + +#[allow(clippy::maybe_infinite_iter)] +unsafe fn from_wstr(ws: *const u16) -> String { + // null pointer case, return empty string + if ws.is_null() { + return String::new(); + } + // this code from https://stackoverflow.com/a/48587463/558006 + let len = (0..).take_while(|&i| unsafe { *ws.offset(i) != 0 }).count(); + if len == 0 { + return String::new(); + } + let slice = unsafe { std::slice::from_raw_parts(ws, len) }; + String::from_utf16_lossy(slice) +} + +/// Windows error codes are `DWORDS` which are 32-bit unsigned ints. +#[derive(Debug)] +pub struct Error(pub u32); + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self.0 { + ERROR_NO_SUCH_LOGON_SESSION => write!(f, "Windows ERROR_NO_SUCH_LOGON_SESSION"), + ERROR_NOT_FOUND => write!(f, "Windows ERROR_NOT_FOUND"), + ERROR_BAD_USERNAME => write!(f, "Windows ERROR_BAD_USERNAME"), + ERROR_INVALID_FLAGS => write!(f, "Windows ERROR_INVALID_FLAGS"), + ERROR_INVALID_PARAMETER => write!(f, "Windows ERROR_INVALID_PARAMETER"), + err => write!(f, "Windows error code {err}"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + None + } +} + +/// Map the last encountered Windows API error to a crate error with appropriate annotation. +pub fn decode_error() -> ErrorCode { + // SAFETY: Calling Windows API + match unsafe { GetLastError() } { + ERROR_NOT_FOUND => ErrorCode::NoEntry, + ERROR_NO_SUCH_LOGON_SESSION => { + ErrorCode::NoStorageAccess(wrap(ERROR_NO_SUCH_LOGON_SESSION)) + } + err => ErrorCode::PlatformFailure(wrap(err)), + } +} + +fn wrap(code: u32) -> Box { + Box::new(Error(code)) +} + +#[cfg(feature = "keyring-tests")] +#[cfg(test)] +mod tests { + use super::*; + + use crate::Entry; + use crate::credential::CredentialPersistence; + use crate::tests::{generate_random_string, generate_random_string_of_len}; + + #[test] + fn test_persistence() { + assert!(matches!( + default_credential_builder().persistence(), + CredentialPersistence::UntilDelete + )); + } + + fn entry_new(service: &str, user: &str) -> Entry { + crate::tests::entry_from_constructor(WinCredential::new_with_target, service, user) + } + + #[allow(clippy::cast_possible_truncation)] + #[test] + fn test_bad_password() { + fn make_platform_credential(password: &mut Vec) -> CREDENTIALW { + let last_written = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let attribute_count = 0; + let attributes: *mut CREDENTIAL_ATTRIBUTEW = std::ptr::null_mut(); + CREDENTIALW { + Flags: 0, + Type: CRED_TYPE_GENERIC, + TargetName: std::ptr::null_mut(), + Comment: std::ptr::null_mut(), + LastWritten: last_written, + CredentialBlobSize: password.len() as u32, + CredentialBlob: password.as_mut_ptr(), + Persist: CRED_PERSIST_ENTERPRISE, + AttributeCount: attribute_count, + Attributes: attributes, + TargetAlias: std::ptr::null_mut(), + UserName: std::ptr::null_mut(), + } + } + // the first malformed sequence can't be UTF-16 because it has an odd number of bytes. + // the second malformed sequence has a first surrogate marker (0xd800) without a matching + // companion (it's taken from the String::fromUTF16 docs). + let mut odd_bytes = b"1".to_vec(); + let malformed_utf16 = [0xD834, 0xDD1E, 0x006d, 0x0075, 0xD800, 0x0069, 0x0063]; + let mut malformed_bytes: Vec = vec![0; malformed_utf16.len() * 2]; + LittleEndian::write_u16_into(&malformed_utf16, &mut malformed_bytes); + for bytes in [&mut odd_bytes, &mut malformed_bytes] { + let credential = make_platform_credential(bytes); + match extract_password(&credential) { + Err(ErrorCode::BadEncoding(str)) => assert_eq!(&str, bytes), + Err(other) => panic!("Bad password ({bytes:?}) decode gave wrong error: {other}"), + Ok(s) => panic!("Bad password ({bytes:?}) decode gave results: {s:?}"), + } + } + } + + #[test] + fn test_validate_attributes() { + fn validate_attribute_too_long(result: Result<()>, attr: &str, len: u32) { + match result { + Err(ErrorCode::TooLong(arg, val)) => { + if attr == "password" { + assert_eq!( + &arg, "password encoded as UTF-16", + "Error names wrong attribute" + ); + } else { + assert_eq!(&arg, attr, "Error names wrong attribute"); + } + assert_eq!(val, len, "Error names wrong limit"); + } + Err(other) => panic!("Error is not '{attr} too long': {other}"), + Ok(()) => panic!("No error when {attr} too long"), + } + } + let cred = WinCredential { + username: "username".to_string(), + target_name: "target_name".to_string(), + target_alias: "target_alias".to_string(), + comment: "comment".to_string(), + }; + for (attr, len) in [ + ("user", CRED_MAX_USERNAME_LENGTH), + ("target", CRED_MAX_GENERIC_TARGET_NAME_LENGTH), + ("target alias", CRED_MAX_STRING_LENGTH), + ("comment", CRED_MAX_STRING_LENGTH), + ("password", CRED_MAX_CREDENTIAL_BLOB_SIZE), + ("secret", CRED_MAX_CREDENTIAL_BLOB_SIZE), + ] { + let long_string = generate_random_string_of_len(1 + len as usize); + let mut bad_cred = cred.clone(); + match attr { + "user" => bad_cred.username = long_string.clone(), + "target" => bad_cred.target_name = long_string.clone(), + "target alias" => bad_cred.target_alias = long_string.clone(), + "comment" => bad_cred.comment = long_string.clone(), + _ => (), + } + let validate = |r| validate_attribute_too_long(r, attr, len); + match attr { + "password" => { + let password = generate_random_string_of_len((len / 2) as usize + 1); + validate(bad_cred.validate_attributes(None, Some(&password))); + } + "secret" => { + let secret: Vec = vec![255u8; len as usize + 1]; + validate(bad_cred.validate_attributes(Some(&secret), None)); + } + _ => validate(bad_cred.validate_attributes(None, None)), + } + } + } + + #[test] + fn test_password_valid_only_after_conversion_to_utf16() { + let cred = WinCredential { + username: "username".to_string(), + target_name: "target_name".to_string(), + target_alias: "target_alias".to_string(), + comment: "comment".to_string(), + }; + + let len = CRED_MAX_CREDENTIAL_BLOB_SIZE / 2; + let password: String = (0..len).map(|_| "笑").collect(); + + assert!(password.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE as usize); + cred.validate_attributes(None, Some(&password)) + .expect("Password of appropriate length in UTF16 was invalid"); + } + + #[test] + fn test_invalid_parameter() { + let credential = WinCredential::new_with_target(Some(""), "service", "user"); + assert!( + matches!(credential, Err(ErrorCode::Invalid(_, _))), + "Created entry with empty target" + ); + } + + #[tokio::test] + async fn test_missing_entry() { + crate::tests::test_missing_entry(entry_new).await; + } + + #[tokio::test] + async fn test_empty_password() { + crate::tests::test_empty_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_ascii_password() { + crate::tests::test_round_trip_ascii_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_non_ascii_password() { + crate::tests::test_round_trip_non_ascii_password(entry_new).await; + } + + #[tokio::test] + async fn test_round_trip_random_secret() { + crate::tests::test_round_trip_random_secret(entry_new).await; + } + + #[tokio::test] + async fn test_update() { + crate::tests::test_update(entry_new).await; + } + + #[tokio::test] + async fn test_get_update_attributes() { + let name = generate_random_string(); + let cred = WinCredential::new_with_target(None, &name, &name) + .expect("Can't create credential for attribute test"); + let entry = Entry::new_with_credential(Box::new(cred.clone())); + assert!( + matches!(entry.get_attributes().await, Err(ErrorCode::NoEntry)), + "Read missing credential in attribute test", + ); + let mut in_map: HashMap<&str, &str> = HashMap::new(); + in_map.insert("label", "ignored label value"); + in_map.insert("attribute name", "ignored attribute value"); + in_map.insert("target_alias", "target alias value"); + in_map.insert("comment", "comment value"); + in_map.insert("username", "username value"); + assert!( + matches!( + entry.update_attributes(&in_map).await, + Err(ErrorCode::NoEntry) + ), + "Updated missing credential in attribute test", + ); + // create the credential and test again + entry + .set_password("test password for attributes") + .await + .unwrap_or_else(|err| panic!("Can't set password for attribute test: {err:?}")); + let out_map = entry + .get_attributes() + .await + .expect("Can't get attributes after create"); + assert_eq!(out_map["target_alias"], cred.target_alias); + assert_eq!(out_map["comment"], cred.comment); + assert_eq!(out_map["username"], cred.username); + assert!( + matches!(entry.update_attributes(&in_map).await, Ok(())), + "Couldn't update attributes in attribute test", + ); + let after_map = entry + .get_attributes() + .await + .expect("Can't get attributes after update"); + assert_eq!(after_map["target_alias"], in_map["target_alias"]); + assert_eq!(after_map["comment"], in_map["comment"]); + assert_eq!(after_map["username"], in_map["username"]); + assert!(!after_map.contains_key("label")); + assert!(!after_map.contains_key("attribute name")); + entry + .delete_credential() + .await + .unwrap_or_else(|err| panic!("Can't delete credential for attribute test: {err:?}")); + assert!( + matches!(entry.get_attributes().await, Err(ErrorCode::NoEntry)), + "Read deleted credential in attribute test", + ); + } + + #[tokio::test] + async fn test_get_credential() { + let name = generate_random_string(); + let entry = entry_new(&name, &name); + let password = "test get password"; + entry + .set_password(password) + .await + .expect("Can't set test get password"); + let credential: &WinCredential = entry + .get_credential() + .downcast_ref() + .expect("Not a windows credential"); + let actual = credential + .get_credential() + .await + .expect("Can't read credential"); + assert_eq!( + actual.username, credential.username, + "Usernames don't match" + ); + assert_eq!( + actual.target_name, credential.target_name, + "Target names don't match" + ); + assert_eq!( + actual.target_alias, credential.target_alias, + "Target aliases don't match" + ); + assert_eq!(actual.comment, credential.comment, "Comments don't match"); + entry + .delete_credential() + .await + .expect("Couldn't delete get-credential"); + assert!(matches!( + entry.get_password().await, + Err(ErrorCode::NoEntry) + )); + } +} diff --git a/crates/uv-keyring/tests/basic.rs b/crates/uv-keyring/tests/basic.rs new file mode 100644 index 000000000..fc833f8ab --- /dev/null +++ b/crates/uv-keyring/tests/basic.rs @@ -0,0 +1,175 @@ +#![cfg(feature = "keyring-tests")] + +use common::{generate_random_bytes_of_len, generate_random_string, init_logger}; +use uv_keyring::{Entry, Error}; + +mod common; + +#[tokio::test] +async fn test_missing_entry() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Missing entry has password" + ); +} + +#[tokio::test] +#[cfg(target_os = "linux")] +async fn test_empty_password() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + let in_pass = ""; + entry + .set_password(in_pass) + .await + .expect("Can't set empty password"); + let out_pass = entry + .get_password() + .await + .expect("Can't get empty password"); + assert_eq!( + in_pass, out_pass, + "Retrieved and set empty passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted password" + ); +} + +#[tokio::test] +async fn test_round_trip_ascii_password() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + let password = "test ascii password"; + entry + .set_password(password) + .await + .expect("Can't set ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, password, + "Retrieved and set ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted ascii password" + ); +} + +#[tokio::test] +async fn test_round_trip_non_ascii_password() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + let password = "このきれいな花は桜です"; + entry + .set_password(password) + .await + .expect("Can't set non-ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get non-ascii password"); + assert_eq!( + stored_password, password, + "Retrieved and set non-ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete non-ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted non-ascii password" + ); +} + +#[tokio::test] +async fn test_round_trip_random_secret() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + let secret = generate_random_bytes_of_len(24); + entry + .set_secret(secret.as_slice()) + .await + .expect("Can't set random secret"); + let stored_secret = entry.get_secret().await.expect("Can't get random secret"); + assert_eq!( + &stored_secret, + secret.as_slice(), + "Retrieved and set random secrets don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete random secret"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted random secret" + ); +} + +#[tokio::test] +async fn test_update() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + let password = "test ascii password"; + entry + .set_password(password) + .await + .expect("Can't set initial ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, password, + "Retrieved and set initial ascii passwords don't match" + ); + let password = "このきれいな花は桜です"; + entry + .set_password(password) + .await + .expect("Can't update ascii with non-ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get non-ascii password"); + assert_eq!( + stored_password, password, + "Retrieved and updated non-ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete updated password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted updated password" + ); +} diff --git a/crates/uv-keyring/tests/common/mod.rs b/crates/uv-keyring/tests/common/mod.rs new file mode 100644 index 000000000..0e2304db9 --- /dev/null +++ b/crates/uv-keyring/tests/common/mod.rs @@ -0,0 +1,33 @@ +#![allow(dead_code)] // not all of these utilities are used by all tests + +/// When tests fail, they leave keys behind, and those keys +/// have to be cleaned up before the tests can be run again +/// in order to avoid bad results. So it's a lot easier just +/// to have tests use a random string for key names to avoid +/// the conflicts, and then do any needed cleanup once everything +/// is working correctly. So tests make keys with these functions. +/// When tests fail, they leave keys behind, and those keys +/// have to be cleaned up before the tests can be run again +/// in order to avoid bad results. So it's a lot easier just +/// to have tests use a random string for key names to avoid +/// the conflicts, and then do any needed cleanup once everything +/// is working correctly. So we export this function for tests to use. +pub(crate) fn generate_random_string_of_len(len: usize) -> String { + use fastrand; + use std::iter::repeat_with; + repeat_with(fastrand::alphanumeric).take(len).collect() +} + +pub(crate) fn generate_random_string() -> String { + generate_random_string_of_len(30) +} + +pub(crate) fn generate_random_bytes_of_len(len: usize) -> Vec { + use fastrand; + use std::iter::repeat_with; + repeat_with(|| fastrand::u8(..)).take(len).collect() +} + +pub(crate) fn init_logger() { + let _ = env_logger::builder().is_test(true).try_init(); +} diff --git a/crates/uv-keyring/tests/threading.rs b/crates/uv-keyring/tests/threading.rs new file mode 100644 index 000000000..e2389755d --- /dev/null +++ b/crates/uv-keyring/tests/threading.rs @@ -0,0 +1,280 @@ +#![cfg(feature = "keyring-tests")] + +use common::{generate_random_string, init_logger}; +use uv_keyring::{Entry, Error}; + +mod common; + +#[tokio::test] +async fn test_create_then_move() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).unwrap(); + + let handle = tokio::spawn(async move { + let password = "test ascii password"; + entry + .set_password(password) + .await + .expect("Can't set initial ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, password, + "Retrieved and set initial ascii passwords don't match" + ); + let password = "このきれいな花は桜です"; + entry + .set_password(password) + .await + .expect("Can't set non-ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get non-ascii password"); + assert_eq!( + stored_password, password, + "Retrieved and set non-ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete non-ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted non-ascii password" + ); + }); + + handle.await.expect("Task failed"); +} + +#[tokio::test] +async fn test_simultaneous_create_then_move() { + init_logger(); + + let mut handles = vec![]; + for i in 0..10 { + let name = format!("{}-{}", generate_random_string(), i); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + + let handle = tokio::spawn(async move { + entry + .set_password(&name) + .await + .expect("Can't set ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, name, + "Retrieved and set ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted ascii password" + ); + }); + handles.push(handle); + } + + for handle in handles { + handle.await.expect("Task failed"); + } +} + +#[tokio::test] +#[cfg(not(target_os = "windows"))] +async fn test_create_set_then_move() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + let password = "test ascii password"; + entry + .set_password(password) + .await + .expect("Can't set ascii password"); + + let handle = tokio::spawn(async move { + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, password, + "Retrieved and set ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted ascii password" + ); + }); + + handle.await.expect("Task failed"); +} + +#[tokio::test] +#[cfg(not(target_os = "windows"))] +async fn test_simultaneous_create_set_then_move() { + init_logger(); + + let mut handles = vec![]; + for i in 0..10 { + let name = format!("{}-{}", generate_random_string(), i); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + entry + .set_password(&name) + .await + .expect("Can't set ascii password"); + + let handle = tokio::spawn(async move { + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, name, + "Retrieved and set ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted ascii password" + ); + }); + handles.push(handle); + } + + for handle in handles { + handle.await.expect("Task failed"); + } +} + +#[tokio::test] +async fn test_simultaneous_independent_create_set() { + init_logger(); + + let mut handles = vec![]; + for i in 0..10 { + let name = format!("thread_entry{i}"); + let handle = tokio::spawn(async move { + let entry = Entry::new(&name, &name).expect("Can't create entry"); + entry + .set_password(&name) + .await + .expect("Can't set ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, name, + "Retrieved and set ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted ascii password" + ); + }); + handles.push(handle); + } + + for handle in handles { + handle.await.expect("Task failed"); + } +} + +#[tokio::test] +#[cfg(any(target_os = "macos", target_os = "windows"))] +async fn test_multiple_create_delete_single_thread() { + init_logger(); + + let name = generate_random_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + let repeats = 10; + for _i in 0..repeats { + entry + .set_password(&name) + .await + .expect("Can't set ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, name, + "Retrieved and set ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted ascii password" + ); + } +} + +#[tokio::test] +#[cfg(any(target_os = "macos", target_os = "windows"))] +async fn test_simultaneous_multiple_create_delete_single_thread() { + init_logger(); + + let mut handles = vec![]; + for t in 0..10 { + let name = generate_random_string(); + let handle = tokio::spawn(async move { + let name = format!("{name}-{t}"); + let entry = Entry::new(&name, &name).expect("Can't create entry"); + let repeats = 10; + for _i in 0..repeats { + entry + .set_password(&name) + .await + .expect("Can't set ascii password"); + let stored_password = entry + .get_password() + .await + .expect("Can't get ascii password"); + assert_eq!( + stored_password, name, + "Retrieved and set ascii passwords don't match" + ); + entry + .delete_credential() + .await + .expect("Can't delete ascii password"); + assert!( + matches!(entry.get_password().await, Err(Error::NoEntry)), + "Able to read a deleted ascii password" + ); + } + }); + handles.push(handle); + } + + for handle in handles { + handle.await.expect("Task failed"); + } +}