Skip to content

Commit 4a3a934

Browse files
committed
PBM-1791: Add client-side encryption for backups
Encrypt backup data on the client before it reaches storage so plaintext never leaves the node. A new encrypt package provides a streaming AEAD format (AES-256-GCM and XChaCha20-Poly1305) with an Argon2id-derived key, wired through the backup, oplog, and restore paths. Configured via the encryption fields in the backup configuration and the --encryption flag on `pbm backup`. The passphrase is redacted from config output. Default is none. Backup metadata and the physical-backup filelist are not yet encrypted and are left for follow-up commits.
1 parent b7f17df commit 4a3a934

30 files changed

Lines changed: 1333 additions & 38 deletions

cmd/pbm-speed-test/speedt.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"go.mongodb.org/mongo-driver/v2/mongo"
1212

1313
"github.com/percona/percona-backup-mongodb/pbm/compress"
14+
"github.com/percona/percona-backup-mongodb/pbm/encrypt"
1415
"github.com/percona/percona-backup-mongodb/pbm/errors"
1516
"github.com/percona/percona-backup-mongodb/pbm/storage"
1617
)
@@ -142,7 +143,8 @@ func doTest(
142143

143144
r := &Results{}
144145
ts := time.Now()
145-
size, err := storage.Upload(context.Background(), src, stg, compression, level, fileName)
146+
size, err := storage.Upload(context.Background(), src, stg, compression, level,
147+
encrypt.EncryptionTypeNone, "", fileName)
146148
r.Size = Byte(size)
147149
if err != nil {
148150
return nil, errors.Wrap(err, "upload")

cmd/pbm/backup.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/percona/percona-backup-mongodb/pbm/connect"
1919
"github.com/percona/percona-backup-mongodb/pbm/ctrl"
2020
"github.com/percona/percona-backup-mongodb/pbm/defs"
21+
"github.com/percona/percona-backup-mongodb/pbm/encrypt"
2122
"github.com/percona/percona-backup-mongodb/pbm/errors"
2223
"github.com/percona/percona-backup-mongodb/pbm/log"
2324
"github.com/percona/percona-backup-mongodb/pbm/storage"
@@ -33,6 +34,7 @@ type backupOpts struct {
3334
base bool
3435
compression string
3536
compressionLevel []int
37+
encryption string
3638
profile ProfileFlag
3739
ns string
3840
wait bool
@@ -147,6 +149,11 @@ func runBackup(
147149
level = &b.compressionLevel[0]
148150
}
149151

152+
encryption := cfg.EncryptionType()
153+
if b.encryption != "" {
154+
encryption = encrypt.EncryptionType(b.encryption)
155+
}
156+
150157
err = sendCmd(ctx, conn, ctrl.Cmd{
151158
Cmd: ctrl.CmdBackup,
152159
Backup: &ctrl.BackupCmd{
@@ -157,6 +164,7 @@ func runBackup(
157164
UsersAndRoles: b.usersAndRoles,
158165
Compression: compression,
159166
CompressionLevel: level,
167+
Encryption: encryption,
160168
NumParallelColls: numParallelColls,
161169
NumParallelFiles: numParallelFiles,
162170
Filelist: b.externList,

cmd/pbm/main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/percona/percona-backup-mongodb/pbm/compress"
1717
"github.com/percona/percona-backup-mongodb/pbm/connect"
1818
"github.com/percona/percona-backup-mongodb/pbm/defs"
19+
"github.com/percona/percona-backup-mongodb/pbm/encrypt"
1920
"github.com/percona/percona-backup-mongodb/pbm/errors"
2021
"github.com/percona/percona-backup-mongodb/pbm/log"
2122
"github.com/percona/percona-backup-mongodb/pbm/oplog"
@@ -241,6 +242,12 @@ func (app *pbmApp) buildBackupCmd() *cobra.Command {
241242
string(compress.CompressionTypeZstandard),
242243
}
243244

245+
validEncryptions := []string{
246+
string(encrypt.EncryptionTypeNone),
247+
string(encrypt.EncryptionTypeAES256GCM),
248+
string(encrypt.EncryptionTypeXChaCha20Poly1305),
249+
}
250+
244251
validBackupTypes := []string{
245252
string(defs.PhysicalBackup),
246253
string(defs.LogicalBackup),
@@ -260,6 +267,10 @@ func (app *pbmApp) buildBackupCmd() *cobra.Command {
260267
return nil, err
261268
}
262269

270+
if err := app.validateEnum("encryption", backupOptions.encryption, validEncryptions); err != nil {
271+
return nil, err
272+
}
273+
263274
if err := app.validateEnum("type", backupOptions.typ, validBackupTypes); err != nil {
264275
return nil, err
265276
}
@@ -273,6 +284,10 @@ func (app *pbmApp) buildBackupCmd() *cobra.Command {
273284
&backupOptions.compression, "compression", "",
274285
"Compression type <none>/<gzip>/<snappy>/<lz4>/<s2>/<pgzip>/<zstd>",
275286
)
287+
backupCmd.Flags().StringVar(
288+
&backupOptions.encryption, "encryption", "",
289+
"Encryption type <none>/<aes-256-gcm>/<xchacha20-poly1305>",
290+
)
276291
backupCmd.Flags().StringVarP(
277292
&backupOptions.typ, "type", "t", string(defs.LogicalBackup),
278293
fmt.Sprintf(

e2e-tests/cmd/ensure-oplog/main.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,11 @@ func ensureReplsetOplog(ctx context.Context, uri string, from, till bson.Timesta
283283
compression = compress.CompressionType(cfg.PITR.Compression)
284284
compressionLevel = cfg.PITR.CompressionLevel
285285
}
286+
encryption := cfg.EncryptionType()
287+
passphrase, err := cfg.EncryptionPassphrase()
288+
if err != nil {
289+
return errors.Wrap(err, "resolve encryption passphrase")
290+
}
286291

287292
for _, t := range missedChunks {
288293
logger.Printf("[%s] ensure missed chunk: %s - %s",
@@ -292,7 +297,8 @@ func ensureReplsetOplog(ctx context.Context, uri string, from, till bson.Timesta
292297
o := oplog.NewOplogBackup(m)
293298
o.SetTailingSpan(t.from, t.till)
294299

295-
n, err := storage.Upload(ctx, o, stg, compression, compressionLevel, filename)
300+
n, err := storage.Upload(ctx, o, stg, compression, compressionLevel,
301+
encryption, passphrase, filename)
296302
if err != nil {
297303
return errors.Wrapf(err, "failed to upload %s - %s chunk",
298304
formatTimestamp(t.from), formatTimestamp(t.till))

e2e-tests/cmd/pbm-test/run.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/percona/percona-backup-mongodb/e2e-tests/pkg/tests/sharded"
1313
"github.com/percona/percona-backup-mongodb/pbm/defs"
14+
"github.com/percona/percona-backup-mongodb/pbm/encrypt"
1415
)
1516

1617
func run(t *sharded.Cluster, typ testTyp) {
@@ -73,6 +74,17 @@ func run(t *sharded.Cluster, typ testTyp) {
7374

7475
t.SetBallastData(1e5)
7576

77+
runTest("Encrypted Logical Backup & Restore AES-256-GCM",
78+
func() { t.EncryptedBackupAndRestore("/etc/pbm/minio-encrypt-aes.yaml", encrypt.EncryptionTypeAES256GCM) })
79+
runTest("Encrypted Logical Backup & Restore XChaCha20-Poly1305",
80+
func() { t.EncryptedBackupAndRestore("/etc/pbm/minio-encrypt-xchacha.yaml", encrypt.EncryptionTypeXChaCha20Poly1305) })
81+
runTest("Encrypted Restore With Wrong Passphrase Fails",
82+
func() { t.EncryptedRestoreWrongPassphraseFails("/etc/pbm/minio-encrypt-aes.yaml", "/etc/pbm/minio-encrypt-aes-wrong.yaml") })
83+
84+
flushStore(t)
85+
t.ApplyConfig(context.TODO(), storage)
86+
t.SetBallastData(1e5)
87+
7688
runTest("Check the Cannot Run Delete During Backup",
7789
t.CannotRunDeleteDuringBackup)
7890

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
storage:
2+
type: s3
3+
s3:
4+
endpointUrl: http://minio:9000
5+
region: us-east-1
6+
bucket: bcp
7+
prefix: pbme2etest
8+
credentials:
9+
access-key-id: "minio1234"
10+
secret-access-key: "minio1234"
11+
backup:
12+
encryption: aes-256-gcm
13+
encryptionPassphrase: "wrong passphrase that cannot decrypt"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
storage:
2+
type: s3
3+
s3:
4+
endpointUrl: http://minio:9000
5+
region: us-east-1
6+
bucket: bcp
7+
prefix: pbme2etest
8+
credentials:
9+
access-key-id: "minio1234"
10+
secret-access-key: "minio1234"
11+
backup:
12+
encryption: aes-256-gcm
13+
encryptionPassphrase: "correct horse battery staple"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
storage:
2+
type: s3
3+
s3:
4+
endpointUrl: http://minio:9000
5+
region: us-east-1
6+
bucket: bcp
7+
prefix: pbme2etest
8+
credentials:
9+
access-key-id: "minio1234"
10+
secret-access-key: "minio1234"
11+
backup:
12+
encryption: xchacha20-poly1305
13+
encryptionPassphrase: "correct horse battery staple"
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package sharded
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"io"
7+
stdlog "log"
8+
9+
"go.mongodb.org/mongo-driver/v2/bson"
10+
11+
"github.com/percona/percona-backup-mongodb/pbm/defs"
12+
"github.com/percona/percona-backup-mongodb/pbm/encrypt"
13+
)
14+
15+
// pbmEncryptedMagic is the 4-byte marker every PBM-encrypted stream begins
16+
// with. Data objects on storage must carry it when encryption is enabled.
17+
var pbmEncryptedMagic = []byte("PBME")
18+
19+
// maxObjectScanBytes caps how large an object we pull off storage to inspect.
20+
// The big ballast dump is skipped. The small per-collection dumps are enough to
21+
// prove the storage data is encrypted.
22+
const maxObjectScanBytes = 8 << 20 // 8 Mib
23+
24+
func (c *Cluster) EncryptedBackupAndRestore(confFile string, expected encrypt.EncryptionType) {
25+
c.ApplyConfig(context.TODO(), confFile)
26+
27+
c.SetBallastData(1e5)
28+
checkData := c.DataChecker()
29+
30+
bcpName := c.LogicalBackup()
31+
c.BackupWaitDone(context.TODO(), bcpName)
32+
33+
c.assertBackupEncryption(context.TODO(), bcpName, expected)
34+
c.assertStorageEncrypted(context.TODO(), bcpName)
35+
36+
c.DeleteBallast()
37+
38+
stdlog.Println("resync backup list")
39+
if err := c.mongopbm.StoreResync(context.TODO()); err != nil {
40+
stdlog.Fatalln("Error: resync backup lists:", err)
41+
}
42+
43+
c.LogicalRestore(context.TODO(), bcpName)
44+
checkData()
45+
}
46+
47+
func (c *Cluster) EncryptedRestoreWrongPassphraseFails(correctConf, wrongConf string) {
48+
c.ApplyConfig(context.TODO(), correctConf)
49+
50+
c.SetBallastData(1e5)
51+
checkData := c.DataChecker()
52+
53+
bcpName := c.LogicalBackup()
54+
c.BackupWaitDone(context.TODO(), bcpName)
55+
56+
c.DeleteBallast()
57+
58+
c.ApplyConfig(context.TODO(), wrongConf)
59+
60+
stdlog.Println("restoring with the wrong passphrase (expecting failure)")
61+
if _, err := c.pbm.Restore(bcpName, []string{}); err != nil {
62+
stdlog.Fatalln("starting restore with wrong passphrase:", err)
63+
}
64+
65+
if err := c.pbm.CheckRestore(bcpName, defs.WaitBackupStart*6); err == nil {
66+
stdlog.Fatalln("Error: restore with wrong passphrase succeeded, expected decryption failure")
67+
} else {
68+
stdlog.Printf("restore failed as expected with wrong passphrase: %v", err)
69+
}
70+
71+
c.clearRestoreHistory(context.TODO())
72+
73+
stdlog.Println("healing cluster with the correct passphrase")
74+
c.ApplyConfig(context.TODO(), correctConf)
75+
c.LogicalRestore(context.TODO(), bcpName)
76+
checkData()
77+
}
78+
79+
func (c *Cluster) clearRestoreHistory(ctx context.Context) {
80+
_, err := c.mongopbm.Conn().MongoClient().
81+
Database(defs.DB).
82+
Collection(defs.RestoresCollection).
83+
DeleteMany(ctx, bson.M{})
84+
if err != nil {
85+
stdlog.Fatalln("clear restore history:", err)
86+
}
87+
}
88+
89+
func (c *Cluster) assertBackupEncryption(ctx context.Context, bcpName string, expected encrypt.EncryptionType) {
90+
meta, err := c.mongopbm.GetBackupMeta(ctx, bcpName)
91+
if err != nil {
92+
stdlog.Fatalln("get backup meta:", err)
93+
}
94+
if meta.Encryption != expected {
95+
stdlog.Fatalf("backup %s encryption mismatch: got %q, want %q", bcpName, meta.Encryption, expected)
96+
}
97+
stdlog.Printf("backup %s reports encryption %q", bcpName, meta.Encryption)
98+
}
99+
100+
func (c *Cluster) assertStorageEncrypted(ctx context.Context, bcpName string) {
101+
stg, err := c.mongopbm.Storage(ctx)
102+
if err != nil {
103+
stdlog.Fatalln("get storage:", err)
104+
}
105+
106+
files, err := stg.List(bcpName, "")
107+
if err != nil {
108+
stdlog.Fatalln("list backup files:", err)
109+
}
110+
if len(files) == 0 {
111+
stdlog.Fatalf("no files found on storage for backup %s", bcpName)
112+
}
113+
114+
encrypted, checked, skipped := 0, 0, 0
115+
for _, f := range files {
116+
if f.Size <= 0 || f.Size > maxObjectScanBytes {
117+
skipped++
118+
continue
119+
}
120+
name := bcpName + "/" + f.Name
121+
r, err := stg.SourceReader(name)
122+
if err != nil {
123+
stdlog.Fatalf("read %s: %v", name, err)
124+
}
125+
// Partial reads can stall
126+
data, err := io.ReadAll(r)
127+
r.Close()
128+
if err != nil {
129+
stdlog.Fatalf("read %s: %v", name, err)
130+
}
131+
checked++
132+
if bytes.HasPrefix(data, pbmEncryptedMagic) {
133+
encrypted++
134+
}
135+
}
136+
137+
if encrypted == 0 {
138+
stdlog.Fatalf("no encrypted data objects found for backup %s (checked %d, skipped %d large or empty of %d)",
139+
bcpName, checked, skipped, len(files))
140+
}
141+
stdlog.Printf("%d/%d checked objects of backup %s carry the PBM encryption header (%d large or empty skipped)",
142+
encrypted, checked, bcpName, skipped)
143+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ require (
3636
github.com/testcontainers/testcontainers-go/modules/minio v0.42.0
3737
github.com/testcontainers/testcontainers-go/modules/mongodb v0.42.0
3838
go.mongodb.org/mongo-driver/v2 v2.6.0
39+
golang.org/x/crypto v0.49.0
3940
golang.org/x/mod v0.33.0
4041
golang.org/x/oauth2 v0.36.0
4142
golang.org/x/sync v0.20.0
@@ -157,7 +158,6 @@ require (
157158
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
158159
go.opentelemetry.io/otel/trace v1.43.0 // indirect
159160
go.yaml.in/yaml/v3 v3.0.4 // indirect
160-
golang.org/x/crypto v0.49.0 // indirect
161161
golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect
162162
golang.org/x/net v0.52.0 // indirect
163163
golang.org/x/sys v0.42.0 // indirect

0 commit comments

Comments
 (0)