Files
buzz/desktop/src-tauri/src/models.rs
T
468647a51f feat(desktop): locally stored NIP-49 encrypted key backup (#2937)
## Summary

Adds a locally stored **NIP-49 encrypted key backup** (`ncryptsec`) to
the desktop app, per the plan reviewed in buzz-development (Rev 3,
approved 9/10 by Wren; implementation also reviewed and approved 9/10).

**Two-artifact design — canonical bytes originate entirely in Rust:**
- `create_ncryptsec_backup` runs under the `identity_mutation` lock:
encrypt → decrypt-verify against the live pubkey → atomic `0o600` write
to `{app_data_dir}/identity.ncryptsec` → reread/byte-compare → return
the exact persisted bytes. The frontend never re-derives or re-encrypts.
- `save_ncryptsec_copy` writes a portable copy via the save dialog
(parse-gated, secret-file semantics) and never mutates canonical state.
- `generate_backup_passphrase`: 6 words from the EFF short wordlist via
`OsRng` (custom passphrases min 12 chars).
- Import accepts `ncryptsec1` with optional password; the raw-`nsec`
path is untouched. Different-pubkey import and sign-out wipe the
app-managed backup (post-commit, best-effort — a failed import can never
destroy the still-live identity's backup; regression-tested).

**Never-relay guarantee (egress guard + tripwires):**
- `egress_guard.rs` fail-closed at all 8 `/events` submission boundaries
(relay submit funnel, 3× `relay.rs`, huddle STT, both engram submitters,
native WS choke point), rejecting `ncryptsec1`/`NCRYPTSEC1` in text and
binary frames. Scope is deliberately ncryptsec-only: pairing
intentionally carries raw nsec inside its encrypted session.
- Site-granular `/events` inventory tripwire: per-file (`/events` count,
guard-call count) pairs; unlisted files expect zero. Mutation-style
tests prove a ninth site in an existing file, a removed guard, and a new
unlisted file all fail the scan.
- ncryptsec source-allowlist scans in **both** trees (Rust + TS).

**Frontend:** onboarding `BackupStep` is encrypted-by-default — the
default path never invokes `get_nsec` (e2e asserts the command log).
Raw-nsec export stays behind an explicit click with prior semantics.
Shared `EncryptedBackupCreator` powers onboarding + a new settings row;
the import form auto-switches to encrypted mode on `ncryptsec1` paste
(case-insensitive HRP).

**Open product call for @tlongwell-block:** onboarding default is
*encrypted* in this PR; flipping to raw-default is a small change either
way (documented in the plan).

Review history: plan Rev 3 and the implementation were both iterated
with Wren to 9/10 (two blockers from round 1 — import ordering,
inventory granularity — plus an uppercase-bech32 hardening gap, all
fixed in `dde37183e`). Thread: buzz-development.

### Related issue

Follow-up to the direction explored in #385 (NIP-PB, closed) — this
ships local NIP-49 (the standard) instead of a new NIP. No open
duplicate found.

### Testing

All at exactly `dde37183e` (same shell, HEAD verified):

- `cargo test` — 1680 passed / 0 failed / 14 ignored (includes a
deliberate ~70s log_n-18 NIP-49 round trip, spec vector, wrong-password,
NFKC, uppercase-vector decrypt, injection test per egress boundary,
inventory mutation tests, import-ordering regression tests)
- `cargo clippy --all-targets -- -D warnings` — clean; `cargo fmt
--check` — clean
- `pnpm typecheck` — clean; JS unit suite 3529/3529; biome (repo-pinned
2.4.16) clean
- Playwright `onboarding-backup` / `onboarding` /
`onboarding-agent-defaults` / `profile-nsec-reveal` — 86 passed, 1 known
avatar-reservation flake (passed on rerun; untouched by this diff).
`passThroughBackupStep` now exercises the encrypted default, so every
downstream onboarding spec covers the new path.
- Note: browser e2e fakes the crypto via the mock bridge (fixed
spec-vector blob); decryption correctness is proven in the Rust tests.

## Latest onboarding integration

The current head adds an additive `IdentityInfo.storage` field
(`ephemeral`, `system-keyring`, `local-file`, or `environment`) so
onboarding can accurately explain where the active identity is
protected. It surfaces storage metadata only—never key material—and
leaves the existing lost/keyring-locked recovery behavior intact.

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-07-30 20:58:03 -04:00

380 lines
12 KiB
Rust

use std::collections::HashMap;
use serde::{Deserialize, Deserializer, Serialize};
#[derive(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.
/// Mutually exclusive with `locked`.
pub lost: bool,
/// True when the app booted with an ephemeral key because the OS keyring
/// holding the identity is unreachable this boot (keyring locked or
/// unavailable). The real key still exists in the keyring; the frontend
/// shows a "unlock the keyring and relaunch" screen. Mutually exclusive
/// with `lost`.
pub locked: bool,
/// True when the boot-time Phase 2 reset attempted a wipe but verification
/// failed. Identity resolution was skipped; the frontend shows a
/// reset-failed recovery screen. The sentinel is preserved so the next
/// relaunch retries the wipe automatically.
pub reset_failed: bool,
}
#[derive(Serialize, Deserialize)]
pub struct ProfileInfo {
pub pubkey: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub about: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
/// `true` when a real kind:0 event was found on the relay; `false` for the
/// synthesized fallback returned when no metadata event exists. The
/// onboarding gate uses this to distinguish "new user with no profile" from
/// "returning user whose display_name happens to be empty".
pub has_profile_event: bool,
}
#[derive(Serialize, Deserialize)]
pub struct UserProfileSummaryInfo {
pub display_name: Option<String>,
/// Kind-0 `name` field, carried separately from `display_name` so clients
/// can match @mention text against either alias (agents and the CLI
/// resolve mentions server-side against `display_name` *or* `name`).
#[serde(default)]
pub name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
pub is_agent: bool,
}
#[derive(Serialize, Deserialize)]
pub struct UsersBatchResponse {
pub profiles: HashMap<String, UserProfileSummaryInfo>,
pub missing: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UserSearchResultInfo {
pub pubkey: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
pub is_agent: bool,
}
#[derive(Serialize, Deserialize)]
pub struct SearchUsersResponse {
pub users: Vec<UserSearchResultInfo>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UserNoteInfo {
pub id: String,
pub pubkey: String,
pub created_at: i64,
pub content: String,
pub tags: Vec<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
pub struct NoteReactionSummary {
pub note_id: String,
pub emoji: String,
pub count: usize,
pub pubkeys: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UserNotesCursor {
pub before: i64,
pub before_id: String,
}
#[derive(Serialize, Deserialize)]
pub struct UserNotesResponse {
pub notes: Vec<UserNoteInfo>,
pub next_cursor: Option<UserNotesCursor>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelInfo {
pub id: String,
pub name: String,
pub channel_type: String,
pub visibility: String,
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
pub description: String,
pub topic: Option<String>,
pub purpose: Option<String>,
pub member_count: i64,
#[serde(default)]
pub member_pubkeys: Vec<String>,
pub last_message_at: Option<String>,
pub archived_at: Option<String>,
#[serde(default)]
pub participants: Vec<String>,
#[serde(default)]
pub participant_pubkeys: Vec<String>,
#[serde(default = "default_true")]
pub is_member: bool,
pub ttl_seconds: Option<i32>,
pub ttl_deadline: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelDetailInfo {
pub id: String,
pub name: String,
pub channel_type: String,
pub visibility: String,
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
pub description: String,
pub topic: Option<String>,
pub topic_set_by: Option<String>,
pub topic_set_at: Option<String>,
pub purpose: Option<String>,
pub purpose_set_by: Option<String>,
pub purpose_set_at: Option<String>,
pub created_by: String,
pub created_at: String,
pub updated_at: String,
pub archived_at: Option<String>,
pub member_count: i64,
pub topic_required: bool,
pub max_members: Option<i32>,
pub nip29_group_id: Option<String>,
pub ttl_seconds: Option<i32>,
pub ttl_deadline: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelMemberInfo {
pub pubkey: String,
pub role: String,
#[serde(default)]
pub is_agent: bool,
/// Optional — kind:39002 events do not carry per-member join timestamps,
/// so this is `None` when populated from a NIP-29 members event.
#[serde(default)]
pub joined_at: Option<String>,
pub display_name: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ChannelMembersResponse {
pub members: Vec<ChannelMemberInfo>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct FeedItemInfo {
pub id: String,
pub kind: u32,
pub pubkey: String,
pub content: String,
pub created_at: u64,
pub channel_id: Option<String>,
pub channel_name: String,
#[serde(default)]
pub channel_type: Option<String>,
pub tags: Vec<Vec<String>>,
pub category: String,
}
#[derive(Serialize, Deserialize)]
pub struct FeedSections {
pub mentions: Vec<FeedItemInfo>,
pub needs_action: Vec<FeedItemInfo>,
pub activity: Vec<FeedItemInfo>,
pub agent_activity: Vec<FeedItemInfo>,
}
#[derive(Serialize, Deserialize)]
pub struct FeedMeta {
pub since: i64,
pub total: u64,
pub generated_at: i64,
}
#[derive(Serialize, Deserialize)]
pub struct FeedResponse {
pub feed: FeedSections,
pub meta: FeedMeta,
}
#[derive(Serialize, Deserialize)]
pub struct SearchHitInfo {
pub event_id: String,
pub content: String,
pub kind: u32,
pub pubkey: String,
pub channel_id: Option<String>,
pub channel_name: Option<String>,
pub created_at: u64,
pub score: f64,
}
#[derive(Serialize, Deserialize)]
pub struct SearchResponse {
pub hits: Vec<SearchHitInfo>,
pub found: u64,
}
#[derive(Serialize, Deserialize)]
pub struct SendChannelMessageResponse {
pub event_id: String,
pub parent_event_id: Option<String>,
pub root_event_id: Option<String>,
pub depth: u32,
pub created_at: i64,
}
#[derive(Serialize, Deserialize)]
pub struct ThreadSummary {
pub reply_count: u32,
pub descendant_count: u32,
pub last_reply_at: Option<i64>,
pub participants: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct ForumMessageInfo {
pub event_id: String,
pub pubkey: String,
pub sig: String,
pub content: String,
pub kind: u32,
pub created_at: i64,
pub channel_id: String,
pub tags: Vec<Vec<String>>,
#[serde(default)]
pub thread_summary: Option<ThreadSummary>,
#[serde(default)]
pub reactions: serde_json::Value,
}
#[derive(Serialize, Deserialize)]
pub struct ForumPostsResponse {
pub messages: Vec<ForumMessageInfo>,
pub next_cursor: Option<i64>,
}
#[derive(Serialize, Deserialize)]
pub struct ForumThreadReplyInfo {
pub event_id: String,
pub pubkey: String,
pub sig: String,
pub content: String,
pub kind: u32,
pub created_at: i64,
pub channel_id: String,
pub tags: Vec<Vec<String>>,
pub parent_event_id: Option<String>,
pub root_event_id: Option<String>,
pub depth: u32,
pub broadcast: bool,
#[serde(default)]
pub reactions: serde_json::Value,
}
#[derive(Serialize, Deserialize)]
pub struct ForumThreadResponse {
pub root: ForumMessageInfo,
pub replies: Vec<ForumThreadReplyInfo>,
pub total_replies: u32,
pub next_cursor: Option<String>,
}
/// Forward keyset pagination cursor for `get_thread_replies`.
///
/// Thread replies routinely share a `created_at` second (bursty threads), so
/// the cursor must carry the last reply's `event_id` as a tiebreak alongside
/// its `created_at`. A timestamp-only cursor advances past the entire tied
/// second after one page and silently drops every tied reply beyond the page
/// limit — the "missed messages" bug this read-path work fixes. The relay
/// keysets on `(event_created_at, event_id)` to match.
#[derive(Serialize, Deserialize, Clone)]
pub struct ThreadCursor {
/// `created_at` of the last reply already loaded (Unix seconds).
pub created_at: i64,
/// Hex event id of that last reply — the tiebreak within a shared second.
pub event_id: String,
}
/// Response for `get_thread_replies` — the full reply subtree under a root
/// event, fetched server-side from `thread_metadata` (NOT assembled from the
/// channel cache). `events` are raw Nostr events in chronological order;
/// `next_cursor` is the composite `(created_at, event_id)` of the last event
/// when a full page was returned, for forward keyset paging, else `None`.
#[derive(Serialize, Deserialize)]
pub struct ThreadRepliesResponse {
pub events: Vec<serde_json::Value>,
pub next_cursor: Option<ThreadCursor>,
}
/// Composite backward keyset cursor for channel-timeline paging via the bridge
/// (`get_channel_messages_before`). The relay orders `created_at DESC, id ASC`
/// and advances past a tied second with `id > before_id`, so the event id is the
/// tiebreak that lets paging escape a second denser than one WS page —
/// the case a bare `until` cursor cannot advance through.
#[derive(Serialize, Deserialize, Clone)]
pub struct ChannelPageCursor {
/// `created_at` of the last (oldest) message already loaded (Unix seconds).
pub created_at: i64,
/// Hex event id of that message — the `before_id` tiebreak within a second.
pub event_id: String,
}
/// Response for `get_channel_messages_before` — one keyset page of top-level
/// channel history, oldest-last (relay order: `created_at DESC, id ASC`).
/// `next_cursor` is the composite key of the last (oldest) event when a full
/// page was returned, else `None`.
#[derive(Serialize, Deserialize)]
pub struct ChannelMessagesPageResponse {
pub events: Vec<serde_json::Value>,
pub next_cursor: Option<ChannelPageCursor>,
}
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
}
fn default_true() -> bool {
true
}
// ── Social / Contact list ───────────────────────────────────────────────────
#[derive(Serialize, Deserialize)]
pub struct ContactListResponse {
pub id: String,
pub pubkey: String,
pub created_at: i64,
pub tags: Vec<Vec<String>>,
pub content: String,
}
#[derive(Serialize, Deserialize)]
pub struct ContactEntry {
pub pubkey: String,
#[serde(default)]
pub relay_url: Option<String>,
#[serde(default)]
pub petname: Option<String>,
}