mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
Add two-secret identity recovery kits
Signed-off-by: Jordan Mecom <jm@squareup.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
279
desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx
Normal file
279
desktop/src/features/settings/ui/TwoSkdRecoveryCreator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user