Smail/server/config/secret.go

179 lines
4.6 KiB
Go

package config
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.pcux.cn/xuans/Smail/utils/credential"
)
type SecretOperation string
const (
SecretKeep SecretOperation = "keep"
SecretReplace SecretOperation = "replace"
SecretClear SecretOperation = "clear"
)
var (
ErrInvalidSecretChange = errors.New("invalid secret change")
ErrSecretClearDenied = errors.New("secret clear is not allowed")
ErrSecretUnavailable = errors.New("secret is unavailable")
)
type SecretInput struct {
Plaintext string `json:"plaintext"`
}
func (s SecretInput) Reveal() string {
return s.Plaintext
}
func (s SecretInput) String() string {
return "[REDACTED]"
}
func (s SecretInput) GoString() string {
return "[REDACTED]"
}
func (s SecretInput) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Plaintext string `json:"plaintext"`
}{Plaintext: "[REDACTED]"})
}
type SecretChange struct {
Operation SecretOperation `json:"operation"`
Value *SecretInput `json:"value,omitempty"`
Confirmed bool `json:"confirmed,omitempty"`
}
type StoredSecret struct {
Ciphertext string `json:"ciphertext,omitempty"`
Version uint32 `json:"version,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
}
func (s StoredSecret) Configured() bool {
return s.Ciphertext != "" && s.Version != 0 && !s.UpdatedAt.IsZero()
}
type SecretStatus struct {
Configured bool `json:"configured"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
Hint string `json:"hint,omitempty"`
Version uint32 `json:"version,omitempty"`
}
type SecretPolicy struct {
Required bool
AllowClear bool
MaxLength int
}
func (s StoredSecret) Status() SecretStatus {
if !s.Configured() {
return SecretStatus{}
}
return SecretStatus{
Configured: true,
UpdatedAt: s.UpdatedAt,
Hint: "******",
Version: s.Version,
}
}
func ApplySecretChange(vault *credential.Vault, current StoredSecret, change SecretChange, policy SecretPolicy, now time.Time) (StoredSecret, error) {
if vault == nil || policy.MaxLength <= 0 {
return StoredSecret{}, ErrInvalidSecretChange
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
switch change.Operation {
case SecretKeep:
if change.Value != nil || change.Confirmed {
return StoredSecret{}, ErrInvalidSecretChange
}
if err := ValidateStoredSecret(current, policy.Required); err != nil {
return StoredSecret{}, ErrSecretUnavailable
}
return current, nil
case SecretReplace:
if change.Value == nil || change.Confirmed {
return StoredSecret{}, ErrInvalidSecretChange
}
plaintext, err := credential.ValidateSecret(change.Value.Reveal(), policy.MaxLength)
if err != nil {
return StoredSecret{}, ErrInvalidSecretChange
}
ciphertext, err := vault.Encrypt(plaintext)
if err != nil {
return StoredSecret{}, ErrSecretUnavailable
}
return StoredSecret{Ciphertext: ciphertext, Version: vault.Version(), UpdatedAt: now}, nil
case SecretClear:
if change.Value != nil || !change.Confirmed {
return StoredSecret{}, ErrInvalidSecretChange
}
if policy.Required || !policy.AllowClear {
return StoredSecret{}, ErrSecretClearDenied
}
return StoredSecret{}, nil
default:
return StoredSecret{}, ErrInvalidSecretChange
}
}
func OpenStoredSecret(vault *credential.Vault, stored StoredSecret) (string, error) {
if vault == nil || !stored.Configured() || strings.TrimSpace(stored.Ciphertext) != stored.Ciphertext {
return "", ErrSecretUnavailable
}
plaintext, err := vault.Decrypt(stored.Version, stored.Ciphertext)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrSecretUnavailable, err)
}
return plaintext, nil
}
// ValidateRuntimeSecrets 验证运行时配置中的持久化 Secret 可以由当前密钥解密。
func ValidateRuntimeSecrets(vault *credential.Vault, runtime RuntimeConfig) error {
for _, stored := range []StoredSecret{runtime.Root.Database.Password, runtime.SMTP.Relay.Password} {
if !stored.Configured() {
continue
}
if _, err := OpenStoredSecret(vault, stored); err != nil {
return err
}
}
for _, plugin := range runtime.Plugins.Plugins {
if !plugin.Secret.Configured() {
continue
}
if _, err := OpenStoredSecret(vault, plugin.Secret); err != nil {
return err
}
}
return nil
}
func ValidateStoredSecret(stored StoredSecret, required bool) error {
if !stored.Configured() {
if stored.Ciphertext != "" || stored.Version != 0 || !stored.UpdatedAt.IsZero() || required {
return ErrSecretUnavailable
}
return nil
}
if stored.Version != credential.CurrentVersion || !credential.IsEncrypted(stored.Ciphertext) {
return ErrSecretUnavailable
}
return nil
}