diff --git a/pkg/meta/internal/crypto/aes.go b/pkg/meta/internal/crypto/aes.go new file mode 100644 index 0000000..482402e --- /dev/null +++ b/pkg/meta/internal/crypto/aes.go @@ -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 +} diff --git a/pkg/meta/internal/crypto/aes_test.go b/pkg/meta/internal/crypto/aes_test.go new file mode 100644 index 0000000..1d0d3e4 --- /dev/null +++ b/pkg/meta/internal/crypto/aes_test.go @@ -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) + }) + } +} diff --git a/pkg/meta/meta.go b/pkg/meta/meta.go index 093ed4a..a08d97c 100644 --- a/pkg/meta/meta.go +++ b/pkg/meta/meta.go @@ -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 @@ -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. @@ -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. @@ -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) { diff --git a/pkg/meta/meta_test.go b/pkg/meta/meta_test.go index ca00be0..2fbb176 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" @@ -13,10 +14,64 @@ 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.Error(t, err) }) + + 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) + + _, err = m.GetString("secret") + require.Error(t, err) // the ciphertext is saved as []byte instead of string + + 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.Error(t, err) + require.Contains(t, err.Error(), "encryption key is required") + }) + }) } diff --git a/pkg/meta/readonly.go b/pkg/meta/readonly.go index 1c8188d..ce3674e 100644 --- a/pkg/meta/readonly.go +++ b/pkg/meta/readonly.go @@ -17,6 +17,10 @@ func (r ReadOnly) GetString(key string) (string, error) { return r.m.GetString(key) } +func (r ReadOnly) GetEncryptedString(key string, encryptionKey []byte) (string, error) { + return r.m.GetEncryptedString(key, encryptionKey) +} + func (r ReadOnly) GetInt64(key string) (int64, error) { return r.m.GetInt64(key) } @@ -29,6 +33,10 @@ func (r ReadOnly) GetBytes(key string) ([]byte, error) { return r.m.GetBytes(key) } +func (r ReadOnly) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, error) { + return r.m.GetEncryptedBytes(key, encryptionKey) +} + func (r ReadOnly) GetNode(key string) (ipld.Node, error) { return r.m.GetNode(key) } diff --git a/token/delegation/delegation_test.go b/token/delegation/delegation_test.go index 42008b4..b49578c 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) { @@ -121,6 +124,109 @@ 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.WithEncryptedMetaString(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) + + _, err = decodedTkn.Meta().GetString(tt.key) + require.Error(t, err) + + 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", + } + var opts []delegation.Option + for k, v := range values { + opts = append(opts, delegation.WithEncryptedMetaString(k, v, encryptionKey)) + } + + // Create token with multiple encrypted values + tkn, err := delegation.New(privKey, aud, cmd, pol, opts...) + 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..4df14e7 100644 --- a/token/delegation/options.go +++ b/token/delegation/options.go @@ -44,6 +44,22 @@ func WithMeta(key string, val any) Option { } } +// WithEncryptedMetaString adds a key/value pair in the "meta" field. +// The string value is encrypted with the given aesKey. +func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option { + return func(t *Token) error { + return t.meta.AddEncrypted(key, val, encryptionKey) + } +} + +// WithEncryptedMetaBytes adds a key/value pair in the "meta" field. +// The []byte value is encrypted with the given aesKey. +func WithEncryptedMetaBytes(key string, val, 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 { diff --git a/token/invocation/options.go b/token/invocation/options.go index 9322cd7..349af2c 100644 --- a/token/invocation/options.go +++ b/token/invocation/options.go @@ -44,6 +44,22 @@ func WithMeta(key string, val any) Option { } } +// WithEncryptedMetaString adds a key/value pair in the "meta" field. +// The string value is encrypted with the given aesKey. +func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option { + return func(t *Token) error { + return t.meta.AddEncrypted(key, val, encryptionKey) + } +} + +// WithEncryptedMetaBytes adds a key/value pair in the "meta" field. +// The []byte value is encrypted with the given aesKey. +func WithEncryptedMetaBytes(key string, val, encryptionKey []byte) Option { + return func(t *Token) error { + return t.meta.AddEncrypted(key, val, encryptionKey) + } +} + // WithNonce sets the Token's nonce with the given value. // // If this option is not used, a random 12-byte nonce is generated for