mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
## 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>
128 lines
4.2 KiB
TypeScript
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;
|
|
}
|