Files
Alphaeus Mote 5301b47f16 fix(crypto): explain that a decryption failure means the secret key changed
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>
2026-09-03 10:59:43 -04:00

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
}