Skip to content

Commit 722dbaa

Browse files
committed
feat: implement encrypted chunked storage and convergent encryption
Signed-off-by: skidoodle <contact@albert.lol>
1 parent 2d6a3ab commit 722dbaa

File tree

4 files changed

+284
-44
lines changed

4 files changed

+284
-44
lines changed

internal/app/server_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app
22

33
import (
44
"bytes"
5+
"encoding/base64"
56
"fmt"
67
"io"
78
"log/slog"
@@ -12,6 +13,8 @@ import (
1213
"path/filepath"
1314
"strings"
1415
"testing"
16+
17+
"github.com/skidoodle/safebin/internal/crypto"
1518
)
1619

1720
func setupTestApp(t *testing.T) (*App, string) {
@@ -176,6 +179,102 @@ func TestIntegration_ChunkedUpload(t *testing.T) {
176179
}
177180
}
178181

182+
func TestIntegration_ChunkedUpload_VerifyEncryption(t *testing.T) {
183+
app, storageDir := setupTestApp(t)
184+
server := httptest.NewServer(app.Routes())
185+
defer server.Close()
186+
187+
uploadID := "securechunk123"
188+
plaintext := []byte("This is a secret message that should be encrypted")
189+
190+
uploadChunk(t, server.URL, uploadID, 0, plaintext)
191+
192+
chunkPath := filepath.Join(storageDir, TempDirName, uploadID, "0")
193+
encryptedData, err := os.ReadFile(chunkPath)
194+
if err != nil {
195+
t.Fatalf("Failed to read chunk file: %v", err)
196+
}
197+
198+
if bytes.Contains(encryptedData, plaintext) {
199+
t.Fatal("Chunk file contains plaintext data!")
200+
}
201+
202+
if len(encryptedData) <= crypto.KeySize {
203+
t.Fatalf("Chunk file too small: %d bytes", len(encryptedData))
204+
}
205+
206+
key := encryptedData[:crypto.KeySize]
207+
ciphertext := encryptedData[crypto.KeySize:]
208+
209+
streamer, err := crypto.NewGCMStreamer(key)
210+
if err != nil {
211+
t.Fatalf("Failed to create streamer: %v", err)
212+
}
213+
214+
r := bytes.NewReader(ciphertext)
215+
d := crypto.NewDecryptor(r, streamer.AEAD, int64(len(ciphertext)))
216+
217+
decrypted, err := io.ReadAll(d)
218+
if err != nil {
219+
t.Fatalf("Failed to decrypt chunk: %v", err)
220+
}
221+
222+
if !bytes.Equal(decrypted, plaintext) {
223+
t.Errorf("Decrypted data mismatch.\nWant: %s\nGot: %s", plaintext, decrypted)
224+
}
225+
}
226+
227+
func TestIntegration_Upload_VerifyEncryption(t *testing.T) {
228+
app, storageDir := setupTestApp(t)
229+
server := httptest.NewServer(app.Routes())
230+
defer server.Close()
231+
232+
plaintext := []byte("Sensitive Data For Full Upload")
233+
234+
body := &bytes.Buffer{}
235+
writer := multipart.NewWriter(body)
236+
part, _ := writer.CreateFormFile("file", "secret.txt")
237+
part.Write(plaintext)
238+
writer.Close()
239+
240+
req, _ := http.NewRequest("POST", server.URL+"/", body)
241+
req.Header.Set("Content-Type", writer.FormDataContentType())
242+
resp, err := http.DefaultClient.Do(req)
243+
if err != nil {
244+
t.Fatal(err)
245+
}
246+
defer resp.Body.Close()
247+
248+
respBytes, _ := io.ReadAll(resp.Body)
249+
slug := filepath.Base(strings.TrimSpace(string(respBytes)))
250+
251+
if len(slug) < SlugLength {
252+
t.Fatalf("Invalid slug: %s", slug)
253+
}
254+
keyBase64 := slug[:SlugLength]
255+
key, _ := base64.RawURLEncoding.DecodeString(keyBase64)
256+
ext := filepath.Ext("secret.txt")
257+
id := crypto.GetID(key, ext)
258+
259+
finalPath := filepath.Join(storageDir, id)
260+
finalData, err := os.ReadFile(finalPath)
261+
if err != nil {
262+
t.Fatalf("Failed to read final file: %v", err)
263+
}
264+
265+
if bytes.Contains(finalData, plaintext) {
266+
t.Fatal("Final file contains plaintext!")
267+
}
268+
269+
streamer, _ := crypto.NewGCMStreamer(key)
270+
d := crypto.NewDecryptor(bytes.NewReader(finalData), streamer.AEAD, int64(len(finalData)))
271+
decrypted, _ := io.ReadAll(d)
272+
273+
if !bytes.Equal(decrypted, plaintext) {
274+
t.Error("Final file decryption failed")
275+
}
276+
}
277+
179278
func uploadChunk(t *testing.T, baseURL, uid string, idx int, data []byte) {
180279
body := &bytes.Buffer{}
181280
writer := multipart.NewWriter(body)

internal/app/storage.go

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app
22

33
import (
44
"context"
5+
"crypto/rand"
56
"encoding/json"
67
"fmt"
78
"io"
@@ -48,15 +49,30 @@ func (app *App) saveChunk(uid string, idx int, src io.Reader) error {
4849
}
4950
}()
5051

51-
if _, err := io.Copy(dest, src); err != nil {
52-
return fmt.Errorf("copy chunk: %w", err)
52+
key := make([]byte, crypto.KeySize)
53+
if _, err := rand.Read(key); err != nil {
54+
return fmt.Errorf("generate chunk key: %w", err)
55+
}
56+
57+
if _, err := dest.Write(key); err != nil {
58+
return fmt.Errorf("write chunk key: %w", err)
59+
}
60+
61+
streamer, err := crypto.NewGCMStreamer(key)
62+
if err != nil {
63+
return fmt.Errorf("create streamer: %w", err)
64+
}
65+
66+
if err := streamer.EncryptStream(dest, src); err != nil {
67+
return fmt.Errorf("encrypt chunk: %w", err)
5368
}
5469

5570
return nil
5671
}
5772

58-
func (app *App) openChunkFiles(uid string, total int) ([]*os.File, error) {
73+
func (app *App) getChunkDecryptors(uid string, total int) ([]io.ReadSeeker, func(), error) {
5974
files := make([]*os.File, 0, total)
75+
decryptors := make([]io.ReadSeeker, 0, total)
6076

6177
closeAll := func() {
6278
for _, f := range files {
@@ -69,12 +85,41 @@ func (app *App) openChunkFiles(uid string, total int) ([]*os.File, error) {
6985
f, err := os.Open(partPath)
7086
if err != nil {
7187
closeAll()
72-
return nil, fmt.Errorf("open chunk %d: %w", i, err)
88+
return nil, nil, fmt.Errorf("open chunk %d: %w", i, err)
7389
}
7490
files = append(files, f)
91+
92+
key := make([]byte, crypto.KeySize)
93+
if _, err := io.ReadFull(f, key); err != nil {
94+
closeAll()
95+
return nil, nil, fmt.Errorf("read chunk key %d: %w", i, err)
96+
}
97+
98+
info, err := f.Stat()
99+
if err != nil {
100+
closeAll()
101+
return nil, nil, fmt.Errorf("stat chunk %d: %w", i, err)
102+
}
103+
104+
bodySize := info.Size() - int64(crypto.KeySize)
105+
if bodySize < 0 {
106+
closeAll()
107+
return nil, nil, fmt.Errorf("invalid chunk size %d", i)
108+
}
109+
110+
bodyReader := io.NewSectionReader(f, int64(crypto.KeySize), bodySize)
111+
112+
streamer, err := crypto.NewGCMStreamer(key)
113+
if err != nil {
114+
closeAll()
115+
return nil, nil, fmt.Errorf("create streamer %d: %w", i, err)
116+
}
117+
118+
decryptor := crypto.NewDecryptor(bodyReader, streamer.AEAD, bodySize)
119+
decryptors = append(decryptors, decryptor)
75120
}
76121

77-
return files, nil
122+
return decryptors, closeAll, nil
78123
}
79124

80125
func (app *App) encryptAndSave(src io.Reader, key []byte, finalPath string) error {

internal/app/storage_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package app
22

33
import (
4+
"bytes"
5+
"crypto/rand"
46
"encoding/json"
7+
"io"
58
"os"
69
"path/filepath"
710
"testing"
811
"time"
912

13+
"github.com/skidoodle/safebin/internal/crypto"
1014
"go.etcd.io/bbolt"
1115
)
1216

@@ -131,3 +135,84 @@ func TestCleanup_ExpiredStorage(t *testing.T) {
131135
t.Fatalf("DB View failed: %v", err)
132136
}
133137
}
138+
139+
func TestSaveChunk_EncryptsData(t *testing.T) {
140+
tmpDir := t.TempDir()
141+
app := &App{
142+
Conf: Config{StorageDir: tmpDir},
143+
Logger: discardLogger(),
144+
}
145+
146+
uid := "test-encrypt-chunk"
147+
plaintext := make([]byte, 1024)
148+
if _, err := rand.Read(plaintext); err != nil {
149+
t.Fatal(err)
150+
}
151+
152+
if err := app.saveChunk(uid, 0, bytes.NewReader(plaintext)); err != nil {
153+
t.Fatalf("saveChunk failed: %v", err)
154+
}
155+
156+
path := filepath.Join(tmpDir, TempDirName, uid, "0")
157+
fileData, err := os.ReadFile(path)
158+
if err != nil {
159+
t.Fatalf("ReadFile failed: %v", err)
160+
}
161+
162+
if bytes.Equal(fileData, plaintext) {
163+
t.Fatal("Chunk stored as plaintext!")
164+
}
165+
if bytes.Contains(fileData, plaintext) {
166+
t.Fatal("Chunk contains plaintext!")
167+
}
168+
169+
expectedSize := crypto.KeySize + len(plaintext) + 16
170+
if len(fileData) != expectedSize {
171+
t.Errorf("Unexpected file size. Want %d, got %d", expectedSize, len(fileData))
172+
}
173+
}
174+
175+
func TestGetChunkDecryptors_RestoresData(t *testing.T) {
176+
tmpDir := t.TempDir()
177+
app := &App{
178+
Conf: Config{StorageDir: tmpDir},
179+
Logger: discardLogger(),
180+
}
181+
182+
uid := "test-restore"
183+
data1 := []byte("chunk one data")
184+
data2 := []byte("chunk two data")
185+
186+
if err := app.saveChunk(uid, 0, bytes.NewReader(data1)); err != nil {
187+
t.Fatal(err)
188+
}
189+
if err := app.saveChunk(uid, 1, bytes.NewReader(data2)); err != nil {
190+
t.Fatal(err)
191+
}
192+
193+
decryptors, closeFn, err := app.getChunkDecryptors(uid, 2)
194+
if err != nil {
195+
t.Fatalf("getChunkDecryptors failed: %v", err)
196+
}
197+
defer closeFn()
198+
199+
if len(decryptors) != 2 {
200+
t.Fatalf("Expected 2 decryptors, got %d", len(decryptors))
201+
}
202+
203+
buf1, err := io.ReadAll(decryptors[0])
204+
if err != nil {
205+
t.Fatalf("Failed to read decryptor 1: %v", err)
206+
}
207+
if !bytes.Equal(buf1, data1) {
208+
t.Errorf("Chunk 1 mismatch. Want %s, got %s", data1, buf1)
209+
}
210+
211+
buf2, err := io.ReadAll(decryptors[1])
212+
if err != nil {
213+
t.Fatalf("Failed to read decryptor 2: %v", err)
214+
}
215+
if !bytes.Equal(buf2, data2) {
216+
t.Errorf("Chunk 2 mismatch. Want %s, got %s", data2, buf2)
217+
}
218+
}

0 commit comments

Comments
 (0)