Skip to content

Commit

Permalink
Feature: PGP Asymmetric Encryption (#456)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update cmd/backup/encrypt_archive.go

use errwarp for initial error msg

Co-authored-by: Frederik Ring <[email protected]>

* 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 <[email protected]>
  • Loading branch information
lbsekr and m90 authored Aug 11, 2024
1 parent f97ce11 commit 8a64da4
Show file tree
Hide file tree
Showing 8 changed files with 191 additions and 15 deletions.
1 change: 1 addition & 0 deletions cmd/backup/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
88 changes: 76 additions & 12 deletions cmd/backup/encrypt_archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -36,30 +91,39 @@ 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")
}

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
}
2 changes: 1 addition & 1 deletion docs/how-tos/encrypt-backups-using-gpg.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
29 changes: 28 additions & 1 deletion docs/recipes/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ volumes:
data:
```
## Encrypting your backups using GPG
## Encrypting your backups symmetrically using GPG
```yml
version: '3'
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ RUN apk add \
coreutils \
curl \
gpg \
gpg-agent \
jq \
moreutils \
tar \
Expand Down
25 changes: 25 additions & 0 deletions test/gpg-asym/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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 changes: 49 additions & 0 deletions test/gpg-asym/run.sh
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 8a64da4

Please sign in to comment.