Files
buzz/desktop/src/shared/api/tauriIdentity.ts
Tyler 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

106 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { invokeTauri } from "@/shared/api/tauri";
import type { Identity, IdentityStorage } from "@/shared/api/types";
type RawIdentity = {
pubkey: string;
display_name: string;
storage?: IdentityStorage;
lost?: boolean;
locked?: boolean;
reset_failed?: boolean;
};
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,
};
}
export async function getIdentity(): Promise<Identity> {
return fromRawIdentity(await invokeTauri<RawIdentity>("get_identity"));
}
export async function getNsec(): Promise<string> {
return invokeTauri<string>("get_nsec");
}
export async function importIdentity(
nsec: string,
password?: string,
): Promise<Identity> {
return fromRawIdentity(
await invokeTauri<RawIdentity>("import_identity", { nsec, password }),
);
}
export async function persistCurrentIdentity(): Promise<Identity> {
return fromRawIdentity(
await invokeTauri<RawIdentity>("persist_current_identity"),
);
}
/**
* Wipe all local Buzz state (keychain, App Support, WebKit, nest, OAuth cache,
* CLI symlinks) and relaunch into first-run onboarding.
*
* The app restarts after this call completes. Callers should keep the pending
* state until the process exits and only handle errors (e.g. display a toast).
*/
export async function signOut(): Promise<void> {
await invokeTauri("sign_out");
}
export type GeneratePassphraseOptions = {
/** Word count; Rust clamps to its allowed range (currently 3–10). */
words?: number;
/** Separator joined between words. Defaults to a space in Rust. */
separator?: string;
};
/** Generate a word passphrase (EFF short wordlist, OS entropy) in Rust. */
export async function generateBackupPassphrase(
options?: GeneratePassphraseOptions,
): Promise<string> {
return invokeTauri<string>("generate_backup_passphrase", {
words: options?.words,
separator: options?.separator,
});
}
/** Encrypt the current identity as an in-memory NIP-49 backup for native save. */
export async function createNcryptsecBackup(password: string): Promise<string> {
return invokeTauri<string>("create_ncryptsec_backup", { password });
}
/** Save a portable backup copy. Returns null when the native dialog is cancelled. */
export async function saveNcryptsecCopy(
ncryptsec: string,
): Promise<string | null> {
return (
(await invokeTauri<string | null>("save_ncryptsec_copy", { ncryptsec })) ??
null
);
}
export type BackupVerification = {
pubkey: string;
npub: string;
matchesCurrentIdentity: boolean;
};
/** Decrypt locally and return only the backup's public identity and match state. */
export async function verifyNcryptsecBackup(
ncryptsec: string,
password: string,
): Promise<BackupVerification> {
return invokeTauri<BackupVerification>("verify_ncryptsec_backup", {
ncryptsec,
password,
});
}