Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cdf9cb7
test(keyring): add fixture generation test for encrypted file backend
senamakel Oct 10, 2026
06f8688
test(keyring): add fixture compatibility tests for encrypted file bac…
senamakel Oct 10, 2026
4480ba2
test(keyring): update fixture path to secrets_enc.bin
senamakel Oct 10, 2026
a719407
test(keyring): commit the secrets.enc fixture generated by the pre-po…
senamakel Oct 10, 2026
bd63ace
feat(security): add encrypted file keyring backend
senamakel Oct 10, 2026
39259d6
feat(security): add encrypted file keyring backend
senamakel Oct 10, 2026
081d770
chore: files changed Cargo.lock,crates/openhuman-core/Cargo.toml,crat…
senamakel Oct 10, 2026
7880b58
refactor(security): extract keyring ops into dedicated module
senamakel Oct 10, 2026
1a911fd
chore(keyring): apply rustfmt and register adapter tests
senamakel Oct 10, 2026
ea98f8a
chore(deps): add keyring to openhuman-app lockfile
senamakel Oct 10, 2026
a221a84
Merge upstream/main into storage-secrets-config
senamakel Oct 10, 2026
93ad98f
docs(keyring): update backend descriptions for tinystoragedrivers port
senamakel Oct 10, 2026
1dd9e9a
fix(keyring): recheck legacy file under lock before import
senamakel Oct 10, 2026
a023245
Merge upstream/main into storage-secrets-config
senamakel Oct 10, 2026
b32eef7
fix(security): persist keyring entries to the file store
senamakel Oct 10, 2026
b3c6fb3
test(keyring): cover recovery and quarantine edge cases
senamakel Oct 10, 2026
ac085e0
Merge upstream/main into storage-secrets-config
senamakel Oct 10, 2026
48977cc
Merge upstream/main into storage-secrets-config
senamakel Oct 10, 2026
69fa9b9
chore(vendor): bump tinybus submodule
senamakel Oct 10, 2026
4f9fe3a
refactor(relay): extract thread lock map type alias
senamakel Oct 10, 2026
8128347
chore(deps): remove libc from lockfile dependencies
senamakel Oct 10, 2026
ce6e610
refactor(agent): simplify drain_target fallback arm
senamakel Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/openhuman-app/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/openhuman-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,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", "secrets"] }
tinystoragedrivers = { path = "../../vendor/tinyagents/vendor/tinystoragedrivers/crates/tinystoragedrivers", default-features = false, features = ["blocking", "secrets", "keyring"] }
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
tinyagents-runtime = { path = "../../vendor/tinyagents/crates/tinyagents-runtime" }
tinyagents-orchestration = { path = "../../vendor/tinyagents/crates/tinyagents-orchestration" }
tinyagents-tasks = { path = "../../vendor/tinyagents/crates/tinyagents-tasks" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ fn drain_target(event: &DomainEvent) -> Option<(&String, Option<&String>, Durati
task_id,
..
} => Some((parent_session, Some(task_id), DEBOUNCE)),
_ => return None,
_ => None,
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/openhuman-core/src/channels/providers/relay/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ pub fn reply_message_id(message_id: &str) -> String {
/// One async lock per `(workspace, thread)`, so the duplicate check and the
/// append in [`record_inbound`] are atomic against a concurrent gateway retry.
fn thread_lock(workspace_dir: &Path, thread_id: &str) -> Arc<tokio::sync::Mutex<()>> {
static LOCKS: OnceLock<Mutex<HashMap<(PathBuf, String), Arc<tokio::sync::Mutex<()>>>>> =
OnceLock::new();
type ThreadLocks = Mutex<HashMap<(PathBuf, String), Arc<tokio::sync::Mutex<()>>>>;
static LOCKS: OnceLock<ThreadLocks> = OnceLock::new();
let mut map = LOCKS
.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
Expand Down
4 changes: 2 additions & 2 deletions crates/openhuman-core/src/security/keyring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ OS-keychain-backed secret storage with pluggable test and debug backends, plus a
| `crates/openhuman-core/src/security/keyring/mod.rs` | Module docstring and exports only. Documents backend-selection priority and the Linux-headless availability note. Re-exports the public surface. |
| `crates/openhuman-core/src/security/keyring/ops.rs` | Core operations: `get`/`set`/`delete`/`is_available`/`get_or_create_random`/`migrate_from_file`, the `MigrationOutcome` enum, the `namespaced_key` helper, the cached availability probe, and `force_backend_for_test`. |
| `crates/openhuman-core/src/security/keyring/store.rs` | Backend selection and global state. Owns the `WORKSPACE_DIR` and `BACKEND` `OnceLock`s, `init_workspace`, `backend()`, `build_backend()`/`build_backend_at()`, and `workspace_dir_for_file_backend()` path derivation. Also the `cfg(test)`-only `test_scope` module (thread-scoped test workspaces). |
| `crates/openhuman-core/src/security/keyring/backend.rs` | `KeyringBackend` trait plus `OsBackend` (native keychain via the `keyring` crate, service name `"openhuman"`), `FileBackend` (plaintext `dev-keychain.json`, test/debug only), and test-only `MockBackend`. |
| `crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs` | `EncryptedFileBackend`: all secrets in one ChaCha20-Poly1305 `secrets.enc` file keyed by an app master key; `init_master_key`; legacy `dev-keychain.json` migration; corrupt-file quarantine. |
| `crates/openhuman-core/src/security/keyring/backend.rs` | `KeyringBackend` trait plus `OsBackend` (native keychain through `tinystoragedrivers`' `KeyringSecrets` over the `Blocking` bridge, service name `"openhuman"`), `FileBackend` (plaintext `dev-keychain.json`, test/debug only), and test-only `MockBackend`. |
| `crates/openhuman-core/src/security/keyring/encrypted_file_backend.rs` | `EncryptedFileBackend`: an adapter over `tinystoragedrivers`' `EncryptedFileSecrets` (format and `secrets.enc.lock` unchanged): all secrets in one ChaCha20-Poly1305 `secrets.enc` file keyed by an app master key; `init_master_key`; legacy `dev-keychain.json` migration; corrupt-file quarantine (a desktop policy kept here: the driver fails closed, this adapter moves the file aside as `secrets.enc.corrupt.<ts>` and continues, so a bad file never wedges sign-in). `adapter.rs` holds the shared error mapping and that policy; `fixtures/` holds bytes written by the pre-port code. |
| `crates/openhuman-core/src/security/keyring/encrypted_store.rs` | `SecretStore`: config-field encryption (`enc2:` ChaCha20-Poly1305, legacy `enc:` XOR migration), keychain-backed master key with legacy `.secret_key` file migration, process-wide key cache, Windows ACL repair (`icacls`). |
| `crates/openhuman-core/src/security/keyring/file_store.rs` | Shared secrets-file primitives for both file backends: the cross-process advisory write lock (`lock_for_write`, on a sidecar `<path>.lock`), the `0600` unique-temp-then-rename `write_atomic`, and `quarantine_corrupt`. |
| `crates/openhuman-core/src/security/keyring/crypto.rs` | Shared ChaCha20-Poly1305 helpers (`chacha20_encrypt`/`chacha20_decrypt`), random-byte generation, hex encode/decode. Used by both `encrypted_store` and `encrypted_file_backend`. |
Expand Down
111 changes: 111 additions & 0 deletions crates/openhuman-core/src/security/keyring/adapter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
//! Shared plumbing for the keyring backends that sit over
//! `tinystoragedrivers`' `SecretStore` port: error mapping, UTF-8 decoding
//! and the desktop corruption policy.

use std::path::Path;

use tinystoragedrivers::secrets::{EncryptedFileSecrets, SecretStore as _};
Comment thread
senamakel marked this conversation as resolved.
use tinystoragedrivers::{ErrorKind, StorageError};
use zeroize::Zeroizing;

use crate::security::keyring::error::KeyringError;
use crate::security::keyring::file_store;

/// A driver error as a [`KeyringError::Backend`], for the file store.
pub(super) fn backend_error(error: StorageError) -> KeyringError {
KeyringError::Backend(format!("secret store: {error}"))
}

/// A driver error as the [`KeyringError::Os`] the OS-keychain callers (and
/// the credential-ref error classifier) have always seen. A locked or denied
/// store keeps its `NoStorageAccess` identity; the driver's error rides along
/// as the source (it never carries a secret value).
pub(super) fn os_error(key: &str, error: StorageError) -> KeyringError {
let source = if error.kind() == ErrorKind::Unavailable {
Comment thread
senamakel marked this conversation as resolved.
keyring::Error::NoStorageAccess(Box::new(error))
} else {
keyring::Error::PlatformFailure(Box::new(error))
};
KeyringError::Os {
key: key.to_string(),
source,
}
}

/// Whether the keychain refused access rather than failed: reads and deletes
/// treat that as "nothing there", as they always have.
pub(super) fn is_no_access(error: &StorageError) -> bool {
error.kind() == ErrorKind::Unavailable
}

/// Whether the driver reports the secrets file itself as unreadable
/// (undecryptable under this key, or not the expected JSON).
pub(super) fn is_corruption(error: &StorageError) -> bool {
matches!(error.kind(), ErrorKind::Crypto | ErrorKind::Serialization)
}

/// Decode a stored value as UTF-8 text.
pub(super) fn utf8(
key: &str,
value: Option<Zeroizing<Vec<u8>>>,
) -> Result<Option<String>, KeyringError> {
value
.map(|bytes| {
String::from_utf8(bytes.to_vec()).map_err(|source| KeyringError::InvalidUtf8 {
key: key.to_string(),
source,
})
})
.transpose()
}

/// Desktop policy for a `secrets.enc` the driver could not read: move it
/// aside and let the caller continue on an empty store.
///
/// The driver fails closed (error, file untouched), which suits a server with
/// an operator but would wedge a desktop: every write fails, so the user can
/// never sign in again. Today's behaviour is kept instead: quarantine the
/// bytes (`secrets.enc.corrupt.<ts>`, never deleted) and log loudly.
///
/// Under the same cross-process lock writers take, the file is probed again
/// first: a concurrent writer may already have replaced it, and quarantining
/// that replacement would destroy good secrets.
///
/// # Errors
///
/// When the file cannot be moved aside. Continuing would overwrite secrets
/// that could not be read, so the operation fails closed instead.
pub(super) fn recover_corrupt_file(
path: &Path,
key: &[u8; 32],
cause: &StorageError,
) -> Result<(), KeyringError> {
let _guard = file_store::lock_for_write(path)?;
let store = EncryptedFileSecrets::at_path(path, Zeroizing::new(*key));
match crate::storage::block_on(async move { store.list("").await }) {
Ok(_) => {
log::info!("[keyring:encrypted_file] secrets file readable again; not quarantining");
return Ok(());
}
// Still corrupt: fall through to quarantine.
Err(error) if is_corruption(&error) => {}
// Anything else (a transient read or permission error) says nothing
// about the file's contents: never move a possibly healthy file aside.
Err(error) => return Err(backend_error(error)),
}
log::error!(
"[keyring:encrypted_file] secrets file unreadable ({}): master key may have changed \
or the file is corrupt",
cause.message()
);
if file_store::quarantine_corrupt(path, "enc").is_none() {
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
return Err(KeyringError::Backend(
"secrets file is unreadable and could not be moved aside".to_string(),
));
}
Ok(())
}

#[cfg(test)]
#[path = "adapter_tests.rs"]
pub(crate) mod tests;
Loading
Loading