Files
buzz/desktop/src/features/settings/EncryptedBackupProvider.tsx
Taylor Ho bd0bff24bf feat(desktop): add password-protected backups in settings (#3701)
**Category:** new-feature
**User Impact:** Users can create, download, and verify a
password-protected backup of their private identity from desktop
Settings.

**Problem:** Buzz does not currently give signed-in users a
Settings-based path to protect or validate their private identity
independently of onboarding. **Solution:** Add a focused backup menu to
the private-key row, keep encryption and verification local in Rust, and
preserve completed encrypted backups briefly so native saves can be
retried without repeating encryption.

<details>
<summary>File changes</summary>

**desktop/src/features/settings/**
Adds the background backup lifecycle, create and test dialogs,
private-key menu integration, password handling, and focused unit
coverage.

**desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx**
Extends the masked private-key display with reusable overflow-menu
actions used by Settings.

**desktop/src/app/App.tsx**
Mounts the backup provider at app scope so encryption and save work
survive closing Settings or the modal.

**desktop/src/shared/api/tauriIdentity.ts**
Adds typed desktop bindings for local backup creation, save, selection,
and verification.

**desktop/src-tauri/src/key_backup.rs and
desktop/src-tauri/src/commands/identity.rs**
Implements local NIP-49 encryption, password generation, file handling,
and public-identity-only verification results.

**desktop/src-tauri/src/egress_guard.rs and guarded call sites**
Blocks encrypted secret material from relay, websocket, snapshot,
sharing, and huddle egress paths.

**desktop/src-tauri tests and fixtures**
Covers encryption, verification, file behavior, and fail-closed
no-egress protections.

**desktop/src/testing/e2eBridge.ts, desktop/tests/, and
desktop/playwright.config.ts**
Expands the mock native bridge and browser coverage across create,
retry, expiry, and current/different-identity verification states.

**desktop/src-tauri/Cargo.toml, Cargo.lock, and assets**
Adds the local cryptography/password-generation dependencies and
embedded short-word list.

</details>

## Reproduction steps

1. Run the desktop app and open **Settings → Profile → Identity**.
2. Open the private-key overflow menu and choose **Create backup**.
3. Enter or generate a valid password, submit, and confirm progress
continues if the dialog or Settings is closed.
4. Save the resulting `.ncryptsec` file; cancel and retry to confirm the
temporary download remains available.
5. Choose **Test backup**, select the file, enter a wrong password, then
retry with the correct password.
6. Confirm success identifies whether the backup matches the current
identity and displays only the public `npub`.

## Screenshots

| Settings identity | Private-key menu | Create backup |
|---|---|---|
| <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/981e391b-6829-4081-95ca-ca75a369de71"
/> | <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/7972c68e-7635-47d8-b0ad-9639390d3e6c"
/> | <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/4709c8f7-cf02-46f1-bec9-b3f98fe56fb2"
/> |

| Encrypting | Download available | Test success |
|---|---|---|
| <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/1ac3e934-2b4b-4135-bae6-126c715c8c59"
/> | <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/cb6f07ee-a16f-44a5-b9a0-6b9fe0e4d40d"
/> | <img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/ea58b1b1-966c-46aa-8d59-92c9f06a25bd"
/> |

Visual review and additional states: [Buzz
thread](buzz://message?channel=50ca7ef1-201e-4159-9499-40de3964b7c3&id=87eceb5f0f82fd50c32e560de3d35be48e293760f6620718aafdcef289d475fe)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
2026-07-30 11:15:39 -07:00

252 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as React from "react";
import { toast } from "sonner";
import {
createNcryptsecBackup,
saveNcryptsecCopy,
} from "@/shared/api/tauriIdentity";
import {
type EncryptedBackupEvent,
type EncryptedBackupState,
encryptedBackupReducer,
initialEncryptedBackupState,
pendingEncryptPassphrase,
} from "./lib/encryptedBackup";
const ENCRYPT_DEBOUNCE_MS = 400;
/** How long a completed encrypted backup remains available in memory. */
export const BACKUP_AVAILABILITY_MS = 5 * 60 * 1000;
const BACKUP_READY_TOAST_ID = "encrypted-key-backup-ready";
type EncryptedBackupContextValue = {
state: EncryptedBackupState;
dispatch: React.Dispatch<EncryptedBackupEvent>;
backupAvailable: boolean;
availableUntil: number | null;
isSaving: boolean;
saveError: string | null;
downloadBackup: () => Promise<void>;
startNewBackup: () => void;
};
const EncryptedBackupContext =
React.createContext<EncryptedBackupContextValue | null>(null);
export function EncryptedBackupProvider({
children,
onOpenSettings,
}: {
children: React.ReactNode;
onOpenSettings: () => void;
}) {
const [state, dispatch] = React.useReducer(
encryptedBackupReducer,
initialEncryptedBackupState,
);
const [availableUntil, setAvailableUntil] = React.useState<number | null>(
null,
);
const [isSaving, setIsSaving] = React.useState(false);
const [saveError, setSaveError] = React.useState<string | null>(null);
const autoSaveStartedForRef = React.useRef<string | null>(null);
const mountedRef = React.useRef(true);
const onOpenSettingsRef = React.useRef(onOpenSettings);
React.useEffect(() => {
onOpenSettingsRef.current = onOpenSettings;
}, [onOpenSettings]);
React.useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
React.useEffect(() => {
if (availableUntil === null) return;
const expiresIn = Math.max(0, availableUntil - Date.now());
const timer = window.setTimeout(() => {
autoSaveStartedForRef.current = null;
setAvailableUntil(null);
setSaveError(null);
dispatch({ type: "start-new-backup" });
toast.dismiss(BACKUP_READY_TOAST_ID);
}, expiresIn);
return () => window.clearTimeout(timer);
}, [availableUntil]);
const pendingPassphrase = pendingEncryptPassphrase(state);
const skipDebounce = state.downloadPending;
React.useEffect(() => {
if (!pendingPassphrase) return;
let started = false;
let cancelledBeforeStart = false;
const requestId = state.nextRequestId;
const start = () => {
if (cancelledBeforeStart) return;
started = true;
dispatch({ type: "encrypt-started", requestId });
void createNcryptsecBackup(pendingPassphrase)
.then((ncryptsec) => {
dispatch({ type: "encrypt-succeeded", requestId, ncryptsec });
})
.catch((err: unknown) => {
dispatch({
type: "encrypt-failed",
requestId,
message:
err instanceof Error
? err.message
: "Failed to encrypt your key.",
});
});
};
const timer = window.setTimeout(
start,
skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS,
);
return () => {
if (!started) cancelledBeforeStart = true;
window.clearTimeout(timer);
};
}, [pendingPassphrase, skipDebounce, state.nextRequestId]);
React.useEffect(() => {
if (state.downloadPending) {
toast.loading("Preparing backup…", {
description: "You can close this window while Buzz finishes.",
duration: Number.POSITIVE_INFINITY,
id: BACKUP_READY_TOAST_ID,
});
return;
}
if (
state.createError &&
state.passphrase.length === 0 &&
!state.ncryptsec
) {
toast.error("Couldn’t create backup", {
description: state.createError,
id: BACKUP_READY_TOAST_ID,
});
}
}, [
state.createError,
state.downloadPending,
state.ncryptsec,
state.passphrase.length,
]);
const showAvailableToast = React.useCallback(
(description: string, error = false) => {
const options = {
action: {
label: "Open settings",
onClick: () => onOpenSettingsRef.current(),
},
description,
id: BACKUP_READY_TOAST_ID,
};
if (error) toast.error("Backup ready to download", options);
else toast.success("Backup ready to download", options);
},
[],
);
const saveBackup = React.useCallback(
async (ncryptsec: string) => {
if (isSaving) return;
setIsSaving(true);
setSaveError(null);
toast("Saving backup…", {
description: "The download window will open when it’s ready.",
id: BACKUP_READY_TOAST_ID,
});
try {
const path = await saveNcryptsecCopy(ncryptsec);
if (mountedRef.current) {
showAvailableToast(
path === null
? "Your backup will be available to download for 5 minutes."
: "You can download another copy for 5 minutes.",
);
}
} catch (err) {
if (!mountedRef.current) return;
const message =
err instanceof Error ? err.message : "Failed to save your key.";
setSaveError(message);
showAvailableToast(
`${message} It will be available to download for 5 minutes.`,
true,
);
} finally {
if (mountedRef.current) setIsSaving(false);
}
},
[isSaving, showAvailableToast],
);
React.useEffect(() => {
const ncryptsec = state.ncryptsec;
if (!ncryptsec || autoSaveStartedForRef.current === ncryptsec) return;
autoSaveStartedForRef.current = ncryptsec;
setAvailableUntil(Date.now() + BACKUP_AVAILABILITY_MS);
void saveBackup(ncryptsec);
}, [saveBackup, state.ncryptsec]);
const downloadBackup = React.useCallback(async () => {
if (!state.ncryptsec) return;
await saveBackup(state.ncryptsec);
}, [saveBackup, state.ncryptsec]);
const startNewBackup = React.useCallback(() => {
autoSaveStartedForRef.current = null;
setAvailableUntil(null);
setSaveError(null);
toast.dismiss(BACKUP_READY_TOAST_ID);
dispatch({ type: "start-new-backup" });
}, []);
const value = React.useMemo<EncryptedBackupContextValue>(
() => ({
state,
dispatch,
backupAvailable:
state.savedPassword &&
state.ncryptsec !== null &&
availableUntil !== null,
availableUntil,
isSaving,
saveError,
downloadBackup,
startNewBackup,
}),
[
availableUntil,
downloadBackup,
isSaving,
saveError,
startNewBackup,
state,
],
);
return (
<EncryptedBackupContext.Provider value={value}>
{children}
</EncryptedBackupContext.Provider>
);
}
export function useEncryptedBackup(): EncryptedBackupContextValue {
const value = React.useContext(EncryptedBackupContext);
if (!value) {
throw new Error(
"useEncryptedBackup must be used within EncryptedBackupProvider",
);
}
return value;
}