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:
Michael Muré
2024-12-02 17:34:29 +01:00
committed by GitHub
5 changed files with 140 additions and 159 deletions

View File

@@ -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
}

View 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
}

View File

@@ -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
}

View File

@@ -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
} }

View File

@@ -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)