This is an excellent question, and one of the motivations for OPAQUE, as explained in the Introduction section. Unfortunately, PAKEs are still rarely used as far as I know, so this issue has sort of just been accepted.
Would sending a SHA to the server be better, so that we at least never
see the password server-side, even briefly?
Yes, it's a slight improvement from that perspective. However, hashes for weak passwords will be well known and easy to find, so it doesn't solve the issue completely if such hashes are logged.
What would a migration path look like, beyond changing this and
resetting all passwords, requiring users to create new passwords whose
bcrypt is now based on a SHA?
The other dilemma is that pre-hashing is particularly problematic with bcrypt and thus often not recommended.
- Unsalted/unpeppered hashes can allow shucking attacks. This is an argument against pre-hashing beyond just bcrypt.
- Null bytes from pre-hashing can lead to colliding passwords.
- Some implementations reportedly can't handle binary inputs properly.
Solutions include Base64 or hex encoding the hash to address points 2 and 3, not pre-hashing, or using hmac-bcrypt, which is designed to address all of these problems whilst performing pre-hashing. You could replicate the hmac-bcrypt pre-hashing approach.
Any other ideas?
You could use public-key cryptography without needing to rely on a PAKE. This is easier to implement but less secure because it doesn't prevent precomputation. The idea is explained nicely in a blog post by Frank Denis, author of the libsodium and LibHydrogen cryptographic libraries.
In simplified terms (please see the blog post for the full details), it goes like this:
- Do client-side password-based key derivation (not bcrypt, which isn't a KDF) to generate a deterministic seed. Use
context string || username
as the salt, or you could get the server to send a salt after the user provides their username.
- Generate a key pair on the client from the seed.
- The server sends a random 256-bit nonce to the client.
- The client computes a signature over
context string || username || nonce
and sends it to the server alongside their public key and username.
- The server verifies the signature using the received information.
Now passwords don't need to be sent to the server, and there's no server-side password hashing DoS risk. However, passkeys are hopefully the future.