mirror of https://github.com/astral-sh/uv
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
This commit is contained in:
parent
77fe8d2e60
commit
880eb286e8
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
use crate::error::{Error as ErrorCode, Result};
|
||||
|
||||
pub(crate) async fn spawn_blocking<F, T>(f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce() -> Result<T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
tokio::task::spawn_blocking(f)
|
||||
.await
|
||||
.map_err(|e| ErrorCode::PlatformFailure(Box::new(e)))?
|
||||
}
|
||||
|
|
@ -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<String> {
|
||||
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<Vec<u8>>;
|
||||
|
||||
/// 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<HashMap<String, String>> {
|
||||
// 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<Box<Credential>>;
|
||||
|
||||
/// 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<Box<Credential>> {
|
||||
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<CredentialBuilder> {
|
||||
Box::new(NopCredentialBuilder)
|
||||
}
|
||||
|
|
@ -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<dyn std::error::Error + Send + Sync>),
|
||||
/// 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<dyn std::error::Error + Send + Sync>),
|
||||
/// 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<u8>),
|
||||
/// 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<Box<Credential>>),
|
||||
/// This indicates that there was no default credential builder to use;
|
||||
/// the client must set one before creating entries.
|
||||
NoDefaultCredentialBuilder,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
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<u8>) -> Result<String> {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Box<CredentialBuilder>>,
|
||||
}
|
||||
|
||||
static DEFAULT_BUILDER: std::sync::RwLock<EntryBuilder> =
|
||||
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<CredentialBuilder>) {
|
||||
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<CredentialBuilder> {
|
||||
#[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<Entry> {
|
||||
static DEFAULT: std::sync::LazyLock<Box<CredentialBuilder>> =
|
||||
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<Credential>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Credential>) -> 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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<HashMap<String, String>> {
|
||||
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, T>(f: F, service: &str, user: &str) -> Entry
|
||||
where
|
||||
F: FnOnce(Option<&str>, &str, &str) -> Result<T>,
|
||||
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<u8> {
|
||||
use fastrand;
|
||||
use std::iter::repeat_with;
|
||||
repeat_with(|| fastrand::u8(..)).take(len).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn test_missing_entry<F>(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: 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: 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: 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: 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: 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: 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> {
|
||||
let service = self.service.clone();
|
||||
let account = self.account.clone();
|
||||
let domain = self.domain;
|
||||
|
||||
let password_bytes = crate::blocking::spawn_blocking(move || -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
let service = self.service.clone();
|
||||
let account = self.account.clone();
|
||||
let domain = self.domain;
|
||||
|
||||
let password_bytes = crate::blocking::spawn_blocking(move || -> Result<Vec<u8>> {
|
||||
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<Self> {
|
||||
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<MacKeychainDomain>,
|
||||
service: &str,
|
||||
user: &str,
|
||||
) -> Result<Self> {
|
||||
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<CredentialBuilder> {
|
||||
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<Box<Credential>> {
|
||||
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<Self> {
|
||||
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<SecKeychain> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RefCell<MockData>>,
|
||||
}
|
||||
|
||||
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<Vec<u8>>,
|
||||
pub error: Option<Error>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<Box<Credential>> {
|
||||
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<CredentialBuilder> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, String>,
|
||||
pub label: String,
|
||||
target: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<HashMap<String, String>> {
|
||||
let attributes: Vec<HashMap<String, String>> =
|
||||
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<String, String> = 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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Vec<String>> {
|
||||
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<F, T>(&self, f: F, require_unique: bool) -> Result<Vec<T>>
|
||||
where
|
||||
F: AsyncFn(&Item<'_>) -> Result<T>,
|
||||
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<Box<Credential>> = 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<T> = 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<F, T>(
|
||||
&self,
|
||||
ss: &SecretService<'_>,
|
||||
f: F,
|
||||
require_unique: bool,
|
||||
) -> Result<Vec<T>>
|
||||
where
|
||||
F: AsyncFn(&Item<'_>) -> Result<T>,
|
||||
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<Box<Credential>> = 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<T> = 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<CredentialBuilder> {
|
||||
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<Box<Credential>> {
|
||||
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<Collection<'a>> {
|
||||
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<Collection<'a>> {
|
||||
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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<HashMap<String, String>> {
|
||||
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<dyn std::error::Error + Send + Sync> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<HashMap<String, String>> {
|
||||
let cred = self.extract_from_platform(Self::extract_credential).await?;
|
||||
let mut attributes: HashMap<String, String> = 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> {
|
||||
self.extract_from_platform(Self::extract_credential).await
|
||||
}
|
||||
|
||||
async fn extract_from_platform<F, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&CREDENTIALW) -> Result<T> + 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<Self> {
|
||||
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<Self> {
|
||||
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<CredentialBuilder> {
|
||||
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<Box<Credential>> {
|
||||
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<String> {
|
||||
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<Vec<u8>> {
|
||||
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<u16> {
|
||||
s.encode_utf16().chain(once(0)).collect()
|
||||
}
|
||||
|
||||
fn to_wstr_no_null(s: &str) -> Vec<u16> {
|
||||
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<dyn std::error::Error + Send + Sync> {
|
||||
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<u8>) -> 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<u8> = 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<u8> = 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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<u8> {
|
||||
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();
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue