Files
new-api/common/password_crypto.go
T
2026-08-29 20:11:36 +08:00

116 lines
3.8 KiB
Go

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
}