// Package secretbox encrypts member-owned secrets for persistence. package secretbox import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/binary" "errors" "fmt" "io" "strings" ) const prefix = "enc:v1:" var ( ErrInvalidConfig = errors.New("invalid secretbox configuration") ErrInvalidCiphertext = errors.New("invalid encrypted secret") ErrUnsupportedVersion = errors.New("unsupported encrypted secret version") ) // Codec is an AES-256-GCM codec for the current encryption key. type Codec struct { keyID string aead cipher.AEAD rand io.Reader } // New constructs a codec from a key ID and a base64-encoded 32-byte key. func New(keyID, encodedKey string) (*Codec, error) { keyID = strings.TrimSpace(keyID) encodedKey = strings.TrimSpace(encodedKey) if keyID == "" || strings.Contains(keyID, ":") || encodedKey == "" { return nil, fmt.Errorf("%w: key ID and key are required", ErrInvalidConfig) } key, err := base64.StdEncoding.DecodeString(encodedKey) if err != nil { return nil, fmt.Errorf("%w: key must be base64: %v", ErrInvalidConfig, err) } if len(key) != 32 { return nil, fmt.Errorf("%w: decoded key must be 32 bytes", ErrInvalidConfig) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) } aead, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidConfig, err) } return &Codec{keyID: keyID, aead: aead, rand: rand.Reader}, nil } // Encrypt encrypts plaintext and binds it to its member, field, and provider. func (c *Codec) Encrypt(uid int64, field, provider, plaintext string) (string, error) { if c == nil || c.aead == nil { return "", ErrInvalidConfig } nonce := make([]byte, c.aead.NonceSize()) if _, err := io.ReadFull(c.rand, nonce); err != nil { return "", fmt.Errorf("generate encryption nonce: %w", err) } sealed := c.aead.Seal(nil, nonce, []byte(plaintext), additionalData(uid, field, provider)) payload := append(nonce, sealed...) return prefix + c.keyID + ":" + base64.RawURLEncoding.EncodeToString(payload), nil } // Decrypt decrypts encrypted values. Unprefixed values are legacy plaintext. func (c *Codec) Decrypt(uid int64, field, provider, value string) (string, error) { if !strings.HasPrefix(value, "enc:") { return value, nil } if !strings.HasPrefix(value, prefix) { return "", ErrUnsupportedVersion } if c == nil || c.aead == nil { return "", ErrInvalidConfig } rest := strings.TrimPrefix(value, prefix) keyID, payloadText, ok := strings.Cut(rest, ":") if !ok || keyID == "" || payloadText == "" { return "", ErrInvalidCiphertext } if keyID != c.keyID { return "", fmt.Errorf("%w: unknown key ID %q", ErrInvalidCiphertext, keyID) } payload, err := base64.RawURLEncoding.DecodeString(payloadText) if err != nil || len(payload) < c.aead.NonceSize()+c.aead.Overhead() { return "", ErrInvalidCiphertext } nonce := payload[:c.aead.NonceSize()] ciphertext := payload[c.aead.NonceSize():] plaintext, err := c.aead.Open(nil, nonce, ciphertext, additionalData(uid, field, provider)) if err != nil { return "", fmt.Errorf("%w: authentication failed", ErrInvalidCiphertext) } return string(plaintext), nil } // IsEncrypted reports whether a value uses the encrypted-value namespace. func IsEncrypted(value string) bool { return strings.HasPrefix(value, "enc:") } func additionalData(uid int64, field, provider string) []byte { fieldBytes := []byte(field) providerBytes := []byte(provider) data := make([]byte, 8+4+len(fieldBytes)+4+len(providerBytes)) binary.BigEndian.PutUint64(data, uint64(uid)) offset := 8 binary.BigEndian.PutUint32(data[offset:], uint32(len(fieldBytes))) offset += 4 copy(data[offset:], fieldBytes) offset += len(fieldBytes) binary.BigEndian.PutUint32(data[offset:], uint32(len(providerBytes))) offset += 4 copy(data[offset:], providerBytes) return data }