mirror of
https://github.com/block/buzz.git
synced 2026-11-08 02:11:36 +00:00
**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>
252 lines
7.0 KiB
TypeScript
252 lines
7.0 KiB
TypeScript
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;
|
||
}
|