package security import ( "crypto/aes" "crypto/cipher" "crypto/rand" "errors" "fmt" ) // AESKeySize is the required key length for AES 256. const AESKeySize = 32 // SealSecret encrypts plaintext with AES 256 GCM under key, returning the ciphertext and the random // nonce that must be stored alongside it. key must be exactly 32 bytes. This protects the TOTP secret // at rest; the key is a KEK from the secrets file, carried with a key_version so it can rotate. func SealSecret(key, plaintext []byte) (ciphertext, nonce []byte, err error) { gcm, err := newGCM(key) if err != nil { return nil, nil, err } nonce = make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return nil, nil, fmt.Errorf("security: read gcm nonce: %w", err) } ciphertext = gcm.Seal(nil, nonce, plaintext, nil) return ciphertext, nonce, nil } // OpenSecret decrypts a ciphertext and nonce produced by SealSecret under key. func OpenSecret(key, ciphertext, nonce []byte) ([]byte, error) { gcm, err := newGCM(key) if err != nil { return nil, err } if len(nonce) != gcm.NonceSize() { return nil, errors.New("security: wrong gcm nonce size") } plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, fmt.Errorf("security: open secret: %w", err) } return plaintext, nil } func newGCM(key []byte) (cipher.AEAD, error) { if len(key) != AESKeySize { return nil, fmt.Errorf("security: AES key must be %d bytes, got %d", AESKeySize, len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("security: new cipher: %w", err) } return cipher.NewGCM(block) }