Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015a8P6WMRPeqdpd5VCh6jAy
159 lines
4.6 KiB
Go
159 lines
4.6 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 token secrets 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 token secrets, 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 token secrets 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
|
|
}
|
|
|
|
// RandomPassword returns a password for the first admin account.
|
|
func RandomPassword() string {
|
|
b := make([]byte, 12)
|
|
_, _ = rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|