internal/persistence/blob.go (view raw)
1package persistence
2
3import (
4 "bufio"
5 "bytes"
6 "compress/gzip"
7 "crypto/rand"
8 "crypto/sha256"
9 "encoding/hex"
10 "errors"
11 "fmt"
12 "io"
13 "log/slog"
14 "net/mail"
15 "os"
16 "path/filepath"
17
18 "github.com/minio/sio"
19 "golang.org/x/crypto/hkdf"
20)
21
22// WriteBlob gzips and streams r into the nonce-addressed file and returns the hex-encoded nonce that identifies the blob.
23// More information about encryption: https://github.com/minio/sio/blob/master/DARE.md
24func (p *Persistence) WriteBlob(r io.Reader) (string, int64, error) {
25 // Generate a random nonce to derive an encryption key from the master key.
26 var nonce [32]byte
27 if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
28 return "", 0, fmt.Errorf("failed to read random data: %w", err)
29 }
30
31 // Use the nonce as file name
32 nonceHex := hex.EncodeToString(nonce[:])
33 dir := filepath.Join(p.blobRoot, nonceHex[:2])
34 if err := os.MkdirAll(dir, 0755); err != nil {
35 return "", 0, fmt.Errorf("creating blob dir: %w", err)
36 }
37 finalPath := filepath.Join(dir, nonceHex)
38
39 f, err := os.Create(finalPath)
40 if err != nil {
41 return "", 0, fmt.Errorf("creating file: %w", err)
42 }
43
44 // Derive an encryption key from the master key and the nonce
45 var key [32]byte
46 kdf := hkdf.New(sha256.New, p.masterkey, nonce[:], nil)
47 if _, err = io.ReadFull(kdf, key[:]); err != nil {
48 return "", 0, fmt.Errorf("failed to derive encryption key: %w", err)
49 }
50
51 // Create encryption writer
52 encrypted, err := sio.EncryptWriter(f, sio.Config{Key: key[:]})
53 if err != nil {
54 return "", 0, fmt.Errorf("failed to create encrypted writer: %w", err)
55 }
56
57 gzipWriter, err := gzip.NewWriterLevel(encrypted, gzip.BestCompression)
58 if err != nil {
59 return "", 0, fmt.Errorf("failed to create gzip writer: %w", err)
60 }
61
62 size, err := io.Copy(gzipWriter, r)
63 if err != nil {
64 return "", 0, fmt.Errorf("copying data: %w", err)
65 }
66
67 if err := gzipWriter.Close(); err != nil {
68 return "", 0, fmt.Errorf("closing gzip writer: %w", err)
69 }
70
71 if err := encrypted.Close(); err != nil {
72 return "", 0, fmt.Errorf("closing encryption writer: %w", err)
73 }
74
75 return nonceHex, size, nil
76}
77
78// WriteBlobMessage parses the RFC 5322 headers from r, then gzips and streams the entire message to disk.
79func (p *Persistence) WriteBlobMessage(r io.Reader) (string, int64, *mail.Message, error) {
80 msg, fullReader, err := parseMailHeader(r)
81 if err != nil {
82 return "", 0, nil, fmt.Errorf("parsing message header: %w", err)
83 }
84
85 nonceHex, size, err := p.WriteBlob(fullReader)
86 if err != nil {
87 return "", 0, nil, err
88 }
89
90 return nonceHex, size, msg, nil
91}
92
93// parseMailHeader extracts RFC 5322 mail headers line-by-line without reading the whole body into memory.
94func parseMailHeader(r io.Reader) (*mail.Message, io.Reader, error) {
95 var headerBuf bytes.Buffer
96 br := bufio.NewReader(r)
97
98 for {
99 line, err := br.ReadBytes('\n')
100 headerBuf.Write(line)
101
102 // Blank line (\r\n or \n) marks the end of headers
103 if bytes.Equal(line, []byte("\r\n")) || bytes.Equal(line, []byte("\n")) {
104 break
105 }
106
107 if err != nil {
108 if errors.Is(err, io.EOF) {
109 break // End of message with headers only
110 }
111 return nil, nil, err
112 }
113 }
114
115 // Parse header structure from the captured header bytes
116 msg, err := mail.ReadMessage(bytes.NewReader(headerBuf.Bytes()))
117 if err != nil {
118 return nil, nil, err
119 }
120
121 // Reconstruct the exact stream: header bytes first, then remaining body bytes in br
122 fullReader := io.MultiReader(&headerBuf, br)
123 return msg, fullReader, nil
124}
125
126// BlobReader decrypts and unzips the stored blob
127func (p *Persistence) BlobReader(nonceIdentifier string) (io.ReadCloser, error) {
128 if len(nonceIdentifier) < 8 {
129 return nil, fmt.Errorf("invalid nonce identifier")
130 }
131
132 dir := filepath.Join(p.blobRoot, nonceIdentifier[:2])
133 finalPath := filepath.Join(dir, nonceIdentifier)
134
135 nonce, err := hex.DecodeString(nonceIdentifier)
136 if err != nil {
137 return nil, fmt.Errorf("invalid nonce identifier: %w", err)
138 }
139
140 // derive an encryption key from the master key and the nonce
141 var key [32]byte
142 kdf := hkdf.New(sha256.New, p.masterkey, nonce, nil)
143 if _, err = io.ReadFull(kdf, key[:]); err != nil {
144 return nil, fmt.Errorf("failed to derive encryption key: %w", err)
145 }
146
147 f, err := os.Open(finalPath) // leave Close() to sio.DecryptReader
148 if err != nil {
149 return nil, fmt.Errorf("opening file: %w", err)
150 }
151
152 decrypter, err := sio.DecryptReader(f, sio.Config{Key: key[:]})
153 if err != nil {
154 return nil, fmt.Errorf("failed to create decrypt reader: %w", err)
155 }
156
157 gzipReader, err := gzip.NewReader(decrypter)
158 if err != nil {
159 return nil, fmt.Errorf("failed to create gzip reader: %w", err)
160 }
161
162 slog.Info("reading blob", "path", finalPath)
163 // Return a wrapper that closes both the gzip reader and the underlying file
164 return &blobReadCloser{
165 Reader: gzipReader,
166 closeFunc: func() error {
167 gzErr := gzipReader.Close()
168 fErr := f.Close()
169 if gzErr != nil {
170 return gzErr
171 }
172 return fErr
173 },
174 }, nil
175}
176
177type blobReadCloser struct {
178 io.Reader
179 closeFunc func() error
180}
181
182func (b *blobReadCloser) Close() error {
183 return b.closeFunc()
184}
185
186func (p *Persistence) RemoveBlob(nonceIdentifier string) error {
187 if len(nonceIdentifier) < 8 {
188 return fmt.Errorf("invalid nonce identifier")
189 }
190
191 dir := filepath.Join(p.blobRoot, nonceIdentifier[:2])
192 finalPath := filepath.Join(dir, nonceIdentifier)
193
194 err := os.Remove(finalPath)
195 if err != nil {
196 return fmt.Errorf("deleting blob: %w", err)
197 }
198 slog.Info("deleted blob", "path", finalPath)
199
200 err = os.Remove(dir)
201 if err == nil {
202 // Only logs if the directory was actually empty and successfully deleted
203 slog.Info("deleted empty blob directory", "dir", dir)
204 }
205
206 return nil
207}