Skip to content

fix(keyring): don't mint a new master key on keychain access-denied (#3311)#3338

Merged
senamakel merged 2 commits into
tinyhumansai:mainfrom
oxoxDev:fix/3311-keyring-no-mint-on-access-denied
Jun 4, 2026
Merged

fix(keyring): don't mint a new master key on keychain access-denied (#3311)#3338
senamakel merged 2 commits into
tinyhumansai:mainfrom
oxoxDev:fix/3311-keyring-no-mint-on-access-denied

Conversation

@oxoxDev
Copy link
Copy Markdown
Contributor

@oxoxDev oxoxDev commented Jun 4, 2026

Summary

  • Stop the encrypted-file keyring backend from minting a new master key when the OS keychain denies access to the existing one.
  • Root cause of Update wiped out my API keys and some conecters disconnect by themselves #3311: on a macOS app update, a code-signing / keychain-ACL change makes the existing master key unreadable (NoStorageAccess, not NoEntry). The old code conflated the two and minted a replacement — orphaning every secret encrypted under the old key (silent API-key wipe + disconnected connectors, no warning).
  • Only a genuine NoEntry may now mint; every other error fails safe (no mint, no set_password overwrite), so existing ciphertext survives and recovers on the next launch once access is restored.
  • On failure, startup now logs at error and publishes KeyringConsentRequired so the frontend can warn instead of silently resetting — the "warn before reset" the issue asks for.
  • Decision logic extracted behind a MasterKeyEntry trait and covered by unit tests for each error arm.

Problem

Secrets (API keys, connector tokens) are encrypted in a file whose single master key lives in the OS keychain (introduced in #2660). In try_load_master_key, the mint-new branch matched both keyring::Error::NoEntry and keyring::Error::NoStorageAccess(_):

Err(keyring::Error::NoEntry) | Err(keyring::Error::NoStorageAccess(_)) => {
    // generate a NEW random master key and set_password(new)
}

NoEntry means "no key was ever stored" — minting is correct. NoStorageAccess means "a key exists but I can't read it right now" (locked keychain, or — the #3311 trigger — an app update changed the binary's code-signing identity / the keychain item's ACL trust). Minting there overwrites the only key that can decrypt the user's secrets. Every secret is then undecryptable: API keys appear wiped, connectors disconnect, and the only log line was an info!. This matches the report exactly — "after update", "all at once" (single master key gates everything), "no warning".

This path only runs in staging/production (OPENHUMAN_APP_ENV), so dev never hit it — consistent with it only surfacing in shipped builds.

Solution

  • Split the match so only NoEntry mints. A catch-all Err(e) arm returns an error without minting or calling set_password. This is deliberately variant-independent: whatever variant macOS actually returns on a post-update denial, anything that isn't NoEntry fails safe.
  • init_master_key now logs the failure at error and calls a new keyring_consent::policy::notify_master_key_unavailable, which publishes the existing DomainEvent::KeyringConsentRequired (reusing the consent-gate dedup flag so it never double-publishes).
  • Extracted the get/mint decision into load_or_mint_master_key<E: MasterKeyEntry> behind a small trait. A real keyring::Entry can't be exercised under cargo test (first access blocks on a GUI prompt), so the trait lets a fake inject each keyring::Error variant. The pure decision fn touches no MASTER_KEY OnceLock state, so the tests need no global reset seam.
  • Trade-off: a genuinely corrupted/missing entry now needs a user action (re-grant or explicit reset) instead of auto-minting. Auto-minting is exactly what destroyed the data, so failing safe is the correct default.

Submission Checklist

If a section does not apply to this change, mark the item as N/A with a one-line reason. Do not delete items.

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage ≥ 80%cargo test --lib openhuman::keyring green (92 tests); the new load_or_mint_master_key arms are unit-tested. The 2-line event-publish glue in init_master_key is not unit-covered (it drives the global event bus); CI diff-cover is the gate.
  • N/A: behaviour-only bug fix — no feature row added/removed/renamed in the coverage matrix.
  • N/A: bug fix, no feature IDs from the matrix are affected.
  • No new external network dependencies introduced (mock backend used per Testing Strategy)
  • N/A: no release-cut smoke surface changed (the trigger — a code-signing/ACL change across an update — cannot be reproduced locally; see Impact).
  • Linked issue closed via Closes #NNN in the ## Related section

Impact

  • Platform: desktop only (staging/production keyring path). No mobile/web/CLI impact.
  • Security: strictly safer — removes a silent destructive overwrite of the master key under access denial. No secret is logged.
  • Compatibility: no data migration. Users whose secrets were previously orphaned by the old mint are not auto-recovered by this PR (the old key is already gone); this prevents future occurrences and surfaces the denied state instead.
  • Repro caveat (honest): the trigger is an OS-keychain access denial caused by a code-signing/ACL change across an app update, which cannot be deterministically reproduced in a local/dev build. Confidence rests on unit tests for the branch logic, a keyring-crate error-variant audit, and the variant-independent catch-all design — not on a live end-to-end repro.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

Commit & Branch

  • Branch: fix/3311-keyring-no-mint-on-access-denied
  • Commit SHA: 239c4c2f865972bdf0c72be9deacc460de618d99

Validation Run

  • N/A: pnpm --filter openhuman-app format:check — no frontend files changed
  • N/A: pnpm typecheck — no frontend files changed
  • Focused tests: cargo test --lib openhuman::keyring — 92 passed, 0 failed
  • Rust fmt/check (if changed): cargo fmt --check clean; cargo check --lib clean; cargo clippy --lib no new lints in changed files
  • N/A: Tauri fmt/check — no app/src-tauri files changed

Validation Blocked

  • command: live end-to-end repro of post-update keychain access denial
  • error: cannot fake a code-signing / keychain-ACL change in a local build
  • impact: branch logic verified by unit tests + crate audit instead; catch-all design is variant-independent

Behavior Changes

  • Intended behavior change: keychain access denial no longer mints/overwrites the master key; it fails safe and warns.
  • User-visible effect: after an update that denies keychain access, secrets stay intact (recover once access is restored) instead of being silently wiped; the app surfaces a keyring-consent/warning signal.

Parity Contract

  • Legacy behavior preserved: genuine first-run (NoEntry) still mints + stores a master key exactly as before; successful load path unchanged.
  • Guard/fallback/dispatch parity checks: only the access-denied/error path changed (mint → fail-safe + warn).

Duplicate / Superseded PR Handling

  • Duplicate PR(s): none (verified open + 30d-merged on api-key/connector/credential/persist/migrat/secret keywords)
  • Canonical PR: this PR
  • Resolution: N/A

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Enhanced error handling for master key access failures with explicit status notifications to the frontend.
  • New Features

    • Improved data preservation when master key access is unavailable, preventing accidental overwrite of existing encrypted secrets.

oxoxDev added 2 commits June 4, 2026 14:08
…inyhumansai#3311)

Surface a master-key load failure proactively at startup by publishing
KeyringConsentRequired, reusing the existing consent-gate dedup flag so
the user is warned instead of secrets silently appearing empty.
…inyhumansai#3311)

try_load_master_key conflated keyring::Error::NoStorageAccess with
NoEntry, so a macOS app update that changes the binary's code-signing
identity / keychain ACL — making the EXISTING master key unreadable —
caused the code to mint a brand-new key and overwrite the keychain
entry. Every secret encrypted under the old key became undecryptable:
API keys silently wiped, connectors disconnected, no warning.

Split the match so only a genuine NoEntry mints; the catch-all Err(e)
arm fails safe (no mint, no set_password), making the fix independent
of the exact error variant macOS returns on denial. Ciphertext survives
and recovers on the next launch once keychain access is restored. On
failure, init_master_key now logs at error and publishes the warn signal.

Extract the decision behind a MasterKeyEntry trait so the NoEntry-mints
vs denied-fails-safe arms are unit-tested with an injected fake (no real
OS keychain, no MASTER_KEY OnceLock involvement).
@oxoxDev oxoxDev requested a review from a team June 4, 2026 08:40
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR prevents data loss when the OS keychain becomes unavailable by restricting new master-key generation to genuine absence (NoEntry error) rather than all failures, preserving existing encrypted secrets on access denial or platform errors. The fix is abstracted for testability and integrated with frontend event notification.

Changes

Master Key Availability and Error Handling

Layer / File(s) Summary
MasterKeyEntry trait and load-vs-mint error logic
src/openhuman/keyring/encrypted_file_backend.rs
Introduced MasterKeyEntry trait for abstract keyring interaction and refactored load_or_mint_master_key to only mint new master keys on NoEntry error; all other errors return without calling set_password, preserving existing ciphertext per issue #3311.
Unit tests with FakeEntry implementation
src/openhuman/keyring/encrypted_file_backend.rs
Added in-memory FakeEntry mock implementing MasterKeyEntry, with tests validating: no overwrite of existing keys, mint only on genuine absence, fail-safe refusal on access denied and platform failures, and error rejection of wrong-length keys without overwriting.
Error notification and frontend event publishing
src/openhuman/keyring/encrypted_file_backend.rs, src/openhuman/keyring_consent/policy.rs
init_master_key logs and calls notify_master_key_unavailable when load fails; the new public function publishes KeyringConsentRequired frontend event with session-level deduplication to surface unavailability to the user.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

bug, rust-core

Poem

🐰 A keychain once lost, yet secrets stayed true,
No mint without proof—only birth when brand new.
With tests as our shield and the frontend alright,
Lost keys shall not vanish in the dark of the night! 🔐✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: preventing master-key minting when OS keychain access is denied, directly addressing issue #3311.
Linked Issues check ✅ Passed The PR implements all coding requirements from #3311: prevents silent key overwrites on access-denied errors, surfaces user warnings via KeyringConsentRequired event, and preserves existing secrets across updates.
Out of Scope Changes check ✅ Passed All changes are scoped to master-key loading logic and keyring consent notifications required by #3311; no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. bug labels Jun 4, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/openhuman/keyring/encrypted_file_backend.rs (1)

125-164: ⚡ Quick win

Add debug/trace logs around the keychain decision branches.

This new fix path makes the key external calls and branch decisions explicit, but it only emits an info! on successful mint. Please add debug!/trace! logs for the existing-key load path, the NoEntry mint path, the write/readback verification, and the fail-safe refusal branch so this flow is diagnosable without reproducing it locally.

Suggested diff
 fn load_or_mint_master_key<E: MasterKeyEntry>(entry: &E) -> Result<[u8; KEY_LEN], String> {
+    log::debug!("[keyring:encrypted_file] loading master key from OS keychain");
     match entry.get_password() {
         Ok(hex_str) => {
+            log::debug!("[keyring:encrypted_file] existing master key found");
             let bytes = crypto::hex_decode(hex_str.trim())?;
             if bytes.len() != KEY_LEN {
                 return Err(format!(
@@
         }
         Err(keyring::Error::NoEntry) => {
+            log::debug!(
+                "[keyring:encrypted_file] master key entry missing; minting a new master key"
+            );
             let key_bytes = crypto::generate_random_bytes(KEY_LEN);
             let hex_value = crypto::hex_encode(&key_bytes);
             entry
                 .set_password(&hex_value)
                 .map_err(|e| format!("failed to store new master key in keychain: {e}"))?;
@@
             if readback.trim() != hex_value {
                 return Err("master key write verification failed".to_string());
             }
+            log::debug!(
+                "[keyring:encrypted_file] new master key stored and readback verified"
+            );
@@
-        Err(e) => Err(format!(
-            "OS keychain access unavailable; refusing to mint a replacement master key so \
-             existing secrets are preserved (`#3311`): {e}"
-        )),
+        Err(e) => {
+            log::debug!(
+                "[keyring:encrypted_file] master key load failed; refusing replacement mint: {e}"
+            );
+            Err(format!(
+                "OS keychain access unavailable; refusing to mint a replacement master key so \
+                 existing secrets are preserved (`#3311`): {e}"
+            ))
+        }
     }
 }

As per coding guidelines, "Use log / tracing at debug or trace level for development-oriented logs at entry/exit points, branch decisions, external calls, retries/timeouts, state transitions, and error handling paths" and "Add substantial debug-level logs while implementing features or fixes in Rust using log / tracing crate with stable prefixes and correlation fields (request IDs, method names, entity IDs) for grep-friendly tracing".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/keyring/encrypted_file_backend.rs` around lines 125 - 164, Add
debug/trace logs in load_or_mint_master_key to make each branch observable: log
a debug/trace at the start of the function and when entry.get_password()
succeeds (include trimmed hex length or KEY_LEN), log a debug/trace on the
Err(keyring::Error::NoEntry) path right before generating the key and after
storing it (with hex_value masked or first/last chars for safety), log a
debug/trace around the write/readback verification (both success and mismatch
cases), and log a debug/trace in the final Err(e) refusal branch including the
error string; use log::debug!/log::trace! with a stable prefix like
"[keyring:encrypted_file]" and include method name load_or_mint_master_key for
grepability.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/openhuman/keyring_consent/policy.rs`:
- Around line 153-160: notify_master_key_unavailable currently always sets
CONSENT_EVENT_PUBLISHED and publishes DomainEvent::KeyringConsentRequired even
when consent is cached; change it to first consult the existing consent cache
(the same check used by check_secret_access() / CONSENT_CACHE) and return early
if consent is already present or marked declined, so you do not flip
CONSENT_EVENT_PUBLISHED or publish the event; only when the cache indicates no
consent should you proceed to swap CONSENT_EVENT_PUBLISHED and call
crate::core::event_bus::publish_global(DomainEvent::KeyringConsentRequired)
within notify_master_key_unavailable.

---

Nitpick comments:
In `@src/openhuman/keyring/encrypted_file_backend.rs`:
- Around line 125-164: Add debug/trace logs in load_or_mint_master_key to make
each branch observable: log a debug/trace at the start of the function and when
entry.get_password() succeeds (include trimmed hex length or KEY_LEN), log a
debug/trace on the Err(keyring::Error::NoEntry) path right before generating the
key and after storing it (with hex_value masked or first/last chars for safety),
log a debug/trace around the write/readback verification (both success and
mismatch cases), and log a debug/trace in the final Err(e) refusal branch
including the error string; use log::debug!/log::trace! with a stable prefix
like "[keyring:encrypted_file]" and include method name load_or_mint_master_key
for grepability.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 26ce499e-5f6f-4706-ad22-337007c8fb07

📥 Commits

Reviewing files that changed from the base of the PR and between 87a91ae and 239c4c2.

📒 Files selected for processing (2)
  • src/openhuman/keyring/encrypted_file_backend.rs
  • src/openhuman/keyring_consent/policy.rs

Comment on lines +153 to +160
pub fn notify_master_key_unavailable(reason: &str) {
warn!("{LOG_PREFIX} master key unavailable: {reason}");
if !CONSENT_EVENT_PUBLISHED.swap(true, Ordering::SeqCst) {
info!("{LOG_PREFIX} publishing KeyringConsentRequired event (master key unavailable)");
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::KeyringConsentRequired,
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Skip KeyringConsentRequired when consent is already cached.

This helper bypasses the CONSENT_CACHE check that check_secret_access() uses, so local_encrypted / declined sessions can still publish a consent-required event and flip CONSENT_EVENT_PUBLISHED to true. That breaks the DomainEvent::KeyringConsentRequired contract and can suppress the first real publish from the lazy gate later in the session.

Suggested diff
 pub fn notify_master_key_unavailable(reason: &str) {
     warn!("{LOG_PREFIX} master key unavailable: {reason}");
+    let cached = CONSENT_CACHE.read().clone();
+    if matches!(
+        cached.as_ref().map(|p| p.storage_mode.as_str()),
+        Some("local_encrypted" | "declined")
+    ) {
+        return;
+    }
     if !CONSENT_EVENT_PUBLISHED.swap(true, Ordering::SeqCst) {
         info!("{LOG_PREFIX} publishing KeyringConsentRequired event (master key unavailable)");
         crate::core::event_bus::publish_global(
             crate::core::event_bus::DomainEvent::KeyringConsentRequired,
         );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn notify_master_key_unavailable(reason: &str) {
warn!("{LOG_PREFIX} master key unavailable: {reason}");
if !CONSENT_EVENT_PUBLISHED.swap(true, Ordering::SeqCst) {
info!("{LOG_PREFIX} publishing KeyringConsentRequired event (master key unavailable)");
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::KeyringConsentRequired,
);
}
pub fn notify_master_key_unavailable(reason: &str) {
warn!("{LOG_PREFIX} master key unavailable: {reason}");
let cached = CONSENT_CACHE.read().clone();
if matches!(
cached.as_ref().map(|p| p.storage_mode.as_str()),
Some("local_encrypted" | "declined")
) {
return;
}
if !CONSENT_EVENT_PUBLISHED.swap(true, Ordering::SeqCst) {
info!("{LOG_PREFIX} publishing KeyringConsentRequired event (master key unavailable)");
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::KeyringConsentRequired,
);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/keyring_consent/policy.rs` around lines 153 - 160,
notify_master_key_unavailable currently always sets CONSENT_EVENT_PUBLISHED and
publishes DomainEvent::KeyringConsentRequired even when consent is cached;
change it to first consult the existing consent cache (the same check used by
check_secret_access() / CONSENT_CACHE) and return early if consent is already
present or marked declined, so you do not flip CONSENT_EVENT_PUBLISHED or
publish the event; only when the cache indicates no consent should you proceed
to swap CONSENT_EVENT_PUBLISHED and call
crate::core::event_bus::publish_global(DomainEvent::KeyringConsentRequired)
within notify_master_key_unavailable.

@senamakel senamakel merged commit 25cd13d into tinyhumansai:main Jun 4, 2026
24 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update wiped out my API keys and some conecters disconnect by themselves

2 participants