158 lines
5.3 KiB
Go
158 lines
5.3 KiB
Go
package common
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const passwordEncryptionKeyBits = 2048
|
|
|
|
var ErrPasswordEncryptionInvalid = errors.New("password encryption payload is invalid")
|
|
|
|
var passwordEncryptionState struct {
|
|
sync.RWMutex
|
|
privateKey *rsa.PrivateKey
|
|
publicKey string
|
|
keyID string
|
|
}
|
|
|
|
// GeneratePasswordEncryptionPrivateKey creates the server key used to decrypt
|
|
// browser login passwords. The caller is responsible for persisting the PEM.
|
|
func GeneratePasswordEncryptionPrivateKey() (string, error) {
|
|
privateKey, err := rsa.GenerateKey(rand.Reader, passwordEncryptionKeyBits)
|
|
if err != nil {
|
|
return "", fmt.Errorf("generate password encryption key: %w", err)
|
|
}
|
|
privateKeyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("marshal password encryption key: %w", err)
|
|
}
|
|
return string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "PRIVATE KEY",
|
|
Bytes: privateKeyDER,
|
|
})), nil
|
|
}
|
|
|
|
// LoadPasswordEncryptionPrivateKey validates a persisted key before replacing
|
|
// the active in-memory key used by request handlers.
|
|
func LoadPasswordEncryptionPrivateKey(privateKeyPEM string) error {
|
|
block, rest := pem.Decode([]byte(privateKeyPEM))
|
|
if block == nil || block.Type != "PRIVATE KEY" || strings.TrimSpace(string(rest)) != "" {
|
|
return errors.New("password encryption key is not valid PKCS#8 PEM")
|
|
}
|
|
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return fmt.Errorf("parse password encryption key: %w", err)
|
|
}
|
|
privateKey, ok := parsed.(*rsa.PrivateKey)
|
|
if !ok {
|
|
return errors.New("password encryption key is not RSA")
|
|
}
|
|
if privateKey.N == nil || privateKey.N.BitLen() < passwordEncryptionKeyBits {
|
|
return fmt.Errorf("password encryption key must be at least %d bits", passwordEncryptionKeyBits)
|
|
}
|
|
if err := privateKey.Validate(); err != nil {
|
|
return fmt.Errorf("validate password encryption key: %w", err)
|
|
}
|
|
privateKey.Precompute()
|
|
|
|
publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal password encryption public key: %w", err)
|
|
}
|
|
publicKeyPEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "PUBLIC KEY",
|
|
Bytes: publicKeyDER,
|
|
}))
|
|
keyDigest := sha256.Sum256(publicKeyDER)
|
|
keyID := hex.EncodeToString(keyDigest[:16])
|
|
|
|
passwordEncryptionState.Lock()
|
|
defer passwordEncryptionState.Unlock()
|
|
passwordEncryptionState.privateKey = privateKey
|
|
passwordEncryptionState.publicKey = publicKeyPEM
|
|
passwordEncryptionState.keyID = keyID
|
|
return nil
|
|
}
|
|
|
|
// PasswordEncryptionPublicKey returns the active key identifier and SPKI PEM
|
|
// public key exposed to browser clients.
|
|
func PasswordEncryptionPublicKey() (keyID string, publicKeyPEM string) {
|
|
passwordEncryptionState.RLock()
|
|
defer passwordEncryptionState.RUnlock()
|
|
return passwordEncryptionState.keyID, passwordEncryptionState.publicKey
|
|
}
|
|
|
|
// DecryptPassword accepts legacy RSA-OAEP/SHA-256 ciphertext and v2 envelopes.
|
|
// V2 wraps a fresh AES-256 key with RSA-OAEP and encrypts the password with GCM,
|
|
// allowing long Unicode passwords to work with existing 2048-bit server keys.
|
|
// Both formats share one public error for all malformed inputs.
|
|
func DecryptPassword(ciphertextBase64 string, keyID string) (string, error) {
|
|
passwordEncryptionState.RLock()
|
|
privateKey := passwordEncryptionState.privateKey
|
|
activeKeyID := passwordEncryptionState.keyID
|
|
passwordEncryptionState.RUnlock()
|
|
if privateKey == nil || keyID == "" || keyID != activeKeyID {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
if strings.HasPrefix(ciphertextBase64, "v2.") {
|
|
if len(ciphertextBase64) > 4096 {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
parts := strings.Split(ciphertextBase64, ".")
|
|
if len(parts) != 4 {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
wrappedKey, err := base64.StdEncoding.Strict().DecodeString(parts[1])
|
|
if err != nil || len(wrappedKey) != privateKey.Size() {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
nonce, err := base64.StdEncoding.Strict().DecodeString(parts[2])
|
|
if err != nil || len(nonce) != 12 {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
ciphertext, err := base64.StdEncoding.Strict().DecodeString(parts[3])
|
|
if err != nil || len(ciphertext) <= 16 || len(ciphertext) > MaxAccountPasswordLength*utf8.UTFMax+16 {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
key, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, wrappedKey, []byte("password-v2"))
|
|
if err != nil || len(key) != 32 {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, []byte("password-v2:"+keyID))
|
|
if err != nil {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
return string(plaintext), nil
|
|
}
|
|
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
|
|
if err != nil || len(ciphertext) != privateKey.Size() {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil)
|
|
if err != nil || len(plaintext) == 0 {
|
|
return "", ErrPasswordEncryptionInvalid
|
|
}
|
|
return string(plaintext), nil
|
|
}
|