Repository navigation
Expand file tree
/
Copy pathops.go
More file actions
347 lines (325 loc) · 11.5 KB
/
Copy pathops.go
File metadata and controls
347 lines (325 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
package cipher
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"github.com/getsops/sops/v3/keyservice"
"github.com/spf13/afero"
"github.com/dcadolph/cipher/internal/atomic"
"github.com/dcadolph/cipher/internal/sopsx"
)
// EditOptions tunes Edit behavior.
type EditOptions struct {
// BackupSuffix, when non-empty, copies the encrypted original to
// <path><suffix> before overwriting it. The backup is retained
// after success so callers can revert or diff.
BackupSuffix string
}
// Edit reads path from files, decrypts the contents with dec, invokes
// fn on the plaintext, re-encrypts the result with enc, and writes it
// back to the same path atomically.
//
// If fn returns the same bytes (by value), no write occurs and Edit
// returns nil. If fn returns an error, the file is left untouched and
// that error is returned wrapped.
//
// Atomicity: the encrypted bytes are written to a sibling temp file
// which is then renamed into place. On any failure the destination
// remains as it was before the call.
//
// Security: Edit hands plaintext bytes to fn. The caller is
// responsible for not leaking those bytes outside its own memory.
// In particular, callers that materialize plaintext on disk (the
// cipher CLI's edit verb does this for $EDITOR) must use a private
// temp directory (mode 0700) plus restrictive file mode (0600) and
// clean up on exit. The current Edit implementation never writes
// plaintext to disk itself.
//
// Semantic equality: Edit decides "did anything change" by
// bytes.Equal of plaintext and fn's return value. A YAML reformat
// that is semantically identical (key order, indentation, quoting)
// still triggers a re-encrypt because the bytes differ. If round-
// trip stability matters, ensure fn preserves byte layout.
func Edit(
ctx context.Context, files afero.Fs, path string,
enc Encoder, dec Decoder,
fn func(plaintext []byte) ([]byte, error),
) error {
return EditWith(ctx, files, path, enc, dec, fn, EditOptions{})
}
// EditWith is Edit with explicit options.
func EditWith(
ctx context.Context, files afero.Fs, path string,
enc Encoder, dec Decoder,
fn func(plaintext []byte) ([]byte, error),
opts EditOptions,
) error {
if files == nil {
panic("cipher: EditWith: filesystem required")
}
if enc == nil {
panic("cipher: EditWith: encoder required")
}
if dec == nil {
panic("cipher: EditWith: decoder required")
}
if fn == nil {
panic("cipher: EditWith: edit function required")
}
info, err := files.Stat(path)
if err != nil {
return fmt.Errorf("stat %q: %w", path, err)
}
original, err := afero.ReadFile(files, path)
if err != nil {
return fmt.Errorf("read %q: %w", path, err)
}
plaintext, err := dec.Decode(ctx, path, original)
if err != nil {
return fmt.Errorf("decode %q: %w", path, err)
}
// Zero the plaintext slice cipher owns. This wipes only the final
// decoded buffer, not the upstream copies sops built inside
// EmitPlainFile, store buffers, and GenerateDataKeyWithKeyServices.
// Those live in freed-but-not-zeroed heap memory until the next
// allocator reuse and cipher cannot reach them. The wipe narrows
// the window of accidental disclosure for the slice we hand back
// to the mutator. It does not guarantee plaintext is unrecoverable
// from process memory after Edit returns.
defer clear(plaintext)
modified, err := fn(plaintext)
if err != nil {
return fmt.Errorf("edit %q: %w", path, err)
}
// Zero the slice cipher sees. Same caveat as the plaintext wipe:
// if the mutator returned a fresh slice, the original backing
// array is already unreferenced and clear does nothing for it.
defer clear(modified)
if bytes.Equal(plaintext, modified) {
return nil
}
encrypted, err := enc.Encode(ctx, path, modified)
if err != nil {
return fmt.Errorf("encode %q: %w", path, err)
}
if err := writeBackup(files, path, original, info, opts.BackupSuffix); err != nil {
return err
}
if err := atomic.WriteFile(files, path, encrypted, info.Mode().Perm()); err != nil {
return err
}
return nil
}
// Rotate decrypts data with dec and re-encrypts it with enc. A fresh
// data key is generated, so any compromise of the previous data key
// no longer protects the file. Recipients are reset to whatever enc's
// KeyProvider yields; pass the original Encoder to keep recipients,
// or a different one to switch backends.
func Rotate(
ctx context.Context, path string, data []byte,
enc Encoder, dec Decoder,
) ([]byte, error) {
if enc == nil {
panic("cipher: Rotate: encoder required")
}
if dec == nil {
panic("cipher: Rotate: decoder required")
}
plain, err := dec.Decode(ctx, path, data)
if err != nil {
return nil, fmt.Errorf("rotate %q: %w", path, err)
}
out, err := enc.Encode(ctx, path, plain)
if err != nil {
return nil, fmt.Errorf("rotate %q: %w", path, err)
}
return out, nil
}
// RotateWalk applies Rotate to every matching file under root. Files
// that are not encrypted are skipped. Plain files do not cause failure.
func RotateWalk(
ctx context.Context, files afero.Fs, root string,
enc Encoder, dec Decoder, matchers []FileMatcher,
) error {
return RotateWalkWith(ctx, files, root, enc, dec, matchers, WalkOptions{})
}
// RotateWalkWith is RotateWalk with explicit options.
func RotateWalkWith(
ctx context.Context, files afero.Fs, root string,
enc Encoder, dec Decoder, matchers []FileMatcher, opts WalkOptions,
) error {
if files == nil {
panic("cipher: RotateWalkWith: filesystem required")
}
if enc == nil {
panic("cipher: RotateWalkWith: encoder required")
}
if dec == nil {
panic("cipher: RotateWalkWith: decoder required")
}
serializeCallbacks(&opts)
return runWalk(ctx, files, root, matchers, opts,
func(ctx context.Context, fs afero.Fs, path string, info fs.FileInfo) error {
data, err := afero.ReadFile(fs, path)
if err != nil {
return fmt.Errorf("read %q: %w", path, err)
}
out, err := Rotate(ctx, path, data, enc, dec)
switch {
case errors.Is(err, ErrNotEncrypted):
notify(opts.OnSkip, path, ErrNotEncrypted)
return nil
case err != nil:
return err
}
if err := writeBackup(fs, path, data, info, opts.BackupSuffix); err != nil {
return err
}
if err := atomic.WriteFile(fs, path, out, info.Mode().Perm()); err != nil {
return err
}
notify(opts.OnFile, path, len(out))
return nil
})
}
// AddRecipientMode controls how AddRecipient merges new keys into an
// existing file's metadata.
type AddRecipientMode int
const (
// AddRecipientFlatten merges all new keys into the file's first
// existing key group, creating one if none exist. The file remains
// decryptable by any single recipient.
AddRecipientFlatten AddRecipientMode = iota
// AddRecipientAsGroups appends the new key groups as additional
// groups. With Shamir-threshold defaults this turns the file into
// a multi-group secret requiring multiple groups to decrypt.
AddRecipientAsGroups
)
// AddRecipientOptions tunes AddRecipient behavior.
type AddRecipientOptions struct {
// Mode controls how the KeyProvider's groups are merged into the
// file's existing key groups. The zero value flattens.
Mode AddRecipientMode
// Format, when non-zero, fixes the sops format. When zero, format
// is derived from path.
Format Format
// KeyServices overrides the default local key service.
KeyServices []keyservice.KeyServiceClient
// DecryptionOrder controls which key types are tried first when
// unwrapping the data key. Empty means sops.DefaultDecryptionOrder.
DecryptionOrder []string
// MaxCiphertextBytes is the maximum allowed input size in bytes.
// Zero means no limit. Returns ErrTooLarge when exceeded.
MaxCiphertextBytes int
}
// AddRecipient appends the keys from add to the file's key groups
// (flattening into the first group by default) and re-wraps the data
// key for all recipients. The payload ciphertext is unchanged. The
// caller must hold an identity for at least one existing recipient so
// the data key can be unwrapped.
func AddRecipient(
ctx context.Context, path string, data []byte,
add KeyProvider, opts DecoderOptions,
) ([]byte, error) {
return AddRecipientWith(ctx, path, data, add, AddRecipientOptions{
Format: opts.Format,
KeyServices: opts.KeyServices,
DecryptionOrder: opts.DecryptionOrder,
})
}
// AddRecipientWith is AddRecipient with explicit options, including
// Mode to control flatten vs. as-groups merge.
func AddRecipientWith(
ctx context.Context, path string, data []byte,
add KeyProvider, opts AddRecipientOptions,
) ([]byte, error) {
if add == nil {
panic("cipher: AddRecipient: KeyProvider required")
}
groups, err := add.KeyGroups(ctx)
if err != nil {
return nil, fmt.Errorf("AddRecipient: key groups: %w", err)
}
if len(groups) == 0 {
return nil, fmt.Errorf("AddRecipient: %w", ErrNoKeyGroups)
}
out, err := sopsx.AddRecipient(sopsx.AddRecipientInput{
Path: path,
Data: data,
Format: opts.Format,
NewGroups: groups,
Mode: sopsx.AddRecipientMode(opts.Mode),
KeyServices: opts.KeyServices,
DecryptionOrder: opts.DecryptionOrder,
MaxCiphertextBytes: opts.MaxCiphertextBytes,
})
switch {
case errors.Is(err, sopsx.ErrNotEncrypted):
return nil, ErrNotEncrypted
case errors.Is(err, sopsx.ErrTooLarge):
return nil, ErrTooLarge
case err != nil:
return nil, fmt.Errorf("AddRecipient: %w", err)
}
return out, nil
}
// RemoveRecipientOptions tunes RemoveRecipient behavior.
type RemoveRecipientOptions struct {
// AllowOrphan, when true, permits removal that would leave the file
// with zero remaining master keys. The resulting file is
// undecryptable forever. Required to make data destruction explicit.
AllowOrphan bool
// Format, when non-zero, fixes the sops format. When zero, format
// is derived from path.
Format Format
// MaxCiphertextBytes is the maximum allowed input size in bytes.
// Zero means no limit. Returns ErrTooLarge when exceeded.
MaxCiphertextBytes int
}
// RemoveRecipient drops master keys from the file's key groups whose
// ToString() identifier matches an entry in identifiers. The payload
// is not decrypted; this is a metadata-only edit. Returns
// ErrOrphanRecipient if removing the supplied identifiers would leave
// the file with zero recipients.
//
// This operation does not perform any network or cancellable work, so
// it intentionally does not take a context.Context. Wrap the call in
// your own context handling if cancellation matters.
func RemoveRecipient(
path string, data []byte, identifiers ...string,
) ([]byte, error) {
return RemoveRecipientWith(path, data, identifiers, RemoveRecipientOptions{})
}
// RemoveRecipientWith is RemoveRecipient with explicit options.
// Setting opts.AllowOrphan permits removal of the last remaining
// recipient, which makes the file undecryptable forever.
func RemoveRecipientWith(
path string, data []byte,
identifiers []string, opts RemoveRecipientOptions,
) ([]byte, error) {
if len(identifiers) == 0 {
return nil, fmt.Errorf("RemoveRecipient: at least one identifier required")
}
out, err := sopsx.RemoveRecipient(sopsx.RemoveRecipientInput{
Path: path,
Data: data,
Format: opts.Format,
Identifiers: identifiers,
DropEmptyGroups: true,
AllowOrphan: opts.AllowOrphan,
MaxCiphertextBytes: opts.MaxCiphertextBytes,
})
switch {
case errors.Is(err, sopsx.ErrNotEncrypted):
return nil, ErrNotEncrypted
case errors.Is(err, sopsx.ErrOrphanRecipient):
return nil, ErrOrphanRecipient
case errors.Is(err, sopsx.ErrTooLarge):
return nil, ErrTooLarge
case err != nil:
return nil, fmt.Errorf("RemoveRecipient: %w", err)
}
return out, nil
}