Files
buzz/desktop/src/features/onboarding/lib/keyImportInput.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

128 lines
4.2 KiB
TypeScript

/**
* Pure classification + submit gating for the key-import form, unit-testable
* without a DOM.
*
* `ncryptsec1…` is a NIP-49 encrypted backup: no npub preview is possible
* (the pubkey is inside the encrypted payload) and a passphrase is required.
* Password validation happens in Rust at decrypt time; this module performs
* the password-independent Bech32 and NIP-49 structure checks needed to decide
* when the form can safely switch modes.
*/
import { nsecToNpub } from "@/shared/lib/nostrUtils";
export type KeyImportKind = "nsec" | "ncryptsec" | "unknown";
const NCRYPTSEC_HRP = "ncryptsec";
const NIP49_VERSION = 2;
const NIP49_PAYLOAD_BYTES = 91;
/** Current NIP-49 payloads encode to 162 characters including the checksum. */
export const NCRYPTSEC_ENCODED_LENGTH = 162;
const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const BECH32_GENERATORS = [
0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3,
] as const;
function bech32Polymod(values: readonly number[]): number {
let checksum = 1;
for (const value of values) {
const high = checksum >>> 25;
checksum = ((checksum & 0x1ffffff) << 5) ^ value;
for (let index = 0; index < BECH32_GENERATORS.length; index += 1) {
if ((high >>> index) & 1) checksum ^= BECH32_GENERATORS[index];
}
}
return checksum >>> 0;
}
function expandBech32Hrp(hrp: string): number[] {
return [
...Array.from(hrp, (character) => character.charCodeAt(0) >>> 5),
0,
...Array.from(hrp, (character) => character.charCodeAt(0) & 31),
];
}
function convertFiveBitWordsToBytes(words: readonly number[]): number[] | null {
let accumulator = 0;
let bitCount = 0;
const bytes: number[] = [];
for (const word of words) {
accumulator = (accumulator << 5) | word;
bitCount += 5;
while (bitCount >= 8) {
bitCount -= 8;
bytes.push((accumulator >>> bitCount) & 0xff);
}
}
// Bech32 conversion without padding permits fewer than five zero remainder
// bits. Any larger or non-zero remainder is not a canonical byte encoding.
if (bitCount >= 5 || ((accumulator << (8 - bitCount)) & 0xff) !== 0) {
return null;
}
return bytes;
}
export function classifyKeyImportInput(input: string): KeyImportKind {
const trimmed = input.trim();
// Case-insensitive on the HRP to match the Rust classifier: an uppercase
// valid backup routes to the encrypted path (and decodes there); mixed
// case routes there too and fails in Rust with the accurate error.
if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec";
if (trimmed.startsWith("nsec1")) return "nsec";
return "unknown";
}
/**
* Password-independent NIP-49 validation used for the automatic UI transition.
* A candidate must have canonical casing and length, a valid Bech32 checksum,
* and the current 91-byte/version-2 NIP-49 payload shape.
*/
export function isPlausibleNcryptsec(input: string): boolean {
const trimmed = input.trim();
if (trimmed.length !== NCRYPTSEC_ENCODED_LENGTH) return false;
if (trimmed !== trimmed.toLowerCase() && trimmed !== trimmed.toUpperCase()) {
return false;
}
const normalized = trimmed.toLowerCase();
const separatorIndex = normalized.lastIndexOf("1");
if (
separatorIndex !== NCRYPTSEC_HRP.length ||
normalized.slice(0, separatorIndex) !== NCRYPTSEC_HRP
) {
return false;
}
const encoded = normalized.slice(separatorIndex + 1);
const words = Array.from(encoded, (character) =>
BECH32_CHARSET.indexOf(character),
);
if (words.some((word) => word < 0) || words.length <= 6) return false;
if (bech32Polymod([...expandBech32Hrp(NCRYPTSEC_HRP), ...words]) !== 1) {
return false;
}
const payload = convertFiveBitWordsToBytes(words.slice(0, -6));
return (
payload?.length === NIP49_PAYLOAD_BYTES && payload[0] === NIP49_VERSION
);
}
/**
* Whether the import form's submit should be enabled.
* nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase.
*/
export function keyImportSubmitEnabled(
input: string,
passphrase: string,
): boolean {
const kind = classifyKeyImportInput(input);
if (kind === "ncryptsec") {
return isPlausibleNcryptsec(input) && passphrase.length > 0;
}
return nsecToNpub(input) !== null;
}