Files
buzz/desktop/src-tauri/src/key_backup.rs
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

235 lines
9.5 KiB
Rust

//! NIP-49 encrypted local key backup.
//!
//! Creates a password-encrypted `ncryptsec` backup of the user's identity key
//! for a user-selected local file. The blob is **local-only by contract**: it must
//! never be transmitted to a relay on any path. That contract is enforced at
//! runtime by [`crate::egress_guard`] (wired into every relay event-body
//! constructor and the native websocket send loop) and structurally by the
//! source-allowlist scan in this module's tests.
//!
//! Creation decrypt-verifies the fresh blob against the live identity before
//! returning it. Portable copies use atomic, owner-only file writes.
use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity};
use nostr::{FromBech32, Keys, ToBech32};
/// Bech32 prefix of NIP-49 encrypted secret keys. Import routing is
/// case-insensitive because bech32 permits all-uppercase encodings.
pub const NCRYPTSEC_HRP: &str = "ncryptsec1";
/// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB).
/// The blob self-describes its cost, so this can be raised later without
/// breaking existing backups.
pub const BACKUP_LOG_N: u8 = 18;
/// Highest scrypt cost accepted when decrypting an untrusted backup.
///
/// NIP-49 intentionally leaves `log_n` client-selected. Capping it at the tier
/// Buzz itself emits keeps generated and upstream-compatible lower-cost backups
/// readable without allowing a crafted payload to request unbounded memory
/// before password authentication.
pub const MAX_VERIFY_LOG_N: u8 = BACKUP_LOG_N;
/// Filename of the app-managed canonical backup inside the app data dir.
pub const BACKUP_FILE_NAME: &str = "identity.ncryptsec";
/// Default number of words in a generated backup passphrase. Three words
/// from a 1296-word list ≈ 31 bits of entropy before the scrypt work factor.
pub const DEFAULT_PASSPHRASE_WORDS: usize = 3;
/// Bounds for the generator's word-count control. At the lower bound a draw
/// can fall below [`MIN_PASSPHRASE_LEN`] (three 3-char words), so
/// [`generate_passphrase`] re-draws until the phrase meets the minimum.
pub const MIN_PASSPHRASE_WORDS: usize = 3;
pub const MAX_PASSPHRASE_WORDS: usize = 10;
/// EFF short wordlist 2.0 (1296 words, one per line).
const WORDLIST: &str = include_str!("assets/eff_short_wordlist_2_0.txt");
/// Minimum length for a user-chosen passphrase.
pub const MIN_PASSPHRASE_LEN: usize = 12;
/// Encrypt the identity secret key under `password` and verify the result.
///
/// Returns the bech32 `ncryptsec1…` string. The fresh blob is decrypted and
/// its derived pubkey compared to the live identity **before** returning, so
/// a returned blob is always provably recoverable with the same password.
pub fn create_backup_blob(keys: &Keys, password: &str, log_n: u8) -> Result<String, String> {
let secret_key = keys.secret_key();
let encrypted = EncryptedSecretKey::new(secret_key, password, log_n, KeySecurity::Unknown)
.map_err(|e| format!("encrypt key backup: {e}"))?;
let ncryptsec = encrypted
.to_bech32()
.map_err(|e| format!("encode ncryptsec: {e}"))?;
// Integrity check: decrypt the fresh blob and confirm it recovers the
// exact live identity. A corrupted or mis-encrypted blob must never be
// shown to the user as a "backup". This is the second, deliberate KDF
// invocation of the one-artifact-per-action contract.
verify_backup_blob(&ncryptsec, password, &keys.public_key())?;
Ok(ncryptsec)
}
/// Decrypt `ncryptsec` with `password` and assert it recovers a key whose
/// public key equals `expected_pubkey`.
pub fn verify_backup_blob(
ncryptsec: &str,
password: &str,
expected_pubkey: &nostr::PublicKey,
) -> Result<(), String> {
let encrypted = parse_ncryptsec(ncryptsec)?;
let recovered = encrypted
.decrypt(password)
.map_err(|e| format!("verify key backup (decrypt): {e}"))?;
let recovered_keys = Keys::new(recovered);
if recovered_keys.public_key() != *expected_pubkey {
return Err("verify key backup: decrypted key does not match identity".to_string());
}
Ok(())
}
/// Parse a bech32 `ncryptsec1…` string, rejecting anything that is not a
/// structurally valid NIP-49 payload.
pub fn parse_ncryptsec(input: &str) -> Result<EncryptedSecretKey, String> {
EncryptedSecretKey::from_bech32(input.trim()).map_err(|e| format!("invalid ncryptsec: {e}"))
}
/// Decrypt an `ncryptsec1…` string with `password` into identity keys.
pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result<Keys, String> {
let encrypted = parse_ncryptsec(input)?;
let log_n = encrypted.log_n();
if log_n > MAX_VERIFY_LOG_N {
return Err(format!(
"unsupported backup KDF cost: log_n {log_n} exceeds maximum {MAX_VERIFY_LOG_N}"
));
}
let secret_key = encrypted
.decrypt(password)
.map_err(|_| "wrong backup password or damaged key backup".to_string())?;
Ok(Keys::new(secret_key))
}
/// Recover identity keys from either an encrypted NIP-49 backup or the raw
/// nsec/hex formats accepted before encrypted imports were added.
pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result<Keys, String> {
let trimmed = input.trim();
let is_ncryptsec = trimmed
.get(..NCRYPTSEC_HRP.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(NCRYPTSEC_HRP));
if is_ncryptsec {
let password = password.ok_or_else(|| "key backup requires a password".to_string())?;
decrypt_ncryptsec(trimmed, password)
} else {
Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))
}
}
/// Path of the canonical app-managed backup file.
pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf {
data_dir.join(BACKUP_FILE_NAME)
}
/// Atomically write `ncryptsec` to `path` with owner-only permissions, then
/// reread and byte-compare. Same crash-safety pattern as
/// `app_state::save_key_file`.
pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> {
use atomic_write_file::AtomicWriteFile;
use std::io::Write;
let mut file = AtomicWriteFile::open(path)
.map_err(|e| format!("open backup file for atomic write: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(std::fs::Permissions::from_mode(0o600))
.map_err(|e| format!("set backup file permissions: {e}"))?;
}
file.write_all(ncryptsec.as_bytes())
.map_err(|e| format!("write backup file: {e}"))?;
file.commit()
.map_err(|e| format!("commit backup file: {e}"))?;
// Reread and byte-compare: only report success for bytes that are
// actually on disk.
let on_disk = std::fs::read_to_string(path).map_err(|e| format!("reread backup file: {e}"))?;
if on_disk != ncryptsec {
return Err("backup file verification failed: on-disk bytes differ".to_string());
}
Ok(())
}
/// Delete the app-managed backup if present. Missing files are already clean.
pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> {
let path = backup_file_path(data_dir);
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("delete stale backup file: {e}")),
}
}
/// Remove the app-managed backup only when an import changes identities.
pub fn cleanup_stale_backup(
previous: &nostr::PublicKey,
new: &nostr::PublicKey,
data_dir: &std::path::Path,
) -> Result<(), String> {
if previous != new {
delete_backup_file(data_dir)?;
}
Ok(())
}
/// Generate a passphrase of `word_count` EFF short-wordlist words joined by
/// `separator`, using OS entropy.
///
/// `word_count` is clamped to `MIN_PASSPHRASE_WORDS..=MAX_PASSPHRASE_WORDS`.
/// Because a low-word-count draw can land under [`MIN_PASSPHRASE_LEN`]
/// (e.g. three 3-char words), whole phrases below the minimum are rejected
/// and re-drawn — the result always passes the same length gate applied to
/// user-chosen passphrases. Uses rejection sampling for a uniform
/// distribution over the 1296 words.
pub fn generate_passphrase(word_count: usize, separator: &str) -> Result<String, String> {
let word_count = word_count.clamp(MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS);
let words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect();
if words.len() != 1296 {
return Err(format!(
"wordlist corrupted: expected 1296 words, found {}",
words.len()
));
}
// At 3 words the under-length probability per draw is small, so a few
// attempts always suffice; the cap only guards against a logic bug
// becoming an infinite loop.
for _ in 0..128 {
let mut chosen: Vec<&str> = Vec::with_capacity(word_count);
while chosen.len() < word_count {
let mut buf = [0u8; 2];
getrandom::getrandom(&mut buf).map_err(|e| format!("entropy source: {e}"))?;
let value = u16::from_le_bytes(buf);
// Rejection sampling: accept only values below the largest
// multiple of 1296 that fits in u16 (65536 - 65536 % 1296 = 64800).
if value < 64800 {
chosen.push(words[(value as usize) % 1296]);
}
}
let phrase = chosen.join(separator);
if phrase.chars().count() >= MIN_PASSPHRASE_LEN {
return Ok(phrase);
}
}
Err("could not generate a passphrase meeting the minimum length".to_string())
}
#[cfg(test)]
#[path = "key_backup_tests.rs"]
mod tests;