feat(auth): support per-deploy login password key override
Multi-replica deployments need every Pod/container to share one RSA private key, otherwise public keys handed to the browser may not match the key that decrypts the resulting ciphertext. Introduce a `*.local.yaml` override layer that ops-config and tenant-config both load on top of the base config, ship example overrides plus compose/k3s wiring, and tolerate PEM bodies that arrive folded or escaped from Secrets.
This commit is contained in:
@@ -268,10 +268,12 @@ func RestartRequired(changes []FieldChange) bool {
|
||||
}
|
||||
|
||||
func load(path string) (*Config, error) {
|
||||
resolved := configruntime.New(configruntime.WithSource(
|
||||
runtimefile.NewSource(path),
|
||||
runtimeenv.NewSource("OPS"),
|
||||
))
|
||||
sources := []configruntime.Source{runtimefile.NewSource(path)}
|
||||
if localPath := localOverridePath(path); localPath != "" {
|
||||
sources = append(sources, runtimefile.NewSource(localPath))
|
||||
}
|
||||
sources = append(sources, runtimeenv.NewSource("OPS"))
|
||||
resolved := configruntime.New(configruntime.WithSource(sources...))
|
||||
defer resolved.Close()
|
||||
|
||||
if err := resolved.Load(); err != nil {
|
||||
@@ -571,12 +573,37 @@ func watchFiles(path string) []string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{path}
|
||||
files := []string{path}
|
||||
if localPath := localOverridePath(path); localPath != "" {
|
||||
files = append(files, localPath)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func watchNames(path string) []string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{filepath.Base(path)}
|
||||
names := []string{filepath.Base(path)}
|
||||
if localPath := localOverridePath(path); localPath != "" {
|
||||
names = append(names, filepath.Base(localPath))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func localOverridePath(path string) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
ext := filepath.Ext(trimmed)
|
||||
base := strings.TrimSuffix(trimmed, ext)
|
||||
if strings.HasSuffix(base, ".local") {
|
||||
return ""
|
||||
}
|
||||
localPath := base + ".local" + ext
|
||||
if _, err := os.Stat(localPath); err != nil {
|
||||
return ""
|
||||
}
|
||||
return localPath
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func (c *PasswordCipher) Decrypt(ciphertextB64, keyID string) (string, error) {
|
||||
}
|
||||
|
||||
func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
trimmed := normalizePrivateKeyPEM(value)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("password cipher private key is required")
|
||||
}
|
||||
@@ -126,3 +126,48 @@ func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
|
||||
return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePrivateKeyPEM(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
normalized := strings.ReplaceAll(trimmed, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, `\n`, "\n")
|
||||
if block, _ := pem.Decode([]byte(normalized)); block != nil {
|
||||
return normalized
|
||||
}
|
||||
|
||||
for _, blockType := range []string{"RSA PRIVATE KEY", "PRIVATE KEY"} {
|
||||
begin := "-----BEGIN " + blockType + "-----"
|
||||
end := "-----END " + blockType + "-----"
|
||||
start := strings.Index(normalized, begin)
|
||||
stop := strings.Index(normalized, end)
|
||||
if start < 0 || stop <= start {
|
||||
continue
|
||||
}
|
||||
body := normalized[start+len(begin) : stop]
|
||||
body = strings.Join(strings.Fields(body), "")
|
||||
if body == "" {
|
||||
return normalized
|
||||
}
|
||||
return begin + "\n" + wrapBase64Lines(body, 64) + "\n" + end + "\n"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func wrapBase64Lines(value string, width int) string {
|
||||
if width <= 0 || len(value) <= width {
|
||||
return value
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(value) + len(value)/width)
|
||||
for len(value) > width {
|
||||
b.WriteString(value[:width])
|
||||
b.WriteByte('\n')
|
||||
value = value[width:]
|
||||
}
|
||||
b.WriteString(value)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -62,3 +63,53 @@ func TestNewEphemeralPasswordCipherExposesPublicKey(t *testing.T) {
|
||||
require.Equal(t, "RSA-OAEP-256", publicKey.Algorithm)
|
||||
require.Contains(t, publicKey.PublicKey, "BEGIN PUBLIC KEY")
|
||||
}
|
||||
|
||||
func TestPasswordCipherAcceptsFoldedPrivateKeyPEM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: mustMarshalPKCS8PrivateKey(t, key),
|
||||
}))
|
||||
foldedPEM := strings.Join(strings.Fields(privatePEM), " ")
|
||||
|
||||
_, err = NewPasswordCipher(foldedPEM, "folded")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPasswordCipherAcceptsEscapedNewlinePrivateKeyPEM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: mustMarshalPKCS8PrivateKey(t, key),
|
||||
}))
|
||||
escapedPEM := strings.ReplaceAll(strings.TrimSpace(privatePEM), "\n", `\n`)
|
||||
|
||||
_, err = NewPasswordCipher(escapedPEM, "escaped")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNormalizePrivateKeyPEMPreservesValidPEM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: mustMarshalPKCS8PrivateKey(t, key),
|
||||
}))
|
||||
|
||||
require.Equal(t, strings.TrimSpace(privatePEM), strings.TrimSpace(normalizePrivateKeyPEM(privatePEM)))
|
||||
}
|
||||
|
||||
func mustMarshalPKCS8PrivateKey(t *testing.T, key *rsa.PrivateKey) []byte {
|
||||
t.Helper()
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
return der
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user