From 76c015e78b9086a56e3c2927c7f791fdc4afba15 Mon Sep 17 00:00:00 2001 From: Fabio Bozzo Date: Thu, 31 Oct 2024 18:24:54 +0100 Subject: [PATCH] feat(meta): values symmetric encryption --- pkg/crypto/aes.go | 62 +++++++++++++++ pkg/crypto/aes_test.go | 113 ++++++++++++++++++++++++++++ pkg/meta/meta.go | 59 ++++++++++++++- pkg/meta/meta_test.go | 61 ++++++++++++++- token/delegation/delegation_test.go | 109 +++++++++++++++++++++++++++ token/delegation/options.go | 9 +++ 6 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 pkg/crypto/aes.go create mode 100644 pkg/crypto/aes_test.go diff --git a/pkg/crypto/aes.go b/pkg/crypto/aes.go new file mode 100644 index 0000000..cfe27f4 --- /dev/null +++ b/pkg/crypto/aes.go @@ -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 + } + + 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 + } + + 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 +} diff --git a/pkg/crypto/aes_test.go b/pkg/crypto/aes_test.go new file mode 100644 index 0000000..b532fc1 --- /dev/null +++ b/pkg/crypto/aes_test.go @@ -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) + }) + } +} diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index a51eec7..01baf11 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -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, @@ -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) + 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. @@ -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. @@ -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) { diff --git a/pkg/meta/meta_test.go b/pkg/meta/meta_test.go index 9ad3e2c..367f54e 100644 --- a/pkg/meta/meta_test.go +++ b/pkg/meta/meta_test.go @@ -1,6 +1,7 @@ package meta_test import ( + "crypto/rand" "testing" "github.com/stretchr/testify/require" @@ -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) + }) + }) } diff --git a/token/delegation/delegation_test.go b/token/delegation/delegation_test.go index 3c662fc..113ea52 100644 --- a/token/delegation/delegation_test.go +++ b/token/delegation/delegation_test.go @@ -1,6 +1,7 @@ package delegation_test import ( + "encoding/base64" "testing" "time" @@ -66,6 +67,8 @@ const ( newCID = "zdpuAn9JgGPvnt2WCmTaKktZdbuvcVGTg9bUT5kQaufwUtZ6e" rootCID = "zdpuAkgGmUp5JrXvehGuuw9JA8DLQKDaxtK3R8brDQQVC2i5X" + + aesKey = "xQklMmNTnVrmaPBq/0pwV5fEwuv/iClF5HWak9MsgI8=" ) func TestConstructors(t *testing.T) { @@ -125,6 +128,112 @@ func TestConstructors(t *testing.T) { }) } +func TestEncryptedMeta(t *testing.T) { + t.Parallel() + + privKey := privKey(t, issuerPrivKeyCfg) + aud, err := did.Parse(AudienceDID) + require.NoError(t, err) + cmd, err := command.Parse(subJectCmd) + require.NoError(t, err) + pol, err := policy.FromDagJson(subjectPol) + require.NoError(t, err) + + encryptionKey, err := base64.StdEncoding.DecodeString(aesKey) + require.NoError(t, err) + require.Len(t, encryptionKey, 32) + + tests := []struct { + name string + key string + value string + expectError bool + }{ + { + name: "simple string", + key: "secret1", + value: "hello world", + }, + { + name: "empty string", + key: "secret2", + value: "", + }, + { + name: "special characters", + key: "secret3", + value: "!@#$%^&*()_+-=[]{}|;:,.<>?", + }, + { + name: "unicode characters", + key: "secret4", + value: "Hello, δΈ–η•Œ! πŸ‘‹", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tkn, err := delegation.New(privKey, aud, cmd, pol, + delegation.WithEncryptedMeta(tt.key, tt.value, encryptionKey), + ) + require.NoError(t, err) + + data, err := tkn.ToDagCbor(privKey) + require.NoError(t, err) + + decodedTkn, _, err := delegation.FromSealed(data) + require.NoError(t, err) + + encrypted, err := decodedTkn.Meta().GetString(tt.key) + require.NoError(t, err) + // Verify the encrypted value is different from original + require.NotEqual(t, tt.value, encrypted) + + decrypted, err := decodedTkn.Meta().GetEncryptedString(tt.key, encryptionKey) + require.NoError(t, err) + // Verify the decrypted value is equal to the original + require.Equal(t, tt.value, decrypted) + + // Try to decrypt with wrong key + wrongKey := make([]byte, 32) + _, err = decodedTkn.Meta().GetEncryptedString(tt.key, wrongKey) + require.Error(t, err) + }) + } + + t.Run("multiple encrypted values in the same token", func(t *testing.T) { + values := map[string]string{ + "secret1": "value1", + "secret2": "value2", + "secret3": "value3", + } + + // Create token with multiple encrypted values + tkn, err := delegation.New(privKey, aud, cmd, pol, delegation.WithMeta("foo", "bar")) + require.NoError(t, err) + + for k, v := range values { + err := tkn.Meta().AddEncrypted(k, v, encryptionKey) + require.NoError(t, err) + } + + data, err := tkn.ToDagCbor(privKey) + require.NoError(t, err) + + decodedTkn, _, err := delegation.FromSealed(data) + require.NoError(t, err) + + for k, v := range values { + decrypted, err := decodedTkn.Meta().GetEncryptedString(k, encryptionKey) + require.NoError(t, err) + require.Equal(t, v, decrypted) + } + }) +} + func privKey(t require.TestingT, privKeyCfg string) crypto.PrivKey { privKeyMar, err := crypto.ConfigDecodeKey(privKeyCfg) require.NoError(t, err) diff --git a/token/delegation/options.go b/token/delegation/options.go index 3c0b5db..ee2eac2 100644 --- a/token/delegation/options.go +++ b/token/delegation/options.go @@ -44,6 +44,15 @@ func WithMeta(key string, val any) Option { } } +// WithEncryptedMeta adds a key/value pair in the "meta" field. +// The value is encrypted with the given aesKey. +// Accepted types for the value are: string, []byte. +func WithEncryptedMeta(key string, val any, encryptionKey []byte) Option { + return func(t *Token) error { + return t.meta.AddEncrypted(key, val, encryptionKey) + } +} + // WithNotBefore set's the Token's optional "notBefore" field to the value // of the provided time.Time. func WithNotBefore(nbf time.Time) Option {