Skip to content

Commit

Permalink
Add gpg encryption
Browse files Browse the repository at this point in the history
  • Loading branch information
twpayne committed Mar 13, 2019
1 parent abc7f6f commit e13bb89
Show file tree
Hide file tree
Showing 11 changed files with 127 additions and 34 deletions.
41 changes: 34 additions & 7 deletions README.md
Expand Up @@ -22,8 +22,9 @@ Manage your dotfiles across multiple machines, securely.
[LastPass](https://lastpass.com/), [pass](https://www.passwordstore.org/),
[Vault](https://www.vaultproject.io/), your Keychain (on macOS), [GNOME
Keyring](https://wiki.gnome.org/Projects/GnomeKeyring) (on Linux), or any
command-line utility of your choice. You can checkout your dotfiles repo on as
many machines as you want without revealing any secrets to anyone.
command-line utility of your choice. You can encrypt individual files with
[`gpg`](https://www.gnupg.org). You can checkout your dotfiles repo on as many
machines as you want without revealing any secrets to anyone.

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

### Encrypting individual files with `gpg` (beta)

`chezmoi` supports encrypting individual files with
[`gpg`](https://www.gnupg.org/). Specify the encryption key to use in your
configuration file (`chezmoi.toml`) with the `gpgReceipient` key:

gpgRecipient = "..."

Add files to be encrypted with the `--encrypt` flag, for example:

chezmoi add --encrypt ~/.ssh/id_rsa

`chezmoi` will encrypt the file with

gpg --armor --encrypt --recipient $gpgRecipient

and store the encrypted file in the source state. The file will automatically be
decrypted when generating the target state.

This feature is still in beta and has a couple of rough edges:

* Editing an encrypted file will edit the cyphertext, not the plaintext.
* Diff'ing an encrypted file will show the difference between the old plaintext
and the new cyphertext.

### Using encrypted config files

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

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

Different target types allow different prefixes and suffixes:

| Target type | Allowed prefixes and suffixes |
| ------------- | ---------------------------------------------------- |
| Directory | `exact_`, `private_`, `dot_` |
| Regular file | `private_`, `empty_`, `executable_`, `dot_`, `.tmpl` |
| Symbolic link | `symlink_`, `dot_`, `.tmpl` |
| Target type | Allowed prefixes and suffixes |
| ------------- | ------------------------------------------------------------------ |
| Directory | `exact_`, `private_`, `dot_` |
| Regular file | `encrypted_`, `private_`, `empty_`, `executable_`, `dot_`, `.tmpl` |
| Symbolic link | `symlink_`, `dot_`, `.tmpl` |

You can change the attributes of a target in the source state with the `chattr`
command. For example, to make `~/.netrc` private and a template:
Expand Down
1 change: 1 addition & 0 deletions cmd/add.go
Expand Up @@ -30,6 +30,7 @@ func init() {

persistentFlags := addCmd.PersistentFlags()
persistentFlags.BoolVarP(&config.add.options.Empty, "empty", "e", false, "add empty files")
persistentFlags.BoolVar(&config.add.options.Encrypt, "encrypt", false, "encrypt files")
persistentFlags.BoolVarP(&config.add.options.Exact, "exact", "x", false, "add directories exactly")
persistentFlags.BoolVarP(&config.add.prompt, "prompt", "p", false, "prompt before adding")
persistentFlags.BoolVarP(&config.add.recursive, "recursive", "r", false, "recurse in to subdirectories")
Expand Down
3 changes: 2 additions & 1 deletion cmd/config.go
Expand Up @@ -39,6 +39,7 @@ type Config struct {
Umask permValue
DryRun bool
Verbose bool
GPGRecipient string
SourceVCS sourceVCSConfig
Bitwarden bitwardenCmdConfig
GenericSecret genericSecretCmdConfig
Expand Down Expand Up @@ -204,7 +205,7 @@ func (c *Config) getTargetState(fs vfs.FS) (*chezmoi.TargetState, error) {
for key, value := range c.Data {
data[key] = value
}
ts := chezmoi.NewTargetState(c.DestDir, os.FileMode(c.Umask), c.SourceDir, data, c.templateFuncs)
ts := chezmoi.NewTargetState(c.DestDir, os.FileMode(c.Umask), c.SourceDir, data, c.templateFuncs, c.GPGRecipient)
readOnlyFS := vfs.NewReadOnlyFS(fs)
if err := ts.Populate(readOnlyFS); err != nil {
return nil, err
Expand Down
5 changes: 3 additions & 2 deletions go.mod
Expand Up @@ -19,9 +19,10 @@ require (
github.com/spf13/viper v1.3.1
github.com/stretchr/objx v0.1.1 // indirect
github.com/twpayne/go-shell v0.0.1
github.com/twpayne/go-vfs v1.0.4
github.com/twpayne/go-vfs v1.0.5
github.com/twpayne/go-xdg v0.0.0-20190220233246-4973c34fec2f
github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa // indirect
gopkg.in/yaml.v2 v2.2.2
)
9 changes: 7 additions & 2 deletions go.sum
Expand Up @@ -64,8 +64,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/twpayne/go-shell v0.0.1 h1:Ako3cUeuULhWadYk37jM3FlJ8lkSSW4INBjYj9K60Gw=
github.com/twpayne/go-shell v0.0.1/go.mod h1:QCjEvdZndTuPObd+11NYAI1UeNLSuGZVxJ+67Wl+IU4=
github.com/twpayne/go-vfs v0.1.5/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
github.com/twpayne/go-vfs v1.0.4 h1:sPmifWt4KGVXk0ZyHbtTRANu+L6BuZLNDF2H7W/qXT8=
github.com/twpayne/go-vfs v1.0.4/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
github.com/twpayne/go-vfs v1.0.5 h1:i45a6Ykg/asDB94fHH5OmScCQHFx/P9A//9M5dfXwQk=
github.com/twpayne/go-vfs v1.0.5/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
github.com/twpayne/go-xdg v0.0.0-20190220233246-4973c34fec2f h1:uYmFQ0IrWCECHYQl+uNqThrKu91ZBqRYbKH/ayY8u7U=
github.com/twpayne/go-xdg v0.0.0-20190220233246-4973c34fec2f/go.mod h1:XO3i+LkLxZIz6VmOkre/w8m0en5znGMlZIg0yOHtbFs=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
Expand All @@ -74,8 +74,13 @@ github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb h1:tXbazu9ZlecQ
github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb/go.mod h1:XlXBIfkGawHNVOHlenOaBW7zlfCh8LovwjOgjamYnkQ=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa h1:lqti/xP+yD/6zH5TqEwx2MilNIJY5Vbc6Qr8J3qyPIQ=
golang.org/x/sys v0.0.0-20190310054646-10058d7d4faa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
Expand Down
1 change: 1 addition & 0 deletions lib/chezmoi/chezmoi.go
Expand Up @@ -15,6 +15,7 @@ const (
symlinkPrefix = "symlink_"
privatePrefix = "private_"
emptyPrefix = "empty_"
encryptedPrefix = "encrypted_"
exactPrefix = "exact_"
executablePrefix = "executable_"
dotPrefix = "dot_"
Expand Down
2 changes: 1 addition & 1 deletion lib/chezmoi/chezmoi_test.go
Expand Up @@ -19,7 +19,7 @@ func TestReturnTemplateError(t *testing.T) {
"func_returning_error": "{{ returnTemplateError }}",
} {
t.Run(name, func(t *testing.T) {
ts := NewTargetState("/home/user", 0, "/home/user/.chezmoi", nil, funcs)
ts := NewTargetState("/home/user", 0, "/home/user/.chezmoi", nil, funcs, "")
if got, err := ts.executeTemplateData(name, []byte(dataString)); err == nil {
t.Errorf("ts.executeTemplate(%q, %q) == %q, <nil>, want _, !<nil>", name, dataString, got)
}
Expand Down
31 changes: 22 additions & 9 deletions lib/chezmoi/file.go
Expand Up @@ -13,17 +13,19 @@ import (

// A FileAttributes holds attributes passed from a source file name.
type FileAttributes struct {
Name string
Mode os.FileMode
Empty bool
Template bool
Name string
Mode os.FileMode
Empty bool
Encrypted bool
Template bool
}

// A File represents the target state of a file.
type File struct {
sourceName string
targetName string
Empty bool
Encrypted bool
Perm os.FileMode
Template bool
contents []byte
Expand All @@ -36,6 +38,7 @@ type fileConcreteValue struct {
SourcePath string `json:"sourcePath" yaml:"sourcePath"`
TargetPath string `json:"targetPath" yaml:"targetPath"`
Empty bool `json:"empty" yaml:"empty"`
Encrypted bool `json:"encrypted" yaml:"encrypted"`
Perm int `json:"perm" yaml:"perm"`
Template bool `json:"template" yaml:"template"`
Contents string `json:"contents" yaml:"contents"`
Expand All @@ -46,12 +49,17 @@ func ParseFileAttributes(sourceName string) FileAttributes {
name := sourceName
mode := os.FileMode(0666)
empty := false
encrypted := false
template := false
if strings.HasPrefix(name, symlinkPrefix) {
name = strings.TrimPrefix(name, symlinkPrefix)
mode |= os.ModeSymlink
} else {
private := false
if strings.HasPrefix(name, encryptedPrefix) {
name = strings.TrimPrefix(name, encryptedPrefix)
encrypted = true
}
if strings.HasPrefix(name, privatePrefix) {
name = strings.TrimPrefix(name, privatePrefix)
private = true
Expand All @@ -76,10 +84,11 @@ func ParseFileAttributes(sourceName string) FileAttributes {
template = true
}
return FileAttributes{
Name: name,
Mode: mode,
Empty: empty,
Template: template,
Name: name,
Mode: mode,
Empty: empty,
Encrypted: encrypted,
Template: template,
}
}

Expand All @@ -88,8 +97,11 @@ func (fa FileAttributes) SourceName() string {
sourceName := ""
switch fa.Mode & os.ModeType {
case 0:
if fa.Encrypted {
sourceName += encryptedPrefix
}
if fa.Mode.Perm()&os.FileMode(077) == os.FileMode(0) {
sourceName = privatePrefix
sourceName += privatePrefix
}
if fa.Empty {
sourceName += emptyPrefix
Expand Down Expand Up @@ -171,6 +183,7 @@ func (f *File) ConcreteValue(destDir string, ignore func(string) bool, sourceDir
SourcePath: filepath.Join(sourceDir, f.SourceName()),
TargetPath: filepath.Join(destDir, f.TargetName()),
Empty: f.Empty,
Encrypted: f.Encrypted,
Perm: int(f.Perm),
Template: f.Template,
Contents: string(contents),
Expand Down
8 changes: 8 additions & 0 deletions lib/chezmoi/file_test.go
Expand Up @@ -106,6 +106,14 @@ func TestFileAttributes(t *testing.T) {
Template: true,
},
},
{
sourceName: "encrypted_private_dot_secret_file",
fa: FileAttributes{
Name: ".secret_file",
Mode: 0600,
Encrypted: true,
},
},
} {
t.Run(tc.sourceName, func(t *testing.T) {
gotFA := ParseFileAttributes(tc.sourceName)
Expand Down
56 changes: 46 additions & 10 deletions lib/chezmoi/target_state.go
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"io/ioutil"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
Expand All @@ -21,6 +22,7 @@ import (
// An AddOptions contains options for TargetState.Add.
type AddOptions struct {
Empty bool
Encrypt bool
Exact bool
Template bool
}
Expand All @@ -40,18 +42,20 @@ type TargetState struct {
SourceDir string
Data map[string]interface{}
TemplateFuncs template.FuncMap
GPGRecipient string
Entries map[string]Entry
}

// NewTargetState creates a new TargetState.
func NewTargetState(destDir string, umask os.FileMode, sourceDir string, data map[string]interface{}, templateFuncs template.FuncMap) *TargetState {
func NewTargetState(destDir string, umask os.FileMode, sourceDir string, data map[string]interface{}, templateFuncs template.FuncMap, gpgRecipient string) *TargetState {
return &TargetState{
DestDir: destDir,
TargetIgnore: NewPatternSet(),
Umask: umask,
SourceDir: sourceDir,
Data: data,
TemplateFuncs: templateFuncs,
GPGRecipient: gpgRecipient,
Entries: make(map[string]Entry),
}
}
Expand Down Expand Up @@ -120,7 +124,19 @@ func (ts *TargetState) Add(fs vfs.FS, addOptions AddOptions, targetPath string,
return err
}
}
return ts.addFile(targetName, entries, parentDirSourceName, info, addOptions.Template, contents, mutator)
if addOptions.Encrypt {
args := []string{"--armor", "--encrypt"}
if ts.GPGRecipient != "" {
args = append(args, "--recipient", ts.GPGRecipient)
}
cmd := exec.Command("gpg", args...)
cmd.Stdin = bytes.NewReader(contents)
contents, err = cmd.Output()
if err != nil {
return err
}
}
return ts.addFile(targetName, entries, parentDirSourceName, info, addOptions.Encrypt, addOptions.Template, contents, mutator)
case info.Mode()&os.ModeType == os.ModeSymlink:
linkname, err := fs.Readlink(targetPath)
if err != nil {
Expand Down Expand Up @@ -283,12 +299,30 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
var entry Entry
switch psfp.Mode & os.ModeType {
case 0:
evaluateContents := func() ([]byte, error) {
readFile := func() ([]byte, error) {
return fs.ReadFile(path)
}
evaluateContents := readFile
if psfp.Encrypted {
prevEvaluateContents := evaluateContents
evaluateContents = func() ([]byte, error) {
encryptedData, err := prevEvaluateContents()
if err != nil {
return nil, err
}
cmd := exec.Command("gpg", "--decrypt")
cmd.Stdin = bytes.NewReader(encryptedData)
return cmd.Output()
}
}
if psfp.Template {
prevEvaluateContents := evaluateContents
evaluateContents = func() ([]byte, error) {
return ts.executeTemplate(fs, path)
data, err := prevEvaluateContents()
if err != nil {
return nil, err
}
return ts.executeTemplateData(path, data)
}
}
entry = &File{
Expand Down Expand Up @@ -359,7 +393,7 @@ func (ts *TargetState) addDir(targetName string, entries map[string]Entry, paren
return nil
}

func (ts *TargetState) addFile(targetName string, entries map[string]Entry, parentDirSourceName string, info os.FileInfo, template bool, contents []byte, mutator Mutator) error {
func (ts *TargetState) addFile(targetName string, entries map[string]Entry, parentDirSourceName string, info os.FileInfo, encrypted, template bool, contents []byte, mutator Mutator) error {
name := filepath.Base(targetName)
var existingFile *File
var existingContents []byte
Expand All @@ -377,10 +411,11 @@ func (ts *TargetState) addFile(targetName string, entries map[string]Entry, pare
perm := info.Mode().Perm()
empty := info.Size() == 0
sourceName := FileAttributes{
Name: name,
Mode: perm,
Empty: empty,
Template: template,
Name: name,
Mode: perm,
Empty: empty,
Encrypted: encrypted,
Template: template,
}.SourceName()
if parentDirSourceName != "" {
sourceName = filepath.Join(parentDirSourceName, sourceName)
Expand All @@ -389,6 +424,7 @@ func (ts *TargetState) addFile(targetName string, entries map[string]Entry, pare
sourceName: sourceName,
targetName: targetName,
Empty: empty,
Encrypted: encrypted,
Perm: perm,
Template: template,
contents: contents,
Expand Down Expand Up @@ -568,7 +604,7 @@ func (ts *TargetState) importHeader(r io.Reader, importTAROptions ImportTAROptio
if err != nil {
return err
}
return ts.addFile(targetName, entries, parentDirSourceName, info, false, contents, mutator)
return ts.addFile(targetName, entries, parentDirSourceName, info, false, false, contents, mutator)
case tar.TypeSymlink:
linkname := header.Linkname
return ts.addSymlink(targetName, entries, parentDirSourceName, linkname, mutator)
Expand Down

0 comments on commit e13bb89

Please sign in to comment.