// Package cryptutil provides authenticated symmetric encryption (AES-256-GCM) // for secrets that must be stored at rest but read back in plaintext at // runtime — currently the LDAP service-bind password (internal/ldapstore). // // The 256-bit key is derived via HKDF-SHA256 from the application's existing // master/JWT secret (config.API.Secret), so no additional secret needs to be // provisioned. A distinct HKDF info label keeps this key independent from the // JWT signing key even though both originate from the same input secret. package cryptutil import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "fmt" "io" "golang.org/x/crypto/hkdf" ) // hkdfInfo domain-separates the secretbox key from every other key derived // from the same master secret (e.g. the JWT signing key uses "archivdms-jwt-v1"). const hkdfInfo = "archivdms-ldap-secretbox-v1" // Box performs AES-256-GCM encrypt/decrypt with a key derived from a secret. type Box struct { gcm cipher.AEAD } // NewBox derives a 256-bit AES key from secret via HKDF-SHA256 and returns a // ready-to-use Box. secret must be non-empty. func NewBox(secret string) (*Box, error) { if secret == "" { return nil, fmt.Errorf("cryptutil: empty secret") } key := make([]byte, 32) if _, err := io.ReadFull(hkdf.New(sha256.New, []byte(secret), nil, []byte(hkdfInfo)), key); err != nil { return nil, fmt.Errorf("cryptutil: derive key: %w", err) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("cryptutil: new cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("cryptutil: new gcm: %w", err) } return &Box{gcm: gcm}, nil } // Encrypt seals plaintext, returning the ciphertext and the freshly generated // nonce (stored separately in the DB). Callers persist both. func (b *Box) Encrypt(plaintext []byte) (ciphertext, nonce []byte, err error) { nonce = make([]byte, b.gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, nil, fmt.Errorf("cryptutil: nonce: %w", err) } ciphertext = b.gcm.Seal(nil, nonce, plaintext, nil) return ciphertext, nonce, nil } // Decrypt opens ciphertext using nonce, returning the original plaintext. func (b *Box) Decrypt(ciphertext, nonce []byte) ([]byte, error) { if len(nonce) != b.gcm.NonceSize() { return nil, fmt.Errorf("cryptutil: bad nonce length %d", len(nonce)) } plaintext, err := b.gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, fmt.Errorf("cryptutil: decrypt: %w", err) } return plaintext, nil }