Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(meta): values symmetric encryption #51

Merged
merged 10 commits into from
Nov 12, 2024
62 changes: 62 additions & 0 deletions pkg/crypto/aes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package crypto

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"io"
)

var ErrShortCipherText = errors.New("ciphertext too short")

func EncryptWithAESKey(data, key []byte) ([]byte, error) {
if key == nil {
return data, nil
fabiobozzo marked this conversation as resolved.
Show resolved Hide resolved
}

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
}

func DecryptStringWithAESKey(data, key []byte) ([]byte, error) {
if key == nil {
return data, nil
fabiobozzo marked this conversation as resolved.
Show resolved Hide resolved
}

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
}
113 changes: 113 additions & 0 deletions pkg/crypto/aes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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
_, err := rand.Read(key)
require.NoError(t, err)

tests := []struct {
name string
data []byte
key []byte
wantErr bool
}{
{
name: "valid encryption/decryption",
data: []byte("hello world"),
key: key,
wantErr: false,
},
{
name: "nil key returns original data",
data: []byte("hello world"),
key: nil,
wantErr: false,
},
{
name: "empty data",
data: []byte{},
key: key,
wantErr: false,
},
{
name: "invalid key size",
data: []byte("hello world"),
key: make([]byte, 31),
wantErr: true,
},
}

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 {
require.Error(t, err)
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",
},
}

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)
})
}
}
59 changes: 58 additions & 1 deletion pkg/meta/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import (
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ipld/go-ipld-prime/printer"

"github.com/ucan-wg/go-ucan/pkg/crypto"
)

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 and transformations,
Expand Down Expand Up @@ -51,6 +53,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.GetString(key)
fabiobozzo marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
return "", err
}

decrypted, err := crypto.DecryptStringWithAESKey([]byte(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 @@ -84,6 +101,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 @@ -125,6 +157,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
}
return m.Add(key, string(encrypted))
case []byte:
encrypted, err = crypto.EncryptWithAESKey(val, encryptionKey)
if err != nil {
return err
}
return m.Add(key, encrypted)
default:
return ErrNotEncryptable
}
}

// 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
61 changes: 60 additions & 1 deletion pkg/meta/meta_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package meta_test

import (
"crypto/rand"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -14,11 +15,69 @@ func TestMeta_Add(t *testing.T) {

type Unsupported struct{}

t.Run("error if not primative or Node", func(t *testing.T) {
t.Run("error if not primitive or Node", func(t *testing.T) {
t.Parallel()

err := (&meta.Meta{}).Add("invalid", &Unsupported{})
require.ErrorIs(t, err, meta.ErrUnsupported)
assert.ErrorContains(t, err, "*github.com/ucan-wg/go-ucan/pkg/meta_test.Unsupported")
})

t.Run("encrypted meta", func(t *testing.T) {
t.Parallel()

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

m := meta.NewMeta()

// string encryption
err = m.AddEncrypted("secret", "hello world", key)
require.NoError(t, err)

encrypted, err := m.GetString("secret")
require.NoError(t, err)
require.NotEqual(t, "hello world", encrypted)

decrypted, err := m.GetEncryptedString("secret", key)
require.NoError(t, err)
require.Equal(t, "hello world", decrypted)

// bytes encryption
originalBytes := make([]byte, 128)
_, err = rand.Read(originalBytes)
require.NoError(t, err)
err = m.AddEncrypted("secret-bytes", originalBytes, key)
require.NoError(t, err)

encryptedBytes, err := m.GetBytes("secret-bytes")
require.NoError(t, err)
require.NotEqual(t, originalBytes, encryptedBytes)

decryptedBytes, err := m.GetEncryptedBytes("secret-bytes", key)
require.NoError(t, err)
require.Equal(t, originalBytes, decryptedBytes)

// error cases
t.Run("error on unsupported type", func(t *testing.T) {
err := m.AddEncrypted("invalid", 123, key)
require.ErrorIs(t, err, meta.ErrNotEncryptable)
})

t.Run("error on invalid key size", func(t *testing.T) {
err := m.AddEncrypted("invalid", "test", []byte("short-key"))
require.Error(t, err)
require.Contains(t, err.Error(), "invalid key size")
})

t.Run("error on nil key", func(t *testing.T) {
err := m.AddEncrypted("invalid", "test", nil)
require.NoError(t, err)
// with nil key, value should be stored unencrypted
val, err := m.GetString("invalid")
require.NoError(t, err)
require.Equal(t, "test", val)
})
})
}
Loading
Loading