Skip to content

Commit

Permalink
Merge pull request #51 from ucan-wg/v1-meta-encryption
Browse files Browse the repository at this point in the history
feat(meta): values symmetric encryption
  • Loading branch information
fabiobozzo authored Nov 12, 2024
2 parents 17a1d54 + d3e97aa commit 6298fa2
Show file tree
Hide file tree
Showing 8 changed files with 520 additions and 2 deletions.
132 changes: 132 additions & 0 deletions pkg/meta/internal/crypto/aes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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
}
124 changes: 124 additions & 0 deletions pkg/meta/internal/crypto/aes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package crypto

import (
"bytes"
"crypto/rand"
"testing"

"github.com/stretchr/testify/require"
)

func TestAESEncryption(t *testing.T) {
t.Parallel()

key := make([]byte, 32) // generated random 32-byte key
_, errKey := rand.Read(key)
require.NoError(t, errKey)

tests := []struct {
name string
data []byte
key []byte
wantErr error
}{
{
name: "valid encryption/decryption",
data: []byte("hello world"),
key: key,
},
{
name: "nil key returns error",
data: []byte("hello world"),
key: nil,
wantErr: ErrNoEncryptionKey,
},
{
name: "empty data",
data: []byte{},
key: key,
},
{
name: "invalid key size",
data: []byte("hello world"),
key: make([]byte, 31),
wantErr: ErrInvalidKeySize,
},
{
name: "zero key returns error",
data: []byte("hello world"),
key: make([]byte, 32),
wantErr: ErrZeroKey,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

encrypted, err := EncryptWithAESKey(tt.data, tt.key)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)

return
}
require.NoError(t, err)

decrypted, err := DecryptStringWithAESKey(encrypted, tt.key)
require.NoError(t, err)

if tt.key == nil {
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))
}
})
}
}

func TestDecryptionErrors(t *testing.T) {
t.Parallel()

key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)

tests := []struct {
name string
data []byte
key []byte
errMsg string
}{
{
name: "short ciphertext",
data: []byte("short"),
key: key,
errMsg: "ciphertext too short",
},
{
name: "invalid ciphertext",
data: make([]byte, 16), // just nonce size
key: key,
errMsg: "message authentication failed",
},
{
name: "missing key",
data: []byte("�`M���l\u001AIF�\u0012���=h�?�c� ��\u0012����\u001C\u0018Ƽ(g"),
key: nil,
errMsg: "encryption key is required",
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

_, err := DecryptStringWithAESKey(tt.data, tt.key)
require.Error(t, err)
require.Contains(t, err.Error(), tt.errMsg)
})
}
}
63 changes: 62 additions & 1 deletion pkg/meta/meta.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package meta

import (
"errors"
"fmt"
"strings"

"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/printer"

"github.com/ucan-wg/go-ucan/pkg/meta/internal/crypto"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)

var ErrNotFound = fmt.Errorf("key-value not found in meta")
var ErrUnsupported = errors.New("failure adding unsupported type to meta")

var ErrNotFound = errors.New("key-value not found in meta")

var ErrNotEncryptable = errors.New("value of this type cannot be encrypted")

// Meta is a container for meta key-value pairs in a UCAN token.
// This also serves as a way to construct the underlying IPLD data with minimum allocations
Expand Down Expand Up @@ -50,6 +56,21 @@ func (m *Meta) GetString(key string) (string, error) {
return v.AsString()
}

// GetEncryptedString decorates GetString and decrypt its output with the given symmetric encryption key.
func (m *Meta) GetEncryptedString(key string, encryptionKey []byte) (string, error) {
v, err := m.GetBytes(key)
if err != nil {
return "", err
}

decrypted, err := crypto.DecryptStringWithAESKey(v, encryptionKey)
if err != nil {
return "", err
}

return string(decrypted), nil
}

// GetInt64 retrieves a value as an int64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
Expand Down Expand Up @@ -83,6 +104,21 @@ func (m *Meta) GetBytes(key string) ([]byte, error) {
return v.AsBytes()
}

// GetEncryptedBytes decorates GetBytes and decrypt its output with the given symmetric encryption key.
func (m *Meta) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, error) {
v, err := m.GetBytes(key)
if err != nil {
return nil, err
}

decrypted, err := crypto.DecryptStringWithAESKey(v, encryptionKey)
if err != nil {
return nil, err
}

return decrypted, nil
}

// GetNode retrieves a value as a raw IPLD node.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
Expand Down Expand Up @@ -112,6 +148,31 @@ func (m *Meta) Add(key string, val any) error {
return nil
}

// AddEncrypted adds a key/value pair in the meta set.
// The value is encrypted with the given encryptionKey.
// Accepted types for the value are: string, []byte.
func (m *Meta) AddEncrypted(key string, val any, encryptionKey []byte) error {
var encrypted []byte
var err error

switch val := val.(type) {
case string:
encrypted, err = crypto.EncryptWithAESKey([]byte(val), encryptionKey)
if err != nil {
return err
}
case []byte:
encrypted, err = crypto.EncryptWithAESKey(val, encryptionKey)
if err != nil {
return err
}
default:
return ErrNotEncryptable
}

return m.Add(key, encrypted)
}

// Equals tells if two Meta hold the same key/values.
func (m *Meta) Equals(other *Meta) bool {
if len(m.Keys) != len(other.Keys) {
Expand Down
Loading

0 comments on commit 6298fa2

Please sign in to comment.