feat(desktop): explain system keychain protection during onboarding

- Track whether the active identity is stored in the system keyring, local fallback file, environment, or ephemeral memory.
- Expose storage metadata through the Tauri identity response and shared TypeScript identity model.
- Add a responsive first information card beside the two backup actions without revealing boot-time identity creation.
- Show keychain password education only when secure storage succeeded and accurate neutral copy for fallback states.
- Extend identity persistence tests and split storage types into focused modules to preserve file-size limits.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-29 23:52:27 -07:00
parent ca754a5daa
commit 38edd28336
12 changed files with 231 additions and 101 deletions
+47 -50
View File
@@ -2,7 +2,7 @@ use std::{
collections::HashMap,
io::Write,
sync::{
atomic::{AtomicBool, AtomicU16},
atomic::{AtomicBool, AtomicU16, AtomicU8},
Arc, Mutex,
},
};
@@ -13,10 +13,15 @@ use tauri::{AppHandle, Manager};
use tokio::sync::Mutex as AsyncMutex;
use crate::huddle::HuddleState;
pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity};
use crate::managed_agents::config_bridge::SessionConfigCache;
use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey};
pub struct AppState {
pub keys: Mutex<Keys>,
/// Durable backend holding `keys`. Updated after the key write and before
/// recovery flags are cleared so `get_identity` reports a consistent state.
pub(crate) identity_storage: AtomicU8,
pub http_client: reqwest::Client,
/// A no-redirect client for authenticated relay media fetches (download,
/// clipboard copy, snapshot, editor). Every caller pre-validates the URL
@@ -178,19 +183,20 @@ pub fn build_media_fetch_client() -> reqwest::Result<reqwest::Client> {
pub fn build_app_state() -> AppState {
// Env var takes precedence (dev/CI). If absent, resolve_persisted_identity()
// in setup() will replace the ephemeral placeholder with a persisted key.
let keys = match identity_from_env() {
let (keys, identity_storage) = match identity_from_env() {
Some(keys) => {
eprintln!(
"buzz-desktop: configured identity pubkey {}",
keys.public_key().to_hex()
);
keys
(keys, IdentityStorage::Environment)
}
None => Keys::generate(),
None => (Keys::generate(), IdentityStorage::Ephemeral),
};
AppState {
keys: Mutex::new(keys),
identity_storage: AtomicU8::new(identity_storage as u8),
http_client: reqwest::Client::builder()
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
.pool_idle_timeout(std::time::Duration::from_secs(10))
@@ -366,9 +372,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<(
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let resolved = load_or_create_identity(&data_dir)?;
// Write keys before setting the recovery flags (Release) so any thread
// that reads a flag as false with Acquire is guaranteed to see the keys.
*state.keys.lock().map_err(|e| e.to_string())? = resolved.keys;
// Write keys and storage before setting the recovery flags (Release) so
// any thread that reads a flag as false with Acquire sees consistent data.
{
let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
*active_keys = resolved.keys;
state.set_identity_storage(resolved.storage);
}
state.identity_lost.store(
resolved.recovery == RecoveryState::Lost,
std::sync::atomic::Ordering::Release,
@@ -394,26 +404,6 @@ const IDENTITY_KEY_NAME: &str = "identity";
/// keyring is merely unreachable (the key IS in the keyring, must NOT generate).
const MIGRATION_MARKER_NAME: &str = "identity.migrated";
/// Recovery state produced by identity resolution. `None` means the app has
/// a real, usable identity. `Lost` means the keyring was reachable-but-empty
/// despite a prior successful migration — the key vanished externally. `KeyringLocked`
/// means the keyring is unreachable this boot but was used in the past
/// (marker present, no file) — the key still exists but is temporarily
/// inaccessible. Both non-`None` variants boot with an ephemeral key; the
/// frontend shows a different recovery screen for each.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RecoveryState {
None,
Lost,
KeyringLocked,
}
/// The output of identity resolution.
struct ResolvedIdentity {
keys: Keys,
recovery: RecoveryState,
}
/// 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.
@@ -465,6 +455,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result<ResolvedIdentit
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage: IdentityStorage::LocalFile,
});
}
@@ -514,7 +505,7 @@ fn resolve_identity_with_store(
// fails. A transient keyring failure must not abort
// boot — the file key is safe and adoption retries next
// boot when the keyring is reachable again.
if let Err(e) = persist_identity_to_keyring(
let storage = if let Err(e) = persist_identity_to_keyring(
store,
&file_keys,
legacy_path,
@@ -524,10 +515,14 @@ fn resolve_identity_with_store(
"buzz-desktop: keyring adoption of identity.key \
failed ({e}); using file key, will retry next boot"
);
}
IdentityStorage::LocalFile
} else {
IdentityStorage::SystemKeyring
};
return Ok(ResolvedIdentity {
keys: file_keys,
recovery: RecoveryState::None,
storage,
});
}
// Corrupt file — keyring is authoritative. Log before
@@ -567,6 +562,7 @@ fn resolve_identity_with_store(
return Ok(ResolvedIdentity {
keys: keyring_keys,
recovery: RecoveryState::None,
storage: IdentityStorage::SystemKeyring,
});
}
// The corruption is in the KEYRING, not the file. Clear the
@@ -595,6 +591,7 @@ fn resolve_identity_with_store(
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage: IdentityStorage::SystemKeyring,
});
}
} else if migration_marker_path(data_dir).exists() {
@@ -615,6 +612,7 @@ fn resolve_identity_with_store(
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::Lost,
storage: IdentityStorage::Ephemeral,
});
}
}
@@ -643,20 +641,23 @@ fn resolve_identity_with_store(
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::KeyringLocked,
storage: IdentityStorage::Ephemeral,
});
}
let keys = load_file_or_generate(legacy_path, data_dir)?;
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage: IdentityStorage::LocalFile,
});
}
}
let keys = generate_and_persist(store, legacy_path, data_dir)?;
let (keys, storage) = generate_and_persist(store, legacy_path, data_dir)?;
Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage,
})
}
@@ -682,6 +683,7 @@ fn recover_from_keyring(
return Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage: IdentityStorage::SystemKeyring,
});
}
}
@@ -699,13 +701,15 @@ fn recover_from_keyring(
return Ok(ResolvedIdentity {
keys: ephemeral,
recovery: RecoveryState::Lost,
storage: IdentityStorage::Ephemeral,
});
}
// No marker: genuine first launch with a corrupt keyring. Generate fresh.
let keys = generate_and_persist(store, legacy_path, data_dir)?;
let (keys, storage) = generate_and_persist(store, legacy_path, data_dir)?;
Ok(ResolvedIdentity {
keys,
recovery: RecoveryState::None,
storage,
})
}
@@ -872,15 +876,16 @@ fn persist_imported_identity_impl(
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), String> {
) -> Result<IdentityStorage, String> {
match persist_identity_to_keyring(store, keys, legacy_path, data_dir) {
Ok(()) => Ok(()),
Ok(()) => Ok(IdentityStorage::SystemKeyring),
Err(e) => {
eprintln!(
"buzz-desktop: keyring write failed during import ({e}), \
falling back to identity.key"
);
save_key_file(legacy_path, keys)
save_key_file(legacy_path, keys)?;
Ok(IdentityStorage::LocalFile)
}
}
}
@@ -892,7 +897,7 @@ pub(crate) fn persist_imported_identity(
keys: &Keys,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), String> {
) -> Result<IdentityStorage, String> {
persist_imported_identity_impl(store, keys, legacy_path, data_dir)
}
@@ -920,15 +925,6 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> {
.map_err(|e| format!("commit migration marker: {e}"))
}
/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes
/// the migration marker only after a keyring success — on the file-fallback arm
/// the key is on disk and a marker would wrongly trip the next Unreachable boot
/// into failing closed.
enum PersistBackend {
Keyring,
File,
}
/// Generate a fresh identity, persist it through the store, return it.
///
/// On a keyring-backed persist no file is written, so a later
@@ -940,9 +936,10 @@ fn generate_and_persist(
store: &impl IdentityKeyStore,
legacy_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<Keys, String> {
) -> Result<(Keys, IdentityStorage), String> {
let keys = Keys::generate();
if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? {
let storage = store_key_preferring_keyring(store, &keys, legacy_path)?;
if storage == IdentityStorage::SystemKeyring {
let marker_path = migration_marker_path(data_dir);
if let Err(e) = write_migration_marker(&marker_path) {
eprintln!(
@@ -956,7 +953,7 @@ fn generate_and_persist(
"buzz-desktop: generated and saved identity pubkey {}",
keys.public_key().to_hex()
);
Ok(keys)
Ok((keys, storage))
}
/// Persist `keys` through the store, silently falling back to the `0o600` file
@@ -968,17 +965,17 @@ fn store_key_preferring_keyring(
store: &impl IdentityKeyStore,
keys: &Keys,
legacy_path: &std::path::Path,
) -> Result<PersistBackend, String> {
) -> Result<IdentityStorage, String> {
let nsec = keys
.secret_key()
.to_bech32()
.map_err(|e| format!("encode nsec: {e}"))?;
match store.store(IDENTITY_KEY_NAME, &nsec) {
Ok(()) => Ok(PersistBackend::Keyring),
Ok(()) => Ok(IdentityStorage::SystemKeyring),
Err(keyring_err) => {
eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback");
save_key_file(legacy_path, keys)?;
Ok(PersistBackend::File)
Ok(IdentityStorage::LocalFile)
}
}
}
+6 -6
View File
@@ -484,7 +484,7 @@ fn fresh_keyring_generate_writes_marker() {
let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap();
// The key was stored in the keyring (not the file), and the marker marks it.
assert!(!legacy_path.exists());
assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring);
assert!(migration_marker_path(dir.path()).exists());
assert_eq!(
store
@@ -541,7 +541,10 @@ fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() {
let from_file = load_key_file(&legacy_path).unwrap();
assert_key_eq(&resolved.keys, &from_file);
// No marker: the file is the authoritative store, not the keyring.
assert!(!migration_marker_path(dir.path()).exists());
assert!(
!migration_marker_path(dir.path()).exists()
&& resolved.storage == IdentityStorage::LocalFile
);
}
// ── New tests for the three defects fixed in this PR ─────────────────────
@@ -786,10 +789,7 @@ fn persist_imported_identity_falls_back_to_file_on_keyring_failure() {
let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path());
// The policy core handles the keyring failure — Ok, not Err.
assert!(
result.is_ok(),
"must not propagate keyring failure when file fallback succeeds"
);
assert_eq!(result.unwrap(), IdentityStorage::LocalFile);
// Key is recoverable from the file on next boot.
let from_file = load_key_file(&legacy_path).unwrap();
+18 -10
View File
@@ -43,6 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result<IdentityInfo, String>
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
storage: state.identity_storage().as_str().to_string(),
lost,
locked,
reset_failed,
@@ -380,7 +381,7 @@ pub async fn import_identity(
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
let pubkey = commit_imported_identity(&state, &data_dir, keys, |keys| {
let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| {
// Persist into the OS keyring first (store → read-back verify →
// marker → delete file). Falls back to the 0o600 file when the
// keyring is unavailable; returns Err only when both backends fail.
@@ -397,6 +398,7 @@ pub async fn import_identity(
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
storage: storage.as_str().to_string(),
lost: false,
locked: false,
reset_failed: false,
@@ -426,18 +428,22 @@ fn commit_imported_identity(
state: &AppState,
data_dir: &std::path::Path,
keys: nostr::Keys,
persist: impl FnOnce(&nostr::Keys) -> Result<(), String>,
) -> Result<nostr::PublicKey, String> {
persist: impl FnOnce(&nostr::Keys) -> Result<crate::app_state::IdentityStorage, String>,
) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> {
// Capture the previous pubkey up front for post-commit cleanup.
let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key();
persist(&keys)?;
let storage = persist(&keys)?;
// Update in-memory keys BEFORE clearing recovery flags. The Release
// stores below pair with Acquire loads in get_identity: a reader
// observing false is guaranteed to see the updated keys.
let pubkey = keys.public_key();
*state.keys.lock().map_err(|e| e.to_string())? = keys;
{
let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?;
*active_keys = keys;
state.set_identity_storage(storage);
}
// Clear both recovery flags — an import is valid in either lost or
// keyring-locked state and resolves both. In the locked case the
@@ -462,7 +468,7 @@ fn commit_imported_identity(
);
}
Ok(pubkey)
Ok((pubkey, storage))
}
/// Make the current ephemeral identity durable by persisting it to the OS
@@ -508,11 +514,12 @@ pub async fn persist_current_identity(
let key_path = data_dir.join("identity.key");
let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service());
crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
let storage =
crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?;
// Keys are already the live identity — only clear identity_lost.
// Release pairs with Acquire in get_identity so readers see
// consistent state.
// Keys are already the live identity. Record where the durable write
// landed before clearing identity_lost.
state.set_identity_storage(storage);
state
.identity_lost
.store(false, std::sync::atomic::Ordering::Release);
@@ -524,6 +531,7 @@ pub async fn persist_current_identity(
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
storage: storage.as_str().to_string(),
lost: false,
locked: false,
reset_failed: false,
@@ -1,5 +1,5 @@
use super::{create_and_persist_backup_with_log_n, verify_ncryptsec_backup_inner};
use crate::app_state::build_app_state;
use crate::app_state::{build_app_state, IdentityStorage};
use nostr::Keys;
/// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered
@@ -174,19 +174,21 @@ fn successful_import_removes_stale_backup_after_commit() {
let new_keys = Keys::generate();
let backup_present_at_persist = std::cell::Cell::new(false);
let _guard = state.identity_mutation.lock().unwrap();
let pubkey = super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| {
// Ordering probe: the old backup must still exist while
// persistence is running (cleanup has not happened yet).
backup_present_at_persist.set(backup_path.exists());
Ok(())
})
.unwrap();
let (pubkey, storage) =
super::commit_imported_identity(&state, dir.path(), new_keys.clone(), |_| {
// Ordering probe: the old backup must still exist while
// persistence is running (cleanup has not happened yet).
backup_present_at_persist.set(backup_path.exists());
Ok(IdentityStorage::SystemKeyring)
})
.unwrap();
assert!(
backup_present_at_persist.get(),
"cleanup must not precede persist"
);
assert_eq!(pubkey, new_keys.public_key());
assert_eq!(storage, IdentityStorage::SystemKeyring);
assert_eq!(
state.keys.lock().unwrap().public_key(),
new_keys.public_key()
+62
View File
@@ -0,0 +1,62 @@
use nostr::Keys;
use crate::app_state::AppState;
/// Durable location of the active human identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub(crate) enum IdentityStorage {
Ephemeral = 0,
SystemKeyring = 1,
LocalFile = 2,
Environment = 3,
}
impl IdentityStorage {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Ephemeral => "ephemeral",
Self::SystemKeyring => "system-keyring",
Self::LocalFile => "local-file",
Self::Environment => "environment",
}
}
fn from_u8(value: u8) -> Self {
match value {
1 => Self::SystemKeyring,
2 => Self::LocalFile,
3 => Self::Environment,
_ => Self::Ephemeral,
}
}
}
impl AppState {
pub(crate) fn identity_storage(&self) -> IdentityStorage {
IdentityStorage::from_u8(
self.identity_storage
.load(std::sync::atomic::Ordering::Acquire),
)
}
pub(crate) fn set_identity_storage(&self, storage: IdentityStorage) {
self.identity_storage
.store(storage as u8, std::sync::atomic::Ordering::Release);
}
}
/// Recovery state produced by identity resolution.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RecoveryState {
None,
Lost,
KeyringLocked,
}
/// Identity and persistence metadata produced by startup resolution.
pub(crate) struct ResolvedIdentity {
pub(crate) keys: Keys,
pub(crate) recovery: RecoveryState,
pub(crate) storage: IdentityStorage,
}
+1
View File
@@ -8,6 +8,7 @@ mod egress_guard;
mod event_sync;
mod events;
mod huddle;
mod identity_storage;
mod key_backup;
mod managed_agents;
mod media_proxy;
+2
View File
@@ -6,6 +6,8 @@ use serde::{Deserialize, Deserializer, Serialize};
pub struct IdentityInfo {
pub pubkey: String,
pub display_name: String,
/// Durable location of the active identity key.
pub storage: String,
/// True when the app booted with an ephemeral key because the OS keyring
/// was empty despite a prior successful migration (key was externally
/// deleted). The frontend routes to the nsec re-import step when true.
@@ -10,6 +10,7 @@ import {
import * as React from "react";
import { getNsec } from "@/shared/api/tauriIdentity";
import type { IdentityStorage } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { Button } from "@/shared/ui/button";
@@ -48,6 +49,7 @@ export function backupNextDisabled(): boolean {
type BackupStepProps = {
direction: OnboardingTransitionDirection;
identityStorage?: IdentityStorage;
onBack: () => void;
onNext: () => void;
onOpenPasswordBackup: () => void;
@@ -64,6 +66,7 @@ type BackupStepProps = {
*/
export function BackupStep({
direction,
identityStorage,
onBack,
onNext,
onOpenPasswordBackup,
@@ -152,6 +155,24 @@ export function BackupStep({
() => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"),
[nsec],
);
const storageMessage =
identityStorage === "system-keyring"
? {
title: "Protected by your system keychain",
description:
"Buzz uses your system keychain to protect your identity key. Your system may ask for your computer password when Buzz accesses it.",
}
: identityStorage === "local-file"
? {
title: "Stored on this device",
description:
"Your system keychain wasn’t available, so Buzz stores your identity key in a private file on this device.",
}
: {
title: "Protected on this device",
description:
"Keep a separate backup so you can restore your identity if you lose access to this device.",
};
if (optionsExpanded) {
return (
@@ -171,18 +192,37 @@ export function BackupStep({
</p>
</div>
<div className="flex w-full max-w-240 flex-1 flex-col justify-center py-10">
<div className="flex w-full max-w-260 flex-1 flex-col justify-center py-10">
<div
className="grid w-full grid-cols-1 gap-8 md:grid-cols-2"
className="grid w-full grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3"
data-testid="backup-options"
>
<div
className="relative min-h-56 w-full md:col-span-2 lg:col-span-1"
data-testid="backup-storage-info"
>
<Card
aria-hidden="true"
className="pointer-events-none absolute inset-0 brightness-[0.02]"
variant="textured"
/>
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-10 text-left text-foreground">
<span className="text-lg font-medium">
{storageMessage.title}
</span>
<span className="mt-3 block text-sm leading-6 text-foreground/65">
{storageMessage.description}
</span>
</div>
</div>
<div className="relative min-h-56 w-full">
<Card
aria-hidden="true"
className="pointer-events-none absolute inset-0 brightness-[0.005]"
variant="textured"
/>
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-20 text-left text-foreground">
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-10 text-left text-foreground">
<span className="text-lg font-medium">
Save your key safely
</span>
@@ -220,7 +260,7 @@ export function BackupStep({
className="pointer-events-none absolute inset-0 brightness-[0.005]"
variant="textured"
/>
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-20 text-left text-foreground">
<div className="relative z-10 flex min-h-56 w-full flex-col justify-center p-10 text-left text-foreground">
<span className="text-lg font-medium">
Create a portable backup
</span>
@@ -282,9 +322,8 @@ export function BackupStep({
REVEAL_ANIMATION_CLASS,
)}
>
Your identity key will be saved to your keychain. Back it up
somewhere safe so you can restore your account. Never share your
key.
Your identity key is protected on this device. Back it up somewhere
safe so you can restore your account. Never share your key.
</p>
) : null}
</div>
@@ -7,6 +7,7 @@ import {
importIdentity,
persistCurrentIdentity,
} from "@/shared/api/tauriIdentity";
import type { IdentityStorage } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { BackupStep } from "./BackupStep";
@@ -72,6 +73,9 @@ export function MachineOnboardingFlow({
const [selectedPubkey, setSelectedPubkey] = React.useState<string | null>(
null,
);
const [identityStorage, setIdentityStorage] = React.useState<
IdentityStorage | undefined
>();
const [readyRuntimeIds, setReadyRuntimeIds] = React.useState<string[]>([]);
const [backupSubview, setBackupSubview] =
React.useState<BackupSubview>("created");
@@ -98,6 +102,7 @@ export function MachineOnboardingFlow({
const identity = await getIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
setIdentityStorage(identity.storage);
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("created");
@@ -123,6 +128,7 @@ export function MachineOnboardingFlow({
const identity = await persistCurrentIdentity();
queryClient.setQueryData(["identity"], identity);
setSelectedPubkey(identity.pubkey);
setIdentityStorage(identity.storage);
setBackupDirection("forward");
setReturningFromSecurity(false);
setBackupSubview("created");
@@ -281,6 +287,7 @@ export function MachineOnboardingFlow({
) : (
<BackupStep
direction={backupDirection}
identityStorage={identityStorage}
onBack={() => setPage("identity")}
onNext={() => setPage("setup")}
onOpenPasswordBackup={() => {
+28
View File
@@ -0,0 +1,28 @@
export type IdentityStorage =
| "system-keyring"
| "local-file"
| "environment"
| "ephemeral";
export type Identity = {
pubkey: string;
displayName: string;
/** Durable location of the active identity key. Older/mock bridges may omit
* this until they adopt identity storage reporting. */
storage?: IdentityStorage;
/** True when the app booted in "identity lost" recovery mode — the OS
* keyring was empty despite a prior successful migration. The frontend
* should route to nsec re-import instead of normal onboarding.
* Mutually exclusive with `locked`. */
lost?: boolean;
/** True when the app booted with an ephemeral key because the OS keyring
* holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
* locked). The real key still exists; no in-app recovery is possible —
* the user must unlock the keyring externally and relaunch.
* Mutually exclusive with `lost`. */
locked?: boolean;
/** True when the boot-time Phase 2 reset attempted a wipe but verification
* failed. Identity resolution was skipped; the sentinel is preserved so
* the next relaunch retries the wipe automatically. */
resetFailed?: boolean;
};
+3 -1
View File
@@ -1,9 +1,10 @@
import { invokeTauri } from "@/shared/api/tauri";
import type { Identity } from "@/shared/api/types";
import type { Identity, IdentityStorage } from "@/shared/api/types";
type RawIdentity = {
pubkey: string;
display_name: string;
storage?: IdentityStorage;
lost?: boolean;
locked?: boolean;
reset_failed?: boolean;
@@ -13,6 +14,7 @@ function fromRawIdentity(raw: RawIdentity): Identity {
return {
pubkey: raw.pubkey,
displayName: raw.display_name,
storage: raw.storage,
lost: raw.lost === true,
locked: raw.locked === true,
resetFailed: raw.reset_failed === true,
+1 -19
View File
@@ -103,25 +103,7 @@ export type AddChannelMembersResult = {
}>;
};
export type Identity = {
pubkey: string;
displayName: string;
/** True when the app booted in "identity lost" recovery mode — the OS
* keyring was empty despite a prior successful migration. The frontend
* should route to nsec re-import instead of normal onboarding.
* Mutually exclusive with `locked`. */
lost?: boolean;
/** True when the app booted with an ephemeral key because the OS keyring
* holding the real identity is UNREACHABLE (e.g. GNOME Keyring / KWallet
* locked). The real key still exists; no in-app recovery is possible —
* the user must unlock the keyring externally and relaunch.
* Mutually exclusive with `lost`. */
locked?: boolean;
/** True when the boot-time Phase 2 reset attempted a wipe but verification
* failed. Identity resolution was skipped; the sentinel is preserved so
* the next relaunch retries the wipe automatically. */
resetFailed?: boolean;
};
export type { Identity, IdentityStorage } from "./identityTypes";
export type Profile = {
pubkey: string;