Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 12 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,18 @@ just reset # Wipe all dev state and recreate it; installed Buzz is preserved
```

Development desktop state uses separate bundle identifiers
(`xyz.block.buzz.app.dev` and per-worktree variants), a separate keyring service
(`buzz-desktop-dev`), and `~/.buzz-dev`. `just reset` removes those dev-only
locations and the local Docker volumes. It does not touch the installed app's
`xyz.block.buzz.app` data, `buzz-desktop` keyring service, or `~/.buzz` nest.
(`xyz.block.buzz.app.dev` and per-worktree variants), a separate secret-store
service (`buzz-desktop-dev`), and `~/.buzz-dev`. `just reset` removes those
dev-only locations and the local Docker volumes. It does not touch the
installed app's `xyz.block.buzz.app` data, `buzz-desktop` keyring service, or
`~/.buzz` nest.

Debug builds keep their secrets (dev nsecs) in a `0o600` file,
`secrets.<service>.json` in the app-data dir, instead of the OS keychain —
unsigned dev binaries get a new code identity every rebuild, which would
otherwise trigger a macOS keychain password prompt on every relaunch. Set
`BUZZ_DEV_USE_KEYCHAIN=1` to opt a debug build back into the keychain.
Release builds always use the OS keychain.

---

Expand Down
47 changes: 14 additions & 33 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,20 +363,14 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
#[path = "app_state_keyring.rs"]
mod keyring_config;
pub(crate) use keyring_config::keyring_service;
use keyring_config::{migration_marker_path, write_migration_marker};

#[path = "app_state_pending_channels.rs"]
mod pending_channels;

/// Keyring key name for the human identity nsec.
const IDENTITY_KEY_NAME: &str = "identity";

/// Filename of the marker written once a successful keyring migration deletes
/// the legacy `identity.key`. Its presence is the only durable signal that a
/// key once lived in the keyring — used to tell a genuine first-ever launch
/// (no key anywhere, generating is correct) from a post-migration boot whose
/// keyring is merely unreachable (the key IS in the keyring, must NOT generate).
const MIGRATION_MARKER_NAME: &str = "identity.migrated";

/// The keyring operations the identity resolution flow needs. Abstracted so the
/// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be
/// unit-tested against a fake without touching the live OS keyring.
Expand Down Expand Up @@ -433,7 +427,13 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result<ResolvedIdentit
}

let store = crate::secret_store::SecretStore::shared(keyring_service());
resolve_identity_with_store(store, &legacy_path, data_dir)
let mut resolved = resolve_identity_with_store(store, &legacy_path, data_dir)?;
// The debug file backend stores in a plain file — reporting
// "system-keyring" to the UI would be a lie.
if store.is_file_backed() && resolved.storage == IdentityStorage::SystemKeyring {
resolved.storage = IdentityStorage::LocalFile;
}
Ok(resolved)
}

/// Identity resolution over an [`IdentityKeyStore`] seam. Split from
Expand Down Expand Up @@ -871,31 +871,12 @@ pub(crate) fn persist_imported_identity(
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<IdentityStorage, String> {
persist_imported_identity_impl(store, keys, legacy_path, data_dir)
}

/// Path of the migration-completed marker within `data_dir`.
fn migration_marker_path(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join(keyring_config::migration_marker_name(
keyring_service(),
MIGRATION_MARKER_NAME,
))
}

/// Atomically write (and fsync) the migration-completed marker. The content is
/// irrelevant — only the file's durable existence is the signal — so a single
/// byte keeps it minimal. Atomicity + fsync guarantee that once this returns
/// `Ok`, the marker survives a crash, which is what makes deleting the legacy
/// file afterward safe.
fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> {
use atomic_write_file::AtomicWriteFile;

let mut file = AtomicWriteFile::open(marker_path)
.map_err(|e| format!("open migration marker for atomic write: {e}"))?;
file.write_all(b"1")
.map_err(|e| format!("write migration marker: {e}"))?;
file.commit()
.map_err(|e| format!("commit migration marker: {e}"))
let storage = persist_imported_identity_impl(store, keys, legacy_path, data_dir)?;
// See load_or_create_identity: the debug file backend reports local-file.
if store.is_file_backed() && storage == IdentityStorage::SystemKeyring {
return Ok(IdentityStorage::LocalFile);
}
Ok(storage)
}

/// Generate a fresh identity, persist it through the store, return it.
Expand Down
32 changes: 32 additions & 0 deletions desktop/src-tauri/src/app_state_keyring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,38 @@ pub(super) fn migration_marker_name(service: &str, default_name: &str) -> String
}
}

/// Filename of the marker written once a successful keyring migration deletes
/// the legacy `identity.key`. Its presence is the only durable signal that a
/// key once lived in the keyring — used to tell a genuine first-ever launch
/// (no key anywhere, generating is correct) from a post-migration boot whose
/// keyring is merely unreachable (the key IS in the keyring, must NOT generate).
const MIGRATION_MARKER_NAME: &str = "identity.migrated";

/// Path of the migration-completed marker within `data_dir`.
pub(super) fn migration_marker_path(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join(migration_marker_name(
keyring_service(),
MIGRATION_MARKER_NAME,
))
}

/// Atomically write (and fsync) the migration-completed marker. The content is
/// irrelevant — only the file's durable existence is the signal — so a single
/// byte keeps it minimal. Atomicity + fsync guarantee that once this returns
/// `Ok`, the marker survives a crash, which is what makes deleting the legacy
/// file afterward safe.
pub(super) fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> {
use atomic_write_file::AtomicWriteFile;
use std::io::Write;

let mut file = AtomicWriteFile::open(marker_path)
.map_err(|e| format!("open migration marker for atomic write: {e}"))?;
file.write_all(b"1")
.map_err(|e| format!("write migration marker: {e}"))?;
file.commit()
.map_err(|e| format!("commit migration marker: {e}"))
}

#[cfg(test)]
mod tests {
use super::{dev_keyring_service, migration_marker_name};
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ pub fn run() {
// init_nest_dir is called early here (normally it runs inside
// run_boot_migrations) so reset::run_boot_reset can call nest_dir().
let reset_outcome = if let Ok(data_dir) = app_handle.path().app_data_dir() {
// Must precede the first SecretStore::shared() call (the boot
// reset below) so debug builds resolve the file backend.
#[cfg(debug_assertions)]
crate::secret_store::init_file_backend_dir(&data_dir);
let is_dev_for_reset = data_dir
.file_name()
.and_then(|n| n.to_str())
Expand Down
6 changes: 6 additions & 0 deletions desktop/src-tauri/src/managed_agents/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,12 @@ pub fn migrate_agent_keys_to_dev_service(app: &tauri::AppHandle) {
if !cfg!(feature = "system-keyring") || keyring_service() != "buzz-desktop-dev" {
return;
}
// The dev file backend deliberately starts empty (no keychain
// migration); reading the prod keychain here would reintroduce the
// password prompt that backend exists to avoid.
if crate::secret_store::SecretStore::shared(keyring_service()).is_file_backed() {
return;
}

// Read the JSON store for pubkeys only — we want every instance
// record without running hydrate_keys (which would try the dev
Expand Down
6 changes: 4 additions & 2 deletions desktop/src-tauri/src/reset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome {
.map(crate::migration::is_dev_data_dir_name)
.unwrap_or(false);

let store = crate::secret_store::SecretStore::keyring(crate::app_state::keyring_service());
// shared() (not keyring()) so the wipe targets the build's active
// backend — the debug file backend when it is in play.
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
let home_dir = dirs::home_dir();
let legacy_dir = crate::migration::legacy_app_data_dir(app_data_dir);
let nest_dir = crate::managed_agents::nest_dir();
Expand All @@ -130,7 +132,7 @@ pub(crate) fn run_boot_reset(app_data_dir: &Path) -> ResetOutcome {
app_data_dir,
legacy_app_data_dir: legacy_dir,
nest_dir,
keychain: &store,
keychain: store,
home_dir,
is_dev,
};
Expand Down
Loading
Loading