Skip to content

Commit 896c1d4

Browse files
authored
Merge pull request #391 from databacker/encrypted-config-support
support for encrypted config
2 parents 0de526d + 4467dd0 commit 896c1d4

File tree

4 files changed

+306
-2
lines changed

4 files changed

+306
-2
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ require (
3131
)
3232

3333
require (
34-
github.com/databacker/api/go/api v0.0.0-20241128084006-ed33dc044eaa
34+
github.com/databacker/api/go/api v0.0.0-20241202154620-01b0380f21cb
3535
github.com/google/go-cmp v0.6.0
3636
go.opentelemetry.io/otel v1.31.0
3737
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,12 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfc
7272
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
7373
github.com/databacker/api/go/api v0.0.0-20241128084006-ed33dc044eaa h1:cDI48+AG1mPMdvgGWz/SLpNKhzDiGZwfSSww9VvvWuI=
7474
github.com/databacker/api/go/api v0.0.0-20241128084006-ed33dc044eaa/go.mod h1:bQhbl71Lk1ATni0H+u249hjoQ8ShAdVNcNjnw6z+SbE=
75+
github.com/databacker/api/go/api v0.0.0-20241201124314-f86f0bf46c54 h1:NirzpOdczBCCwBlmfdYLcroaFYIdR2bJkeDjwJKaxQE=
76+
github.com/databacker/api/go/api v0.0.0-20241201124314-f86f0bf46c54/go.mod h1:bQhbl71Lk1ATni0H+u249hjoQ8ShAdVNcNjnw6z+SbE=
77+
github.com/databacker/api/go/api v0.0.0-20241201140600-cb4443d89ac3 h1:RhB+NKRnj6T+2mbmvy1me2zglATss01byA9Nar+0pbk=
78+
github.com/databacker/api/go/api v0.0.0-20241201140600-cb4443d89ac3/go.mod h1:bQhbl71Lk1ATni0H+u249hjoQ8ShAdVNcNjnw6z+SbE=
79+
github.com/databacker/api/go/api v0.0.0-20241202154620-01b0380f21cb h1:9PthuA+o1wBZuTkNc2LLXQfI5+Myy+ok8nD3bQzd7DA=
80+
github.com/databacker/api/go/api v0.0.0-20241202154620-01b0380f21cb/go.mod h1:bQhbl71Lk1ATni0H+u249hjoQ8ShAdVNcNjnw6z+SbE=
7581
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7682
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
7783
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

pkg/config/process.go

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
package config
22

33
import (
4+
"crypto/aes"
5+
"crypto/cipher"
6+
"crypto/ecdh"
7+
"crypto/ed25519"
8+
"crypto/sha256"
9+
"encoding/base64"
410
"errors"
511
"fmt"
612
"io"
713

814
"github.com/databacker/api/go/api"
15+
"golang.org/x/crypto/chacha20poly1305"
16+
"golang.org/x/crypto/hkdf"
17+
"golang.org/x/crypto/nacl/box"
918
"gopkg.in/yaml.v3"
1019

1120
"github.com/databacker/mysql-backup/pkg/remote"
@@ -15,7 +24,10 @@ import (
1524
// If the configuration is of type remote, it will retrieve the remote configuration.
1625
// Continues to process remotes until it gets a final valid ConfigSpec or fails.
1726
func ProcessConfig(r io.Reader) (actualConfig *api.ConfigSpec, err error) {
18-
var conf api.Config
27+
var (
28+
conf api.Config
29+
credentials []string
30+
)
1931
decoder := yaml.NewDecoder(r)
2032
if err := decoder.Decode(&conf); err != nil {
2133
return nil, fmt.Errorf("fatal error reading config file: %w", err)
@@ -60,6 +72,20 @@ func ProcessConfig(r io.Reader) (actualConfig *api.ConfigSpec, err error) {
6072
return nil, fmt.Errorf("error parsing remote config: %w", err)
6173
}
6274
conf = remoteConfig
75+
// save encryption key for later
76+
if spec.Credentials != nil {
77+
credentials = append(credentials, *spec.Credentials)
78+
}
79+
case api.Encrypted:
80+
var spec api.EncryptedSpec
81+
if err := yaml.Unmarshal(specBytes, &spec); err != nil {
82+
return nil, fmt.Errorf("parsed yaml had kind encrypted, but spec invalid")
83+
}
84+
// now try to decrypt it
85+
conf, err = decryptConfig(spec, credentials)
86+
if err != nil {
87+
return nil, fmt.Errorf("error decrypting config: %w", err)
88+
}
6389
default:
6490
return nil, fmt.Errorf("unknown config type: %s", conf.Kind)
6591
}
@@ -91,3 +117,121 @@ func getRemoteConfig(spec api.RemoteSpec) (conf api.Config, err error) {
91117

92118
return baseConf, nil
93119
}
120+
121+
// decryptConfig decrypt an EncryptedSpec given an EncryptedSpec and a list of credentials.
122+
// Returns the decrypted Config struct.
123+
func decryptConfig(spec api.EncryptedSpec, credentials []string) (api.Config, error) {
124+
var plainConfig api.Config
125+
if spec.Algorithm == nil {
126+
return plainConfig, errors.New("empty algorithm")
127+
}
128+
if spec.RecipientPublicKey == nil {
129+
return plainConfig, errors.New("empty recipient public key")
130+
}
131+
if spec.SenderPublicKey == nil {
132+
return plainConfig, errors.New("empty sender public key")
133+
}
134+
if spec.Data == nil {
135+
return plainConfig, errors.New("empty data")
136+
}
137+
// make sure we have the key matching the public key
138+
var (
139+
privateKey *ecdh.PrivateKey
140+
curve = ecdh.X25519()
141+
)
142+
143+
for _, cred := range credentials {
144+
// get our curve25519 private key
145+
keyBytes, err := base64.StdEncoding.DecodeString(cred)
146+
if err != nil {
147+
return plainConfig, fmt.Errorf("error decoding credentials: %w", err)
148+
}
149+
if len(keyBytes) != ed25519.SeedSize {
150+
return plainConfig, fmt.Errorf("invalid key size %d, must be %d", len(keyBytes), ed25519.SeedSize)
151+
}
152+
candidatePrivateKey, err := curve.NewPrivateKey(keyBytes)
153+
if err != nil {
154+
return plainConfig, fmt.Errorf("error creating private key: %w", err)
155+
}
156+
// get the public key from the private key
157+
candidatePublicKey := candidatePrivateKey.PublicKey()
158+
// check if the public key matches the one we have, if so, break
159+
pubKeyBase64 := base64.StdEncoding.EncodeToString(candidatePublicKey.Bytes())
160+
if pubKeyBase64 == *spec.RecipientPublicKey {
161+
privateKey = candidatePrivateKey
162+
break
163+
}
164+
}
165+
// if we didn't find a matching key, return an error
166+
if privateKey == nil {
167+
return plainConfig, fmt.Errorf("no private key found that matches public key %s", *spec.RecipientPublicKey)
168+
}
169+
senderPublicKeyBytes, err := base64.StdEncoding.DecodeString(*spec.SenderPublicKey)
170+
if err != nil {
171+
return plainConfig, fmt.Errorf("failed to decode sender public key: %w", err)
172+
}
173+
174+
// Derive the shared secret using the sender's public key and receiver's private key
175+
var senderPublicKey, receiverPrivateKey, sharedSecret [32]byte
176+
copy(senderPublicKey[:], senderPublicKeyBytes)
177+
copy(receiverPrivateKey[:], privateKey.Bytes()) // Use the seed to get the private scalar
178+
box.Precompute(&sharedSecret, &senderPublicKey, &receiverPrivateKey)
179+
180+
// Derive a symmetric key using HKDF with the shared secret
181+
hkdfReader := hkdf.New(sha256.New, sharedSecret[:], nil, []byte(api.SymmetricKey))
182+
var symmetricKeySize int
183+
switch *spec.Algorithm {
184+
case api.AesGcm256:
185+
symmetricKeySize = 32
186+
case api.Chacha20Poly1305:
187+
symmetricKeySize = 32
188+
default:
189+
return plainConfig, fmt.Errorf("unsupported algorithm: %s", *spec.Algorithm)
190+
}
191+
symmetricKey := make([]byte, symmetricKeySize)
192+
if _, err := hkdfReader.Read(symmetricKey); err != nil {
193+
return plainConfig, fmt.Errorf("failed to derive symmetric key: %w", err)
194+
}
195+
196+
var (
197+
plaintext []byte
198+
aead cipher.AEAD
199+
)
200+
encryptedData, err := base64.StdEncoding.DecodeString(*spec.Data)
201+
if err != nil {
202+
return plainConfig, fmt.Errorf("failed to decode encrypted data: %w", err)
203+
}
204+
switch *spec.Algorithm {
205+
case api.AesGcm256:
206+
// Decrypt with AES-GCM
207+
block, err := aes.NewCipher(symmetricKey)
208+
if err != nil {
209+
return plainConfig, fmt.Errorf("failed to initialize AES cipher: %w", err)
210+
}
211+
aead, err = cipher.NewGCM(block)
212+
if err != nil {
213+
return plainConfig, fmt.Errorf("failed to initialize AES-GCM: %w", err)
214+
}
215+
case api.Chacha20Poly1305:
216+
// Decrypt with ChaCha20Poly1305
217+
aead, err = chacha20poly1305.New(symmetricKey)
218+
if err != nil {
219+
return plainConfig, fmt.Errorf("failed to initialize ChaCha20Poly1305: %w", err)
220+
}
221+
default:
222+
return plainConfig, fmt.Errorf("unsupported algorithm: %s", *spec.Algorithm)
223+
}
224+
if len(encryptedData) < aead.NonceSize() {
225+
return plainConfig, errors.New("invalid encrypted data length")
226+
}
227+
dataNonce := encryptedData[:aead.NonceSize()]
228+
ciphertext := encryptedData[aead.NonceSize():]
229+
plaintext, err = aead.Open(nil, dataNonce, ciphertext, nil)
230+
if err != nil {
231+
return plainConfig, fmt.Errorf("failed to decrypt data: %w", err)
232+
}
233+
if err := yaml.Unmarshal(plaintext, &plainConfig); err != nil {
234+
return plainConfig, fmt.Errorf("parsed yaml had kind remote, but spec invalid")
235+
}
236+
return plainConfig, nil
237+
}

pkg/config/process_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@ package config
22

33
import (
44
"bytes"
5+
"crypto/aes"
6+
"crypto/cipher"
7+
"crypto/ecdh"
8+
"crypto/ed25519"
9+
cryptorand "crypto/rand"
10+
"crypto/sha256"
511
"encoding/base64"
12+
"errors"
13+
"io"
614
"net/http"
715
"os"
816
"strings"
917
"testing"
1018

1119
utiltest "github.com/databacker/mysql-backup/pkg/internal/test"
20+
"golang.org/x/crypto/hkdf"
21+
"golang.org/x/crypto/nacl/box"
1222
"gopkg.in/yaml.v3"
1323

1424
"github.com/databacker/api/go/api"
@@ -88,3 +98,147 @@ func TestGetRemoteConfig(t *testing.T) {
8898
}
8999

90100
}
101+
102+
func TestDecryptConfig(t *testing.T) {
103+
configFile := "./testdata/config.yml"
104+
content, err := os.ReadFile(configFile)
105+
if err != nil {
106+
t.Fatalf("failed to read config file: %v", err)
107+
}
108+
var validConfig api.Config
109+
if err := yaml.Unmarshal(content, &validConfig); err != nil {
110+
t.Fatalf("failed to unmarshal config: %v", err)
111+
}
112+
113+
senderCurve := ecdh.X25519()
114+
senderPrivateKey, err := senderCurve.GenerateKey(cryptorand.Reader)
115+
if err != nil {
116+
t.Fatalf("failed to generate sender random seed: %v", err)
117+
}
118+
senderPublicKey := senderPrivateKey.PublicKey()
119+
senderPublicKeyBytes := senderPublicKey.Bytes()
120+
121+
recipientCurve := ecdh.X25519()
122+
recipientPrivateKey, err := recipientCurve.GenerateKey(cryptorand.Reader)
123+
if err != nil {
124+
t.Fatalf("failed to generate recipient random seed: %v", err)
125+
}
126+
recipientPublicKey := recipientPrivateKey.PublicKey()
127+
recipientPublicKeyBytes := recipientPublicKey.Bytes()
128+
129+
var recipientPublicKeyArray, senderPrivateKeyArray [32]byte
130+
copy(recipientPublicKeyArray[:], recipientPublicKeyBytes)
131+
copy(senderPrivateKeyArray[:], senderPrivateKey.Bytes())
132+
133+
senderPublicKeyB64 := base64.StdEncoding.EncodeToString(senderPublicKeyBytes)
134+
135+
recipientPublicKeyB64 := base64.StdEncoding.EncodeToString(recipientPublicKeyBytes)
136+
137+
// compute the shared secret using the sender's private key and the recipient's public key
138+
var sharedSecret [32]byte
139+
box.Precompute(&sharedSecret, &recipientPublicKeyArray, &senderPrivateKeyArray)
140+
141+
// Derive the symmetric key using HKDF with the shared secret
142+
hkdfReader := hkdf.New(sha256.New, sharedSecret[:], nil, []byte(api.SymmetricKey))
143+
symmetricKey := make([]byte, 32) // AES-GCM requires 32 bytes
144+
if _, err := hkdfReader.Read(symmetricKey); err != nil {
145+
t.Fatalf("failed to derive symmetric key: %v", err)
146+
}
147+
148+
// Create AES cipher block
149+
block, err := aes.NewCipher(symmetricKey)
150+
if err != nil {
151+
t.Fatalf("failed to create AES cipher")
152+
}
153+
// Create GCM instance
154+
aesGCM, err := cipher.NewGCM(block)
155+
if err != nil {
156+
t.Fatalf("failed to create AES-GCM")
157+
}
158+
159+
// Generate a random nonce
160+
nonce := make([]byte, aesGCM.NonceSize())
161+
_, err = cryptorand.Read(nonce)
162+
if err != nil {
163+
t.Fatalf("failed to generate nonce")
164+
}
165+
166+
// Encrypt the plaintext
167+
ciphertext := aesGCM.Seal(nil, nonce, content, nil)
168+
169+
// Embed the nonce in the ciphertext
170+
fullCiphertext := append(nonce, ciphertext...)
171+
172+
algo := api.AesGcm256
173+
data := base64.StdEncoding.EncodeToString(fullCiphertext)
174+
175+
// this is a valid spec, we want to be able to change fields
176+
// without modifying the original, so we have a utility function after
177+
validSpec := api.EncryptedSpec{
178+
Algorithm: &algo,
179+
Data: &data,
180+
RecipientPublicKey: &recipientPublicKeyB64,
181+
SenderPublicKey: &senderPublicKeyB64,
182+
}
183+
184+
// copy a spec, changing specific fields
185+
copyModifySpec := func(opts ...func(*api.EncryptedSpec)) api.EncryptedSpec {
186+
copy := validSpec
187+
for _, opt := range opts {
188+
opt(&copy)
189+
}
190+
return copy
191+
}
192+
193+
unusedSeed := make([]byte, ed25519.SeedSize)
194+
if _, err := io.ReadFull(cryptorand.Reader, unusedSeed); err != nil {
195+
t.Fatalf("failed to generate sender random seed: %v", err)
196+
}
197+
198+
// recipient private key credentials
199+
recipientCreds := []string{base64.StdEncoding.EncodeToString(recipientPrivateKey.Bytes())}
200+
unusedCreds := []string{base64.StdEncoding.EncodeToString(unusedSeed)}
201+
202+
tests := []struct {
203+
name string
204+
inSpec api.EncryptedSpec
205+
credentials []string
206+
config api.Config
207+
err error
208+
}{
209+
{"no algorithm", copyModifySpec(func(s *api.EncryptedSpec) { s.Algorithm = nil }), recipientCreds, api.Config{}, errors.New("empty algorithm")},
210+
{"no data", copyModifySpec(func(s *api.EncryptedSpec) { s.Data = nil }), recipientCreds, api.Config{}, errors.New("empty data")},
211+
{"bad base64 data", copyModifySpec(func(s *api.EncryptedSpec) { data := "abcdef"; s.Data = &data }), recipientCreds, api.Config{}, errors.New("failed to decode encrypted data: illegal base64 data")},
212+
{"short encrypted data", copyModifySpec(func(s *api.EncryptedSpec) {
213+
data := base64.StdEncoding.EncodeToString([]byte("abcdef"))
214+
s.Data = &data
215+
}), recipientCreds, api.Config{}, errors.New("invalid encrypted data length")},
216+
{"invalid encrypted data", copyModifySpec(func(s *api.EncryptedSpec) {
217+
bad := nonce
218+
bad = append(bad, 1, 2, 3, 4)
219+
data := base64.StdEncoding.EncodeToString(bad)
220+
s.Data = &data
221+
}), recipientCreds, api.Config{}, errors.New("failed to decrypt data: cipher: message authentication failed")},
222+
{"empty credentials", validSpec, nil, api.Config{}, errors.New("no private key found that matches public key")},
223+
{"unmatched credentials", validSpec, unusedCreds, api.Config{}, errors.New("no private key found that matches public key")},
224+
{"success with just one credential", validSpec, recipientCreds, validConfig, nil},
225+
{"success with multiple credentials", validSpec, append(recipientCreds, unusedCreds...), validConfig, nil},
226+
}
227+
for _, tt := range tests {
228+
t.Run(tt.name, func(t *testing.T) {
229+
conf, err := decryptConfig(tt.inSpec, tt.credentials)
230+
switch {
231+
case err == nil && tt.err != nil:
232+
t.Fatalf("expected error: %v", tt.err)
233+
case err != nil && tt.err == nil:
234+
t.Fatalf("unexpected error: %v", err)
235+
case err != nil && tt.err != nil && !strings.HasPrefix(err.Error(), tt.err.Error()):
236+
t.Fatalf("mismatched error: %v", err)
237+
}
238+
diff := cmp.Diff(tt.config, conf)
239+
if diff != "" {
240+
t.Fatalf("mismatched config: %s", diff)
241+
}
242+
})
243+
}
244+
}

0 commit comments

Comments
 (0)