Skip to content

Commit 31e45bf

Browse files
committed
Add security utilities to zero out secrets
1 parent 104b625 commit 31e45bf

4 files changed

Lines changed: 336 additions & 3 deletions

File tree

cmd/mpcium/main.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,6 @@ func loadPasswordFromFile(filePath string) error {
314314
}
315315

316316
viper.Set("badger_password", password)
317-
logger.Info(fmt.Sprintf("Loaded BadgerDB password from file: %s", filePath), "password", password)
318-
319317
security.ZeroBytes(passwordBytes)
320318
security.ZeroString(&password)
321319

examples/generate/kms/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func main() {
3030

3131
flag.Parse()
3232

33-
config.InitViperConfig()
33+
config.InitViperConfig("")
3434
logger.Init(environment, false)
3535

3636
// KMS signer only supports P256

pkg/security/memory.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package security
2+
3+
import (
4+
"runtime"
5+
)
6+
7+
// ZeroBytes securely zeros out a byte slice to prevent sensitive data from
8+
// remaining in memory. This uses explicit memory zeroing and garbage collection
9+
// to help ensure the data is actually cleared.
10+
func ZeroBytes(data []byte) {
11+
if len(data) == 0 {
12+
return
13+
}
14+
15+
// Zero out the slice
16+
for i := range data {
17+
data[i] = 0
18+
}
19+
20+
// Force garbage collection to help ensure the zeroed memory is reclaimed
21+
runtime.GC()
22+
}
23+
24+
// ZeroString securely clears a string reference and encourages garbage collection.
25+
// Note: Go strings are immutable, so we can only clear the reference and rely on GC.
26+
// This provides best-effort security by clearing the reference and forcing GC.
27+
func ZeroString(s *string) {
28+
if s == nil {
29+
return
30+
}
31+
32+
// Clear the string reference - this is the safe approach
33+
// The actual string data will be garbage collected
34+
*s = ""
35+
36+
// Force garbage collection to help clear the original string data from memory
37+
// This is best-effort as GC timing is not guaranteed
38+
runtime.GC()
39+
runtime.GC() // Run twice to increase chances of collection
40+
}
41+
42+
// SecureBytes is a wrapper for sensitive byte data that automatically
43+
// zeros itself when no longer needed
44+
type SecureBytes struct {
45+
data []byte
46+
}
47+
48+
// NewSecureBytes creates a new SecureBytes instance
49+
func NewSecureBytes(data []byte) *SecureBytes {
50+
// Make a copy to ensure we own the memory
51+
copied := make([]byte, len(data))
52+
copy(copied, data)
53+
54+
sb := &SecureBytes{data: copied}
55+
56+
// Set finalizer to zero memory when GC'd
57+
runtime.SetFinalizer(sb, (*SecureBytes).zero)
58+
59+
return sb
60+
}
61+
62+
// Bytes returns the underlying byte slice (use with caution)
63+
func (sb *SecureBytes) Bytes() []byte {
64+
return sb.data
65+
}
66+
67+
// Copy returns a copy of the data
68+
func (sb *SecureBytes) Copy() []byte {
69+
result := make([]byte, len(sb.data))
70+
copy(result, sb.data)
71+
return result
72+
}
73+
74+
// Clear explicitly zeros the data and removes the finalizer
75+
func (sb *SecureBytes) Clear() {
76+
sb.zero()
77+
runtime.SetFinalizer(sb, nil)
78+
}
79+
80+
// zero securely clears the data
81+
func (sb *SecureBytes) zero() {
82+
if sb.data != nil {
83+
ZeroBytes(sb.data)
84+
sb.data = nil
85+
}
86+
}

pkg/security/memory_test.go

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package security
2+
3+
import (
4+
"bytes"
5+
"runtime"
6+
"testing"
7+
)
8+
9+
func TestZeroBytes(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
data []byte
13+
}{
14+
{
15+
name: "non-empty slice",
16+
data: []byte("sensitive data"),
17+
},
18+
{
19+
name: "empty slice",
20+
data: []byte{},
21+
},
22+
{
23+
name: "nil slice",
24+
data: nil,
25+
},
26+
{
27+
name: "single byte",
28+
data: []byte{0x42},
29+
},
30+
{
31+
name: "binary data",
32+
data: []byte{0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd},
33+
},
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
original := make([]byte, len(tt.data))
39+
copy(original, tt.data)
40+
41+
ZeroBytes(tt.data)
42+
43+
// Verify all bytes are zeroed
44+
for i, b := range tt.data {
45+
if b != 0 {
46+
t.Errorf("byte at index %d not zeroed: got %d, want 0", i, b)
47+
}
48+
}
49+
50+
// Verify we didn't panic on edge cases
51+
if len(tt.data) == 0 {
52+
// Should handle empty/nil slices gracefully
53+
return
54+
}
55+
56+
// Verify the slice was actually modified
57+
if bytes.Equal(tt.data, original) && len(original) > 0 {
58+
t.Error("slice was not modified")
59+
}
60+
})
61+
}
62+
}
63+
64+
func TestZeroString(t *testing.T) {
65+
tests := []struct {
66+
name string
67+
input string
68+
expected string
69+
}{
70+
{
71+
name: "non-empty string",
72+
input: "sensitive password",
73+
expected: "",
74+
},
75+
{
76+
name: "empty string",
77+
input: "",
78+
expected: "",
79+
},
80+
{
81+
name: "single character",
82+
input: "x",
83+
expected: "",
84+
},
85+
{
86+
name: "unicode string",
87+
input: "🔐password🔑",
88+
expected: "",
89+
},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
s := tt.input
95+
96+
ZeroString(&s)
97+
98+
// Verify string is now empty
99+
if s != tt.expected {
100+
t.Errorf("string not cleared: got %q, want %q", s, tt.expected)
101+
}
102+
103+
// Note: We can't reliably test if the underlying memory was zeroed
104+
// because Go strings are immutable and memory clearing depends on GC timing
105+
})
106+
}
107+
}
108+
109+
func TestZeroStringNilPointer(t *testing.T) {
110+
// Test that ZeroString handles nil pointer gracefully
111+
defer func() {
112+
if r := recover(); r != nil {
113+
t.Errorf("ZeroString panicked with nil pointer: %v", r)
114+
}
115+
}()
116+
117+
ZeroString(nil)
118+
}
119+
120+
func TestSecureBytes(t *testing.T) {
121+
t.Run("basic functionality", func(t *testing.T) {
122+
original := []byte("secret data")
123+
sb := NewSecureBytes(original)
124+
125+
// Verify data is accessible
126+
data := sb.Bytes()
127+
if !bytes.Equal(data, original) {
128+
t.Errorf("SecureBytes data mismatch: got %v, want %v", data, original)
129+
}
130+
131+
// Verify copy works
132+
copied := sb.Copy()
133+
if !bytes.Equal(copied, original) {
134+
t.Errorf("SecureBytes copy mismatch: got %v, want %v", copied, original)
135+
}
136+
137+
// Verify modifying copy doesn't affect original
138+
copied[0] = 'X'
139+
if bytes.Equal(sb.Bytes(), copied) {
140+
t.Error("SecureBytes copy shares memory with original")
141+
}
142+
})
143+
144+
t.Run("manual clear", func(t *testing.T) {
145+
sb := NewSecureBytes([]byte("secret"))
146+
sb.Clear()
147+
148+
// After Clear(), data should be nil
149+
if sb.data != nil {
150+
t.Error("SecureBytes data not nil after Clear()")
151+
}
152+
153+
// Calling Clear() again should not panic
154+
sb.Clear()
155+
})
156+
157+
t.Run("finalizer behavior", func(t *testing.T) {
158+
// This test verifies that the finalizer doesn't panic
159+
// We can't easily test that it actually zeros memory due to GC timing
160+
func() {
161+
sb := NewSecureBytes([]byte("secret"))
162+
_ = sb // Use the variable to prevent optimization
163+
}()
164+
165+
// Force garbage collection to potentially trigger finalizer
166+
runtime.GC()
167+
runtime.GC()
168+
169+
// If we reach here without panic, the finalizer worked correctly
170+
})
171+
172+
t.Run("empty data", func(t *testing.T) {
173+
sb := NewSecureBytes([]byte{})
174+
175+
if len(sb.Bytes()) != 0 {
176+
t.Error("SecureBytes should handle empty data")
177+
}
178+
179+
sb.Clear()
180+
// Should not panic
181+
})
182+
183+
t.Run("nil data", func(t *testing.T) {
184+
sb := NewSecureBytes(nil)
185+
186+
if sb.Bytes() == nil {
187+
t.Error("SecureBytes should create empty slice for nil input")
188+
}
189+
190+
if len(sb.Bytes()) != 0 {
191+
t.Error("SecureBytes should create empty slice for nil input")
192+
}
193+
})
194+
}
195+
196+
func TestSecureBytesIsolation(t *testing.T) {
197+
// Verify that SecureBytes creates its own copy of data
198+
original := []byte("secret data")
199+
sb := NewSecureBytes(original)
200+
201+
// Modify the original
202+
original[0] = 'X'
203+
204+
// SecureBytes should be unaffected
205+
if sb.Bytes()[0] == 'X' {
206+
t.Error("SecureBytes shares memory with input data")
207+
}
208+
}
209+
210+
// Benchmark tests to ensure performance is reasonable
211+
func BenchmarkZeroBytes(b *testing.B) {
212+
data := make([]byte, 1024)
213+
for i := range data {
214+
data[i] = byte(i % 256)
215+
}
216+
217+
b.ResetTimer()
218+
for i := 0; i < b.N; i++ {
219+
// Reset data for each iteration
220+
for j := range data {
221+
data[j] = byte(j % 256)
222+
}
223+
ZeroBytes(data)
224+
}
225+
}
226+
227+
func BenchmarkZeroString(b *testing.B) {
228+
original := "this is a test string that represents a password or other sensitive data"
229+
230+
b.ResetTimer()
231+
for i := 0; i < b.N; i++ {
232+
s := original
233+
ZeroString(&s)
234+
}
235+
}
236+
237+
func BenchmarkSecureBytes(b *testing.B) {
238+
data := make([]byte, 256)
239+
for i := range data {
240+
data[i] = byte(i)
241+
}
242+
243+
b.ResetTimer()
244+
for i := 0; i < b.N; i++ {
245+
sb := NewSecureBytes(data)
246+
_ = sb.Copy()
247+
sb.Clear()
248+
}
249+
}

0 commit comments

Comments
 (0)