Merge pull request #88 from ucan-wg/feat/secretbox-meta-encryption
feat(meta): secretbox encryption in place of aes-gcm
This commit is contained in:
@@ -1,132 +0,0 @@
|
|||||||
package crypto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
)
|
|
||||||
|
|
||||||
// KeySize represents valid AES key sizes
|
|
||||||
type KeySize int
|
|
||||||
|
|
||||||
const (
|
|
||||||
KeySize128 KeySize = 16 // AES-128
|
|
||||||
KeySize192 KeySize = 24 // AES-192
|
|
||||||
KeySize256 KeySize = 32 // AES-256 (recommended)
|
|
||||||
)
|
|
||||||
|
|
||||||
// IsValid returns true if the key size is valid for AES
|
|
||||||
func (ks KeySize) IsValid() bool {
|
|
||||||
switch ks {
|
|
||||||
case KeySize128, KeySize192, KeySize256:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ErrShortCipherText = errors.New("ciphertext too short")
|
|
||||||
var ErrNoEncryptionKey = errors.New("encryption key is required")
|
|
||||||
var ErrInvalidKeySize = errors.New("invalid key size: must be 16, 24, or 32 bytes")
|
|
||||||
var ErrZeroKey = errors.New("encryption key cannot be all zeros")
|
|
||||||
|
|
||||||
// GenerateKey generates a random AES key of default size KeySize256 (32 bytes).
|
|
||||||
// Returns an error if the specified size is invalid or if key generation fails.
|
|
||||||
func GenerateKey() ([]byte, error) {
|
|
||||||
return GenerateKeyWithSize(KeySize256)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateKeyWithSize generates a random AES key of the specified size.
|
|
||||||
// Returns an error if the specified size is invalid or if key generation fails.
|
|
||||||
func GenerateKeyWithSize(size KeySize) ([]byte, error) {
|
|
||||||
if !size.IsValid() {
|
|
||||||
return nil, ErrInvalidKeySize
|
|
||||||
}
|
|
||||||
|
|
||||||
key := make([]byte, size)
|
|
||||||
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to generate AES key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EncryptWithAESKey encrypts data using AES-GCM with the provided key.
|
|
||||||
// The key must be 16, 24, or 32 bytes long (for AES-128, AES-192, or AES-256).
|
|
||||||
// Returns the encrypted data with the nonce prepended, or an error if encryption fails.
|
|
||||||
func EncryptWithAESKey(data, key []byte) ([]byte, error) {
|
|
||||||
if err := validateAESKey(key); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce := make([]byte, gcm.NonceSize())
|
|
||||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return gcm.Seal(nonce, nonce, data, nil), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecryptStringWithAESKey decrypts data that was encrypted with EncryptWithAESKey.
|
|
||||||
// The key must match the one used for encryption.
|
|
||||||
// Expects the input to have a prepended nonce.
|
|
||||||
// Returns the decrypted data or an error if decryption fails.
|
|
||||||
func DecryptStringWithAESKey(data, key []byte) ([]byte, error) {
|
|
||||||
if err := validateAESKey(key); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(data) < gcm.NonceSize() {
|
|
||||||
return nil, ErrShortCipherText
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce, ciphertext := data[:gcm.NonceSize()], data[gcm.NonceSize():]
|
|
||||||
decrypted, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return decrypted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAESKey(key []byte) error {
|
|
||||||
if key == nil {
|
|
||||||
return ErrNoEncryptionKey
|
|
||||||
}
|
|
||||||
|
|
||||||
if !KeySize(len(key)).IsValid() {
|
|
||||||
return ErrInvalidKeySize
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if key is all zeros
|
|
||||||
for _, b := range key {
|
|
||||||
if b != 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrZeroKey
|
|
||||||
}
|
|
||||||
90
pkg/meta/internal/crypto/secretbox.go
Normal file
90
pkg/meta/internal/crypto/secretbox.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/nacl/secretbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keySize = 32 // secretbox allows only 32-byte keys
|
||||||
|
|
||||||
|
var ErrShortCipherText = errors.New("ciphertext too short")
|
||||||
|
var ErrNoEncryptionKey = errors.New("encryption key is required")
|
||||||
|
var ErrInvalidKeySize = errors.New("invalid key size: must be 32 bytes")
|
||||||
|
var ErrZeroKey = errors.New("encryption key cannot be all zeros")
|
||||||
|
|
||||||
|
// GenerateKey generates a random 32-byte key to be used by EncryptWithKey and DecryptWithKey
|
||||||
|
func GenerateKey() ([]byte, error) {
|
||||||
|
key := make([]byte, keySize)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, key); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate key: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptWithKey encrypts data using NaCl's secretbox with the provided key.
|
||||||
|
// 40 bytes of overhead (24-byte nonce + 16-byte MAC) are added to the plaintext size.
|
||||||
|
func EncryptWithKey(data, key []byte) ([]byte, error) {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var secretKey [keySize]byte
|
||||||
|
copy(secretKey[:], key)
|
||||||
|
|
||||||
|
// Generate 24 bytes of random data as nonce
|
||||||
|
var nonce [24]byte
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt and authenticate data
|
||||||
|
encrypted := secretbox.Seal(nonce[:], data, &nonce, &secretKey)
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptStringWithKey decrypts data using secretbox with the provided key
|
||||||
|
func DecryptStringWithKey(data, key []byte) ([]byte, error) {
|
||||||
|
if err := validateKey(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) < 24 {
|
||||||
|
return nil, ErrShortCipherText
|
||||||
|
}
|
||||||
|
|
||||||
|
var secretKey [keySize]byte
|
||||||
|
copy(secretKey[:], key)
|
||||||
|
|
||||||
|
var nonce [24]byte
|
||||||
|
copy(nonce[:], data[:24])
|
||||||
|
|
||||||
|
decrypted, ok := secretbox.Open(nil, data[24:], &nonce, &secretKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("decryption failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return decrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateKey(key []byte) error {
|
||||||
|
if key == nil {
|
||||||
|
return ErrNoEncryptionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(key) != keySize {
|
||||||
|
return ErrInvalidKeySize
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if key is all zeros
|
||||||
|
for _, b := range key {
|
||||||
|
if b != 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ErrZeroKey
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAESEncryption(t *testing.T) {
|
func TestSecretBoxEncryption(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
key := make([]byte, 32) // generated random 32-byte key
|
key := make([]byte, keySize) // generate random 32-byte key
|
||||||
_, errKey := rand.Read(key)
|
_, errKey := rand.Read(key)
|
||||||
require.NoError(t, errKey)
|
require.NoError(t, errKey)
|
||||||
|
|
||||||
@@ -40,13 +40,13 @@ func TestAESEncryption(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "invalid key size",
|
name: "invalid key size",
|
||||||
data: []byte("hello world"),
|
data: []byte("hello world"),
|
||||||
key: make([]byte, 31),
|
key: make([]byte, 16), // Only 32 bytes allowed now
|
||||||
wantErr: ErrInvalidKeySize,
|
wantErr: ErrInvalidKeySize,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "zero key returns error",
|
name: "zero key returns error",
|
||||||
data: []byte("hello world"),
|
data: []byte("hello world"),
|
||||||
key: make([]byte, 32),
|
key: make([]byte, keySize),
|
||||||
wantErr: ErrZeroKey,
|
wantErr: ErrZeroKey,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -56,24 +56,22 @@ func TestAESEncryption(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
encrypted, err := EncryptWithAESKey(tt.data, tt.key)
|
encrypted, err := EncryptWithKey(tt.data, tt.key)
|
||||||
if tt.wantErr != nil {
|
if tt.wantErr != nil {
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
require.ErrorIs(t, err, tt.wantErr)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
decrypted, err := DecryptStringWithAESKey(encrypted, tt.key)
|
// Verify encrypted data is different and includes nonce
|
||||||
require.NoError(t, err)
|
require.Greater(t, len(encrypted), 24) // At least nonce size
|
||||||
|
if len(tt.data) > 0 {
|
||||||
if tt.key == nil {
|
require.NotEqual(t, tt.data, encrypted[24:]) // Ignore nonce prefix
|
||||||
require.Equal(t, tt.data, encrypted)
|
|
||||||
require.Equal(t, tt.data, decrypted)
|
|
||||||
} else {
|
|
||||||
require.NotEqual(t, tt.data, encrypted)
|
|
||||||
require.True(t, bytes.Equal(tt.data, decrypted))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
decrypted, err := DecryptStringWithKey(encrypted, tt.key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, bytes.Equal(tt.data, decrypted))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,10 +79,15 @@ func TestAESEncryption(t *testing.T) {
|
|||||||
func TestDecryptionErrors(t *testing.T) {
|
func TestDecryptionErrors(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
key := make([]byte, 32)
|
key := make([]byte, keySize)
|
||||||
_, err := rand.Read(key)
|
_, err := rand.Read(key)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create valid encrypted data for tampering tests
|
||||||
|
validData := []byte("test message")
|
||||||
|
encrypted, err := EncryptWithKey(validData, key)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
data []byte
|
data []byte
|
||||||
@@ -93,19 +96,25 @@ func TestDecryptionErrors(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "short ciphertext",
|
name: "short ciphertext",
|
||||||
data: []byte("short"),
|
data: make([]byte, 23), // Less than nonce size
|
||||||
key: key,
|
key: key,
|
||||||
errMsg: "ciphertext too short",
|
errMsg: "ciphertext too short",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid ciphertext",
|
name: "invalid ciphertext",
|
||||||
data: make([]byte, 16), // just nonce size
|
data: make([]byte, 24), // Just nonce size
|
||||||
key: key,
|
key: key,
|
||||||
errMsg: "message authentication failed",
|
errMsg: "decryption failed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tampered ciphertext",
|
||||||
|
data: tamperWithBytes(encrypted),
|
||||||
|
key: key,
|
||||||
|
errMsg: "decryption failed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing key",
|
name: "missing key",
|
||||||
data: []byte("<22>`M<><4D><EFBFBD>l\u001AIF<49>\u0012<31><32><EFBFBD>=h<>?<3F>c<EFBFBD> <20><>\u0012<31><32><EFBFBD><EFBFBD>\u001C<31>\u0018Ƽ(g"),
|
data: encrypted,
|
||||||
key: nil,
|
key: nil,
|
||||||
errMsg: "encryption key is required",
|
errMsg: "encryption key is required",
|
||||||
},
|
},
|
||||||
@@ -116,9 +125,20 @@ func TestDecryptionErrors(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
_, err := DecryptStringWithAESKey(tt.data, tt.key)
|
_, err := DecryptStringWithKey(tt.data, tt.key)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Contains(t, err.Error(), tt.errMsg)
|
require.Contains(t, err.Error(), tt.errMsg)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tamperWithBytes modifies a byte in the encrypted data to simulate tampering
|
||||||
|
func tamperWithBytes(data []byte) []byte {
|
||||||
|
if len(data) < 25 { // Need at least nonce + 1 byte
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
tampered := make([]byte, len(data))
|
||||||
|
copy(tampered, data)
|
||||||
|
tampered[24] ^= 0x01 // Modify first byte after nonce
|
||||||
|
return tampered
|
||||||
|
}
|
||||||
@@ -63,7 +63,7 @@ func (m *Meta) GetEncryptedString(key string, encryptionKey []byte) (string, err
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypted, err := crypto.DecryptStringWithAESKey(v, encryptionKey)
|
decrypted, err := crypto.DecryptStringWithKey(v, encryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ func (m *Meta) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, erro
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypted, err := crypto.DecryptStringWithAESKey(v, encryptionKey)
|
decrypted, err := crypto.DecryptStringWithKey(v, encryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -150,18 +150,19 @@ func (m *Meta) Add(key string, val any) error {
|
|||||||
// AddEncrypted adds a key/value pair in the meta set.
|
// AddEncrypted adds a key/value pair in the meta set.
|
||||||
// The value is encrypted with the given encryptionKey.
|
// The value is encrypted with the given encryptionKey.
|
||||||
// Accepted types for the value are: string, []byte.
|
// Accepted types for the value are: string, []byte.
|
||||||
|
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.
|
||||||
func (m *Meta) AddEncrypted(key string, val any, encryptionKey []byte) error {
|
func (m *Meta) AddEncrypted(key string, val any, encryptionKey []byte) error {
|
||||||
var encrypted []byte
|
var encrypted []byte
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch val := val.(type) {
|
switch val := val.(type) {
|
||||||
case string:
|
case string:
|
||||||
encrypted, err = crypto.EncryptWithAESKey([]byte(val), encryptionKey)
|
encrypted, err = crypto.EncryptWithKey([]byte(val), encryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case []byte:
|
case []byte:
|
||||||
encrypted, err = crypto.EncryptWithAESKey(val, encryptionKey)
|
encrypted, err = crypto.EncryptWithKey(val, encryptionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ func WithMeta(key string, val any) Option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WithEncryptedMetaString adds a key/value pair in the "meta" field.
|
// WithEncryptedMetaString adds a key/value pair in the "meta" field.
|
||||||
// The string value is encrypted with the given aesKey.
|
// The string value is encrypted with the given key.
|
||||||
|
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.
|
||||||
func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option {
|
func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option {
|
||||||
return func(t *Token) error {
|
return func(t *Token) error {
|
||||||
return t.meta.AddEncrypted(key, val, encryptionKey)
|
return t.meta.AddEncrypted(key, val, encryptionKey)
|
||||||
@@ -53,7 +54,8 @@ func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WithEncryptedMetaBytes adds a key/value pair in the "meta" field.
|
// WithEncryptedMetaBytes adds a key/value pair in the "meta" field.
|
||||||
// The []byte value is encrypted with the given aesKey.
|
// The []byte value is encrypted with the given key.
|
||||||
|
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.
|
||||||
func WithEncryptedMetaBytes(key string, val, encryptionKey []byte) Option {
|
func WithEncryptedMetaBytes(key string, val, encryptionKey []byte) Option {
|
||||||
return func(t *Token) error {
|
return func(t *Token) error {
|
||||||
return t.meta.AddEncrypted(key, val, encryptionKey)
|
return t.meta.AddEncrypted(key, val, encryptionKey)
|
||||||
|
|||||||
Reference in New Issue
Block a user