122 lines
3.8 KiB
Go
122 lines
3.8 KiB
Go
// 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
|
|
}
|