fix: harden login for MLPS requirements

This commit is contained in:
2026-05-14 11:05:20 +08:00
parent ecf3ee1ef0
commit 879677516f
39 changed files with 1230 additions and 71 deletions
@@ -0,0 +1,128 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"strings"
"sync"
)
const DefaultPasswordCipherKeyID = "login-password-rsa-oaep-v1"
type PasswordCipher struct {
mu sync.RWMutex
privateKey *rsa.PrivateKey
keyID string
publicPEM string
}
type PasswordCipherPublicKey struct {
KeyID string `json:"key_id"`
Algorithm string `json:"algorithm"`
PublicKey string `json:"public_key"`
}
func NewPasswordCipher(privateKeyPEM, keyID string) (*PasswordCipher, error) {
privateKey, err := parseRSAPrivateKeyPEM(privateKeyPEM)
if err != nil {
return nil, err
}
return newPasswordCipherFromKey(privateKey, keyID)
}
func NewEphemeralPasswordCipher(keyID string) (*PasswordCipher, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generate ephemeral rsa private key: %w", err)
}
return newPasswordCipherFromKey(privateKey, keyID)
}
func newPasswordCipherFromKey(privateKey *rsa.PrivateKey, keyID string) (*PasswordCipher, error) {
if strings.TrimSpace(keyID) == "" {
keyID = DefaultPasswordCipherKeyID
}
publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshal rsa public key: %w", err)
}
publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
return &PasswordCipher{
privateKey: privateKey,
keyID: keyID,
publicPEM: publicPEM,
}, nil
}
func (c *PasswordCipher) PublicKey() PasswordCipherPublicKey {
if c == nil {
return PasswordCipherPublicKey{}
}
c.mu.RLock()
defer c.mu.RUnlock()
return PasswordCipherPublicKey{
KeyID: c.keyID,
Algorithm: "RSA-OAEP-256",
PublicKey: c.publicPEM,
}
}
func (c *PasswordCipher) Decrypt(ciphertextB64, keyID string) (string, error) {
if c == nil {
return "", fmt.Errorf("password cipher is not configured")
}
c.mu.RLock()
privateKey := c.privateKey
expectedKeyID := c.keyID
c.mu.RUnlock()
if strings.TrimSpace(keyID) != "" && strings.TrimSpace(keyID) != expectedKeyID {
return "", fmt.Errorf("password cipher key id mismatch")
}
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(ciphertextB64))
if err != nil {
return "", fmt.Errorf("decode encrypted password: %w", err)
}
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decrypt encrypted password: %w", err)
}
return string(plaintext), nil
}
func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, fmt.Errorf("password cipher private key is required")
}
block, _ := pem.Decode([]byte(trimmed))
if block == nil {
return nil, fmt.Errorf("password cipher private key is not PEM encoded")
}
switch block.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse pkcs1 rsa private key: %w", err)
}
return key, nil
case "PRIVATE KEY":
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse pkcs8 private key: %w", err)
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not RSA")
}
return rsaKey, nil
default:
return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type)
}
}
@@ -0,0 +1,64 @@
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"testing"
"github.com/stretchr/testify/require"
)
func TestPasswordCipherDecryptsRSAOAEP256(t *testing.T) {
t.Parallel()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
privatePEM := string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}))
cipher, err := NewPasswordCipher(privatePEM, "test-key")
require.NoError(t, err)
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Admin@123"), nil)
require.NoError(t, err)
got, err := cipher.Decrypt(base64.StdEncoding.EncodeToString(encrypted), "test-key")
require.NoError(t, err)
require.Equal(t, "Admin@123", got)
}
func TestPasswordCipherRejectsWrongKeyID(t *testing.T) {
t.Parallel()
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
privatePEM := string(pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}))
cipher, err := NewPasswordCipher(privatePEM, "expected")
require.NoError(t, err)
_, err = cipher.Decrypt("bad", "other")
require.Error(t, err)
require.Contains(t, err.Error(), "key id mismatch")
}
func TestNewEphemeralPasswordCipherExposesPublicKey(t *testing.T) {
t.Parallel()
cipher, err := NewEphemeralPasswordCipher("ephemeral")
require.NoError(t, err)
publicKey := cipher.PublicKey()
require.Equal(t, "ephemeral", publicKey.KeyID)
require.Equal(t, "RSA-OAEP-256", publicKey.Algorithm)
require.Contains(t, publicKey.PublicKey, "BEGIN PUBLIC KEY")
}