From 8a64da4b0bf3f279e38192c1704a3237195d6f64 Mon Sep 17 00:00:00 2001 From: Lennart Date: Sun, 11 Aug 2024 10:11:23 +0200 Subject: [PATCH] Feature: PGP Asymmetric Encryption (#456) * feat: asym encryption * tests * docs * refactor * logs & errs * comment * Update docs/reference/index.md use correct env var in example Co-authored-by: Frederik Ring * Update cmd/backup/encrypt_archive.go use errwarp for initial error msg Co-authored-by: Frederik Ring * rm orphaned code in encryption functions * inline readArmoredKeys * naming -GPG_PUBLIC_KEYS- to GPG_PUBLIC_KEY_RING * add eror handling for closing func * use dynamically generated keys for testing * rm explicit gpg-agent start * rm unnecessary private_key export * pass PASSPHRASE correctly to the decryption command * capture defer errors * log & err msg --------- Co-authored-by: Frederik Ring --- cmd/backup/config.go | 1 + cmd/backup/encrypt_archive.go | 88 +++++++++++++++++++---- docs/how-tos/encrypt-backups-using-gpg.md | 2 +- docs/recipes/index.md | 29 +++++++- docs/reference/index.md | 11 ++- test/Dockerfile | 1 + test/gpg-asym/docker-compose.yml | 25 +++++++ test/gpg-asym/run.sh | 49 +++++++++++++ 8 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 test/gpg-asym/docker-compose.yml create mode 100755 test/gpg-asym/run.sh diff --git a/cmd/backup/config.go b/cmd/backup/config.go index 0609a2f..4230827 100644 --- a/cmd/backup/config.go +++ b/cmd/backup/config.go @@ -47,6 +47,7 @@ type Config struct { BackupExcludeRegexp RegexpDecoder `split_words:"true"` BackupSkipBackendsFromPrune []string `split_words:"true"` GpgPassphrase string `split_words:"true"` + GpgPublicKeyRing 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 32a013c..8749702 100644 --- a/cmd/backup/encrypt_archive.go +++ b/cmd/backup/encrypt_archive.go @@ -4,20 +4,75 @@ package main import ( + "bytes" + "errors" "fmt" "io" "os" "path" + "github.com/ProtonMail/go-crypto/openpgp/armor" openpgp "github.com/ProtonMail/go-crypto/openpgp/v2" "github.com/offen/docker-volume-backup/internal/errwrap" ) -// encryptArchive encrypts the backup file using PGP and the configured passphrase. -// In case no passphrase is given it returns early, leaving the backup file +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 + } + 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 +} + +// 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 { - if s.c.GpgPassphrase == "" { + + 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: return nil } @@ -36,22 +91,31 @@ func (s *script) encryptArchive() error { if err != nil { return errwrap.Wrap(err, "error opening out file") } - defer outFile.Close() + defer func() { + if err := outFile.Close(); err != nil { + cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing out file")) + } + }() - _, name := path.Split(s.file) - dst, err := openpgp.SymmetricallyEncrypt(outFile, []byte(s.c.GpgPassphrase), &openpgp.FileHints{ - FileName: name, - }, nil) + dst, dstCloseCallback, err := encrypt(outFile) if err != nil { return errwrap.Wrap(err, "error encrypting backup file") } - defer dst.Close() + defer func() { + if err := dstCloseCallback(); err != nil { + cleanUpErr = errors.Join(cleanUpErr, 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)) } - defer src.Close() + defer func() { + if err := src.Close(); err != nil { + cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing backup file")) + } + }() if _, err := io.Copy(dst, src); err != nil { return errwrap.Wrap(err, "error writing ciphertext to file") @@ -59,7 +123,7 @@ func (s *script) encryptArchive() error { s.file = gpgFile s.logger.Info( - fmt.Sprintf("Encrypted backup using given passphrase, saving as `%s`.", s.file), + fmt.Sprintf("Encrypted backup using gpg, saving as `%s`.", s.file), ) - return nil + return cleanUpErr } diff --git a/docs/how-tos/encrypt-backups-using-gpg.md b/docs/how-tos/encrypt-backups-using-gpg.md index 001a7c4..59438e9 100644 --- a/docs/how-tos/encrypt-backups-using-gpg.md +++ b/docs/how-tos/encrypt-backups-using-gpg.md @@ -8,7 +8,7 @@ nav_order: 7 # Encrypt backups using GPG The image supports encrypting backups using GPG out of the box. -In case a `GPG_PASSPHRASE` environment variable is set, the backup archive will be encrypted using the given key and saved as a `.gpg` file instead. +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): diff --git a/docs/recipes/index.md b/docs/recipes/index.md index 3b4045e..f5ea77f 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -289,7 +289,7 @@ volumes: data: ``` -## Encrypting your backups using GPG +## Encrypting your backups symmetrically using GPG ```yml version: '3' @@ -311,6 +311,33 @@ volumes: data: ``` +## Encrypting your backups asymmetrically using GPG + +```yml +version: '3' + +services: + # ... define other services using the `data` volume here + backup: + image: offen/docker-volume-backup:v2 + environment: + AWS_S3_BUCKET_NAME: backup-bucket + AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE + AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + GPG_PUBLIC_KEY_RING: | + -----BEGIN PGP PUBLIC KEY BLOCK----- + + D/cIHu6GH/0ghlcUVSbgMg5RRI5QKNNKh04uLAPxr75mKwUg0xPUaWgyyrAChVBi + ... + -----END PGP PUBLIC KEY BLOCK----- + volumes: + - data:/backup/my-app-backup:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + +volumes: + data: +``` + ## Using mysqldump to prepare the backup ```yml diff --git a/docs/reference/index.md b/docs/reference/index.md index 40146d8..25840f0 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -337,10 +337,19 @@ You can populate below template according to your requirements and use it as you ########### BACKUP ENCRYPTION -# Backups can be encrypted using gpg in case a passphrase is given. +# Backups can be encrypted symmetrically using gpg in case a passphrase is given. # GPG_PASSPHRASE="" +# Backups can be encrypted asymmetrically using gpg in case publickeys are given. + +# GPG_PUBLIC_KEY_RING= | +#-----BEGIN PGP PUBLIC KEY BLOCK----- +# +#D/cIHu6GH/0ghlcUVSbgMg5RRI5QKNNKh04uLAPxr75mKwUg0xPUaWgyyrAChVBi +#... +#-----END PGP PUBLIC KEY BLOCK----- + ########### STOPPING CONTAINERS AND SERVICES DURING BACKUP # Containers or services can be stopped by applying a diff --git a/test/Dockerfile b/test/Dockerfile index 55647de..0a77a3a 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -4,6 +4,7 @@ RUN apk add \ coreutils \ curl \ gpg \ + gpg-agent \ jq \ moreutils \ tar \ diff --git a/test/gpg-asym/docker-compose.yml b/test/gpg-asym/docker-compose.yml new file mode 100644 index 0000000..097d030 --- /dev/null +++ b/test/gpg-asym/docker-compose.yml @@ -0,0 +1,25 @@ +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.gpg + BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7} + GPG_PUBLIC_KEY_RING_FILE: /keys/public_key.asc + volumes: + - ${KEY_DIR:-.}/public_key.asc:/keys/public_key.asc + - ${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/gpg-asym/run.sh b/test/gpg-asym/run.sh new file mode 100755 index 0000000..c680fba --- /dev/null +++ b/test/gpg-asym/run.sh @@ -0,0 +1,49 @@ +#!/bin/sh + +set -e + +cd "$(dirname "$0")" +. ../util.sh +current_test=$(basename $(pwd)) + +export LOCAL_DIR=$(mktemp -d) + +export KEY_DIR=$(mktemp -d) + +export PASSPHRASE="test" + +gpg --batch --gen-key < "$LOCAL_DIR/decrypted.tar.gz" + +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 "$LOCAL_DIR/decrypted.tar.gz" + +pass "Found relevant files in decrypted and untared local backup." + +if [ ! -L "$LOCAL_DIR/test-latest.tar.gz.gpg" ]; then + fail "Could not find local symlink to latest encrypted backup." +fi