package auth import ( "crypto/aes" "crypto/cipher" "crypto/rand" "fmt" ) // Sealer is the encryption seam for TOTP secrets at rest (A9). The key comes // from config later (D-035); this package only defines the mechanism. // // A Sealer must return ciphertext that never equals — and never contains — the // plaintext. type Sealer interface { Seal(plaintext []byte) ([]byte, error) Open(ciphertext []byte) ([]byte, error) } // KeySize is the AES-256 key length required by AEADSealer. const KeySize = 32 // AEADSealer encrypts with AES-256-GCM. The stored blob is // // nonce || ciphertext || tag // // A fresh random nonce is generated on every Seal, so sealing the same secret // twice yields different ciphertext. type AEADSealer struct { aead cipher.AEAD } // NewAEADSealer builds a Sealer from a 32-byte key. func NewAEADSealer(key []byte) (*AEADSealer, error) { if len(key) != KeySize { return nil, fmt.Errorf("auth: TOTP encryption key must be %d bytes, got %d", KeySize, len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("auth: new cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("auth: new GCM: %w", err) } return &AEADSealer{aead: gcm}, nil } // Seal encrypts plaintext. func (s *AEADSealer) Seal(plaintext []byte) ([]byte, error) { nonce := make([]byte, s.aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("auth: seal nonce: %w", err) } // Seal appends to nonce, producing nonce || ciphertext||tag. return s.aead.Seal(nonce, nonce, plaintext, nil), nil } // Open decrypts a blob produced by Seal. func (s *AEADSealer) Open(ciphertext []byte) ([]byte, error) { ns := s.aead.NonceSize() if len(ciphertext) < ns { return nil, ErrInvalidCiphertext } nonce, ct := ciphertext[:ns], ciphertext[ns:] plaintext, err := s.aead.Open(nil, nonce, ct, nil) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidCiphertext, err) } return plaintext, nil } var _ Sealer = (*AEADSealer)(nil)