From e7442acd7f908549ce50e2d6691a6cbc2e1f9028 Mon Sep 17 00:00:00 2001 From: Ali Date: Mon, 18 May 2026 08:06:40 +0400 Subject: [PATCH] fix: prevent plaintext from being inadventerly leaked in logs, stack traces, etc. --- encrypted_bytes.go | 31 +++++--- encrypted_bytes_test.go | 152 +++++++++++++++++++++++++++++++++++++++- random_test.go | 43 ++++++++++++ 3 files changed, 215 insertions(+), 11 deletions(-) create mode 100644 random_test.go diff --git a/encrypted_bytes.go b/encrypted_bytes.go index 2641fef..cd3b3f3 100644 --- a/encrypted_bytes.go +++ b/encrypted_bytes.go @@ -6,18 +6,12 @@ import ( "database/sql/driver" "encoding/json" "errors" + "fmt" "gorm.io/gorm" "gorm.io/gorm/schema" ) -var ( - _ driver.Valuer = &EncryptedBytes{} - _ sql.Scanner = &EncryptedBytes{} - _ json.Marshaler = &EncryptedBytes{} - _ json.Unmarshaler = &EncryptedBytes{} -) - func NewEncryptedBytes(s string) EncryptedBytes { e := &EncryptedBytes{} @@ -50,7 +44,20 @@ func (e *EncryptedBytes) GormDBDataType(db *gorm.DB, field *schema.Field) string } } +// String intentionally returns a redacted placeholder to +// safeguard the plaintext from being inadventerly leaked +// in logs, stack traces, etc. +// +// To deliberately access the plaintext, call Plaintext() or Bytes(). func (e EncryptedBytes) String() string { + return "[REDACTED]" +} + +// Plaintext returns the decrypted contents as a string. +// +// It is the caller's responsibility to ensure this value is +// not logged or otherwise exposed. +func (e EncryptedBytes) Plaintext() string { return string(e) } @@ -59,7 +66,7 @@ func (e EncryptedBytes) Bytes() []byte { } // Scan implements the scanner interface -func (e *EncryptedBytes) Scan(value interface{}) error { +func (e *EncryptedBytes) Scan(value any) error { b, ok := value.([]byte) if !ok { return errors.New("failed to read value as bytes") @@ -122,3 +129,11 @@ func (e *EncryptedBytes) UnmarshalJSON(data []byte) error { return e.Scan(b) } + +var ( + _ driver.Valuer = &EncryptedBytes{} + _ sql.Scanner = &EncryptedBytes{} + _ json.Marshaler = &EncryptedBytes{} + _ json.Unmarshaler = &EncryptedBytes{} + _ fmt.Stringer = &EncryptedBytes{} +) diff --git a/encrypted_bytes_test.go b/encrypted_bytes_test.go index df85fe0..44cfac1 100644 --- a/encrypted_bytes_test.go +++ b/encrypted_bytes_test.go @@ -2,6 +2,7 @@ package sqlcrypter import ( "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -19,8 +20,153 @@ func Test_NewEncryptedBytes(t *testing.T) { t.Run("success", func(t *testing.T) { s := "Hello World" e := NewEncryptedBytes(s) - assert.Equal(t, s, e.String()) + assert.Equal(t, s, e.Plaintext()) }) + + t.Run("String redacts", func(t *testing.T) { + e := NewEncryptedBytes("super secret") + assert.Equal(t, "[REDACTED]", e.String()) + }) +} + +func Test_EncryptedBytes_String(t *testing.T) { + secret := "do-not-leak-this-credential" + e := NewEncryptedBytes(secret) + + out := fmt.Sprint(e) + assert.NotContains(t, out, secret, "formatted output must not contain plaintext") + assert.Equal(t, "[REDACTED]", out) +} + +func Test_EncryptedBytes_Plaintext(t *testing.T) { + tests := []struct { + name string + value func() EncryptedBytes + want string + nilBuf bool + }{ + { + name: "empty", + value: func() EncryptedBytes { return NewEncryptedBytes("") }, + want: "", + nilBuf: true, + }, + { + name: "non_empty", + value: func() EncryptedBytes { return NewEncryptedBytes("Hello World") }, + want: "Hello World", + }, + { + name: "unicode", + value: func() EncryptedBytes { return NewEncryptedBytes("你好世界") }, + want: "你好世界", + }, + { + name: "nil_slice", + value: func() EncryptedBytes { + var e EncryptedBytes + return e + }, + want: "", + nilBuf: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := tt.value() + assert.Equal(t, tt.want, e.Plaintext()) + if tt.nilBuf { + assert.Nil(t, []byte(e)) + } + }) + } +} + +func Test_EncryptedBytes_Bytes(t *testing.T) { + tests := []struct { + name string + value func() EncryptedBytes + want []byte + nilBuf bool + mutateFirst bool // mutating returned slice updates backing EncryptedBytes + afterMutate string + }{ + { + name: "empty", + value: func() EncryptedBytes { return NewEncryptedBytes("") }, + want: nil, + nilBuf: true, + }, + { + name: "non_empty", + value: func() EncryptedBytes { return NewEncryptedBytes("Hello World") }, + want: []byte("Hello World"), + mutateFirst: true, + afterMutate: "Jello World", + }, + { + name: "unicode", + value: func() EncryptedBytes { return NewEncryptedBytes("你好世界") }, + want: []byte("你好世界"), + }, + { + name: "nil_slice", + value: func() EncryptedBytes { + var e EncryptedBytes + return e + }, + want: nil, + nilBuf: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := tt.value() + got := e.Bytes() + assert.Equal(t, tt.want, got) + if tt.nilBuf { + assert.Nil(t, got) + } + if tt.mutateFirst { + require.NotEmpty(t, got) + got[0] = 'J' + assert.Equal(t, tt.afterMutate, e.Plaintext()) + } + }) + } +} + +func Test_EncryptedBytes_GormDataType(t *testing.T) { + tests := []struct { + name string + value func() EncryptedBytes + }{ + { + name: "with_plaintext", + value: func() EncryptedBytes { return NewEncryptedBytes("secret") }, + }, + { + name: "empty_constructor", + value: func() EncryptedBytes { return NewEncryptedBytes("") }, + }, + { + name: "nil_slice", + value: func() EncryptedBytes { + var e EncryptedBytes + return e + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := tt.value() + p := &e + assert.Equal(t, "encryptedbytes", p.GormDataType()) + }) + } } func Test_EncryptedBytes_Scan(t *testing.T) { @@ -45,7 +191,7 @@ func Test_EncryptedBytes_Scan(t *testing.T) { e := &EncryptedBytes{} err := e.Scan([]byte("SGVsbG8gV29ybGQ=")) require.NoError(t, err) - assert.Equal(t, "Hello World", e.String()) + assert.Equal(t, "Hello World", e.Plaintext()) }) } @@ -96,5 +242,5 @@ func Test_EncryptedBytes_UnmarshalJSON(t *testing.T) { err := json.Unmarshal(data, &e) require.NoError(t, err) - assert.Equal(t, "Hello World", e.Secret.String()) + assert.Equal(t, "Hello World", e.Secret.Plaintext()) } diff --git a/random_test.go b/random_test.go new file mode 100644 index 0000000..a401a98 --- /dev/null +++ b/random_test.go @@ -0,0 +1,43 @@ +package sqlcrypter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateBytes(t *testing.T) { + tests := []struct { + name string + n int + wantLen int + }{ + {name: "n_zero", n: 0, wantLen: 0}, + {name: "n_one", n: 1, wantLen: 1}, + {name: "n_16", n: 16, wantLen: 16}, + {name: "n_256", n: 256, wantLen: 256}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := GenerateBytes(tt.n) + require.NoError(t, err) + require.Len(t, got, tt.wantLen) + }) + } + + t.Run("distinct_outputs", func(t *testing.T) { + a, err := GenerateBytes(32) + require.NoError(t, err) + b, err := GenerateBytes(32) + require.NoError(t, err) + assert.NotEqual(t, a, b, "two 32-byte draws from crypto/rand should almost never match") + }) +} + +func TestGenerateBytes_Panics(t *testing.T) { + assert.Panics(t, func() { + _, _ = GenerateBytes(-1) + }) +}