Files
2026-10-23 17:43:20 +02:00

152 lines
4.4 KiB
Go

package store
import (
"crypto/aes"
"crypto/cipher"
"crypto/pbkdf2"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
)
// Box encrypts the cluster passwords at rest (AES-256-GCM, random nonce, version byte).
type Box struct{ aead cipher.AEAD }
const boxVersion = 1
// NewBox builds a Box from a 32 byte key.
func NewBox(key []byte) (*Box, error) {
if len(key) != 32 {
return nil, fmt.Errorf("encryption key: %d bytes, 32 expected", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
return &Box{aead}, nil
}
// Seal encrypts a secret: version | nonce | ciphertext+tag.
func (b *Box) Seal(plain []byte) ([]byte, error) {
nonce := make([]byte, b.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
out := append([]byte{boxVersion}, nonce...)
return b.aead.Seal(out, nonce, plain, []byte("pve-confsync token")), nil
}
// Open decrypts a secret sealed by Seal. An empty blob gives an empty secret.
func (b *Box) Open(blob []byte) ([]byte, error) {
if len(blob) == 0 {
return nil, nil
}
n := b.aead.NonceSize()
if blob[0] != boxVersion || len(blob) < 1+n {
return nil, errors.New("unknown secret format")
}
plain, err := b.aead.Open(nil, blob[1:1+n], blob[1+n:], []byte("pve-confsync token"))
if err != nil {
return nil, errors.New("cannot decrypt (wrong encryption key)")
}
return plain, nil
}
// LoadKey returns the encryption key of the cluster passwords, in this order:
// CONFSYNC_SECRET_KEY (64 hex characters or 44 base64 characters),
// CONFSYNC_SECRET_KEY_FILE (same content, docker secret), or <dataDir>/secret.key,
// created on first start. The last case keeps the key next to the database: fine for
// a lab, a docker secret is better in production (a stolen /data then holds no
// usable secret).
func LoadKey(dataDir string) ([]byte, string, error) {
if v := strings.TrimSpace(os.Getenv("CONFSYNC_SECRET_KEY")); v != "" {
k, err := parseKey(v)
return k, "CONFSYNC_SECRET_KEY", err
}
if f := os.Getenv("CONFSYNC_SECRET_KEY_FILE"); f != "" {
raw, err := os.ReadFile(f)
if err != nil {
return nil, "", fmt.Errorf("CONFSYNC_SECRET_KEY_FILE: %w", err)
}
k, err := parseKey(strings.TrimSpace(string(raw)))
return k, f, err
}
path := filepath.Join(dataDir, "secret.key")
if raw, err := os.ReadFile(path); err == nil {
k, err := parseKey(strings.TrimSpace(string(raw)))
return k, path, err
}
k := make([]byte, 32)
if _, err := rand.Read(k); err != nil {
return nil, "", err
}
if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil {
return nil, "", fmt.Errorf("cannot save the encryption key: %w", err)
}
log.Printf("encryption key created in %s: keep a copy, the cluster passwords cannot be read without it", path)
return k, path, nil
}
func parseKey(v string) ([]byte, error) {
if k, err := hex.DecodeString(v); err == nil && len(k) == 32 {
return k, nil
}
if k, err := base64.StdEncoding.DecodeString(v); err == nil && len(k) == 32 {
return k, nil
}
return nil, errors.New("encryption key: 32 bytes expected, as 64 hex or 44 base64 characters (openssl rand -hex 32)")
}
// ---- passwords ----
// PBKDF2-HMAC-SHA256, 600 000 iterations (OWASP 2023 recommendation); the
// standard library only, crypto/pbkdf2 exists since Go 1.24.
const pbkdf2Iter = 600000
// HashPassword returns "pbkdf2-sha256$<iter>$<salt>$<hash>".
func HashPassword(pass string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
h, err := pbkdf2.Key(sha256.New, pass, salt, pbkdf2Iter, 32)
if err != nil {
return "", err
}
enc := base64.RawStdEncoding
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", pbkdf2Iter, enc.EncodeToString(salt), enc.EncodeToString(h)), nil
}
// CheckPassword compares in constant time.
func CheckPassword(hash, pass string) bool {
p := strings.Split(hash, "$")
if len(p) != 4 || p[0] != "pbkdf2-sha256" {
return false
}
iter, err := strconv.Atoi(p[1])
if err != nil || iter < 1 {
return false
}
enc := base64.RawStdEncoding
salt, err1 := enc.DecodeString(p[2])
want, err2 := enc.DecodeString(p[3])
if err1 != nil || err2 != nil {
return false
}
got, err := pbkdf2.Key(sha256.New, pass, salt, iter, len(want))
return err == nil && subtle.ConstantTimeCompare(got, want) == 1
}