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 <frederik.ring@gmail.com>

* Update cmd/backup/encrypt_archive.go

use errwarp for initial error msg

Co-authored-by: Frederik Ring <frederik.ring@gmail.com>

* 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 <frederik.ring@gmail.com>
This commit is contained in:
Lennart 2024-08-11 10:11:23 +02:00 committed by GitHub
parent f97ce11734
commit 8a64da4b0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 191 additions and 15 deletions

View File

@ -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"`

View File

@ -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
}

View File

@ -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):

View File

@ -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

View File

@ -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="<xxx>"
# 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

View File

@ -4,6 +4,7 @@ RUN apk add \
coreutils \
curl \
gpg \
gpg-agent \
jq \
moreutils \
tar \

View File

@ -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:

49
test/gpg-asym/run.sh Executable file
View File

@ -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 <<EOF
Key-Type: RSA
Key-Length: 4096
Name-Real: offen
Name-Email: docker-volume-backup@local
Expire-Date: 0
Passphrase: $PASSPHRASE
%commit
EOF
gpg --export --armor --batch --yes --pinentry-mode loopback --passphrase $PASSPHRASE --output $KEY_DIR/public_key.asc
docker compose up -d --quiet-pull
sleep 5
docker compose exec backup backup
expect_running_containers "2"
TMP_DIR=$(mktemp -d)
gpg -d --pinentry-mode loopback --yes --passphrase $PASSPHRASE "$LOCAL_DIR/test.tar.gz.gpg" > "$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