From cc258d07c0e44315dff8556998d9a1b3ca5accb0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:44:49 +0300 Subject: [PATCH 01/26] feat(keyring): add storage master key resolution Add a storage_master_key helper that returns the master key used to encrypt secrets on a configured storage backend, reusing the key loaded by init_master_key when present and otherwise resolving it once from the environment, key file, or OS keychain. When no source provides the key it fails closed so storage secrets are never written unencrypted or under a freshly minted key that would orphan existing ones. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../keyring/encrypted_file_backend.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs b/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs index c29132f4064..e12c82b8f3c 100644 --- a/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs +++ b/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs @@ -115,6 +115,35 @@ pub fn init_master_key() -> Result<(), String> { }) } +/// The master key that encrypts secrets on a configured storage backend +/// ([`crate::storage::secrets`]): the key [`init_master_key`] loaded when +/// there is one, otherwise the same resolution run once for storage — +/// [`MASTER_KEY_ENV`] / [`MASTER_KEY_FILE_ENV`] first, then the OS keychain. +/// +/// # Errors +/// +/// When no source can provide the key. Storage secrets then fail closed: +/// they are never written unencrypted or under a freshly minted key that +/// would orphan the ones already stored. +pub(crate) fn storage_master_key() -> Result<[u8; KEY_LEN], String> { + static STORAGE_MASTER_KEY: OnceLock> = OnceLock::new(); + if let Some(Ok(Some(key))) = MASTER_KEY.get() { + return Ok(*key); + } + STORAGE_MASTER_KEY + .get_or_init(|| match try_load_master_key() { + Ok((key, source)) => { + log::info!("[keyring:storage] master key loaded from {source}"); + Ok(key) + } + Err(MasterKeyError::Configured(error) | MasterKeyError::Keychain(error)) => { + log::error!("[keyring:storage] master key unavailable: {error}"); + Err(error) + } + }) + .clone() +} + /// Runs `init` at most once per `cell` and reports its outcome on every call. /// /// A configuration error is stored in the cell rather than leaving it empty From cd699f3755ebafbab4a752f3f8bbea63268a8ba0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:45:03 +0300 Subject: [PATCH 02/26] chore(core): enable secrets feature for tinystoragedrivers The tinystoragedrivers dependency now enables its secrets feature so the storage layer can back the new secrets module. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/Cargo.toml | 2 +- crates/openhuman-core/src/storage/secrets.rs | 59 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 crates/openhuman-core/src/storage/secrets.rs diff --git a/crates/openhuman-core/Cargo.toml b/crates/openhuman-core/Cargo.toml index fd7f62e7b81..bf311e2d1af 100644 --- a/crates/openhuman-core/Cargo.toml +++ b/crates/openhuman-core/Cargo.toml @@ -152,7 +152,7 @@ tinyagents-registry = { path = "../../vendor/tinyagents/crates/tinyagents-regist tinyagents-session = { path = "../../vendor/tinyagents/crates/tinyagents-session", features = ["storage-drivers"] } # The storage ports and the URL-to-backend facade (`storage` domain). No driver # by default: the `storage-*` features below add them. -tinystoragedrivers = { path = "../../vendor/tinyagents/vendor/tinystoragedrivers/crates/tinystoragedrivers", default-features = false, features = ["blocking"] } +tinystoragedrivers = { path = "../../vendor/tinyagents/vendor/tinystoragedrivers/crates/tinystoragedrivers", default-features = false, features = ["blocking", "secrets"] } tinyagents-runtime = { path = "../../vendor/tinyagents/crates/tinyagents-runtime" } tinyagents-orchestration = { path = "../../vendor/tinyagents/crates/tinyagents-orchestration" } tinyinference-core = { path = "../../vendor/tinyagents/vendor/tinyinference/crates/tinyinference-core" } diff --git a/crates/openhuman-core/src/storage/secrets.rs b/crates/openhuman-core/src/storage/secrets.rs new file mode 100644 index 00000000000..c234b0661d1 --- /dev/null +++ b/crates/openhuman-core/src/storage/secrets.rs @@ -0,0 +1,59 @@ +//! Secrets on the installed storage backend. +//! +//! With a backend configured, the keyring's user secrets +//! ([`crate::security::keyring::get`] / `set` / `delete`) and the credential +//! stores' files live here instead of the OS keychain or `secrets.enc`: one +//! encrypted document per secret (`tinystoragedrivers` `DocumentSecrets`, +//! `enc2:` ChaCha20-Poly1305), under the acting agent's storage scope. +//! +//! Each scope has its own data key, derived from one master key with +//! HKDF-SHA256 (`DerivedKeys`), so one tenant's key exposes no other's. The +//! master key is the keyring's: `OPENHUMAN_KEYRING_MASTER_KEY` / +//! `OPENHUMAN_KEYRING_MASTER_KEY_FILE`, else the OS keychain. With no master +//! key the secrets fail closed. +//! +//! The config-field encryption key (`SecretStore`'s `secretstore.master_key`) +//! does **not** move: it encrypts the process's own `config.toml`, which is +//! loaded before any agent acts, so it stays on the process keyring backend. + +use std::sync::{Arc, OnceLock}; + +use tinystoragedrivers::secrets::{DerivedKeys, DocumentSecrets, KeyProvider}; +use zeroize::Zeroizing; + +use super::{current_scoped, ScopedStorage, StorageError}; + +/// The key provider every scope's secrets use, built once per process. +fn keys() -> Result, StorageError> { + static KEYS: OnceLock, String>> = OnceLock::new(); + KEYS.get_or_init(|| { + crate::security::keyring::encrypted_file_backend::storage_master_key().map(|master| { + Arc::new(DerivedKeys::new(Zeroizing::new(master))) as Arc + }) + }) + .clone() + .map_err(|error| StorageError::crypto(format!("no master key for storage secrets: {error}"))) +} + +/// The secret store for this call when the host configured a backend, in +/// the acting agent's scope; `None` keeps secrets on the process keyring. +/// +/// # Errors +/// +/// When the scope cannot be resolved (SaaS mode with no acting agent) or no +/// master key is available. +pub fn current() -> Result, StorageError> { + let Some(scoped) = current_scoped()? else { + return Ok(None); + }; + Ok(Some(over(&scoped, keys()?))) +} + +/// Secrets in `scoped` under `keys` (tests and explicit scopes). +pub fn over(scoped: &ScopedStorage, keys: Arc) -> DocumentSecrets { + DocumentSecrets::new(scoped, keys) +} + +#[cfg(test)] +#[path = "secrets_tests.rs"] +mod tests; From 90a624eb2d47f16ee820781efb6d2cae45853466 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:45:27 +0300 Subject: [PATCH 03/26] feat(keyring): route secrets through the storage backend when configured The keyring get, set, and delete operations now use the storage-backed secret store when the host has configured one, falling back to the process backend otherwise. The process-only paths were split into process_get, process_set, and process_delete so that the availability probe and app-level keys stay bound to this process, and is_available now reports true whenever a storage backend is installed since those secrets no longer depend on the OS keychain. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/security/keyring/ops.rs | 105 +++++++++++++++--- crates/openhuman-core/src/storage/mod.rs | 2 + 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/crates/openhuman-core/src/security/keyring/ops.rs b/crates/openhuman-core/src/security/keyring/ops.rs index 9760c4c868f..66cd82c1844 100644 --- a/crates/openhuman-core/src/security/keyring/ops.rs +++ b/crates/openhuman-core/src/security/keyring/ops.rs @@ -38,12 +38,83 @@ pub enum MigrationOutcome { } // ── Core operations ─────────────────────────────────────────────────────────── +// +// `get` / `set` / `delete` serve a user's secrets from the storage backend +// when the host configured one (`crate::storage::secrets`, the acting agent's +// scope), else from the process backend. The `process_*` functions always use +// the process backend: the availability probe and the app-level keys +// (`get_or_create_random`, `migrate_from_file` — the config encryption key) +// belong to this process, not to an agent. + +/// The storage-backed secret store for this call, when one is configured. +fn storage_secrets( +) -> Result, KeyringError> { + crate::storage::secrets::current() + .map_err(|error| KeyringError::Backend(format!("storage secrets: {error}"))) +} + +fn storage_error(error: crate::storage::StorageError) -> KeyringError { + KeyringError::Backend(format!("storage secrets: {error}")) +} -/// Retrieve a secret from the active backend. +/// Retrieve a secret: from the storage backend when one is configured, +/// else from the process backend. /// /// Returns `Ok(None)` when no entry exists for this user + key combination. /// Never logs the secret value. pub fn get(user_id: &str, key: &str) -> Result, KeyringError> { + use tinystoragedrivers::secrets::SecretStore as _; + let Some(secrets) = storage_secrets()? else { + return process_get(user_id, key); + }; + log::debug!("[keyring] get (storage) user_id={user_id} key={key}"); + let name = namespaced_key(user_id, key); + let value = crate::storage::block_on(async move { secrets.get(&name).await }) + .map_err(storage_error)?; + value + .map(|bytes| { + String::from_utf8(bytes.to_vec()).map_err(|_| KeyringError::InvalidUtf8 { + key: key.to_string(), + }) + }) + .transpose() +} + +/// Store a secret: in the storage backend when one is configured, else in +/// the process backend. Overwrites any existing entry. Never logs the value. +pub fn set(user_id: &str, key: &str, value: &str) -> Result<(), KeyringError> { + use tinystoragedrivers::secrets::SecretStore as _; + let Some(secrets) = storage_secrets()? else { + return process_set(user_id, key, value); + }; + log::debug!("[keyring] set (storage) user_id={user_id} key={key}"); + let name = namespaced_key(user_id, key); + let value = zeroize::Zeroizing::new(value.as_bytes().to_vec()); + crate::storage::block_on(async move { secrets.set(&name, &value).await }) + .map_err(storage_error) +} + +/// Delete a secret: from the storage backend when one is configured, else +/// from the process backend. Idempotent. +pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { + use tinystoragedrivers::secrets::SecretStore as _; + let Some(secrets) = storage_secrets()? else { + return process_delete(user_id, key); + }; + log::debug!("[keyring] delete (storage) user_id={user_id} key={key}"); + let name = namespaced_key(user_id, key); + crate::storage::block_on(async move { secrets.delete(&name).await.map(|_| ()) }) + .map_err(storage_error) +} + +// ── Process backend ─────────────────────────────────────────────────────────── + + +/// Retrieve a secret from the process backend. +/// +/// Returns `Ok(None)` when no entry exists for this user + key combination. +/// Never logs the secret value. +pub(crate) fn process_get(user_id: &str, key: &str) -> Result, KeyringError> { log::debug!("[keyring] get user_id={user_id} key={key}"); let namespaced = namespaced_key(user_id, key); let result = backend().get(&namespaced); @@ -58,10 +129,10 @@ pub fn get(user_id: &str, key: &str) -> Result, KeyringError> { result } -/// Store a secret in the active backend. +/// Store a secret in the process backend. /// /// Overwrites any existing entry for this user + key. Never logs the value. -pub fn set(user_id: &str, key: &str, value: &str) -> Result<(), KeyringError> { +pub(crate) fn process_set(user_id: &str, key: &str, value: &str) -> Result<(), KeyringError> { log::debug!("[keyring] set user_id={user_id} key={key}"); let namespaced = namespaced_key(user_id, key); let result = backend().set(&namespaced, value); @@ -75,10 +146,10 @@ pub fn set(user_id: &str, key: &str, value: &str) -> Result<(), KeyringError> { result } -/// Delete a secret from the active backend. +/// Delete a secret from the process backend. /// /// Returns `Ok(())` even if no entry existed (idempotent). -pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { +pub(crate) fn process_delete(user_id: &str, key: &str) -> Result<(), KeyringError> { log::debug!("[keyring] delete user_id={user_id} key={key}"); let namespaced = namespaced_key(user_id, key); let result = backend().delete(&namespaced); @@ -103,6 +174,10 @@ pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { /// the macOS access-permission dialogs they trigger) when polled by /// wallet guards or snapshot loops. pub fn is_available() -> bool { + // Secrets on a storage backend do not depend on the OS keychain. + if crate::storage::installed().is_some() { + return true; + } let mut cached = AVAILABILITY_CACHE.lock(); if let Some(val) = *cached { return val; @@ -150,10 +225,10 @@ fn probe_availability() -> bool { // Without this, `set` fails with "item already exists" (-25299) on // every launch after the first, causing `is_available` to incorrectly // return false even when the keychain is fully functional. - let _ = delete(PROBE_USER, PROBE_KEY); - set(PROBE_USER, PROBE_KEY, PROBE_VALUE)?; - let readback = get(PROBE_USER, PROBE_KEY)?; - delete(PROBE_USER, PROBE_KEY)?; + let _ = process_delete(PROBE_USER, PROBE_KEY); + process_set(PROBE_USER, PROBE_KEY, PROBE_VALUE)?; + let readback = process_get(PROBE_USER, PROBE_KEY)?; + process_delete(PROBE_USER, PROBE_KEY)?; Ok(readback.as_deref() == Some(PROBE_VALUE)) })(); @@ -195,7 +270,7 @@ pub fn get_or_create_random( )); } - if let Some(existing) = get(user_id, key)? { + if let Some(existing) = process_get(user_id, key)? { log::debug!( "[keyring] get_or_create_random returning existing value user_id={user_id} key={key}" ); @@ -208,10 +283,10 @@ pub fn get_or_create_random( let hex_value = hex_encode(&bytes); log::debug!("[keyring] get_or_create_random creating new entry user_id={user_id} key={key}"); - set(user_id, key, &hex_value)?; + process_set(user_id, key, &hex_value)?; // Verify write succeeded. - let readback = get(user_id, key)?; + let readback = process_get(user_id, key)?; if readback.as_deref() != Some(&hex_value) { log::warn!( "[keyring] get_or_create_random write verification failed user_id={user_id} key={key}" @@ -246,7 +321,7 @@ pub fn migrate_from_file( ); // Step 1: check if already migrated. - if get(user_id, key)?.is_some() { + if process_get(user_id, key)?.is_some() { log::debug!("[keyring] migrate_from_file already migrated user_id={user_id} key={key}"); return Ok(MigrationOutcome::AlreadyMigrated); } @@ -274,10 +349,10 @@ pub fn migrate_from_file( // Step 4: write to backend. log::debug!("[keyring] migrate_from_file writing to backend user_id={user_id} key={key}"); - set(user_id, key, &value)?; + process_set(user_id, key, &value)?; // Step 5: verify read-back matches. - let readback = get(user_id, key)?; + let readback = process_get(user_id, key)?; if readback.as_deref() != Some(value.as_str()) { log::warn!( "[keyring] migrate_from_file verification failed user_id={user_id} key={key}; NOT deleting source file" diff --git a/crates/openhuman-core/src/storage/mod.rs b/crates/openhuman-core/src/storage/mod.rs index c4299297bc5..32708a751ea 100644 --- a/crates/openhuman-core/src/storage/mod.rs +++ b/crates/openhuman-core/src/storage/mod.rs @@ -18,6 +18,8 @@ //! driver the build does not carry fails at [`open`] naming the feature, so a //! misconfigured deployment stops at boot instead of at its first write. +pub mod secrets; + use std::future::Future; use std::sync::{Arc, LazyLock, OnceLock, RwLock}; From 2a3f39fa0bf7fabfe7e12f40cbcf913977dbd64c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:45:47 +0300 Subject: [PATCH 04/26] feat(deps): add tinystoragedrivers-secrets crate Register the new secrets crate in the lockfile and wire it into the tinystoragedrivers dependency set. The crate provides encrypted secret storage backed by chacha20poly1305 and hkdf. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 722dffebf46..7be756fe567 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7949,6 +7949,7 @@ dependencies = [ "tinystoragedrivers-core", "tinystoragedrivers-file", "tinystoragedrivers-mongodb", + "tinystoragedrivers-secrets", "tinystoragedrivers-sqlite", ] @@ -7986,6 +7987,21 @@ dependencies = [ "tinystoragedrivers-core", ] +[[package]] +name = "tinystoragedrivers-secrets" +version = "0.4.0" +dependencies = [ + "async-trait", + "chacha20poly1305", + "fs4", + "hkdf 0.12.4", + "serde_json", + "sha2 0.10.9", + "tinystoragedrivers-core", + "tokio", + "zeroize", +] + [[package]] name = "tinystoragedrivers-sqlite" version = "0.4.0" From b9b18662b30e4d5abfa0d36decd65e1d24dc4fdc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:47:24 +0300 Subject: [PATCH 05/26] fix(keyring): include source error in invalid utf8 variant The InvalidUtf8 error now carries the underlying FromUtf8Error so callers can inspect the original failure instead of only the key name. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/security/keyring/ops.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/security/keyring/ops.rs b/crates/openhuman-core/src/security/keyring/ops.rs index 66cd82c1844..6614441fc0c 100644 --- a/crates/openhuman-core/src/security/keyring/ops.rs +++ b/crates/openhuman-core/src/security/keyring/ops.rs @@ -73,8 +73,9 @@ pub fn get(user_id: &str, key: &str) -> Result, KeyringError> { .map_err(storage_error)?; value .map(|bytes| { - String::from_utf8(bytes.to_vec()).map_err(|_| KeyringError::InvalidUtf8 { + String::from_utf8(bytes.to_vec()).map_err(|source| KeyringError::InvalidUtf8 { key: key.to_string(), + source, }) }) .transpose() From bef7dca6280ccb52eb59e99c2b948b3fb32473f9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:49:01 +0300 Subject: [PATCH 06/26] feat(security): persist credentials to the storage backend HTTP credentials and auth profiles now read and write through the encrypted secret store when a storage backend is active, falling back to the existing files otherwise. Blocking helpers were added to the secrets module so the synchronous credential stores can use the async backend. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/security/credentials/http_creds.rs | 18 ++++++++++ .../credentials/profiles/persistence.rs | 34 +++++++++++++++++++ crates/openhuman-core/src/storage/secrets.rs | 30 ++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/crates/openhuman-core/src/security/credentials/http_creds.rs b/crates/openhuman-core/src/security/credentials/http_creds.rs index b5ca39ebca3..95aa9ebe428 100644 --- a/crates/openhuman-core/src/security/credentials/http_creds.rs +++ b/crates/openhuman-core/src/security/credentials/http_creds.rs @@ -334,6 +334,13 @@ impl HttpCredentialsStore { } fn read_persisted(&self) -> Result { + if let Some(secrets) = crate::storage::secrets::current()? { + return match crate::storage::secrets::get_blocking(&secrets, STORAGE_SECRET_NAME)? { + Some(bytes) => serde_json::from_slice(&bytes) + .context("http-credentials record on the storage backend is not valid JSON"), + None => Ok(PersistedHttpCredentials::default()), + }; + } if !self.path.exists() { return Ok(PersistedHttpCredentials::default()); } @@ -355,6 +362,12 @@ impl HttpCredentialsStore { } fn write_persisted(&self, persisted: &PersistedHttpCredentials) -> Result<()> { + if let Some(secrets) = crate::storage::secrets::current()? { + let json = serde_json::to_vec(persisted) + .context("failed to serialize http-credentials store")?; + crate::storage::secrets::set_blocking(&secrets, STORAGE_SECRET_NAME, &json)?; + return Ok(()); + } if let Some(parent) = self.path.parent() { fs::create_dir_all(parent).with_context(|| { format!( @@ -388,6 +401,11 @@ impl HttpCredentialsStore { } } +/// The secret the store lives in on a storage backend +/// ([`crate::storage::secrets`]): the same JSON as the file, encrypted as one +/// secret in the acting agent's scope. +const STORAGE_SECRET_NAME: &str = "file:http-credentials.json"; + fn parse_scheme(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().as_str() { "bearer" => Some(HttpCredentialScheme::Bearer), diff --git a/crates/openhuman-core/src/security/credentials/profiles/persistence.rs b/crates/openhuman-core/src/security/credentials/profiles/persistence.rs index 0e2ce1afe96..66c376b4b6e 100644 --- a/crates/openhuman-core/src/security/credentials/profiles/persistence.rs +++ b/crates/openhuman-core/src/security/credentials/profiles/persistence.rs @@ -18,6 +18,12 @@ use super::{ PERSIST_RETRY_BASE_MS, PROFILES_FILENAME, }; + +/// The secret the profiles live in on a storage backend +/// ([`crate::storage::secrets`]): the same JSON as `auth-profiles.json`, +/// encrypted as one secret in the acting agent's scope. +const STORAGE_SECRET_NAME: &str = "file:auth-profiles.json"; + impl AuthProfilesStore { pub(super) fn save_locked(&self, data: &AuthProfilesData) -> Result<()> { let mut persisted = PersistedAuthProfiles { @@ -114,6 +120,28 @@ impl AuthProfilesStore { } pub(super) fn read_persisted_locked(&self) -> Result { + if let Some(secrets) = crate::storage::secrets::current()? { + let Some(bytes) = crate::storage::secrets::get_blocking(&secrets, STORAGE_SECRET_NAME)? + else { + return Ok(PersistedAuthProfiles::default()); + }; + // No quarantine on a storage backend: an unparseable record is an + // error, so a later write can never replace profiles it could not + // read. + let mut persisted: PersistedAuthProfiles = serde_json::from_slice(&bytes) + .context("auth profile record on the storage backend is not valid JSON")?; + if persisted.schema_version == 0 { + persisted.schema_version = CURRENT_SCHEMA_VERSION; + } + if persisted.schema_version > CURRENT_SCHEMA_VERSION { + anyhow::bail!( + "Unsupported auth profile schema version {} (max supported: {})", + persisted.schema_version, + CURRENT_SCHEMA_VERSION + ); + } + return Ok(persisted); + } if !self.path.exists() { return Ok(PersistedAuthProfiles::default()); } @@ -163,6 +191,12 @@ impl AuthProfilesStore { } pub(super) fn write_persisted_locked(&self, persisted: &PersistedAuthProfiles) -> Result<()> { + if let Some(secrets) = crate::storage::secrets::current()? { + let json = + serde_json::to_vec(persisted).context("Failed to serialize auth profiles")?; + crate::storage::secrets::set_blocking(&secrets, STORAGE_SECRET_NAME, &json)?; + return Ok(()); + } if let Some(parent) = self.path.parent() { fs::create_dir_all(parent).with_context(|| { format!( diff --git a/crates/openhuman-core/src/storage/secrets.rs b/crates/openhuman-core/src/storage/secrets.rs index c234b0661d1..9d6398403e8 100644 --- a/crates/openhuman-core/src/storage/secrets.rs +++ b/crates/openhuman-core/src/storage/secrets.rs @@ -54,6 +54,36 @@ pub fn over(scoped: &ScopedStorage, keys: Arc) -> DocumentSecre DocumentSecrets::new(scoped, keys) } +/// Reads secret `name` from synchronous code. +/// +/// # Errors +/// +/// A storage or decryption error. +pub fn get_blocking( + secrets: &DocumentSecrets, + name: &str, +) -> Result>>, StorageError> { + use tinystoragedrivers::secrets::SecretStore as _; + let (secrets, name) = (secrets.clone(), name.to_string()); + super::block_on(async move { secrets.get(&name).await }) +} + +/// Writes secret `name` from synchronous code. +/// +/// # Errors +/// +/// A storage or encryption error. +pub fn set_blocking( + secrets: &DocumentSecrets, + name: &str, + value: &[u8], +) -> Result<(), StorageError> { + use tinystoragedrivers::secrets::SecretStore as _; + let (secrets, name) = (secrets.clone(), name.to_string()); + let value = Zeroizing::new(value.to_vec()); + super::block_on(async move { secrets.set(&name, &value).await }) +} + #[cfg(test)] #[path = "secrets_tests.rs"] mod tests; From f33c38aca7b30107267ac78d19328a5c7a52e2a7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:51:20 +0300 Subject: [PATCH 07/26] refactor(security): delegate keyring crypto to tinystoragedrivers The ChaCha20-Poly1305 encrypt and decrypt paths in the keyring now call tinystoragedrivers' secrets::crypto instead of using the cipher directly, so the config store and secrets file share one implementation and format. A cipher_key helper centralises loading the key into the fixed-size array the cipher expects. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/security/keyring/crypto.rs | 26 ++++------- .../src/security/keyring/encrypted_store.rs | 43 ++++++++----------- 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/crates/openhuman-core/src/security/keyring/crypto.rs b/crates/openhuman-core/src/security/keyring/crypto.rs index 47a58b0101c..d1e2570b10b 100644 --- a/crates/openhuman-core/src/security/keyring/crypto.rs +++ b/crates/openhuman-core/src/security/keyring/crypto.rs @@ -3,24 +3,19 @@ //! Used by both [`super::encrypted_store::SecretStore`] (config field encryption) //! and [`super::encrypted_file_backend::EncryptedFileBackend`] (secrets file encryption). -use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; -use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Key, Nonce}; +use chacha20poly1305::aead::OsRng; +use tinystoragedrivers::secrets::crypto as secrets_crypto; pub(super) const NONCE_LEN: usize = 12; pub(super) const KEY_LEN: usize = 32; /// Encrypt `plaintext` with ChaCha20-Poly1305. Returns `nonce || ciphertext || tag`. +/// +/// The cipher is `tinystoragedrivers`' (`secrets::crypto`), the same format +/// its secret stores read and write. pub(super) fn chacha20_encrypt(key: &[u8; KEY_LEN], plaintext: &[u8]) -> Result, String> { - let cipher = ChaCha20Poly1305::new(Key::from_slice(key)); - let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); - let ciphertext = cipher - .encrypt(&nonce, plaintext) - .map_err(|e| format!("ChaCha20 encryption failed: {e}"))?; - - let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); - blob.extend_from_slice(&nonce); - blob.extend_from_slice(&ciphertext); - Ok(blob) + secrets_crypto::encrypt(key, plaintext) + .map_err(|e| format!("ChaCha20 encryption failed: {e}")) } /// Decrypt a `nonce || ciphertext || tag` blob produced by [`chacha20_encrypt`]. @@ -28,11 +23,8 @@ pub(super) fn chacha20_decrypt(key: &[u8; KEY_LEN], blob: &[u8]) -> Result NONCE_LEN, "Encrypted value too short (missing nonce)" ); - - let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN); - let nonce = Nonce::from_slice(nonce_bytes); - let key_bytes = self.load_or_create_key()?; - let key = Key::from_slice(&key_bytes); - let cipher = ChaCha20Poly1305::new(key); - - let plaintext_bytes = cipher - .decrypt(nonce, ciphertext) + let key = self.cipher_key()?; + let plaintext_bytes = tinystoragedrivers::secrets::crypto::decrypt(&key, &blob) .map_err(|_| anyhow::anyhow!("Decryption failed — wrong key or tampered data"))?; - String::from_utf8(plaintext_bytes) + String::from_utf8(plaintext_bytes.to_vec()) .context("Decrypted secret is not valid UTF-8 — corrupt data") } + /// The config encryption key as the fixed-size array the cipher takes. + fn cipher_key(&self) -> Result> { + let bytes = self.load_or_create_key()?; + let key: [u8; KEY_LEN] = bytes.as_slice().try_into().map_err(|_| { + anyhow::anyhow!( + "config encryption key is {} bytes, expected {KEY_LEN}", + bytes.len() + ) + })?; + Ok(Zeroizing::new(key)) + } + /// Decrypt using legacy XOR cipher (insecure, for backward compatibility only). /// /// This is the single choke-point for the insecure `enc:` format, so it logs From 95cd6bdbb9d746754c7fbbd335c902c2772b5db1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:51:55 +0300 Subject: [PATCH 08/26] chore(security): remove unused chacha20poly1305 imports Drop the unused chacha20poly1305 imports from the encrypted store module, since the encryption code no longer references them directly. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/security/keyring/encrypted_store.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/openhuman-core/src/security/keyring/encrypted_store.rs b/crates/openhuman-core/src/security/keyring/encrypted_store.rs index 5fe22703b6e..04124eda474 100644 --- a/crates/openhuman-core/src/security/keyring/encrypted_store.rs +++ b/crates/openhuman-core/src/security/keyring/encrypted_store.rs @@ -27,8 +27,6 @@ // encryptions always produce `enc2:` (ChaCha20-Poly1305). use anyhow::{Context, Result}; -use chacha20poly1305::aead::{Aead, KeyInit, OsRng}; -use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Key, Nonce}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; From b56dadbf99147e8901b78af6700150cf338e3e57 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:55:26 +0300 Subject: [PATCH 09/26] test(storage): add secrets storage tests Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/storage/secrets_tests.rs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 crates/openhuman-core/src/storage/secrets_tests.rs diff --git a/crates/openhuman-core/src/storage/secrets_tests.rs b/crates/openhuman-core/src/storage/secrets_tests.rs new file mode 100644 index 00000000000..0706b623559 --- /dev/null +++ b/crates/openhuman-core/src/storage/secrets_tests.rs @@ -0,0 +1,66 @@ +use super::*; +use crate::storage::{MemoryStorage, Scope, StorageBackend}; + +fn keys(byte: u8) -> Arc { + Arc::new(DerivedKeys::new(Zeroizing::new([byte; 32]))) +} + +fn secrets_in(storage: &MemoryStorage, scope: &str, key: u8) -> DocumentSecrets { + over(&storage.for_scope(&Scope::new(scope).unwrap()).unwrap(), keys(key)) +} + +#[test] +fn a_secret_round_trips_from_sync_code() { + let storage = MemoryStorage::new(); + let secrets = secrets_in(&storage, "local", 7); + assert!(get_blocking(&secrets, "user:token").unwrap().is_none()); + set_blocking(&secrets, "user:token", b"s3cret").unwrap(); + assert_eq!( + get_blocking(&secrets, "user:token").unwrap().unwrap().as_slice(), + b"s3cret" + ); +} + +#[test] +fn scopes_and_keys_keep_secrets_apart() { + let storage = MemoryStorage::new(); + set_blocking(&secrets_in(&storage, "alice", 7), "user:token", b"a").unwrap(); + assert!( + get_blocking(&secrets_in(&storage, "bob", 7), "user:token") + .unwrap() + .is_none(), + "another scope sees nothing" + ); + assert!( + get_blocking(&secrets_in(&storage, "alice", 8), "user:token").is_err(), + "a different master key cannot decrypt" + ); +} + +#[test] +fn the_stored_document_holds_no_plaintext() { + let storage = MemoryStorage::new(); + set_blocking(&secrets_in(&storage, "local", 7), "user:token", b"plain-value").unwrap(); + let docs = storage + .for_scope(&Scope::new("local").unwrap()) + .unwrap() + .documents() + .clone(); + let page = crate::storage::block_on(async move { + docs.query( + tinystoragedrivers::secrets::DEFAULT_COLLECTION, + &tinystoragedrivers::Query::all(), + ) + .await + }) + .unwrap(); + let raw = serde_json::to_string(&page.items[0].doc).unwrap(); + assert!(!raw.contains("plain-value"), "{raw}"); + assert!(raw.contains("enc2:")); +} + +#[test] +fn no_backend_means_no_storage_secrets() { + // The process slot is empty in the lib test binary. + assert!(crate::storage::installed().is_none() || current().is_ok()); +} From bc547f27ecae94f654c1be0d600bfa0bf93c75b0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:55:38 +0300 Subject: [PATCH 10/26] test(storage): remove redundant backend absence test The test asserted a condition that always holds in the lib test binary, so it provided no real coverage. Removing it keeps the suite focused on meaningful checks. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-core/src/storage/secrets_tests.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/openhuman-core/src/storage/secrets_tests.rs b/crates/openhuman-core/src/storage/secrets_tests.rs index 0706b623559..d3a1e2b14ac 100644 --- a/crates/openhuman-core/src/storage/secrets_tests.rs +++ b/crates/openhuman-core/src/storage/secrets_tests.rs @@ -58,9 +58,3 @@ fn the_stored_document_holds_no_plaintext() { assert!(!raw.contains("plain-value"), "{raw}"); assert!(raw.contains("enc2:")); } - -#[test] -fn no_backend_means_no_storage_secrets() { - // The process slot is empty in the lib test binary. - assert!(crate::storage::installed().is_none() || current().is_ok()); -} From 57c700a1886bacd53c71ed1367ec3572270aab9a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:56:26 +0300 Subject: [PATCH 11/26] test(cli): register storage_secrets_e2e test binary Add a Cargo test target for the new storage secrets end-to-end test so it runs in its own binary, since it installs a storage backend into the process-wide slot and sets the keyring master-key environment. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-cli/Cargo.toml | 6 +++ tests/storage_secrets_e2e.rs | 66 +++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/storage_secrets_e2e.rs diff --git a/crates/openhuman-cli/Cargo.toml b/crates/openhuman-cli/Cargo.toml index 5341e1c4296..e831530df8b 100644 --- a/crates/openhuman-cli/Cargo.toml +++ b/crates/openhuman-cli/Cargo.toml @@ -122,6 +122,12 @@ path = "../../tests/cost_cap_removed.rs" name = "storage_approvals_e2e" path = "../../tests/storage_approvals_e2e.rs" +[[test]] +# Its own binary: it installs a storage backend into the process-wide slot and +# sets the keyring master-key environment. +name = "storage_secrets_e2e" +path = "../../tests/storage_secrets_e2e.rs" + [[test]] name = "embedded_server_shutdown_e2e" path = "../../tests/embedded_server_shutdown_e2e.rs" diff --git a/tests/storage_secrets_e2e.rs b/tests/storage_secrets_e2e.rs new file mode 100644 index 00000000000..aebfc49cec1 --- /dev/null +++ b/tests/storage_secrets_e2e.rs @@ -0,0 +1,66 @@ +//! Keyring secrets and the credential stores on a configured storage +//! backend, end to end through their public functions. +//! +//! Its own test binary because it installs a backend into the process-wide +//! storage slot and sets the keyring master-key environment, which would +//! reroute every other suite's secrets in a shared process. One test, so +//! nothing in this binary races either. + +use std::sync::Arc; + +use openhuman_core::security::credentials::http_creds::{HttpCredential, HttpCredentialsStore}; +use openhuman_core::security::credentials::profiles::{AuthProfile, AuthProfilesStore}; +use openhuman_core::security::keyring; + +#[test] +fn a_configured_backend_holds_keyring_and_credential_secrets() { + let workspace = tempfile::tempdir().unwrap(); + // Keep any process-backend fallback inside the temp workspace, and give + // the storage secrets a master key without touching an OS keychain. + std::env::set_var("OPENHUMAN_WORKSPACE", workspace.path()); + std::env::set_var("OPENHUMAN_KEYRING_BACKEND", "file"); + std::env::set_var("OPENHUMAN_KEYRING_MASTER_KEY", "11".repeat(32)); + openhuman_core::storage::install(Arc::new(openhuman_core::storage::MemoryStorage::new())); + + keyring::set("user-1", "api_token", "tok-123").unwrap(); + assert_eq!( + keyring::get("user-1", "api_token").unwrap().as_deref(), + Some("tok-123") + ); + assert!(keyring::is_available()); + keyring::delete("user-1", "api_token").unwrap(); + assert!(keyring::get("user-1", "api_token").unwrap().is_none()); + + let state = workspace.path().join("state"); + let profiles = AuthProfilesStore::new(&state, false); + profiles + .upsert_profile( + AuthProfile::new_token("openai", "default", "sk-test".to_string()), + true, + ) + .unwrap(); + let loaded = profiles.load().unwrap(); + assert_eq!(loaded.profiles.len(), 1); + + let http = HttpCredentialsStore::new(&state, false); + http.upsert(&HttpCredential::bearer("github", "ghp-test")) + .unwrap(); + assert!(http.get("github").unwrap().is_some()); + + for file in [ + "state/auth-profiles.json", + "state/http-credentials.json", + "secrets.enc", + "dev-keychain.json", + ] { + assert!( + !workspace.path().join(file).exists(), + "{file} was not written" + ); + } + + // Without a backend the files are back in use. + assert!(openhuman_core::storage::clear()); + assert!(http.get("github").unwrap().is_none()); + assert!(profiles.load().unwrap().profiles.is_empty()); +} From da2b516c35bead9a7fa796950bf5de7f8ff0e672 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:58:07 +0300 Subject: [PATCH 12/26] style: apply rustfmt formatting to security and storage modules Reformat keyring crypto and ops, credential profile persistence, and secrets tests to satisfy rustfmt, collapsing wrapped calls onto single lines and removing stray blank lines. No behaviour changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../credentials/profiles/persistence.rs | 1 - .../src/security/keyring/crypto.rs | 3 +-- .../openhuman-core/src/security/keyring/ops.rs | 11 ++++------- .../openhuman-core/src/storage/secrets_tests.rs | 17 ++++++++++++++--- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/openhuman-core/src/security/credentials/profiles/persistence.rs b/crates/openhuman-core/src/security/credentials/profiles/persistence.rs index 66c376b4b6e..de92710429d 100644 --- a/crates/openhuman-core/src/security/credentials/profiles/persistence.rs +++ b/crates/openhuman-core/src/security/credentials/profiles/persistence.rs @@ -18,7 +18,6 @@ use super::{ PERSIST_RETRY_BASE_MS, PROFILES_FILENAME, }; - /// The secret the profiles live in on a storage backend /// ([`crate::storage::secrets`]): the same JSON as `auth-profiles.json`, /// encrypted as one secret in the acting agent's scope. diff --git a/crates/openhuman-core/src/security/keyring/crypto.rs b/crates/openhuman-core/src/security/keyring/crypto.rs index d1e2570b10b..37d7f60afa4 100644 --- a/crates/openhuman-core/src/security/keyring/crypto.rs +++ b/crates/openhuman-core/src/security/keyring/crypto.rs @@ -14,8 +14,7 @@ pub(super) const KEY_LEN: usize = 32; /// The cipher is `tinystoragedrivers`' (`secrets::crypto`), the same format /// its secret stores read and write. pub(super) fn chacha20_encrypt(key: &[u8; KEY_LEN], plaintext: &[u8]) -> Result, String> { - secrets_crypto::encrypt(key, plaintext) - .map_err(|e| format!("ChaCha20 encryption failed: {e}")) + secrets_crypto::encrypt(key, plaintext).map_err(|e| format!("ChaCha20 encryption failed: {e}")) } /// Decrypt a `nonce || ciphertext || tag` blob produced by [`chacha20_encrypt`]. diff --git a/crates/openhuman-core/src/security/keyring/ops.rs b/crates/openhuman-core/src/security/keyring/ops.rs index 6614441fc0c..0b4c4886688 100644 --- a/crates/openhuman-core/src/security/keyring/ops.rs +++ b/crates/openhuman-core/src/security/keyring/ops.rs @@ -47,8 +47,7 @@ pub enum MigrationOutcome { // belong to this process, not to an agent. /// The storage-backed secret store for this call, when one is configured. -fn storage_secrets( -) -> Result, KeyringError> { +fn storage_secrets() -> Result, KeyringError> { crate::storage::secrets::current() .map_err(|error| KeyringError::Backend(format!("storage secrets: {error}"))) } @@ -69,8 +68,8 @@ pub fn get(user_id: &str, key: &str) -> Result, KeyringError> { }; log::debug!("[keyring] get (storage) user_id={user_id} key={key}"); let name = namespaced_key(user_id, key); - let value = crate::storage::block_on(async move { secrets.get(&name).await }) - .map_err(storage_error)?; + let value = + crate::storage::block_on(async move { secrets.get(&name).await }).map_err(storage_error)?; value .map(|bytes| { String::from_utf8(bytes.to_vec()).map_err(|source| KeyringError::InvalidUtf8 { @@ -91,8 +90,7 @@ pub fn set(user_id: &str, key: &str, value: &str) -> Result<(), KeyringError> { log::debug!("[keyring] set (storage) user_id={user_id} key={key}"); let name = namespaced_key(user_id, key); let value = zeroize::Zeroizing::new(value.as_bytes().to_vec()); - crate::storage::block_on(async move { secrets.set(&name, &value).await }) - .map_err(storage_error) + crate::storage::block_on(async move { secrets.set(&name, &value).await }).map_err(storage_error) } /// Delete a secret: from the storage backend when one is configured, else @@ -110,7 +108,6 @@ pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { // ── Process backend ─────────────────────────────────────────────────────────── - /// Retrieve a secret from the process backend. /// /// Returns `Ok(None)` when no entry exists for this user + key combination. diff --git a/crates/openhuman-core/src/storage/secrets_tests.rs b/crates/openhuman-core/src/storage/secrets_tests.rs index d3a1e2b14ac..57b295c901c 100644 --- a/crates/openhuman-core/src/storage/secrets_tests.rs +++ b/crates/openhuman-core/src/storage/secrets_tests.rs @@ -6,7 +6,10 @@ fn keys(byte: u8) -> Arc { } fn secrets_in(storage: &MemoryStorage, scope: &str, key: u8) -> DocumentSecrets { - over(&storage.for_scope(&Scope::new(scope).unwrap()).unwrap(), keys(key)) + over( + &storage.for_scope(&Scope::new(scope).unwrap()).unwrap(), + keys(key), + ) } #[test] @@ -16,7 +19,10 @@ fn a_secret_round_trips_from_sync_code() { assert!(get_blocking(&secrets, "user:token").unwrap().is_none()); set_blocking(&secrets, "user:token", b"s3cret").unwrap(); assert_eq!( - get_blocking(&secrets, "user:token").unwrap().unwrap().as_slice(), + get_blocking(&secrets, "user:token") + .unwrap() + .unwrap() + .as_slice(), b"s3cret" ); } @@ -40,7 +46,12 @@ fn scopes_and_keys_keep_secrets_apart() { #[test] fn the_stored_document_holds_no_plaintext() { let storage = MemoryStorage::new(); - set_blocking(&secrets_in(&storage, "local", 7), "user:token", b"plain-value").unwrap(); + set_blocking( + &secrets_in(&storage, "local", 7), + "user:token", + b"plain-value", + ) + .unwrap(); let docs = storage .for_scope(&Scope::new("local").unwrap()) .unwrap() From 5d7b0afa959e554148904624ecbeb1fcf10b375e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:00:24 +0300 Subject: [PATCH 13/26] chore(deps): add tinystoragedrivers-secrets to lockfile Regenerate the lockfile to include the new tinystoragedrivers-secrets crate and its dependency edge from tinystoragedrivers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-app/Cargo.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/openhuman-app/Cargo.lock b/crates/openhuman-app/Cargo.lock index 1b3a12839be..4b774d7559f 100644 --- a/crates/openhuman-app/Cargo.lock +++ b/crates/openhuman-app/Cargo.lock @@ -8178,6 +8178,7 @@ name = "tinystoragedrivers" version = "0.4.0" dependencies = [ "tinystoragedrivers-core", + "tinystoragedrivers-secrets", ] [[package]] @@ -8191,6 +8192,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "tinystoragedrivers-secrets" +version = "0.4.0" +dependencies = [ + "async-trait", + "chacha20poly1305", + "fs4", + "hkdf", + "serde_json", + "sha2 0.10.9", + "tinystoragedrivers-core", + "tokio", + "zeroize", +] + [[package]] name = "tinystoragedrivers-sqlite" version = "0.4.0" From 0fed91114568a42d7bc4854dd1b6536d01fea0af Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:00:47 +0300 Subject: [PATCH 14/26] docs(storage): document storage-backed secrets and credentials The storage, keyring, and credentials READMEs now describe how secrets and credential files are stored as encrypted documents in the acting agent's scope when a storage backend is configured, including the HKDF-derived per-scope data key and fail-closed behaviour without a master key. This records the behaviour so the modules' docs match what the code does. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/security/credentials/README.md | 11 +++++++++++ .../openhuman-core/src/security/keyring/README.md | 15 +++++++++++++++ crates/openhuman-core/src/storage/README.md | 9 +++++++++ 3 files changed, 35 insertions(+) diff --git a/crates/openhuman-core/src/security/credentials/README.md b/crates/openhuman-core/src/security/credentials/README.md index 4ec38efce92..977fded90d6 100644 --- a/crates/openhuman-core/src/security/credentials/README.md +++ b/crates/openhuman-core/src/security/credentials/README.md @@ -82,6 +82,17 @@ Note: `list_provider_credentials_by_prefix` and the Composio-direct/secret helpe - Loads migrate legacy `enc:`/`enc2:` cipher fields and promote secrets into the keychain; unrecoverable (un-decryptable / bad-`kind`) profiles are dropped rather than poisoning the whole store; unparseable files are quarantined to `auth-profiles.corrupt-.json` and reset to empty. - Mutations are guarded by `auth-profiles.lock` (PID-stamped). Stale/leaked/malformed locks are reclaimed by liveness + age checks to avoid the "stuck on Initializing OpenHuman" hang. +### On a storage backend + +With a storage backend configured (`crate::storage`), `auth-profiles.json` +and `http-credentials.json` are not written to disk: the same JSON is kept as +one encrypted secret each (`file:auth-profiles.json`, +`file:http-credentials.json`) in the acting agent's scope +(`storage::secrets`), and per-profile keychain secrets go there through the +keyring. An unparseable record is an error rather than quarantined, so a +write never replaces profiles it could not read. Writes are last-writer-wins +across processes; the local file lock still serializes one host. + ## Dependencies - `crate::config`: `Config`, config load (`load_config_with_timeout`), user-dir activation (`default_root_openhuman_dir`, `user_openhuman_dir`, `read/write/clear_active_user`, `pre_login_user_dir`), onboarding state. diff --git a/crates/openhuman-core/src/security/keyring/README.md b/crates/openhuman-core/src/security/keyring/README.md index 80df6d774dc..4625bde76c3 100644 --- a/crates/openhuman-core/src/security/keyring/README.md +++ b/crates/openhuman-core/src/security/keyring/README.md @@ -68,6 +68,21 @@ The workspace dir resolves from `init_workspace`, else `OPENHUMAN_WORKSPACE`, el Both file backends keep every secret in one file, so a `set` of one key rewrites all of them. That read-modify-write cycle is guarded by `file_store::lock_for_write`. An in-process mutex is not sufficient, because a desktop core, another process embedding the same core, and a `cargo test` run that inherited `OPENHUMAN_WORKSPACE` can all address the same path. +### On a storage backend + +When the host configured a storage backend (`OPENHUMAN_STORAGE_URL` / +`[storage] url`, see `crate::storage`), `get`, `set` and `delete` serve the +secret from `storage::secrets` instead: one encrypted document per +namespaced key in the acting agent's storage scope, under a per-scope key +derived from the master key above. `is_available` reports `true` without +probing. The app-level keys stay on the process backend: the availability +probe, `get_or_create_random` and `migrate_from_file` (the config +encryption key, `secretstore.master_key`) use `process_get` / `process_set` / +`process_delete`. `backend_name` keeps naming the process backend, which is +what the consent UI describes. The `enc2:` cipher itself is +`tinystoragedrivers::secrets::crypto` (byte-compatible; its fixtures were +written by this module), used by `crypto.rs` and `SecretStore`. + ## Dependencies Internal openhuman/core modules: none. The keyring module's own files only `use crate::security::keyring::*` (self-internal). It is a leaf infrastructure module. External crates: `keyring`, `chacha20poly1305`, `serde_json`, `parking_lot`, `thiserror`, `anyhow`, `chrono`, `dirs`. diff --git a/crates/openhuman-core/src/storage/README.md b/crates/openhuman-core/src/storage/README.md index 5a98b3884af..244c2a89846 100644 --- a/crates/openhuman-core/src/storage/README.md +++ b/crates/openhuman-core/src/storage/README.md @@ -38,6 +38,15 @@ on `storage-mongodb`. - The session store: `openhuman_rpc::session_store::install_for_host` opens the configured backend before boot and installs `DriverSessionStores` over it. See that module's README. +- Approvals (`security::approval`), through `store_documents.rs`. +- Secrets (`storage::secrets`): the keyring's user secrets + (`security::keyring::get` / `set` / `delete`) and the credential stores' + files (`auth-profiles.json`, `http-credentials.json`) become encrypted + documents (`DocumentSecrets`, `enc2:`) in the acting agent's scope. Each + scope's data key is derived with HKDF-SHA256 from the keyring master key + (`OPENHUMAN_KEYRING_MASTER_KEY` / `_FILE`, else the OS keychain); with no + master key they fail closed. The config encryption key stays on the + process keyring, because `config.toml` is loaded before any agent acts. ## Boundaries From 3a5a52382e216cfe3eeda865813538c5a8820744 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:24:53 +0300 Subject: [PATCH 15/26] fix(security): retry master key load and migrate legacy credential files Failed master key loads are no longer cached, so secrets recover once keychain access is restored, and the storage key error no longer leaks a path from the environment. Credential and auth-profile stores now adopt the existing on-disk files on first use of a storage backend so those credentials stay visible and cannot be overwritten by an empty set. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/security/credentials/http_creds.rs | 26 ++++++++++++- .../credentials/profiles/persistence.rs | 18 ++++++++- .../keyring/encrypted_file_backend.rs | 37 ++++++++++++------- .../src/security/keyring/ops.rs | 8 ++-- crates/openhuman-core/src/storage/secrets.rs | 18 +++++---- 5 files changed, 79 insertions(+), 28 deletions(-) diff --git a/crates/openhuman-core/src/security/credentials/http_creds.rs b/crates/openhuman-core/src/security/credentials/http_creds.rs index 95aa9ebe428..d60cc5f3384 100644 --- a/crates/openhuman-core/src/security/credentials/http_creds.rs +++ b/crates/openhuman-core/src/security/credentials/http_creds.rs @@ -338,9 +338,33 @@ impl HttpCredentialsStore { return match crate::storage::secrets::get_blocking(&secrets, STORAGE_SECRET_NAME)? { Some(bytes) => serde_json::from_slice(&bytes) .context("http-credentials record on the storage backend is not valid JSON"), - None => Ok(PersistedHttpCredentials::default()), + None => { + // First use of the backend: adopt the on-disk store so + // existing credentials stay visible and the next upsert + // cannot replace them. The file is left in place. + let legacy = self.read_file()?; + if self.path.exists() && !legacy.credentials.is_empty() { + let json = serde_json::to_vec(&legacy) + .context("failed to serialize migrated http-credentials store")?; + crate::storage::secrets::set_blocking( + &secrets, + STORAGE_SECRET_NAME, + &json, + )?; + log::info!( + target: "credentials", + "[credentials] migrated http-credentials file to storage secret" + ); + } + Ok(legacy) + } }; } + self.read_file() + } + + /// Reads the on-disk `http-credentials.json` store (empty when absent). + fn read_file(&self) -> Result { if !self.path.exists() { return Ok(PersistedHttpCredentials::default()); } diff --git a/crates/openhuman-core/src/security/credentials/profiles/persistence.rs b/crates/openhuman-core/src/security/credentials/profiles/persistence.rs index de92710429d..d380a82d657 100644 --- a/crates/openhuman-core/src/security/credentials/profiles/persistence.rs +++ b/crates/openhuman-core/src/security/credentials/profiles/persistence.rs @@ -122,7 +122,18 @@ impl AuthProfilesStore { if let Some(secrets) = crate::storage::secrets::current()? { let Some(bytes) = crate::storage::secrets::get_blocking(&secrets, STORAGE_SECRET_NAME)? else { - return Ok(PersistedAuthProfiles::default()); + // First use of the backend: adopt the profiles the on-disk + // store holds, so they do not vanish from reads and the next + // save cannot replace them with an empty set. The file is left + // in place (never deleted by this path). + let legacy = self.read_file_locked()?; + if self.path.exists() && !legacy.profiles.is_empty() { + let json = serde_json::to_vec(&legacy) + .context("Failed to serialize migrated auth profiles")?; + crate::storage::secrets::set_blocking(&secrets, STORAGE_SECRET_NAME, &json)?; + tracing::info!("[credentials] migrated auth profiles file to storage secret"); + } + return Ok(legacy); }; // No quarantine on a storage backend: an unparseable record is an // error, so a later write can never replace profiles it could not @@ -141,6 +152,11 @@ impl AuthProfilesStore { } return Ok(persisted); } + self.read_file_locked() + } + + /// Reads the on-disk `auth-profiles.json` store (empty when absent). + fn read_file_locked(&self) -> Result { if !self.path.exists() { return Ok(PersistedAuthProfiles::default()); } diff --git a/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs b/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs index e12c82b8f3c..1b62e8af704 100644 --- a/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs +++ b/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs @@ -126,22 +126,31 @@ pub fn init_master_key() -> Result<(), String> { /// they are never written unencrypted or under a freshly minted key that /// would orphan the ones already stored. pub(crate) fn storage_master_key() -> Result<[u8; KEY_LEN], String> { - static STORAGE_MASTER_KEY: OnceLock> = OnceLock::new(); - if let Some(Ok(Some(key))) = MASTER_KEY.get() { + // Only a loaded key is cached: a failure (locked keychain, denied prompt) + // is retried on the next call so secrets recover once access is restored. + static STORAGE_MASTER_KEY: OnceLock<[u8; KEY_LEN]> = OnceLock::new(); + match MASTER_KEY.get() { + Some(Ok(Some(key))) => return Ok(*key), + // `init_master_key` already tried the keychain this session and + // recorded it unavailable: reuse that outcome, do not prompt again. + Some(Ok(None)) => return Err("OS keychain master key unavailable this session".into()), + _ => {} + } + if let Some(key) = STORAGE_MASTER_KEY.get() { return Ok(*key); } - STORAGE_MASTER_KEY - .get_or_init(|| match try_load_master_key() { - Ok((key, source)) => { - log::info!("[keyring:storage] master key loaded from {source}"); - Ok(key) - } - Err(MasterKeyError::Configured(error) | MasterKeyError::Keychain(error)) => { - log::error!("[keyring:storage] master key unavailable: {error}"); - Err(error) - } - }) - .clone() + match try_load_master_key() { + Ok((key, source)) => { + log::info!("[keyring:storage] master key loaded from {source}"); + Ok(*STORAGE_MASTER_KEY.get_or_init(|| key)) + } + Err(MasterKeyError::Configured(_) | MasterKeyError::Keychain(_)) => { + // Fixed message: the underlying error can carry a path taken + // from `MASTER_KEY_FILE_ENV`. + log::error!("[keyring:storage] master key unavailable"); + Err("master key unavailable".into()) + } + } } /// Runs `init` at most once per `cell` and reports its outcome on every call. diff --git a/crates/openhuman-core/src/security/keyring/ops.rs b/crates/openhuman-core/src/security/keyring/ops.rs index 0b4c4886688..7d1ce0aa2e2 100644 --- a/crates/openhuman-core/src/security/keyring/ops.rs +++ b/crates/openhuman-core/src/security/keyring/ops.rs @@ -66,7 +66,7 @@ pub fn get(user_id: &str, key: &str) -> Result, KeyringError> { let Some(secrets) = storage_secrets()? else { return process_get(user_id, key); }; - log::debug!("[keyring] get (storage) user_id={user_id} key={key}"); + log::debug!("[keyring] get (storage)"); let name = namespaced_key(user_id, key); let value = crate::storage::block_on(async move { secrets.get(&name).await }).map_err(storage_error)?; @@ -87,7 +87,7 @@ pub fn set(user_id: &str, key: &str, value: &str) -> Result<(), KeyringError> { let Some(secrets) = storage_secrets()? else { return process_set(user_id, key, value); }; - log::debug!("[keyring] set (storage) user_id={user_id} key={key}"); + log::debug!("[keyring] set (storage)"); let name = namespaced_key(user_id, key); let value = zeroize::Zeroizing::new(value.as_bytes().to_vec()); crate::storage::block_on(async move { secrets.set(&name, &value).await }).map_err(storage_error) @@ -100,7 +100,7 @@ pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { let Some(secrets) = storage_secrets()? else { return process_delete(user_id, key); }; - log::debug!("[keyring] delete (storage) user_id={user_id} key={key}"); + log::debug!("[keyring] delete (storage)"); let name = namespaced_key(user_id, key); crate::storage::block_on(async move { secrets.delete(&name).await.map(|_| ()) }) .map_err(storage_error) @@ -173,7 +173,7 @@ pub(crate) fn process_delete(user_id: &str, key: &str) -> Result<(), KeyringErro /// wallet guards or snapshot loops. pub fn is_available() -> bool { // Secrets on a storage backend do not depend on the OS keychain. - if crate::storage::installed().is_some() { + if matches!(storage_secrets(), Ok(Some(_))) { return true; } let mut cached = AVAILABILITY_CACHE.lock(); diff --git a/crates/openhuman-core/src/storage/secrets.rs b/crates/openhuman-core/src/storage/secrets.rs index 9d6398403e8..7a4862799c4 100644 --- a/crates/openhuman-core/src/storage/secrets.rs +++ b/crates/openhuman-core/src/storage/secrets.rs @@ -25,14 +25,16 @@ use super::{current_scoped, ScopedStorage, StorageError}; /// The key provider every scope's secrets use, built once per process. fn keys() -> Result, StorageError> { - static KEYS: OnceLock, String>> = OnceLock::new(); - KEYS.get_or_init(|| { - crate::security::keyring::encrypted_file_backend::storage_master_key().map(|master| { - Arc::new(DerivedKeys::new(Zeroizing::new(master))) as Arc - }) - }) - .clone() - .map_err(|error| StorageError::crypto(format!("no master key for storage secrets: {error}"))) + // Only a built provider is cached; a failed key load is retried next call. + static KEYS: OnceLock> = OnceLock::new(); + if let Some(keys) = KEYS.get() { + return Ok(Arc::clone(keys)); + } + let master = crate::security::keyring::encrypted_file_backend::storage_master_key().map_err( + |error| StorageError::crypto(format!("no master key for storage secrets: {error}")), + )?; + let built: Arc = Arc::new(DerivedKeys::new(Zeroizing::new(master))); + Ok(Arc::clone(KEYS.get_or_init(|| built))) } /// The secret store for this call when the host configured a backend, in From 397ccd6edd333e505b73bdfd28bfa09c05f2d487 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:25:27 +0300 Subject: [PATCH 16/26] test(storage): cover legacy credential adoption by the secrets backend The end-to-end test now seeds classic auth-profile and HTTP credential files before a backend is installed, then asserts the backend adopts those entries instead of replacing them and leaves the legacy files untouched. It also verifies the legacy entries remain readable once the backend is cleared. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/storage_secrets_e2e.rs | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/storage_secrets_e2e.rs b/tests/storage_secrets_e2e.rs index aebfc49cec1..e6e286cf929 100644 --- a/tests/storage_secrets_e2e.rs +++ b/tests/storage_secrets_e2e.rs @@ -20,6 +20,21 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { std::env::set_var("OPENHUMAN_WORKSPACE", workspace.path()); std::env::set_var("OPENHUMAN_KEYRING_BACKEND", "file"); std::env::set_var("OPENHUMAN_KEYRING_MASTER_KEY", "11".repeat(32)); + + // Credentials written before any backend existed (the classic files). + let state = workspace.path().join("state"); + AuthProfilesStore::new(&state, false) + .upsert_profile( + AuthProfile::new_token("legacy", "default", "sk-legacy".to_string()), + true, + ) + .unwrap(); + HttpCredentialsStore::new(&state, false) + .upsert(&HttpCredential::bearer("legacy-http", "ghp-legacy")) + .unwrap(); + let profiles_file = std::fs::read(state.join("auth-profiles.json")).unwrap(); + let http_file = std::fs::read(state.join("http-credentials.json")).unwrap(); + openhuman_core::storage::install(Arc::new(openhuman_core::storage::MemoryStorage::new())); keyring::set("user-1", "api_token", "tok-123").unwrap(); @@ -31,7 +46,6 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { keyring::delete("user-1", "api_token").unwrap(); assert!(keyring::get("user-1", "api_token").unwrap().is_none()); - let state = workspace.path().join("state"); let profiles = AuthProfilesStore::new(&state, false); profiles .upsert_profile( @@ -39,17 +53,26 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { true, ) .unwrap(); + // The pre-existing profile was adopted, not replaced. let loaded = profiles.load().unwrap(); - assert_eq!(loaded.profiles.len(), 1); + assert_eq!(loaded.profiles.len(), 2); let http = HttpCredentialsStore::new(&state, false); http.upsert(&HttpCredential::bearer("github", "ghp-test")) .unwrap(); assert!(http.get("github").unwrap().is_some()); + assert!(http.get("legacy-http").unwrap().is_some()); + // The legacy files are left untouched by the backend path. + assert_eq!( + std::fs::read(state.join("auth-profiles.json")).unwrap(), + profiles_file + ); + assert_eq!( + std::fs::read(state.join("http-credentials.json")).unwrap(), + http_file + ); for file in [ - "state/auth-profiles.json", - "state/http-credentials.json", "secrets.enc", "dev-keychain.json", ] { @@ -62,5 +85,6 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { // Without a backend the files are back in use. assert!(openhuman_core::storage::clear()); assert!(http.get("github").unwrap().is_none()); - assert!(profiles.load().unwrap().profiles.is_empty()); + assert!(http.get("legacy-http").unwrap().is_some()); + assert_eq!(profiles.load().unwrap().profiles.len(), 1); } From ebc5bb1f049a9f2adc7493af30a23f946954eda2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:27:00 +0300 Subject: [PATCH 17/26] test(storage): assert secret files are unchanged after install The end-to-end test now snapshots the keychain and secrets files before installing the memory storage backend and compares their contents afterwards, instead of only checking that they do not exist. This verifies the backend leaves any pre-existing secret files untouched rather than assuming they were never written. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/storage_secrets_e2e.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/storage_secrets_e2e.rs b/tests/storage_secrets_e2e.rs index e6e286cf929..288cd050bf6 100644 --- a/tests/storage_secrets_e2e.rs +++ b/tests/storage_secrets_e2e.rs @@ -35,6 +35,12 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { let profiles_file = std::fs::read(state.join("auth-profiles.json")).unwrap(); let http_file = std::fs::read(state.join("http-credentials.json")).unwrap(); + let before: std::collections::HashMap<&str, Option>> = + ["secrets.enc", "dev-keychain.json"] + .into_iter() + .map(|f| (f, std::fs::read(workspace.path().join(f)).ok())) + .collect(); + openhuman_core::storage::install(Arc::new(openhuman_core::storage::MemoryStorage::new())); keyring::set("user-1", "api_token", "tok-123").unwrap(); @@ -72,13 +78,12 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { std::fs::read(state.join("http-credentials.json")).unwrap(), http_file ); - for file in [ - "secrets.enc", - "dev-keychain.json", - ] { - assert!( - !workspace.path().join(file).exists(), - "{file} was not written" + // Neither the process keychain file nor `secrets.enc` was touched. + for file in ["secrets.enc", "dev-keychain.json"] { + assert_eq!( + std::fs::read(workspace.path().join(file)).ok(), + before.get(file).cloned().flatten(), + "{file} unchanged" ); } From f5344c855b066fe28dce70477ee86efad1f2e987 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:53:59 +0300 Subject: [PATCH 18/26] refactor(security): extract keyring file operations into ops module Moved the encrypted file backend's read, write, and delete helpers into a dedicated ops module so the backend only handles keyring orchestration. This keeps the file-level crypto and IO concerns separate from the backend's credential management logic. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../keyring/encrypted_file_backend.rs | 15 +++++-- .../src/security/keyring/ops.rs | 44 ++++++++++++++----- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs b/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs index 1b62e8af704..52e6aca3114 100644 --- a/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs +++ b/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs @@ -45,6 +45,11 @@ type MasterKeyInit = Result, String>; /// Process-wide master-key outcome, set once by [`init_master_key`]. static MASTER_KEY: OnceLock = OnceLock::new(); +/// Set when [`init_master_key`] found the OS keychain unable to provide the +/// key, so storage secrets reuse that outcome instead of retrying the +/// keychain (and its prompt) on their first operation. +static KEYCHAIN_UNAVAILABLE: OnceLock<()> = OnceLock::new(); + // ── Public API for core startup ────────────────────────────────────────────── /// Initialize the keyring subsystem: set the workspace directory and load @@ -109,6 +114,7 @@ pub fn init_master_key() -> Result<(), String> { // Surface the denied state to the frontend instead of silently // resetting — this is the "warn before reset" the issue asks for. crate::security::keyring_consent::policy::notify_master_key_unavailable(&e); + let _ = KEYCHAIN_UNAVAILABLE.set(()); Ok(None) } } @@ -131,11 +137,14 @@ pub(crate) fn storage_master_key() -> Result<[u8; KEY_LEN], String> { static STORAGE_MASTER_KEY: OnceLock<[u8; KEY_LEN]> = OnceLock::new(); match MASTER_KEY.get() { Some(Ok(Some(key))) => return Ok(*key), - // `init_master_key` already tried the keychain this session and - // recorded it unavailable: reuse that outcome, do not prompt again. - Some(Ok(None)) => return Err("OS keychain master key unavailable this session".into()), _ => {} } + // `init_master_key` already tried the keychain this session and it + // failed: reuse that outcome, do not prompt again. (`Ok(None)` alone also + // means "backend needs no key / init skipped", which must still load.) + if KEYCHAIN_UNAVAILABLE.get().is_some() { + return Err("OS keychain master key unavailable this session".into()); + } if let Some(key) = STORAGE_MASTER_KEY.get() { return Ok(*key); } diff --git a/crates/openhuman-core/src/security/keyring/ops.rs b/crates/openhuman-core/src/security/keyring/ops.rs index 7d1ce0aa2e2..54b0865ce6a 100644 --- a/crates/openhuman-core/src/security/keyring/ops.rs +++ b/crates/openhuman-core/src/security/keyring/ops.rs @@ -68,9 +68,24 @@ pub fn get(user_id: &str, key: &str) -> Result, KeyringError> { }; log::debug!("[keyring] get (storage)"); let name = namespaced_key(user_id, key); + let write_back = secrets.clone(); let value = crate::storage::block_on(async move { secrets.get(&name).await }).map_err(storage_error)?; - value + let Some(value) = value else { + // Not on the backend yet: adopt a secret an earlier process-keyring + // install stored, so enabling a backend does not hide it. + let legacy = process_get(user_id, key).ok().flatten(); + if let Some(legacy) = &legacy { + let name = namespaced_key(user_id, key); + let bytes = zeroize::Zeroizing::new(legacy.as_bytes().to_vec()); + match crate::storage::block_on(async move { write_back.set(&name, &bytes).await }) { + Ok(()) => log::debug!("[keyring] adopted process-backend secret into storage"), + Err(_) => log::warn!("[keyring] could not adopt process-backend secret"), + } + } + return Ok(legacy); + }; + Some(value) .map(|bytes| { String::from_utf8(bytes.to_vec()).map_err(|source| KeyringError::InvalidUtf8 { key: key.to_string(), @@ -103,7 +118,12 @@ pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { log::debug!("[keyring] delete (storage)"); let name = namespaced_key(user_id, key); crate::storage::block_on(async move { secrets.delete(&name).await.map(|_| ()) }) - .map_err(storage_error) + .map_err(storage_error)?; + // Also drop an adoptable process-backend copy so it cannot resurface. + if process_delete(user_id, key).is_err() { + log::warn!("[keyring] delete: process-backend copy not removed"); + } + Ok(()) } // ── Process backend ─────────────────────────────────────────────────────────── @@ -113,14 +133,14 @@ pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { /// Returns `Ok(None)` when no entry exists for this user + key combination. /// Never logs the secret value. pub(crate) fn process_get(user_id: &str, key: &str) -> Result, KeyringError> { - log::debug!("[keyring] get user_id={user_id} key={key}"); + log::debug!("[keyring] get"); let namespaced = namespaced_key(user_id, key); let result = backend().get(&namespaced); match &result { - Ok(Some(_)) => log::debug!("[keyring] get hit user_id={user_id} key={key}"), - Ok(None) => log::debug!("[keyring] get miss user_id={user_id} key={key}"), + Ok(Some(_)) => log::debug!("[keyring] get hit"), + Ok(None) => log::debug!("[keyring] get miss"), Err(e) => log::warn!( - "[keyring] get error user_id={user_id} key={key}: {e} | detail={}", + "[keyring] get error: {e} | detail={}", e.diagnostic() ), } @@ -131,13 +151,13 @@ pub(crate) fn process_get(user_id: &str, key: &str) -> Result, Ke /// /// Overwrites any existing entry for this user + key. Never logs the value. pub(crate) fn process_set(user_id: &str, key: &str, value: &str) -> Result<(), KeyringError> { - log::debug!("[keyring] set user_id={user_id} key={key}"); + log::debug!("[keyring] set"); let namespaced = namespaced_key(user_id, key); let result = backend().set(&namespaced, value); match &result { - Ok(()) => log::debug!("[keyring] set ok user_id={user_id} key={key}"), + Ok(()) => log::debug!("[keyring] set ok"), Err(e) => log::warn!( - "[keyring] set error user_id={user_id} key={key}: {e} | detail={}", + "[keyring] set error: {e} | detail={}", e.diagnostic() ), } @@ -148,13 +168,13 @@ pub(crate) fn process_set(user_id: &str, key: &str, value: &str) -> Result<(), K /// /// Returns `Ok(())` even if no entry existed (idempotent). pub(crate) fn process_delete(user_id: &str, key: &str) -> Result<(), KeyringError> { - log::debug!("[keyring] delete user_id={user_id} key={key}"); + log::debug!("[keyring] delete"); let namespaced = namespaced_key(user_id, key); let result = backend().delete(&namespaced); match &result { - Ok(()) => log::debug!("[keyring] delete ok user_id={user_id} key={key}"), + Ok(()) => log::debug!("[keyring] delete ok"), Err(e) => log::warn!( - "[keyring] delete error user_id={user_id} key={key}: {e} | detail={}", + "[keyring] delete error: {e} | detail={}", e.diagnostic() ), } From 9d8e496d1a57a6b77a1b07c463dfd5f8e1dc87ab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:54:16 +0300 Subject: [PATCH 19/26] fix(security): tolerate failed credential migration to storage Migration of legacy http-credentials and auth profiles to the storage secret no longer aborts the read when the write fails; the legacy data stays readable and the next read retries the adoption. The keyring get path was also simplified to drop a redundant Option round-trip. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/security/credentials/http_creds.rs | 19 ++++++++----- .../credentials/profiles/persistence.rs | 16 +++++++++-- .../src/security/keyring/ops.rs | 27 ++++++------------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/crates/openhuman-core/src/security/credentials/http_creds.rs b/crates/openhuman-core/src/security/credentials/http_creds.rs index d60cc5f3384..51a09d7e50d 100644 --- a/crates/openhuman-core/src/security/credentials/http_creds.rs +++ b/crates/openhuman-core/src/security/credentials/http_creds.rs @@ -346,15 +346,22 @@ impl HttpCredentialsStore { if self.path.exists() && !legacy.credentials.is_empty() { let json = serde_json::to_vec(&legacy) .context("failed to serialize migrated http-credentials store")?; - crate::storage::secrets::set_blocking( + // A failed adoption keeps the legacy credentials + // readable; the next read retries it. + match crate::storage::secrets::set_blocking( &secrets, STORAGE_SECRET_NAME, &json, - )?; - log::info!( - target: "credentials", - "[credentials] migrated http-credentials file to storage secret" - ); + ) { + Ok(()) => log::info!( + target: "credentials", + "[credentials] migrated http-credentials file to storage secret" + ), + Err(_) => log::warn!( + target: "credentials", + "[credentials] http-credentials migration to storage failed; will retry" + ), + } } Ok(legacy) } diff --git a/crates/openhuman-core/src/security/credentials/profiles/persistence.rs b/crates/openhuman-core/src/security/credentials/profiles/persistence.rs index d380a82d657..44eb936437f 100644 --- a/crates/openhuman-core/src/security/credentials/profiles/persistence.rs +++ b/crates/openhuman-core/src/security/credentials/profiles/persistence.rs @@ -130,8 +130,20 @@ impl AuthProfilesStore { if self.path.exists() && !legacy.profiles.is_empty() { let json = serde_json::to_vec(&legacy) .context("Failed to serialize migrated auth profiles")?; - crate::storage::secrets::set_blocking(&secrets, STORAGE_SECRET_NAME, &json)?; - tracing::info!("[credentials] migrated auth profiles file to storage secret"); + // A failed adoption keeps the legacy profiles readable; + // the next read retries it. + match crate::storage::secrets::set_blocking( + &secrets, + STORAGE_SECRET_NAME, + &json, + ) { + Ok(()) => tracing::info!( + "[credentials] migrated auth profiles file to storage secret" + ), + Err(_) => tracing::warn!( + "[credentials] auth profiles migration to storage failed; will retry" + ), + } } return Ok(legacy); }; diff --git a/crates/openhuman-core/src/security/keyring/ops.rs b/crates/openhuman-core/src/security/keyring/ops.rs index 54b0865ce6a..e806379cddb 100644 --- a/crates/openhuman-core/src/security/keyring/ops.rs +++ b/crates/openhuman-core/src/security/keyring/ops.rs @@ -85,14 +85,12 @@ pub fn get(user_id: &str, key: &str) -> Result, KeyringError> { } return Ok(legacy); }; - Some(value) - .map(|bytes| { - String::from_utf8(bytes.to_vec()).map_err(|source| KeyringError::InvalidUtf8 { - key: key.to_string(), - source, - }) + String::from_utf8(value.to_vec()) + .map(Some) + .map_err(|source| KeyringError::InvalidUtf8 { + key: key.to_string(), + source, }) - .transpose() } /// Store a secret: in the storage backend when one is configured, else in @@ -139,10 +137,7 @@ pub(crate) fn process_get(user_id: &str, key: &str) -> Result, Ke match &result { Ok(Some(_)) => log::debug!("[keyring] get hit"), Ok(None) => log::debug!("[keyring] get miss"), - Err(e) => log::warn!( - "[keyring] get error: {e} | detail={}", - e.diagnostic() - ), + Err(e) => log::warn!("[keyring] get error: {e} | detail={}", e.diagnostic()), } result } @@ -156,10 +151,7 @@ pub(crate) fn process_set(user_id: &str, key: &str, value: &str) -> Result<(), K let result = backend().set(&namespaced, value); match &result { Ok(()) => log::debug!("[keyring] set ok"), - Err(e) => log::warn!( - "[keyring] set error: {e} | detail={}", - e.diagnostic() - ), + Err(e) => log::warn!("[keyring] set error: {e} | detail={}", e.diagnostic()), } result } @@ -173,10 +165,7 @@ pub(crate) fn process_delete(user_id: &str, key: &str) -> Result<(), KeyringErro let result = backend().delete(&namespaced); match &result { Ok(()) => log::debug!("[keyring] delete ok"), - Err(e) => log::warn!( - "[keyring] delete error: {e} | detail={}", - e.diagnostic() - ), + Err(e) => log::warn!("[keyring] delete error: {e} | detail={}", e.diagnostic()), } result } From ef59bd44942eb721987379a9d7b1750fe342a2df Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:56:16 +0300 Subject: [PATCH 20/26] test(storage): cover keyring adoption and deletion of legacy secrets Extend the secrets end-to-end test to seed a value in the process keyring and verify it is adopted on first read. Also assert that deleting it clears the secret from both stores so it cannot resurface. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/storage_secrets_e2e.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/storage_secrets_e2e.rs b/tests/storage_secrets_e2e.rs index 288cd050bf6..6ae379a6868 100644 --- a/tests/storage_secrets_e2e.rs +++ b/tests/storage_secrets_e2e.rs @@ -32,6 +32,7 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { HttpCredentialsStore::new(&state, false) .upsert(&HttpCredential::bearer("legacy-http", "ghp-legacy")) .unwrap(); + keyring::set("legacy-user", "old_token", "old-value").unwrap(); let profiles_file = std::fs::read(state.join("auth-profiles.json")).unwrap(); let http_file = std::fs::read(state.join("http-credentials.json")).unwrap(); @@ -52,6 +53,15 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { keyring::delete("user-1", "api_token").unwrap(); assert!(keyring::get("user-1", "api_token").unwrap().is_none()); + // A secret the process keyring held is adopted on first read, and a + // delete removes it from both places so it cannot resurface. + assert_eq!( + keyring::get("legacy-user", "old_token").unwrap().as_deref(), + Some("old-value") + ); + keyring::delete("legacy-user", "old_token").unwrap(); + assert!(keyring::get("legacy-user", "old_token").unwrap().is_none()); + let profiles = AuthProfilesStore::new(&state, false); profiles .upsert_profile( From 5e1212af4577fe8d279a77519923ad64b35a90e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 14:56:53 +0300 Subject: [PATCH 21/26] test(storage): assert secrets stay out of the process keychain The end-to-end test now checks that secrets.enc is untouched and that the storage-side secret never reaches the process keychain file, rather than comparing the keychain file byte for byte. This makes the assertion about the behaviour that matters instead of incidental file contents. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/storage_secrets_e2e.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/storage_secrets_e2e.rs b/tests/storage_secrets_e2e.rs index 6ae379a6868..ccc4572451f 100644 --- a/tests/storage_secrets_e2e.rs +++ b/tests/storage_secrets_e2e.rs @@ -88,14 +88,15 @@ fn a_configured_backend_holds_keyring_and_credential_secrets() { std::fs::read(state.join("http-credentials.json")).unwrap(), http_file ); - // Neither the process keychain file nor `secrets.enc` was touched. - for file in ["secrets.enc", "dev-keychain.json"] { - assert_eq!( - std::fs::read(workspace.path().join(file)).ok(), - before.get(file).cloned().flatten(), - "{file} unchanged" - ); - } + // `secrets.enc` was not touched, and nothing of the storage-side secret + // reached the process keychain file. + assert_eq!( + std::fs::read(workspace.path().join("secrets.enc")).ok(), + before.get("secrets.enc").cloned().flatten(), + "secrets.enc unchanged" + ); + let dev = std::fs::read_to_string(workspace.path().join("dev-keychain.json")).unwrap(); + assert!(!dev.contains("user-1") && !dev.contains("tok-123"), "{dev}"); // Without a backend the files are back in use. assert!(openhuman_core::storage::clear()); From bcbc3ee4e4f75f916573ac98814ede98e26b0dc7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 15:00:29 +0300 Subject: [PATCH 22/26] refactor(security): simplify master key cache lookup Replace the match on the cached master key with an if-let so the hit path returns directly and the miss case falls through without an empty arm. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/security/keyring/encrypted_file_backend.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs b/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs index 52e6aca3114..5c662e4b842 100644 --- a/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs +++ b/crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs @@ -135,9 +135,8 @@ pub(crate) fn storage_master_key() -> Result<[u8; KEY_LEN], String> { // Only a loaded key is cached: a failure (locked keychain, denied prompt) // is retried on the next call so secrets recover once access is restored. static STORAGE_MASTER_KEY: OnceLock<[u8; KEY_LEN]> = OnceLock::new(); - match MASTER_KEY.get() { - Some(Ok(Some(key))) => return Ok(*key), - _ => {} + if let Some(Ok(Some(key))) = MASTER_KEY.get() { + return Ok(*key); } // `init_master_key` already tried the keychain this session and it // failed: reuse that outcome, do not prompt again. (`Ok(None)` alone also From 7382cd7ad48ecc161fdfd11497dd60c3b1295a0a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 15:13:13 +0300 Subject: [PATCH 23/26] chore(ci): bump dep-sim calibration to 314 names Secrets on the storage backend add the first-party tinystoragedrivers-secrets crate to the flows graph, whose crypto dependencies were already counted. The expected name count and the kernel floor limits entry are raised to match. Auto-committed-on: dragonfly Co-authored-by: Medulla --- scripts/ci/check-dep-sim-calibration.sh | 7 +++++-- scripts/kernel-floor.limits | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/ci/check-dep-sim-calibration.sh b/scripts/ci/check-dep-sim-calibration.sh index 60a5011c5a5..2a94d1283bd 100755 --- a/scripts/ci/check-dep-sim-calibration.sh +++ b/scripts/ci/check-dep-sim-calibration.sh @@ -101,8 +101,11 @@ cd "$(dirname "$0")/../.." # dependencies into the flows graph. # 320 -> 313 on 2026-10-09: the tinyagents and tinyflows mains shed seven # names from the flows graph. -# This matches the current `flows:335:313:2` entry in +# 313 -> 314 on 2026-10-09: secrets on the storage backend add the +# first-party tinystoragedrivers-secrets crate (its crypto deps were already +# in the graph). +# This matches the current `flows:336:314:2` entry in # scripts/kernel-floor.limits; its preceding entries are historical. -EXPECTED_NAMES=313 +EXPECTED_NAMES=314 exec python3 scripts/dep-sim.py --cut-nothing --expect-names "${EXPECTED_NAMES}" diff --git a/scripts/kernel-floor.limits b/scripts/kernel-floor.limits index 790c700dca6..1f467e44c60 100644 --- a/scripts/kernel-floor.limits +++ b/scripts/kernel-floor.limits @@ -663,4 +663,4 @@ # not add a native build dependency. # 332/311/3 2026-10-04 Memory lifecycle refresh removed five packages/names # from the Linux flows graph; native builds remain at 3. -flows:335:313:2 +flows:336:314:2 From 621445ca04eb68c9a76decf4eb2ff4f9da482888 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 15:13:18 +0300 Subject: [PATCH 24/26] chore(ci): raise the flows kernel floor for tinystoragedrivers-secrets Co-authored-by: Medulla --- scripts/kernel-floor.limits | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/kernel-floor.limits b/scripts/kernel-floor.limits index 1f467e44c60..a96aba5121c 100644 --- a/scripts/kernel-floor.limits +++ b/scripts/kernel-floor.limits @@ -663,4 +663,6 @@ # not add a native build dependency. # 332/311/3 2026-10-04 Memory lifecycle refresh removed five packages/names # from the Linux flows graph; native builds remain at 3. +# 336/314/2 2026-10-09 secrets on the storage backend add the first-party +# tinystoragedrivers-secrets crate (one package, one name). flows:336:314:2 From e3d78e15bd3f1e56498a21935bf54281241bd3f5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 15:31:24 +0300 Subject: [PATCH 25/26] fix(rpc): use crate-relative path for background completion store release The shutdown path referenced the orchestration module through the openhuman_core crate path, which does not resolve from within the RPC server. Pointing it at the crate-local core_host re-export restores the call so background completion logs are released on exit. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/openhuman-rpc/src/server/serve.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-rpc/src/server/serve.rs b/crates/openhuman-rpc/src/server/serve.rs index f21710b6665..604004a0cde 100644 --- a/crates/openhuman-rpc/src/server/serve.rs +++ b/crates/openhuman-rpc/src/server/serve.rs @@ -205,7 +205,7 @@ pub async fn serve( runtime.exit_cleanup().await; // Close the per-workspace background-completion logs (they replay from disk // on the next boot) so a data reset can delete the workspace directory. - openhuman_core::agent::orchestration::release_background_completion_stores().await; + crate::core_host::agent::orchestration::release_background_completion_stores().await; served?; Ok(()) From cff1d3075d7ec1c9116e1a93818ac7befbea0478 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 15:32:56 +0300 Subject: [PATCH 26/26] style(storage): order module declarations after the merge Co-authored-by: Medulla --- crates/openhuman-core/src/storage/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/storage/mod.rs b/crates/openhuman-core/src/storage/mod.rs index aa71abea313..d6b1ab5ba2d 100644 --- a/crates/openhuman-core/src/storage/mod.rs +++ b/crates/openhuman-core/src/storage/mod.rs @@ -18,8 +18,8 @@ //! driver the build does not carry fails at [`open`] naming the feature, so a //! misconfigured deployment stops at boot instead of at its first write. -pub mod secrets; pub mod documents; +pub mod secrets; use std::future::Future; use std::sync::{Arc, LazyLock, OnceLock, RwLock};