Skip to content

Commit 8a64da4

Browse files
lbsekrm90
andauthored
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 <[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]>
1 parent f97ce11 commit 8a64da4

File tree

8 files changed

+191
-15
lines changed

8 files changed

+191
-15
lines changed

cmd/backup/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ type Config struct {
4747
BackupExcludeRegexp RegexpDecoder `split_words:"true"`
4848
BackupSkipBackendsFromPrune []string `split_words:"true"`
4949
GpgPassphrase string `split_words:"true"`
50+
GpgPublicKeyRing string `split_words:"true"`
5051
NotificationURLs []string `envconfig:"NOTIFICATION_URLS"`
5152
NotificationLevel string `split_words:"true" default:"error"`
5253
EmailNotificationRecipient string `split_words:"true"`

cmd/backup/encrypt_archive.go

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,75 @@
44
package main
55

66
import (
7+
"bytes"
8+
"errors"
79
"fmt"
810
"io"
911
"os"
1012
"path"
1113

14+
"github.com/ProtonMail/go-crypto/openpgp/armor"
1215
openpgp "github.com/ProtonMail/go-crypto/openpgp/v2"
1316
"github.com/offen/docker-volume-backup/internal/errwrap"
1417
)
1518

16-
// encryptArchive encrypts the backup file using PGP and the configured passphrase.
17-
// In case no passphrase is given it returns early, leaving the backup file
19+
func (s *script) encryptAsymmetrically(outFile *os.File) (io.WriteCloser, func() error, error) {
20+
21+
entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.c.GpgPublicKeyRing)))
22+
if err != nil {
23+
return nil, nil, errwrap.Wrap(err, "error parsing armored keyring")
24+
}
25+
26+
armoredWriter, err := armor.Encode(outFile, "PGP MESSAGE", nil)
27+
if err != nil {
28+
return nil, nil, errwrap.Wrap(err, "error preparing encryption")
29+
}
30+
31+
_, name := path.Split(s.file)
32+
dst, err := openpgp.Encrypt(armoredWriter, entityList, nil, nil, &openpgp.FileHints{
33+
FileName: name,
34+
}, nil)
35+
if err != nil {
36+
return nil, nil, err
37+
}
38+
39+
return dst, func() error {
40+
if err := dst.Close(); err != nil {
41+
return err
42+
}
43+
return armoredWriter.Close()
44+
}, err
45+
}
46+
47+
func (s *script) encryptSymmetrically(outFile *os.File) (io.WriteCloser, func() error, error) {
48+
49+
_, name := path.Split(s.file)
50+
dst, err := openpgp.SymmetricallyEncrypt(outFile, []byte(s.c.GpgPassphrase), &openpgp.FileHints{
51+
FileName: name,
52+
}, nil)
53+
if err != nil {
54+
return nil, nil, err
55+
}
56+
57+
return dst, dst.Close, nil
58+
}
59+
60+
// encryptArchive encrypts the backup file using PGP and the configured passphrase or publickey(s).
61+
// In case no passphrase or publickey is given it returns early, leaving the backup file
1862
// untouched.
1963
func (s *script) encryptArchive() error {
20-
if s.c.GpgPassphrase == "" {
64+
65+
var encrypt func(outFile *os.File) (io.WriteCloser, func() error, error)
66+
var cleanUpErr error
67+
68+
switch {
69+
case s.c.GpgPassphrase != "" && s.c.GpgPublicKeyRing != "":
70+
return errwrap.Wrap(nil, "error in selecting asymmetric and symmetric encryption methods: conflicting env vars are set")
71+
case s.c.GpgPassphrase != "":
72+
encrypt = s.encryptSymmetrically
73+
case s.c.GpgPublicKeyRing != "":
74+
encrypt = s.encryptAsymmetrically
75+
default:
2176
return nil
2277
}
2378

@@ -36,30 +91,39 @@ func (s *script) encryptArchive() error {
3691
if err != nil {
3792
return errwrap.Wrap(err, "error opening out file")
3893
}
39-
defer outFile.Close()
94+
defer func() {
95+
if err := outFile.Close(); err != nil {
96+
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing out file"))
97+
}
98+
}()
4099

41-
_, name := path.Split(s.file)
42-
dst, err := openpgp.SymmetricallyEncrypt(outFile, []byte(s.c.GpgPassphrase), &openpgp.FileHints{
43-
FileName: name,
44-
}, nil)
100+
dst, dstCloseCallback, err := encrypt(outFile)
45101
if err != nil {
46102
return errwrap.Wrap(err, "error encrypting backup file")
47103
}
48-
defer dst.Close()
104+
defer func() {
105+
if err := dstCloseCallback(); err != nil {
106+
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing encrypted backup file"))
107+
}
108+
}()
49109

50110
src, err := os.Open(s.file)
51111
if err != nil {
52112
return errwrap.Wrap(err, fmt.Sprintf("error opening backup file `%s`", s.file))
53113
}
54-
defer src.Close()
114+
defer func() {
115+
if err := src.Close(); err != nil {
116+
cleanUpErr = errors.Join(cleanUpErr, errwrap.Wrap(err, "error closing backup file"))
117+
}
118+
}()
55119

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

60124
s.file = gpgFile
61125
s.logger.Info(
62-
fmt.Sprintf("Encrypted backup using given passphrase, saving as `%s`.", s.file),
126+
fmt.Sprintf("Encrypted backup using gpg, saving as `%s`.", s.file),
63127
)
64-
return nil
128+
return cleanUpErr
65129
}

docs/how-tos/encrypt-backups-using-gpg.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ nav_order: 7
88
# Encrypt backups using GPG
99

1010
The image supports encrypting backups using GPG out of the box.
11-
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.
11+
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.
1212

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

docs/recipes/index.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ volumes:
289289
data:
290290
```
291291
292-
## Encrypting your backups using GPG
292+
## Encrypting your backups symmetrically using GPG
293293
294294
```yml
295295
version: '3'
@@ -311,6 +311,33 @@ volumes:
311311
data:
312312
```
313313
314+
## Encrypting your backups asymmetrically using GPG
315+
316+
```yml
317+
version: '3'
318+
319+
services:
320+
# ... define other services using the `data` volume here
321+
backup:
322+
image: offen/docker-volume-backup:v2
323+
environment:
324+
AWS_S3_BUCKET_NAME: backup-bucket
325+
AWS_ACCESS_KEY_ID: AKIAIOSFODNN7EXAMPLE
326+
AWS_SECRET_ACCESS_KEY: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
327+
GPG_PUBLIC_KEY_RING: |
328+
-----BEGIN PGP PUBLIC KEY BLOCK-----
329+
330+
D/cIHu6GH/0ghlcUVSbgMg5RRI5QKNNKh04uLAPxr75mKwUg0xPUaWgyyrAChVBi
331+
...
332+
-----END PGP PUBLIC KEY BLOCK-----
333+
volumes:
334+
- data:/backup/my-app-backup:ro
335+
- /var/run/docker.sock:/var/run/docker.sock:ro
336+
337+
volumes:
338+
data:
339+
```
340+
314341
## Using mysqldump to prepare the backup
315342
316343
```yml

docs/reference/index.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,10 +337,19 @@ You can populate below template according to your requirements and use it as you
337337
338338
########### BACKUP ENCRYPTION
339339
340-
# Backups can be encrypted using gpg in case a passphrase is given.
340+
# Backups can be encrypted symmetrically using gpg in case a passphrase is given.
341341
342342
# GPG_PASSPHRASE="<xxx>"
343343
344+
# Backups can be encrypted asymmetrically using gpg in case publickeys are given.
345+
346+
# GPG_PUBLIC_KEY_RING= |
347+
#-----BEGIN PGP PUBLIC KEY BLOCK-----
348+
#
349+
#D/cIHu6GH/0ghlcUVSbgMg5RRI5QKNNKh04uLAPxr75mKwUg0xPUaWgyyrAChVBi
350+
#...
351+
#-----END PGP PUBLIC KEY BLOCK-----
352+
344353
########### STOPPING CONTAINERS AND SERVICES DURING BACKUP
345354
346355
# Containers or services can be stopped by applying a

test/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ RUN apk add \
44
coreutils \
55
curl \
66
gpg \
7+
gpg-agent \
78
jq \
89
moreutils \
910
tar \

test/gpg-asym/docker-compose.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
services:
2+
backup:
3+
image: offen/docker-volume-backup:${TEST_VERSION:-canary}
4+
restart: always
5+
environment:
6+
BACKUP_CRON_EXPRESSION: 0 0 5 31 2 ?
7+
BACKUP_FILENAME: test.tar.gz
8+
BACKUP_LATEST_SYMLINK: test-latest.tar.gz.gpg
9+
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7}
10+
GPG_PUBLIC_KEY_RING_FILE: /keys/public_key.asc
11+
volumes:
12+
- ${KEY_DIR:-.}/public_key.asc:/keys/public_key.asc
13+
- ${LOCAL_DIR:-./local}:/archive
14+
- app_data:/backup/app_data:ro
15+
- /var/run/docker.sock:/var/run/docker.sock
16+
17+
offen:
18+
image: offen/offen:latest
19+
labels:
20+
- docker-volume-backup.stop-during-backup=true
21+
volumes:
22+
- app_data:/var/opt/offen
23+
24+
volumes:
25+
app_data:

test/gpg-asym/run.sh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/bin/sh
2+
3+
set -e
4+
5+
cd "$(dirname "$0")"
6+
. ../util.sh
7+
current_test=$(basename $(pwd))
8+
9+
export LOCAL_DIR=$(mktemp -d)
10+
11+
export KEY_DIR=$(mktemp -d)
12+
13+
export PASSPHRASE="test"
14+
15+
gpg --batch --gen-key <<EOF
16+
Key-Type: RSA
17+
Key-Length: 4096
18+
Name-Real: offen
19+
Name-Email: docker-volume-backup@local
20+
Expire-Date: 0
21+
Passphrase: $PASSPHRASE
22+
%commit
23+
EOF
24+
25+
gpg --export --armor --batch --yes --pinentry-mode loopback --passphrase $PASSPHRASE --output $KEY_DIR/public_key.asc
26+
27+
docker compose up -d --quiet-pull
28+
sleep 5
29+
30+
docker compose exec backup backup
31+
32+
expect_running_containers "2"
33+
34+
TMP_DIR=$(mktemp -d)
35+
36+
gpg -d --pinentry-mode loopback --yes --passphrase $PASSPHRASE "$LOCAL_DIR/test.tar.gz.gpg" > "$LOCAL_DIR/decrypted.tar.gz"
37+
38+
tar -xf "$LOCAL_DIR/decrypted.tar.gz" -C $TMP_DIR
39+
40+
if [ ! -f $TMP_DIR/backup/app_data/offen.db ]; then
41+
fail "Could not find expected file in untared archive."
42+
fi
43+
rm "$LOCAL_DIR/decrypted.tar.gz"
44+
45+
pass "Found relevant files in decrypted and untared local backup."
46+
47+
if [ ! -L "$LOCAL_DIR/test-latest.tar.gz.gpg" ]; then
48+
fail "Could not find local symlink to latest encrypted backup."
49+
fi

0 commit comments

Comments
 (0)