Add two-secret identity recovery kits

Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
Jordan Mecom
2026-08-21 19:13:41 -07:00
parent cd0d33f085
commit 230bf6fa7c
21 changed files with 1223 additions and 49 deletions

View File

@@ -10,6 +10,7 @@ import { generateSecretKey } from "nostr-tools/pure";
import {
classifyKeyImportInput,
isPlausibleNcryptsec,
isPlausibleTwoSkdBackup,
keyImportSubmitEnabled,
NCRYPTSEC_ENCODED_LENGTH,
} from "./keyImportInput.ts";
@@ -19,10 +20,12 @@ const NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
const VALID_NSEC = nsecEncode(generateSecretKey());
const TWO_SKD_BACKUP = `buzz2skd1:${"A".repeat(80)}`;
test("classify_by_hrp_with_whitespace_tolerance", () => {
assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec");
assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec");
assert.equal(classifyKeyImportInput(TWO_SKD_BACKUP), "two-skd");
assert.equal(classifyKeyImportInput("npub1whatever"), "unknown");
assert.equal(classifyKeyImportInput(""), "unknown");
// nsec must not be shadowed by the longer HRP check.
@@ -71,3 +74,21 @@ test("submit_gating_ncryptsec_requires_passphrase", () => {
// Structurally implausible blob never submits, passphrase or not.
assert.equal(keyImportSubmitEnabled("ncryptsec1bio", "hunter2"), false);
});
test("2SKD backup requires both password and recovery code", () => {
assert.equal(isPlausibleTwoSkdBackup(TWO_SKD_BACKUP), true);
assert.equal(keyImportSubmitEnabled(TWO_SKD_BACKUP, "", ""), false);
assert.equal(
keyImportSubmitEnabled(TWO_SKD_BACKUP, "hunter2hunter2", ""),
false,
);
assert.equal(
keyImportSubmitEnabled(
TWO_SKD_BACKUP,
"hunter2hunter2",
"buzz-recovery-v1-00112233445566778899aabbccddeeff",
),
true,
);
assert.equal(isPlausibleTwoSkdBackup("buzz2skd1:not valid"), false);
});

View File

@@ -2,16 +2,14 @@
* 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.
* Encrypted backups cannot be previewed before their recovery inputs are
* provided. Password and recovery-code validation happens in Rust; this module
* performs only the password-independent shape checks needed to switch modes.
*/
import { nsecToNpub } from "@/shared/lib/nostrUtils";
export type KeyImportKind = "nsec" | "ncryptsec" | "unknown";
export type KeyImportKind = "nsec" | "ncryptsec" | "two-skd" | "unknown";
const NCRYPTSEC_HRP = "ncryptsec";
const NIP49_VERSION = 2;
@@ -67,6 +65,7 @@ function convertFiveBitWordsToBytes(words: readonly number[]): number[] | null {
export function classifyKeyImportInput(input: string): KeyImportKind {
const trimmed = input.trim();
if (trimmed.startsWith("buzz2skd1:")) return "two-skd";
// 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.
@@ -75,6 +74,14 @@ export function classifyKeyImportInput(input: string): KeyImportKind {
return "unknown";
}
/** Lightweight shape check for Buzz's versioned, base64url 2SKD artifact. */
export function isPlausibleTwoSkdBackup(input: string): boolean {
const trimmed = input.trim();
if (!trimmed.startsWith("buzz2skd1:") || trimmed.length > 4096) return false;
const encoded = trimmed.slice("buzz2skd1:".length);
return encoded.length >= 40 && /^[A-Za-z0-9_-]+$/.test(encoded);
}
/**
* Password-independent NIP-49 validation used for the automatic UI transition.
* A candidate must have canonical casing and length, a valid Bech32 checksum,
@@ -113,13 +120,22 @@ export function isPlausibleNcryptsec(input: string): boolean {
/**
* Whether the import form's submit should be enabled.
* nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase.
* Raw keys must derive an npub. Encrypted backups require their complete set
* of recovery inputs before submission.
*/
export function keyImportSubmitEnabled(
input: string,
passphrase: string,
recoverySecret = "",
): boolean {
const kind = classifyKeyImportInput(input);
if (kind === "two-skd") {
return (
isPlausibleTwoSkdBackup(input) &&
passphrase.length > 0 &&
recoverySecret.trim().length > 0
);
}
if (kind === "ncryptsec") {
return isPlausibleNcryptsec(input) && passphrase.length > 0;
}

View File

@@ -22,8 +22,8 @@ export function KeyringLockedScreen() {
}, []);
const handleImport = React.useCallback(
async (nsec: string, password?: string) => {
const identity = await importIdentity(nsec, password);
async (nsec: string, password?: string, recoverySecret?: string) => {
const identity = await importIdentity(nsec, password, recoverySecret);
// Update the identity query cache so useIdentityQuery observers see
// locked: false. The bootedLocked latch in hooks.ts will then route
// to RelaunchRequiredScreen via bootedLocked && !identityLocked.

View File

@@ -200,8 +200,8 @@ export function MachineOnboardingFlow({
}, [queryClient]);
const importExistingIdentity = React.useCallback(
async (nsec: string, password?: string) => {
const identity = await importIdentity(nsec, password);
async (nsec: string, password?: string, recoverySecret?: string) => {
const identity = await importIdentity(nsec, password, recoverySecret);
continueWithIdentity(identity.pubkey);
queryClient.setQueryData(["identity"], identity);
setIdentityWasImported(true);

View File

@@ -6,6 +6,7 @@ import { nsecToNpub } from "@/shared/lib/nostrUtils";
import {
classifyKeyImportInput,
isPlausibleNcryptsec,
isPlausibleTwoSkdBackup,
keyImportSubmitEnabled,
} from "../lib/keyImportInput";
import { Button } from "@/shared/ui/button";
@@ -22,7 +23,7 @@ import {
} from "./BackupPasswordTimeline";
import { OnboardingFooter } from "./OnboardingFooter";
const NOSTR_KEY_FILE_MAX_BYTES = 1024;
const NOSTR_KEY_FILE_MAX_BYTES = 4096;
export type NostrKeyImportStage = "key-entry" | "backup-password";
@@ -31,7 +32,11 @@ type NostrKeyImportFormProps = {
disabled?: boolean;
errorMessage?: string | null;
onBack: () => void;
onImport: (nsec: string, password?: string) => Promise<void>;
onImport: (
nsec: string,
password?: string,
recoverySecret?: string,
) => Promise<void>;
/** Reports whether an import is in flight so host-owned navigation can be disabled. */
onImportingChange?: (isImporting: boolean) => void;
onStageChange?: (stage: NostrKeyImportStage) => void;
@@ -70,6 +75,7 @@ export function NostrKeyImportForm({
}: NostrKeyImportFormProps) {
const [nsecInput, setNsecInput] = React.useState("");
const [passphrase, setPassphrase] = React.useState("");
const [recoverySecret, setRecoverySecret] = React.useState("");
const [isImporting, setIsImporting] = React.useState(false);
const importInFlightRef = React.useRef(false);
const [importError, setImportError] = React.useState<string | null>(null);
@@ -78,13 +84,16 @@ export function NostrKeyImportForm({
const [isRevealed, setIsRevealed] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const passphraseInputRef = React.useRef<HTMLInputElement | null>(null);
const recoverySecretInputRef = React.useRef<HTMLInputElement | null>(null);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const previewNpub = React.useMemo(() => nsecToNpub(nsecInput), [nsecInput]);
const trimmedInput = nsecInput.trim();
const hasInput = trimmedInput.length > 0;
const inputKind = classifyKeyImportInput(nsecInput);
const isEncryptedInput = inputKind === "ncryptsec";
const isPasswordStage = isPlausibleNcryptsec(nsecInput);
const isTwoSkdInput = inputKind === "two-skd";
const isPasswordStage =
isPlausibleNcryptsec(nsecInput) || isPlausibleTwoSkdBackup(nsecInput);
// Masked-by-default must re-assert whenever the field empties: a sticky
// reveal from a previous key must never apply to newly pasted content the
@@ -99,9 +108,10 @@ export function NostrKeyImportForm({
React.useEffect(() => {
if (!isPasswordStage) {
setPassphrase("");
setRecoverySecret("");
}
}, [isPasswordStage]);
const isValid = keyImportSubmitEnabled(nsecInput, passphrase);
const isValid = keyImportSubmitEnabled(nsecInput, passphrase, recoverySecret);
const isInteractionDisabled = disabled || isImporting;
const showInvalidHint =
hasInput &&
@@ -113,11 +123,12 @@ export function NostrKeyImportForm({
React.useLayoutEffect(() => {
if (isPasswordStage) {
passphraseInputRef.current?.focus();
if (isTwoSkdInput) recoverySecretInputRef.current?.focus();
else passphraseInputRef.current?.focus();
} else {
inputRef.current?.focus();
}
}, [isPasswordStage]);
}, [isPasswordStage, isTwoSkdInput]);
React.useEffect(() => {
onStageChange?.(isPasswordStage ? "backup-password" : "key-entry");
@@ -217,7 +228,11 @@ export function NostrKeyImportForm({
setImportError(null);
try {
await onImport(trimmedInput, isPasswordStage ? passphrase : undefined);
await onImport(
trimmedInput,
isPasswordStage ? passphrase : undefined,
isTwoSkdInput ? recoverySecret.trim() : undefined,
);
} catch (error) {
setImportError(
error instanceof Error ? error.message : "Couldn't import this key.",
@@ -231,10 +246,12 @@ export function NostrKeyImportForm({
isEncryptedInput,
isInteractionDisabled,
isPasswordStage,
isTwoSkdInput,
isValid,
onImport,
onImportingChange,
passphrase,
recoverySecret,
trimmedInput,
]);
@@ -246,6 +263,7 @@ export function NostrKeyImportForm({
setNsecInput("");
setPassphrase("");
setRecoverySecret("");
setImportError(null);
setIsRevealed(false);
onStageChange?.("key-entry");
@@ -359,11 +377,10 @@ export function NostrKeyImportForm({
) : null}
{/* Hidden file input shared by both variants: the default drop zone and
the spotlight "Choose a backup file" button both open it. Accepts the
.ncryptsec backups our own save flow emits alongside raw .key files. */}
the spotlight "Choose a backup file" button both open it. */}
{mode === "backup" || variant !== "spotlight" ? (
<input
accept=".key,.ncryptsec,text/plain"
accept=".key,.ncryptsec,.buzzbackup,text/plain"
className="sr-only"
data-testid="nostr-import-file-input"
disabled={isInteractionDisabled}
@@ -495,6 +512,28 @@ export function NostrKeyImportForm({
Backup password
</label>
<div className="relative z-10">
{isTwoSkdInput ? (
<div className="mb-3">
<label className="sr-only" htmlFor="nostr-import-recovery-code">
Recovery code
</label>
<Input
autoComplete="off"
autoCorrect="off"
className="h-12 rounded-2xl border-black/20 bg-white px-6 text-center font-mono text-sm text-black/80 shadow-none placeholder:text-black/55 focus-visible:ring-black/35"
data-testid="nostr-import-recovery-code"
id="nostr-import-recovery-code"
onChange={(event) => {
setRecoverySecret(event.target.value);
setImportError(null);
}}
placeholder="Recovery code"
ref={recoverySecretInputRef}
spellCheck={false}
value={recoverySecret}
/>
</div>
) : null}
<Input
autoComplete="current-password"
autoCorrect="off"
@@ -575,9 +614,11 @@ export function NostrKeyImportForm({
{showInvalidHint && !errorMessage ? (
<p className="text-sm text-muted-foreground">
{isEncryptedInput
? "Waiting for a complete ncryptsec backup"
: "Waiting for a valid nsec1 key"}
{isTwoSkdInput
? "Waiting for a complete Buzz recovery backup"
: isEncryptedInput
? "Waiting for a complete ncryptsec backup"
: "Waiting for a valid nsec1 key"}
</p>
) : null}

View File

@@ -395,8 +395,8 @@ export function OnboardingFlow({
// key's relay profile reseeds the steps, and a key that already finished
// onboarding on this machine skips straight into the app.
const importExistingKey = React.useCallback(
async (nsec: string, password?: string) => {
const identity = await importIdentity(nsec, password);
async (nsec: string, password?: string, recoverySecret?: string) => {
const identity = await importIdentity(nsec, password, recoverySecret);
relayClient.disconnect();
queryClient.setQueryData(["identity"], identity);
queryClient.removeQueries({ queryKey: profileQueryKey });

View File

@@ -4,6 +4,7 @@ import * as React from "react";
import {
verifyNcryptsecBackup,
verifyTwoSkdBackup,
type BackupVerification,
} from "@/shared/api/tauriIdentity";
import { Button } from "@/shared/ui/button";
@@ -119,6 +120,7 @@ export function BackupTestFlow({
}: BackupTestFlowProps) {
const reduceMotion = useReducedMotion() ?? false;
const { stage, fileName, ncryptsec, result } = progress;
const isTwoSkd = ncryptsec?.startsWith("buzz2skd1:") === true;
// True while a file drag is anywhere over the window — the drop overlay
// takes over the host surface only for the duration of the drag.
const [isWindowDragging, setIsWindowDragging] = React.useState(false);
@@ -155,6 +157,7 @@ export function BackupTestFlow({
// The password attempt is component-local, never host state: it is cleared
// when verification is submitted and when this component unmounts.
const [attempt, setAttempt] = React.useState("");
const [recoveryAttempt, setRecoveryAttempt] = React.useState("");
const [error, setError] = React.useState<string | null>(null);
const [isVerifying, setIsVerifying] = React.useState(false);
const [isRevealed, setIsRevealed] = React.useState(false);
@@ -171,6 +174,7 @@ export function BackupTestFlow({
mountedRef.current = false;
requestRef.current += 1;
setAttempt("");
setRecoveryAttempt("");
};
}, []);
@@ -188,12 +192,16 @@ export function BackupTestFlow({
return;
}
if (!mountedRef.current) return;
if (!text.toLowerCase().startsWith("ncryptsec1")) {
if (
!text.toLowerCase().startsWith("ncryptsec1") &&
!text.startsWith("buzz2skd1:")
) {
setError("That doesn't look like a key backup file.");
return;
}
setError(null);
setAttempt("");
setRecoveryAttempt("");
onProgressChange({
stage: "password",
fileName: file.name,
@@ -205,8 +213,15 @@ export function BackupTestFlow({
);
const handleVerify = React.useCallback(async () => {
if (!ncryptsec || !attempt || isVerifying) return;
if (
!ncryptsec ||
!attempt ||
(isTwoSkd && !recoveryAttempt.trim()) ||
isVerifying
)
return;
const password = attempt;
const recoverySecret = recoveryAttempt.trim();
const requestId = ++requestRef.current;
setIsVerifying(true);
setError(null);
@@ -214,8 +229,11 @@ export function BackupTestFlow({
// Clear the attempt the moment it's handed to Rust — success or failure,
// the typed password never lingers in the field.
setAttempt("");
setRecoveryAttempt("");
try {
const verified = await verifyNcryptsecBackup(ncryptsec, password);
const verified = isTwoSkd
? await verifyTwoSkdBackup(ncryptsec, password, recoverySecret)
: await verifyNcryptsecBackup(ncryptsec, password);
if (!mountedRef.current || requestId !== requestRef.current) return;
onProgressChange((prev) => ({
...prev,
@@ -231,7 +249,14 @@ export function BackupTestFlow({
if (mountedRef.current && requestId === requestRef.current)
setIsVerifying(false);
}
}, [attempt, isVerifying, ncryptsec, onProgressChange]);
}, [
attempt,
isTwoSkd,
isVerifying,
ncryptsec,
onProgressChange,
recoveryAttempt,
]);
if (stage === "success" && result) {
return (
@@ -299,7 +324,7 @@ export function BackupTestFlow({
{stage === "drop" ? (
<>
<input
accept=".ncryptsec,text/plain"
accept=".ncryptsec,.buzzbackup,text/plain"
className="sr-only"
data-testid="backup-test-file-input"
onChange={(event) => {
@@ -370,8 +395,23 @@ export function BackupTestFlow({
<Check aria-hidden="true" className="h-4 w-4 text-primary" />
</div>
<p className="text-center text-sm leading-6 text-muted-foreground">
That's the one. Now enter your password to prove you can unlock it.
{isTwoSkd
? "That’s the one. Enter its separate recovery code and password."
: "That’s the one. Now enter your password to prove you can unlock it."}
</p>
{isTwoSkd ? (
<Input
aria-label="Recovery code"
autoComplete="off"
className="h-10 bg-background font-mono"
data-testid="backup-test-recovery-code"
disabled={isVerifying}
onChange={(event) => setRecoveryAttempt(event.target.value)}
placeholder="Recovery code"
spellCheck={false}
value={recoveryAttempt}
/>
) : null}
<div className="relative">
<Input
aria-label="Backup password"
@@ -421,7 +461,9 @@ export function BackupTestFlow({
<Button
className="h-9 rounded-full bg-primary px-6 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
data-testid="backup-test-verify"
disabled={!attempt || isVerifying}
disabled={
!attempt || (isTwoSkd && !recoveryAttempt.trim()) || isVerifying
}
onClick={() => void handleVerify()}
type="button"
>

View File

@@ -1,4 +1,4 @@
import { Download, Eye, EyeOff, ShieldCheck } from "lucide-react";
import { Download, Eye, EyeOff, KeyRound, ShieldCheck } from "lucide-react";
import * as React from "react";
import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay";
@@ -11,6 +11,7 @@ import {
initialBackupTestProgress,
} from "@/features/settings/ui/BackupTestFlow";
import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator";
import { TwoSkdRecoveryCreator } from "@/features/settings/ui/TwoSkdRecoveryCreator";
import { getNsec } from "@/shared/api/tauriIdentity";
import { Button } from "@/shared/ui/button";
import {
@@ -64,6 +65,7 @@ export function PrivateKeyBackupRow() {
const [loadError, setLoadError] = React.useState<string | null>(null);
const [createOpen, setCreateOpen] = React.useState(false);
const [testOpen, setTestOpen] = React.useState(false);
const [recoveryKitOpen, setRecoveryKitOpen] = React.useState(false);
const [testProgress, setTestProgress] = React.useState(
initialBackupTestProgress,
);
@@ -171,6 +173,12 @@ export function PrivateKeyBackupRow() {
) : nsec ? (
<NsecMaskedDisplay
actions={[
{
icon: <KeyRound aria-hidden="true" />,
label: "Create recovery kit",
onSelect: () => setRecoveryKitOpen(true),
testId: "private-key-create-recovery-kit",
},
{
icon: <Download aria-hidden="true" />,
label: "Create backup",
@@ -191,13 +199,17 @@ export function PrivateKeyBackupRow() {
) : null}
</div>
<EncryptedBackupCreator onOpenChange={setCreateOpen} open={createOpen} />
<TwoSkdRecoveryCreator
onOpenChange={setRecoveryKitOpen}
open={recoveryKitOpen}
/>
<Dialog onOpenChange={handleTestOpenChange} open={testOpen}>
<DialogContent className="max-w-lg" data-testid="backup-test-dialog">
<DialogHeader className="pr-8">
<DialogTitle>Test a key backup</DialogTitle>
<DialogDescription>
Confirm that a backup file and its password can unlock an
identity.
Confirm that a backup file and its required recovery inputs can
unlock an identity.
</DialogDescription>
</DialogHeader>
<BackupTestFlow
@@ -205,8 +217,8 @@ export function PrivateKeyBackupRow() {
progress={testProgress}
/>
<p className="text-xs leading-5 text-muted-foreground">
Backups use the standard NIP-49 format, so this works for backups
from compatible Nostr apps too.
Buzz recovery kits use 2SKD. Standard NIP-49 backups from compatible
Nostr apps remain supported.
</p>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,279 @@
import { Check, Copy, Eye, EyeOff, ShieldCheck } from "lucide-react";
import * as React from "react";
import {
createTwoSkdBackup,
type CreatedTwoSkdBackup,
saveTwoSkdBackupCopy,
} from "@/shared/api/tauriIdentity";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Spinner } from "@/shared/ui/spinner";
const MIN_PASSPHRASE_LEN = 12;
/** Create the smallest useful 2SKD recovery kit: one encrypted file plus a
* separately-held recovery code. The raw identity key never reaches React. */
export function TwoSkdRecoveryCreator({
onOpenChange,
open,
}: {
onOpenChange: (open: boolean) => void;
open: boolean;
}) {
const [passphrase, setPassphrase] = React.useState("");
const [revealed, setRevealed] = React.useState(false);
const [created, setCreated] = React.useState<CreatedTwoSkdBackup | null>(
null,
);
const [savedPath, setSavedPath] = React.useState<string | null>(null);
const [pending, setPending] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const copyTimerRef = React.useRef<number | null>(null);
const requestRef = React.useRef(0);
const reset = React.useCallback(() => {
requestRef.current += 1;
setPassphrase("");
setRevealed(false);
setCreated(null);
setSavedPath(null);
setPending(false);
setCopied(false);
setError(null);
if (copyTimerRef.current !== null) {
window.clearTimeout(copyTimerRef.current);
copyTimerRef.current = null;
}
}, []);
React.useEffect(
() => () => {
if (copyTimerRef.current !== null)
window.clearTimeout(copyTimerRef.current);
},
[],
);
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
if (!nextOpen) reset();
onOpenChange(nextOpen);
},
[onOpenChange, reset],
);
const saveBackup = React.useCallback(
(result: CreatedTwoSkdBackup) => saveTwoSkdBackupCopy(result.backup),
[],
);
const create = React.useCallback(async () => {
if ([...passphrase].length < MIN_PASSPHRASE_LEN || pending) return;
const requestId = ++requestRef.current;
setPending(true);
setError(null);
setRevealed(false);
try {
const result = await createTwoSkdBackup(passphrase);
if (requestId !== requestRef.current) return;
setPassphrase("");
setCreated(result);
const path = await saveBackup(result);
if (requestId !== requestRef.current) return;
if (path) setSavedPath(path);
} catch (cause) {
if (requestId !== requestRef.current) return;
setError(
cause instanceof Error
? cause.message
: "Could not create your recovery kit.",
);
} finally {
if (requestId === requestRef.current) setPending(false);
}
}, [passphrase, pending, saveBackup]);
const copyRecoveryCode = React.useCallback(async () => {
if (!created) return;
try {
await writeTextToClipboard(created.recoverySecret);
setCopied(true);
if (copyTimerRef.current !== null)
window.clearTimeout(copyTimerRef.current);
copyTimerRef.current = window.setTimeout(() => setCopied(false), 2000);
} catch (cause) {
setError(
cause instanceof Error ? cause.message : "Could not copy the code.",
);
}
}, [created]);
const retrySave = React.useCallback(async () => {
if (!created || pending) return;
const requestId = ++requestRef.current;
setPending(true);
setError(null);
try {
const path = await saveBackup(created);
if (requestId !== requestRef.current) return;
if (path) setSavedPath(path);
} catch (cause) {
if (requestId !== requestRef.current) return;
setError(
cause instanceof Error
? cause.message
: "Could not save the encrypted backup.",
);
} finally {
if (requestId === requestRef.current) setPending(false);
}
}, [created, pending, saveBackup]);
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="max-w-lg" data-testid="two-skd-dialog">
<DialogHeader className="pr-8">
<DialogTitle>Create a recovery kit</DialogTitle>
<DialogDescription>
Recovery requires the encrypted backup, your password, and a
separate recovery code.
</DialogDescription>
</DialogHeader>
{!created ? (
<div className="space-y-4" data-testid="two-skd-password-step">
<div className="relative">
<Input
aria-label="Recovery password"
autoComplete="new-password"
className="h-10 bg-background pr-10"
disabled={pending}
onChange={(event) => {
setPassphrase(event.target.value);
setError(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void create();
}
}}
placeholder={`Password (min ${MIN_PASSPHRASE_LEN} characters)`}
type={revealed ? "text" : "password"}
value={passphrase}
/>
<Button
aria-label={revealed ? "Hide password" : "Reveal password"}
className="absolute right-1 top-1/2 size-8 -translate-y-1/2 text-muted-foreground"
disabled={pending}
onClick={() => setRevealed((value) => !value)}
size="icon"
type="button"
variant="ghost"
>
{revealed ? (
<EyeOff aria-hidden="true" className="size-4" />
) : (
<Eye aria-hidden="true" className="size-4" />
)}
</Button>
</div>
<p className="text-xs leading-5 text-muted-foreground">
Buzz combines this password with a random code that never enters
the encrypted backup. A stolen backup alone cannot be tested
against guessed passwords.
</p>
<div className="flex justify-end">
<Button
className="rounded-full px-6"
data-testid="two-skd-create"
disabled={
[...passphrase].length < MIN_PASSPHRASE_LEN || pending
}
onClick={() => void create()}
type="button"
>
{pending ? <Spinner className="size-4 border-2" /> : null}
Create kit
</Button>
</div>
</div>
) : savedPath ? (
<div className="space-y-4" data-testid="two-skd-recovery-code-step">
<div className="flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-4">
<ShieldCheck
aria-hidden="true"
className="mt-0.5 size-5 shrink-0 text-primary"
/>
<div className="min-w-0 space-y-2">
<p className="text-sm font-medium">Save this separately</p>
<p
className="break-all font-mono text-sm leading-6"
data-testid="two-skd-recovery-code"
>
{created.recoverySecret}
</p>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
The encrypted backup was saved to {savedPath}. Put this code in a
different place, such as your password manager or a printed QR.
Buzz cannot recover it later.
</p>
<div className="flex justify-end gap-2">
<Button
data-testid="two-skd-copy-code"
onClick={() => void copyRecoveryCode()}
type="button"
variant="secondary"
>
{copied ? (
<Check aria-hidden="true" className="size-4" />
) : (
<Copy aria-hidden="true" className="size-4" />
)}
{copied ? "Copied" : "Copy code"}
</Button>
<Button onClick={() => handleOpenChange(false)} type="button">
Done
</Button>
</div>
</div>
) : (
<div className="space-y-4" data-testid="two-skd-save-step">
<p className="text-sm leading-6 text-muted-foreground">
Save the encrypted backup before Buzz shows its separate recovery
code.
</p>
<div className="flex justify-end">
<Button
disabled={pending}
onClick={() => void retrySave()}
type="button"
>
{pending ? <Spinner className="size-4 border-2" /> : null}
Save encrypted backup
</Button>
</div>
</div>
)}
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
</DialogContent>
</Dialog>
);
}

View File

@@ -32,9 +32,14 @@ export async function getNsec(): Promise<string> {
export async function importIdentity(
nsec: string,
password?: string,
recoverySecret?: string,
): Promise<Identity> {
return fromRawIdentity(
await invokeTauri<RawIdentity>("import_identity", { nsec, password }),
await invokeTauri<RawIdentity>("import_identity", {
nsec,
password,
recoverySecret,
}),
);
}
@@ -77,6 +82,28 @@ export async function createNcryptsecBackup(password: string): Promise<string> {
return invokeTauri<string>("create_ncryptsec_backup", { password });
}
export type CreatedTwoSkdBackup = {
backup: string;
recoverySecret: string;
};
/** Create a 2SKD backup and its separately-held recovery code in Rust. */
export async function createTwoSkdBackup(
password: string,
): Promise<CreatedTwoSkdBackup> {
return invokeTauri<CreatedTwoSkdBackup>("create_2skd_backup", { password });
}
/** Save only the encrypted half of a 2SKD recovery kit. */
export async function saveTwoSkdBackupCopy(
backup: string,
): Promise<string | null> {
return (
(await invokeTauri<string | null>("save_2skd_backup_copy", { backup })) ??
null
);
}
/** Save a portable backup copy. Returns null when the native dialog is cancelled. */
export async function saveNcryptsecCopy(
ncryptsec: string,
@@ -103,3 +130,16 @@ export async function verifyNcryptsecBackup(
password,
});
}
/** Verify all three inputs for a 2SKD recovery without exposing the nsec. */
export async function verifyTwoSkdBackup(
backup: string,
password: string,
recoverySecret: string,
): Promise<BackupVerification> {
return invokeTauri<BackupVerification>("verify_2skd_backup", {
backup,
password,
recoverySecret,
});
}

View File

@@ -8171,6 +8171,9 @@ let backupSaveCallCount = 0;
const MOCK_NCRYPTSEC =
"ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p";
const MOCK_TWO_SKD_BACKUP = `buzz2skd1:${"A".repeat(80)}`;
const MOCK_RECOVERY_SECRET =
"buzz-recovery-v1-00112233445566778899aabbccddeeff";
const MOCK_BACKUP_PASSPHRASE = "mock horse battery staple lake orbit";
const MOCK_PASSPHRASE_WORDS = [
"mock",
@@ -11740,6 +11743,16 @@ export function maybeInstallE2eTauriMocks() {
}
return MOCK_NCRYPTSEC;
}
case "create_2skd_backup": {
const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0;
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return {
backup: MOCK_TWO_SKD_BACKUP,
recoverySecret: MOCK_RECOVERY_SECRET,
};
}
case "save_ncryptsec_copy": {
const paths = activeConfig?.mock?.backupSavePaths ?? [
"/tmp/buzz-identity.ncryptsec",
@@ -11748,6 +11761,14 @@ export function maybeInstallE2eTauriMocks() {
backupSaveCallCount += 1;
return paths[index];
}
case "save_2skd_backup_copy": {
const paths = activeConfig?.mock?.backupSavePaths ?? [
"/tmp/identity.buzzbackup",
];
const index = Math.min(backupSaveCallCount, paths.length - 1);
backupSaveCallCount += 1;
return paths[index];
}
case "verify_ncryptsec_backup": {
const request = payload as { password?: string } | null;
const configuredErrors = activeConfig?.mock?.backupVerificationErrors;
@@ -11777,6 +11798,26 @@ export function maybeInstallE2eTauriMocks() {
pubkey === (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey),
};
}
case "verify_2skd_backup": {
const request = payload as {
password?: string;
recoverySecret?: string;
} | null;
if (
request?.password !== MOCK_BACKUP_PASSPHRASE ||
request?.recoverySecret !== MOCK_RECOVERY_SECRET
) {
throw new Error(
"wrong backup password, recovery code, or damaged backup",
);
}
const pubkey = identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey;
return {
pubkey,
npub: npubEncode(pubkey),
matchesCurrentIdentity: true,
};
}
case "get_nsec": {
const nsecSequence = activeConfig?.mock?.nsecErrors;
if (nsecSequence && nsecSequence.length > 0) {
@@ -11816,8 +11857,28 @@ export function maybeInstallE2eTauriMocks() {
window.setTimeout(resolve, importDelayMs),
);
}
const request = payload as { nsec?: string; password?: string } | null;
const request = payload as {
nsec?: string;
password?: string;
recoverySecret?: string;
} | null;
const input = request?.nsec ?? "";
if (input.trim().startsWith("buzz2skd1:")) {
if (
input.trim() !== MOCK_TWO_SKD_BACKUP ||
request?.password !== MOCK_BACKUP_PASSPHRASE ||
request?.recoverySecret !== MOCK_RECOVERY_SECRET
) {
throw new Error(
"wrong backup password, recovery code, or damaged backup",
);
}
mockIdentityLostCleared = true;
mockIdentityLockedCleared = true;
return importMockIdentity(
nsecEncode(hexToBytes(DEFAULT_REAL_IDENTITY.privateKey)),
);
}
if (input.trim().startsWith("ncryptsec1")) {
if (
input.trim() !== MOCK_NCRYPTSEC ||