Skip to content

Commit

Permalink
feat: allow backups to be encrypted with age
Browse files Browse the repository at this point in the history
GPG is known to have usability issues and is generally cumbersome to
use. age [0] is a modern alternative to GPG that is designed by a
cryptographer that has worked and continues to work on Golang's crypto
packages for years.

Allowing age to be used to encrypt backups dramatically simplifies the
backup process.

[0]: https://age-encryption.org/
  • Loading branch information
nkcmr committed Aug 19, 2024
1 parent 74e065c commit 6c3cc1b
Show file tree
Hide file tree
Showing 11 changed files with 296 additions and 62 deletions.
2 changes: 2 additions & 0 deletions cmd/backup/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ type Config struct {
BackupSkipBackendsFromPrune []string `split_words:"true"`
GpgPassphrase string `split_words:"true"`
GpgPublicKeyRing string `split_words:"true"`
AgePassphrase string `split_words:"true"`
AgePublicKeys []string `split_words:"true"`
NotificationURLs []string `envconfig:"NOTIFICATION_URLS"`
NotificationLevel string `split_words:"true" default:"error"`
EmailNotificationRecipient string `split_words:"true"`
Expand Down
197 changes: 140 additions & 57 deletions cmd/backup/encrypt_archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,119 +11,202 @@ import (
"os"
"path"

"filippo.io/age"
"github.com/ProtonMail/go-crypto/openpgp/armor"
openpgp "github.com/ProtonMail/go-crypto/openpgp/v2"
"github.com/offen/docker-volume-backup/internal/errwrap"
)

func (s *script) encryptAsymmetrically(outFile *os.File) (io.WriteCloser, func() error, error) {
func countTrue(b ...bool) int {
c := int(0)
for _, v := range b {
if v {
c++
}
}
return c
}

entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.c.GpgPublicKeyRing)))
if err != nil {
return nil, nil, errwrap.Wrap(err, "error parsing armored keyring")
// encryptArchive encrypts the backup file using PGP and the configured passphrase or publickey(s).
// In case no passphrase or publickey is given it returns early, leaving the backup file
// untouched.
func (s *script) encryptArchive() error {
useGPGSymmetric := s.c.GpgPassphrase != ""
useGPGAsymmetric := s.c.GpgPublicKeyRing != ""
useAgeSymmetric := s.c.AgePassphrase != ""
useAgeAsymmetric := len(s.c.AgePublicKeys) > 0
switch nconfigured := countTrue(
useGPGSymmetric,
useGPGAsymmetric,
useAgeSymmetric,
useAgeAsymmetric,
); nconfigured {
case 0:
return nil
case 1:
// ok!
default:
return fmt.Errorf(
"error in selecting archive encryption method: expected 0 or 1 to be configured, %d methods are configured",
nconfigured,
)
}

armoredWriter, err := armor.Encode(outFile, "PGP MESSAGE", nil)
if err != nil {
return nil, nil, errwrap.Wrap(err, "error preparing encryption")
if useGPGSymmetric {
return s.encryptWithGPGSymmetric()
} else if useGPGAsymmetric {
return s.encryptWithGPGAsymmetric()
} else if useAgeSymmetric || useAgeAsymmetric {
ar, err := s.getConfiguredAgeRecipients()
if err != nil {
return errwrap.Wrap(err, "failed to get configured age recipients")
}
return s.encryptWithAge(ar)
}
return nil
}

_, name := path.Split(s.file)
dst, err := openpgp.Encrypt(armoredWriter, entityList, nil, nil, &openpgp.FileHints{
FileName: name,
}, nil)
if err != nil {
return nil, nil, err
func (s *script) getConfiguredAgeRecipients() ([]age.Recipient, error) {
if s.c.AgePassphrase == "" && len(s.c.AgePublicKeys) == 0 {
return nil, fmt.Errorf("no age recipients configured")
}
recipients := []age.Recipient{}
if len(s.c.AgePublicKeys) > 0 {
for _, pk := range s.c.AgePublicKeys {
pkr, err := age.ParseX25519Recipient(pk)
if err != nil {
return nil, errwrap.Wrap(err, "failed to parse age public key")
}
recipients = append(recipients, pkr)
}
}
if s.c.AgePassphrase != "" {
if len(recipients) != 0 {
return nil, fmt.Errorf("age encryption must only be enabled via passphrase or public key, not both")
}

return dst, func() error {
if err := dst.Close(); err != nil {
return err
r, err := age.NewScryptRecipient(s.c.AgePassphrase)
if err != nil {
return nil, errwrap.Wrap(err, "failed to create scrypt identity from age passphrase")
}
return armoredWriter.Close()
}, err
recipients = append(recipients, r)
}
return recipients, nil
}

func (s *script) encryptSymmetrically(outFile *os.File) (io.WriteCloser, func() error, error) {
func (s *script) encryptWithAge(rec []age.Recipient) error {
return s.doEncrypt("age", func(ciphertextWriter io.Writer) (io.WriteCloser, error) {
return age.Encrypt(ciphertextWriter, rec...)
})
}

_, name := path.Split(s.file)
dst, err := openpgp.SymmetricallyEncrypt(outFile, []byte(s.c.GpgPassphrase), &openpgp.FileHints{
FileName: name,
}, nil)
if err != nil {
return nil, nil, err
}
func (s *script) encryptWithGPGSymmetric() error {
return s.doEncrypt("gpg", func(ciphertextWriter io.Writer) (io.WriteCloser, error) {
_, name := path.Split(s.file)
return openpgp.SymmetricallyEncrypt(ciphertextWriter, []byte(s.c.GpgPassphrase), &openpgp.FileHints{
FileName: name,
}, nil)
})
}

return dst, dst.Close, nil
type closeAllWriter struct {
io.Writer
closers []io.Closer
}

// encryptArchive encrypts the backup file using PGP and the configured passphrase or publickey(s).
// In case no passphrase or publickey is given it returns early, leaving the backup file
// untouched.
func (s *script) encryptArchive() error {
func (c *closeAllWriter) Close() (err error) {
for _, cl := range c.closers {
err = errors.Join(err, cl.Close())
}
return
}

var encrypt func(outFile *os.File) (io.WriteCloser, func() error, error)
var cleanUpErr error
var _ io.WriteCloser = (*closeAllWriter)(nil)

switch {
case s.c.GpgPassphrase != "" && s.c.GpgPublicKeyRing != "":
return errwrap.Wrap(nil, "error in selecting asymmetric and symmetric encryption methods: conflicting env vars are set")
case s.c.GpgPassphrase != "":
encrypt = s.encryptSymmetrically
case s.c.GpgPublicKeyRing != "":
encrypt = s.encryptAsymmetrically
default:
return nil
}
func (s *script) encryptWithGPGAsymmetric() error {
return s.doEncrypt("gpg", func(ciphertextWriter io.Writer) (_ io.WriteCloser, outerr error) {
entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.c.GpgPublicKeyRing)))
if err != nil {
return nil, errwrap.Wrap(err, "error parsing armored keyring")
}

armoredWriter, err := armor.Encode(ciphertextWriter, "PGP MESSAGE", nil)
if err != nil {
return nil, errwrap.Wrap(err, "error preparing encryption")
}
defer func() {
if outerr != nil {
_ = armoredWriter.Close()
}
}()

_, name := path.Split(s.file)
encWriter, err := openpgp.Encrypt(armoredWriter, entityList, nil, nil, &openpgp.FileHints{
FileName: name,
}, nil)
if err != nil {
return nil, err
}
return &closeAllWriter{
Writer: encWriter,
closers: []io.Closer{encWriter, armoredWriter},
}, nil
})
}

gpgFile := fmt.Sprintf("%s.gpg", s.file)
func (s *script) doEncrypt(
extension string,
encryptor func(ciphertextWriter io.Writer) (io.WriteCloser, error),
) (outerr error) {
encFile := fmt.Sprintf("%s.%s", s.file, extension)
s.registerHook(hookLevelPlumbing, func(error) error {
if err := remove(gpgFile); err != nil {
return errwrap.Wrap(err, "error removing gpg file")
if err := remove(encFile); err != nil {
return errwrap.Wrap(err, "error removing encrypted file")
}
s.logger.Info(
fmt.Sprintf("Removed GPG file `%s`.", gpgFile),
fmt.Sprintf("Removed encrypted file `%s`.", encFile),
)
return nil
})

outFile, err := os.Create(gpgFile)
outFile, err := os.Create(encFile)
if err != nil {
return errwrap.Wrap(err, "error opening out file")
}
defer func() {
if err := outFile.Close(); err != nil {
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing out file"))
outerr = errors.Join(outerr, errwrap.Wrap(err, "error closing out file"))
}
}()

dst, dstCloseCallback, err := encrypt(outFile)
dst, err := encryptor(outFile)
if err != nil {
return errwrap.Wrap(err, "error encrypting backup file")
}
defer func() {
if err := dstCloseCallback(); err != nil {
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing encrypted backup file"))
if err := dst.Close(); err != nil {
outerr = errors.Join(outerr, errwrap.Wrap(err, "error closing encrypted backup file"))
}
}()

src, err := os.Open(s.file)
if err != nil {
return errwrap.Wrap(err, fmt.Sprintf("error opening backup file `%s`", s.file))
return errwrap.Wrap(err, fmt.Sprintf("error opening backup file %q", s.file))
}
defer func() {
if err := src.Close(); err != nil {
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing backup file"))
outerr = errors.Join(outerr, errwrap.Wrap(err, "error closing backup file"))
}
}()

if _, err := io.Copy(dst, src); err != nil {
return errwrap.Wrap(err, "error writing ciphertext to file")
}

s.file = gpgFile
s.file = encFile
s.logger.Info(
fmt.Sprintf("Encrypted backup using gpg, saving as `%s`.", s.file),
fmt.Sprintf("Encrypted backup using %q, saving as %q", extension, s.file),
)
return cleanUpErr

return
}
17 changes: 14 additions & 3 deletions docs/how-tos/encrypt-backups-using-gpg.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
---
title: Encrypt backups using GPG
title: Encrypting backups
layout: default
parent: How Tos
nav_order: 7
---

# Encrypt backups using GPG
# Encrypting backups

The image supports encrypting backups using one of two available methods: **GPG** or **[age](https://age-encryption.org/)**

## Using GPG encryption

The image supports encrypting backups using GPG out of the box.
In case a `GPG_PASSPHRASE` or `GPG_PUBLIC_KEY_RING` environment variable is set, the backup archive will be encrypted using the given key and saved as a `.gpg` file instead.

Assuming you have `gpg` installed, you can decrypt such a backup using (your OS will prompt for the passphrase before decryption can happen):

```console
gpg -o backup.tar.gz -d backup.tar.gz.gpg
```

## Using age encryption

age allows backups to be encrypted with either a symmetric key (password) or a public key. One of those options are available for use.

Given `AGE_PASSPHRASE` being provided, the backup archive will be encrypted with the passphrase and saved as a `.age` file instead. Refer to age documentation for how to properly decrypt.

Given `AGE_PUBLIC_KEYS` being provided (allowing multiple by separating each public key with `,`), the backup archive will be encrypted with the provided public keys. It will also result in the archive being saved as a `.age` file.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/offen/docker-volume-backup
go 1.22

require (
filippo.io/age v1.2.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.4.0
github.com/containrrr/shoutrrr v0.8.0
Expand Down
8 changes: 6 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
Expand Down Expand Up @@ -31,6 +33,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/age v1.2.0 h1:vRDp7pUMaAJzXNIWJVAZnEf/Dyi4Vu4wI8S1LBzufhE=
filippo.io/age v1.2.0/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0 h1:GJHeeA2N7xrG3q30L2UXDyuWRzDM900/65j70wcM4Ww=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.13.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
Expand Down Expand Up @@ -485,8 +489,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
Expand Down
2 changes: 2 additions & 0 deletions test/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
FROM docker:27-dind

RUN apk add \
age \
coreutils \
curl \
expect \
gpg \
gpg-agent \
jq \
Expand Down
24 changes: 24 additions & 0 deletions test/age-passphrase/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
services:
backup:
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
restart: always
environment:
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
BACKUP_FILENAME: test.tar.gz
BACKUP_LATEST_SYMLINK: test-latest.tar.gz.age
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
AGE_PASSPHRASE: "Dance.0Tonight.Go.Typical"
volumes:
- ${LOCAL_DIR:-./local}:/archive
- app_data:/backup/app_data:ro
- /var/run/docker.sock:/var/run/docker.sock

offen:
image: offen/offen:latest
labels:
- docker-volume-backup.stop-during-backup=true
volumes:
- app_data:/var/opt/offen

volumes:
app_data:
Loading

0 comments on commit 6c3cc1b

Please sign in to comment.