direct_attachments.go

v1.0.0
Doc Versions Source
1
package bitwarden
2
3
import (
4
	"bytes"
5
	"context"
6
	cryptorand "crypto/rand"
7
	"encoding/json"
8
	"fmt"
9
	"io"
10
	"mime/multipart"
11
	"net/http"
12
13
	"sourcecraft.dev/bigbes/go-bitwarden/crypto"
14
)
15
16
// DirectAttachmentsService handles attachment operations via the direct server API.
17
type DirectAttachmentsService struct {
18
	dc *DirectClient
19
}
20
21
func (s *DirectAttachmentsService) Create(ctx context.Context, itemID, filename string, file io.Reader) (*Item, error) {
22
	kc := s.dc.getKeyChain()
23
	if kc == nil {
24
		return nil, &VaultLockedError{}
25
	}
26
27
	// Read file content
28
	fileData, err := io.ReadAll(file)
29
	if err != nil {
30
		return nil, fmt.Errorf("read file: %w", err)
31
	}
32
33
	// Get the cipher to determine the correct base key
34
	cipherData, err := s.dc.transport.Get(ctx, "/api/ciphers/"+itemID, nil)
35
	if err != nil {
36
		return nil, fmt.Errorf("get cipher for attachment: %w", err)
37
	}
38
39
	var ec struct {
40
		OrganizationID string `json:"organizationId"`
41
		Key            string `json:"key"`
42
	}
43
	if err := json.Unmarshal(cipherData, &ec); err != nil {
44
		return nil, fmt.Errorf("parse cipher: %w", err)
45
	}
46
47
	cipherKey, err := kc.KeyForCipher(ec.OrganizationID, ec.Key)
48
	if err != nil {
49
		return nil, fmt.Errorf("get cipher key: %w", err)
50
	}
51
52
	// Generate random 64-byte attachment key
53
	attKeyRaw := make([]byte, 64)
54
	if _, err := io.ReadFull(cryptorand.Reader, attKeyRaw); err != nil {
55
		return nil, fmt.Errorf("generate attachment key: %w", err)
56
	}
57
	attKey, err := crypto.NewSymmetricKey(attKeyRaw)
58
	if err != nil {
59
		return nil, fmt.Errorf("create attachment key: %w", err)
60
	}
61
62
	// Encrypt the attachment key with the cipher key
63
	encAttKeyCS, err := cipherKey.Encrypt(attKeyRaw)
64
	if err != nil {
65
		return nil, fmt.Errorf("encrypt attachment key: %w", err)
66
	}
67
68
	// Encrypt the file content with the attachment key
69
	encFileCS, err := attKey.Encrypt(fileData)
70
	if err != nil {
71
		return nil, fmt.Errorf("encrypt file data: %w", err)
72
	}
73
	encFileBytes := encFileCS.MarshalRaw()
74
75
	// Encrypt the filename with the cipher key
76
	encFilename, err := cipherKey.EncryptString(filename)
77
	if err != nil {
78
		return nil, fmt.Errorf("encrypt filename: %w", err)
79
	}
80
81
	// Build multipart form
82
	var buf bytes.Buffer
83
	writer := multipart.NewWriter(&buf)
84
85
	// Add the encrypted file
86
	part, err := writer.CreateFormFile("data", encFilename)
87
	if err != nil {
88
		return nil, fmt.Errorf("create form file: %w", err)
89
	}
90
	if _, err := part.Write(encFileBytes); err != nil {
91
		return nil, fmt.Errorf("write encrypted data: %w", err)
92
	}
93
94
	// Add the encrypted attachment key
95
	if err := writer.WriteField("key", encAttKeyCS.String()); err != nil {
96
		return nil, fmt.Errorf("write key field: %w", err)
97
	}
98
99
	if err := writer.Close(); err != nil {
100
		return nil, fmt.Errorf("close multipart: %w", err)
101
	}
102
103
	// Get access token
104
	token, err := s.dc.transport.AccessToken(ctx)
105
	if err != nil {
106
		return nil, fmt.Errorf("get token: %w", err)
107
	}
108
109
	u := s.dc.serverURL + "/api/ciphers/" + itemID + "/attachment"
110
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, &buf)
111
	if err != nil {
112
		return nil, fmt.Errorf("create request: %w", err)
113
	}
114
	req.Header.Set("Content-Type", writer.FormDataContentType())
115
	req.Header.Set("Authorization", "Bearer "+token)
116
117
	resp, err := s.dc.httpClient.Do(req)
118
	if err != nil {
119
		return nil, fmt.Errorf("upload attachment: %w", err)
120
	}
121
	defer resp.Body.Close()
122
123
	respBody, err := io.ReadAll(resp.Body)
124
	if err != nil {
125
		return nil, fmt.Errorf("read response: %w", err)
126
	}
127
128
	if resp.StatusCode >= 400 {
129
		return nil, &APIError{StatusCode: resp.StatusCode, Message: string(respBody)}
130
	}
131
132
	created, err := decryptCipher(respBody, kc)
133
	if err != nil {
134
		return nil, err
135
	}
136
	s.dc.cache.upsertCipher(*created)
137
	return created, nil
138
}
139
140
func (s *DirectAttachmentsService) Get(ctx context.Context, attachmentID, itemID string) ([]byte, error) {
141
	kc := s.dc.getKeyChain()
142
	if kc == nil {
143
		return nil, &VaultLockedError{}
144
	}
145
146
	// Get the cipher to find the attachment metadata
147
	cipherData, err := s.dc.transport.Get(ctx, "/api/ciphers/"+itemID, nil)
148
	if err != nil {
149
		return nil, fmt.Errorf("get cipher: %w", err)
150
	}
151
152
	var cipher struct {
153
		OrganizationID string `json:"organizationId"`
154
		Key            string `json:"key"`
155
		Attachments    []struct {
156
			ID       string `json:"id"`
157
			FileName string `json:"fileName"`
158
			Key      string `json:"key"`
159
			URL      string `json:"url"`
160
		} `json:"attachments"`
161
	}
162
	if err := json.Unmarshal(cipherData, &cipher); err != nil {
163
		return nil, fmt.Errorf("parse cipher: %w", err)
164
	}
165
166
	// Find the attachment
167
	var attKey string
168
	for _, att := range cipher.Attachments {
169
		if att.ID == attachmentID {
170
			attKey = att.Key
171
			break
172
		}
173
	}
174
175
	// Get the download URL
176
	data, err := s.dc.transport.Get(ctx, "/api/ciphers/"+itemID+"/attachment/"+attachmentID, nil)
177
	if err != nil {
178
		return nil, err
179
	}
180
181
	var attResp struct {
182
		URL string `json:"url"`
183
	}
184
	if err := json.Unmarshal(data, &attResp); err != nil {
185
		return nil, fmt.Errorf("parse attachment response: %w", err)
186
	}
187
188
	if attResp.URL == "" {
189
		return nil, fmt.Errorf("no download URL for attachment")
190
	}
191
192
	// Download the file
193
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, attResp.URL, nil)
194
	if err != nil {
195
		return nil, fmt.Errorf("create download request: %w", err)
196
	}
197
198
	resp, err := s.dc.httpClient.Do(req)
199
	if err != nil {
200
		return nil, fmt.Errorf("download attachment: %w", err)
201
	}
202
	defer resp.Body.Close()
203
204
	if resp.StatusCode >= 400 {
205
		return nil, fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
206
	}
207
208
	encData, err := io.ReadAll(resp.Body)
209
	if err != nil {
210
		return nil, fmt.Errorf("read download: %w", err)
211
	}
212
213
	// If no attachment key, return raw data (legacy unencrypted attachment)
214
	if attKey == "" {
215
		return encData, nil
216
	}
217
218
	// Decrypt the attachment key
219
	cipherSymKey, err := kc.KeyForCipher(cipher.OrganizationID, cipher.Key)
220
	if err != nil {
221
		return nil, fmt.Errorf("get cipher key: %w", err)
222
	}
223
224
	attKeyCS, err := crypto.ParseCipherString(attKey)
225
	if err != nil {
226
		return nil, fmt.Errorf("parse attachment key: %w", err)
227
	}
228
	attKeyRaw, err := cipherSymKey.Decrypt(attKeyCS)
229
	if err != nil {
230
		return nil, fmt.Errorf("decrypt attachment key: %w", err)
231
	}
232
	attSymKey, err := crypto.NewSymmetricKey(attKeyRaw)
233
	if err != nil {
234
		return nil, fmt.Errorf("create attachment sym key: %w", err)
235
	}
236
237
	// Parse and decrypt the file content
238
	fileCS, err := crypto.ParseRawCipherString(encData)
239
	if err != nil {
240
		return nil, fmt.Errorf("parse encrypted file: %w", err)
241
	}
242
243
	return attSymKey.Decrypt(fileCS)
244
}
245
246
func (s *DirectAttachmentsService) Delete(ctx context.Context, attachmentID, itemID string) error {
247
	_, err := s.dc.transport.Delete(ctx, "/api/ciphers/"+itemID+"/attachment/"+attachmentID)
248
	if err == nil {
249
		// Invalidate the cipher cache entry since attachments changed
250
		s.dc.cache.invalidate()
251
	}
252
	return err
253
}
254

Source Files