Skip to content

Commit

Permalink
feat: Add age command
Browse files Browse the repository at this point in the history
  • Loading branch information
twpayne committed Aug 29, 2023
1 parent fe903d4 commit 205fd6c
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 0 deletions.
18 changes: 18 additions & 0 deletions assets/chezmoi.io/docs/reference/commands/age.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# `age`

Interact with age's passphrase-based encryption.

!!! hint

To get a full list of subcommands run:

```console
$ chezmoi age help
```

!!! example

```console
$ chezmoi age encrypt --passphrase plaintext.txt > ciphertext.txt
$ chezmoi age decrypt --passphrase ciphertext.txt > decrypted-ciphertext.txt
```
1 change: 1 addition & 0 deletions assets/chezmoi.io/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ nav:
- .chezmoiversion: reference/special-files-and-directories/chezmoiversion.md
- Commands:
- add: reference/commands/add.md
- age: reference/commands/age.md
- apply: reference/commands/apply.md
- archive: reference/commands/archive.md
- cat: reference/commands/cat.md
Expand Down
129 changes: 129 additions & 0 deletions internal/cmd/agecmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package cmd

import (
"bytes"
"errors"
"io"

"filippo.io/age"
"filippo.io/age/armor"
"github.com/spf13/cobra"
)

type ageDecryptCmdConfig struct {
passphrase bool
}

type ageEncryptCmdConfig struct {
passphrase bool
}

type ageCmdConfig struct {
decrypt ageDecryptCmdConfig
encrypt ageEncryptCmdConfig
}

func (c *Config) newAgeCmd() *cobra.Command {
ageCmd := &cobra.Command{
Use: "age",
Args: cobra.NoArgs,
Short: "Interact with age",
}

ageDecryptCmd := &cobra.Command{
Use: "decrypt [file...]",
Short: "Decrypt file or standard input",
RunE: c.runAgeDecryptCmd,
}
ageDecryptFlags := ageDecryptCmd.Flags()
ageDecryptFlags.BoolVarP(
&c.age.decrypt.passphrase,
"passphrase",
"p",
c.age.decrypt.passphrase,
"Decrypt with a passphrase",
)
ageCmd.AddCommand(ageDecryptCmd)

ageEncryptCmd := &cobra.Command{
Use: "encrypt [file...]",
Short: "Encrypt file or standard input",
RunE: c.runAgeEncryptCmd,
}
ageEncryptFlags := ageEncryptCmd.Flags()
ageEncryptFlags.BoolVarP(
&c.age.encrypt.passphrase,
"passphrase",
"p",
c.age.encrypt.passphrase,
"Encrypt with a passphrase",
)
ageCmd.AddCommand(ageEncryptCmd)

return ageCmd
}

func (c *Config) runAgeDecryptCmd(cmd *cobra.Command, args []string) error {
if !c.age.decrypt.passphrase {
return errors.New("only passphrase encryption is supported")
}
decrypt := func(ciphertext []byte) ([]byte, error) {
ciphertextReader := bytes.NewReader(ciphertext)
armoredCiphertextReader := armor.NewReader(ciphertextReader)
identity := &LazyScryptIdentity{
Passphrase: func() (string, error) {
return c.readPassword("Enter passphrase: ")
},
}
plaintextReader, err := age.Decrypt(armoredCiphertextReader, identity)
if err != nil {
return nil, err
}
plaintextBuffer := &bytes.Buffer{}
if _, err := io.Copy(plaintextBuffer, plaintextReader); err != nil {
return nil, err
}
return plaintextBuffer.Bytes(), nil
}
return c.filterInput(args, decrypt)
}

func (c *Config) runAgeEncryptCmd(cmd *cobra.Command, args []string) error {
if !c.age.encrypt.passphrase {
return errors.New("only passphrase encryption is supported")
}
passphrase, err := c.readPassword("Enter passphrase: ")
if err != nil {
return err
}
confirmPassphrase, err := c.readPassword("Confirm passphrase: ")
if err != nil {
return err
}
if passphrase != confirmPassphrase {
return errors.New("passphrases didn't match")
}
recipient, err := age.NewScryptRecipient(passphrase)
if err != nil {
return err
}
encrypt := func(plaintext []byte) ([]byte, error) {
ciphertextBuffer := &bytes.Buffer{}
armoredCiphertextWriter := armor.NewWriter(ciphertextBuffer)
ciphertextWriteCloser, err := age.Encrypt(armoredCiphertextWriter, recipient)
if err != nil {
return nil, err
}
if _, err := io.Copy(ciphertextWriteCloser, bytes.NewReader(plaintext)); err != nil {
return nil, err
}
if err := ciphertextWriteCloser.Close(); err != nil {
return nil, err
}
if err := armoredCiphertextWriter.Close(); err != nil {
return nil, err
}
return ciphertextBuffer.Bytes(), nil
}
return c.filterInput(args, encrypt)
}
2 changes: 2 additions & 0 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ type Config struct {
keyring keyringData

// Command configurations, not settable in the config file.
age ageCmdConfig
apply applyCmdConfig
archive archiveCmdConfig
chattr chattrCmdConfig
Expand Down Expand Up @@ -1544,6 +1545,7 @@ func (c *Config) newRootCmd() (*cobra.Command, error) {
rootCmd.SetHelpCommand(c.newHelpCmd())
for _, cmd := range []*cobra.Command{
c.newAddCmd(),
c.newAgeCmd(),
c.newApplyCmd(),
c.newArchiveCmd(),
c.newCatCmd(),
Expand Down
48 changes: 48 additions & 0 deletions internal/cmd/lazyscryptidentity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package cmd

import (
"errors"
"fmt"

"filippo.io/age"
)

// This is copied from https://github.com/FiloSottile/age/blob/6ad4560f4afc3fe46b6cda0bc568e50b89a22e4c/cmd/age/encrypted_keys.go#L15-L51

// LazyScryptIdentity is an age.Identity that requests a passphrase only if it
// encounters an scrypt stanza. After obtaining a passphrase, it delegates to
// ScryptIdentity.
type LazyScryptIdentity struct {
Passphrase func() (string, error)
}

var _ age.Identity = &LazyScryptIdentity{}

func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
for _, s := range stanzas {
if s.Type == "scrypt" && len(stanzas) != 1 {
return nil, errors.New("an scrypt recipient must be the only one")
}
}
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
return nil, age.ErrIncorrectIdentity
}
pass, err := i.Passphrase()
if err != nil {
return nil, fmt.Errorf("could not read passphrase: %w", err)
}
ii, err := age.NewScryptIdentity(pass)
if err != nil {
return nil, err
}
fileKey, err = ii.Unwrap(stanzas)
if errors.Is(err, age.ErrIncorrectIdentity) {
// ScryptIdentity returns ErrIncorrectIdentity for an incorrect
// passphrase, which would lead Decrypt to returning "no identity
// matched any recipient". That makes sense in the API, where there
// might be multiple configured ScryptIdentity. Since in cmd/age there
// can be only one, return a better error message.
return nil, errors.New("incorrect passphrase")
}
return fileKey, err
}
19 changes: 19 additions & 0 deletions internal/cmd/testdata/scripts/age.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# test that chezmoi age encrypt encrypts a file with a passphrase
stdin $HOME/passphrases
exec chezmoi age encrypt --output $HOME${/}secret.txt.age --passphrase --no-tty $HOME${/}secret.txt
grep '-----BEGIN AGE ENCRYPTED FILE----' $HOME/secret.txt.age

# test that chezmoi decrypt decrypts a file with a passphrase
stdin $HOME/passphrase
exec chezmoi age decrypt --output $HOME${/}secret.txt.decrypted --passphrase --no-tty $HOME${/}secret.txt.age
cmp $HOME/secret.txt.decrypted $HOME/secret.txt

-- home/user/passphrase --
passphrase
-- home/user/passphrases --
passphrase
passphrase
-- home/user/plaintext.txt --
plaintext
-- home/user/secret.txt --
secret

0 comments on commit 205fd6c

Please sign in to comment.