Implement secure OS-backed secret storage

This commit is contained in:
Hermes Agent
2026-08-10 00:23:40 +00:00
parent 75ce19da00
commit b685a4864c
14 changed files with 1838 additions and 9 deletions

125
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -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<B: SecretStoreBackend, O: Write, E: Write>(
config: &Config,
request: &CommandRequest,
secrets: &mut SecretStore<B>,
stdout: &mut O,
stderr: &mut E,
) -> Result<u8, ()> {
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<E: Write>(stderr: &mut E, error: impl std::fmt::Display) -> Result<u8, ()> {
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<dyn Error>>;
#[derive(Clone, Default)]
struct MemoryBackend(Arc<Mutex<BTreeMap<SecretLocator, SecretBytes>>>);
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<SecretBytes, SecretStoreError> {
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(())
}
}

View File

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

View File

@@ -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<String>, password: Vec<u8>) -> Result<Self, GitError> {
pub fn new(username: impl Into<String>, mut password: Vec<u8>) -> Result<Self, GitError> {
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 {

View File

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

View File

@@ -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<String>) -> Result<Self, SecretStoreError> {
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<String>,
application_id: impl Into<String>,
account: impl Into<String>,
) -> Result<Self, SecretStoreError> {
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<Self, SecretStoreError> {
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<SecretBytes, SecretStoreError>;
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<SecretLocator, CachedSecret>,
}
pub struct SecretStore<B> {
backend: B,
cache_policy: SecretCachePolicy,
protections: SecretProtectionPolicy,
state: Mutex<StoreState>,
}
impl<B> fmt::Debug for SecretStore<B> {
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<B: SecretStoreBackend> SecretStore<B> {
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<SecretBytes, SecretStoreError> {
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<SecretRecord, SecretStoreError> {
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<std::sync::MutexGuard<'_, StoreState>, 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<SecretBytes> {
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<platform::NativeSecretBackend>;
impl NativeSecretStore {
pub fn system(
cache_policy: SecretCachePolicy,
protections: SecretProtectionPolicy,
) -> Result<Self, SecretStoreError> {
Ok(Self::new(
platform::NativeSecretBackend::new()?,
cache_policy,
protections,
))
}
}
impl<B: SecretStoreBackend> SecretProvider for SecretStore<B> {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
let reference = SecretReference::openpgp_passphrase(key.fingerprint().as_str())
.map_err(|_| SecretProviderError::Unavailable)?;
self.retrieve(&reference).map_err(provider_error)
}
}
impl<B: SecretStoreBackend> GitCredentialProvider for SecretStore<B> {
fn credential(
&self,
server: &ServerId,
application: &ApplicationId,
) -> Result<GitCredential, GitError> {
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<SecretBytes, SecretStoreError> {
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<SecretRecord, SecretStoreError> {
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<u8>, 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<String, SecretStoreError> {
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)
));
}
}

View File

@@ -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<CredentialStore>,
#[cfg(target_os = "ios")]
protected: Arc<CredentialStore>,
#[cfg(target_os = "macos")]
keychain: Arc<CredentialStore>,
#[cfg(target_os = "macos")]
protected: Arc<CredentialStore>,
}
impl NativeSecretBackend {
pub fn new() -> Result<Self, SecretStoreError> {
#[cfg(target_os = "linux")]
{
let store: Arc<CredentialStore> =
zbus_secret_service_keyring_store::Store::new().map_err(map_error)?;
Ok(Self { store })
}
#[cfg(target_os = "windows")]
{
let store: Arc<CredentialStore> =
windows_native_keyring_store::Store::new().map_err(map_error)?;
Ok(Self { store })
}
#[cfg(target_os = "ios")]
{
let protected: Arc<CredentialStore> =
apple_native_keyring_store::protected::Store::new().map_err(map_error)?;
Ok(Self { protected })
}
#[cfg(target_os = "macos")]
{
let keychain: Arc<CredentialStore> =
apple_native_keyring_store::keychain::Store::new().map_err(map_error)?;
let protected: Arc<CredentialStore> =
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<Entry, SecretStoreError> {
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<SecretBytes, SecretStoreError> {
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<HashMap<&'static str, &'static str>> {
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::<security_framework::base::Error>()
.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::<secret_service::Error>() {
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
}

View File

@@ -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<dyn Error>>;
#[derive(Default)]
struct MemoryState {
values: BTreeMap<SecretLocator, SecretBytes>,
fault: Option<SecretStoreError>,
retrieves: usize,
protections: Vec<SecretProtection>,
}
#[derive(Clone, Default)]
struct MemoryBackend(Arc<Mutex<MemoryState>>);
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<SecretProtection> {
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<SecretBytes, SecretStoreError> {
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<MemoryBackend> {
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(())
}

View File

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

View File

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

View File

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