Skip to content

Conversation

@cedwies
Copy link
Collaborator

@cedwies cedwies commented Nov 18, 2025

Implements a new API endpoint to allow users to change their device password without re-entering the BIP39 passphrase.

Changes:

  • Add ChangePasswordRequest message to proto definitions
  • Implement change_password module in Rust API
  • Refactor keystore to support password re-encryption:
    • Add re_encrypt_seed() function to change password while preserving BIP39 seed state
    • Extract encrypt_and_store_seed_internal() helper to share logic between initial setup and password change
  • Add Python bindings for change_password workflow
  • Add comprehensive tests including password verification
  • Fix minor formatting issue in qtouch.c (clang-format)
  • Regenerated protobuf files

The password change workflow:

  1. Unlock device with current password
  2. Prompt for new password (entered twice for confirmation)
  3. Re-encrypt seed with new password while preserving BIP39 seed

This prevents users from having to re-enter their BIP39 passphrase after changing their password.

@cedwies cedwies marked this pull request as draft November 18, 2025 22:52
@cedwies cedwies force-pushed the cedwies/change-password branch 4 times, most recently from 1a3e636 to e119797 Compare November 19, 2025 08:50
@cedwies cedwies marked this pull request as ready for review November 19, 2025 08:59
@cedwies cedwies requested review from NickeZ and benma and removed request for NickeZ November 19, 2025 09:00
@benma benma removed the request for review from NickeZ November 19, 2025 09:10
Copy link
Collaborator

@benma benma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice job! Haven't tested it yet, just left some initial comments.

Comment on lines 217 to 219
Returns True if the user changes password successfully by
unlocking with old password and entering and confirming new password.
Returns False otherwise.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe better to simply return nothing on success and let the exception propagate on failure.

As right now, it returns False if they enter the wrong password, and an exception if cancelled or if there is another kind of error. Seems simpler to just do the same in all cases.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest a similar approach compared to show_mnemonic:

    def change_password(self) -> bool:
        """
        Returns True if the user changes password successfully by
        unlocking with old password and entering and confirming new password.
        Raises a Bitbox02Exception on failure.
        """
        # pylint: disable=no-member
        request = hww.Request()
        request.change_password.CopyFrom(bitbox02_system.ChangePasswordRequest())
        self._msg_query(request, expected_response="success")

matches!(state, State::Uninitialized | State::Seeded)
}
Request::CreateBackup(_) | Request::ShowMnemonic(_) => {
Request::CreateBackup(_) | Request::ShowMnemonic(_) | Request::ChangePassword(_) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should probably be in the arm below where it's only InitializedAndUnlocked. Here you also allow the call if it is not initialized (but seeded).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you mentioned already:

In this case, is_seeded() is enough, because then there is an encrypted seed whose password we can change.

So we should be able to call ChangePassword when seeded, no?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically I guess you are right, but practically we will expose the functionality in the BitBoxApp under 'Manage device' after it's been initialized. I don't think we'll add a change password function right after setting the password, before creating a backup, in the setup wizard. If one wants a new password then, just reconnecting the device will do the trick too, as then the setup starts from scratch.

Long story short, imho you can enforce that it is initialized and unlocked here.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As for

In this case, is_seeded() is enough, be

That's true, in that case you could leave the check that it is initialized. It does not matter that much, as long as it's seeded and unlocked the function works (so is_seeded() is the minimum check). To me, it makes a bit more sense to keep the minimum check (seeded) in the keystore module, while doing the more strict check in the API here to decide when the API call can be made. No strong opinion though.

Comment on lines 11 to 14
// Must be initialized
if !bitbox02::memory::is_initialized() {
return Err(Error::Generic);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed if in api.rs you move the check in can_call so that it can only be called if initialized and unlocked.


// Re-encrypt seed with new password
if keystore::re_encrypt_seed(hal, &seed, &new_password).is_err() {
hal.ui().status("Could not change\npassword", false).await;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Elsewhere we use

hal.ui().status(&format!("Error\n{:?}", err), false).await;

Maybe that's better, it's not expected to ever be shown, but if it is, there will be some more info.

// 1. The secure chip's internal keys are regenerated with the new password
// 2. encrypt_and_store_seed_internal calls lock() which clears RAM including BIP39 seed
// 3. We want to avoid forcing the user to re-enter their BIP39 passphrase
let bip39_seed = copy_bip39_seed().map_err(|_| Error::CannotUnlockBIP39)?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong error, we are not in the process of unlocking bip39.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'd suggest Error::Memory (?)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other funcs we just use copy_bip39_seed()?, which propagates as Generic error.

Comment on lines 266 to 270
RETAINED_BIP39_SEED.write(Some(RetainedEncryptedBuffer::from_buffer(
hal.random(),
bip39_seed.as_slice(),
"keystore_retained_bip39_seed_access",
)?));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Best to make a retain_bip39_seed function similar to retain_seed

}

/// Re-encrypts the seed with a (new) password
pub fn re_encrypt_seed(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to add some unit tests in this file too for this new function.

if bitbox02::memory::is_initialized() {
return Err(Error::Memory);
}
// We are in setup phase, so we clear memory first to guarantee clean RAM
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does that mean?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, was an outdated comment. In an earlier approach I only called lock() in encrypt_and_store_seed(...). But I later moved it it into encrypt_and_store_seed_internal(...) so RAM is cleared both in encrypt_and_store_seed(...) and in re_encrypt_seed(...)

Comment on lines 253 to 255
if !bitbox02::memory::is_initialized() {
return Err(Error::Unseeded);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fyi, seeded means an encrypted seed is stored, and initialized means it is seeded and a backup has been created.

In this case, is_seeded() is enough, because then there is an encrypted seed whose password we can change. And the Unseeded error will be correct too then 😄

Implements a new API endpoint to allow users to change their device
password without re-entering the BIP39 passphrase.

Changes:
- Add ChangePasswordRequest message to proto definitions
- Implement change_password module in Rust API
- Refactor keystore to support password re-encryption:
  - Add re_encrypt_seed() function to change password while preserving
    BIP39 seed state
  - Extract encrypt_and_store_seed_internal() helper to share logic
    between initial setup and password change
- Add Python bindings for change_password workflow
- Add comprehensive tests including password verification
- Regenerated protobuf files

The password change workflow:
1. Unlock device with current password
2. Prompt for new password (entered twice for confirmation)
3. Re-encrypt seed with new password while preserving BIP39 seed

This prevents users from having to re-enter their BIP39 passphrase
after changing their password.
@cedwies cedwies force-pushed the cedwies/change-password branch 3 times, most recently from 4fa4c53 to 256a514 Compare November 20, 2025 11:49
- Modified change_password in bitbox02.py to raise BitBox02Exception on failure
- The api call is now only allowed if the device is initialized and unlocked
- Extra check in removed in process(...) in change_password.rs
- Changed ui status to show more info when re_encrypt_seed fails
- corrected error type in re_encrypt_seed(...) in keystore.rs
- Added retain_bip39_seed(...) to re-use code
- Added unit tests for re_encrypt_seed(...) and retain_bip39_seed(...)
- Removed outdated comment
- Changed check in re_encrypt_seed(...) to is_seeded() instead of is_initialzed()
- Adapted change_password.rs due to merge conflict
- Bumped firmware version to v9.25.0
@cedwies cedwies force-pushed the cedwies/change-password branch from 256a514 to 8e48d8d Compare November 20, 2025 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants