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:
John Mumm 2025-08-15 15:57:56 +02:00 committed by GitHub
parent 77fe8d2e60
commit 880eb286e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 4474 additions and 11 deletions

View File

@ -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

426
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"]

View File

@ -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.

View File

@ -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)))?
}

View File

@ -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)
}

View File

@ -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:?}"),
}
}
}
}

View File

@ -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",
);
}
}

View File

@ -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"
);
}
}
}
}

View File

@ -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"
);
}
}

View File

@ -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");
}
}

View File

@ -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)
));
}
}

View File

@ -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"
);
}

View File

@ -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();
}

View File

@ -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");
}
}