diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13a5a14d..012805d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -507,10 +507,12 @@ jobs: - name: Install stable toolchain run: rustup toolchain install stable --profile minimal - - name: Install keyring helper + # dbus-daemon backs the fake Secret portal the client suite starts, and + # gnome-keyring the Secret Service group below (R71). + - name: Install keyring helpers run: | sudo apt-get update - sudo apt-get install -y keyutils + sudo apt-get install -y keyutils dbus gnome-keyring - uses: taiki-e/install-action@nextest @@ -527,6 +529,19 @@ jobs: - name: Run Rust suite run: keyctl session -- cargo +stable nextest run --release --all-features --test-threads 4 ${{ matrix.packages }} + # R71's groups that need a private session bus, root, or Docker. + - name: Run Secret Service custody tests + if: matrix.name == 'client' + run: scripts/linux-secret-store-tests.sh secret-service + + - name: Run systemd unit custody tests + if: matrix.name == 'client' + run: scripts/linux-secret-store-tests.sh systemd + + - name: Run container custody tests + if: matrix.name == 'client' + run: scripts/linux-secret-store-tests.sh container + - name: Prune target cache if: always() run: | diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a7156679..bf1a9c12 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -72,10 +72,10 @@ jobs: --profile minimal \ --component llvm-tools-preview - - name: Install keyring helper + - name: Install keyring helpers run: | sudo apt-get update - sudo apt-get install -y keyutils + sudo apt-get install -y keyutils dbus gnome-keyring - uses: taiki-e/install-action@cargo-llvm-cov - uses: taiki-e/install-action@nextest @@ -101,6 +101,12 @@ jobs: --all-features \ --test-threads 4 \ ${{ matrix.packages }} + # The desktop Secret Service path, whose child processes inherit the + # profile environment. The systemd and container groups run with a + # clean environment, so ci.yml runs them uninstrumented. + if [ "${{ matrix.name }}" = client ]; then + CARGO_TEST_PROFILE=testfast scripts/linux-secret-store-tests.sh secret-service + fi cargo +stable llvm-cov report \ --profile testfast \ --codecov \ diff --git a/Cargo.lock b/Cargo.lock index 8bd6acbc..8bb4a05a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -119,6 +131,20 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "tokio", + "zbus", +] + [[package]] name = "askama_escape" version = "0.13.0" @@ -141,6 +167,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-lock" version = "3.4.2" @@ -152,6 +190,17 @@ dependencies = [ "pin-project-lite", ] +[[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 2.0.119", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -383,7 +432,7 @@ dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -447,6 +496,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "bollard" version = "0.21.1" @@ -562,6 +620,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.4.5" @@ -724,6 +791,7 @@ dependencies = [ "apple-native-keyring-store", "axum", "base64 0.22.1", + "chacha20poly1305", "chrono", "connetto-core", "connetto-server", @@ -731,10 +799,12 @@ dependencies = [ "diesel", "diesel-async", "diesel-sqlite-session", + "futures-util", "getrandom 0.3.4", "keyring-core", "libsqlite3-sys", "linux-keyutils-keyring-store", + "oo7", "open", "openidconnect", "pg2sqlite", @@ -754,6 +824,7 @@ dependencies = [ "uuid", "webbrowser", "windows-native-keyring-store", + "zbus", "zeroize", "zip", "zstd", @@ -1202,6 +1273,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1969,6 +2041,33 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[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 2.0.119", +] + [[package]] name = "enumset" version = "1.1.14" @@ -2042,6 +2141,7 @@ version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ + "parking", "pin-project-lite", ] @@ -2205,6 +2305,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -3009,6 +3122,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -3671,6 +3785,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" @@ -3858,6 +3981,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-bigint-dig" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" +dependencies = [ + "libm", + "num-integer", + "num-iter", + "num-traits", + "once_cell", + "rand 0.9.5", + "serde", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -3951,7 +4091,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.5.0", @@ -4067,6 +4207,37 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oo7" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" +dependencies = [ + "aes", + "ashpd", + "cbc", + "cipher", + "digest 0.10.7", + "endi", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hmac 0.12.1", + "md-5 0.10.6", + "num", + "num-bigint-dig 0.9.1", + "pbkdf2", + "serde", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "tokio", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + [[package]] name = "oorandom" version = "11.1.5" @@ -4179,6 +4350,16 @@ dependencies = [ "num-traits", ] +[[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 = "p256" version = "0.13.2" @@ -4203,6 +4384,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -4251,6 +4438,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pem" version = "3.0.6" @@ -4633,7 +4830,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.10.5", + "itertools 0.14.0", "log", "multimap", "petgraph", @@ -4652,7 +4849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.10.5", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -5238,7 +5435,7 @@ checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid 0.9.6", "digest 0.10.7", - "num-bigint-dig", + "num-bigint-dig 0.8.6", "num-integer", "num-traits", "pkcs1", @@ -6319,6 +6516,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -6724,6 +6922,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unarray" version = "0.1.4" @@ -7490,6 +7699,71 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.57" @@ -7536,6 +7810,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -7615,3 +7903,45 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "serde_bytes", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 2482d432..dccb6742 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ exclude = ["examples/dioxus-desktop-demo", "examples/webauthn-unlock"] [workspace.package] version = "0.0.0" edition = "2024" -rust-version = "1.88" +rust-version = "1.92" authors = ["Luca Cappelletti "] license = "MIT" repository = "https://github.com/LucaCappelletti94/connetto-rs" diff --git a/crates/connetto-client/Cargo.toml b/crates/connetto-client/Cargo.toml index 27fe63db..8891e0bf 100644 --- a/crates/connetto-client/Cargo.toml +++ b/crates/connetto-client/Cargo.toml @@ -44,11 +44,16 @@ native-auth = [ "dep:keyring-core", "dep:apple-native-keyring-store", "dep:linux-keyutils-keyring-store", + "dep:oo7", + "dep:zbus", + "dep:chacha20poly1305", + "dep:futures-util", "dep:windows-native-keyring-store", "dep:android-native-keyring-store", "dep:webbrowser", "dep:open", "dep:getrandom", + "tokio/signal", ] [[bin]] @@ -56,6 +61,11 @@ name = "connetto-client" path = "src/bin/connetto-client.rs" required-features = ["native-transport", "native-auth"] +# The container custody test runs this against a mounted wrap-key file (R71). +[[example]] +name = "secret_store_probe" +required-features = ["native-auth"] + [dependencies] # Wire types and framing. The native transports ride the feature above. connetto-core.workspace = true @@ -156,6 +166,14 @@ apple-native-keyring-store = { version = "1.0.2", features = [ [target.'cfg(target_os = "linux")'.dependencies] linux-keyutils-keyring-store = { version = "1.0.0", optional = true } +# The Secret Service and libsecret's sandbox keyring (R71 decisions 2 and 14). +oo7 = { version = "0.6", optional = true } +# The bounded unlock and create prompts oo7 waits on with no bound (R71 decision 10). +zbus = { version = "5.12", default-features = false, features = ["tokio"], optional = true } +# Sealing records under the systemd credential or key file (R71 decision 4). +chacha20poly1305 = { version = "0.10", optional = true } +# Waiting on a Secret Service prompt's Completed signal stream. +futures-util = { version = "0.3", default-features = false, features = ["std"], optional = true } [target.'cfg(target_os = "windows")'.dependencies] windows-native-keyring-store = { version = "1.1.0", optional = true } @@ -236,3 +254,7 @@ openidconnect = { version = "4.0.1", default-features = false, features = [ # PKCE token of their own. Test-only since R47: the client's own PKCE token now # comes from the platform RNG through `getrandom`. uuid = { version = "1", features = ["v4", "v5"] } + +# The fake Secret Service the Linux store tests serve on a peer-to-peer bus. +[target.'cfg(target_os = "linux")'.dev-dependencies] +zbus = { version = "5.12", default-features = false, features = ["tokio", "p2p"] } diff --git a/crates/connetto-client/examples/secret_store_probe.rs b/crates/connetto-client/examples/secret_store_probe.rs new file mode 100644 index 00000000..c6e6f0fd --- /dev/null +++ b/crates/connetto-client/examples/secret_store_probe.rs @@ -0,0 +1,62 @@ +//! Writes, then in a later run reads back, a replica key and a refresh token +//! through a named wrap-key file, for R71's container custody test. +//! +//! Usage: `secret_store_probe write|read KEY_FILE STATE_DIR`. + +#[cfg(target_os = "linux")] +#[tokio::main(flavor = "current_thread")] +async fn main() -> anyhow::Result<()> { + use anyhow::{Context as _, bail, ensure}; + use connetto_client::{ + KeyFile, KeyringKeyStore, KeyringStore, LinuxStore, provision_replica_key, + }; + use connetto_core::traits::{RefreshTokenStore as _, ReplicaKeyStore as _}; + + const SERVICE: &str = "connetto-r71-container"; + const ACCOUNT: &str = "\"alice\""; + const TOKEN: &str = "alice-refresh"; + + let args: Vec = std::env::args().collect(); + let [_, phase, key, state] = args.as_slice() else { + bail!("usage: secret_store_probe write|read KEY_FILE STATE_DIR"); + }; + let store = LinuxStore::KeyFile(KeyFile::new(key, state)); + let tokens = KeyringStore::with_linux_store(SERVICE, store.clone()); + let keys = KeyringKeyStore::with_linux_store(SERVICE, store); + match phase.as_str() { + "write" => { + provision_replica_key(&keys, "replica") + .await + .context("provisioning the key")?; + tokens + .store(ACCOUNT, TOKEN) + .await + .context("storing the token")?; + } + "read" => { + let backend = tokens.backend().await.context("opening the store")?; + ensure!( + backend.survives_reboot(), + "{backend:?} does not survive a reboot" + ); + ensure!( + keys.load("replica") + .await + .context("loading the key")? + .is_some(), + "the replica key is gone" + ); + let token = tokens.load(ACCOUNT).await.context("loading the token")?; + ensure!( + token.as_deref() == Some(TOKEN), + "the token read back as {token:?}" + ); + } + other => bail!("unknown phase {other}"), + } + println!("{phase} ok"); + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +fn main() {} diff --git a/crates/connetto-client/src/auth.rs b/crates/connetto-client/src/auth.rs index 4fa8af58..545f0a83 100644 --- a/crates/connetto-client/src/auth.rs +++ b/crates/connetto-client/src/auth.rs @@ -13,14 +13,14 @@ //! so a reconnect silently refreshes with no user interaction. use std::fmt::Write as _; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use connetto_core::ReplicaKey; use connetto_core::percent::{percent_decode, percent_encode}; -use connetto_core::traits::{RefreshTokenStore, ReplicaKeyStore}; +use connetto_core::traits::{RefreshFuture, RefreshTokenStore, ReplicaKeyStore}; use serde::Deserialize; use serde::de::DeserializeOwned; use sha2::{Digest, Sha256}; @@ -28,112 +28,11 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use zeroize::{Zeroize, Zeroizing}; +use crate::keyring::Keyring; use crate::replica::PENDING_LOGIN_RECORD; use crate::{AccessTokenSource, ClientError, IDENTITY_RECORD, encode_identity}; - -fn ensure_keyring_store() -> Result<(), ClientError> { - static STORE: OnceLock>> = OnceLock::new(); - STORE - .get_or_init(|| install_keyring_store().map_err(|err| Arc::::from(err.to_string()))) - .as_ref() - .copied() - .map_err(|err| ClientError::Auth(format!("keyring setup: {err}"))) -} - -#[cfg(target_os = "macos")] -fn install_keyring_store() -> keyring_core::Result<()> { - keyring_core::set_default_store(apple_native_keyring_store::keychain::Store::new()?); - Ok(()) -} - -#[cfg(target_os = "ios")] -fn install_keyring_store() -> keyring_core::Result<()> { - keyring_core::set_default_store(apple_native_keyring_store::protected::Store::new()?); - Ok(()) -} - -#[cfg(target_os = "linux")] -fn install_keyring_store() -> keyring_core::Result<()> { - keyring_core::set_default_store(linux_keyutils_keyring_store::Store::new()?); - Ok(()) -} - -#[cfg(target_os = "android")] -fn install_keyring_store() -> keyring_core::Result<()> { - keyring_core::set_default_store(android_native_keyring_store::Store::new()?); - Ok(()) -} - -#[cfg(target_os = "windows")] -fn install_keyring_store() -> keyring_core::Result<()> { - keyring_core::set_default_store(windows_native_keyring_store::Store::new()?); - Ok(()) -} - -#[cfg(not(any( - target_os = "android", - target_os = "ios", - target_os = "linux", - target_os = "macos", - target_os = "windows" -)))] -fn install_keyring_store() -> keyring_core::Result<()> { - Err(keyring_core::Error::Invalid( - "platform".to_owned(), - "native auth has no keyring store for this platform".to_owned(), - )) -} - -/// The keyring sequence both secret stores here perform. -/// -/// One service holds one entry per name, so the sequence lives here once. -struct Keyring { - service: String, -} - -impl Keyring { - fn new(service: impl Into) -> Self { - Self { - service: service.into(), - } - } - - /// The keyring entry for `name`. - fn entry(&self, name: &str) -> Result { - ensure_keyring_store()?; - keyring_core::Entry::new(&self.service, name) - .map_err(|err| ClientError::Auth(format!("keyring open: {err}"))) - } - - /// The secret stored under `name`, or `None` when none was stored. - fn read(&self, name: &str) -> Result, ClientError> { - let entry = self.entry(name)?; - match entry.get_password() { - Ok(secret) => Ok(Some(secret)), - Err(keyring_core::Error::NoEntry) => Ok(None), - Err(err) => Err(ClientError::Auth(format!("keyring load: {err}"))), - } - } - - /// Persist `secret` under `name`, replacing any prior one. - fn write(&self, name: &str, secret: &str) -> Result<(), ClientError> { - self.entry(name)? - .set_password(secret) - .map_err(|err| ClientError::Auth(format!("keyring store: {err}"))) - } - - /// Remove the entry stored under `name`, if any. - fn clear(&self, name: &str) -> Result<(), ClientError> { - let entry = self.entry(name)?; - match entry.delete_credential() { - Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), - Err(err) => Err(ClientError::Auth(format!("keyring clear: {err}"))), - } - } -} - /// OS secure storage for the refresh token: Keychain on Apple platforms, -/// Credential Manager on Windows, and keyutils on Linux. +/// Credential Manager on Windows, and on Linux the store [`LinuxStore`](crate::LinuxStore) names. /// /// One service holds one entry per account, exactly as [`KeyringKeyStore`] /// holds one per replica record. @@ -142,7 +41,7 @@ pub struct KeyringStore { } impl KeyringStore { - /// Store refresh tokens under `service` in the OS keyring. + /// Store refresh tokens under `service` in the detected OS store. #[must_use] pub fn new(service: impl Into) -> Self { Self { @@ -150,24 +49,44 @@ impl KeyringStore { } } + /// Store refresh tokens under `service` in the Linux store the application names. + #[cfg(target_os = "linux")] + #[must_use] + pub fn with_linux_store(service: impl Into, store: crate::LinuxStore) -> Self { + Self { + keyring: Keyring::with_linux_store(service, store), + } + } + + /// Which Linux store holds the tokens, opening it on first use. + /// + /// # Errors + /// + /// [`ClientError::SecretStore`] if no store can be opened. + #[cfg(target_os = "linux")] + pub async fn backend(&self) -> Result { + self.keyring.backend().await + } + /// The indexed accounts, empty when nothing was ever stored. /// /// An index that does not parse is treated as absent rather than fatal: it is /// a hint about what to offer, and refusing to open the store over it would /// turn a listing problem into a lockout. The credentials themselves are /// untouched, and the next sign-in rewrites it. - fn index(&self) -> Result, ClientError> { - let Some(raw) = self.keyring.read(crate::replica::ACCOUNTS_RECORD)? else { + async fn index(&self) -> Result, ClientError> { + let Some(raw) = self.keyring.read(crate::replica::ACCOUNTS_RECORD).await? else { return Ok(Vec::new()); }; Ok(serde_json::from_str(&raw).unwrap_or_default()) } - fn write_index(&self, accounts: &[String]) -> Result<(), ClientError> { + async fn write_index(&self, accounts: &[String]) -> Result<(), ClientError> { let encoded = serde_json::to_string(accounts) .map_err(|err| ClientError::Auth(format!("encoding the account index: {err}")))?; self.keyring .write(crate::replica::ACCOUNTS_RECORD, &encoded) + .await } } @@ -203,8 +122,8 @@ fn indexed_without(known: &[String], account: &str) -> Option> { impl RefreshTokenStore for KeyringStore { type Error = ClientError; - fn load(&self, account: &str) -> Result, ClientError> { - self.keyring.read(account) + fn load<'a>(&'a self, account: &'a str) -> RefreshFuture<'a, Option, ClientError> { + Box::pin(self.keyring.read(account)) } /// Writes the entry, then records the account in the index so @@ -213,12 +132,14 @@ impl RefreshTokenStore for KeyringStore { /// The index is maintained here rather than by the authenticator so that no /// caller can write a credential without it being listable. A reserved /// record is not an account and is not indexed. - fn store(&self, account: &str, token: &str) -> Result<(), ClientError> { - self.keyring.write(account, token)?; - match indexed_with(&self.index()?, account) { - Some(updated) => self.write_index(&updated), - None => Ok(()), - } + fn store<'a>(&'a self, account: &'a str, token: &'a str) -> RefreshFuture<'a, (), ClientError> { + Box::pin(async move { + self.keyring.write(account, token).await?; + match indexed_with(&self.index().await?, account) { + Some(updated) => self.write_index(&updated).await, + None => Ok(()), + } + }) } /// Removes the entry and drops the account from the index. @@ -230,19 +151,21 @@ impl RefreshTokenStore for KeyringStore { /// /// Clearing the last account clears the index record rather than writing an /// empty list, since an absent index reads as empty. - fn clear(&self, account: &str) -> Result<(), ClientError> { - self.keyring.clear(account)?; - match indexed_without(&self.index()?, account) { - Some(updated) if updated.is_empty() => { - self.keyring.clear(crate::replica::ACCOUNTS_RECORD) + fn clear<'a>(&'a self, account: &'a str) -> RefreshFuture<'a, (), ClientError> { + Box::pin(async move { + self.keyring.clear(account).await?; + match indexed_without(&self.index().await?, account) { + Some(updated) if updated.is_empty() => { + self.keyring.clear(crate::replica::ACCOUNTS_RECORD).await + } + Some(updated) => self.write_index(&updated).await, + None => Ok(()), } - Some(updated) => self.write_index(&updated), - None => Ok(()), - } + }) } - fn accounts(&self) -> Result, ClientError> { - self.index() + fn accounts(&self) -> RefreshFuture<'_, Vec, ClientError> { + Box::pin(self.index()) } } @@ -255,41 +178,43 @@ pub struct MemoryRefreshStore { impl RefreshTokenStore for MemoryRefreshStore { type Error = ClientError; - fn load(&self, account: &str) -> Result, ClientError> { - Ok(self + fn load<'a>(&'a self, account: &'a str) -> RefreshFuture<'a, Option, ClientError> { + let token = self .inner .lock() .expect("refresh store lock") .get(account) - .cloned()) + .cloned(); + Box::pin(std::future::ready(Ok(token))) } - fn store(&self, account: &str, token: &str) -> Result<(), ClientError> { + fn store<'a>(&'a self, account: &'a str, token: &'a str) -> RefreshFuture<'a, (), ClientError> { self.inner .lock() .expect("refresh store lock") .insert(account.to_owned(), token.to_owned()); - Ok(()) + Box::pin(std::future::ready(Ok(()))) } - fn clear(&self, account: &str) -> Result<(), ClientError> { + fn clear<'a>(&'a self, account: &'a str) -> RefreshFuture<'a, (), ClientError> { self.inner .lock() .expect("refresh store lock") .remove(account); - Ok(()) + Box::pin(std::future::ready(Ok(()))) } /// Enumerated from the map itself, so it cannot disagree with what is stored. - fn accounts(&self) -> Result, ClientError> { - Ok(self + fn accounts(&self) -> RefreshFuture<'_, Vec, ClientError> { + let accounts = self .inner .lock() .expect("refresh store lock") .keys() .filter(|name| !crate::is_reserved_record(name)) .cloned() - .collect()) + .collect(); + Box::pin(std::future::ready(Ok(accounts))) } } @@ -307,11 +232,11 @@ impl RefreshTokenStore for MemoryRefreshStore { /// # Errors /// /// [`ClientError`] if the store cannot be read. -pub fn remembered_account(store: &S) -> Result, ClientError> +pub async fn remembered_account(store: &S) -> Result, ClientError> where S: RefreshTokenStore + ?Sized, { - store.load(IDENTITY_RECORD) + store.load(IDENTITY_RECORD).await } /// The effective key for the replica `name`, minting one when this device has @@ -370,7 +295,7 @@ fn mint_replica_key() -> Result { } /// OS secure storage for the per-replica encryption keys, using the same -/// keyring backend as [`KeyringStore`]. +/// store as [`KeyringStore`]. /// /// The keyring account is the record name, so one service holds one entry per /// identity. @@ -379,29 +304,41 @@ pub struct KeyringKeyStore { } impl KeyringKeyStore { - /// Store replica keys under `service` in the OS keyring. + /// Store replica keys under `service` in the detected OS store. #[must_use] pub fn new(service: impl Into) -> Self { Self { keyring: Keyring::new(service), } } + + /// Store replica keys under `service` in the Linux store the application names. + #[cfg(target_os = "linux")] + #[must_use] + pub fn with_linux_store(service: impl Into, store: crate::LinuxStore) -> Self { + Self { + keyring: Keyring::with_linux_store(service, store), + } + } + + /// Which Linux store holds the keys, opening it on first use. + /// + /// # Errors + /// + /// [`ClientError::SecretStore`] if no store can be opened. + #[cfg(target_os = "linux")] + pub async fn backend(&self) -> Result { + self.keyring.backend().await + } } -// Every method here returns before it yields, which is the cost decision 2 of -// R41 accepted: the trait awaits because the browser must, and the keychain -// call blocks whoever polls it. Bounded, since key custody runs at open and at -// logout rather than per change. -#[expect( - clippy::unused_async_trait_impl, - reason = "the trait method is async and this body finishes without awaiting" -)] impl ReplicaKeyStore for KeyringKeyStore { type Error = ClientError; async fn load(&self, name: &str) -> Result, ClientError> { self.keyring - .read(name)? + .read(name) + .await? // The keyring hands back an owned hex string, which is key // material until it is wiped, hence the `Zeroizing` wrapper. .map(|hex| { @@ -417,11 +354,11 @@ impl ReplicaKeyStore for KeyringKeyStore { for byte in key.as_bytes() { let _ = write!(&mut *hex, "{byte:02x}"); } - self.keyring.write(name, &hex) + self.keyring.write(name, &hex).await } async fn clear(&self, name: &str) -> Result<(), ClientError> { - self.keyring.clear(name) + self.keyring.clear(name).await } } @@ -646,7 +583,7 @@ impl NativeAuthenticator { &self, ) -> Result, ClientError> { if let Some(account) = self.account() - && self.store.load(&account)?.is_some() + && self.store.load(&account).await?.is_some() && let Ok(session) = self.refresh_access().await { return Ok(session); @@ -665,7 +602,7 @@ impl NativeAuthenticator { &self, ) -> Result, ClientError> { let response = self.refresh_tokens::().await?; - self.remember(&response.user_id)?; + self.remember(encode_identity(&response.user_id)?).await?; Ok(response.into()) } @@ -682,7 +619,8 @@ impl NativeAuthenticator { .ok_or_else(|| ClientError::Auth("no account to refresh".to_owned()))?; let refresh = self .store - .load(&account)? + .load(&account) + .await? .ok_or_else(|| ClientError::Auth("no stored refresh token".to_owned()))?; let response: TokenResponse = self .post_json( @@ -690,7 +628,7 @@ impl NativeAuthenticator { &serde_json::json!({ "refresh_token": refresh }), ) .await?; - self.store.store(&account, &response.refresh_token)?; + self.store.store(&account, &response.refresh_token).await?; Ok(response) } @@ -736,10 +674,10 @@ impl NativeAuthenticator { }; let pending = serde_json::to_string(&pending) .map_err(|err| ClientError::Auth(format!("recording the login: {err}")))?; - self.store.store(PENDING_LOGIN_RECORD, &pending)?; + self.store.store(PENDING_LOGIN_RECORD, &pending).await?; let delivered = claimed.session.authorize(login_url(&claimed.uri)).await; // This process saw the login end, so nothing is left to resume. - self.store.clear(PENDING_LOGIN_RECORD)?; + self.store.clear(PENDING_LOGIN_RECORD).await?; let delivered = delivered?; code_and_state(query_of(&delivered), "redirect")? } else { @@ -766,10 +704,10 @@ impl NativeAuthenticator { &self, claimed: &ClaimedRedirect, ) -> Result>, ClientError> { - let Some(pending) = self.store.load(PENDING_LOGIN_RECORD)? else { + let Some(pending) = self.store.load(PENDING_LOGIN_RECORD).await? else { return Ok(None); }; - self.store.clear(PENDING_LOGIN_RECORD)?; + self.store.clear(PENDING_LOGIN_RECORD).await?; let Some(delivered) = claimed.session.delivered() else { return Ok(None); }; @@ -806,11 +744,13 @@ impl NativeAuthenticator { // Keyed off the response, because a first login has no account to key on // and learns it here. The marker's value is the same encoding, so it is // literally the key of the record it points at. - self.store.store( - &encode_identity(&response.user_id)?, - &response.refresh_token, - )?; - self.remember(&response.user_id)?; + self.store + .store( + &encode_identity(&response.user_id)?, + &response.refresh_token, + ) + .await?; + self.remember(encode_identity(&response.user_id)?).await?; Ok(response.into()) } @@ -819,9 +759,8 @@ impl NativeAuthenticator { /// Both token paths call it, because either can be the one that establishes /// who this device is: a silent refresh on a start, or an interactive login /// on a first run or after the credential lapsed. - fn remember(&self, user_id: &Id) -> Result<(), ClientError> { - let account = encode_identity(user_id)?; - self.store.store(IDENTITY_RECORD, &account)?; + async fn remember(&self, account: String) -> Result<(), ClientError> { + self.store.store(IDENTITY_RECORD, &account).await?; *self .account .lock() @@ -896,7 +835,7 @@ impl NativeAuthenticator { let Some(account) = self.account() else { return Ok(()); }; - let Some(refresh) = self.store.load(&account)? else { + let Some(refresh) = self.store.load(&account).await? else { return Ok(()); }; let revoked = self @@ -905,7 +844,7 @@ impl NativeAuthenticator { &serde_json::json!({ "refresh_token": refresh }), ) .await; - self.store.clear(&account)?; + self.store.clear(&account).await?; revoked.map(drop) } @@ -1099,19 +1038,25 @@ mod tests { /// This reads the index back through the store's own keyring, which needs a /// live secret store, so it stands beside the pure-logic /// `the_account_index_drops_only_the_account_signed_out` above. - #[test] - fn clearing_the_last_account_removes_the_index_record() { + #[cfg(target_os = "linux")] + #[tokio::test] + async fn clearing_the_last_account_removes_the_index_record() { let _keyring = connetto_test_harness::isolated_session_keyring(); let service = format!("connetto-index-clear-{}", std::process::id()); - let store = KeyringStore::new(&service); + let store = KeyringStore::with_linux_store(&service, crate::LinuxStore::Keyutils); store .store("\"alice\"", "token") + .await .expect("store one account"); - store.clear("\"alice\"").expect("clear the only account"); + store + .clear("\"alice\"") + .await + .expect("clear the only account"); assert_eq!( store .keyring .read(crate::replica::ACCOUNTS_RECORD) + .await .expect("read the index record"), None, "the last account's departure removes the index record rather than leaving it holding []", diff --git a/crates/connetto-client/src/bin/connetto-client.rs b/crates/connetto-client/src/bin/connetto-client.rs index 59b8f750..4423ce33 100644 --- a/crates/connetto-client/src/bin/connetto-client.rs +++ b/crates/connetto-client/src/bin/connetto-client.rs @@ -19,13 +19,17 @@ //! the client declares no version and a versioned server rejects it. //! - `CONNETTO_SUB_ID`: subscription id (default `default`). //! - `CONNETTO_QUERY`: the row subscription `SELECT` (required). +//! - `CONNETTO_KEY_STORE`: `keyutils` keeps the replica keys in the kernel +//! session keyring on Linux, which a reboot empties. Unset means the detected +//! durable store. //! - `CONNETTO_WRITE`: optional SQL run on the managed local connection after //! subscribing, one statement per line. Each line is run and pushed to the //! server as a separate mutation, in order. The server applies them to //! Postgres. //! //! Connects, subscribes, and pumps inbound frames, printing each client event -//! until the server closes the connection. When `CONNETTO_WRITE` is set, the +//! until the server closes the connection or a SIGINT or SIGTERM arrives, either +//! of which ends the process normally. When `CONNETTO_WRITE` is set, the //! client applies those writes locally and pushes them right after subscribing, //! then observes its own rows echoed back over CDC. @@ -45,6 +49,34 @@ const KEYRING_SERVICE: &str = "connetto-client"; #[tokio::main] async fn main() -> Result<()> { connetto_core::logging::init_stdout(); + // Armed before any work, so a SIGTERM at any point ends the process by returning from main. + let terminated = terminate_signal()?; + tokio::select! { + result = run() => result, + () = terminated => { + tracing::info!("terminated"); + Ok(()) + } + } +} + +/// Resolves on the first SIGINT, or on unix the first SIGTERM. +fn terminate_signal() -> Result> { + #[cfg(unix)] + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("installing the SIGTERM handler")?; + Ok(async move { + #[cfg(unix)] + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + #[cfg(not(unix))] + let _ = tokio::signal::ctrl_c().await; + }) +} + +async fn run() -> Result<()> { let server = var_or("CONNETTO_SERVER", "ws://127.0.0.1:8080/"); let db_path = std::env::var("CONNETTO_DB").context("set CONNETTO_DB to a file path")?; let sqlite_ddl = read_ddl("CONNETTO_SQLITE_DDL")?; @@ -93,7 +125,7 @@ async fn main() -> Result<()> { // identity to name a record after. A replica already on disk reads the cache // and never mints: a fresh key for an existing file decrypts nothing, and // writing one would fill the record that restoring a backup still could. - let keys = KeyringKeyStore::new(KEYRING_SERVICE); + let keys = key_store()?; let resolved = if std::path::Path::new(&db_path).exists() { keys.load(&db_path) .await @@ -152,3 +184,53 @@ async fn main() -> Result<()> { } } } + +/// The replica-key store `CONNETTO_KEY_STORE` names, or the detected one. +fn key_store() -> anyhow::Result { + key_store_named(std::env::var("CONNETTO_KEY_STORE").ok().as_deref()) +} + +fn key_store_named(name: Option<&str>) -> anyhow::Result { + match name { + None => Ok(KeyringKeyStore::new(KEYRING_SERVICE)), + #[cfg(target_os = "linux")] + Some("keyutils") => Ok(KeyringKeyStore::with_linux_store( + KEYRING_SERVICE, + connetto_client::LinuxStore::Keyutils, + )), + Some(other) => Err(anyhow!( + "CONNETTO_KEY_STORE={other} names no store this platform has" + )), + } +} + +#[cfg(test)] +mod tests { + use super::key_store_named; + + #[test] + fn an_unknown_store_name_is_refused_naming_it() { + let err = key_store_named(Some("secret-service")) + .err() + .expect("refused"); + assert!( + err.to_string() + .contains("CONNETTO_KEY_STORE=secret-service"), + "got {err}" + ); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn keyutils_and_detection_are_the_two_choices() { + let keyutils = key_store_named(Some("keyutils")).expect("keyutils"); + assert_eq!( + keyutils.backend().await.expect("opens"), + connetto_client::Backend::Keyutils + ); + assert!( + key_store_named(None).is_ok(), + "unset means the detected store" + ); + } +} diff --git a/crates/connetto-client/src/keyring/linux/mod.rs b/crates/connetto-client/src/keyring/linux/mod.rs new file mode 100644 index 00000000..b4a57ac7 --- /dev/null +++ b/crates/connetto-client/src/keyring/linux/mod.rs @@ -0,0 +1,373 @@ +//! The Linux secret stores (R71): the Secret Service, libsecret's sandbox +//! keyring, sealed files under a systemd credential or a named key file, and +//! keyutils. + +mod sandbox; +mod sealed; +mod secret_service; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use keyring_core::api::CredentialStoreApi as _; +use zeroize::Zeroizing; + +use super::SecretStoreError; +use crate::ClientError; + +/// The systemd credential holding the current wrap key. +const CREDENTIAL: &str = "connetto.wrap-key"; +/// The systemd credential holding the wrap key being rotated out. +const PREVIOUS_CREDENTIAL: &str = "connetto.wrap-key.previous"; +/// The directory under a state directory that holds the sealed records. +const SEALED_DIR: &str = "connetto-secrets"; +/// What detection tries, in order. +const PROBED: &str = + "the sandbox keyring, the connetto.wrap-key systemd credential and the Secret Service"; + +/// A Linux secret store an application names instead of detection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinuxStore { + /// The desktop session's Secret Service, in its default collection. + SecretService, + /// libsecret's sandbox keyring, opened with the Secret portal's secret. + SandboxKeyring, + /// Files sealed under the unit's `connetto.wrap-key` systemd credential. + SystemdCredential, + /// Files sealed under a wrap key the application names. + KeyFile(KeyFile), + /// The kernel session keyring, which a reboot empties. + Keyutils, +} + +/// A wrap-key file and the state directory its sealed records live in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyFile { + key: PathBuf, + previous: Option, + state: PathBuf, +} + +impl KeyFile { + /// Records under `state`, sealed with the 32-byte key in the file `key`. + #[must_use] + pub fn new(key: impl Into, state: impl Into) -> Self { + Self { + key: key.into(), + previous: None, + state: state.into(), + } + } + + /// Reseal every record still under the key in the file `previous`. + #[must_use] + pub fn with_previous(mut self, previous: impl Into) -> Self { + self.previous = Some(previous.into()); + self + } +} + +/// Which Linux store holds a store's secrets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backend { + /// The Secret Service's default collection. + SecretService, + /// libsecret's sandbox keyring. + SandboxKeyring, + /// Sealed files under the `connetto.wrap-key` systemd credential. + SystemdCredential { + /// Whether a record still opens only under the previous wrap key. + previous_key_needed: bool, + }, + /// Sealed files under an application-named key file. + KeyFile { + /// Whether a record still opens only under the previous wrap key. + previous_key_needed: bool, + }, + /// The kernel session keyring. + Keyutils, +} + +impl Backend { + /// Whether the secrets survive a reboot. + #[must_use] + pub const fn survives_reboot(self) -> bool { + !matches!(self, Self::Keyutils) + } +} + +/// The process facts detection reads. +struct Environment { + sandboxed: bool, + credentials: Option, + state: Option, +} + +impl Environment { + fn of_process() -> Self { + Self { + sandboxed: oo7::ashpd::is_sandboxed(), + credentials: std::env::var_os("CREDENTIALS_DIRECTORY").map(PathBuf::from), + // systemd joins several state directories with ':' and the first is the unit's own. + state: std::env::var_os("STATE_DIRECTORY").and_then(|dirs| { + dirs.to_str() + .and_then(|dirs| dirs.split(':').next()) + .map(PathBuf::from) + }), + } + } +} + +/// A store resolved at first use, since reaching one awaits. +pub(crate) struct Store { + named: Option, + opened: tokio::sync::OnceCell, +} + +enum Opened { + SecretService(secret_service::SecretService), + Sandbox(sandbox::Sandbox), + Sealed { + files: sealed::Sealed, + credential: bool, + }, + Keyutils(Arc), +} + +impl Store { + pub(crate) fn detect() -> Self { + Self { + named: None, + opened: tokio::sync::OnceCell::new(), + } + } + + pub(crate) fn named(store: LinuxStore) -> Self { + Self { + named: Some(store), + opened: tokio::sync::OnceCell::new(), + } + } + + async fn opened(&self) -> Result<&Opened, ClientError> { + self.opened + .get_or_try_init(|| async { + let env = Environment::of_process(); + match &self.named { + Some(store) => open_named(store, &env).await, + None => detect(&env, secret_service::session_bus().await).await, + } + }) + .await + } + + pub(crate) async fn backend(&self) -> Result { + Ok(match self.opened().await? { + Opened::SecretService(_) => Backend::SecretService, + Opened::Sandbox(_) => Backend::SandboxKeyring, + Opened::Sealed { + files, + credential: true, + } => Backend::SystemdCredential { + previous_key_needed: files.previous_key_needed(), + }, + Opened::Sealed { + files, + credential: false, + } => Backend::KeyFile { + previous_key_needed: files.previous_key_needed(), + }, + Opened::Keyutils(_) => Backend::Keyutils, + }) + } + + pub(crate) async fn read( + &self, + service: &str, + name: &str, + ) -> Result, ClientError> { + match self.opened().await? { + Opened::SecretService(store) => store.read(service, name).await, + Opened::Sandbox(store) => store.read(service, name).await, + Opened::Sealed { files, .. } => files + .read(service, name)? + .map(|mut bytes| utf8(core::mem::take(&mut *bytes))) + .transpose(), + Opened::Keyutils(store) => match keyutils_entry(store, service, name)?.get_password() { + Ok(secret) => Ok(Some(secret)), + Err(keyring_core::Error::NoEntry) => Ok(None), + Err(err) => Err(backend_error("keyutils load", err)), + }, + } + } + + pub(crate) async fn write( + &self, + service: &str, + name: &str, + secret: &str, + ) -> Result<(), ClientError> { + match self.opened().await? { + Opened::SecretService(store) => store.write(service, name, secret).await, + Opened::Sandbox(store) => store.write(service, name, secret).await, + Opened::Sealed { files, .. } => files.write(service, name, secret.as_bytes()), + Opened::Keyutils(store) => keyutils_entry(store, service, name)? + .set_password(secret) + .map_err(|err| backend_error("keyutils store", err)), + } + } + + pub(crate) async fn clear(&self, service: &str, name: &str) -> Result<(), ClientError> { + match self.opened().await? { + Opened::SecretService(store) => store.clear(service, name).await, + Opened::Sandbox(store) => store.clear(service, name).await, + Opened::Sealed { files, .. } => files.clear(service, name), + Opened::Keyutils(store) => { + match keyutils_entry(store, service, name)?.delete_credential() { + Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), + Err(err) => Err(backend_error("keyutils clear", err)), + } + } + } + } +} + +/// What detection settles on. +enum Detected<'a> { + Sandbox, + Credential(&'a Path), + SecretService(zbus::Connection), +} + +/// Decision 5's order: the sandbox, then a credential, then the Secret Service. +fn choose( + env: &Environment, + bus: Option, +) -> Result, SecretStoreError> { + if env.sandboxed { + return Ok(Detected::Sandbox); + } + if let Some(credentials) = env.credentials.as_deref() + && credentials.join(CREDENTIAL).exists() + { + return Ok(Detected::Credential(credentials)); + } + bus.map(Detected::SecretService) + .ok_or(SecretStoreError::NoStore { probed: PROBED }) +} + +async fn detect(env: &Environment, bus: Option) -> Result { + match choose(env, bus)? { + Detected::Sandbox => Ok(Opened::Sandbox(sandbox::Sandbox::open().await?)), + Detected::Credential(credentials) => open_credential(credentials, env.state.as_deref()), + Detected::SecretService(bus) => Ok(Opened::SecretService( + secret_service::SecretService::open(bus, secret_service::PROMPT_BOUND).await?, + )), + } +} + +async fn open_named(store: &LinuxStore, env: &Environment) -> Result { + match store { + LinuxStore::SecretService => { + let bus = secret_service::session_bus() + .await + .ok_or(SecretStoreError::NoStore { + probed: "the Secret Service", + })?; + Ok(Opened::SecretService( + secret_service::SecretService::open(bus, secret_service::PROMPT_BOUND).await?, + )) + } + LinuxStore::SandboxKeyring => Ok(Opened::Sandbox(sandbox::Sandbox::open().await?)), + LinuxStore::SystemdCredential => { + let credentials = env + .credentials + .as_deref() + .ok_or(SecretStoreError::NoStore { + probed: "the connetto.wrap-key systemd credential", + })?; + open_credential(credentials, env.state.as_deref()) + } + LinuxStore::KeyFile(file) => Ok(Opened::Sealed { + files: sealed::Sealed::open( + file.state.join(SEALED_DIR), + &*sealed::read_wrap_key(&file.key)?, + file.previous + .as_deref() + .map(sealed::read_wrap_key) + .transpose()? + .as_deref(), + )?, + credential: false, + }), + LinuxStore::Keyutils => Ok(Opened::Keyutils( + linux_keyutils_keyring_store::Store::new() + .map_err(|err| backend_error("keyutils open", err))?, + )), + } +} + +fn open_credential(credentials: &Path, state: Option<&Path>) -> Result { + let state = state.ok_or_else(|| { + SecretStoreError::Backend( + "the unit has a connetto.wrap-key credential and no StateDirectory=".to_owned(), + ) + })?; + let previous = credentials.join(PREVIOUS_CREDENTIAL); + let previous = previous + .exists() + .then(|| sealed::read_wrap_key(&previous)) + .transpose()?; + Ok(Opened::Sealed { + files: sealed::Sealed::open( + state.join(SEALED_DIR), + &*sealed::read_wrap_key(&credentials.join(CREDENTIAL))?, + previous.as_deref(), + )?, + credential: true, + }) +} + +fn keyutils_entry( + store: &linux_keyutils_keyring_store::Store, + service: &str, + name: &str, +) -> Result { + store + .build(service, name, None) + .map_err(|err| backend_error("keyutils open", err)) +} + +/// The attributes a keyring item is found by. +fn attributes<'a>(service: &'a str, name: &'a str) -> [(&'static str, &'a str); 2] { + [("service", service), ("record", name)] +} + +/// The secret as the base64 text a keyring item holds (decision 6). +fn encode(secret: &str) -> Zeroizing { + Zeroizing::new(STANDARD.encode(secret)) +} + +/// The secret a keyring item's base64 text holds. +fn decode(text: &[u8]) -> Result { + let bytes = STANDARD + .decode(text) + .map_err(|_| SecretStoreError::Encoding)?; + utf8(bytes) +} + +fn utf8(bytes: Vec) -> Result { + String::from_utf8(bytes).map_err(|err| { + drop(Zeroizing::new(err.into_bytes())); + SecretStoreError::Encoding.into() + }) +} + +fn backend_error(what: &str, err: impl std::fmt::Display) -> ClientError { + SecretStoreError::Backend(format!("{what}: {err}")).into() +} + +#[cfg(test)] +mod tests; diff --git a/crates/connetto-client/src/keyring/linux/sandbox.rs b/crates/connetto-client/src/keyring/linux/sandbox.rs new file mode 100644 index 00000000..3794198c --- /dev/null +++ b/crates/connetto-client/src/keyring/linux/sandbox.rs @@ -0,0 +1,143 @@ +//! libsecret's sandbox keyring, a file opened with the Secret portal's +//! per-application secret (R71 decision 14). + +use std::ffi::OsString; +use std::io::Read as _; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use oo7::ashpd::desktop::secret::{RetrieveOptions, Secret}; +use oo7::file::{Item, UnlockedKeyring}; +use zeroize::Zeroizing; + +use super::{attributes, backend_error, decode, encode}; +use crate::ClientError; +use crate::keyring::SecretStoreError; + +pub(super) struct Sandbox { + keyring: UnlockedKeyring, +} + +impl Sandbox { + pub(super) async fn open() -> Result { + let bus = zbus::Connection::session() + .await + .map_err(|err| backend_error("the session bus", err))?; + Self::open_with(&bus, super::secret_service::PROMPT_BOUND, keyring_path()).await + } + + /// The keyring at `path`, opened with the secret the portal on `bus` hands + /// over within `bound`. + pub(super) async fn open_with( + bus: &zbus::Connection, + bound: Duration, + path: Option, + ) -> Result { + let path = path.ok_or_else(|| { + SecretStoreError::Backend( + "the sandbox has no data directory for its keyring".to_owned(), + ) + })?; + let secret = tokio::time::timeout(bound, portal_secret(bus)) + .await + .map_err(|_| SecretStoreError::TimedOut(bound))??; + Self::load(&path, oo7::Secret::from(secret)).await + } + + pub(super) async fn load(path: &Path, secret: oo7::Secret) -> Result { + Ok(Self { + keyring: UnlockedKeyring::load(path, secret) + .await + .map_err(|err| backend_error("the sandbox keyring", err))?, + }) + } + + pub(super) async fn read( + &self, + service: &str, + name: &str, + ) -> Result, ClientError> { + let item = self + .keyring + .lookup_item(&attributes(service, name)) + .await + .map_err(|err| backend_error("the sandbox keyring", err))?; + match item { + Some(Item::Unlocked(item)) => decode(&item.secret()).map(Some), + Some(Item::Locked(_)) => Err(SecretStoreError::Backend( + "a sandbox keyring item does not open under the portal's secret".to_owned(), + ) + .into()), + None => Ok(None), + } + } + + pub(super) async fn write( + &self, + service: &str, + name: &str, + secret: &str, + ) -> Result<(), ClientError> { + self.keyring + .create_item( + service, + &attributes(service, name), + oo7::Secret::text(encode(secret).as_str()), + true, + ) + .await + .map(drop) + .map_err(|err| backend_error("the sandbox keyring", err)) + } + + pub(super) async fn clear(&self, service: &str, name: &str) -> Result<(), ClientError> { + self.keyring + .delete(&attributes(service, name)) + .await + .map_err(|err| backend_error("the sandbox keyring", err)) + } +} + +/// The application's own secret, which the portal writes into a socket it is handed. +async fn portal_secret(bus: &zbus::Connection) -> Result>, ClientError> { + let portal = Secret::with_connection(bus.clone()) + .await + .map_err(|err| backend_error("the Secret portal", err))?; + let (mut reader, writer) = + UnixStream::pair().map_err(|err| backend_error("the Secret portal socket", err))?; + portal + .retrieve(&writer, RetrieveOptions::default()) + .await + .map_err(|err| backend_error("the Secret portal", err))?; + // The portal holds its own copy, so the read below ends once the portal closes it. + drop(writer); + tokio::task::spawn_blocking(move || { + let mut secret = Zeroizing::new(Vec::with_capacity(64)); + reader.read_to_end(&mut secret).map(|_| secret) + }) + .await + .map_err(|err| backend_error("the Secret portal socket", err))? + .map_err(|err| backend_error("the Secret portal socket", err)) +} + +/// libsecret's own path for the sandbox keyring, which `oo7` keeps crate-private. +fn keyring_path() -> Option { + keyring_path_from(std::env::var_os("XDG_DATA_HOME"), std::env::var_os("HOME")) +} + +/// The keyring path under `XDG_DATA_HOME` when it is absolute, else under `HOME`. +pub(super) fn keyring_path_from( + xdg_data_home: Option, + home: Option, +) -> Option { + let data = xdg_data_home + .filter(|dir| !dir.is_empty()) + .map(PathBuf::from) + .filter(|dir| dir.is_absolute()) + .or_else(|| { + home.filter(|home| !home.is_empty()) + .map(|home| PathBuf::from(home).join(".local/share")) + })?; + Some(data.join("keyrings").join("default.keyring")) +} diff --git a/crates/connetto-client/src/keyring/linux/sealed.rs b/crates/connetto-client/src/keyring/linux/sealed.rs new file mode 100644 index 00000000..067480e5 --- /dev/null +++ b/crates/connetto-client/src/keyring/linux/sealed.rs @@ -0,0 +1,267 @@ +//! Secrets sealed with XChaCha20-Poly1305 under a 32-byte wrap key, one file +//! per record (R71 decisions 4, 11 and 13). +//! +//! A record's file name is the SHA-256 of its store's service and its record +//! name, and that name is the associated data, so a file renamed onto another +//! record's name, or read by another store, fails to open. + +use std::fs::{self, File, OpenOptions}; +use std::io::{ErrorKind, Write as _}; +use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; + +use chacha20poly1305::aead::{Aead as _, KeyInit as _, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use sha2::{Digest as _, Sha256}; +use zeroize::Zeroizing; + +use crate::ClientError; +use crate::keyring::SecretStoreError; + +/// The length of a wrap key. +pub(super) const WRAP_KEY_LEN: usize = 32; +/// The first bytes of every record file. +const MAGIC: &[u8] = b"CNTS\x01"; +const NONCE_LEN: usize = 24; +/// Held exclusively around every change, so a reseal never overwrites a newer write. +const LOCK: &str = ".lock"; +/// Marks a record file not yet renamed into place. +const TEMPORARY: &str = ".tmp-"; + +/// The wrap key in the file at `path`. +pub(super) fn read_wrap_key(path: &Path) -> Result, ClientError> { + let bytes = + Zeroizing::new(fs::read(path).map_err(|err| io("reading the wrap key", path, &err))?); + let key: [u8; WRAP_KEY_LEN] = + bytes + .as_slice() + .try_into() + .map_err(|_| SecretStoreError::WrapKeyLength { + path: path.to_owned(), + len: bytes.len(), + })?; + Ok(Zeroizing::new(key)) +} + +pub(super) struct Sealed { + dir: PathBuf, + current: XChaCha20Poly1305, + previous: Option, + previous_needed: bool, +} + +impl Sealed { + /// The records in `dir`, resealing any still under `previous` (decision 13). + pub(super) fn open( + dir: PathBuf, + current: &[u8; WRAP_KEY_LEN], + previous: Option<&[u8; WRAP_KEY_LEN]>, + ) -> Result { + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&dir) + .map_err(|err| io("creating the state directory", &dir, &err))?; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)) + .map_err(|err| io("closing the state directory", &dir, &err))?; + let mut sealed = Self { + dir, + current: XChaCha20Poly1305::new(current.into()), + previous: previous.map(|key| XChaCha20Poly1305::new(key.into())), + previous_needed: false, + }; + let _lock = sealed.lock()?; + sealed.previous_needed = sealed.sweep()?; + Ok(sealed) + } + + /// Whether a record still opens only under the previous key. + pub(super) const fn previous_key_needed(&self) -> bool { + self.previous_needed + } + + pub(super) fn read( + &self, + service: &str, + name: &str, + ) -> Result>>, ClientError> { + let stem = stem(service, name); + let _lock = self.lock()?; + let path = self.dir.join(&stem); + let blob = match fs::read(&path) { + Ok(blob) => blob, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(io("reading a sealed record", &path, &err)), + }; + if let Some(secret) = unseal(&self.current, &stem, &blob) { + return Ok(Some(secret)); + } + let Some(secret) = self + .previous + .as_ref() + .and_then(|previous| unseal(previous, &stem, &blob)) + else { + return Err(SecretStoreError::Unsealable { record: stem }.into()); + }; + self.put(&stem, &secret)?; + Ok(Some(secret)) + } + + pub(super) fn write( + &self, + service: &str, + name: &str, + secret: &[u8], + ) -> Result<(), ClientError> { + let _lock = self.lock()?; + self.put(&stem(service, name), secret) + } + + pub(super) fn clear(&self, service: &str, name: &str) -> Result<(), ClientError> { + let _lock = self.lock()?; + let path = self.dir.join(stem(service, name)); + match fs::remove_file(&path) { + Ok(()) => self.sync_dir(), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(io("removing a sealed record", &path, &err)), + } + } + + /// Drops files a crash left before their rename and reseals records still + /// under the previous key, answering whether a reseal failed and left one + /// under it. + fn sweep(&self) -> Result { + let entries = fs::read_dir(&self.dir) + .map_err(|err| io("listing the state directory", &self.dir, &err))?; + let mut left_under_previous = false; + for entry in entries { + let entry = entry.map_err(|err| io("listing the state directory", &self.dir, &err))?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name.contains(TEMPORARY) { + fs::remove_file(entry.path()) + .map_err(|err| io("removing a stale record", &entry.path(), &err))?; + continue; + } + let Some(previous) = &self.previous else { + continue; + }; + if !is_stem(&name) { + continue; + } + let blob = fs::read(entry.path()) + .map_err(|err| io("reading a sealed record", &entry.path(), &err))?; + if unseal(&self.current, &name, &blob).is_some() { + continue; + } + if let Some(secret) = unseal(previous, &name, &blob) { + // A failed reseal leaves the record readable under the previous key, which the report says. + left_under_previous |= self.put(&name, &secret).is_err(); + } + } + Ok(left_under_previous) + } + + /// Writes `secret` under the current key through a temporary file and a rename. + fn put(&self, stem: &str, secret: &[u8]) -> Result<(), ClientError> { + let mut nonce = [0_u8; NONCE_LEN]; + getrandom::fill(&mut nonce) + .map_err(|err| SecretStoreError::Backend(format!("platform RNG: {err}")))?; + let sealed = self + .current + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: secret, + aad: stem.as_bytes(), + }, + ) + .map_err(|_| SecretStoreError::Backend("sealing a record failed".to_owned()))?; + let mut suffix = [0_u8; 8]; + getrandom::fill(&mut suffix) + .map_err(|err| SecretStoreError::Backend(format!("platform RNG: {err}")))?; + let temporary = self.dir.join(format!("{stem}{TEMPORARY}{}", hex(&suffix))); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary) + .map_err(|err| io("creating a sealed record", &temporary, &err))?; + file.write_all(MAGIC) + .and_then(|()| file.write_all(&nonce)) + .and_then(|()| file.write_all(&sealed)) + .and_then(|()| file.sync_all()) + .map_err(|err| io("writing a sealed record", &temporary, &err))?; + let path = self.dir.join(stem); + fs::rename(&temporary, &path) + .map_err(|err| io("replacing a sealed record", &path, &err))?; + self.sync_dir() + } + + fn sync_dir(&self) -> Result<(), ClientError> { + File::open(&self.dir) + .and_then(|dir| dir.sync_all()) + .map_err(|err| io("syncing the state directory", &self.dir, &err)) + } + + fn lock(&self) -> Result { + let path = self.dir.join(LOCK); + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(&path) + .map_err(|err| io("opening the state lock", &path, &err))?; + file.lock() + .map_err(|err| io("locking the state directory", &path, &err))?; + Ok(file) + } +} + +fn unseal(cipher: &XChaCha20Poly1305, stem: &str, blob: &[u8]) -> Option>> { + let rest = blob.strip_prefix(MAGIC)?; + let (nonce, sealed) = rest.split_at_checked(NONCE_LEN)?; + cipher + .decrypt( + XNonce::from_slice(nonce), + Payload { + msg: sealed, + aad: stem.as_bytes(), + }, + ) + .ok() + .map(Zeroizing::new) +} + +/// A record's file name, from its store's service and its record name. +pub(super) fn stem(service: &str, name: &str) -> String { + let mut digest = Sha256::new(); + digest.update( + u64::try_from(service.len()) + .unwrap_or(u64::MAX) + .to_be_bytes(), + ); + digest.update(service.as_bytes()); + digest.update(name.as_bytes()); + hex(&digest.finalize()) +} + +fn is_stem(name: &str) -> bool { + name.len() == 64 && name.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut out, byte| { + let _ = write!(out, "{byte:02x}"); + out + }) +} + +fn io(what: &str, path: &Path, err: &std::io::Error) -> ClientError { + SecretStoreError::Backend(format!("{what} {}: {err}", path.display())).into() +} diff --git a/crates/connetto-client/src/keyring/linux/secret_service.rs b/crates/connetto-client/src/keyring/linux/secret_service.rs new file mode 100644 index 00000000..f090befd --- /dev/null +++ b/crates/connetto-client/src/keyring/linux/secret_service.rs @@ -0,0 +1,248 @@ +//! The Secret Service's default collection through `oo7`, unlocked or created +//! first through a prompt connetto bounds (R71 decisions 2, 6, 10 and 12). + +use std::collections::HashMap; +use std::time::Duration; + +use futures_util::StreamExt as _; +use zbus::zvariant::{ObjectPath, OwnedObjectPath, OwnedValue, Value}; + +use super::{attributes, backend_error, decode, encode}; +use crate::ClientError; +use crate::keyring::SecretStoreError; + +/// How long a desktop dialog may stay unanswered before it is dismissed. +pub(super) const PROMPT_BOUND: Duration = Duration::from_secs(120); +const BUS_NAME: &str = "org.freedesktop.secrets"; +const DEFAULT_ALIAS: &str = "default"; +/// libsecret's label for the collection it creates under the default alias. +const DEFAULT_LABEL: &str = "Default keyring"; +const LABEL_PROPERTY: &str = "org.freedesktop.Secret.Collection.Label"; +/// The Secret Service's "no object" path. +const NONE: &str = "/"; + +#[zbus::proxy( + interface = "org.freedesktop.Secret.Service", + default_service = "org.freedesktop.secrets", + default_path = "/org/freedesktop/secrets", + gen_blocking = false +)] +trait Secrets { + fn read_alias(&self, name: &str) -> zbus::Result; + + fn create_collection( + &self, + properties: HashMap<&str, Value<'_>>, + alias: &str, + ) -> zbus::Result<(OwnedObjectPath, OwnedObjectPath)>; + + fn unlock( + &self, + objects: &[ObjectPath<'_>], + ) -> zbus::Result<(Vec, OwnedObjectPath)>; +} + +#[zbus::proxy( + interface = "org.freedesktop.Secret.Collection", + default_service = "org.freedesktop.secrets", + gen_blocking = false +)] +trait SecretCollection { + #[zbus(property(emits_changed_signal = "false"))] + fn locked(&self) -> zbus::Result; +} + +#[zbus::proxy( + interface = "org.freedesktop.Secret.Prompt", + default_service = "org.freedesktop.secrets", + gen_blocking = false +)] +trait SecretPrompt { + fn prompt(&self, window_id: &str) -> zbus::Result<()>; + + fn dismiss(&self) -> zbus::Result<()>; + + #[zbus(signal)] + fn completed(&self, dismissed: bool, result: OwnedValue) -> zbus::Result<()>; +} + +/// The session bus, when it carries a Secret Service or can start one. +pub(super) async fn session_bus() -> Option { + let bus = zbus::Connection::session().await.ok()?; + let dbus = zbus::fdo::DBusProxy::new(&bus).await.ok()?; + let name = zbus::names::BusName::try_from(BUS_NAME).ok()?; + if dbus.name_has_owner(name).await.unwrap_or(false) { + return Some(bus); + } + let activatable = dbus.list_activatable_names().await.ok()?; + activatable + .iter() + .any(|name| name.as_str() == BUS_NAME) + .then_some(bus) +} + +/// Makes the default collection exist and unlocked, through prompts that are +/// dismissed once `bound` passes. +pub(super) async fn ensure_default( + bus: &zbus::Connection, + bound: Duration, +) -> Result<(), ClientError> { + let secrets = SecretsProxy::new(bus).await.map_err(dbus_error)?; + let mut collection = secrets + .read_alias(DEFAULT_ALIAS) + .await + .map_err(dbus_error)?; + if collection.as_str() == NONE { + let properties = HashMap::from([(LABEL_PROPERTY, Value::from(DEFAULT_LABEL))]); + let (created, prompt) = secrets + .create_collection(properties, DEFAULT_ALIAS) + .await + .map_err(dbus_error)?; + collection = if created.as_str() == NONE { + OwnedObjectPath::try_from(run_prompt(bus, prompt, bound).await?) + .map_err(|err| backend_error("the created collection", err))? + } else { + created + }; + } + let locked = SecretCollectionProxy::builder(bus) + .path(collection.as_ref()) + .map_err(dbus_error)? + .build() + .await + .map_err(dbus_error)? + .locked() + .await + .map_err(dbus_error)?; + if locked { + let (_, prompt) = secrets + .unlock(&[collection.as_ref()]) + .await + .map_err(dbus_error)?; + if prompt.as_str() != NONE { + run_prompt(bus, prompt, bound).await?; + } + } + Ok(()) +} + +/// Shows the prompt at `path` and waits for its answer up to `bound`, +/// dismissing it once the bound passes. +async fn run_prompt( + bus: &zbus::Connection, + path: OwnedObjectPath, + bound: Duration, +) -> Result { + let prompt = SecretPromptProxy::builder(bus) + .path(path) + .map_err(dbus_error)? + .build() + .await + .map_err(dbus_error)?; + // Subscribed before the prompt shows, so a fast answer is not missed. + let mut completed = prompt.receive_completed().await.map_err(dbus_error)?; + prompt + .prompt("") + .await + .map_err(|_| SecretStoreError::Dismissed)?; + match tokio::time::timeout(bound, completed.next()).await { + Ok(Some(signal)) => { + let args = signal.args().map_err(dbus_error)?; + if *args.dismissed() { + Err(SecretStoreError::Dismissed.into()) + } else { + Ok(args.result) + } + } + Ok(None) => Err(SecretStoreError::Dismissed.into()), + Err(_) => { + // The dialog is already failing the caller, so a failed dismissal changes nothing. + let _ = prompt.dismiss().await; + Err(SecretStoreError::TimedOut(bound).into()) + } + } +} + +pub(super) struct SecretService { + bus: zbus::Connection, + service: oo7::dbus::Service, + bound: Duration, +} + +impl SecretService { + pub(super) async fn open(bus: zbus::Connection, bound: Duration) -> Result { + ensure_default(&bus, bound).await?; + let service = oo7::dbus::Service::new() + .await + .map_err(|err| backend_error("the Secret Service", err))?; + Ok(Self { + bus, + service, + bound, + }) + } + + async fn collection(&self) -> Result { + ensure_default(&self.bus, self.bound).await?; + self.service + .default_collection() + .await + .map_err(|err| backend_error("the Secret Service", err)) + } + + async fn items(&self, service: &str, name: &str) -> Result, ClientError> { + self.collection() + .await? + .search_items(&attributes(service, name)) + .await + .map_err(|err| backend_error("the Secret Service", err)) + } + + pub(super) async fn read( + &self, + service: &str, + name: &str, + ) -> Result, ClientError> { + let Some(item) = self.items(service, name).await?.into_iter().next() else { + return Ok(None); + }; + let secret = item + .secret() + .await + .map_err(|err| backend_error("the Secret Service", err))?; + decode(&secret).map(Some) + } + + pub(super) async fn write( + &self, + service: &str, + name: &str, + secret: &str, + ) -> Result<(), ClientError> { + self.collection() + .await? + .create_item( + service, + &attributes(service, name), + oo7::Secret::text(encode(secret).as_str()), + true, + None, + ) + .await + .map(drop) + .map_err(|err| backend_error("the Secret Service", err)) + } + + pub(super) async fn clear(&self, service: &str, name: &str) -> Result<(), ClientError> { + for item in self.items(service, name).await? { + item.delete(None) + .await + .map_err(|err| backend_error("the Secret Service", err))?; + } + Ok(()) + } +} + +fn dbus_error(err: zbus::Error) -> ClientError { + backend_error("the Secret Service", err) +} diff --git a/crates/connetto-client/src/keyring/linux/tests.rs b/crates/connetto-client/src/keyring/linux/tests.rs new file mode 100644 index 00000000..f5b2840d --- /dev/null +++ b/crates/connetto-client/src/keyring/linux/tests.rs @@ -0,0 +1,1383 @@ +use std::collections::HashMap; +use std::os::unix::fs::PermissionsExt as _; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use zbus::object_server::SignalEmitter; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +use super::sealed::{Sealed, read_wrap_key, stem}; +use super::secret_service::ensure_default; +use super::{Backend, Detected, Environment, Opened, SEALED_DIR, choose, detect}; +use crate::ClientError; +use crate::keyring::SecretStoreError; + +const CURRENT: [u8; 32] = [7; 32]; +const PREVIOUS: [u8; 32] = [9; 32]; + +fn sealed(dir: &Path, current: &[u8; 32], previous: Option<&[u8; 32]>) -> Sealed { + Sealed::open(dir.to_owned(), current, previous).expect("open the sealed store") +} + +fn records(dir: &Path) -> Vec { + let mut found: Vec<_> = std::fs::read_dir(dir) + .expect("list the state directory") + .map(|entry| entry.expect("entry").path()) + .filter(|path| path.file_name().is_some_and(|name| name.len() == 64)) + .collect(); + found.sort(); + found +} + +fn is_unsealable(err: &ClientError) -> bool { + matches!( + err, + ClientError::SecretStore(SecretStoreError::Unsealable { .. }) + ) +} + +#[test] +fn a_second_open_reads_what_the_first_wrote_and_two_services_stay_apart() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = sealed(dir.path(), &CURRENT, None); + first + .write("tokens", "\"alice\"", b"alice-refresh") + .expect("write alice"); + first + .write("keys", "\"alice\"", b"alice-key") + .expect("write alice's key"); + drop(first); + + let second = sealed(dir.path(), &CURRENT, None); + assert_eq!( + second + .read("tokens", "\"alice\"") + .expect("read") + .as_deref() + .map(Vec::as_slice), + Some(&b"alice-refresh"[..]) + ); + assert_eq!( + second + .read("keys", "\"alice\"") + .expect("read") + .as_deref() + .map(Vec::as_slice), + Some(&b"alice-key"[..]), + "the same name under another service is its own record" + ); + second.clear("tokens", "\"alice\"").expect("clear"); + assert!(second.read("tokens", "\"alice\"").expect("read").is_none()); + assert!( + second.read("keys", "\"alice\"").expect("read").is_some(), + "the clear stayed in its service" + ); +} + +#[test] +fn a_record_holds_no_secret_bytes_and_every_file_is_private() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = sealed(dir.path(), &CURRENT, None); + store + .write("keys", "replica", b"0123456789abcdef-key-material") + .expect("write"); + let files = records(dir.path()); + assert_eq!(files.len(), 1, "one record and no temporary left behind"); + let blob = std::fs::read(&files[0]).expect("read the record"); + assert!( + !blob + .windows(b"key-material".len()) + .any(|window| window == b"key-material"), + "the record holds ciphertext only" + ); + let mode = std::fs::metadata(&files[0]) + .expect("stat") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + let dir_mode = std::fs::metadata(dir.path()) + .expect("stat") + .permissions() + .mode() + & 0o777; + assert_eq!( + dir_mode & 0o077, + 0, + "the state directory is closed to others, got {dir_mode:o}" + ); + assert!( + !std::fs::read_dir(dir.path()) + .expect("list") + .any(|entry| entry + .expect("entry") + .file_name() + .to_string_lossy() + .contains(".tmp-")), + "the write renamed its temporary into place" + ); +} + +#[test] +fn a_tampered_swapped_or_wrongly_keyed_record_refuses_and_nothing_is_minted() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = sealed(dir.path(), &CURRENT, None); + store + .write("keys", "alice", b"alice-key") + .expect("write alice"); + store.write("keys", "bob", b"bob-key").expect("write bob"); + let before = records(dir.path()); + assert_eq!(before.len(), 2); + + let wrong = sealed(dir.path(), &PREVIOUS, None); + let err = wrong + .read("keys", "alice") + .expect_err("a wrong key refuses"); + assert!(is_unsealable(&err), "got {err}"); + assert_eq!(records(dir.path()), before, "the refusal wrote nothing"); + + let alice = dir.path().join(stem("keys", "alice")); + let bob = dir.path().join(stem("keys", "bob")); + std::fs::copy(&alice, &bob).expect("rename alice's record onto bob's name"); + let err = store + .read("keys", "bob") + .expect_err("a swapped record refuses"); + assert!(is_unsealable(&err), "got {err}"); + + let mut tampered = std::fs::read(&alice).expect("read alice"); + let last = tampered.len() - 1; + tampered[last] ^= 1; + std::fs::write(&alice, tampered).expect("flip a byte"); + let err = store + .read("keys", "alice") + .expect_err("a flipped byte refuses"); + assert!(is_unsealable(&err), "got {err}"); +} + +#[test] +fn a_rotated_key_reseals_once_and_leaves_current_records_untouched() { + let dir = tempfile::tempdir().expect("tempdir"); + let old = sealed(dir.path(), &PREVIOUS, None); + old.write("keys", "rotated", b"rotated-secret") + .expect("write under the old key"); + old.write("keys", "interrupted", b"interrupted-secret") + .expect("write under the old key"); + drop(old); + let current = sealed(dir.path(), &CURRENT, None); + current + .write("keys", "fresh", b"fresh-secret") + .expect("write under the new key"); + drop(current); + let fresh_path = dir.path().join(stem("keys", "fresh")); + let fresh_before = std::fs::read(&fresh_path).expect("read fresh"); + // A crash between the temporary write and the rename leaves both files. + std::fs::write( + dir.path().join(format!("{}.tmp-deadbeef", "0".repeat(64))), + b"partial", + ) + .expect("stale temporary"); + + let rotating = sealed(dir.path(), &CURRENT, Some(&PREVIOUS)); + assert!(!rotating.previous_key_needed(), "every record resealed"); + assert_eq!( + std::fs::read(&fresh_path).expect("read fresh"), + fresh_before, + "a current record is left byte for byte" + ); + drop(rotating); + + let after = sealed(dir.path(), &CURRENT, None); + for (name, secret) in [ + ("rotated", &b"rotated-secret"[..]), + ("interrupted", &b"interrupted-secret"[..]), + ("fresh", &b"fresh-secret"[..]), + ] { + assert_eq!( + after + .read("keys", name) + .expect("opens without the previous key") + .as_deref() + .map(Vec::as_slice), + Some(secret), + "{name} opens under the current key alone" + ); + } + assert!( + !std::fs::read_dir(dir.path()) + .expect("list") + .any(|entry| entry + .expect("entry") + .file_name() + .to_string_lossy() + .contains(".tmp-")), + "the stale temporary is gone" + ); +} + +#[test] +fn a_wrap_key_of_any_other_length_is_refused() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("wrap.key"); + std::fs::write(&path, [1_u8; 31]).expect("write a short key"); + let err = read_wrap_key(&path).expect_err("31 bytes refuse"); + assert!( + matches!( + err, + ClientError::SecretStore(SecretStoreError::WrapKeyLength { len: 31, .. }) + ), + "got {err}" + ); + std::fs::write(&path, [1_u8; 32]).expect("write a full key"); + assert!(read_wrap_key(&path).is_ok()); +} + +fn environment(sandboxed: bool, credentials: Option<&Path>, state: Option<&Path>) -> Environment { + Environment { + sandboxed, + credentials: credentials.map(Path::to_owned), + state: state.map(Path::to_owned), + } +} + +#[tokio::test] +async fn detection_prefers_the_sandbox_then_a_credential_then_the_secret_service() { + let credentials = tempfile::tempdir().expect("credentials"); + std::fs::write(credentials.path().join(super::CREDENTIAL), CURRENT).expect("credential"); + let state = tempfile::tempdir().expect("state"); + let with_credential = environment(false, Some(credentials.path()), Some(state.path())); + + let (fake, bus) = FakeSecretService::start(Answer::Never).await; + assert!(matches!( + choose( + &environment(true, Some(credentials.path()), None), + Some(bus.clone()) + ), + Ok(Detected::Sandbox) + )); + assert!(matches!( + choose(&with_credential, Some(bus.clone())), + Ok(Detected::Credential(_)) + )); + assert!( + matches!( + choose( + &environment(false, Some(state.path()), None), + Some(bus.clone()) + ), + Ok(Detected::SecretService(_)) + ), + "a credentials directory without connetto's credential is not a credential" + ); + + let opened = detect(&with_credential, Some(bus)) + .await + .expect("the credential opens"); + assert!(matches!( + opened, + Opened::Sealed { + credential: true, + .. + } + )); + assert!( + fake.calls().is_empty(), + "a credential wins over the Secret Service" + ); + assert!(state.path().join(SEALED_DIR).is_dir()); + + let err = detect(&environment(false, None, None), None) + .await + .err() + .expect("nothing is reachable"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::NoStore { probed }) if probed.contains("sandbox") && probed.contains("credential") && probed.contains("Secret Service")), + "the refusal names what it probed" + ); +} + +#[tokio::test] +async fn detection_with_only_the_secret_service_goes_through_its_unlock() { + let (fake, bus) = FakeSecretService::start(Answer::Dismiss).await; + let err = detect(&environment(false, None, None), Some(bus)) + .await + .err() + .expect("the fake's dialog is dismissed"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::Dismissed)), + "got {err}" + ); + assert_eq!(fake.calls(), ["ReadAlias", "Unlock", "Prompt"]); +} + +#[tokio::test] +async fn a_credential_with_no_state_directory_refuses() { + let credentials = tempfile::tempdir().expect("credentials"); + std::fs::write(credentials.path().join(super::CREDENTIAL), CURRENT).expect("credential"); + let err = detect(&environment(false, Some(credentials.path()), None), None) + .await + .err() + .expect("a unit with no StateDirectory= refuses"); + assert!(err.to_string().contains("StateDirectory"), "got {err}"); +} + +#[test] +fn only_keyutils_is_lost_at_reboot() { + for backend in [ + Backend::SecretService, + Backend::SandboxKeyring, + Backend::SystemdCredential { + previous_key_needed: false, + }, + Backend::KeyFile { + previous_key_needed: true, + }, + ] { + assert!(backend.survives_reboot(), "{backend:?}"); + } + assert!(!Backend::Keyutils.survives_reboot()); +} + +#[derive(Clone, Copy, Debug)] +enum Answer { + /// The service creates or unlocks at once and hands back no prompt. + Immediate, + /// The service drops the connection after showing the prompt, as a crashed daemon does. + Hangup, + Never, + Dismiss, + Complete, +} + +const COLLECTION: &str = "/org/freedesktop/secrets/collection/login"; +const PROMPT: &str = "/org/freedesktop/secrets/prompt/p1"; + +struct FakeState { + answer: Answer, + alias: Option, + locked: bool, + calls: Vec, +} + +#[derive(Clone)] +struct FakeSecretService { + state: Arc>, +} + +impl FakeSecretService { + /// A fake whose default collection exists, locked, on a private peer-to-peer bus. + async fn start(answer: Answer) -> (Self, zbus::Connection) { + Self::start_with(answer, true).await + } + + async fn start_with(answer: Answer, has_default: bool) -> (Self, zbus::Connection) { + let fake = Self { + state: Arc::new(Mutex::new(FakeState { + answer, + alias: has_default.then(|| OwnedObjectPath::try_from(COLLECTION).expect("path")), + locked: true, + calls: Vec::new(), + })), + }; + let (server, client) = tokio::net::UnixStream::pair().expect("socket pair"); + let guid = zbus::Guid::generate(); + let server = zbus::connection::Builder::unix_stream(server) + .server(guid) + .expect("server") + .p2p() + .serve_at("/org/freedesktop/secrets", Service(fake.clone())) + .expect("serve the service") + .serve_at(COLLECTION, Collection(fake.clone())) + .expect("serve the collection") + .serve_at(PROMPT, Prompt(fake.clone())) + .expect("serve the prompt") + .build(); + let client = zbus::connection::Builder::unix_stream(client).p2p().build(); + let (server, client) = tokio::join!(server, client); + // The server connection lives as long as the test by leaking into a background task. + let server = server.expect("server connection"); + tokio::spawn(async move { + let _server = server; + std::future::pending::<()>().await; + }); + (fake, client.expect("client connection")) + } + + fn calls(&self) -> Vec { + self.state.lock().expect("fake state").calls.clone() + } + + fn record(&self, call: &str) { + self.state + .lock() + .expect("fake state") + .calls + .push(call.to_owned()); + } +} + +struct Service(FakeSecretService); + +#[zbus::interface(name = "org.freedesktop.Secret.Service")] +#[expect( + clippy::needless_pass_by_value, + reason = "zbus hands interface arguments over owned" +)] +impl Service { + fn read_alias(&self, name: &str) -> OwnedObjectPath { + self.0.record("ReadAlias"); + assert_eq!(name, "default"); + self.0 + .state + .lock() + .expect("fake state") + .alias + .clone() + .unwrap_or_else(|| OwnedObjectPath::try_from("/").expect("path")) + } + + fn create_collection( + &self, + properties: HashMap, + alias: String, + ) -> (OwnedObjectPath, OwnedObjectPath) { + self.0.record("CreateCollection"); + assert_eq!(alias, "default"); + let label = properties + .get("org.freedesktop.Secret.Collection.Label") + .and_then(|value| <&str>::try_from(value).ok().map(str::to_owned)); + assert_eq!(label.as_deref(), Some("Default keyring")); + let mut state = self.0.state.lock().expect("fake state"); + if matches!(state.answer, Answer::Immediate) { + let created = OwnedObjectPath::try_from(COLLECTION).expect("path"); + state.alias = Some(created.clone()); + state.locked = false; + return (created, OwnedObjectPath::try_from("/").expect("path")); + } + ( + OwnedObjectPath::try_from("/").expect("path"), + OwnedObjectPath::try_from(PROMPT).expect("path"), + ) + } + + fn unlock(&self, objects: Vec) -> (Vec, OwnedObjectPath) { + self.0.record("Unlock"); + assert_eq!(objects.len(), 1); + let mut state = self.0.state.lock().expect("fake state"); + if matches!(state.answer, Answer::Immediate) { + state.locked = false; + return (objects, OwnedObjectPath::try_from("/").expect("path")); + } + (Vec::new(), OwnedObjectPath::try_from(PROMPT).expect("path")) + } +} + +struct Collection(FakeSecretService); + +#[zbus::interface(name = "org.freedesktop.Secret.Collection")] +impl Collection { + #[zbus(property)] + fn locked(&self) -> bool { + self.0.state.lock().expect("fake state").locked + } +} + +struct Prompt(FakeSecretService); + +#[zbus::interface(name = "org.freedesktop.Secret.Prompt")] +impl Prompt { + async fn prompt( + &self, + window_id: &str, + #[zbus(signal_emitter)] emitter: SignalEmitter<'_>, + #[zbus(connection)] connection: &zbus::Connection, + ) { + self.0.record("Prompt"); + assert!( + window_id.is_empty(), + "connetto has no window to parent the dialog to" + ); + let answer = self.0.state.lock().expect("fake state").answer; + match answer { + Answer::Immediate | Answer::Never => {} + Answer::Hangup => { + let connection = connection.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = connection.close().await; + }); + } + Answer::Dismiss => { + Self::completed(&emitter, true, Value::from("")) + .await + .expect("emit"); + } + Answer::Complete => { + { + let mut state = self.0.state.lock().expect("fake state"); + state.locked = false; + state.alias = Some(OwnedObjectPath::try_from(COLLECTION).expect("path")); + } + let collection = zbus::zvariant::ObjectPath::try_from(COLLECTION).expect("path"); + Self::completed(&emitter, false, Value::from(collection)) + .await + .expect("emit"); + } + } + } + + fn dismiss(&self) { + self.0.record("Dismiss"); + } + + #[zbus(signal)] + async fn completed( + emitter: &SignalEmitter<'_>, + dismissed: bool, + result: Value<'_>, + ) -> zbus::Result<()>; +} + +#[tokio::test] +async fn an_unanswered_unlock_is_dismissed_at_the_bound_and_refused() { + let (fake, bus) = FakeSecretService::start(Answer::Never).await; + let err = ensure_default(&bus, Duration::from_millis(200)) + .await + .expect_err("an unanswered dialog refuses"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::TimedOut(_))), + "got {err}" + ); + assert_eq!(fake.calls(), ["ReadAlias", "Unlock", "Prompt", "Dismiss"]); +} + +#[tokio::test] +async fn a_dismissed_unlock_is_refused() { + let (fake, bus) = FakeSecretService::start(Answer::Dismiss).await; + let err = ensure_default(&bus, Duration::from_secs(5)) + .await + .expect_err("a dismissed dialog refuses"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::Dismissed)), + "got {err}" + ); + assert!( + !fake.calls().contains(&"Dismiss".to_owned()), + "an answered prompt is not dismissed" + ); +} + +#[tokio::test] +async fn an_answered_unlock_opens_and_a_second_call_needs_no_prompt() { + let (fake, bus) = FakeSecretService::start(Answer::Complete).await; + ensure_default(&bus, Duration::from_secs(5)) + .await + .expect("unlocked"); + ensure_default(&bus, Duration::from_secs(5)) + .await + .expect("already unlocked"); + assert_eq!(fake.calls(), ["ReadAlias", "Unlock", "Prompt", "ReadAlias"]); +} + +#[tokio::test] +async fn a_missing_default_collection_is_created_through_the_prompt() { + let (fake, bus) = FakeSecretService::start_with(Answer::Complete, false).await; + ensure_default(&bus, Duration::from_secs(5)) + .await + .expect("created"); + assert_eq!(fake.calls(), ["ReadAlias", "CreateCollection", "Prompt"]); +} + +#[tokio::test(flavor = "current_thread")] +async fn a_prompt_that_never_completes_does_not_stall_a_one_worker_runtime() { + let (_fake, bus) = FakeSecretService::start(Answer::Never).await; + let waiting = + tokio::spawn(async move { ensure_default(&bus, Duration::from_millis(500)).await }); + let other = tokio::spawn(async { tokio::time::sleep(Duration::from_millis(20)).await }); + tokio::time::timeout(Duration::from_millis(300), other) + .await + .expect("another task ran while the prompt waited") + .expect("the other task"); + assert!(!waiting.is_finished(), "the prompt is still waiting"); + let err = waiting + .await + .expect("join") + .expect_err("refused at the bound"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::TimedOut(_))), + "got {err}" + ); +} + +#[tokio::test] +async fn the_sandbox_keyring_keeps_base64_text_a_second_open_reads_back() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("keyrings").join("default.keyring"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("keyrings directory"); + let secret = || oo7::Secret::from(vec![5_u8; 64]); + + let first = super::sandbox::Sandbox::load(&path, secret()) + .await + .expect("open"); + first + .write("tokens", "\"alice\"", "alice-refresh") + .await + .expect("write"); + drop(first); + + let second = super::sandbox::Sandbox::load(&path, secret()) + .await + .expect("reopen"); + assert_eq!( + second + .read("tokens", "\"alice\"") + .await + .expect("read") + .as_deref(), + Some("alice-refresh") + ); + let raw = oo7::file::UnlockedKeyring::load(&path, secret()) + .await + .expect("open the file directly") + .lookup_item(&super::attributes("tokens", "\"alice\"")) + .await + .expect("lookup") + .expect("the item exists"); + let stored = raw.as_unlocked().secret(); + assert_eq!(stored.content_type(), oo7::ContentType::Text); + assert_eq!( + &*stored, b"YWxpY2UtcmVmcmVzaA==", + "the item holds base64 text" + ); + second.clear("tokens", "\"alice\"").await.expect("clear"); + assert!( + second + .read("tokens", "\"alice\"") + .await + .expect("read") + .is_none() + ); +} + +#[tokio::test] +async fn a_named_credential_that_the_unit_does_not_hand_over_refuses_naming_it() { + let err = super::open_named( + &super::LinuxStore::SystemdCredential, + &environment(false, None, None), + ) + .await + .err() + .expect("no credentials directory"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::NoStore { probed }) if probed.contains("connetto.wrap-key")), + "got {err}" + ); +} + +#[tokio::test] +async fn a_named_key_file_with_a_previous_key_reseals_and_reports_it_no_longer_needed() { + let dir = tempfile::tempdir().expect("tempdir"); + let (key, previous) = (dir.path().join("wrap.key"), dir.path().join("previous.key")); + std::fs::write(&key, CURRENT).expect("key"); + std::fs::write(&previous, PREVIOUS).expect("previous key"); + let old = sealed(&dir.path().join("state").join(SEALED_DIR), &PREVIOUS, None); + old.write("tokens", "alice", b"alice-refresh") + .expect("write under the old key"); + drop(old); + + let store = super::Store::named(super::LinuxStore::KeyFile( + super::KeyFile::new(&key, dir.path().join("state")).with_previous(&previous), + )); + assert_eq!( + store.backend().await.expect("opens"), + Backend::KeyFile { + previous_key_needed: false + } + ); + std::fs::remove_file(&previous).expect("retire the previous key"); + let reopened = super::Store::named(super::LinuxStore::KeyFile(super::KeyFile::new( + &key, + dir.path().join("state"), + ))); + assert_eq!( + reopened + .read("tokens", "alice") + .await + .expect("read") + .as_deref(), + Some("alice-refresh") + ); +} + +#[tokio::test] +async fn a_service_that_unlocks_without_a_prompt_needs_no_dialog() { + let (fake, bus) = FakeSecretService::start(Answer::Immediate).await; + ensure_default(&bus, Duration::from_secs(5)) + .await + .expect("unlocked at once"); + ensure_default(&bus, Duration::from_secs(5)) + .await + .expect("already unlocked"); + assert_eq!(fake.calls(), ["ReadAlias", "Unlock", "ReadAlias"]); +} + +#[tokio::test] +async fn a_service_that_creates_the_default_without_a_prompt_needs_no_dialog() { + let (fake, bus) = FakeSecretService::start_with(Answer::Immediate, false).await; + ensure_default(&bus, Duration::from_secs(5)) + .await + .expect("created at once"); + ensure_default(&bus, Duration::from_secs(5)) + .await + .expect("found the second time"); + assert_eq!(fake.calls(), ["ReadAlias", "CreateCollection", "ReadAlias"]); +} + +#[test] +fn a_truncated_record_refuses_rather_than_minting_or_panicking() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = sealed(dir.path(), &CURRENT, Some(&PREVIOUS)); + store.write("keys", "alice", b"alice-key").expect("write"); + let path = dir.path().join(stem("keys", "alice")); + let whole = std::fs::read(&path).expect("read"); + // Empty, shorter than the header, and cut inside the nonce. + for length in [0, 3, 5 + 10] { + std::fs::write(&path, &whole[..length]).expect("truncate"); + let err = store + .read("keys", "alice") + .expect_err("a truncated record refuses"); + assert!(is_unsealable(&err), "length {length}: got {err}"); + } +} + +#[test] +fn the_sandbox_keyring_lives_where_libsecret_puts_it() { + use std::ffi::OsString; + let path = |xdg: Option<&str>, home: Option<&str>| { + super::sandbox::keyring_path_from(xdg.map(OsString::from), home.map(OsString::from)) + }; + assert_eq!( + path(Some("/data"), Some("/home/app")), + Some("/data/keyrings/default.keyring".into()) + ); + for ignored in [Some(""), Some("relative/data"), None] { + assert_eq!( + path(ignored, Some("/home/app")), + Some("/home/app/.local/share/keyrings/default.keyring".into()), + "XDG_DATA_HOME {ignored:?} falls back to HOME" + ); + } + assert_eq!(path(None, Some("")), None); + assert_eq!(path(None, None), None); +} + +/// A private session bus, stopped on drop. +struct PrivateBus { + daemon: std::process::Child, + address: String, +} + +impl PrivateBus { + fn start() -> Self { + use std::io::BufRead as _; + let mut daemon = std::process::Command::new("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(std::process::Stdio::piped()) + .spawn() + .expect("start dbus-daemon"); + let mut address = String::new(); + std::io::BufReader::new(daemon.stdout.take().expect("daemon stdout")) + .read_line(&mut address) + .expect("read the bus address"); + Self { + daemon, + address: address.trim().to_owned(), + } + } + + async fn connect(&self) -> zbus::Connection { + zbus::connection::Builder::address(self.address.as_str()) + .expect("bus address") + .build() + .await + .expect("connect to the private bus") + } + + /// Serves a fake Secret portal that answers with `secret`, or never answers. + async fn serve_portal(&self, secret: Option>) -> zbus::Connection { + zbus::connection::Builder::address(self.address.as_str()) + .expect("bus address") + .name("org.freedesktop.portal.Desktop") + .expect("portal name") + .serve_at("/org/freedesktop/portal/desktop", FakePortal { secret }) + .expect("serve the portal") + .build() + .await + .expect("portal connection") + } +} + +impl Drop for PrivateBus { + fn drop(&mut self) { + let _ = self.daemon.kill(); + let _ = self.daemon.wait(); + } +} + +struct FakePortal { + secret: Option>, +} + +#[zbus::interface(name = "org.freedesktop.portal.Secret")] +impl FakePortal { + async fn retrieve_secret( + &self, + fd: zbus::zvariant::OwnedFd, + options: HashMap, + #[zbus(header)] header: zbus::message::Header<'_>, + #[zbus(connection)] connection: &zbus::Connection, + ) -> OwnedObjectPath { + use std::io::Write as _; + let token = options + .get("handle_token") + .and_then(|value| <&str>::try_from(value).ok()) + .expect("a handle token") + .to_owned(); + let sender = header.sender().expect("a sender").to_owned(); + let request = OwnedObjectPath::try_from(format!( + "/org/freedesktop/portal/desktop/request/{}/{token}", + sender.trim_start_matches(':').replace('.', "_") + )) + .expect("request path"); + if let Some(secret) = &self.secret { + std::fs::File::from(std::os::fd::OwnedFd::from(fd)) + .write_all(secret) + .expect("hand the secret over"); + connection + .emit_signal( + Some(zbus::names::BusName::from(sender.clone())), + &request, + "org.freedesktop.portal.Request", + "Response", + &(0_u32, HashMap::<&str, Value<'_>>::new()), + ) + .await + .expect("answer the request"); + } + request + } + + #[zbus(property)] + #[expect( + clippy::unused_self, + reason = "a zbus property is read through the object" + )] + fn version(&self) -> u32 { + 1 + } +} + +const PORTAL_SECRET: [u8; 64] = [11; 64]; + +#[tokio::test(flavor = "multi_thread")] +async fn the_sandbox_keyring_opens_with_the_portals_secret_and_reopens() { + let bus = PrivateBus::start(); + let _portal = bus.serve_portal(Some(PORTAL_SECRET.to_vec())).await; + let client = bus.connect().await; + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("keyrings").join("default.keyring"); + std::fs::create_dir_all(path.parent().expect("parent")).expect("keyrings directory"); + + let first = + super::sandbox::Sandbox::open_with(&client, Duration::from_secs(5), Some(path.clone())) + .await + .expect("the portal answers"); + first + .write("tokens", "\"alice\"", "alice-refresh") + .await + .expect("write"); + drop(first); + + let second = + super::sandbox::Sandbox::open_with(&client, Duration::from_secs(5), Some(path.clone())) + .await + .expect("reopen"); + assert_eq!( + second + .read("tokens", "\"alice\"") + .await + .expect("read") + .as_deref(), + Some("alice-refresh") + ); + let direct = super::sandbox::Sandbox::load(&path, oo7::Secret::from(PORTAL_SECRET.to_vec())) + .await + .expect("the file opens under the portal's secret"); + assert!( + direct + .read("tokens", "\"alice\"") + .await + .expect("read") + .is_some() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_portal_that_never_answers_is_refused_at_the_bound() { + let bus = PrivateBus::start(); + let _portal = bus.serve_portal(None).await; + let client = bus.connect().await; + let dir = tempfile::tempdir().expect("tempdir"); + let err = super::sandbox::Sandbox::open_with( + &client, + Duration::from_millis(300), + Some(dir.path().join("default.keyring")), + ) + .await + .err() + .expect("an unanswered portal refuses"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::TimedOut(_))), + "got {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_sandbox_with_no_data_directory_refuses_before_asking_the_portal() { + let bus = PrivateBus::start(); + let client = bus.connect().await; + let err = super::sandbox::Sandbox::open_with(&client, Duration::from_secs(5), None) + .await + .err() + .expect("no data directory refuses"); + assert!(err.to_string().contains("no data directory"), "got {err}"); +} + +/// One side of the session-bus run, driven by the environment. Run alone it does nothing. +/// One side of a session-bus run, chosen by the environment. Run alone it does nothing. +#[tokio::test] +#[ignore = "a phase the session-bus tests run in a child process"] +async fn session_bus_phase() { + let Ok(phase) = std::env::var("CONNETTO_R71_SESSION_PHASE") else { + return; + }; + match phase.as_str() { + "open" => { + let sandbox = super::sandbox::Sandbox::open() + .await + .expect("open through the session bus"); + sandbox + .write("tokens", "\"alice\"", "alice-refresh") + .await + .expect("write"); + } + "named-sandbox" => { + let store = super::Store::named(super::LinuxStore::SandboxKeyring); + assert_eq!( + store.backend().await.expect("opens"), + Backend::SandboxKeyring + ); + store + .write("tokens", "\"alice\"", "alice-refresh") + .await + .expect("write"); + assert_eq!( + store + .read("tokens", "\"alice\"") + .await + .expect("read") + .as_deref(), + Some("alice-refresh") + ); + store.clear("tokens", "\"alice\"").await.expect("clear"); + assert!( + store + .read("tokens", "\"alice\"") + .await + .expect("read") + .is_none() + ); + } + "detected-sandbox" => { + let opened = detect(&environment(true, None, None), None) + .await + .expect("a sandbox opens through the portal"); + assert!(matches!(opened, Opened::Sandbox(_))); + } + "no-secret-service" => { + let store = super::Store::named(super::LinuxStore::SecretService); + let err = store.backend().await.expect_err("no session bus refuses"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::NoStore { probed }) if probed == "the Secret Service"), + "got {err}" + ); + } + other => panic!("unknown phase {other}"), + } +} + +/// Runs `phase` in a child process whose session bus and data home are the given ones. +async fn run_session_phase(phase: &'static str, bus_address: &str, data_home: &Path) { + let exe = std::env::current_exe().expect("the test binary"); + let (address, data_home) = (bus_address.to_owned(), data_home.to_owned()); + let status = tokio::task::spawn_blocking(move || { + std::process::Command::new(exe) + .args([ + "keyring::linux::tests::session_bus_phase", + "--exact", + "--ignored", + ]) + .env("CONNETTO_R71_SESSION_PHASE", phase) + .env("DBUS_SESSION_BUS_ADDRESS", address) + .env("XDG_DATA_HOME", data_home) + .status() + }) + .await + .expect("join") + .expect("spawn the phase"); + assert!(status.success(), "the {phase} phase failed"); +} + +fn data_home() -> tempfile::TempDir { + let data = tempfile::tempdir().expect("data home"); + std::fs::create_dir_all(data.path().join("keyrings")).expect("keyrings directory"); + data +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_sandbox_opens_through_the_session_bus_at_libsecrets_path() { + let bus = PrivateBus::start(); + let _portal = bus.serve_portal(Some(PORTAL_SECRET.to_vec())).await; + let data = data_home(); + run_session_phase("open", &bus.address, data.path()).await; + + let path = data.path().join("keyrings").join("default.keyring"); + let keyring = super::sandbox::Sandbox::load(&path, oo7::Secret::from(PORTAL_SECRET.to_vec())) + .await + .expect("the child wrote libsecret's file under XDG_DATA_HOME"); + assert_eq!( + keyring + .read("tokens", "\"alice\"") + .await + .expect("read") + .as_deref(), + Some("alice-refresh") + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_named_sandbox_store_keeps_secrets_through_the_store() { + let bus = PrivateBus::start(); + let _portal = bus.serve_portal(Some(PORTAL_SECRET.to_vec())).await; + let data = data_home(); + run_session_phase("named-sandbox", &bus.address, data.path()).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn detection_inside_a_sandbox_opens_the_portal_keyring() { + let bus = PrivateBus::start(); + let _portal = bus.serve_portal(Some(PORTAL_SECRET.to_vec())).await; + let data = data_home(); + run_session_phase("detected-sandbox", &bus.address, data.path()).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_named_secret_service_with_no_session_bus_refuses_naming_it() { + let data = data_home(); + run_session_phase( + "no-secret-service", + "unix:path=/nonexistent/connetto-r71-bus", + data.path(), + ) + .await; +} + +#[test] +fn an_unusable_state_directory_refuses_naming_it() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("not-a-directory"); + std::fs::write(&file, b"").expect("a regular file"); + let err = Sealed::open(file.join("state"), &CURRENT, None) + .err() + .expect("a state directory under a file refuses"); + assert!( + matches!(&err, ClientError::SecretStore(SecretStoreError::Backend(message)) if message.contains("not-a-directory")), + "got {err}" + ); +} + +#[tokio::test] +async fn a_secret_service_that_answers_with_errors_refuses() { + let (server, client) = tokio::net::UnixStream::pair().expect("socket pair"); + let guid = zbus::Guid::generate(); + // An object server with nothing at the Secret Service's path answers every call with an error. + let server = zbus::connection::Builder::unix_stream(server) + .server(guid) + .expect("server") + .p2p() + .serve_at("/unrelated", FakePortal { secret: None }) + .expect("serve") + .build(); + let client = zbus::connection::Builder::unix_stream(client).p2p().build(); + let (_server, client) = tokio::join!(server, client); + let err = tokio::time::timeout( + Duration::from_secs(5), + ensure_default(&client.expect("client"), Duration::from_secs(5)), + ) + .await + .expect("the error comes back rather than a hang") + .expect_err("a service with no objects refuses"); + assert!( + matches!(&err, ClientError::SecretStore(SecretStoreError::Backend(message)) if message.starts_with("the Secret Service")), + "got {err}" + ); +} + +#[tokio::test] +async fn a_service_that_hangs_up_mid_prompt_is_refused_as_dismissed() { + let (fake, bus) = FakeSecretService::start(Answer::Hangup).await; + let err = tokio::time::timeout( + Duration::from_secs(5), + ensure_default(&bus, Duration::from_secs(5)), + ) + .await + .expect("a hang-up ends the wait rather than the bound") + .expect_err("refused"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::Dismissed)), + "got {err}" + ); + assert!( + !fake.calls().contains(&"Dismiss".to_owned()), + "nothing is left to dismiss" + ); +} + +#[tokio::test] +async fn a_sandbox_item_that_is_not_connettos_base64_text_refuses() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("default.keyring"); + let secret = || oo7::Secret::from(PORTAL_SECRET.to_vec()); + let raw = oo7::file::UnlockedKeyring::load(&path, secret()) + .await + .expect("open"); + for (name, text) in [("garbled", "not base64!"), ("binary", "/w==")] { + raw.create_item( + "tokens", + &super::attributes("tokens", name), + oo7::Secret::text(text), + true, + ) + .await + .expect("plant a value connetto never writes"); + } + drop(raw); + let sandbox = super::sandbox::Sandbox::load(&path, secret()) + .await + .expect("reopen"); + for name in ["garbled", "binary"] { + let err = sandbox.read("tokens", name).await.expect_err("refused"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::Encoding)), + "{name}: got {err}" + ); + } +} + +fn key_file_store(dir: &Path, key: &[u8; 32]) -> super::LinuxStore { + let path = dir.join(format!("wrap-{}.key", key[0])); + std::fs::write(&path, key).expect("key file"); + super::LinuxStore::KeyFile(super::KeyFile::new(path, dir.join("state"))) +} + +#[tokio::test] +async fn a_record_that_is_not_text_refuses_as_badly_encoded() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = super::Store::named(key_file_store(dir.path(), &CURRENT)); + sealed(&dir.path().join("state").join(SEALED_DIR), &CURRENT, None) + .write("tokens", "alice", &[0xff, 0xfe]) + .expect("plant bytes connetto never writes"); + let err = store.read("tokens", "alice").await.expect_err("refused"); + assert!( + matches!(err, ClientError::SecretStore(SecretStoreError::Encoding)), + "got {err}" + ); +} + +#[test] +fn a_directory_where_a_record_belongs_refuses_reads_and_clears() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = sealed(dir.path(), &CURRENT, None); + std::fs::create_dir(dir.path().join(stem("keys", "alice"))) + .expect("a directory in the record's place"); + let err = store.read("keys", "alice").expect_err("read refuses"); + assert!( + err.to_string().contains("reading a sealed record"), + "got {err}" + ); + let err = store.clear("keys", "alice").expect_err("clear refuses"); + assert!( + err.to_string().contains("removing a sealed record"), + "got {err}" + ); +} + +#[test] +fn a_record_left_under_the_previous_key_after_opening_is_resealed_when_read() { + let dir = tempfile::tempdir().expect("tempdir"); + let rotating = sealed(dir.path(), &CURRENT, Some(&PREVIOUS)); + sealed(dir.path(), &PREVIOUS, None) + .write("keys", "late", b"late-secret") + .expect("another process writes under the previous key"); + assert_eq!( + rotating + .read("keys", "late") + .expect("read") + .as_deref() + .map(Vec::as_slice), + Some(&b"late-secret"[..]) + ); + assert_eq!( + sealed(dir.path(), &CURRENT, None) + .read("keys", "late") + .expect("opens under the current key alone") + .as_deref() + .map(Vec::as_slice), + Some(&b"late-secret"[..]) + ); +} + +#[test] +fn opening_skips_names_it_does_not_own_and_records_under_neither_key() { + use std::os::unix::ffi::OsStrExt as _; + let dir = tempfile::tempdir().expect("tempdir"); + let stranger = dir + .path() + .join(std::ffi::OsStr::from_bytes(b"not-utf8-\xff")); + std::fs::write(&stranger, b"left alone").expect("a file with a non-UTF-8 name"); + sealed(dir.path(), &[5; 32], None) + .write("keys", "foreign", b"foreign-secret") + .expect("a record under a third key"); + let foreign = dir.path().join(stem("keys", "foreign")); + let before = std::fs::read(&foreign).expect("read"); + + let rotating = sealed(dir.path(), &CURRENT, Some(&PREVIOUS)); + assert!(!rotating.previous_key_needed()); + assert_eq!( + std::fs::read(&foreign).expect("read"), + before, + "a record under neither key is untouched" + ); + assert!( + stranger.exists(), + "a name that is not connetto's is left alone" + ); + let err = rotating.read("keys", "foreign").expect_err("refused"); + assert!(is_unsealable(&err), "got {err}"); +} + +#[tokio::test] +async fn a_named_credential_opens_the_sealed_store_and_bad_state_directories_refuse() { + let dir = tempfile::tempdir().expect("tempdir"); + let credentials = dir.path().join("credentials"); + std::fs::create_dir_all(&credentials).expect("credentials"); + std::fs::write(credentials.join(super::CREDENTIAL), CURRENT).expect("credential"); + let state = dir.path().join("state"); + let opened = super::open_named( + &super::LinuxStore::SystemdCredential, + &environment(false, Some(&credentials), Some(&state)), + ) + .await + .expect("the named credential opens"); + assert!(matches!( + opened, + Opened::Sealed { + credential: true, + .. + } + )); + + let file = dir.path().join("a-file"); + std::fs::write(&file, b"").expect("a regular file"); + let under_file = file.join("state"); + let err = super::open_named( + &super::LinuxStore::SystemdCredential, + &environment(false, Some(&credentials), Some(&under_file)), + ) + .await + .err() + .expect("a state directory under a file refuses"); + assert!(err.to_string().contains("a-file"), "got {err}"); + let key = dir.path().join("wrap.key"); + std::fs::write(&key, CURRENT).expect("key"); + let err = super::open_named( + &super::LinuxStore::KeyFile(super::KeyFile::new(&key, &under_file)), + &environment(false, None, None), + ) + .await + .err() + .expect("a key file's state directory under a file refuses"); + assert!(err.to_string().contains("a-file"), "got {err}"); + let err = super::open_named( + &super::LinuxStore::KeyFile(super::KeyFile::new(dir.path().join("missing.key"), &state)), + &environment(false, None, None), + ) + .await + .err() + .expect("a missing key file refuses"); + assert!( + err.to_string().contains("reading the wrap key"), + "got {err}" + ); +} + +#[tokio::test] +async fn store_failures_reach_the_refresh_and_key_stores_as_errors() { + use connetto_core::traits::{RefreshTokenStore as _, ReplicaKeyStore as _}; + let dir = tempfile::tempdir().expect("tempdir"); + let (first, second) = ( + key_file_store(dir.path(), &CURRENT), + key_file_store(dir.path(), &PREVIOUS), + ); + let tokens = crate::KeyringStore::with_linux_store("svc", first.clone()); + tokens + .store("\"alice\"", "alice-refresh") + .await + .expect("store"); + crate::KeyringKeyStore::with_linux_store("keys", first) + .store( + "replica", + &crate::ReplicaKey::from_bytes([1; crate::ReplicaKey::LEN]), + ) + .await + .expect("store a key"); + + let rekeyed = crate::KeyringStore::with_linux_store("svc", second.clone()); + assert!( + rekeyed.accounts().await.is_err(), + "an index under another key refuses to list" + ); + assert!( + rekeyed.store("\"bob\"", "bob-refresh").await.is_err(), + "and to add an account" + ); + assert!(rekeyed.clear("\"alice\"").await.is_err(), "and to drop one"); + let err = crate::KeyringKeyStore::with_linux_store("keys", second) + .load("replica") + .await + .expect_err("a key under another wrap key refuses"); + assert!(is_unsealable(&err), "got {err}"); + + let records = dir.path().join("state").join(SEALED_DIR); + let carol = records.join(stem("svc", "\"carol\"")); + std::fs::create_dir(&carol).expect("a directory in carol's place"); + std::fs::write(carol.join("occupant"), b"").expect("non-empty"); + assert!( + tokens.store("\"carol\"", "carol-refresh").await.is_err(), + "an unwritable record refuses" + ); + assert!( + tokens.clear("\"carol\"").await.is_err(), + "an unremovable record refuses" + ); +} diff --git a/crates/connetto-client/src/keyring/mod.rs b/crates/connetto-client/src/keyring/mod.rs new file mode 100644 index 00000000..1ce3ac9d --- /dev/null +++ b/crates/connetto-client/src/keyring/mod.rs @@ -0,0 +1,219 @@ +//! The OS secret store behind [`KeyringStore`](crate::KeyringStore) and +//! [`KeyringKeyStore`](crate::KeyringKeyStore). + +#[cfg(target_os = "linux")] +mod linux; + +use std::path::PathBuf; +use std::time::Duration; + +#[cfg(target_os = "linux")] +pub use linux::{Backend, KeyFile, LinuxStore}; + +use crate::ClientError; + +/// Why an OS secret store refused. +#[derive(Debug, thiserror::Error)] +pub enum SecretStoreError { + /// No durable store was reachable, and the application named none. + #[error("no durable secret store is reachable, probed {probed}")] + NoStore { + /// Every store detection tried, in order. + probed: &'static str, + }, + /// The desktop's unlock or create dialog was dismissed, or could not be shown. + #[error("the desktop keyring stayed locked: the dialog was dismissed or could not be shown")] + Dismissed, + /// Nobody answered the desktop's dialog, or the Secret portal, within the bound. + #[error("the secret store did not answer within {0:?}")] + TimedOut(Duration), + /// A wrap key does not hold exactly 32 bytes. + #[error("the wrap key {path} holds {len} bytes, not 32")] + WrapKeyLength { + /// Where the key was read from. + path: PathBuf, + /// How many bytes it held. + len: usize, + }, + /// A sealed record opens under no wrap key the store holds. + #[error("the sealed record {record} opens under no wrap key this store holds")] + Unsealable { + /// The record's file name. + record: String, + }, + /// A stored secret is not the base64 text connetto writes. + #[error("a stored secret is not base64 text")] + Encoding, + /// The backing store failed. + #[error("{0}")] + Backend(String), +} + +impl From for ClientError { + fn from(err: SecretStoreError) -> Self { + Self::SecretStore(err) + } +} + +/// One service's entries in the OS secret store, one per name. +pub(crate) struct Keyring { + service: String, + #[cfg(target_os = "linux")] + store: linux::Store, +} + +impl Keyring { + /// The detected store. + pub(crate) fn new(service: impl Into) -> Self { + Self { + service: service.into(), + #[cfg(target_os = "linux")] + store: linux::Store::detect(), + } + } + + /// The store the application named. + #[cfg(target_os = "linux")] + pub(crate) fn with_linux_store(service: impl Into, store: LinuxStore) -> Self { + Self { + service: service.into(), + store: linux::Store::named(store), + } + } + + /// Which Linux store holds these secrets. + #[cfg(target_os = "linux")] + pub(crate) async fn backend(&self) -> Result { + self.store.backend().await + } + + /// The secret stored under `name`, or `None` when none was stored. + #[cfg_attr( + not(target_os = "linux"), + expect(clippy::unused_async, reason = "only the Linux stores await") + )] + pub(crate) async fn read(&self, name: &str) -> Result, ClientError> { + #[cfg(target_os = "linux")] + { + self.store.read(&self.service, name).await + } + #[cfg(not(target_os = "linux"))] + { + platform::read(&self.service, name) + } + } + + /// Persist `secret` under `name`, replacing any prior one. + #[cfg_attr( + not(target_os = "linux"), + expect(clippy::unused_async, reason = "only the Linux stores await") + )] + pub(crate) async fn write(&self, name: &str, secret: &str) -> Result<(), ClientError> { + #[cfg(target_os = "linux")] + { + self.store.write(&self.service, name, secret).await + } + #[cfg(not(target_os = "linux"))] + { + platform::write(&self.service, name, secret) + } + } + + /// Remove the entry stored under `name`, if any. + #[cfg_attr( + not(target_os = "linux"), + expect(clippy::unused_async, reason = "only the Linux stores await") + )] + pub(crate) async fn clear(&self, name: &str) -> Result<(), ClientError> { + #[cfg(target_os = "linux")] + { + self.store.clear(&self.service, name).await + } + #[cfg(not(target_os = "linux"))] + { + platform::clear(&self.service, name) + } + } +} + +/// `keyring-core` entries through the process-wide platform store. +#[cfg(not(target_os = "linux"))] +mod platform { + use std::sync::{Arc, LazyLock}; + + use crate::ClientError; + + static STORE: LazyLock>> = + LazyLock::new(|| install_store().map_err(|err| Arc::::from(err.to_string()))); + + fn ensure_store() -> Result<(), ClientError> { + STORE + .as_ref() + .copied() + .map_err(|err| ClientError::Auth(format!("keyring setup: {err}"))) + } + + #[cfg(target_os = "macos")] + fn install_store() -> keyring_core::Result<()> { + keyring_core::set_default_store(apple_native_keyring_store::keychain::Store::new()?); + Ok(()) + } + + #[cfg(target_os = "ios")] + fn install_store() -> keyring_core::Result<()> { + keyring_core::set_default_store(apple_native_keyring_store::protected::Store::new()?); + Ok(()) + } + + #[cfg(target_os = "android")] + fn install_store() -> keyring_core::Result<()> { + keyring_core::set_default_store(android_native_keyring_store::Store::new()?); + Ok(()) + } + + #[cfg(target_os = "windows")] + fn install_store() -> keyring_core::Result<()> { + keyring_core::set_default_store(windows_native_keyring_store::Store::new()?); + Ok(()) + } + + #[cfg(not(any( + target_os = "android", + target_os = "ios", + target_os = "macos", + target_os = "windows" + )))] + fn install_store() -> keyring_core::Result<()> { + Err(keyring_core::Error::Invalid( + "platform".to_owned(), + "native auth has no keyring store for this platform".to_owned(), + )) + } + + fn entry(service: &str, name: &str) -> Result { + ensure_store()?; + keyring_core::Entry::new(service, name) + .map_err(|err| ClientError::Auth(format!("keyring open: {err}"))) + } + + pub(super) fn read(service: &str, name: &str) -> Result, ClientError> { + match entry(service, name)?.get_password() { + Ok(secret) => Ok(Some(secret)), + Err(keyring_core::Error::NoEntry) => Ok(None), + Err(err) => Err(ClientError::Auth(format!("keyring load: {err}"))), + } + } + + pub(super) fn write(service: &str, name: &str, secret: &str) -> Result<(), ClientError> { + entry(service, name)? + .set_password(secret) + .map_err(|err| ClientError::Auth(format!("keyring store: {err}"))) + } + + pub(super) fn clear(service: &str, name: &str) -> Result<(), ClientError> { + match entry(service, name)?.delete_credential() { + Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), + Err(err) => Err(ClientError::Auth(format!("keyring clear: {err}"))), + } + } +} diff --git a/crates/connetto-client/src/lib.rs b/crates/connetto-client/src/lib.rs index d8051d4c..03a0f822 100644 --- a/crates/connetto-client/src/lib.rs +++ b/crates/connetto-client/src/lib.rs @@ -72,6 +72,8 @@ mod clock; pub mod dsl; mod grant_expiry; pub mod harden; +#[cfg(feature = "native-auth")] +mod keyring; pub mod live; pub mod reconnect; pub mod replica; @@ -92,6 +94,10 @@ pub use auth::{ }; pub use cipher::{ReplicaKey, UnlockError}; pub use dsl::Watchable; +#[cfg(feature = "native-auth")] +pub use keyring::SecretStoreError; +#[cfg(all(feature = "native-auth", target_os = "linux"))] +pub use keyring::{Backend, KeyFile, LinuxStore}; pub use live::{ ConnettoClient, LiveGroups, LiveHandle, LiveQuery, LiveRows, LiveValue, subscription_is_aggregate, subscription_tables, @@ -173,6 +179,10 @@ pub enum ClientError { /// Acquiring or refreshing the access token failed. #[error("authentication error: {0}")] Auth(String), + /// An OS secret store refused. + #[cfg(feature = "native-auth")] + #[error("secret store: {0}")] + SecretStore(keyring::SecretStoreError), /// The local database exists but does not decrypt under the key given at /// connect. /// diff --git a/crates/connetto-client/src/subscriptions.rs b/crates/connetto-client/src/subscriptions.rs index 1dd928c1..3a219d83 100644 --- a/crates/connetto-client/src/subscriptions.rs +++ b/crates/connetto-client/src/subscriptions.rs @@ -153,12 +153,12 @@ fn decode_bind(kind: i32, value: Option>) -> Result Integer, + /// Note content. + body -> Nullable, + } +} + +fn stores() -> (KeyringStore, KeyringKeyStore) { + match std::env::var_os(KEY_FILE) { + Some(key) => { + let state = PathBuf::from(std::env::var_os(DIR).expect("dir")).join("state"); + let store = LinuxStore::KeyFile(KeyFile::new(key, state)); + ( + KeyringStore::with_linux_store(SERVICE, store.clone()), + KeyringKeyStore::with_linux_store(SERVICE, store), + ) + } + None if std::env::var_os(NAMED_SECRET_SERVICE).is_some() => ( + KeyringStore::with_linux_store(SERVICE, LinuxStore::SecretService), + KeyringKeyStore::with_linux_store(SERVICE, LinuxStore::SecretService), + ), + None => (KeyringStore::new(SERVICE), KeyringKeyStore::new(SERVICE)), + } +} + +fn replica_url(dir: &Path) -> String { + dir.join("replica.sqlite") + .to_str() + .expect("utf-8 path") + .to_owned() +} + +fn config() -> ClientConfig { + ClientConfig::new("r71").with_login(Some(Grant::new("user:token"))) +} + +/// One phase of a multi-process run, chosen by the environment. Run alone it does nothing. +#[tokio::test] +#[ignore = "a phase the custody tests run in a child process"] +async fn custody_phase() { + let Ok(phase) = std::env::var(PHASE) else { + return; + }; + let dir = PathBuf::from(std::env::var_os(DIR).expect("dir")); + let _keyring = connetto_test_harness::isolated_session_keyring(); + if let Some(credentials) = std::env::var_os("CREDENTIALS_DIRECTORY") { + let entries: Vec<_> = std::fs::read_dir(credentials) + .expect("list the credentials") + .collect(); + assert_eq!( + entries.len(), + 1, + "only connetto.wrap-key sits in the credentials directory" + ); + } + let (tokens, keys) = stores(); + let url = replica_url(&dir); + match phase.as_str() { + "write" => write_phase(&tokens, &keys, &url).await, + "read" => read_phase(&tokens, &keys, &url).await, + "wipe" => wipe_phase(&tokens, &keys, &url).await, + "empty" => empty_phase(&tokens, &keys).await, + "stored-as-text" => stored_as_text_phase(&tokens).await, + other => panic!("unknown phase {other}"), + } +} + +async fn write_phase(tokens: &KeyringStore, keys: &KeyringKeyStore, url: &str) { + let key = provision_replica_key(keys, "replica") + .await + .expect("provision"); + tokens.store(ACCOUNT, TOKEN).await.expect("store the token"); + let replica = Replica::encrypted_file(url, Some(key)) + .expect("key") + .with_tier(TIER_DDL); + let mut conn = + ConnettoConnection::connect(FakeTransport::accepting(), &replica, "", &config(), None) + .await + .expect("first boot"); + diesel::insert_into(notes::table) + .values((notes::id.eq(1), notes::body.eq(NOTE))) + .execute(conn.conn()) + .expect("write the tier"); +} + +async fn read_phase(tokens: &KeyringStore, keys: &KeyringKeyStore, url: &str) { + let backend = tokens.backend().await.expect("the store opens"); + assert!(backend.survives_reboot(), "{backend:?}"); + let key = keys + .load("replica") + .await + .expect("load") + .expect("the key survived, so nothing is re-minted"); + assert_eq!( + tokens.load(ACCOUNT).await.expect("load").as_deref(), + Some(TOKEN) + ); + assert_eq!(tokens.accounts().await.expect("accounts"), [ACCOUNT]); + let replica = Replica::encrypted_file(url, Some(key)) + .expect("key") + .with_existing_tier(); + let mut conn = + ConnettoConnection::connect_existing(FakeTransport::accepting(), &replica, &config(), None) + .await + .expect("the replica reopens"); + let rows: Vec> = notes::table + .select(notes::body) + .load(conn.conn()) + .expect("read the tier"); + assert_eq!(rows, [Some(NOTE.to_owned())]); +} + +async fn wipe_phase(tokens: &KeyringStore, keys: &KeyringKeyStore, url: &str) { + teardown::wipe_replica(Path::new(url), keys, "replica", &[], false) + .await + .expect("wipe"); + tokens.clear(ACCOUNT).await.expect("log out"); +} + +async fn empty_phase(tokens: &KeyringStore, keys: &KeyringKeyStore) { + assert_eq!( + keys.load("replica").await.expect("load"), + None, + "the key is gone" + ); + assert_eq!( + tokens.load(ACCOUNT).await.expect("load"), + None, + "the token is gone" + ); + assert!( + tokens.accounts().await.expect("accounts").is_empty(), + "the index is gone" + ); +} + +async fn stored_as_text_phase(tokens: &KeyringStore) { + let service = oo7::dbus::Service::new().await.expect("the Secret Service"); + let collection = service + .default_collection() + .await + .expect("the default collection"); + let items = collection + .search_items(&[("service", SERVICE), ("record", ACCOUNT)]) + .await + .expect("search"); + let secret = items + .first() + .expect("the token item") + .secret() + .await + .expect("secret"); + assert_eq!(secret.content_type(), oo7::ContentType::Text); + assert_eq!( + &*secret, b"YWxpY2UtcmVmcmVzaA==", + "the item holds base64 text" + ); + collection + .create_item( + SERVICE, + &[("service", SERVICE), ("record", "corrupt")], + oo7::Secret::text("not base64!"), + true, + None, + ) + .await + .expect("plant a value connetto never writes"); + let err = tokens + .load("corrupt") + .await + .expect_err("a value that is not base64 refuses"); + assert!( + matches!( + err, + connetto_client::ClientError::SecretStore(connetto_client::SecretStoreError::Encoding) + ), + "got {err}" + ); + tokens + .clear("corrupt") + .await + .expect("remove the planted value"); +} + +fn run_phase(phase: &str, dir: &Path, env: &[(&str, OsString)]) { + let status = Command::new(std::env::current_exe().expect("the test binary")) + .args([ + "linux_custody::custody_phase", + "--exact", + "--ignored", + "--nocapture", + ]) + .env(PHASE, phase) + .env(DIR, dir) + .env_remove("CREDENTIALS_DIRECTORY") + .env_remove("STATE_DIRECTORY") + .envs(env.iter().map(|(name, value)| (name, value))) + .status() + .expect("spawn the phase"); + assert!(status.success(), "the {phase} phase failed"); +} + +fn round_trip(dir: &Path, env: &[(&str, OsString)]) { + for phase in ["write", "read", "wipe", "empty"] { + run_phase(phase, dir, env); + } +} + +fn records(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .map(|entries| { + entries + .map(|entry| entry.expect("entry").path()) + .filter(|path| path.file_name().is_some_and(|name| name.len() == 64)) + .collect() + }) + .unwrap_or_default() +} + +#[test] +fn a_fresh_process_reads_what_another_wrote_under_the_credential() { + let dir = tempfile::tempdir().expect("tempdir"); + let credentials = dir.path().join("credentials"); + let state = dir.path().join("state"); + std::fs::create_dir_all(&credentials).expect("credentials"); + std::fs::create_dir_all(&state).expect("state"); + std::fs::write(credentials.join("connetto.wrap-key"), [4_u8; 32]).expect("credential"); + let env = [ + ( + "CREDENTIALS_DIRECTORY", + credentials.clone().into_os_string(), + ), + ("STATE_DIRECTORY", state.clone().into_os_string()), + ]; + for phase in ["write", "read"] { + run_phase(phase, dir.path(), &env); + } + let sealed = records(&state.join("connetto-secrets")); + assert_eq!(sealed.len(), 3, "the key, the token and the account index"); + for record in &sealed { + let mode = std::fs::metadata(record) + .expect("stat") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "{}", record.display()); + } + let credential_files: Vec<_> = std::fs::read_dir(&credentials).expect("list").collect(); + assert_eq!( + credential_files.len(), + 1, + "nothing is written beside the credential" + ); + for phase in ["wipe", "empty"] { + run_phase(phase, dir.path(), &env); + } + assert!( + records(&state.join("connetto-secrets")).is_empty(), + "the wipe left no record file" + ); +} + +#[test] +fn a_fresh_process_reads_what_another_wrote_under_a_named_key_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let key = dir.path().join("wrap.key"); + std::fs::write(&key, [6_u8; 32]).expect("key file"); + round_trip(dir.path(), &[(KEY_FILE, key.into_os_string())]); +} + +/// Needs a private session bus with an unlocked `gnome-keyring-daemon`, which +/// `scripts/linux-secret-store-tests.sh secret-service` sets up. +#[test] +#[ignore = "needs the private Secret Service bus scripts/linux-secret-store-tests.sh secret-service starts"] +fn a_fresh_process_reads_what_another_wrote_under_the_secret_service() { + assert!( + std::env::var_os(PRIVATE_BUS).is_some(), + "run under scripts/linux-secret-store-tests.sh, never against a desktop's own keyring" + ); + let named: [(&str, OsString); 1] = [(NAMED_SECRET_SERVICE, "1".into())]; + for env in [&[][..], &named[..]] { + let dir = tempfile::tempdir().expect("tempdir"); + for phase in ["write", "read", "stored-as-text", "wipe", "empty"] { + run_phase(phase, dir.path(), env); + } + } +} + +/// The command line `sudo -n` runs as root, or a refusal naming what the group needs. +fn sudo(args: &[&std::ffi::OsStr]) -> std::process::Output { + let output = Command::new("sudo") + .arg("-n") + .args(args) + .output() + .expect("spawn sudo"); + assert!( + output.status.success(), + "sudo {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + output +} + +/// Runs one phase in a transient system service that systemd hands the +/// encrypted wrap key and a state directory (R71 decision 7). +fn run_unit_phase(phase: &str, dir: &Path, credential: &Path, state: &str) { + let uid = String::from_utf8(Command::new("id").arg("-u").output().expect("id").stdout) + .expect("utf-8"); + let exe = std::env::current_exe().expect("the test binary"); + let args: Vec = vec![ + "systemd-run".into(), + "--wait".into(), + "--pipe".into(), + "--collect".into(), + "--quiet".into(), + format!("--uid={}", uid.trim()).into(), + format!("--property=StateDirectory={state}").into(), + { + let mut property = + OsString::from("--property=LoadCredentialEncrypted=connetto.wrap-key:"); + property.push(credential); + property + }, + format!("--setenv={PHASE}={phase}").into(), + { + let mut setenv = OsString::from(format!("--setenv={DIR}=")); + setenv.push(dir); + setenv + }, + exe.into_os_string(), + "linux_custody::custody_phase".into(), + "--exact".into(), + "--ignored".into(), + "--nocapture".into(), + ]; + let args: Vec<&std::ffi::OsStr> = args.iter().map(OsString::as_os_str).collect(); + sudo(&args); +} + +/// A transient service writes and a second one reads back, with systemd +/// decrypting the credential and creating the state directory. +#[test] +#[ignore = "needs passwordless sudo and systemd, which the CI runner has"] +fn a_second_transient_service_reads_what_the_first_wrote() { + let dir = tempfile::tempdir().expect("tempdir"); + let key = dir.path().join("wrap.key"); + std::fs::write(&key, [8_u8; 32]).expect("key"); + let credential = dir.path().join("wrap.cred"); + sudo(&[ + "systemd-creds".as_ref(), + "encrypt".as_ref(), + "--with-key=host".as_ref(), + "--name=connetto.wrap-key".as_ref(), + key.as_os_str(), + credential.as_os_str(), + ]); + std::fs::remove_file(&key).expect("only the encrypted credential stays"); + let state = format!("connetto-r71-{}", std::process::id()); + for phase in ["write", "read"] { + run_unit_phase(phase, dir.path(), &credential, &state); + } + let sealed = Path::new("/var/lib").join(&state).join("connetto-secrets"); + let records = records(&sealed); + assert_eq!(records.len(), 3, "the key, the token and the account index"); + for record in &records { + let mode = std::fs::metadata(record) + .expect("stat") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600, "{}", record.display()); + } + for phase in ["wipe", "empty"] { + run_unit_phase(phase, dir.path(), &credential, &state); + } + sudo(&[ + "rm".as_ref(), + "-rf".as_ref(), + Path::new("/var/lib").join(&state).as_os_str(), + ]); +} + +/// Runs the probe in a fresh container under Docker's default seccomp +/// profile, with the wrap key mounted read-only the way Docker mounts a secret. +fn run_container_phase(probe: &Path, phase: &str, key: &Path, state: &Path) { + let mut mount_probe = probe.as_os_str().to_owned(); + mount_probe.push(":/probe:ro"); + let mut mount_key = key.as_os_str().to_owned(); + mount_key.push(":/run/secrets/connetto-wrap-key:ro"); + let mut mount_state = state.as_os_str().to_owned(); + mount_state.push(":/state"); + let uid = String::from_utf8(Command::new("id").arg("-u").output().expect("id").stdout) + .expect("utf-8"); + let output = Command::new("docker") + .args(["run", "--rm", "--user", uid.trim(), "-v"]) + .arg(mount_probe) + .arg("-v") + .arg(mount_key) + .arg("-v") + .arg(mount_state) + .args([ + "ubuntu:24.04", + "/probe", + phase, + "/run/secrets/connetto-wrap-key", + "/state", + ]) + .output() + .expect("spawn docker"); + assert!( + output.status.success(), + "the {phase} container failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// `CONNETTO_R71_PROBE` names the built `secret_store_probe` example. +#[test] +#[ignore = "needs Docker and the secret_store_probe example, which CI builds"] +fn a_restarted_container_reads_its_keys_back_through_a_mounted_key_file() { + let probe = PathBuf::from( + std::env::var_os("CONNETTO_R71_PROBE").expect("CONNETTO_R71_PROBE names the probe"), + ); + let dir = tempfile::tempdir().expect("tempdir"); + let key = dir.path().join("wrap.key"); + std::fs::write(&key, [2_u8; 32]).expect("key"); + let state = dir.path().join("state"); + std::fs::create_dir_all(&state).expect("state"); + run_container_phase(&probe, "write", &key, &state); + run_container_phase(&probe, "read", &key, &state); + + let short = dir.path().join("short.key"); + std::fs::write(&short, [2_u8; 31]).expect("short key"); + let mut mount_probe = probe.into_os_string(); + mount_probe.push(":/probe:ro"); + let mut mount_short = short.into_os_string(); + mount_short.push(":/run/secrets/connetto-wrap-key:ro"); + let refused = Command::new("docker") + .args(["run", "--rm", "-v"]) + .arg(mount_probe) + .arg("-v") + .arg(mount_short) + .args([ + "ubuntu:24.04", + "/probe", + "read", + "/run/secrets/connetto-wrap-key", + "/tmp/state", + ]) + .output() + .expect("spawn docker"); + assert!(!refused.status.success(), "a 31-byte key is refused"); + assert!( + String::from_utf8_lossy(&refused.stderr).contains("31 bytes"), + "the refusal names the length: {}", + String::from_utf8_lossy(&refused.stderr) + ); +} diff --git a/crates/connetto-client/tests/it/main.rs b/crates/connetto-client/tests/it/main.rs index d6b4a51f..d4c5e406 100644 --- a/crates/connetto-client/tests/it/main.rs +++ b/crates/connetto-client/tests/it/main.rs @@ -25,6 +25,9 @@ mod key_requirement; mod live_dispatch; +#[cfg(all(feature = "native-auth", target_os = "linux"))] +mod linux_custody; + mod local_export; mod local_import; diff --git a/crates/connetto-client/tests/it/native_auth.rs b/crates/connetto-client/tests/it/native_auth.rs index 439ef09d..3dc11f3d 100644 --- a/crates/connetto-client/tests/it/native_auth.rs +++ b/crates/connetto-client/tests/it/native_auth.rs @@ -232,6 +232,7 @@ async fn native_login_refreshes_and_silently_reacquires() { let encoded_account = encode_identity(&login.user_id).expect("encode account"); let first_refresh = store .load(&encoded_account) + .await .expect("load") .expect("refresh stored"); @@ -255,6 +256,7 @@ async fn native_login_refreshes_and_silently_reacquires() { assert_eq!(refreshed.user_id, login.user_id, "identity is continuous"); let second_refresh = store .load(&encoded_account) + .await .expect("load") .expect("refresh stored"); assert_ne!(first_refresh, second_refresh, "refresh token rotated"); @@ -299,7 +301,11 @@ async fn a_first_login_refreshes_for_its_own_reconnect() { ); let login = authenticator.login::().await.expect("login"); let account = encode_identity(&login.user_id).expect("encode account"); - let issued = store.load(&account).expect("load").expect("refresh stored"); + let issued = store + .load(&account) + .await + .expect("load") + .expect("refresh stored"); let token = authenticator .token_source() @@ -307,7 +313,11 @@ async fn a_first_login_refreshes_for_its_own_reconnect() { .await .expect("the token source refreshes for the account the login revealed"); assert!(!token.is_empty(), "a fresh access token"); - let rotated = store.load(&account).expect("load").expect("refresh stored"); + let rotated = store + .load(&account) + .await + .expect("load") + .expect("refresh stored"); assert_ne!(issued, rotated, "the refresh rotated that account's token"); } @@ -432,6 +442,7 @@ async fn a_logout_revokes_the_session_and_clears_the_local_credential() { let encoded_account = encode_identity(&login.user_id).expect("encode account"); let refresh = store .load(&encoded_account) + .await .expect("load") .expect("the refresh token is stored"); @@ -456,7 +467,7 @@ async fn a_logout_revokes_the_session_and_clears_the_local_credential() { // Local state is gone, so nothing on this device can silently reacquire. assert_eq!( - store.load(&encoded_account).expect("load"), + store.load(&encoded_account).await.expect("load"), None, "the refresh token is cleared", ); @@ -477,6 +488,7 @@ async fn a_logout_revokes_the_session_and_clears_the_local_credential() { // cannot be resurrected into a fresh access token. let kept = MemoryRefreshStore::default(); kept.store(&encoded_account, &refresh) + .await .expect("seed the copy"); let resurrect = NativeAuthenticator::new( base, @@ -516,7 +528,7 @@ async fn a_first_login_logout_revokes_its_own_session() { authenticator.logout().await.expect("logout"); assert_eq!( - store.load(&account).expect("load"), + store.load(&account).await.expect("load"), None, "the refresh token is cleared" ); @@ -541,6 +553,7 @@ async fn an_offline_logout_still_clears_local_state_and_says_the_revoke_failed() let store: SharedRefresh = Arc::new(MemoryRefreshStore::default()); store .store(&encoded_account, "session-id.secret") + .await .expect("seed a credential"); // Port 1 is reserved and nothing listens there, which is this test's stand-in // for a device with no connectivity. @@ -557,29 +570,32 @@ async fn an_offline_logout_still_clears_local_state_and_says_the_revoke_failed() Ok(()) => panic!("an unreachable server must not report a successful revoke"), } assert_eq!( - store.load(&encoded_account).expect("load"), + store.load(&encoded_account).await.expect("load"), None, "the credential is cleared even when the revoke never landed", ); } -#[test] -fn memory_refresh_store_round_trips() { +#[tokio::test] +async fn memory_refresh_store_round_trips() { let store = MemoryRefreshStore::default(); - assert!(store.load("any-key").unwrap().is_none(), "empty at first"); - store.store("any-key", "refresh-abc").unwrap(); + assert!( + store.load("any-key").await.unwrap().is_none(), + "empty at first" + ); + store.store("any-key", "refresh-abc").await.unwrap(); assert_eq!( - store.load("any-key").unwrap().as_deref(), + store.load("any-key").await.unwrap().as_deref(), Some("refresh-abc") ); - store.store("any-key", "refresh-def").unwrap(); + store.store("any-key", "refresh-def").await.unwrap(); assert_eq!( - store.load("any-key").unwrap().as_deref(), + store.load("any-key").await.unwrap().as_deref(), Some("refresh-def"), "replaces" ); - store.clear("any-key").unwrap(); - assert!(store.load("any-key").unwrap().is_none(), "cleared"); + store.clear("any-key").await.unwrap(); + assert!(store.load("any-key").await.unwrap().is_none(), "cleared"); } /// The redirect a mobile build registers with its operating system. @@ -707,16 +723,16 @@ async fn a_claimed_redirect_login_completes_through_the_apps_session() { let account = encode_identity(&login.user_id).expect("encode account"); assert!( - store.load(&account).expect("load").is_some(), + store.load(&account).await.expect("load").is_some(), "the refresh token is stored for the account the login revealed" ); assert_eq!( - store.accounts().expect("accounts"), + store.accounts().await.expect("accounts"), vec![account], "a finished login lists only the account" ); assert_eq!( - store.load("connetto-pending-login").expect("load"), + store.load("connetto-pending-login").await.expect("load"), None, "a finished login leaves no pending record" ); @@ -781,12 +797,12 @@ async fn a_login_finishes_in_the_process_its_redirect_restarts() { ); let account = encode_identity(&login.user_id).expect("encode account"); assert_eq!( - store.accounts().expect("accounts"), + store.accounts().await.expect("accounts"), vec![account], "the resumed login lists only the account" ); assert_eq!( - store.load("connetto-pending-login").expect("load"), + store.load("connetto-pending-login").await.expect("load"), None, "the pending record is gone once the login finishes" ); @@ -854,7 +870,7 @@ async fn a_restarted_process_starts_over_when_the_resumed_code_is_refused() { "the refused code is dropped and one new tab opens" ); assert_eq!( - store.accounts().expect("accounts"), + store.accounts().await.expect("accounts"), vec![encode_identity(&login.user_id).expect("encode account")], "the new login's account is the only record listed" ); @@ -874,7 +890,7 @@ async fn a_restarted_process_without_a_redirect_starts_over() { let _ = tokio::time::timeout(std::time::Duration::from_secs(10), first.login::()).await; drop(first); assert!( - store.accounts().expect("accounts").is_empty(), + store.accounts().await.expect("accounts").is_empty(), "a pending login is never listed as an account" ); @@ -882,7 +898,7 @@ async fn a_restarted_process_without_a_redirect_starts_over() { let second = claimed(&base, &store, Arc::new(restarted)); let login = second.login::().await.expect("a fresh login"); assert_eq!( - store.accounts().expect("accounts"), + store.accounts().await.expect("accounts"), vec![encode_identity(&login.user_id).expect("encode account")], "the new login's account is the only record listed" ); diff --git a/crates/connetto-client/tests/it/secret_stores.rs b/crates/connetto-client/tests/it/secret_stores.rs index a883a166..0f8aa5ba 100644 --- a/crates/connetto-client/tests/it/secret_stores.rs +++ b/crates/connetto-client/tests/it/secret_stores.rs @@ -2,10 +2,7 @@ //! //! What this buys over the per-store suites next to it is the caller. Both //! exercises are written in `connetto_core::test_support` against the traits -//! alone and know nothing about a keyring, an `IndexedDB` database, or an -//! encrypted `SQLite` file. `connetto-web`'s `secret_stores.rs` runs the same -//! two functions against the browser stores, so the seam is proven by one -//! caller working on both targets rather than by the rename. +//! alone and know nothing about a keyring. use connetto_client::{IDENTITY_RECORD, MemoryKeyStore, MemoryRefreshStore}; use connetto_core::test_support::{ @@ -13,19 +10,36 @@ use connetto_core::test_support::{ two_accounts_keep_their_own_token, }; -#[test] -fn the_in_memory_refresh_store_keeps_two_accounts_apart() { - two_accounts_keep_their_own_token(&MemoryRefreshStore::default(), "alice", "bob"); +#[tokio::test] +async fn the_in_memory_refresh_store_keeps_two_accounts_apart() { + two_accounts_keep_their_own_token(&MemoryRefreshStore::default(), "alice", "bob").await; } /// R42: the account list the picker is built on, against the enumerable store. -#[test] -fn the_in_memory_refresh_store_lists_every_account_it_holds() { +#[tokio::test] +async fn the_in_memory_refresh_store_lists_every_account_it_holds() { every_stored_account_is_listed( &MemoryRefreshStore::default(), "alice", "bob", IDENTITY_RECORD, + ) + .await; +} + +#[tokio::test] +async fn the_remembered_account_is_the_identity_record() { + use connetto_client::auth::remembered_account; + use connetto_core::traits::RefreshTokenStore as _; + let store = MemoryRefreshStore::default(); + assert_eq!(remembered_account(&store).await.expect("read"), None); + store + .store(IDENTITY_RECORD, "\"alice\"") + .await + .expect("remember alice"); + assert_eq!( + remembered_account(&store).await.expect("read").as_deref(), + Some("\"alice\"") ); } @@ -34,31 +48,99 @@ async fn the_in_memory_key_store_keeps_two_accounts_apart() { two_accounts_keep_their_own_key(&MemoryKeyStore::default(), "alice", "bob").await; } -/// The production stores use names unique to this process, so reruns do not collide. -#[test] -fn the_keyring_refresh_store_keeps_two_accounts_apart() { - use connetto_client::KeyringStore; - let _keyring = connetto_test_harness::isolated_session_keyring(); +/// The Linux keyring stores, each named explicitly so no test writes into the +/// desktop's own keyring (R71 decision 7). +#[cfg(target_os = "linux")] +mod linux { + use connetto_client::{ + Backend, IDENTITY_RECORD, KeyFile, KeyringKeyStore, KeyringStore, LinuxStore, + }; + use connetto_core::test_support::{ + every_stored_account_is_listed, two_accounts_keep_their_own_key, + two_accounts_keep_their_own_token, + }; - let service = format!("connetto-r41-refresh-{}", std::process::id()); - two_accounts_keep_their_own_token(&KeyringStore::new(service), "alice", "bob"); -} - -/// R42: the same property against the store that cannot be enumerated. -#[test] -fn the_keyring_refresh_store_lists_every_account_it_holds() { - use connetto_client::KeyringStore; - let _keyring = connetto_test_harness::isolated_session_keyring(); + /// Every durable store and keyutils, each under a service unique to this process. + fn stores(dir: &std::path::Path) -> Vec<(&'static str, LinuxStore)> { + let key = dir.join("wrap.key"); + std::fs::write(&key, [3_u8; 32]).expect("write a wrap key"); + vec![ + ("keyutils", LinuxStore::Keyutils), + ( + "key-file", + LinuxStore::KeyFile(KeyFile::new(key, dir.join("state"))), + ), + ] + } - let service = format!("connetto-r42-refresh-{}", std::process::id()); - every_stored_account_is_listed(&KeyringStore::new(service), "alice", "bob", IDENTITY_RECORD); -} + #[tokio::test] + async fn every_store_keeps_two_accounts_apart_and_lists_them() { + let _keyring = connetto_test_harness::isolated_session_keyring(); + let dir = tempfile::tempdir().expect("tempdir"); + for (label, store) in stores(dir.path()) { + let service = format!("connetto-r71-{label}-{}", std::process::id()); + let tokens = KeyringStore::with_linux_store(&service, store.clone()); + two_accounts_keep_their_own_token(&tokens, "alice", "bob").await; + every_stored_account_is_listed(&tokens, "alice", "bob", IDENTITY_RECORD).await; + let keys = KeyringKeyStore::with_linux_store(&service, store); + two_accounts_keep_their_own_key(&keys, "alice", "bob").await; + } + } -#[tokio::test] -async fn the_keyring_key_store_keeps_two_accounts_apart() { - use connetto_client::KeyringKeyStore; - let _keyring = connetto_test_harness::isolated_session_keyring(); + #[tokio::test] + async fn the_report_names_the_store_and_whether_it_survives_a_reboot() { + let _keyring = connetto_test_harness::isolated_session_keyring(); + let dir = tempfile::tempdir().expect("tempdir"); + let mut reports = Vec::new(); + for (label, store) in stores(dir.path()) { + let tokens = KeyringStore::with_linux_store( + format!("connetto-r71-report-{label}"), + store.clone(), + ); + let report = tokens.backend().await.expect("the store opens"); + let keys = + KeyringKeyStore::with_linux_store(format!("connetto-r71-report-{label}"), store); + assert_eq!( + keys.backend().await.expect("the key store opens"), + report, + "both stores report alike" + ); + reports.push(report); + } + assert_eq!( + reports, + [ + Backend::Keyutils, + Backend::KeyFile { + previous_key_needed: false + } + ] + ); + assert!( + !reports[0].survives_reboot(), + "keyutils says it is lost at reboot" + ); + assert!(reports[1].survives_reboot()); + } - let service = format!("connetto-r41-keys-{}", std::process::id()); - two_accounts_keep_their_own_key(&KeyringKeyStore::new(service), "alice", "bob").await; + #[tokio::test] + async fn a_named_key_file_of_the_wrong_length_refuses() { + let dir = tempfile::tempdir().expect("tempdir"); + let key = dir.path().join("short.key"); + std::fs::write(&key, [3_u8; 16]).expect("write a short key"); + let tokens = KeyringStore::with_linux_store( + "connetto-r71-short", + LinuxStore::KeyFile(KeyFile::new(key, dir.path().join("state"))), + ); + let err = tokens.backend().await.expect_err("a 16-byte key refuses"); + assert!( + matches!( + err, + connetto_client::ClientError::SecretStore( + connetto_client::SecretStoreError::WrapKeyLength { len: 16, .. } + ) + ), + "got {err}" + ); + } } diff --git a/crates/connetto-client/tests/it/teardown.rs b/crates/connetto-client/tests/it/teardown.rs index df92bd66..28c001b5 100644 --- a/crates/connetto-client/tests/it/teardown.rs +++ b/crates/connetto-client/tests/it/teardown.rs @@ -253,9 +253,9 @@ async fn a_purge_removes_the_content_directory_but_keeps_the_key() { /// once the guard drops it is gone again, so a passing or panicking keyring test /// frees its keys rather than leaking one per run against the per-user quota. #[cfg(target_os = "linux")] -#[test] -fn the_session_guard_leaves_the_persistent_keyring_as_it_found_it() { - use connetto_client::KeyringStore; +#[tokio::test] +async fn the_session_guard_leaves_the_persistent_keyring_as_it_found_it() { + use connetto_client::{KeyringStore, LinuxStore}; use connetto_core::traits::RefreshTokenStore as _; let service = format!("connetto-guard-{}", std::process::id()); @@ -265,9 +265,10 @@ fn the_session_guard_leaves_the_persistent_keyring_as_it_found_it() { ); { let _keyring = connetto_test_harness::isolated_session_keyring(); - let store = KeyringStore::new(&service); + let store = KeyringStore::with_linux_store(&service, LinuxStore::Keyutils); store .store("\"alice\"", "token") + .await .expect("store one account under the guard"); assert!( connetto_test_harness::persistent_keyring_holds_service(&service), @@ -422,6 +423,7 @@ async fn forget_device_checks_the_guard_before_it_touches_the_credential() { let alice_account = encode_identity("alice").expect("encode alice account"); refresh .store(&alice_account, "session-id.secret") + .await .expect("seed a credential"); // Port 1 is reserved and nothing listens there. The revoke can therefore // never land, which is deliberate: the guard must refuse before the request @@ -439,7 +441,7 @@ async fn forget_device_checks_the_guard_before_it_touches_the_credential() { Ok(()) => panic!("forget_device must not silently drop queued writes"), } assert_eq!( - refresh.load(&alice_account).expect("load").as_deref(), + refresh.load(&alice_account).await.expect("load").as_deref(), Some("session-id.secret"), "the credential is intact, so the queued writes can still be uploaded" ); diff --git a/crates/connetto-core/src/lib.rs b/crates/connetto-core/src/lib.rs index c12c69a5..0867c8a1 100644 --- a/crates/connetto-core/src/lib.rs +++ b/crates/connetto-core/src/lib.rs @@ -71,7 +71,8 @@ pub use session_id::{SessionId, SessionIdParseError}; pub use sql::quote_ident; pub use traits::{ ContentTicketSigner, GrantCheckFuture, GrantRefused, HandleError, HandshakeAuthority, - IncomingFrame, PendingMutation, RefreshTokenStore, ReplicaKeyStore, Store, Transport, + IncomingFrame, PendingMutation, RefreshFuture, RefreshTokenStore, ReplicaKeyStore, Store, + Transport, }; #[cfg(feature = "loopback")] pub use transport::{LoopbackError, LoopbackTransport, loopback}; diff --git a/crates/connetto-core/src/test_support.rs b/crates/connetto-core/src/test_support.rs index 3a62ef8d..2eff942d 100644 --- a/crates/connetto-core/src/test_support.rs +++ b/crates/connetto-core/src/test_support.rs @@ -300,8 +300,7 @@ impl Transport for FakeTransport { /// alone. /// /// One caller written against the trait rather than against any store, run -/// against every implementation on both targets. That is the property the seam -/// exists to buy, and the reason this lives here rather than in either suite. +/// against every implementation. That is the property the seam exists to buy. /// /// Both records are cleared on the way in and on the way out, so a durable /// store survives a rerun. @@ -309,48 +308,55 @@ impl Transport for FakeTransport { /// # Panics /// /// If either account reads back anything but its own token. -pub fn two_accounts_keep_their_own_token( +pub async fn two_accounts_keep_their_own_token( store: &S, alice: &str, bob: &str, ) { assert_ne!(alice, bob, "the two accounts must differ"); - store.clear(alice).expect("clear alice"); - store.clear(bob).expect("clear bob"); + store.clear(alice).await.expect("clear alice"); + store.clear(bob).await.expect("clear bob"); - assert_eq!(store.load(alice).expect("load alice"), None, "starts empty"); + assert_eq!( + store.load(alice).await.expect("load alice"), + None, + "starts empty" + ); - store.store(alice, "alice-refresh").expect("store alice"); + store + .store(alice, "alice-refresh") + .await + .expect("store alice"); assert_eq!( - store.load(bob).expect("load bob"), + store.load(bob).await.expect("load bob"), None, "alice's write did not reach bob" ); - store.store(bob, "bob-refresh").expect("store bob"); + store.store(bob, "bob-refresh").await.expect("store bob"); assert_eq!( - store.load(alice).expect("load alice").as_deref(), + store.load(alice).await.expect("load alice").as_deref(), Some("alice-refresh"), "alice reads her own token back" ); assert_eq!( - store.load(bob).expect("load bob").as_deref(), + store.load(bob).await.expect("load bob").as_deref(), Some("bob-refresh"), "and bob his" ); - store.clear(alice).expect("clear alice"); + store.clear(alice).await.expect("clear alice"); assert_eq!( - store.load(alice).expect("load alice"), + store.load(alice).await.expect("load alice"), None, "the clear removed alice" ); assert_eq!( - store.load(bob).expect("load bob").as_deref(), + store.load(bob).await.expect("load bob").as_deref(), Some("bob-refresh"), "and left bob alone" ); - store.clear(bob).expect("clear bob"); + store.clear(bob).await.expect("clear bob"); } /// Every stored account is listed, and connetto's own records are not, driven @@ -358,10 +364,7 @@ pub fn two_accounts_keep_their_own_token( /// alone. /// /// The sibling of [`two_accounts_keep_their_own_token`] and the same doctrine: -/// one caller written against the trait, run against every implementation on both -/// targets, because the two answer it by different means. The browser reads the -/// rows the tokens live in, and the native store reads an index it maintains, -/// since `keyring` exposes no enumeration on any backend. +/// one caller written against the trait, run against every implementation. /// /// `reserved` is one of connetto's own record names, which the caller supplies /// because the core does not define them. Writing it and finding it absent from @@ -375,7 +378,7 @@ pub fn two_accounts_keep_their_own_token( /// /// If a stored account is missing from the list, a cleared one survives in it, or /// a reserved record appears in it. -pub fn every_stored_account_is_listed( +pub async fn every_stored_account_is_listed( store: &S, alice: &str, bob: &str, @@ -383,25 +386,28 @@ pub fn every_stored_account_is_listed( ) { assert_ne!(alice, bob, "the two accounts must differ"); for name in [alice, bob, reserved] { - store.clear(name).expect("clear"); + store.clear(name).await.expect("clear"); } - let listed = store.accounts().expect("list an empty store"); + let listed = store.accounts().await.expect("list an empty store"); assert!( !listed.contains(&alice.to_owned()) && !listed.contains(&bob.to_owned()), "an empty store offers neither account, got {listed:?}" ); - store.store(alice, "alice-refresh").expect("store alice"); - let listed = store.accounts().expect("list one account"); + store + .store(alice, "alice-refresh") + .await + .expect("store alice"); + let listed = store.accounts().await.expect("list one account"); assert!(listed.contains(&alice.to_owned()), "alice is listed"); assert!( !listed.contains(&bob.to_owned()), "bob is not, having stored nothing" ); - store.store(bob, "bob-refresh").expect("store bob"); - let listed = store.accounts().expect("list two accounts"); + store.store(bob, "bob-refresh").await.expect("store bob"); + let listed = store.accounts().await.expect("list two accounts"); assert!( listed.contains(&alice.to_owned()) && listed.contains(&bob.to_owned()), "both accounts are signed in at once, got {listed:?}" @@ -409,8 +415,9 @@ pub fn every_stored_account_is_listed( store .store(reserved, "not-an-account") + .await .expect("store the reserved record"); - let listed = store.accounts().expect("list past a reserved record"); + let listed = store.accounts().await.expect("list past a reserved record"); assert!( !listed.contains(&reserved.to_owned()), "connetto's own record is not somebody to sign in as, got {listed:?}" @@ -420,8 +427,8 @@ pub fn every_stored_account_is_listed( "and it hid neither account, got {listed:?}" ); - store.clear(alice).expect("clear alice"); - let listed = store.accounts().expect("list after a clear"); + store.clear(alice).await.expect("clear alice"); + let listed = store.accounts().await.expect("list after a clear"); assert!( !listed.contains(&alice.to_owned()), "a signed-out account is no longer offered, got {listed:?}" @@ -432,7 +439,7 @@ pub fn every_stored_account_is_listed( ); for name in [bob, reserved] { - store.clear(name).expect("clear"); + store.clear(name).await.expect("clear"); } } diff --git a/crates/connetto-core/src/traits.rs b/crates/connetto-core/src/traits.rs index 0e7194ce..1ae5d731 100644 --- a/crates/connetto-core/src/traits.rs +++ b/crates/connetto-core/src/traits.rs @@ -148,17 +148,24 @@ pub trait Store { async fn set_session_token(&mut self, token: String) -> Result<(), Self::Error>; } +/// What every [`RefreshTokenStore`] method resolves to. +/// +/// Boxed so that `dyn RefreshTokenStore` stays usable, which the native +/// authenticator holds. +pub type RefreshFuture<'a, T, E> = + core::pin::Pin> + Send + 'a>>; + /// Where a device persists its rotating refresh token between runs. /// -/// Both targets implement this: an OS keyring natively, an encrypted `SQLite` -/// database in the browser. Neither needs to await, so this stays synchronous -/// while [`ReplicaKeyStore`] does not. +/// It awaits because the Linux desktop store is the Secret Service, reached only +/// over D-Bus, and a caller on a runtime worker must not hold that worker for a +/// round trip or for an unlock dialog (R71 decision 15). /// /// Every accessor names the account whose token it addresses. The store itself is -/// therefore not scoped to anybody, which is what the browser bootstrap -/// requires: the refresh token is what reveals the account, so something has to -/// be readable before any account is known, and a store constructed for an -/// account would have nobody to construct it for. +/// therefore not scoped to anybody, which is what a bootstrap requires: the +/// refresh token is what reveals the account, so something has to be readable +/// before any account is known, and a store constructed for an account would +/// have nobody to construct it for. pub trait RefreshTokenStore { /// Store-specific error. type Error: core::fmt::Debug + core::fmt::Display + Send + Sync + 'static; @@ -168,21 +175,21 @@ pub trait RefreshTokenStore { /// # Errors /// /// [`Self::Error`] if the backing store cannot be read. - fn load(&self, account: &str) -> Result, Self::Error>; + fn load<'a>(&'a self, account: &'a str) -> RefreshFuture<'a, Option, Self::Error>; /// Persist `token` for `account`, replacing any prior one. /// /// # Errors /// /// [`Self::Error`] if the backing store cannot be written. - fn store(&self, account: &str, token: &str) -> Result<(), Self::Error>; + fn store<'a>(&'a self, account: &'a str, token: &'a str) -> RefreshFuture<'a, (), Self::Error>; /// Remove the token stored for `account`, if any. /// /// # Errors /// /// [`Self::Error`] if the backing store cannot be cleared. - fn clear(&self, account: &str) -> Result<(), Self::Error>; + fn clear<'a>(&'a self, account: &'a str) -> RefreshFuture<'a, (), Self::Error>; /// Every account this store holds a token for, in unspecified order. /// @@ -191,13 +198,9 @@ pub trait RefreshTokenStore { /// account key can collide with one, because an account key is a serialized /// id and a reserved name is not valid JSON. /// - /// The two targets answer differently because only one of them can. The - /// browser reads the rows the tokens themselves live in, so its answer - /// cannot disagree with what is stored. `keyring` 3.6.3 exposes no - /// enumeration on any of its backends, so the native store maintains an - /// index record and answers from that, which an out-of-band keychain edit - /// can leave stale. A stale entry costs an interactive login, never a wrong - /// identity. + /// No OS keyring backend enumerates, so the native store maintains an index + /// record and answers from that, which an out-of-band keychain edit can leave + /// stale. A stale entry costs an interactive login, never a wrong identity. /// /// Order carries no meaning, so a caller wanting the boot default reads the /// last-used marker rather than taking the first entry. @@ -205,7 +208,7 @@ pub trait RefreshTokenStore { /// # Errors /// /// [`Self::Error`] if the backing store cannot be read. - fn accounts(&self) -> Result, Self::Error>; + fn accounts(&self) -> RefreshFuture<'_, Vec, Self::Error>; } /// Where a device caches the per-replica encryption keys it minted. diff --git a/crates/connetto-server/src/abuse.rs b/crates/connetto-server/src/abuse.rs index 579a0922..191a313c 100644 --- a/crates/connetto-server/src/abuse.rs +++ b/crates/connetto-server/src/abuse.rs @@ -22,7 +22,7 @@ use connetto_core::SessionId; use crate::throttle::Limit; /// One day, the window every per-person default uses. -const DAY: Duration = Duration::from_secs(24 * 60 * 60); +const DAY: Duration = Duration::from_hours(24); /// One act of naming something precise and being told no. /// diff --git a/crates/connetto-server/src/authn/token.rs b/crates/connetto-server/src/authn/token.rs index c1f701cb..6bfa8eb7 100644 --- a/crates/connetto-server/src/authn/token.rs +++ b/crates/connetto-server/src/authn/token.rs @@ -21,18 +21,18 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned}; /// Default access-token lifetime. Short by design: a re-auth cadence, not the /// revocation bound. -const DEFAULT_ACCESS_TTL: Duration = Duration::from_secs(15 * 60); +const DEFAULT_ACCESS_TTL: Duration = Duration::from_mins(15); /// Default sliding refresh window, extended on each successful online refresh. -const DEFAULT_REFRESH_IDLE_WINDOW: Duration = Duration::from_secs(14 * 24 * 60 * 60); +const DEFAULT_REFRESH_IDLE_WINDOW: Duration = Duration::from_hours(336); /// Default absolute refresh ceiling, a hard maximum regardless of use. -const DEFAULT_REFRESH_ABSOLUTE_CEILING: Duration = Duration::from_secs(90 * 24 * 60 * 60); +const DEFAULT_REFRESH_ABSOLUTE_CEILING: Duration = Duration::from_hours(2160); /// Default window in which a caller with no identity may keep resuming. -const DEFAULT_RESUME_TTL: Duration = Duration::from_secs(14 * 24 * 60 * 60); +const DEFAULT_RESUME_TTL: Duration = Duration::from_hours(336); /// Default share-key lifetime. A week is the ordinary span of a share link. -const DEFAULT_CAPABILITY_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const DEFAULT_CAPABILITY_TTL: Duration = Duration::from_hours(168); /// Default ceiling on a share-key lifetime. It is what makes "a share key must /// expire" something the server enforces rather than advice. -const DEFAULT_CAPABILITY_MAX_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60); +const DEFAULT_CAPABILITY_MAX_TTL: Duration = Duration::from_hours(720); /// Server-side authentication configuration: token identity and lifetimes. /// diff --git a/crates/connetto-server/src/oplog.rs b/crates/connetto-server/src/oplog.rs index 48006cfd..d139a0c6 100644 --- a/crates/connetto-server/src/oplog.rs +++ b/crates/connetto-server/src/oplog.rs @@ -46,7 +46,7 @@ use subql::{ClockHandle, EventKind, PgChangeEvent, PgCommit, PgCommitPosition, P use crate::fence::Unseen; /// Default retention age: 72 hours (`06-reconnect.md` line 69). -const DEFAULT_MAX_AGE: Duration = Duration::from_secs(72 * 60 * 60); +const DEFAULT_MAX_AGE: Duration = Duration::from_hours(72); /// Default retention count: one million entries (`06-reconnect.md` line 69). const DEFAULT_MAX_ENTRIES: usize = 1_000_000; diff --git a/crates/connetto-server/tests/it/e2e.rs b/crates/connetto-server/tests/it/e2e.rs index 6f1eb359..f1f0333d 100644 --- a/crates/connetto-server/tests/it/e2e.rs +++ b/crates/connetto-server/tests/it/e2e.rs @@ -99,21 +99,39 @@ pub(super) fn client_bin() -> PathBuf { } /// Kills its child on drop so a panicking assertion never leaks a process. -pub(super) struct ChildGuard(Option); +pub(super) struct ChildGuard { + child: Option, + terminate_first: bool, +} + +/// How long a child asked to terminate may take before it is killed. +const TERMINATE_GRACE: Duration = Duration::from_secs(5); impl ChildGuard { pub(super) fn new(child: Child) -> Self { - Self(Some(child)) + Self { + child: Some(child), + terminate_first: false, + } + } + + /// A guard that sends SIGTERM and waits before it kills, so an + /// instrumented child exits normally and writes its coverage. + pub(super) fn terminating(child: Child) -> Self { + Self { + child: Some(child), + terminate_first: true, + } } /// The child's exit status, if it has exited. fn exited(&mut self) -> Option { - self.0.as_mut()?.try_wait().ok().flatten() + self.child.as_mut()?.try_wait().ok().flatten() } /// Kill the child and collect what it wrote to its piped streams. pub(super) async fn kill_and_collect(mut self) -> std::process::Output { - let mut child = self.0.take().expect("a child the guard still holds"); + let mut child = self.child.take().expect("a child the guard still holds"); child.kill().expect("kill the child"); tokio::task::spawn_blocking(move || child.wait_with_output()) .await @@ -124,10 +142,25 @@ impl ChildGuard { impl Drop for ChildGuard { fn drop(&mut self) { - if let Some(child) = self.0.as_mut() { - let _ = child.kill(); - let _ = child.wait(); + let Some(child) = self.child.as_mut() else { + return; + }; + if self.terminate_first + && Command::new("kill") + .args(["-TERM", &child.id().to_string()]) + .status() + .is_ok_and(|status| status.success()) + { + let deadline = Instant::now() + TERMINATE_GRACE; + while Instant::now() < deadline { + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } } + let _ = child.kill(); + let _ = child.wait(); } } @@ -518,6 +551,7 @@ pub(super) fn spawn_client_env( let mut command = Command::new(client_bin()); command .env("CONNETTO_SERVER", ws) + .env("CONNETTO_KEY_STORE", "keyutils") .env("CONNETTO_DB", db_path) .env("CONNETTO_SQLITE_DDL", sqlite_ddl) // The client hashes the SAME canonical source the server does, so the @@ -538,7 +572,7 @@ pub(super) fn spawn_client_env( command.env_remove("CONNETTO_WRITE"); } let child = command.spawn().expect("spawn client"); - ChildGuard::new(child) + ChildGuard::terminating(child) } /// Run a single DDL/DML statement in its own transaction (autocommit). diff --git a/crates/connetto-server/tests/it/reconnect.rs b/crates/connetto-server/tests/it/reconnect.rs index a75b9332..c436cdd0 100644 --- a/crates/connetto-server/tests/it/reconnect.rs +++ b/crates/connetto-server/tests/it/reconnect.rs @@ -393,7 +393,7 @@ async fn cursor_outside_window_forces_full_resync() { let oplog = InMemoryOplog::new( OplogConfig::new() .with_max_entries(2) - .with_max_age(Duration::from_secs(72 * 60 * 60)), + .with_max_age(Duration::from_hours(72)), ); let manager = SessionManager::with_oplog( materializer, diff --git a/crates/connetto-server/tests/it/snapshot_nonfatal.rs b/crates/connetto-server/tests/it/snapshot_nonfatal.rs index 13d1bc81..b11aa9eb 100644 --- a/crates/connetto-server/tests/it/snapshot_nonfatal.rs +++ b/crates/connetto-server/tests/it/snapshot_nonfatal.rs @@ -362,7 +362,7 @@ async fn a_resuming_refusal_is_as_bare_as_a_fresh_one() { let oplog = InMemoryOplog::new( OplogConfig::new() .with_max_entries(2) - .with_max_age(Duration::from_secs(72 * 60 * 60)), + .with_max_age(Duration::from_hours(72)), ); let manager = SessionManager::with_oplog( materializer, diff --git a/crates/connetto-test-harness/src/lib.rs b/crates/connetto-test-harness/src/lib.rs index e4689857..4749e412 100644 --- a/crates/connetto-test-harness/src/lib.rs +++ b/crates/connetto-test-harness/src/lib.rs @@ -157,7 +157,7 @@ const STARTED_LABEL: &str = "io.connetto.harness.started"; /// How old a labelled container must be before the sweep removes it. Longer /// than any test by a wide margin, because a sibling test process owns /// containers this process must not touch. -const STALE_AFTER: Duration = Duration::from_secs(2 * 60 * 60); +const STALE_AFTER: Duration = Duration::from_hours(2); /// How long a container has to pass its health probe. /// diff --git a/docs/architecture/14-at-rest-encryption.md b/docs/architecture/14-at-rest-encryption.md index 6f9406b7..193b1b43 100644 --- a/docs/architecture/14-at-rest-encryption.md +++ b/docs/architecture/14-at-rest-encryption.md @@ -41,14 +41,14 @@ The key survives logout deliberately. It is scoped per device rather than per se | Secret | Trait | Native | Browser | |---|---|---|---| -| refresh token | `connetto_core::traits::RefreshTokenStore`, synchronous | `KeyringStore`, `MemoryRefreshStore` | none after R90, the credential is an `HttpOnly` cookie (`11-authentication.md`) | +| refresh token | `connetto_core::traits::RefreshTokenStore`, awaiting through boxed futures | `KeyringStore`, `MemoryRefreshStore` | none after R90, the credential is an `HttpOnly` cookie (`11-authentication.md`) | | replica keys | `connetto_core::traits::ReplicaKeyStore`, awaiting | `KeyringKeyStore`, `MemoryKeyStore` | `IdbKeyStore` | Each trait carries an associated `Error`, following `connetto_core::traits::Transport::Error`, so neither target's error type had to move and no shared error was invented. **Why the account is an argument rather than a field on the store.** One store instance serves every account on both targets, so the account is data the caller supplies rather than identity the constructor bakes. `KeyringStore` in `crates/connetto-client/src/auth.rs` used to carry `(service, user)` and now carries the service alone, composing its entry the way `KeyringKeyStore` beside it always did. The R41 form also served the browser's per-account refresh rows and the `connetto-device-key` record, and R90 deletes both (**Decided (R90, 2026-09-22)**), leaving the parameter standing on the native shape alone. Which account a caller attempts on boot is read from the last-used marker (`connetto_web::auth::remembered_account` in the browser, `connetto_client::auth::remembered_account` on native), from an explicit switch target when the user picks an account, or is `None` on a first run, which goes straight to an interactive login. -**Why only the key store awaits.** The browser reaches `IndexedDB` and `SubtleCrypto` through promises that have no synchronous form in a worker. The native implementations therefore wear an awaiting signature over a keychain call that returns immediately, and that call blocks whoever polls it. Bounded rather than hidden: key custody runs when a database is opened or an account is logged out, never per change. The futures carry `MaybeSend` exactly as `Transport`'s do. The refresh-token trait stays synchronous, because the native target needs no await there and forcing symmetry would be a false await, and its browser implementation goes with the browser refresh store (R90). +**Why both stores await.** The browser reaches `IndexedDB` and `SubtleCrypto` through promises that have no synchronous form in a worker, and the Linux desktop reaches the Secret Service only over D-Bus, where a synchronous call would hold a runtime worker for the round trip and for an unlock dialog (R71 decision 15). The key store's futures carry `MaybeSend` exactly as `Transport`'s do. The refresh-token trait returns boxed `Send` futures instead, so the native authenticator can hold it as `dyn RefreshTokenStore`. The Apple, Windows and Android stores still answer from a call that returns immediately. What the seam buys over the rename is one caller. `crates/connetto-client/tests/secret_stores.rs` and `crates/connetto-web/tests/secret_stores.rs` run the same two exercises from `connetto_core::test_support`, written against the traits alone, against the native and the browser stores. R90 retargets the browser half to the key store, its refresh exercise retiring with the store. @@ -64,9 +64,9 @@ What the seam buys over the rename is one caller. `crates/connetto-client/tests/ `name` is the same value `replica_db_name` produced for the replica file, so two identities on one device hold separate records and a wipe of one cannot reach the other. A literal name is equally valid. -The concrete implementation shipped for production on native is `connetto_client::auth::KeyringKeyStore` in `crates/connetto-client/src/auth.rs`, which uses OS secure storage: Keychain on macOS, Credential Manager on Windows, and the kernel keyutils keyring on Linux. On Linux the key lives in the session keyring. That keyring survives logout but not a reboot, so a rebooted Linux device reports `ClientError::ReplicaKeyMissing` and recovers by wiping and re-syncing. +The concrete implementation shipped for production on native is `connetto_client::auth::KeyringKeyStore` in `crates/connetto-client/src/auth.rs`, which uses OS secure storage: Keychain on macOS, Credential Manager on Windows, and on Linux the store below. -**Decided (R71, 2026-09-22 and 2026-09-28), not built.** Every durable Linux secret, the replica key, the refresh token and R74's device key, survives a reboot. A desktop session keeps them as base64 text in the Secret Service's default collection, which connetto unlocks or creates through the desktop's own dialog under a bound, as libsecret does. A Flatpak or Snap application keeps them in files inside its sandbox, sealed under a key derived from the Secret portal's per-application secret. A headless host keeps them in files under a state directory, sealed under a wrap key the operator supplies as the `connetto.wrap-key` systemd credential, or, in a container without systemd, as a key file the application names. A rotated wrap key reseals every record once from the previous key. With none of these reachable the client refuses with a typed error unless the application explicitly chooses keyutils and accepts losing its keys at reboot, which the custody report then states. +**Built (R71, decided 2026-09-22 and 2026-09-28).** Every durable Linux secret, the replica key, the refresh token and R74's device key, survives a reboot. A desktop session keeps them as base64 text in the Secret Service's default collection, which connetto unlocks or creates through the desktop's own dialog under a bound, as libsecret does. A Flatpak or Snap application keeps them in libsecret's sandbox keyring file, opened with the Secret portal's per-application secret. A headless host keeps them in files under a state directory, sealed under a wrap key the operator supplies as the `connetto.wrap-key` systemd credential, or, in a container without systemd, as a key file the application names. A rotated wrap key reseals every record once from the previous key. With none of these reachable the client refuses with a typed error unless the application explicitly chooses keyutils and accepts losing its keys at reboot, which `KeyringStore::backend` and `KeyringKeyStore::backend` then report, together with which store holds the keys and whether the previous wrap key is still needed. The test implementation is `connetto_client::auth::MemoryKeyStore`, an in-memory `HashMap`. @@ -287,7 +287,7 @@ The replica filename is `prefix-sha256(canonical(user_id))` truncated to 128 bit ## No open decisions -Everything this chapter covers is decided. R41, the single seam for the two secret stores, landed on 2026-08-07. R42, the multi-account credential store with enumeration, landed on 2026-08-19. The browser gate is built (R23) and after R90 it covers the replica key alone, the browser's refresh token having moved to an `HttpOnly` cookie with the device key deleted (decided 2026-09-22). Two items remain decided rather than built: R21, which moves the native side onto the browser's page codec, and R71, which makes Linux custody survive a reboot. R51, R52, and R53 carry the native gating surfaces for Apple, Android, and Windows respectively, and R94 carries the gate's default and its re-check setting. An unidentified run introduces no encryption decision at all: its local copy is SQLite's own `:memory:` and carries no key (chapter 12, **Built (R3)**), so nothing of it is at rest. (Corrected 2026-09-12: this paragraph used to say the unauthenticated replica is encrypted under a device-scoped key built in phase E5, a discarded series and a shape R3 replaced with in-memory.) +Everything this chapter covers is decided. R41, the single seam for the two secret stores, landed on 2026-08-07. R42, the multi-account credential store with enumeration, landed on 2026-08-19. The browser gate is built (R23) and after R90 it covers the replica key alone, the browser's refresh token having moved to an `HttpOnly` cookie with the device key deleted (decided 2026-09-22). One item remains decided rather than built: R21, which moves the native side onto the browser's page codec. Linux custody that survives a reboot is built (R71). R51, R52, and R53 carry the native gating surfaces for Apple, Android, and Windows respectively, and R94 carries the gate's default and its re-check setting. An unidentified run introduces no encryption decision at all: its local copy is SQLite's own `:memory:` and carries no key (chapter 12, **Built (R3)**), so nothing of it is at rest. (Corrected 2026-09-12: this paragraph used to say the unauthenticated replica is encrypted under a device-scoped key built in phase E5, a discarded series and a shape R3 replaced with in-memory.) --- diff --git a/examples/dioxus-desktop-demo/Cargo.lock b/examples/dioxus-desktop-demo/Cargo.lock index 8db1bdc7..d1f53ecd 100644 --- a/examples/dioxus-desktop-demo/Cargo.lock +++ b/examples/dioxus-desktop-demo/Cargo.lock @@ -18,6 +18,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -163,6 +175,20 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "tokio", + "zbus", +] + [[package]] name = "astral-tokio-tar" version = "0.6.4" @@ -179,6 +205,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-lock" version = "3.4.2" @@ -190,6 +228,17 @@ dependencies = [ "pin-project-lite", ] +[[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 2.0.119", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -566,6 +615,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -721,6 +779,15 @@ dependencies = [ "system-deps", ] +[[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.4.5" @@ -938,13 +1005,16 @@ dependencies = [ "anyhow", "apple-native-keyring-store", "base64 0.22.1", + "chacha20poly1305", "connetto-core", "diesel", "diesel-sqlite-session", + "futures-util", "getrandom 0.3.4", "keyring-core", "libsqlite3-sys", "linux-keyutils-keyring-store", + "oo7", "open", "reqwest 0.12.28", "serde", @@ -959,6 +1029,7 @@ dependencies = [ "tracing", "webbrowser", "windows-native-keyring-store", + "zbus", "zeroize", "zip", "zstd", @@ -1436,6 +1507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2394,6 +2466,33 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[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 2.0.119", +] + [[package]] name = "enumset" version = "1.1.14" @@ -2488,6 +2587,7 @@ version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ + "parking", "pin-project-lite", ] @@ -2756,6 +2856,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -3716,6 +3829,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -4842,6 +4956,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-bigint-dig" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" +dependencies = [ + "libm", + "num-integer", + "num-iter", + "num-traits", + "once_cell", + "rand 0.9.5", + "serde", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -5120,6 +5251,37 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oo7" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" +dependencies = [ + "aes", + "ashpd", + "cbc", + "cipher", + "digest 0.10.7", + "endi", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hmac 0.12.1", + "md-5 0.10.6", + "num", + "num-bigint-dig 0.9.1", + "pbkdf2", + "serde", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "tokio", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + [[package]] name = "opaque-debug" version = "0.3.1" @@ -5257,6 +5419,16 @@ dependencies = [ "num-traits", ] +[[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 = "p256" version = "0.13.2" @@ -5306,6 +5478,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -5366,6 +5544,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pem" version = "3.0.6" @@ -6738,7 +6926,7 @@ checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid 0.9.6", "digest 0.10.7", - "num-bigint-dig", + "num-bigint-dig 0.8.6", "num-integer", "num-traits", "pkcs1", @@ -8033,6 +8221,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -8483,6 +8672,17 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.60.2", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -9517,6 +9717,71 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.57" @@ -9563,6 +9828,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -9672,3 +9951,45 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "serde_bytes", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.5", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", + "winnow 1.0.4", +] diff --git a/examples/dioxus-desktop-demo/src/main.rs b/examples/dioxus-desktop-demo/src/main.rs index 0bf74b69..1302f4ab 100644 --- a/examples/dioxus-desktop-demo/src/main.rs +++ b/examples/dioxus-desktop-demo/src/main.rs @@ -193,6 +193,8 @@ struct AuthCtx { key_store: Arc, key_name: String, token_store: Arc, + /// The signed-in accounts as the store listed them at startup, since every change restarts. + accounts: Vec, session_expires_at: std::time::SystemTime, current_account: String, } @@ -514,8 +516,9 @@ async fn setup_authenticated( let token_store = Arc::new(KeyringStore::new(KEYRING_SERVICE)); let key_store = Arc::new(KeyringKeyStore::new(KEYRING_SERVICE)); - let account = - remembered_account(token_store.as_ref()).context("reading the remembered account")?; + let account = remembered_account(token_store.as_ref()) + .await + .context("reading the remembered account")?; let authenticator = Arc::new(platform_sign_in(NativeAuthenticator::new( endpoint( std::env::var("CONNETTO_DEMO_AUTH_ORIGIN").ok(), @@ -603,12 +606,17 @@ async fn setup_authenticated( }; let conn = conn.with_token_source(authenticator.token_source()); + let accounts = token_store + .accounts() + .await + .context("listing the signed-in accounts")?; let auth_ctx = AuthCtx { authenticator, db_path, key_store, key_name, + accounts, token_store, session_expires_at, current_account, @@ -1013,8 +1021,8 @@ async fn change_account( return; } let pointed = match account { - Some(key) => token_store.store(IDENTITY_RECORD, key), - None => token_store.clear(IDENTITY_RECORD), + Some(key) => token_store.store(IDENTITY_RECORD, key).await, + None => token_store.clear(IDENTITY_RECORD).await, }; match pointed { Ok(()) => restart.request(), @@ -1028,7 +1036,7 @@ fn AccountsPanel(wipe_state: Signal) -> Element { let client = use_context::>(); let auth_ctx = use_context::(); let mut add_picking: Signal = use_signal(|| false); - let accounts_list = use_signal(|| auth_ctx.token_store.accounts().unwrap_or_default()); + let accounts_list = use_signal(|| auth_ctx.accounts.clone()); let current_account = auth_ctx.current_account.clone(); let token_store = Arc::clone(&auth_ctx.token_store); let add_client = client.clone(); diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index ed822d80..cb68359d 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -154,7 +154,7 @@ Execution order and nothing else. Status, blockers, landing dates and what each | any | R31 | Application schema majors. Deadline is the first deployment intending to survive a schema change | | done | ~~R87~~ | Storage quotas and deployment ceilings | | any | R70 | Backup and restore. Step 4's OpenFGA reconcile waits on one subql request, and step 2's frontier leg on R75, whose frontier is what it demonstrates against | -| any | R71 | Linux replica-key custody. Independent, and it makes R74's Linux caveat go away | +| any | R71 | Linux key custody. Built, and three recorded manual runs on real desktops remain | | any | R72 | Clock discipline. Independent, and it absorbs the certificate clock rule R74 states | | blocked | R74 | Device identity and certificates, first of the peer phases. Blocked on R94, whose builders take the device key's store, the certificate lifetime setting and the enrolment step | | any | R75 | The per-device applied frontier. Needs R74 | @@ -167,7 +167,7 @@ Execution order and nothing else. Status, blockers, landing dates and what each | done | ~~R90~~ | The browser's refresh token into an `HttpOnly` cookie | | any | R91 | Apps, installations and the bot template. Needs nothing since the caller fixes of 2026-09-20 (PRs #41 and #42). The file replica for bots is R93's | | any | R92 | Synced tables without local references, with SQLite's own enforcement for the tier. Needs nothing | -| any | R93 | The file replica for bots. Needs R71's headless custody and R91's template | +| any | R93 | The file replica for bots. Needs R91's template, R71's headless custody being built | | any | R94 | One client builder per platform from shared configuration pieces. Needs nothing, and R51, R52, R74 and R95 wait on it | | blocked | R95 | Share keys added and removed on a running client. Blocked on R94 | | any | R96 | One server builder for programs that embed the server. Minted undesigned, so it starts with its design | @@ -216,7 +216,7 @@ Execution order and nothing else. Status, blockers, landing dates and what each | R90 the browser's refresh token in an `HttpOnly` cookie | **DONE** (2026-09-22), minted 2026-09-13 | nothing. One decision in the section and two settled in its review rounds (the cookie's lifetime, credentials for listed origins only), the 2026-08-06 parked BFF entry absorbed | no | | R91 apps, installations and the bot template | NOT STARTED, designed and reviewed 2026-09-18, unblocked 2026-09-20 | nothing. The content-ticket caller fix (PR #41) and the grant-move narrowing (PR #42) landed 2026-09-20. The bot file replica is R93's. Every decision is in `plans/apps-and-bots.md` | no | | R92 synced tables carry no local references | NOT STARTED, minted and designed 2026-09-22 by R21's decision 8 | nothing. Four decisions in the section | no | -| R93 the file replica for bots | NOT STARTED, minted and designed 2026-09-22 by R71's decision 8 | R71 and R91. Two decisions in the section | no | +| R93 the file replica for bots | NOT STARTED, minted and designed 2026-09-22 by R71's decision 8 | R91. Two decisions in the section | no | | R94 one client builder per platform | NOT STARTED, minted and designed 2026-09-25 while planning R51, open points settled 2026-09-28 | nothing. Fifteen decisions in the section | no | | R95 share keys on a running client | NOT STARTED, minted and designed 2026-09-25 by R94's decision 10 | R94. One decision and one open question in the section | no | | R96 one server builder | NOT STARTED, minted 2026-09-25 by R94's decision 11, undesigned | nothing, its design comes first | no | @@ -265,7 +265,7 @@ Execution order and nothing else. Status, blockers, landing dates and what each | R68 browser file client | **DONE** (2026-09-10) | nothing. Worker-owned encrypted OPFS with memory fallback, browser fetch, reference-counted object URLs, and version 3 archives that restore unsent content through the production worker relay. The offline photo survives export, import under another key, local display and later upload. The browser stack passed and the full release suite passed 738 tests with 3 skipped | no | | R69 files in every demo | **DONE** (A through G complete 2026-09-19), designed (2026-09-12) | nothing. A is #28, B is #29, C is #30, G is #31, the browser stack boots the executable's file half with the online photo flow proven by `photo_flow.rs` and F's offline and two-viewer proofs by `photo_offline.rs` and `photo_visibility.rs`. E is #34, the photos surface in both web demos, and D is #35, the desktop photos surface on the native content client proven by the Docker-gated `demo_photos_flow.rs` | no | | R70 backup and restore story | STARTED 2026-09-22, nineteen decisions taken, steps 1 and 2's pre-R75 legs written into chapter 20, the chunk-store fix built (boot reconcile, `lost`, healing on native and browser, one content retention rule and pin API on both, heal entries in the outbox) | one subql request (decision 6) for the OpenFGA reconcile, and R75 for step 2's frontier leg. Decisions 4, 5, 8, 10, 18 and 19 are built, so every restore method resyncs its clients and revokes every session. Chapter 20 is shared with R73 | no | -| R71 Linux key custody survives reboot | NOT STARTED, designed 2026-09-22, six more decisions 2026-09-28 | nothing. Fourteen decisions in the section | no | +| R71 Linux key custody survives reboot | BUILT 2026-09-28 except the recorded manual runs of proof items 4, 5 and 12 (a GNOME dialog, KDE Wallet, a Flatpak demo) | nothing. Sixteen decisions in the section | no | | R72 clock discipline (X6) | NOT STARTED | nothing | no | | R74 device identity and certificates | NOT STARTED | R94, whose builders take the device key's store, the certificate lifetime setting and the enrolment step (decided 2026-09-28) | no | | R75 the per-device applied frontier | NOT STARTED | R74. Touches the R2 watermark contract and the R56 import | no | @@ -4981,26 +4981,28 @@ The story is written, and a Docker-gated test drives a client against a server r ## R71: Linux replica-key custody survives a reboot -**Status.** NOT STARTED. Minted 2026-08-21: the 14-at-rest-encryption chapter records the behavior, the review found nobody owning it, and the same day's crypto discussion named it the one real safety failure the encryption has caused. +**Status.** BUILT 2026-09-28. Every automated proof item passes locally except proof 9's systemd-run group, which needs passwordless sudo and first runs in CI. The recorded manual runs of proof items 4, 5 and 12 (a GNOME unlock and create dialog, KDE Wallet on Plasma, a Flatpak-packaged demo) are outstanding. Minted 2026-08-21: the 14-at-rest-encryption chapter records the behavior, the review found nobody owning it, and the same day's crypto discussion named it the one real safety failure the encryption has caused. -**Blocked on nothing.** Eight decisions were taken with the maintainer on 2026-09-22 and six more on 2026-09-28, listed below. +**Blocked on nothing.** Eight decisions were taken with the maintainer on 2026-09-22 and eight more on 2026-09-28, listed below. ### Decisions 1. **Every durable Linux secret survives a reboot** (2026-09-22). The replica key, the refresh token and R74's device key share one Linux custody, as they share one on macOS and Windows. -2. **A desktop session keeps them in the Secret Service** (2026-09-22), through `zbus-secret-service-keyring-store`. Corrected the same day. This section used to charge the Secret Service with "the lock hazard that has already wedged this repository's own test runs", and the gate record of 2026-08-22 traced that wedge to a missing `keyctl session`, not to a locked collection. Its real costs are a D-Bus session bus and a collection that PAM unlocks at graphical login and that stays locked after an auto-login or over SSH, which decision 10 answers. The store takes the crate's pure-Rust session encryption and its own executor (`rt-async-io-crypto-rust`), so it links no `libcrypto`, and every call into it runs on `tokio::task::spawn_blocking` (2026-09-28). `keyring-core`'s store interface is synchronous and the crate drives zbus through a blocking `block_on`, which under `rt-tokio` is `Runtime::block_on` on zbus's own runtime and panics on a thread driving async tasks, and under `rt-async-io` holds the calling thread for the whole D-Bus round trip, an open unlock dialog included. Connetto's key stores are awaited from tokio tasks, so the blocking pool is where that wait belongs. Rejected: an upstream request for an async surface, since neither crate is at fault. +2. **A desktop session keeps them in the Secret Service** (2026-09-22), through `oo7` 0.6, GNOME's Rust client for the Secret Service and libsecret's successor (library chosen 2026-09-28). Corrected the same day. This section used to charge the Secret Service with "the lock hazard that has already wedged this repository's own test runs", and the gate record of 2026-08-22 traced that wedge to a missing `keyctl session`, not to a locked collection. Its real costs are a D-Bus session bus and a collection that PAM unlocks at graphical login and that stays locked after an auto-login or over SSH, which decision 10 answers. `oo7` is async only and runs on the caller's tokio runtime with its pure-Rust crypto (`tokio` and `native_crypto`), so nothing blocks a worker and it links no `libcrypto`. It declares `rust-version = 1.92`, so the workspace's minimum Rust version rises to 1.92. Rejected: `zbus-secret-service-keyring-store` 1.0.1, which hardcodes the content type `application/octet-stream` against decision 6, runs its own unbounded unlock prompt before every item call against decision 10, and cannot create a missing default collection against decision 12, and whose synchronous `keyring-core` interface drives zbus through a blocking `block_on` that panics on a tokio worker under `rt-tokio`. Also rejected: `secret-service` 5.2's async API, which would leave decision 12's collection creation and decision 14's sandbox keyring to be written by hand where `oo7` already does both as libsecret does. 3. **A headless host keeps them under a systemd credential** (2026-09-22). The operator's unit declares `LoadCredentialEncrypted=connetto.wrap-key:…` and connetto reads `$CREDENTIALS_DIRECTORY/connetto.wrap-key`. The name is fixed by the library and dotted after systemd's own credentials (`cryptsetup.passphrase`, `network.wireguard.*`), so one unit shape serves every connetto application and `ImportCredential=connetto.*` picks up both of decision 13's keys (names 2026-09-28). 4. **The credential holds a wrap key, and the secrets live beside it** (2026-09-22). The credential is a 32-byte key-encryption key. Each secret (replica key, refresh token, device key) is its own file under the unit's `$STATE_DIRECTORY`, named from its store's service and its record name and sealed with XChaCha20-Poly1305 with both as associated data, so two stores sharing one state directory can neither read nor swap each other's records (2026-09-28). `$CREDENTIALS_DIRECTORY` is read-only to the service, and the client writes a rotated refresh token and an enrolled device key, so a credential holding the secrets themselves has nowhere to put them. -5. **Detect, else refuse unless the application opts in** (2026-09-22). Inside a Flatpak or Snap sandbox the Secret portal of decision 14 is used. Elsewhere a credential wins when the unit provides one, the Secret Service is used otherwise, and with none reachable the client refuses with a typed error naming what it probed. An application may name its store explicitly, keyutils and decision 11's wrap-key file included, and choosing keyutils accepts losing every key at reboot, which the custody report then states. Rejected: refusing with no opt-in, which leaves a tool over SSH unable to sign in, and falling back to keyutils on its own, which keeps the loss R71 exists to end. -6. **The Secret Service holds each secret as base64 text** (2026-09-28). KDE Wallet, the Secret Service on many KDE desktops, stores only UTF-8, so a raw 32-byte key does not round-trip there. Every secret is written as base64 with content type `text/plain`, the shape libsecret's password calls store, on every Secret Service implementation alike. Rejected: leaving KDE unsupported, which makes a KDE user's application refuse at detection. +5. **Detect, else refuse unless the application opts in** (2026-09-22). Inside a Flatpak or Snap sandbox the Secret portal of decision 14 is used. Elsewhere a credential wins when the unit provides one, the Secret Service is used otherwise, and with none reachable the client refuses with a typed error naming what it probed. An application may name its store explicitly, keyutils and decision 11's wrap-key file included, and choosing keyutils accepts losing every key at reboot, which decision 16's report then states. Rejected: refusing with no opt-in, which leaves a tool over SSH unable to sign in, and falling back to keyutils on its own, which keeps the loss R71 exists to end. +6. **The Secret Service holds each secret as base64 text** (2026-09-28). KDE Wallet, the Secret Service on many KDE desktops, stores only UTF-8, so a raw 32-byte key does not round-trip there. Every secret is written as base64 through `oo7::Secret::text`, content type `text/plain`, the shape libsecret's password calls store, on every Secret Service implementation alike. Rejected: leaving KDE unsupported, which makes a KDE user's application refuse at detection. 7. **The gate and CI run the real Secret Service and a real systemd unit** (2026-09-22, the systemd half decided with the maintainer 2026-09-28). The Secret Service test group runs an unlocked `gnome-keyring-daemon` under `dbus-run-session`, locally and on the CI runners. The credential path is tested in two layers. Unit tests point `$CREDENTIALS_DIRECTORY` and `$STATE_DIRECTORY` at temporary directories and run everywhere. A root-only group runs the client inside transient system services started by `systemd-run` with `LoadCredentialEncrypted=`, over a wrap key sealed by `systemd-creds encrypt`, and with `StateDirectory=`, so systemd itself decrypts the credential, mounts its directory read-only and creates the state directory. That group is `#[ignore]`d, and CI runs it on `ubuntu-latest` through the runner's passwordless `sudo`. Every other test names its store explicitly. Rejected: a QEMU/KVM virtual machine rebooted between a write run and a read run, because once the secrets live on disk a reboot adds only a test of the distribution's disks, PAM and systemd, and a fresh process under a fresh session keyring already shows nothing depends on keyutils. 8. **The bot file replica is not R71's** (2026-09-22). It edits the bot template, which only R91 produces, while `plans/apps-and-bots.md` (on R91's branch) said it "is gated on R71 and arrives with it" and the R91 rows here called it R71's to ship. No hard cycle existed, since R91 ships in-memory bots without it and R71's custody needs nothing from R91, but a template feature had been assigned to a custody phase. It is R93, which needs both, so R71 and R91 each close on their own. Rejected: a gated step inside R91, and one inside R71, either of which holds a phase open on the other. 9. **The store is chosen per instance, not per process** (2026-09-22). Today `ensure_keyring_store` (`auth.rs`) installs one default store per process behind a `OnceLock`, so the choice is made once and cannot differ between two stores in one binary. Decisions 5 and 7 need both, an application naming its store and one test binary driving the Secret Service and the credential paths. So `KeyringKeyStore` and `KeyringStore` each hold the store they were constructed with, detection is the default constructor, and the process-global default stops being the mechanism. -10. **A locked collection asks the desktop to unlock it, then refuses** (2026-09-28). Before any store call the client calls the Secret Service's `Unlock` on the default collection itself over `zbus` and runs the prompt it returns, so the desktop shows its own password dialog, as every libsecret application does after an auto-login. It waits for the prompt's `Completed` signal up to a stated bound, and when the bound passes it calls `Prompt.Dismiss` so the dialog closes, which is what libsecret does when its call is cancelled. It refuses with a typed error when the dialog is dismissed, the bound passes, or no dialog can be shown, as over SSH. The store only ever sees an unlocked collection, because `secret-service` 5.2.0 waits for `Completed` with no bound and never exposes the prompt. Rejected: refusing at once, which leaves an application unable to open its replica after an auto-login until the user unlocks the keyring some other way, and a bound added upstream in `secret-service`, which holds this decision on a release. -11. **A headless host without systemd names a wrap-key file** (2026-09-28). The sealed-file store of decision 4 takes its 32-byte wrap key from one of three sources, the systemd credential decision 3 detects, the Secret portal of decision 14, or a key file and a state directory the application names as its explicit store under decision 5. The key file covers Docker and Kubernetes, which mount secrets as files and run no systemd, and where Docker's default seccomp profile blocks the keyring syscalls, so keyutils is not even available as an opt-in (checked 2026-09-28, `keyctl add` fails with `Operation not permitted` in `alpine:3` under the default profile and succeeds with `seccomp=unconfined`). The library reads no environment variable for it, and R91's bot template maps both paths from its own environment. Rejected: recording containers as unsupported, which leaves R93's bots without a durable store in a container. -12. **Secrets go in the default collection** (2026-09-28). The Secret Service store reads and writes the collection behind the `default` alias, which PAM unlocks at graphical login and decision 10 unlocks through the desktop's dialog otherwise. `zbus-secret-service-keyring-store` 1.0.1 writes there unless an entry names another target. When the alias resolves to nothing, as under WSL or on an auto-login desktop before any keyring exists, the client creates a collection labelled "Default keyring" under the `default` alias through the same bounded prompt as decision 10, as libsecret does, and refuses with the same typed error when that prompt is dismissed, times out or cannot be shown. Rejected: a dedicated connetto collection, which makes the desktop ask the user for a new password when it is created and for an unlock on every session on top of the login keyring, and treating a missing alias as no Secret Service, which refuses where every libsecret application carries on. -13. **A rotated wrap key reseals every record once** (2026-09-28). The sealed-file store accepts an optional previous wrap key beside the current one, the `connetto.wrap-key.previous` credential under systemd or a second key file named by the application. `systemd-creds` embeds the credential name and checks it at decryption, so the operator re-encrypts the old key under the new name (`systemd-creds decrypt` piped into `systemd-creds encrypt --name=connetto.wrap-key.previous`). When a previous key is given, opening the store walks every record file in the state directory, and each record that opens only under the previous key is sealed under the current key into a temporary file, read back, and renamed over the old file. A record already under the current key is left untouched, the previous key is used for nothing else, and the custody report states whether any record still needs it, so the operator knows when to remove it. A record that opens under neither key fails with a typed error and never mints a replacement, as proof item 2 states. Rejected: treating rotation as a refusal, which costs a resync, a new sign-in and any tier rows not exported. -14. **A sandboxed application seals its secrets under the Secret portal** (2026-09-28). Inside a Flatpak or a Snap, detected as libsecret detects it by `/.flatpak-info` or `$SNAP_NAME`, the client calls `org.freedesktop.portal.Secret.RetrieveSecret` for the application's own secret and derives the sealed-file store's 32-byte wrap key from it with HKDF-SHA256, because the portal does not guarantee the secret's length. The state directory is inside the sandbox's data directory, and the token the portal may return is kept there and passed back on the next call, as the portal asks. That is libsecret's own sandbox backend, and it keeps the user's keyring out of the sandbox. Rejected: granting `--talk-name=org.freedesktop.secrets`, which exposes the user's whole keyring to the application, and leaving sandboxes out of R71. +10. **A locked collection asks the desktop to unlock it, then refuses** (2026-09-28). Before any store call the client calls the Secret Service's `Unlock` on the default collection itself over the `zbus` connection `oo7` re-exports and runs the prompt it returns, so the desktop shows its own password dialog, as every libsecret application does after an auto-login. It waits for the prompt's `Completed` signal up to a stated bound, and when the bound passes it calls `Prompt.Dismiss` so the dialog closes, which is what libsecret does when its call is cancelled. It refuses with a typed error when the dialog is dismissed, the bound passes, or no dialog can be shown, as over SSH. The store only ever sees an unlocked collection, because `oo7` 0.6 waits for `Completed` with no bound and keeps `Prompt::dismiss` behind its `unstable` feature. Rejected: refusing at once, which leaves an application unable to open its replica after an auto-login until the user unlocks the keyring some other way, and a bound added upstream, which holds this decision on a release. +11. **A headless host without systemd names a wrap-key file** (2026-09-28). The sealed-file store of decision 4 takes its 32-byte wrap key from one of two sources, the systemd credential decision 3 detects, or a key file and a state directory the application names as its explicit store under decision 5. The key file covers Docker and Kubernetes, which mount secrets as files and run no systemd, and where Docker's default seccomp profile blocks the keyring syscalls, so keyutils is not even available as an opt-in (checked 2026-09-28, `keyctl add` fails with `Operation not permitted` in `alpine:3` under the default profile and succeeds with `seccomp=unconfined`). The library reads no environment variable for it, and R91's bot template maps both paths from its own environment. Rejected: recording containers as unsupported, which leaves R93's bots without a durable store in a container. +12. **Secrets go in the default collection** (2026-09-28). The Secret Service store reads and writes the collection behind the `default` alias, which PAM unlocks at graphical login and decision 10 unlocks through the desktop's dialog otherwise. `oo7`'s `Service::default_collection` addresses it. When the alias resolves to nothing, as under WSL or on an auto-login desktop before any keyring exists, the client calls `CreateCollection` for a collection labelled "Default keyring" under the `default` alias through the same bounded prompt as decision 10, as libsecret does (`oo7` creates it too, but through a prompt it waits on with no bound), and refuses with the same typed error when that prompt is dismissed, times out or cannot be shown. Rejected: a dedicated connetto collection, which makes the desktop ask the user for a new password when it is created and for an unlock on every session on top of the login keyring, and treating a missing alias as no Secret Service, which refuses where every libsecret application carries on. +13. **A rotated wrap key reseals every record once** (2026-09-28). The sealed-file store accepts an optional previous wrap key beside the current one, the `connetto.wrap-key.previous` credential under systemd or a second key file named by the application. `systemd-creds` embeds the credential name and checks it at decryption, so the operator re-encrypts the old key under the new name (`systemd-creds decrypt` piped into `systemd-creds encrypt --name=connetto.wrap-key.previous`). When a previous key is given, opening the store walks every record file in the state directory, and each record that opens only under the previous key is sealed under the current key into a temporary file, read back, and renamed over the old file. A record already under the current key is left untouched, the previous key is used for nothing else, and decision 16's report states whether any record still needs it, so the operator knows when to remove it. A record that opens under neither key fails with a typed error and never mints a replacement, as proof item 2 states. Rejected: treating rotation as a refusal, which costs a resync, a new sign-in and any tier rows not exported. +14. **A sandboxed application keeps its secrets in libsecret's sandbox keyring** (2026-09-28, rewritten the same day for `oo7`). Inside a Flatpak or a Snap, detected by `ashpd::is_sandboxed`, the store is `oo7::Keyring::new()`'s file backend. It asks `org.freedesktop.portal.Secret.RetrieveSecret` for the application's own secret and keeps every item in a keyring file in libsecret's own format at libsecret's path inside the sandbox, so a sandboxed connetto application holds its secrets exactly where and how a libsecret one does. Items are written with decision 6's base64 `text/plain` shape and decision 4's service and record name as attributes. It keeps the user's keyring out of the sandbox. Rejected: granting `--talk-name=org.freedesktop.secrets`, which exposes the user's whole keyring to the application, leaving sandboxes out of R71, and a connetto-specific file sealed under an HKDF expansion of the portal secret, which duplicates libsecret's format for no gain. +15. **`RefreshTokenStore` awaits** (2026-09-28). The Secret Service is reached only over D-Bus, and a synchronous trait method called from `NativeAuthenticator`'s async methods would hold a tokio worker for every round trip, up to D-Bus's 25 s method timeout when the daemon hangs, and for decision 10's whole unlock dialog. Its four methods return boxed futures, `Send` natively, so `Arc` keeps working, and `ReplicaKeyStore` is already async. The browser implements neither since R90. Rejected: a one-time async unlock with short synchronous calls afterwards, which still blocks a worker on a hung daemon and refuses a collection that locks again instead of prompting. +16. **The stores report their Linux backend** (2026-09-28). `KeyringStore` and `KeyringKeyStore` answer `backend()` with which store holds their secrets (Secret Service, sandbox keyring, systemd credential, key file, or keyutils) and whether it survives a reboot, and a sealed-file store answers whether any record still needs the previous wrap key. That is where decisions 5 and 13 are reported. `Custody` stays about the gate, which is a separate axis. Rejected: a durability axis inside `connetto_core::Custody`, which mixes reboot survival into the gate report. ### Purpose @@ -5008,7 +5010,7 @@ On Linux the replica key lives in the kernel session keyring, which does not sur ### Steps -1. Implement decisions 2 to 6 and 9 to 14 in `KeyringKeyStore`'s and `KeyringStore`'s Linux arms, leaving the other platforms' stores untouched. +1. Implement decisions 2 to 6 and 9 to 16 in `KeyringKeyStore`'s and `KeyringStore`'s Linux arms, and decision 15 in `RefreshTokenStore` and every implementation and caller, leaving the other platforms' stores untouched. 2. Prove it with the list under Proof. 3. Amend chapter 14's Linux custody paragraph. @@ -5016,7 +5018,7 @@ On Linux the replica key lives in the kernel session keyring, which does not sur 1. A replica key and a refresh token written by one process are read by a fresh process with a fresh session keyring, and the replica and tier reopen without a re-mint, under a real unlocked `gnome-keyring-daemon` and under a credential with `$CREDENTIALS_DIRECTORY` and `$STATE_DIRECTORY` pointing at temporary directories. R74 proves its device key through the same stores. 2. A sealed record file holds no key bytes, a flipped byte fails the load with a typed error, a record file renamed to another record's name fails, and a wrong wrap key fails with a typed error and never mints a replacement key. -3. Inside a sandbox the portal is used, with a credential and the Secret Service both present the credential wins, with only the Secret Service it is used, with none the client refuses naming what it probed, and an explicit keyutils choice works with the custody report stating that the keys do not survive a reboot. +3. Inside a sandbox the sandbox keyring is used, with a credential and the Secret Service both present the credential wins, with only the Secret Service it is used, with none the client refuses naming what it probed, and an explicit keyutils choice works with `backend()` stating that the keys do not survive a reboot. 4. With no dialog available or no bus, a locked collection becomes a typed refusal within the stated bound, never a hang. Against a fake Secret Service on a private `dbus-run-session` bus whose prompt never completes, the client calls `Prompt.Dismiss` at the bound and refuses, and with no `default` alias it creates the collection through the prompt. A dismissed dialog, a dialog left past the bound, a collection unlocked through the dialog and then used, and a missing default collection created through the dialog are shown by a recorded manual run on a desktop session, since no CI runner has a prompter. 5. Every secret is stored in the Secret Service as base64 `text/plain` and reads back byte for byte, a stored value that is not valid base64 fails with a typed error, and a recorded manual run on a KDE Plasma session writes and reads every secret through KDE Wallet. 6. After `wipe_replica`, logout or `forget_device`, a fresh process finds nothing in any durable store, and clearing the last account removes the index record. @@ -5024,9 +5026,9 @@ On Linux the replica key lives in the kernel session keyring, which does not sur 8. A rotated refresh token reloads as the latest, a record file is replaced by write then rename and is never torn, and every record file is mode `0600`. 9. Under decision 7's real systemd unit, one transient service writes the replica key and the refresh token under the `connetto.wrap-key` credential, a second transient service reads them back and reopens the replica without a re-mint, nothing is written under `$CREDENTIALS_DIRECTORY`, and every record file under `$STATE_DIRECTORY` is mode `0600`. 10. With no systemd, a wrap-key file and a state directory named by the application let a second process read back what the first wrote, inside a container run under Docker's default seccomp profile with the key file mounted read-only as Docker mounts a secret, and a key file whose length is not 32 bytes is refused with a typed error. -11. With a previous and a current wrap key, one open reseals every record under the current key and the custody report then says the previous key is no longer needed, a failure between the temporary write and the rename leaves the record readable under the previous key and the next open finishes it, a record already under the current key is left byte for byte unchanged, and after the previous key is removed every record opens. -12. Against a fake Secret portal on a private bus, the store derives its wrap key from the returned secret, keeps the returned token and passes it back on the next call, and a second process reads back what the first wrote. A recorded manual run of a Flatpak-packaged demo on a GNOME session shows the same through the real portal. -13. On a runtime with one worker thread, a Secret Service store call held open by a fake Secret Service whose prompt never completes neither panics nor stops a second task from running. +11. With a previous and a current wrap key, one open reseals every record under the current key and `backend()` then says the previous key is no longer needed, a failure between the temporary write and the rename leaves the record readable under the previous key and the next open finishes it, a record already under the current key is left byte for byte unchanged, and after the previous key is removed every record opens. +12. Against a fake Secret portal on a private bus, the store opens libsecret's sandbox keyring under the secret the portal hands over, stores every secret as base64 `text/plain`, and a second process on the same session bus reads back what the first wrote at libsecret's path under `XDG_DATA_HOME`. A portal that never answers is refused at the bound, and a sandbox with no data directory refuses before asking. A recorded manual run of a Flatpak-packaged demo on a GNOME session shows the same through the real portal. +13. On a runtime with one worker thread, the unlock both stores run before every Secret Service call, held open by a fake Secret Service whose prompt never completes, neither panics nor stops a second task from running. ### Done when @@ -5438,7 +5440,7 @@ Synced tables carry no references on either backend, enforcement is on at every **Status.** NOT STARTED, designed 2026-09-22. Minted the same day by R71's decision 8. -**Blocked on** R71 for durable headless custody and R91 for the bot template. Two decisions, listed below. +**Blocked on** R91 for the bot template. R71's durable headless custody is built. Two decisions, listed below. ### Purpose diff --git a/scripts/linux-secret-store-tests.sh b/scripts/linux-secret-store-tests.sh new file mode 100755 index 00000000..07dd6d30 --- /dev/null +++ b/scripts/linux-secret-store-tests.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Runs the R71 Linux secret-store groups a plain test run cannot, under the +# cargo profile CARGO_TEST_PROFILE names (release by default): +# secret-service a private session bus with an unlocked gnome-keyring-daemon +# systemd transient system services, through passwordless sudo +# container Docker under its default seccomp profile +set -euo pipefail + +profile="${CARGO_TEST_PROFILE:-release}" +nextest=(cargo +stable nextest run --cargo-profile "$profile" --all-features -p connetto-client --run-ignored ignored-only) + +case "${1:-}" in + secret-service) + exec dbus-run-session -- bash -c ' + set -euo pipefail + XDG_DATA_HOME="$(mktemp -d)" + export XDG_DATA_HOME + printf connetto | gnome-keyring-daemon --unlock --components=secrets >/dev/null + CONNETTO_R71_PRIVATE_BUS=1 keyctl session - "$@" + ' _ "${nextest[@]}" -E 'test(=linux_custody::a_fresh_process_reads_what_another_wrote_under_the_secret_service)' + ;; + systemd) + exec keyctl session - "${nextest[@]}" -E 'test(=linux_custody::a_second_transient_service_reads_what_the_first_wrote)' + ;; + container) + cargo +stable build --profile "$profile" --all-features -p connetto-client --example secret_store_probe + target="$(cargo metadata --format-version 1 --no-deps | python3 -c 'import json, sys; print(json.load(sys.stdin)["target_directory"])')" + CONNETTO_R71_PROBE="$target/$profile/examples/secret_store_probe" \ + exec "${nextest[@]}" -E 'test(=linux_custody::a_restarted_container_reads_its_keys_back_through_a_mounted_key_file)' + ;; + *) + echo "usage: $0 secret-service|systemd|container" >&2 + exit 2 + ;; +esac