From b685a4864cd7d614792a3c6d2abf032d5506bdd6 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 00:23:40 +0000 Subject: [PATCH] Implement secure OS-backed secret storage --- Cargo.lock | 125 ++++ Cargo.toml | 6 + DEPENDENCIES.md | 2 +- README.md | 3 + apps/cli/src/main.rs | 265 ++++++- crates/storage/Cargo.toml | 12 + crates/storage/src/git.rs | 12 +- crates/storage/src/lib.rs | 1 + crates/storage/src/secret_store.rs | 775 ++++++++++++++++++++ crates/storage/src/secret_store/platform.rs | 236 ++++++ crates/storage/tests/secret_store.rs | 351 +++++++++ docs/configuration.md | 2 + docs/cryptography.md | 3 + docs/secure-secret-storage.md | 54 ++ 14 files changed, 1838 insertions(+), 9 deletions(-) create mode 100644 crates/storage/src/secret_store.rs create mode 100644 crates/storage/src/secret_store/platform.rs create mode 100644 crates/storage/tests/secret_store.rs create mode 100644 docs/secure-secret-storage.md diff --git a/Cargo.lock b/Cargo.lock index a855713..91fead8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,6 +202,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "approx" version = "0.5.1" @@ -899,6 +910,15 @@ dependencies = [ "rustversion", ] +[[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.0" @@ -3827,6 +3847,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -3875,6 +3896,7 @@ checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" name = "ironstorage" version = "0.1.0" dependencies = [ + "apple-native-keyring-store", "cap-std", "cap-tempfile", "clap", @@ -3882,12 +3904,15 @@ dependencies = [ "gix", "gix-config", "hex", + "keyring-core", "pgp", "rand 0.8.7", "rand_chacha 0.3.1", "regex", "reqwest", "rustix 1.1.4", + "secret-service", + "security-framework", "serde", "sha1", "sha2", @@ -3896,6 +3921,8 @@ dependencies = [ "tempfile", "toml 0.9.12+spec-1.1.0", "url", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", "zeroize", ] @@ -4119,6 +4146,15 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -4512,6 +4548,30 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9737e026353e5cd0736f98eddae28665118eb6f6600902a7f50db585621fecb6" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -4529,6 +4589,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -4565,6 +4634,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -6231,6 +6311,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -7066,7 +7165,9 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", + "tracing", "windows-sys 0.61.2", ] @@ -8210,6 +8311,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -8584,6 +8697,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_repr", + "tokio", "tracing", "uds_windows", "uuid", @@ -8594,6 +8708,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.18.0" diff --git a/Cargo.toml b/Cargo.toml index 2484aec..3034310 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ edition = "2024" rust-version = "1.92" [workspace.dependencies] +apple-native-keyring-store = { version = "1.0", default-features = false, features = ["keychain", "protected"] } cap-std = "4.0" cap-tempfile = "4.0" clap = { version = "4.6", features = ["derive"] } @@ -23,15 +24,20 @@ gix = { version = "0.86", default-features = false, features = ["blocking-http-t gix-config = "0.59" iced = "0.14" ironstorage = { path = "crates/storage" } +keyring-core = "1.0" pgp = { version = "0.20", default-features = false } rand = "0.8" regex = "1.13" reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } +security-framework = "3.7" +secret-service = { version = "5.1", default-features = false, features = ["rt-tokio-crypto-rust"] } serde = { version = "1", features = ["derive"] } sha1 = "0.10" shlex = "1.3" toml = "0.9" uniffi = "0.32" url = { version = "2.5", default-features = false } +windows-native-keyring-store = { version = "1.1", default-features = false } zeroize = "1.8" +zbus-secret-service-keyring-store = { version = "1.0", default-features = false, features = ["rt-tokio-crypto-rust"] } diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 9e8560a..e7ea3a7 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -48,7 +48,7 @@ decision. | GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. | | Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Selected with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only, supply credentials directly, and use the storage-owned receive-pack implementation for push. | | Git FFI fallback | [`git2` 0.21](https://crates.io/crates/git2/0.21.0) | MIT OR Apache-2.0 | Reject for now; it links libgit2 and is unnecessary for the HTTPS-only scope. | -| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store`](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store`](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store`](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Preferred per-platform stores. The Apple protected store supports iOS/macOS protected data and biometric access. Use the Linux store's Rust crypto feature. | +| Server/application credentials | [`keyring-core` 1.0](https://crates.io/crates/keyring-core/1.0.0), [`apple-native-keyring-store` 1.0](https://crates.io/crates/apple-native-keyring-store/1.0.2), [`windows-native-keyring-store` 1.1](https://crates.io/crates/windows-native-keyring-store/1.1.0), [`zbus-secret-service-keyring-store` 1.0](https://crates.io/crates/zbus-secret-service-keyring-store/1.0.0) | MIT OR Apache-2.0 | Selected behind target-specific dependencies. Apple supports legacy Keychain plus protected-data user presence, Windows uses Credential Manager, and Linux uses Secret Service with the Tokio/Rust-crypto feature. | | Secret values in memory | [`secrecy` 0.10](https://crates.io/crates/secrecy/0.10.3), [`zeroize` 1.9](https://crates.io/crates/zeroize/1.9.0) | MIT OR Apache-2.0 | `zeroize` selected for the storage-owned redacted byte type; consider `secrecy` only when typed exposure controls add value. | | Password generation | [`rand`](https://crates.io/crates/rand) | MIT OR Apache-2.0 | Preferred using the operating-system CSPRNG. | | TOTP and HOTP | [`hmac`](https://crates.io/crates/hmac), [`sha1`](https://crates.io/crates/sha1), [`sha2`](https://crates.io/crates/sha2), [`data-encoding`](https://crates.io/crates/data-encoding), [`url`](https://crates.io/crates/url) | MIT or MIT OR Apache-2.0 | Preferred small implementation with RFC test vectors. `totp-rs` is MIT but rejects HOTP URIs, so it cannot cover all of `pass-otp`. | diff --git a/README.md b/README.md index 2f37407..fad50d0 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ The shared TOML schema, path rules, editor precedence, and HTTPS remote format are documented in [`docs/configuration.md`](docs/configuration.md). Embedded Git, HTTPS synchronization, merge behavior, and commit signing are documented in [`docs/git-synchronization.md`](docs/git-synchronization.md). +Native credential storage, opaque secret references, user-presence policy, and +bounded caching are documented in +[`docs/secure-secret-storage.md`](docs/secure-secret-storage.md). The capability-scoped password-store layout and atomic mutation guarantees are documented in [`docs/repository-core.md`](docs/repository-core.md). The embedded OpenPGP backend, exported-key model, secret-provider boundary, and diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 04a3b35..729424b 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -5,10 +5,19 @@ use std::{ffi::OsString, io::Write, process::ExitCode}; use ironstorage::{ command::{ - CliAction, CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, HelpTopic, - OtpRequest, help_text, otp_version_text, parse_from, version_text, + CliAction, CommandRequest, EXIT_CONFIG, EXIT_FAILURE, EXIT_SUCCESS, EXIT_UNAVAILABLE, + GitRequest, HelpTopic, OtpRequest, Presentation, help_text, otp_version_text, parse_from, + version_text, }, config::Config, + crypto::KeyStore, + git::{GitIdentity, GitRepository}, + read::{ShowOutput, ShowResult, VaultReader}, + repository::Repository, + secret_store::{ + NativeSecretStore, SecretCachePolicy, SecretProtectionPolicy, SecretStore, + SecretStoreBackend, + }, }; #[allow(dead_code)] @@ -73,7 +82,24 @@ where .map_err(|_| ())?; Ok(EXIT_SUCCESS) } - _ => match Config::load(invocation.config()) { + request => match Config::load(invocation.config()) { + Ok(config) if needs_secret_store(request) => { + let mut secrets = match NativeSecretStore::system( + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ) + .and_then(|store| { + store.unlock()?; + Ok(store) + }) { + Ok(secrets) => secrets, + Err(error) => { + writeln!(stderr, "{error}").map_err(|_| ())?; + return Ok(EXIT_UNAVAILABLE); + } + }; + execute_secure(&config, request, &mut secrets, &mut stdout, &mut stderr) + } Ok(_) => { stderr .write_all( @@ -91,16 +117,177 @@ where } } +fn needs_secret_store(request: &CommandRequest) -> bool { + matches!( + request, + CommandRequest::Show(_) | CommandRequest::Git(GitRequest::Fetch { .. }) + ) +} + +fn execute_secure( + config: &Config, + request: &CommandRequest, + secrets: &mut SecretStore, + stdout: &mut O, + stderr: &mut E, +) -> Result { + match request { + CommandRequest::Show(request) if request.presentation == Presentation::Terminal => { + let repository = match Repository::open(config.vault()) { + Ok(repository) => repository, + Err(error) => return operation_error(stderr, error), + }; + let keys = match KeyStore::load(config.key_material()) { + Ok(keys) => keys, + Err(error) => return operation_error(stderr, error), + }; + match VaultReader::new(&repository, &keys).execute_show(request, secrets) { + Ok(ShowOutput::Display(ShowResult::Entry(secret))) => { + stdout.write_all(secret.expose()).map_err(|_| ())?; + Ok(EXIT_SUCCESS) + } + Ok(ShowOutput::Display(ShowResult::Directory(tree))) => { + stdout + .write_all(tree.render_plain().as_bytes()) + .map_err(|_| ())?; + Ok(EXIT_SUCCESS) + } + Ok(ShowOutput::Present(_)) => Ok(EXIT_UNAVAILABLE), + Err(error) => operation_error(stderr, error), + } + } + CommandRequest::Git(GitRequest::Fetch { remote }) => { + let configured = match select_remote(config, remote.as_deref()) { + Some(configured) => configured, + None => { + stderr + .write_all(b"the requested HTTPS Git remote is not configured\n") + .map_err(|_| ())?; + return Ok(EXIT_CONFIG); + } + }; + let repository = match Repository::open(config.vault()) { + Ok(repository) => repository, + Err(error) => return operation_error(stderr, error), + }; + let identity = GitIdentity::new("IronStorage", "ironstorage@localhost") + .expect("the built-in Git identity is valid"); + let git = match GitRepository::open(&repository, identity) { + Ok(git) => git, + Err(error) => return operation_error(stderr, error), + }; + match git.fetch(configured, secrets) { + Ok(_) => Ok(EXIT_SUCCESS), + Err(error) => operation_error(stderr, error), + } + } + _ => Ok(EXIT_UNAVAILABLE), + } +} + +fn select_remote<'a>( + config: &'a Config, + requested: Option<&str>, +) -> Option<&'a ironstorage::config::GitRemote> { + match requested { + Some(requested) => config + .git_remotes() + .iter() + .find(|remote| remote.name().as_str() == requested), + None => config.git_remotes().first(), + } +} + +fn operation_error(stderr: &mut E, error: impl std::fmt::Display) -> Result { + writeln!(stderr, "{error}").map_err(|_| ())?; + Ok(EXIT_FAILURE) +} + #[cfg(test)] mod tests { - use std::{error::Error, ffi::OsString, fs}; + use std::{ + collections::BTreeMap, + error::Error, + ffi::OsString, + fs, + sync::{Arc, Mutex}, + }; - use ironstorage::command::{EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE}; + use ironstorage::{ + command::{ + CommandRequest, EXIT_CONFIG, EXIT_SUCCESS, EXIT_UNAVAILABLE, EXIT_USAGE, Presentation, + ShowRequest, + }, + config::Config, + git::GitCredentialProvider as _, + repository::SecretBytes, + secret_store::{ + SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, + SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, + }, + }; - use super::run_with; + use super::{execute_secure, run_with}; type TestResult = Result<(), Box>; + #[derive(Clone, Default)] + struct MemoryBackend(Arc>>); + + impl SecretStoreBackend for MemoryBackend { + fn create( + &self, + locator: &SecretLocator, + _protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; + if values.contains_key(locator) { + return Err(SecretStoreError::AlreadyExists); + } + values.insert(locator.clone(), SecretBytes::new(value.to_vec())); + Ok(()) + } + + fn retrieve( + &self, + locator: &SecretLocator, + _protection: SecretProtection, + ) -> Result { + self.0 + .lock() + .map_err(|_| SecretStoreError::Unavailable)? + .get(locator) + .map(|value| SecretBytes::new(value.expose().to_vec())) + .ok_or(SecretStoreError::Missing) + } + + fn replace( + &self, + locator: &SecretLocator, + _protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + let mut values = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; + let existing = values.get_mut(locator).ok_or(SecretStoreError::Missing)?; + *existing = SecretBytes::new(value.to_vec()); + Ok(()) + } + + fn delete( + &self, + locator: &SecretLocator, + _protection: SecretProtection, + ) -> Result<(), SecretStoreError> { + self.0 + .lock() + .map_err(|_| SecretStoreError::Unavailable)? + .remove(locator) + .map(drop) + .ok_or(SecretStoreError::Missing) + } + } + #[test] fn help_and_usage_errors_have_stable_streams_and_exit_codes() -> TestResult { let mut stdout = Vec::new(); @@ -170,7 +357,71 @@ mod tests { .expect("writing to memory cannot fail"); assert_eq!(code, EXIT_UNAVAILABLE); assert!(stdout.is_empty()); - assert!(String::from_utf8(stderr)?.contains("not available yet")); + assert!(String::from_utf8(stderr)?.contains("secret store is unavailable")); + Ok(()) + } + + #[test] + fn cli_session_unlocks_protected_entries_and_https_credentials_by_reference() -> TestResult { + const FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30"; + let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../crates/storage/tests/fixtures/compatibility"); + let temporary = tempfile::tempdir()?; + let config_path = temporary.path().join("config.toml"); + fs::write( + &config_path, + format!( + "vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'fixture-server'\napplication_id = 'fixture-app'\n", + fixtures.join("stores/basic"), + FINGERPRINT, + fixtures.join("keys"), + ), + )?; + let config = Config::load(Some(&config_path))?; + let mut secrets = SecretStore::new( + MemoryBackend::default(), + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ); + secrets.unlock()?; + secrets.create( + &SecretReference::openpgp_passphrase(FINGERPRINT)?, + SecretBytes::new(b"fixture-alice-passphrase".to_vec()), + )?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + execute_secure( + &config, + &CommandRequest::Show(ShowRequest { + entry: Some("email/personal".to_owned()), + presentation: Presentation::Terminal, + }), + &mut secrets, + &mut stdout, + &mut stderr, + ) + .expect("memory output cannot fail"), + EXIT_SUCCESS + ); + assert_eq!( + stdout, + fs::read(fixtures.join("expected/basic/email/personal.txt"))? + ); + assert!(stderr.is_empty()); + + let remote = &config.git_remotes()[0]; + secrets.create( + &SecretReference::https_git_credential( + remote.server_id().as_str(), + remote.application_id().as_str(), + "fixture-account", + )?, + SecretBytes::new(b"fixture-token".to_vec()), + )?; + let credential = secrets.credential(remote.server_id(), remote.application_id())?; + assert_eq!(credential.username(), "fixture-account"); + assert_eq!(credential.password(), b"fixture-token"); Ok(()) } } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 6525a4b..e3f3758 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -13,6 +13,7 @@ clap.workspace = true flate2.workspace = true gix.workspace = true gix-config.workspace = true +keyring-core.workspace = true pgp.workspace = true rand.workspace = true regex.workspace = true @@ -24,6 +25,17 @@ toml.workspace = true url.workspace = true zeroize.workspace = true +[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] +apple-native-keyring-store.workspace = true +security-framework.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +windows-native-keyring-store.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +secret-service.workspace = true +zbus-secret-service-keyring-store.workspace = true + [dev-dependencies] hex = "0.4" rand_chacha = "0.3" diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index f46b62d..32f3a4f 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -17,6 +17,7 @@ use gix::{ objs::tree::EntryKind, }; use sha1::{Digest as _, Sha1}; +use zeroize::Zeroize as _; use crate::{ config::{ApplicationId, GitRemote, ServerId}, @@ -184,6 +185,8 @@ pub enum GitError { name: String, }, CredentialsUnavailable, + CredentialAccessDenied, + CredentialCancelled, AuthenticationFailed, NonFastForward, MergeConflicts { @@ -223,6 +226,12 @@ impl fmt::Display for GitError { Self::CredentialsUnavailable => { formatter.write_str("HTTPS Git credentials are unavailable") } + Self::CredentialAccessDenied => { + formatter.write_str("access to HTTPS Git credentials was denied") + } + Self::CredentialCancelled => { + formatter.write_str("HTTPS Git credential authentication was cancelled") + } Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"), Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"), Self::MergeConflicts { paths } => write!( @@ -254,7 +263,7 @@ pub struct GitCredential { } impl GitCredential { - pub fn new(username: impl Into, password: Vec) -> Result { + pub fn new(username: impl Into, mut password: Vec) -> Result { let username = username.into(); if username.is_empty() || username.contains(['\n', '\r', '\0']) @@ -262,6 +271,7 @@ impl GitCredential { || password.contains(&b'\r') || password.contains(&0) { + password.zeroize(); return Err(GitError::CredentialsUnavailable); } Ok(Self { diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 20c8c06..a715bd5 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -14,6 +14,7 @@ pub mod mutation; pub mod read; pub mod recipient; pub mod repository; +pub mod secret_store; pub mod write; /// Product name shared by the presentation adapters. diff --git a/crates/storage/src/secret_store.rs b/crates/storage/src/secret_store.rs new file mode 100644 index 0000000..c80f3f2 --- /dev/null +++ b/crates/storage/src/secret_store.rs @@ -0,0 +1,775 @@ +//! OS-backed secret storage, opaque references, and bounded in-memory caching. + +use std::{ + collections::BTreeMap, + error::Error, + fmt, + num::NonZeroUsize, + sync::Mutex, + time::{Duration, Instant}, +}; + +use crate::{ + config::{ApplicationId, ServerId}, + crypto::{KeyInfo, SecretProvider, SecretProviderError}, + git::{GitCredential, GitCredentialProvider, GitError}, + repository::SecretBytes, +}; + +mod platform; + +const RECORD_MAGIC: &[u8] = b"IRONSTORAGE-SECRET\0"; +const RECORD_VERSION: u8 = 1; +const MAX_SECRET_BYTES: usize = 1024; +const MAX_CACHE_LIFETIME: Duration = Duration::from_secs(15 * 60); +const MAX_CACHE_CAPACITY: usize = 128; + +/// The purpose and stable, non-secret identity of an OS credential. +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] +pub struct SecretReference { + kind: SecretReferenceKind, +} + +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] +enum SecretReferenceKind { + OpenPgpPassphrase { + fingerprint: String, + }, + HttpsGitCredential { + server_id: String, + application_id: String, + account: String, + }, +} + +impl SecretReference { + pub fn openpgp_passphrase(fingerprint: impl Into) -> Result { + let fingerprint = fingerprint.into(); + if !matches!(fingerprint.len(), 40 | 64) + || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(SecretStoreError::InvalidReference); + } + Ok(Self { + kind: SecretReferenceKind::OpenPgpPassphrase { + fingerprint: fingerprint.to_ascii_uppercase(), + }, + }) + } + + pub fn https_git_credential( + server_id: impl Into, + application_id: impl Into, + account: impl Into, + ) -> Result { + let server_id = server_id.into(); + let application_id = application_id.into(); + let account = account.into(); + validate_identifier(&server_id)?; + validate_identifier(&application_id)?; + if account.is_empty() + || account.len() > 512 + || account.trim() != account + || account.chars().any(char::is_control) + { + return Err(SecretStoreError::InvalidReference); + } + Ok(Self { + kind: SecretReferenceKind::HttpsGitCredential { + server_id, + application_id, + account, + }, + }) + } + + pub fn account(&self) -> Option<&str> { + match &self.kind { + SecretReferenceKind::OpenPgpPassphrase { .. } => None, + SecretReferenceKind::HttpsGitCredential { account, .. } => Some(account), + } + } + + fn locator(&self) -> SecretLocator { + match &self.kind { + SecretReferenceKind::OpenPgpPassphrase { fingerprint } => { + SecretLocator::OpenPgpPassphrase { + fingerprint: fingerprint.clone(), + } + } + SecretReferenceKind::HttpsGitCredential { + server_id, + application_id, + .. + } => SecretLocator::HttpsGitCredential { + server_id: server_id.clone(), + application_id: application_id.clone(), + }, + } + } +} + +impl fmt::Debug for SecretReference { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self { + kind: SecretReferenceKind::OpenPgpPassphrase { .. }, + } => formatter.write_str("SecretReference::OpenPgpPassphrase([REDACTED])"), + Self { + kind: SecretReferenceKind::HttpsGitCredential { .. }, + } => formatter.write_str("SecretReference::HttpsGitCredential([REDACTED])"), + } + } +} + +/// A backend key. Git accounts live inside the protected record so a configured +/// server/application pair can retrieve its account without TOML metadata. +#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] +pub enum SecretLocator { + OpenPgpPassphrase { + fingerprint: String, + }, + HttpsGitCredential { + server_id: String, + application_id: String, + }, +} + +impl fmt::Debug for SecretLocator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OpenPgpPassphrase { .. } => { + formatter.write_str("SecretLocator::OpenPgpPassphrase([REDACTED])") + } + Self::HttpsGitCredential { .. } => { + formatter.write_str("SecretLocator::HttpsGitCredential([REDACTED])") + } + } + } +} + +impl SecretLocator { + fn service_and_user(&self) -> (&'static str, String) { + match self { + Self::OpenPgpPassphrase { fingerprint } => { + ("org.ironstorage.openpgp-passphrase", fingerprint.clone()) + } + Self::HttpsGitCredential { + server_id, + application_id, + } => ( + "org.ironstorage.https-git", + format!("{server_id}/{application_id}"), + ), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretProtection { + DeviceUnlocked, + RequireUserPresence, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SecretProtectionPolicy { + openpgp: SecretProtection, + git: SecretProtection, +} + +impl SecretProtectionPolicy { + pub const fn new(openpgp: SecretProtection, git: SecretProtection) -> Self { + Self { openpgp, git } + } + + pub const fn device_unlocked() -> Self { + Self::new( + SecretProtection::DeviceUnlocked, + SecretProtection::DeviceUnlocked, + ) + } + + pub const fn user_presence_for_openpgp() -> Self { + Self::new( + SecretProtection::RequireUserPresence, + SecretProtection::DeviceUnlocked, + ) + } + + fn for_reference(self, reference: &SecretReference) -> SecretProtection { + match &reference.kind { + SecretReferenceKind::OpenPgpPassphrase { .. } => self.openpgp, + SecretReferenceKind::HttpsGitCredential { .. } => self.git, + } + } + + fn for_locator(self, locator: &SecretLocator) -> SecretProtection { + match locator { + SecretLocator::OpenPgpPassphrase { .. } => self.openpgp, + SecretLocator::HttpsGitCredential { .. } => self.git, + } + } +} + +impl Default for SecretProtectionPolicy { + fn default() -> Self { + Self::device_unlocked() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretCachePolicy { + Disabled, + Timed { + lifetime: Duration, + capacity: NonZeroUsize, + }, +} + +impl SecretCachePolicy { + pub fn timed(lifetime: Duration, capacity: NonZeroUsize) -> Result { + if lifetime.is_zero() + || lifetime > MAX_CACHE_LIFETIME + || capacity.get() > MAX_CACHE_CAPACITY + { + return Err(SecretStoreError::InvalidCachePolicy); + } + Ok(Self::Timed { lifetime, capacity }) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SecretStoreError { + InvalidReference, + InvalidCachePolicy, + AlreadyExists, + Missing, + Locked, + Denied, + Cancelled, + Corrupted, + UnsupportedProtection, + Unavailable, +} + +impl fmt::Display for SecretStoreError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvalidReference => "the secret reference is invalid", + Self::InvalidCachePolicy => "the secret cache policy is invalid", + Self::AlreadyExists => "the referenced secret already exists", + Self::Missing => "the referenced secret does not exist", + Self::Locked => "the secret store is locked", + Self::Denied => "access to the secret store was denied", + Self::Cancelled => "secret-store authentication was cancelled", + Self::Corrupted => "the stored secret record is corrupted", + Self::UnsupportedProtection => { + "the requested secret protection is unsupported on this platform" + } + Self::Unavailable => "the operating-system secret store is unavailable", + }; + formatter.write_str(message) + } +} + +impl Error for SecretStoreError {} + +/// Mockable contract implemented by each operating-system adapter. +pub trait SecretStoreBackend: Send + Sync { + fn create( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError>; + fn retrieve( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result; + fn replace( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError>; + fn delete( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result<(), SecretStoreError>; + fn lock(&self) -> Result<(), SecretStoreError> { + Ok(()) + } + fn unlock(&self) -> Result<(), SecretStoreError> { + Ok(()) + } +} + +struct CachedSecret { + value: SecretBytes, + expires_at: Instant, + sequence: u64, +} + +struct StoreState { + unlocked: bool, + sequence: u64, + cache: BTreeMap, +} + +pub struct SecretStore { + backend: B, + cache_policy: SecretCachePolicy, + protections: SecretProtectionPolicy, + state: Mutex, +} + +impl fmt::Debug for SecretStore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SecretStore") + .field("cache_policy", &self.cache_policy) + .field("protections", &self.protections) + .field("contents", &"[REDACTED]") + .finish() + } +} + +impl SecretStore { + pub fn new( + backend: B, + cache_policy: SecretCachePolicy, + protections: SecretProtectionPolicy, + ) -> Self { + Self { + backend, + cache_policy, + protections, + state: Mutex::new(StoreState { + unlocked: false, + sequence: 0, + cache: BTreeMap::new(), + }), + } + } + + pub fn is_locked(&self) -> bool { + self.state.lock().map_or(true, |state| !state.unlocked) + } + + pub fn unlock(&self) -> Result<(), SecretStoreError> { + let mut state = self + .state + .lock() + .map_err(|_| SecretStoreError::Unavailable)?; + self.backend.unlock()?; + state.unlocked = true; + Ok(()) + } + + pub fn lock(&self) -> Result<(), SecretStoreError> { + let mut state = self + .state + .lock() + .map_err(|_| SecretStoreError::Unavailable)?; + state.unlocked = false; + state.cache.clear(); + self.backend.lock() + } + + pub fn create( + &self, + reference: &SecretReference, + value: SecretBytes, + ) -> Result<(), SecretStoreError> { + validate_secret(&value)?; + let locator = reference.locator(); + let encoded = encode_record(reference, &value)?; + let mut state = self.unlocked_state()?; + self.backend.create( + &locator, + self.protections.for_reference(reference), + encoded.expose(), + )?; + self.cache_insert(&mut state, locator, encoded); + Ok(()) + } + + pub fn retrieve(&self, reference: &SecretReference) -> Result { + let locator = reference.locator(); + let mut state = self.unlocked_state()?; + let encoded = match self.cache_get(&mut state, &locator) { + Some(value) => value, + None => self + .backend + .retrieve(&locator, self.protections.for_reference(reference))?, + }; + let record = decode_record(encoded)?; + if &record.reference != reference { + return Err(SecretStoreError::Missing); + } + let output = copy_secret(&record.value); + self.cache_insert( + &mut state, + locator, + encode_record(&record.reference, &record.value)?, + ); + Ok(output) + } + + pub fn replace( + &self, + reference: &SecretReference, + value: SecretBytes, + ) -> Result<(), SecretStoreError> { + validate_secret(&value)?; + let locator = reference.locator(); + let encoded = encode_record(reference, &value)?; + let mut state = self.unlocked_state()?; + self.require_exact_record(reference, &locator)?; + self.backend.replace( + &locator, + self.protections.for_reference(reference), + encoded.expose(), + )?; + self.cache_insert(&mut state, locator, encoded); + Ok(()) + } + + pub fn delete(&self, reference: &SecretReference) -> Result<(), SecretStoreError> { + let locator = reference.locator(); + let mut state = self.unlocked_state()?; + self.require_exact_record(reference, &locator)?; + self.backend + .delete(&locator, self.protections.for_reference(reference))?; + state.cache.remove(&locator); + Ok(()) + } + + fn retrieve_git_record( + &self, + server: &ServerId, + application: &ApplicationId, + ) -> Result { + let locator = SecretLocator::HttpsGitCredential { + server_id: server.as_str().to_owned(), + application_id: application.as_str().to_owned(), + }; + let mut state = self.unlocked_state()?; + if let Some(value) = self.cache_get(&mut state, &locator) { + return decode_record(value); + } + let encoded = self + .backend + .retrieve(&locator, self.protections.for_locator(&locator))?; + let record = decode_record(encoded)?; + if record.reference.locator() != locator { + return Err(SecretStoreError::Corrupted); + } + self.cache_insert( + &mut state, + locator, + encode_record(&record.reference, &record.value)?, + ); + Ok(record) + } + + fn require_exact_record( + &self, + reference: &SecretReference, + locator: &SecretLocator, + ) -> Result<(), SecretStoreError> { + let encoded = self + .backend + .retrieve(locator, self.protections.for_reference(reference))?; + let record = decode_record(encoded)?; + if &record.reference != reference { + return Err(SecretStoreError::Missing); + } + Ok(()) + } + + fn unlocked_state(&self) -> Result, SecretStoreError> { + let state = self + .state + .lock() + .map_err(|_| SecretStoreError::Unavailable)?; + if !state.unlocked { + return Err(SecretStoreError::Locked); + } + Ok(state) + } + + fn cache_get(&self, state: &mut StoreState, locator: &SecretLocator) -> Option { + let SecretCachePolicy::Timed { .. } = self.cache_policy else { + return None; + }; + let now = Instant::now(); + state.cache.retain(|_, entry| entry.expires_at > now); + state + .cache + .get(locator) + .map(|entry| copy_secret(&entry.value)) + } + + fn cache_insert(&self, state: &mut StoreState, locator: SecretLocator, value: SecretBytes) { + let SecretCachePolicy::Timed { lifetime, capacity } = self.cache_policy else { + return; + }; + let now = Instant::now(); + state.cache.retain(|_, entry| entry.expires_at > now); + state.sequence = state.sequence.wrapping_add(1); + let sequence = state.sequence; + state.cache.insert( + locator, + CachedSecret { + value, + expires_at: now + lifetime, + sequence, + }, + ); + while state.cache.len() > capacity.get() { + let oldest = state + .cache + .iter() + .min_by_key(|(_, entry)| entry.sequence) + .map(|(locator, _)| locator.clone()); + if let Some(oldest) = oldest { + state.cache.remove(&oldest); + } + } + } +} + +pub type NativeSecretStore = SecretStore; + +impl NativeSecretStore { + pub fn system( + cache_policy: SecretCachePolicy, + protections: SecretProtectionPolicy, + ) -> Result { + Ok(Self::new( + platform::NativeSecretBackend::new()?, + cache_policy, + protections, + )) + } +} + +impl SecretProvider for SecretStore { + fn secret_for(&mut self, key: &KeyInfo) -> Result { + let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str()) + .map_err(|_| SecretProviderError::Unavailable)?; + self.retrieve(&reference).map_err(provider_error) + } +} + +impl GitCredentialProvider for SecretStore { + fn credential( + &self, + server: &ServerId, + application: &ApplicationId, + ) -> Result { + let record = self + .retrieve_git_record(server, application) + .map_err(git_provider_error)?; + let Some(account) = record.reference.account() else { + return Err(GitError::CredentialsUnavailable); + }; + if record.value.expose().contains(&b'\n') + || record.value.expose().contains(&b'\r') + || record.value.expose().contains(&0) + { + return Err(GitError::CredentialsUnavailable); + } + GitCredential::new(account, record.value.expose().to_vec()) + } +} + +fn provider_error(error: SecretStoreError) -> SecretProviderError { + match error { + SecretStoreError::Cancelled => SecretProviderError::Cancelled, + _ => SecretProviderError::Unavailable, + } +} + +fn git_provider_error(error: SecretStoreError) -> GitError { + match error { + SecretStoreError::Cancelled => GitError::CredentialCancelled, + SecretStoreError::Denied => GitError::CredentialAccessDenied, + _ => GitError::CredentialsUnavailable, + } +} + +struct SecretRecord { + reference: SecretReference, + value: SecretBytes, +} + +fn encode_record( + reference: &SecretReference, + value: &SecretBytes, +) -> Result { + validate_secret(value)?; + let mut encoded = Vec::with_capacity(RECORD_MAGIC.len() + value.expose().len() + 1024); + encoded.extend_from_slice(RECORD_MAGIC); + encoded.push(RECORD_VERSION); + match &reference.kind { + SecretReferenceKind::OpenPgpPassphrase { fingerprint } => { + encoded.push(1); + write_field(&mut encoded, fingerprint.as_bytes())?; + } + SecretReferenceKind::HttpsGitCredential { + server_id, + application_id, + account, + } => { + encoded.push(2); + write_field(&mut encoded, server_id.as_bytes())?; + write_field(&mut encoded, application_id.as_bytes())?; + write_field(&mut encoded, account.as_bytes())?; + } + } + let length = u32::try_from(value.expose().len()).map_err(|_| SecretStoreError::Corrupted)?; + encoded.extend_from_slice(&length.to_be_bytes()); + encoded.extend_from_slice(value.expose()); + Ok(SecretBytes::new(encoded)) +} + +fn decode_record(encoded: SecretBytes) -> Result { + let bytes = encoded.expose(); + let Some(mut remainder) = bytes.strip_prefix(RECORD_MAGIC) else { + return Err(SecretStoreError::Corrupted); + }; + let Some((&version, rest)) = remainder.split_first() else { + return Err(SecretStoreError::Corrupted); + }; + if version != RECORD_VERSION { + return Err(SecretStoreError::Corrupted); + } + let Some((&kind, rest)) = rest.split_first() else { + return Err(SecretStoreError::Corrupted); + }; + remainder = rest; + let reference = match kind { + 1 => { + let (fingerprint, rest) = read_field(remainder)?; + remainder = rest; + SecretReference::openpgp_passphrase(read_text(fingerprint)?) + .map_err(|_| SecretStoreError::Corrupted)? + } + 2 => { + let (server_id, rest) = read_field(remainder)?; + let (application_id, rest) = read_field(rest)?; + let (account, rest) = read_field(rest)?; + remainder = rest; + SecretReference::https_git_credential( + read_text(server_id)?, + read_text(application_id)?, + read_text(account)?, + ) + .map_err(|_| SecretStoreError::Corrupted)? + } + _ => return Err(SecretStoreError::Corrupted), + }; + if remainder.len() < 4 { + return Err(SecretStoreError::Corrupted); + } + let length = u32::from_be_bytes( + remainder[..4] + .try_into() + .map_err(|_| SecretStoreError::Corrupted)?, + ) as usize; + let value = &remainder[4..]; + if length == 0 || length != value.len() || length > MAX_SECRET_BYTES { + return Err(SecretStoreError::Corrupted); + } + Ok(SecretRecord { + reference, + value: SecretBytes::new(value.to_vec()), + }) +} + +fn write_field(output: &mut Vec, field: &[u8]) -> Result<(), SecretStoreError> { + let length = u16::try_from(field.len()).map_err(|_| SecretStoreError::InvalidReference)?; + output.extend_from_slice(&length.to_be_bytes()); + output.extend_from_slice(field); + Ok(()) +} + +fn read_field(input: &[u8]) -> Result<(&[u8], &[u8]), SecretStoreError> { + if input.len() < 2 { + return Err(SecretStoreError::Corrupted); + } + let length = u16::from_be_bytes( + input[..2] + .try_into() + .map_err(|_| SecretStoreError::Corrupted)?, + ) as usize; + if input.len() < 2 + length { + return Err(SecretStoreError::Corrupted); + } + Ok((&input[2..2 + length], &input[2 + length..])) +} + +fn read_text(input: &[u8]) -> Result { + std::str::from_utf8(input) + .map(str::to_owned) + .map_err(|_| SecretStoreError::Corrupted) +} + +fn validate_identifier(value: &str) -> Result<(), SecretStoreError> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(SecretStoreError::InvalidReference); + } + Ok(()) +} + +fn validate_secret(value: &SecretBytes) -> Result<(), SecretStoreError> { + if value.expose().is_empty() || value.expose().len() > MAX_SECRET_BYTES { + return Err(SecretStoreError::InvalidReference); + } + Ok(()) +} + +fn copy_secret(value: &SecretBytes) -> SecretBytes { + SecretBytes::new(value.expose().to_vec()) +} + +#[cfg(test)] +mod tests { + use super::{RECORD_MAGIC, RECORD_VERSION, SecretBytes, SecretStoreError, decode_record}; + + fn openpgp_record(fingerprint: &[u8], secret: &[u8]) -> SecretBytes { + let mut record = Vec::new(); + record.extend_from_slice(RECORD_MAGIC); + record.push(RECORD_VERSION); + record.push(1); + record.extend_from_slice(&(fingerprint.len() as u16).to_be_bytes()); + record.extend_from_slice(fingerprint); + record.extend_from_slice(&(secret.len() as u32).to_be_bytes()); + record.extend_from_slice(secret); + SecretBytes::new(record) + } + + #[test] + fn decoder_classifies_invalid_stored_fields_as_corruption() { + assert!(matches!( + decode_record(openpgp_record(b"not-a-fingerprint", b"secret")), + Err(SecretStoreError::Corrupted) + )); + assert!(matches!( + decode_record(openpgp_record( + b"0123456789ABCDEF0123456789ABCDEF01234567", + b"" + )), + Err(SecretStoreError::Corrupted) + )); + } +} diff --git a/crates/storage/src/secret_store/platform.rs b/crates/storage/src/secret_store/platform.rs new file mode 100644 index 0000000..209fc48 --- /dev/null +++ b/crates/storage/src/secret_store/platform.rs @@ -0,0 +1,236 @@ +//! Safe adapters around native credential-store crates. +//! +//! This is the only module that selects operating-system implementations. +//! IronStorage performs no direct FFI and contains no unsafe code: the Apple, +//! Windows, and Linux crates own their respective Security Framework, +//! Credential Manager, and Secret Service boundaries. Operations are serialized +//! by the parent `SecretStore`, as required by the Windows adapter contract. + +use std::sync::Arc; + +#[cfg(any(target_os = "ios", target_os = "macos"))] +use std::collections::HashMap; + +use keyring_core::{CredentialStore, Entry}; + +use super::{SecretLocator, SecretProtection, SecretStoreBackend, SecretStoreError}; +use crate::repository::SecretBytes; + +pub struct NativeSecretBackend { + #[cfg(any(target_os = "linux", target_os = "windows"))] + store: Arc, + #[cfg(target_os = "ios")] + protected: Arc, + #[cfg(target_os = "macos")] + keychain: Arc, + #[cfg(target_os = "macos")] + protected: Arc, +} + +impl NativeSecretBackend { + pub fn new() -> Result { + #[cfg(target_os = "linux")] + { + let store: Arc = + zbus_secret_service_keyring_store::Store::new().map_err(map_error)?; + Ok(Self { store }) + } + #[cfg(target_os = "windows")] + { + let store: Arc = + windows_native_keyring_store::Store::new().map_err(map_error)?; + Ok(Self { store }) + } + #[cfg(target_os = "ios")] + { + let protected: Arc = + apple_native_keyring_store::protected::Store::new().map_err(map_error)?; + Ok(Self { protected }) + } + #[cfg(target_os = "macos")] + { + let keychain: Arc = + apple_native_keyring_store::keychain::Store::new().map_err(map_error)?; + let protected: Arc = + apple_native_keyring_store::protected::Store::new().map_err(map_error)?; + Ok(Self { + keychain, + protected, + }) + } + #[cfg(not(any( + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "windows" + )))] + { + Err(SecretStoreError::Unavailable) + } + } + + fn entry( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result { + let (service, user) = locator.service_and_user(); + #[cfg(any(target_os = "linux", target_os = "windows"))] + { + if protection == SecretProtection::RequireUserPresence { + return Err(SecretStoreError::UnsupportedProtection); + } + self.store.build(service, &user, None).map_err(map_error) + } + #[cfg(target_os = "ios")] + { + let modifiers = presence_modifiers(protection); + self.protected + .build(service, &user, modifiers.as_ref()) + .map_err(map_error) + } + #[cfg(target_os = "macos")] + { + match protection { + SecretProtection::DeviceUnlocked => { + self.keychain.build(service, &user, None).map_err(map_error) + } + SecretProtection::RequireUserPresence => { + let modifiers = presence_modifiers(protection); + self.protected + .build(service, &user, modifiers.as_ref()) + .map_err(map_error) + } + } + } + #[cfg(not(any( + target_os = "ios", + target_os = "linux", + target_os = "macos", + target_os = "windows" + )))] + { + let _ = (service, user, protection); + Err(SecretStoreError::Unavailable) + } + } +} + +impl SecretStoreBackend for NativeSecretBackend { + fn create( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + let entry = self.entry(locator, protection)?; + match entry.get_secret() { + Ok(existing) => { + drop(SecretBytes::new(existing)); + Err(SecretStoreError::AlreadyExists) + } + Err(keyring_core::Error::NoEntry) => entry.set_secret(value).map_err(map_error), + Err(error) => Err(map_error(error)), + } + } + + fn retrieve( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result { + self.entry(locator, protection)? + .get_secret() + .map(SecretBytes::new) + .map_err(map_error) + } + + fn replace( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + let entry = self.entry(locator, protection)?; + let existing = entry.get_secret().map_err(map_error)?; + drop(SecretBytes::new(existing)); + entry.set_secret(value).map_err(map_error) + } + + fn delete( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result<(), SecretStoreError> { + self.entry(locator, protection)? + .delete_credential() + .map_err(map_error) + } +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn presence_modifiers(protection: SecretProtection) -> Option> { + match protection { + SecretProtection::DeviceUnlocked => None, + SecretProtection::RequireUserPresence => { + Some(HashMap::from([("access-policy", "require-user-presence")])) + } + } +} + +fn map_error(error: keyring_core::Error) -> SecretStoreError { + match error { + keyring_core::Error::NoEntry => SecretStoreError::Missing, + keyring_core::Error::NoStorageAccess(error) => platform_access_error(&error), + keyring_core::Error::BadEncoding(bytes) => { + drop(SecretBytes::new(bytes)); + SecretStoreError::Corrupted + } + keyring_core::Error::BadDataFormat(bytes, _) => { + drop(SecretBytes::new(bytes)); + SecretStoreError::Corrupted + } + keyring_core::Error::BadStoreFormat(_) | keyring_core::Error::Ambiguous(_) => { + SecretStoreError::Corrupted + } + keyring_core::Error::TooLong(_, _) | keyring_core::Error::Invalid(_, _) => { + SecretStoreError::InvalidReference + } + keyring_core::Error::NotSupportedByStore(_) | keyring_core::Error::NoDefaultStore => { + SecretStoreError::Unavailable + } + keyring_core::Error::PlatformFailure(error) => { + if platform_cancelled(&error) { + SecretStoreError::Cancelled + } else { + SecretStoreError::Unavailable + } + } + _ => SecretStoreError::Unavailable, + } +} + +#[cfg(any(target_os = "ios", target_os = "macos"))] +fn platform_cancelled(error: &keyring_core::error::PlatformError) -> bool { + error + .downcast_ref::() + .is_some_and(|error| error.code() == -128) +} + +#[cfg(not(any(target_os = "ios", target_os = "macos")))] +fn platform_cancelled(_error: &keyring_core::error::PlatformError) -> bool { + false +} + +#[cfg(target_os = "linux")] +fn platform_access_error(error: &keyring_core::error::PlatformError) -> SecretStoreError { + match error.downcast_ref::() { + Some(secret_service::Error::Prompt) => SecretStoreError::Cancelled, + _ => SecretStoreError::Denied, + } +} + +#[cfg(not(target_os = "linux"))] +fn platform_access_error(_error: &keyring_core::error::PlatformError) -> SecretStoreError { + SecretStoreError::Denied +} diff --git a/crates/storage/tests/secret_store.rs b/crates/storage/tests/secret_store.rs new file mode 100644 index 0000000..7ed1a6e --- /dev/null +++ b/crates/storage/tests/secret_store.rs @@ -0,0 +1,351 @@ +#![forbid(unsafe_code)] + +mod support; + +use std::{ + collections::BTreeMap, + error::Error, + num::NonZeroUsize, + sync::{Arc, Mutex}, + time::Duration, +}; + +use ironstorage::{ + config::ConfigLoader, + crypto::KeyStore, + git::{GitCredentialProvider as _, GitError}, + repository::{EncryptedEntry, SecretBytes}, + secret_store::{ + SecretCachePolicy, SecretLocator, SecretProtection, SecretProtectionPolicy, + SecretReference, SecretStore, SecretStoreBackend, SecretStoreError, + }, +}; +use support::compatibility::FixtureSet; + +type TestResult = Result<(), Box>; + +#[derive(Default)] +struct MemoryState { + values: BTreeMap, + fault: Option, + retrieves: usize, + protections: Vec, +} + +#[derive(Clone, Default)] +struct MemoryBackend(Arc>); + +impl MemoryBackend { + fn fail_next(&self, error: SecretStoreError) { + self.0.lock().expect("test mutex").fault = Some(error); + } + + fn corrupt_first(&self) { + let mut state = self.0.lock().expect("test mutex"); + let value = state.values.values_mut().next().expect("stored test value"); + *value = SecretBytes::new(b"not an IronStorage record".to_vec()); + } + + fn retrieves(&self) -> usize { + self.0.lock().expect("test mutex").retrieves + } + + fn protections(&self) -> Vec { + self.0.lock().expect("test mutex").protections.clone() + } + + fn take_fault(state: &mut MemoryState) -> Result<(), SecretStoreError> { + match state.fault.take() { + Some(error) => Err(error), + None => Ok(()), + } + } +} + +impl SecretStoreBackend for MemoryBackend { + fn create( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; + Self::take_fault(&mut state)?; + state.protections.push(protection); + if state.values.contains_key(locator) { + return Err(SecretStoreError::AlreadyExists); + } + state + .values + .insert(locator.clone(), SecretBytes::new(value.to_vec())); + Ok(()) + } + + fn retrieve( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result { + let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; + Self::take_fault(&mut state)?; + state.retrieves += 1; + state.protections.push(protection); + state + .values + .get(locator) + .map(|value| SecretBytes::new(value.expose().to_vec())) + .ok_or(SecretStoreError::Missing) + } + + fn replace( + &self, + locator: &SecretLocator, + protection: SecretProtection, + value: &[u8], + ) -> Result<(), SecretStoreError> { + let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; + Self::take_fault(&mut state)?; + state.protections.push(protection); + let existing = state + .values + .get_mut(locator) + .ok_or(SecretStoreError::Missing)?; + *existing = SecretBytes::new(value.to_vec()); + Ok(()) + } + + fn delete( + &self, + locator: &SecretLocator, + protection: SecretProtection, + ) -> Result<(), SecretStoreError> { + let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; + Self::take_fault(&mut state)?; + state.protections.push(protection); + state + .values + .remove(locator) + .map(drop) + .ok_or(SecretStoreError::Missing) + } + + fn lock(&self) -> Result<(), SecretStoreError> { + let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; + Self::take_fault(&mut state) + } + + fn unlock(&self) -> Result<(), SecretStoreError> { + let mut state = self.0.lock().map_err(|_| SecretStoreError::Unavailable)?; + Self::take_fault(&mut state) + } +} + +fn store(backend: MemoryBackend) -> SecretStore { + SecretStore::new( + backend, + SecretCachePolicy::Disabled, + SecretProtectionPolicy::device_unlocked(), + ) +} + +fn fingerprint_reference() -> SecretReference { + SecretReference::openpgp_passphrase("0123456789ABCDEF0123456789ABCDEF01234567") + .expect("valid fingerprint") +} + +#[test] +fn lifecycle_is_explicit_and_create_never_silently_replaces() -> TestResult { + let backend = MemoryBackend::default(); + let store = store(backend); + let reference = fingerprint_reference(); + + assert!(matches!( + store.retrieve(&reference), + Err(SecretStoreError::Locked) + )); + store.unlock()?; + assert!(matches!( + store.retrieve(&reference), + Err(SecretStoreError::Missing) + )); + store.create(&reference, SecretBytes::new(b"first".to_vec()))?; + assert_eq!(store.retrieve(&reference)?.expose(), b"first"); + assert_eq!( + store.create(&reference, SecretBytes::new(b"other".to_vec())), + Err(SecretStoreError::AlreadyExists) + ); + store.replace(&reference, SecretBytes::new(b"second".to_vec()))?; + assert_eq!(store.retrieve(&reference)?.expose(), b"second"); + store.delete(&reference)?; + assert!(matches!( + store.retrieve(&reference), + Err(SecretStoreError::Missing) + )); + assert_eq!(store.delete(&reference), Err(SecretStoreError::Missing)); + store.lock()?; + assert!(store.is_locked()); + Ok(()) +} + +#[test] +fn denied_cancelled_unavailable_and_corrupted_are_typed_and_redacted() -> TestResult { + let backend = MemoryBackend::default(); + backend.fail_next(SecretStoreError::Cancelled); + let store = store(backend.clone()); + assert_eq!(store.unlock(), Err(SecretStoreError::Cancelled)); + assert!(store.is_locked()); + store.unlock()?; + + let reference = SecretReference::https_git_credential( + "fixture-server", + "fixture-app", + "secret-account-name", + )?; + backend.fail_next(SecretStoreError::Denied); + assert!(matches!( + store.retrieve(&reference), + Err(SecretStoreError::Denied) + )); + backend.fail_next(SecretStoreError::Unavailable); + assert!(matches!( + store.retrieve(&reference), + Err(SecretStoreError::Unavailable) + )); + store.create(&reference, SecretBytes::new(b"token".to_vec()))?; + backend.corrupt_first(); + assert!(matches!( + store.retrieve(&reference), + Err(SecretStoreError::Corrupted) + )); + + let rendered = format!("{reference:?} {store:?} {}", SecretStoreError::Corrupted); + assert!(!rendered.contains("secret-account-name")); + assert!(!rendered.contains("not an IronStorage record")); + Ok(()) +} + +#[test] +fn bounded_cache_is_cleared_by_lock_and_never_aliases_git_accounts() -> TestResult { + let backend = MemoryBackend::default(); + let policy = SecretCachePolicy::timed( + Duration::from_secs(60), + NonZeroUsize::new(1).expect("non-zero"), + )?; + let store = SecretStore::new( + backend.clone(), + policy, + SecretProtectionPolicy::device_unlocked(), + ); + store.unlock()?; + let alice = SecretReference::https_git_credential("server", "application", "alice")?; + store.create(&alice, SecretBytes::new(b"token".to_vec()))?; + assert_eq!(store.retrieve(&alice)?.expose(), b"token"); + assert_eq!( + backend.retrieves(), + 0, + "create populated the explicit cache" + ); + + let bob = SecretReference::https_git_credential("server", "application", "bob")?; + assert!(matches!( + store.retrieve(&bob), + Err(SecretStoreError::Missing) + )); + assert_eq!( + store.replace(&bob, SecretBytes::new(b"other".to_vec())), + Err(SecretStoreError::Missing) + ); + assert_eq!(store.delete(&bob), Err(SecretStoreError::Missing)); + assert_eq!(store.retrieve(&alice)?.expose(), b"token"); + let passphrase = fingerprint_reference(); + store.create(&passphrase, SecretBytes::new(b"passphrase".to_vec()))?; + assert_eq!(store.retrieve(&alice)?.expose(), b"token"); + assert_eq!(backend.retrieves(), 3, "capacity evicted the older record"); + store.lock()?; + store.unlock()?; + assert_eq!(store.retrieve(&alice)?.expose(), b"token"); + assert_eq!(backend.retrieves(), 4, "lock discarded cached bytes"); + assert!( + SecretCachePolicy::timed( + Duration::from_secs(16 * 60), + NonZeroUsize::new(1).expect("non-zero") + ) + .is_err() + ); + Ok(()) +} + +#[test] +fn one_unlocked_provider_supplies_openpgp_and_https_git_secrets() -> TestResult { + let fixture = FixtureSet::load()?; + let key = fixture.key("alice")?; + let keys = KeyStore::load(fixture.path("keys"))?; + let backend = MemoryBackend::default(); + let mut store = SecretStore::new( + backend.clone(), + SecretCachePolicy::Disabled, + SecretProtectionPolicy::user_presence_for_openpgp(), + ); + store.unlock()?; + let passphrase = SecretReference::openpgp_passphrase(&key.primary_fingerprint)?; + store.create( + &passphrase, + SecretBytes::new(key.passphrase.as_bytes().to_vec()), + )?; + let entry = fixture + .generated + .entries + .iter() + .find(|entry| entry.store == "basic" && entry.path == "email/personal.gpg") + .expect("compatibility entry exists"); + assert_eq!( + keys.decrypt( + &EncryptedEntry::new(fixture.read(format!("stores/basic/{}", entry.path))?), + &mut store, + )? + .expose(), + fixture.read("expected/basic/email/personal.txt")? + ); + + let temporary = tempfile::tempdir()?; + fs_config(&temporary)?; + let config = ConfigLoader::new(temporary.path().to_owned(), temporary.path().join("native")) + .load(Some(&temporary.path().join("config.toml")))?; + let remote = &config.git_remotes()[0]; + backend.fail_next(SecretStoreError::Cancelled); + assert!(matches!( + store.credential(remote.server_id(), remote.application_id()), + Err(GitError::CredentialCancelled) + )); + backend.fail_next(SecretStoreError::Denied); + assert!(matches!( + store.credential(remote.server_id(), remote.application_id()), + Err(GitError::CredentialAccessDenied) + )); + let git = SecretReference::https_git_credential( + remote.server_id().as_str(), + remote.application_id().as_str(), + "alice", + )?; + store.create(&git, SecretBytes::new(b"https-token".to_vec()))?; + let credential = store.credential(remote.server_id(), remote.application_id())?; + assert_eq!(credential.username(), "alice"); + assert_eq!(credential.password(), b"https-token"); + assert!( + backend + .protections() + .contains(&SecretProtection::RequireUserPresence) + ); + Ok(()) +} + +fn fs_config(temporary: &tempfile::TempDir) -> TestResult { + std::fs::create_dir_all(temporary.path().join("keys"))?; + std::fs::create_dir_all(temporary.path().join("vault"))?; + std::fs::create_dir_all(temporary.path().join("native"))?; + std::fs::write( + temporary.path().join("config.toml"), + "vault = 'vault'\ndefault_key = 'alice'\nkey_material = 'keys'\n[[git.remotes]]\nname = 'origin'\nurl = 'https://example.test/store.git'\nserver_id = 'server'\napplication_id = 'application'\n", + )?; + Ok(()) +} diff --git a/docs/configuration.md b/docs/configuration.md index 4df73c2..a0e5c9a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,6 +46,8 @@ Git remotes are HTTPS-only. URLs containing user information, passwords, queries, or fragments are rejected. `server_id` and `application_id` are opaque references used to retrieve credentials from the operating-system secret store; duplicate names and duplicate reference pairs are errors. +The HTTPS account name is stored inside the protected credential record, not in +TOML. OpenPGP passphrases are addressed by the resolved primary fingerprint. Passwords, passphrases, tokens, credentials, private keys, and other secret values are forbidden in TOML. Unknown fields are rejected. Parse errors never diff --git a/docs/cryptography.md b/docs/cryptography.md index c67f61d..8dcea29 100644 --- a/docs/cryptography.md +++ b/docs/cryptography.md @@ -52,6 +52,9 @@ debug representation is redacted and its allocation is zeroed on drop. The OpenPGP backend's password type also zeroes its owned storage. Plaintext enters the message encoder through an owning reader instead of being copied into an ordinary intermediate buffer. +The production provider is the storage-owned native secret store documented in +[`secure-secret-storage.md`](secure-secret-storage.md); protected-key +passphrases are addressed only by their primary fingerprint. Detached `.gpg-id.sig` files use binary-document signatures with SHA-256 and issuer fingerprint/key-ID metadata. Verification succeeds only when the valid diff --git a/docs/secure-secret-storage.md b/docs/secure-secret-storage.md new file mode 100644 index 0000000..d022d23 --- /dev/null +++ b/docs/secure-secret-storage.md @@ -0,0 +1,54 @@ +# Secure secret storage + +`crates/storage` owns the complete secret-storage contract. Configuration, +password-store repositories, Git configuration, command arguments, and logs +contain only opaque identifiers; passphrases, tokens, and HTTPS passwords are +stored by the operating system. + +`SecretReference` has two validated forms. OpenPGP passphrases are keyed by the +primary fingerprint. HTTPS Git credentials are keyed by purpose, server ID, +application ID, and account. The account is kept inside the protected record, +so a configured server/application pair can retrieve it without adding an +account or secret value to TOML. References, locators, store state, and errors +all use redacted `Debug` output. + +Stored values use a small versioned binary envelope containing their reference +and secret bytes. Retrieval validates the envelope and exact reference before +release. Malformed or ambiguous records produce a typed `Corrupted` error; +asking for another account at the same Git locator produces `Missing` without +altering the stored account. `create` refuses to overwrite, `replace` requires +an exact existing record, and missing, denied, cancelled, locked, corrupted, +unsupported-policy, and unavailable-store results remain distinct. Returned +and cached bytes use `SecretBytes`, which zeroizes its allocation when dropped. + +## Operating-system adapters + +The platform-selection code is isolated in +`crates/storage/src/secret_store/platform.rs` and calls only safe Rust APIs: + +- macOS uses legacy Keychain for command-line-compatible device-unlocked + credentials and Protected Data for `RequireUserPresence`; iOS uses Protected + Data. User cancellation is mapped from the native Security Framework status. +- Windows uses Credential Manager. Store operations are serialized because the + upstream adapter documents unreliable same-entry sequencing across threads. +- Linux uses Secret Service through zbus with the Rust cryptography feature. It + does not launch `secret-tool`, a shell, or any other helper. A missing session + service is a typed `Unavailable` result. + +IronStorage contains no direct platform FFI or unsafe Rust. The selected adapter +crates own their OS calls, so there is no project-local FFI safety contract +beyond providing validated UTF-8 identifiers and bounded byte slices. + +## Locking and caching + +A new store starts logically locked. `unlock` must succeed before create, +retrieve, replace, or delete; `lock` immediately clears all cached values even +if the platform lock operation reports an error. Caching is disabled unless a +caller explicitly selects `SecretCachePolicy::Timed`. Timed policies are capped +at 128 entries and 15 minutes, expire lazily, and are always cleared on lock. + +The same unlocked store implements the OpenPGP `SecretProvider` and HTTPS Git +`GitCredentialProvider`. The CLI uses it for terminal `show` and embedded `git +fetch`, proving that protected keys and remote authentication are resolved only +through opaque references. Tests inject a memory backend and never access a +developer or CI user keyring.