block / block/buzz

Encrypted key backup can be saved under a passphrase one keystroke behind the UI (silent, restore fails)

Open
#4,619 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.