5301b47f16
Stored credential secrets are encrypted with a key derived from ORCHESTRAD_SECRET_KEY. When that value changes, the secrets are intact but unreadable, and the only symptom was an opaque "decryption failed" surfacing deep inside an unrelated operation: "preview failed: building LDAP client: failed to decrypt credential: decryption failed" Nothing pointed at the real cause, so the error is now self-diagnosing: - ErrDecryptionFailed states that the data was encrypted under a different ORCHESTRAD_SECRET_KEY (or is corrupted). GCM auth failure on a well-formed ciphertext is overwhelmingly a wrong-key case. - The three credential decrypt sites name the credential, so the operator knows which password to restore or re-enter. - New services.CheckSecretKey verifies every stored secret against the current key. It runs at startup (LogSecretKeyCheck) and in `doctor`, so a mismatched key is reported once, loudly, at the moment it is first used rather than during the next rule run. A correctly-sized but *different* key passed doctor's existing length check and still broke every bind. Not fatal: the server still starts, since an operator may be mid-migration or may intend to re-enter the secrets. Verified on the demo instance: starting with a wrong key logs "1 of 1 stored credential secret(s) CANNOT be decrypted ... [OrchestrAD]", and the rule preview error now names both the credential and the key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
173 lines
5.2 KiB
Go
173 lines
5.2 KiB
Go
// Package crypto provides centralized encryption, hashing, and key management
|
|
package crypto
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidCiphertext = errors.New("invalid ciphertext")
|
|
// ErrDecryptionFailed is returned when AES-GCM authentication fails on a
|
|
// well-formed ciphertext. The overwhelmingly common cause is that the data
|
|
// was encrypted under a different ORCHESTRAD_SECRET_KEY, so the message
|
|
// says so: the alternative (tampered/corrupted bytes) is rare, and an
|
|
// operator who has changed or lost the key otherwise gets no clue why an
|
|
// unrelated-looking operation now fails.
|
|
ErrDecryptionFailed = errors.New(
|
|
"decryption failed - the data was encrypted with a different ORCHESTRAD_SECRET_KEY " +
|
|
"(the key must stay the same for stored secrets to be readable), or the stored value is corrupted")
|
|
)
|
|
|
|
// Argon2 parameters for password hashing
|
|
const (
|
|
argon2Time = 1
|
|
argon2Memory = 64 * 1024
|
|
argon2Threads = 4
|
|
argon2KeyLen = 32
|
|
saltLen = 16
|
|
)
|
|
|
|
// HashPassword hashes a password using Argon2id
|
|
func HashPassword(password string) (string, error) {
|
|
salt := make([]byte, saltLen)
|
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
|
return "", fmt.Errorf("generating salt: %w", err)
|
|
}
|
|
|
|
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
|
|
|
|
// Encode as: $argon2id$v=19$m=65536,t=1,p=4$<salt>$<hash>
|
|
encoded := fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
|
argon2Memory, argon2Time, argon2Threads,
|
|
base64.RawStdEncoding.EncodeToString(salt),
|
|
base64.RawStdEncoding.EncodeToString(hash))
|
|
|
|
return encoded, nil
|
|
}
|
|
|
|
// VerifyPassword verifies a password against an Argon2id hash. The encoded
|
|
// format is the same as produced by HashPassword:
|
|
//
|
|
// $argon2id$v=19$m=<memory>,t=<time>,p=<threads>$<saltB64>$<hashB64>
|
|
//
|
|
// Both base64 fields use RawStdEncoding (no padding) and may contain `+` and
|
|
// `/` characters, so the string is split on `$` rather than parsed with
|
|
// fmt.Sscanf — %s does not stop at `$` and would otherwise swallow the hash.
|
|
func VerifyPassword(password, encoded string) (bool, error) {
|
|
parts := strings.Split(encoded, "$")
|
|
if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" || parts[2] != "v=19" {
|
|
return false, fmt.Errorf("invalid hash format")
|
|
}
|
|
|
|
var memory, t uint32
|
|
var threads uint8
|
|
if n, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &t, &threads); err != nil || n != 3 {
|
|
return false, fmt.Errorf("invalid hash parameters")
|
|
}
|
|
|
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
|
if err != nil {
|
|
return false, fmt.Errorf("decoding salt: %w", err)
|
|
}
|
|
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
|
if err != nil {
|
|
return false, fmt.Errorf("decoding hash: %w", err)
|
|
}
|
|
|
|
computed := argon2.IDKey([]byte(password), salt, t, memory, threads, uint32(len(hash)))
|
|
return subtle.ConstantTimeCompare(hash, computed) == 1, nil
|
|
}
|
|
|
|
// Encryptor handles AES-GCM encryption with a master key
|
|
type Encryptor struct {
|
|
key []byte
|
|
}
|
|
|
|
// NewEncryptor creates a new Encryptor with the given master key
|
|
func NewEncryptor(key []byte) (*Encryptor, error) {
|
|
if len(key) != 32 {
|
|
return nil, fmt.Errorf("key must be 32 bytes for AES-256")
|
|
}
|
|
return &Encryptor{key: key}, nil
|
|
}
|
|
|
|
// Encrypt encrypts plaintext using AES-GCM (AEAD)
|
|
func (e *Encryptor) Encrypt(plaintext []byte) (string, error) {
|
|
block, err := aes.NewCipher(e.key)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating GCM: %w", err)
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return "", fmt.Errorf("generating nonce: %w", err)
|
|
}
|
|
|
|
// Seal appends the encrypted data to the nonce
|
|
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
|
}
|
|
|
|
// Decrypt decrypts ciphertext encrypted with Encrypt
|
|
func (e *Encryptor) Decrypt(encoded string) ([]byte, error) {
|
|
ciphertext, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decoding ciphertext: %w", err)
|
|
}
|
|
|
|
block, err := aes.NewCipher(e.key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating GCM: %w", err)
|
|
}
|
|
|
|
if len(ciphertext) < gcm.NonceSize() {
|
|
return nil, ErrInvalidCiphertext
|
|
}
|
|
|
|
nonce := ciphertext[:gcm.NonceSize()]
|
|
ciphertext = ciphertext[gcm.NonceSize():]
|
|
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return nil, ErrDecryptionFailed
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
// HashAPIKey creates a secure hash of an API key for storage
|
|
func HashAPIKey(key string) string {
|
|
hash := sha256.Sum256([]byte(key))
|
|
return base64.StdEncoding.EncodeToString(hash[:])
|
|
}
|
|
|
|
// GenerateRandomKey generates a random key of the specified length
|
|
func GenerateRandomKey(length int) (string, error) {
|
|
bytes := make([]byte, length)
|
|
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.URLEncoding.EncodeToString(bytes), nil
|
|
}
|