Skip to content

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

Description

@PROACTIVA-US

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 keyreview backup optionsCreate 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:

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:

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions