package common import ( "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/hex" "encoding/pem" "errors" "fmt" "strings" "sync" ) 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 decrypts a base64 RSA-OAEP/SHA-256 password submitted by a // browser. All malformed inputs share one error so callers do not expose // cryptographic details to unauthenticated clients. 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 } 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 }