Encrypted key backup can be saved under a passphrase one keystroke behind the UI (silent, restore fails)
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## 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
1. Fresh onboarding → **Create a new identity key** → **review backup options** → **Create locked backup**
2. Type a passphrase, but **hesitate slightly before the final character** (e.g. reaching for Shift to type `!` at the end of `SomePassphrase!`)
3. Click **Download backup** and save the file
4. 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**:
```ts
case "set-passphrase":
return { ...state, passphrase: event.value, encrypted: null, createError: null };
```
Sequence:
1. Typing pauses ≥400 ms → `encrypt-started(requestId: 1)` for the passphrase so far
2. Another keystroke lands mid-flight → `encrypted: null`, but `requestId` stays `1`
3. `pendingEncryptPassphrase` (lines 119–125) returns `null` while `state.requestId !== null`, so **the new character never schedules an encrypt**
4. The in-flight result resolves → the guard `if (event.requestId !== state.requestId) return state;` **passes** (`1 === 1`) → `encrypted` is repopulated with the *stale* blob
5. `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:
```ts
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.
Contributor guide
Research direction
Start with the reducer and pendingEncryptPassphrase in desktop/src/features/onboarding/lib/encryptedBackup.ts, then compare the debounce and provider flow in EncryptedBackupCreator.tsx and EncryptedBackupProvider.tsx. Reproduce the final-keystroke sequence and verify that Download produces a blob restorable with the displayed passphrase; check the settings copy for the same behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, typescript
- Domain
- desktop, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 75/100