thread-master/apps/backend/internal/lib/secretbox/secretbox.go

122 lines
3.8 KiB
Go
Raw Normal View History

2026-07-15 15:23:59 +00:00
// 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
}