Skip to content

Commit e13bb89

Browse files
committed
Add gpg encryption
1 parent abc7f6f commit e13bb89

File tree

11 files changed

+127
-34
lines changed

11 files changed

+127
-34
lines changed

README.md

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ Manage your dotfiles across multiple machines, securely.
2222
[LastPass](https://lastpass.com/), [pass](https://www.passwordstore.org/),
2323
[Vault](https://www.vaultproject.io/), your Keychain (on macOS), [GNOME
2424
Keyring](https://wiki.gnome.org/Projects/GnomeKeyring) (on Linux), or any
25-
command-line utility of your choice. You can checkout your dotfiles repo on as
26-
many machines as you want without revealing any secrets to anyone.
25+
command-line utility of your choice. You can encrypt individual files with
26+
[`gpg`](https://www.gnupg.org). You can checkout your dotfiles repo on as many
27+
machines as you want without revealing any secrets to anyone.
2728

2829
* Personal: Nothing leaves your machine, unless you want it to. You can use the
2930
version control system of your choice to manage your configuration, and you
@@ -501,6 +502,31 @@ way:
501502
| LastPass | `lpass` | `{{ secretJSON "show" "--json" <id> }}` |
502503
| pass | `pass` | `{{ secret "show" <id> }}` |
503504

505+
### Encrypting individual files with `gpg` (beta)
506+
507+
`chezmoi` supports encrypting individual files with
508+
[`gpg`](https://www.gnupg.org/). Specify the encryption key to use in your
509+
configuration file (`chezmoi.toml`) with the `gpgReceipient` key:
510+
511+
gpgRecipient = "..."
512+
513+
Add files to be encrypted with the `--encrypt` flag, for example:
514+
515+
chezmoi add --encrypt ~/.ssh/id_rsa
516+
517+
`chezmoi` will encrypt the file with
518+
519+
gpg --armor --encrypt --recipient $gpgRecipient
520+
521+
and store the encrypted file in the source state. The file will automatically be
522+
decrypted when generating the target state.
523+
524+
This feature is still in beta and has a couple of rough edges:
525+
526+
* Editing an encrypted file will edit the cyphertext, not the plaintext.
527+
* Diff'ing an encrypted file will show the difference between the old plaintext
528+
and the new cyphertext.
529+
504530
### Using encrypted config files
505531

506532
`chezmoi` takes a `-c` flag specifying the file to read its configuration from.
@@ -564,6 +590,7 @@ collectively referred to as "attributes":
564590

565591
| Prefix/suffix | Effect |
566592
| -------------------- | ----------------------------------------------------------------------------------|
593+
| `encrypted_` prefix | Encrypt the file in the source state. |
567594
| `private_` prefix | Remove all group and world permissions from the target file or directory. |
568595
| `empty_` prefix | Ensure the file exists, even if is empty. By default, empty files are removed. |
569596
| `exact_` prefix | Remove anything not managed by `chezmoi`. |
@@ -577,11 +604,11 @@ Order is important, the order is `exact_`, `private_`, `empty_`, `executable_`,
577604

578605
Different target types allow different prefixes and suffixes:
579606

580-
| Target type | Allowed prefixes and suffixes |
581-
| ------------- | ---------------------------------------------------- |
582-
| Directory | `exact_`, `private_`, `dot_` |
583-
| Regular file | `private_`, `empty_`, `executable_`, `dot_`, `.tmpl` |
584-
| Symbolic link | `symlink_`, `dot_`, `.tmpl` |
607+
| Target type | Allowed prefixes and suffixes |
608+
| ------------- | ------------------------------------------------------------------ |
609+
| Directory | `exact_`, `private_`, `dot_` |
610+
| Regular file | `encrypted_`, `private_`, `empty_`, `executable_`, `dot_`, `.tmpl` |
611+
| Symbolic link | `symlink_`, `dot_`, `.tmpl` |
585612

586613
You can change the attributes of a target in the source state with the `chattr`
587614
command. For example, to make `~/.netrc` private and a template:

cmd/add.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ func init() {
3030

3131
persistentFlags := addCmd.PersistentFlags()
3232
persistentFlags.BoolVarP(&config.add.options.Empty, "empty", "e", false, "add empty files")
33+
persistentFlags.BoolVar(&config.add.options.Encrypt, "encrypt", false, "encrypt files")
3334
persistentFlags.BoolVarP(&config.add.options.Exact, "exact", "x", false, "add directories exactly")
3435
persistentFlags.BoolVarP(&config.add.prompt, "prompt", "p", false, "prompt before adding")
3536
persistentFlags.BoolVarP(&config.add.recursive, "recursive", "r", false, "recurse in to subdirectories")

cmd/config.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type Config struct {
3939
Umask permValue
4040
DryRun bool
4141
Verbose bool
42+
GPGRecipient string
4243
SourceVCS sourceVCSConfig
4344
Bitwarden bitwardenCmdConfig
4445
GenericSecret genericSecretCmdConfig
@@ -204,7 +205,7 @@ func (c *Config) getTargetState(fs vfs.FS) (*chezmoi.TargetState, error) {
204205
for key, value := range c.Data {
205206
data[key] = value
206207
}
207-
ts := chezmoi.NewTargetState(c.DestDir, os.FileMode(c.Umask), c.SourceDir, data, c.templateFuncs)
208+
ts := chezmoi.NewTargetState(c.DestDir, os.FileMode(c.Umask), c.SourceDir, data, c.templateFuncs, c.GPGRecipient)
208209
readOnlyFS := vfs.NewReadOnlyFS(fs)
209210
if err := ts.Populate(readOnlyFS); err != nil {
210211
return nil, err

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ require (
1919
github.com/spf13/viper v1.3.1
2020
github.com/stretchr/objx v0.1.1 // indirect
2121
github.com/twpayne/go-shell v0.0.1
22-
github.com/twpayne/go-vfs v1.0.4
22+
github.com/twpayne/go-vfs v1.0.5
2323
github.com/twpayne/go-xdg v0.0.0-20190220233246-4973c34fec2f
2424
github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb
25-
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9
25+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
26+
golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa // indirect
2627
gopkg.in/yaml.v2 v2.2.2
2728
)

go.sum

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
6464
github.com/twpayne/go-shell v0.0.1 h1:Ako3cUeuULhWadYk37jM3FlJ8lkSSW4INBjYj9K60Gw=
6565
github.com/twpayne/go-shell v0.0.1/go.mod h1:QCjEvdZndTuPObd+11NYAI1UeNLSuGZVxJ+67Wl+IU4=
6666
github.com/twpayne/go-vfs v0.1.5/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
67-
github.com/twpayne/go-vfs v1.0.4 h1:sPmifWt4KGVXk0ZyHbtTRANu+L6BuZLNDF2H7W/qXT8=
68-
github.com/twpayne/go-vfs v1.0.4/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
67+
github.com/twpayne/go-vfs v1.0.5 h1:i45a6Ykg/asDB94fHH5OmScCQHFx/P9A//9M5dfXwQk=
68+
github.com/twpayne/go-vfs v1.0.5/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
6969
github.com/twpayne/go-xdg v0.0.0-20190220233246-4973c34fec2f h1:uYmFQ0IrWCECHYQl+uNqThrKu91ZBqRYbKH/ayY8u7U=
7070
github.com/twpayne/go-xdg v0.0.0-20190220233246-4973c34fec2f/go.mod h1:XO3i+LkLxZIz6VmOkre/w8m0en5znGMlZIg0yOHtbFs=
7171
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
@@ -74,8 +74,13 @@ github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb h1:tXbazu9ZlecQ
7474
github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb/go.mod h1:XlXBIfkGawHNVOHlenOaBW7zlfCh8LovwjOgjamYnkQ=
7575
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
7676
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
77+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
78+
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
7779
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A=
7880
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
81+
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
82+
golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa h1:lqti/xP+yD/6zH5TqEwx2MilNIJY5Vbc6Qr8J3qyPIQ=
83+
golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
7984
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
8085
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
8186
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

lib/chezmoi/chezmoi.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const (
1515
symlinkPrefix = "symlink_"
1616
privatePrefix = "private_"
1717
emptyPrefix = "empty_"
18+
encryptedPrefix = "encrypted_"
1819
exactPrefix = "exact_"
1920
executablePrefix = "executable_"
2021
dotPrefix = "dot_"

lib/chezmoi/chezmoi_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func TestReturnTemplateError(t *testing.T) {
1919
"func_returning_error": "{{ returnTemplateError }}",
2020
} {
2121
t.Run(name, func(t *testing.T) {
22-
ts := NewTargetState("/home/user", 0, "/home/user/.chezmoi", nil, funcs)
22+
ts := NewTargetState("/home/user", 0, "/home/user/.chezmoi", nil, funcs, "")
2323
if got, err := ts.executeTemplateData(name, []byte(dataString)); err == nil {
2424
t.Errorf("ts.executeTemplate(%q, %q) == %q, <nil>, want _, !<nil>", name, dataString, got)
2525
}

lib/chezmoi/file.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,19 @@ import (
1313

1414
// A FileAttributes holds attributes passed from a source file name.
1515
type FileAttributes struct {
16-
Name string
17-
Mode os.FileMode
18-
Empty bool
19-
Template bool
16+
Name string
17+
Mode os.FileMode
18+
Empty bool
19+
Encrypted bool
20+
Template bool
2021
}
2122

2223
// A File represents the target state of a file.
2324
type File struct {
2425
sourceName string
2526
targetName string
2627
Empty bool
28+
Encrypted bool
2729
Perm os.FileMode
2830
Template bool
2931
contents []byte
@@ -36,6 +38,7 @@ type fileConcreteValue struct {
3638
SourcePath string `json:"sourcePath" yaml:"sourcePath"`
3739
TargetPath string `json:"targetPath" yaml:"targetPath"`
3840
Empty bool `json:"empty" yaml:"empty"`
41+
Encrypted bool `json:"encrypted" yaml:"encrypted"`
3942
Perm int `json:"perm" yaml:"perm"`
4043
Template bool `json:"template" yaml:"template"`
4144
Contents string `json:"contents" yaml:"contents"`
@@ -46,12 +49,17 @@ func ParseFileAttributes(sourceName string) FileAttributes {
4649
name := sourceName
4750
mode := os.FileMode(0666)
4851
empty := false
52+
encrypted := false
4953
template := false
5054
if strings.HasPrefix(name, symlinkPrefix) {
5155
name = strings.TrimPrefix(name, symlinkPrefix)
5256
mode |= os.ModeSymlink
5357
} else {
5458
private := false
59+
if strings.HasPrefix(name, encryptedPrefix) {
60+
name = strings.TrimPrefix(name, encryptedPrefix)
61+
encrypted = true
62+
}
5563
if strings.HasPrefix(name, privatePrefix) {
5664
name = strings.TrimPrefix(name, privatePrefix)
5765
private = true
@@ -76,10 +84,11 @@ func ParseFileAttributes(sourceName string) FileAttributes {
7684
template = true
7785
}
7886
return FileAttributes{
79-
Name: name,
80-
Mode: mode,
81-
Empty: empty,
82-
Template: template,
87+
Name: name,
88+
Mode: mode,
89+
Empty: empty,
90+
Encrypted: encrypted,
91+
Template: template,
8392
}
8493
}
8594

@@ -88,8 +97,11 @@ func (fa FileAttributes) SourceName() string {
8897
sourceName := ""
8998
switch fa.Mode & os.ModeType {
9099
case 0:
100+
if fa.Encrypted {
101+
sourceName += encryptedPrefix
102+
}
91103
if fa.Mode.Perm()&os.FileMode(077) == os.FileMode(0) {
92-
sourceName = privatePrefix
104+
sourceName += privatePrefix
93105
}
94106
if fa.Empty {
95107
sourceName += emptyPrefix
@@ -171,6 +183,7 @@ func (f *File) ConcreteValue(destDir string, ignore func(string) bool, sourceDir
171183
SourcePath: filepath.Join(sourceDir, f.SourceName()),
172184
TargetPath: filepath.Join(destDir, f.TargetName()),
173185
Empty: f.Empty,
186+
Encrypted: f.Encrypted,
174187
Perm: int(f.Perm),
175188
Template: f.Template,
176189
Contents: string(contents),

lib/chezmoi/file_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ func TestFileAttributes(t *testing.T) {
106106
Template: true,
107107
},
108108
},
109+
{
110+
sourceName: "encrypted_private_dot_secret_file",
111+
fa: FileAttributes{
112+
Name: ".secret_file",
113+
Mode: 0600,
114+
Encrypted: true,
115+
},
116+
},
109117
} {
110118
t.Run(tc.sourceName, func(t *testing.T) {
111119
gotFA := ParseFileAttributes(tc.sourceName)

lib/chezmoi/target_state.go

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"io"
99
"io/ioutil"
1010
"os"
11+
"os/exec"
1112
"os/user"
1213
"path/filepath"
1314
"strconv"
@@ -21,6 +22,7 @@ import (
2122
// An AddOptions contains options for TargetState.Add.
2223
type AddOptions struct {
2324
Empty bool
25+
Encrypt bool
2426
Exact bool
2527
Template bool
2628
}
@@ -40,18 +42,20 @@ type TargetState struct {
4042
SourceDir string
4143
Data map[string]interface{}
4244
TemplateFuncs template.FuncMap
45+
GPGRecipient string
4346
Entries map[string]Entry
4447
}
4548

4649
// NewTargetState creates a new TargetState.
47-
func NewTargetState(destDir string, umask os.FileMode, sourceDir string, data map[string]interface{}, templateFuncs template.FuncMap) *TargetState {
50+
func NewTargetState(destDir string, umask os.FileMode, sourceDir string, data map[string]interface{}, templateFuncs template.FuncMap, gpgRecipient string) *TargetState {
4851
return &TargetState{
4952
DestDir: destDir,
5053
TargetIgnore: NewPatternSet(),
5154
Umask: umask,
5255
SourceDir: sourceDir,
5356
Data: data,
5457
TemplateFuncs: templateFuncs,
58+
GPGRecipient: gpgRecipient,
5559
Entries: make(map[string]Entry),
5660
}
5761
}
@@ -120,7 +124,19 @@ func (ts *TargetState) Add(fs vfs.FS, addOptions AddOptions, targetPath string,
120124
return err
121125
}
122126
}
123-
return ts.addFile(targetName, entries, parentDirSourceName, info, addOptions.Template, contents, mutator)
127+
if addOptions.Encrypt {
128+
args := []string{"--armor", "--encrypt"}
129+
if ts.GPGRecipient != "" {
130+
args = append(args, "--recipient", ts.GPGRecipient)
131+
}
132+
cmd := exec.Command("gpg", args...)
133+
cmd.Stdin = bytes.NewReader(contents)
134+
contents, err = cmd.Output()
135+
if err != nil {
136+
return err
137+
}
138+
}
139+
return ts.addFile(targetName, entries, parentDirSourceName, info, addOptions.Encrypt, addOptions.Template, contents, mutator)
124140
case info.Mode()&os.ModeType == os.ModeSymlink:
125141
linkname, err := fs.Readlink(targetPath)
126142
if err != nil {
@@ -283,12 +299,30 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
283299
var entry Entry
284300
switch psfp.Mode & os.ModeType {
285301
case 0:
286-
evaluateContents := func() ([]byte, error) {
302+
readFile := func() ([]byte, error) {
287303
return fs.ReadFile(path)
288304
}
305+
evaluateContents := readFile
306+
if psfp.Encrypted {
307+
prevEvaluateContents := evaluateContents
308+
evaluateContents = func() ([]byte, error) {
309+
encryptedData, err := prevEvaluateContents()
310+
if err != nil {
311+
return nil, err
312+
}
313+
cmd := exec.Command("gpg", "--decrypt")
314+
cmd.Stdin = bytes.NewReader(encryptedData)
315+
return cmd.Output()
316+
}
317+
}
289318
if psfp.Template {
319+
prevEvaluateContents := evaluateContents
290320
evaluateContents = func() ([]byte, error) {
291-
return ts.executeTemplate(fs, path)
321+
data, err := prevEvaluateContents()
322+
if err != nil {
323+
return nil, err
324+
}
325+
return ts.executeTemplateData(path, data)
292326
}
293327
}
294328
entry = &File{
@@ -359,7 +393,7 @@ func (ts *TargetState) addDir(targetName string, entries map[string]Entry, paren
359393
return nil
360394
}
361395

362-
func (ts *TargetState) addFile(targetName string, entries map[string]Entry, parentDirSourceName string, info os.FileInfo, template bool, contents []byte, mutator Mutator) error {
396+
func (ts *TargetState) addFile(targetName string, entries map[string]Entry, parentDirSourceName string, info os.FileInfo, encrypted, template bool, contents []byte, mutator Mutator) error {
363397
name := filepath.Base(targetName)
364398
var existingFile *File
365399
var existingContents []byte
@@ -377,10 +411,11 @@ func (ts *TargetState) addFile(targetName string, entries map[string]Entry, pare
377411
perm := info.Mode().Perm()
378412
empty := info.Size() == 0
379413
sourceName := FileAttributes{
380-
Name: name,
381-
Mode: perm,
382-
Empty: empty,
383-
Template: template,
414+
Name: name,
415+
Mode: perm,
416+
Empty: empty,
417+
Encrypted: encrypted,
418+
Template: template,
384419
}.SourceName()
385420
if parentDirSourceName != "" {
386421
sourceName = filepath.Join(parentDirSourceName, sourceName)
@@ -389,6 +424,7 @@ func (ts *TargetState) addFile(targetName string, entries map[string]Entry, pare
389424
sourceName: sourceName,
390425
targetName: targetName,
391426
Empty: empty,
427+
Encrypted: encrypted,
392428
Perm: perm,
393429
Template: template,
394430
contents: contents,
@@ -568,7 +604,7 @@ func (ts *TargetState) importHeader(r io.Reader, importTAROptions ImportTAROptio
568604
if err != nil {
569605
return err
570606
}
571-
return ts.addFile(targetName, entries, parentDirSourceName, info, false, contents, mutator)
607+
return ts.addFile(targetName, entries, parentDirSourceName, info, false, false, contents, mutator)
572608
case tar.TypeSymlink:
573609
linkname := header.Linkname
574610
return ts.addSymlink(targetName, entries, parentDirSourceName, linkname, mutator)

0 commit comments

Comments
 (0)