Having the authentication password by the same as the encryption password by default but allowing the user to set a different encryption password might be the best of both worlds. The data is always going to be lost if the user forgets their encryption password (you can have them download and maybe print out a backup key as a safeguard against this), regardless of whether or not that encryption password is also the authentication password. However, changing the encryption password works fine; you decrypt the master key with the old password-derived key, derive a new key from the new password, re-encrypt the master key with the new password-derived key, and upload the re-encrypted master key back to the server).
One "gotcha" here is that, while client-side hashing is quite fast now (fast enough to make it practical to use a strong key-derivation/password-hashing function twice during login, once for authentication and once for the decryption key), it's hard to have an unpredictable salt. You can use a unique but predictable salt (such as the user's username), but that makes it easier to pre-compute attacks against a specific user or set of users. You can have salts served for arbitrary usernames without authentication, but that's also vulnerable to pre-computing against specific users.
Since the thing you really care about securing is the encrypted data, one option is to have the login flow look like this:
- Client takes user's password (for login, if they're different), salts it with their username, and applies a password-hashing function (such as scrypt or argon2).
- Client sends the username and hash output to the server as a login request.
- Assuming the user has an account on the server, server retrieves a securely random salt (unique to that user) from its database and re-hashes the client-supplied hash with the user's salt, again using a secure hash function (essentially stretching the work factor of the hash function somewhat).
- Server uses the re-hashed result to authenticate the user, ideally also performing a second-factor authentication check (such as via push notification, FIDO2, etc.).
- Assuming the user authenticates successfully, the server sends the client back the following info:
- The user's encrypted data blob.
- The user's encrypted master key.
- A second per-user salt, distinct from the one used for authentication, that is used to derive the decryption key.
- A flag telling the client whether to re-use the login password for deriving the decryption key, or request an additional password.
- The client takes the server-supplied salt plus the decryption key password (whether the same as the login password or not) and uses a password-hashing / key-derivation function on them, to produce the user's current password-derived key.
- The client uses the password-derived key, possibly in combination with another key (such as on a flashdrive), to decrypt the master key, then decrypts the encrypted blob using the master key.
- The client immediately purges the passwords and password-derived key from memory, after decrypting the master key.
9 The client can purge the encrypted master key, server-supplied salt, etc. from memory after using them to decrypt the master, or cache them (such as in local storage) for offline use (using only the decryption password, not the authentication password); whether or not such offline functionality is desirable (against the risk of storing the user's encrypted data and salt in more places) depends on the application and potentially the user.
- The client purges the decrypted master key and any decrypted parts of the blob, along with the authenticated session token, immediately upon logout / session expiration.
The advantage of this scheme is that no one or even two parts (assuming the passwords are different) is useful without the rest. Having both the login and decryption password (which might be the same!) without the second factor won't get you the blob or the stuff needed to decrypt it. Having the decryption password and blob isn't useful without the encrypted master key and the decryption salt. Having the authentication password and second factor (but not the decryption password, assuming it's different) gets you the blob but you can't decrypt it.
This also lets you rotate passwords easily (and even reset the authentication password without data loss, though resetting the encryption password prevents ever decrypting the old data). It easily supports adding additional authentication and/or decryption factors (hardware tokens, flash drives, U2F/FIDO devices, certificates, TOTP or push-based authenticator apps, etc.). It supports backup decryption keys and backup vault copies. It is practical with or without separate authentication and encryption passwords. It doesn't even require passwords at all; for example authentication could use TLS client certificates, and decryption could use a local keyfile or an encrypted key that must be decrypted with a private (asymmetric) key stored on the device or on a hardware token. Finally, because the authentication password is already hashed with a strong password hashing function before the server even sees it, the server can't figure out the user's password (even if the same password is used for decryption) without very expensive brute-forcing. An attacker could start brute-forcing the hashes for individual users, but even though the salt is predictable and probably not extremely long, it's unique for every user so this would be expensive in general.
With all that said... the CRITICAL weakness of such a system implemented as a web app is that there's no practical way for the user to know that the system will do the right thing each time! Because the client (that is, the JS that implements all of this) is downloaded from the server every time the app is used (at least, is used online), the user must either audit all the supplied JS every time, or just trust that there are no harmful additions (such as something that logs the user's passwords, or the decrypted master key, or so on). In other words, a malicious server admin could instantly and totally compromise somebody's account as soon as that user signed on.
To avoid that risk, you need to provide the user some way to run a client that they know is trustworthy and hasn't been changed since it was verified. Browser extensions can theoretically do this, but many browsers automatically update those (without letting you control the update behavior) now. Thick clients - either officially open-source or at least script-based so they're easy to analyze - are another option, but really don't work for a web app (and can be difficult to install a verified and non-automatically-updating version of on mobile).
Finally, consider that web apps come with all the web app security baggage. Because you're storing the secrets (decrypted master key, blob, etc.) in JS, an XSS vulnerability would compromise the user entirely. CSRF or clickjacking could delete the user's account off the server. Malicious browser extensions with sufficient privileges to inject scripts into the site could fully compromise the victim. Additionally, there are other risks from using a JS-based client. JS has a number of unfortunate limitations from a security perspective, such as no way to reliably zero memory or access OS- or hardware-based secure key storage. The language itself is geared more toward ease and speed of development rather than security (see everything about its typing system, "this" keyword, etc.), which makes it harder to catch certain bugs. You don't really get to pick the crypto implementation either - even if you send your own primitives instead of relying on whatever the browser provides, it'll run on the user's JS engine through a JIT compiler that you can't control - so the risk of side channels is going to be higher than normal.