Summary
The encrypted key backup can be written under a passphrase one keystroke behind the one shown in the input. The saved .ncryptsec then fails to restore with "wrong backup password or damaged key backup" even though the user enters exactly the string the UI displayed when they clicked Download.
This is silent: nothing in the create flow reports a problem, and the Rust-side integrity check passes legitimately. The only thing that catches it is the optional Test your backup step. A user who skips that step gets a backup file that cannot be opened by the passphrase they believe they set — a permanent identity-loss risk.
Reproduced twice in a row on a fresh identity.
Environment
- Desktop
v0.5.4, built from source at ce56e3441 (aarch64-apple-darwin, --features mesh-llm)
- macOS 26.2, Apple Silicon
Steps to reproduce
- Fresh onboarding → Create a new identity key → review backup options → Create locked backup
- Type a passphrase, but hesitate slightly before the final character (e.g. reaching for Shift to type
! at the end of SomePassphrase!)
- Click Download backup and save the file
- Continue to Test your backup, select the file, enter the exact passphrase from step 2
Expected: unlocks.
Actual: "wrong backup password or damaged key backup". The file is encrypted with the passphrase minus its final character.
Verified independently of the app with nostr 0.44.7 (features = ["nip49"]): parsing the saved blob and trying successive prefixes shows len N rejected and len N-1 succeeding, recovering the correct pubkey.
Root cause
desktop/src/features/onboarding/lib/encryptedBackup.ts (same defect in the settings copy).
Encryption is speculative and debounced — ENCRYPT_DEBOUNCE_MS = 400 (EncryptedBackupCreator.tsx:64, EncryptedBackupProvider.tsx:16) — so a scrypt encrypt at log_n = 18 (~0.15–0.3 s on Apple Silicon) is typically already in flight while the user is still typing.
set-passphrase clears encrypted but leaves requestId set:
case "set-passphrase":
return { ...state, passphrase: event.value, encrypted: null, createError: null };
Sequence:
- Typing pauses ≥400 ms →
encrypt-started(requestId: 1) for the passphrase so far
- Another keystroke lands mid-flight →
encrypted: null, but requestId stays 1
pendingEncryptPassphrase (lines 119–125) returns null while state.requestId !== null, so the new character never schedules an encrypt
- The in-flight result resolves → the guard
if (event.requestId !== state.requestId) return state; passes (1 === 1) → encrypted is repopulated with the stale blob
download-clicked commits state.encrypted and blanks the field
The state is then permanently stuck: after step 4 requestId is null but encrypted is set, and pendingEncryptPassphrase returns null whenever encrypted is truthy — so it never re-encrypts, no matter how long the user waits. Waiting does not self-heal; only editing the passphrase again clears it.
The Rust side is not at fault. create_backup_blob (desktop/src-tauri/src/key_backup.rs:57-73) decrypt-verifies the fresh blob against the live pubkey before returning, so it correctly verified the password it was given — it just never received the last keystroke. Likewise the error string at key_backup.rs:111 is accurate; MAX_VERIFY_LOG_N emits a different message, so this is genuinely a password mismatch and not a KDF-cost or corruption path.
Suggested fix
Minimal — invalidate the in-flight request when the passphrase changes:
case "set-passphrase":
return { ...state, passphrase: event.value, encrypted: null, requestId: null, createError: null };
Worth considering more broadly: the speculative pre-encrypt exists only to make Download feel instant, and it buys a silent correctness bug in a key-backup path. Encrypting once, on click, from the field's current value cannot produce this class of failure and is what users assume is happening.
Suggested hardening
- Make Test your backup non-optional, or at minimum re-verify the committed blob against the passphrase currently in the input before writing the file to disk.
- The existing
verify_backup_blob call proves the blob matches the password passed in; it cannot detect that the password passed in was not the one the user saw. A UI-level assertion would close that gap.
Summary
The encrypted key backup can be written under a passphrase one keystroke behind the one shown in the input. The saved
.ncryptsecthen fails to restore with "wrong backup password or damaged key backup" even though the user enters exactly the string the UI displayed when they clicked Download.This is silent: nothing in the create flow reports a problem, and the Rust-side integrity check passes legitimately. The only thing that catches it is the optional Test your backup step. A user who skips that step gets a backup file that cannot be opened by the passphrase they believe they set — a permanent identity-loss risk.
Reproduced twice in a row on a fresh identity.
Environment
v0.5.4, built from source atce56e3441(aarch64-apple-darwin,--features mesh-llm)Steps to reproduce
!at the end ofSomePassphrase!)Expected: unlocks.
Actual: "wrong backup password or damaged key backup". The file is encrypted with the passphrase minus its final character.
Verified independently of the app with
nostr0.44.7 (features = ["nip49"]): parsing the saved blob and trying successive prefixes showslen Nrejected andlen N-1succeeding, recovering the correct pubkey.Root cause
desktop/src/features/onboarding/lib/encryptedBackup.ts(same defect in the settings copy).Encryption is speculative and debounced —
ENCRYPT_DEBOUNCE_MS = 400(EncryptedBackupCreator.tsx:64,EncryptedBackupProvider.tsx:16) — so a scrypt encrypt atlog_n = 18(~0.15–0.3 s on Apple Silicon) is typically already in flight while the user is still typing.set-passphraseclearsencryptedbut leavesrequestIdset:Sequence:
encrypt-started(requestId: 1)for the passphrase so farencrypted: null, butrequestIdstays1pendingEncryptPassphrase(lines 119–125) returnsnullwhilestate.requestId !== null, so the new character never schedules an encryptif (event.requestId !== state.requestId) return state;passes (1 === 1) →encryptedis repopulated with the stale blobdownload-clickedcommitsstate.encryptedand blanks the fieldThe state is then permanently stuck: after step 4
requestIdisnullbutencryptedis set, andpendingEncryptPassphrasereturnsnullwheneverencryptedis truthy — so it never re-encrypts, no matter how long the user waits. Waiting does not self-heal; only editing the passphrase again clears it.The Rust side is not at fault.
create_backup_blob(desktop/src-tauri/src/key_backup.rs:57-73) decrypt-verifies the fresh blob against the live pubkey before returning, so it correctly verified the password it was given — it just never received the last keystroke. Likewise the error string atkey_backup.rs:111is accurate;MAX_VERIFY_LOG_Nemits a different message, so this is genuinely a password mismatch and not a KDF-cost or corruption path.Suggested fix
Minimal — invalidate the in-flight request when the passphrase changes:
Worth considering more broadly: the speculative pre-encrypt exists only to make Download feel instant, and it buys a silent correctness bug in a key-backup path. Encrypting once, on click, from the field's current value cannot produce this class of failure and is what users assume is happening.
Suggested hardening
verify_backup_blobcall proves the blob matches the password passed in; it cannot detect that the password passed in was not the one the user saw. A UI-level assertion would close that gap.