package mynalog import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/hex" "errors" "fmt" ) // KeyLength is the AES-256 key size the stored refresh token is sealed with. const KeyLength = 32 // ErrUndecryptable is returned when a stored secret cannot be opened with the configured key — // typically because the key was rotated or lost. Callers treat it as "there is no session" and ask // the operator to sign in again, which is a nuisance rather than a failure. var ErrUndecryptable = errors.New("mynalog: stored secret cannot be decrypted") // ParseKey decodes the configured encryption key. It accepts standard base64 with or without // padding (so the output of `openssl rand -base64 32` works as-is) and plain hex. func ParseKey(s string) ([]byte, error) { for _, decode := range []func(string) ([]byte, error){ base64.StdEncoding.DecodeString, base64.RawStdEncoding.DecodeString, hex.DecodeString, } { if key, err := decode(s); err == nil && len(key) == KeyLength { return key, nil } } return nil, fmt.Errorf("mynalog: encryption key must decode to %d bytes (base64 or hex)", KeyLength) } // Seal encrypts plaintext with key, returning the nonce followed by the AES-GCM ciphertext. // // This exists because the refresh token is a long-lived credential to the operator's personal tax // cabinet, and it is the one part of the rail that has to sit on disk. Encrypting it at the // application layer keeps it out of database dumps, which travel further than the database does. func Seal(key []byte, plaintext string) ([]byte, error) { gcm, err := newGCM(key) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("mynalog: nonce: %w", err) } return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil } // Open decrypts a blob produced by Seal. Any failure — a wrong key, a truncated value, a tampered // one — comes back as ErrUndecryptable, because the caller's response is the same in every case. func Open(key, blob []byte) (string, error) { gcm, err := newGCM(key) if err != nil { return "", err } if len(blob) < gcm.NonceSize() { return "", ErrUndecryptable } nonce, ciphertext := blob[:gcm.NonceSize()], blob[gcm.NonceSize():] plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return "", ErrUndecryptable } return string(plaintext), nil } // newGCM builds the AEAD both directions share. func newGCM(key []byte) (cipher.AEAD, error) { if len(key) != KeyLength { return nil, fmt.Errorf("mynalog: encryption key must be %d bytes, got %d", KeyLength, len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("mynalog: cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("mynalog: gcm: %w", err) } return gcm, nil }