diff --git a/cmd/backup/config.go b/cmd/backup/config.go index 4230827..b5a98d9 100644 --- a/cmd/backup/config.go +++ b/cmd/backup/config.go @@ -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"` diff --git a/cmd/backup/encrypt_archive.go b/cmd/backup/encrypt_archive.go index 8749702..e658a13 100644 --- a/cmd/backup/encrypt_archive.go +++ b/cmd/backup/encrypt_archive.go @@ -11,109 +11,191 @@ 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) { - - entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.c.GpgPublicKeyRing))) - if err != nil { - return nil, nil, errwrap.Wrap(err, "error parsing armored keyring") - } - - armoredWriter, err := armor.Encode(outFile, "PGP MESSAGE", nil) - if err != nil { - return nil, nil, errwrap.Wrap(err, "error preparing encryption") - } - - _, 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 - } - - return dst, func() error { - if err := dst.Close(); err != nil { - return err +func countTrue(b ...bool) int { + c := int(0) + for _, v := range b { + if v { + c++ } - return armoredWriter.Close() - }, err -} - -func (s *script) encryptSymmetrically(outFile *os.File) (io.WriteCloser, func() error, error) { - - _, 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 } - - return dst, dst.Close, nil + return c } // 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 { - - var encrypt func(outFile *os.File) (io.WriteCloser, func() error, error) - var cleanUpErr error - - 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: + 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, + ) } - gpgFile := fmt.Sprintf("%s.gpg", s.file) + 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 +} + +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") + } + + r, err := age.NewScryptRecipient(s.c.AgePassphrase) + if err != nil { + return nil, errwrap.Wrap(err, "failed to create scrypt identity from age passphrase") + } + recipients = append(recipients, r) + } + return recipients, nil +} + +func (s *script) encryptWithAge(rec []age.Recipient) error { + return s.doEncrypt("age", func(ciphertextWriter io.Writer) (io.WriteCloser, error) { + return age.Encrypt(ciphertextWriter, rec...) + }) +} + +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) + }) +} + +type closeAllWriter struct { + io.Writer + closers []io.Closer +} + +func (c *closeAllWriter) Close() (err error) { + for _, cl := range c.closers { + err = errors.Join(err, cl.Close()) + } + return +} + +var _ io.WriteCloser = (*closeAllWriter)(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 + }) +} + +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")) } }() @@ -121,9 +203,10 @@ func (s *script) encryptArchive() error { 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 } diff --git a/docs/how-tos/encrypt-backups-using-gpg.md b/docs/how-tos/encrypt-backups-using-gpg.md index 59438e9..da3947e 100644 --- a/docs/how-tos/encrypt-backups-using-gpg.md +++ b/docs/how-tos/encrypt-backups-using-gpg.md @@ -5,13 +5,4 @@ parent: How Tos nav_order: 7 --- -# Encrypt backups using GPG - -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 -``` +See: [Encrypt Backups](encrypt-backups) diff --git a/docs/how-tos/encrypt-backups.md b/docs/how-tos/encrypt-backups.md new file mode 100644 index 0000000..546a0e6 --- /dev/null +++ b/docs/how-tos/encrypt-backups.md @@ -0,0 +1,28 @@ +--- +title: Encrypting backups +layout: default +parent: How Tos +nav_order: 7 +--- + +# Encrypting backups + +The image supports encrypting backups using one of two available methods: **GPG** or **[age](https://age-encryption.org/)** + +## Using GPG encryption + +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. diff --git a/go.mod b/go.mod index ff0cea9..1c3fcec 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index bced3e1..98003aa 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= diff --git a/test/Dockerfile b/test/Dockerfile index 0a77a3a..9ebbd4a 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -1,8 +1,10 @@ FROM docker:27-dind RUN apk add \ + age \ coreutils \ curl \ + expect \ gpg \ gpg-agent \ jq \ diff --git a/test/age-passphrase/docker-compose.yml b/test/age-passphrase/docker-compose.yml new file mode 100644 index 0000000..b47ac72 --- /dev/null +++ b/test/age-passphrase/docker-compose.yml @@ -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: diff --git a/test/age-passphrase/run.sh b/test/age-passphrase/run.sh new file mode 100755 index 0000000..4c4285b --- /dev/null +++ b/test/age-passphrase/run.sh @@ -0,0 +1,39 @@ +#!/bin/sh + +set -e + +cd "$(dirname "$0")" +. ../util.sh +current_test=$(basename "$(pwd)") + +export LOCAL_DIR="$(mktemp -d)" + +docker compose up -d --quiet-pull +sleep 5 + +docker compose exec backup backup + +expect_running_containers "2" + +TMP_DIR=$(mktemp -d) + +# complex usage of expect(1) due to age not have a way to programmatically +# provide the passphrase +expect -i <"$LOCAL_DIR/pk-a.txt" +PK_A="$(grep -E 'public key' <"$LOCAL_DIR/pk-a.txt" | cut -d: -f2 | xargs)" +age-keygen >"$LOCAL_DIR/pk-b.txt" +PK_B="$(grep -E 'public key' <"$LOCAL_DIR/pk-b.txt" | cut -d: -f2 | xargs)" + +export BACKUP_AGE_PUBLIC_KEYS="$PK_A,$PK_B" + +docker compose up -d --quiet-pull +sleep 5 + +docker compose exec backup backup + +expect_running_containers "2" + +do_decrypt() { + TMP_DIR=$(mktemp -d) + age --decrypt -i "$1" -o "$LOCAL_DIR/decrypted.tar.gz" "$LOCAL_DIR/test.tar.gz.age" + tar -xf "$LOCAL_DIR/decrypted.tar.gz" -C "$TMP_DIR" + + if [ ! -f "$TMP_DIR/backup/app_data/offen.db" ]; then + fail "Could not find expected file in untared archive." + fi + rm -vf "$LOCAL_DIR/decrypted.tar.gz" + + pass "Found relevant files in decrypted and untared local backup." + + if [ ! -L "$LOCAL_DIR/test-latest.tar.gz.age" ]; then + fail "Could not find local symlink to latest encrypted backup." + fi +} + +do_decrypt "$LOCAL_DIR/pk-a.txt" +do_decrypt "$LOCAL_DIR/pk-b.txt"